补齐成交跟进:按线路推成交通知、收下未成交原因、协商中按工作日再问。

成交走询价消息服务;陆运/海运私聊下一句能记下原因;协商中回执写明顺延三个工作日和下次 9:00。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-19 16:08:48 +08:00
co-authored by Cursor
parent 9bb69ae72e
commit 1a2e1d6cb2
31 changed files with 1638 additions and 30 deletions
+160
View File
@@ -0,0 +1,160 @@
"""
成交三态落地:改主账六态、推负责人、收下未成交原因、排协商再问。
本文件职责:私聊/群聊共用。不在回调线程等 LLM。推送只入 outbox。
禁止:把图成功当主账成功;成交失败还去推负责人;未成交推负责人。
"""
from __future__ import annotations
import logging
from typing import Any, Callable, Optional
from agent.config import get_settings
from agent.policy import inquiry_copy as copy
from agent.policy.deal_followup import next_followup_at, pick_leaders
logger = logging.getLogger(__name__)
EnqueueFn = Callable[..., tuple[bool, str]]
def notify_app_ready() -> bool:
"""询价消息服务已配 AgentId+Secret 才推。空则只成交不推。"""
settings = get_settings()
return bool(
str(getattr(settings, "wecom_notify_agent_id", "") or "").strip()
and str(getattr(settings, "wecom_notify_secret", "") or "").strip()
)
def _role_of(business_line: str) -> str:
line = (business_line or "").strip().upper()
if line == "SEA":
return "sea"
if line == "LAND":
return "land"
return "air"
def _enqueue_default(
*,
touser: str,
content: str,
dedupe_key: str,
payload: dict[str, Any],
) -> tuple[bool, str]:
from agent.channel.queue import get_message_store
return get_message_store().enqueue_outbound(
touser=touser,
content=content,
dedupe_key=dedupe_key,
payload=payload,
)
def finish_deal_outcome(
*,
ledger: Any,
work_order_no: str,
outcome: str,
facts: dict[str, str] | None,
quote: dict[str, Any] | None,
business_line: str,
sales_name: str = "",
enqueue: Optional[EnqueueFn] = None,
notify_ready: Optional[bool] = None,
) -> dict[str, Any]:
"""
先入主账,再按结果推人或排再问。
返回 ok / phase / notify_summary。phase:
已成交 done;未成交 wait_lost_reason;协商中 wait_deal。
"""
no = (work_order_no or "").strip()
status = (outcome or "").strip()
extra: dict[str, Any] = {}
summary = ""
if status == "协商中":
extra["next_followup_at"] = next_followup_at().strftime("%Y-%m-%d %H:%M:%S")
if status == "已成交":
staff: list[dict[str, Any]] = []
lister = getattr(ledger, "list_staff_by_role", None)
if callable(lister):
try:
staff = list(lister(role_code=_role_of(business_line)) or [])
except Exception:
logger.exception("成交匹配负责人失败 wo=%s", no)
leaders = pick_leaders(staff, business_line=business_line, facts=facts)
ready = notify_app_ready() if notify_ready is None else bool(notify_ready)
if not leaders:
summary = "无匹配负责人"
elif not ready:
summary = "通知未发出"
else:
names = [str(x.get("name") or x.get("wecomId") or "").strip() for x in leaders]
summary = "已推:" + "、".join(n for n in names if n)
sender = enqueue or _enqueue_default
payload = copy.deal_notify_wecom_payload(
work_order_no=no,
sales_name=sales_name,
facts=facts or {},
quote=quote or {},
business_line=business_line,
)
text = copy.deal_notify_card(
work_order_no=no,
sales_name=sales_name,
facts=facts or {},
quote=quote or {},
business_line=business_line,
)
for row in leaders:
uid = str(row.get("wecomId") or "").strip()
if not uid:
continue
try:
ok, info = sender(
touser=uid,
content=text,
dedupe_key=f"deal-notify:{no}:{uid}",
payload=dict(payload),
)
if not ok:
logger.warning("成交通知入队失败 wo=%s uid=%s info=%s", no, uid, info)
except Exception:
logger.exception("成交通知入队异常 wo=%s uid=%s", no, uid)
extra["deal_notify_summary"] = summary
extra["remark"] = summary
writer = getattr(ledger, "transition", None)
if not callable(writer):
return {"ok": False, "error": "no_transition", "phase": "ledger_fail"}
result = writer(work_order_no=no, to_status=status, **extra) or {}
if not result.get("ok"):
return {"ok": False, "error": result.get("error") or "transition_failed", "phase": "ledger_fail"}
if status == "未成交":
phase = "wait_lost_reason"
elif status == "协商中":
phase = "wait_deal"
else:
phase = "done"
return {
"ok": True,
"phase": phase,
"notify_summary": summary,
"status": status,
"next_followup_at": str(extra.get("next_followup_at") or ""),
}
def capture_lost_reason(*, ledger: Any, work_order_no: str, reason: str) -> dict[str, Any]:
"""把销售下一句写进未成交原因。"""
text = (reason or "").strip()
no = (work_order_no or "").strip()
if not no or not text:
return {"ok": False, "error": "blank"}
patcher = getattr(ledger, "patch_lost_reason", None)
if not callable(patcher):
return {"ok": False, "error": "no_patch"}
return patcher(work_order_no=no, lost_reason=text) or {"ok": False}