""" 成交三态落地:改主账六态、推负责人、收下未成交原因、排协商再问。 本文件职责:私聊/群聊共用。不在回调线程等 LLM。推送只入 outbox。 禁止:把图成功当主账成功;成交失败还去推负责人;未成交推负责人。 """ from __future__ import annotations import logging import re 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]] # 成交跟进里先认未成交,避免被「成交」误伤。 _LOST_MARKERS = ("未成交", "没成交", "没有成交", "没做成", "黄了") _QUESTION_TAILS = frozenset({"吗", "呢", "吧", "啊", "呀", "嘛", "?", "?"}) _KEEP_REASON_AS_IS = frozenset({"原因不明", "原因不详", "理由不明", "理由不详"}) # 群里 @询价小助手 是用来叫醒机器人的,不是未成交原因。 _MENTION = re.compile(r"@[^\s,,。.;;::、@]+") def strip_bot_mentions(text: str) -> str: """去掉 @某人。原因前后都可能带,去掉后不留空标点。""" cleaned = _MENTION.sub("", text or "") return cleaned.strip(",,。.;;::、 ") def harvest_inline_lost_reason(text: str) -> str: """ 从「未成交,原因客户不想要了」这类一句里抽出原因。 只写未成交、问句尾巴(吗/呢)不当原因。原因不明整句保留。 调用:成交跟进私聊/群聊;不在回调线程跑。 """ raw = (text or "").strip() if not raw: return "" compact = raw.replace(" ", "") marker = "" at = -1 for token in _LOST_MARKERS: hit = compact.find(token) if hit < 0: continue if at < 0 or hit < at: at = hit marker = token if at < 0 or not marker: return "" rest = compact[at + len(marker) :].strip() rest = rest.lstrip(",,。.;;::、-/—") if rest in _KEEP_REASON_AS_IS: return rest for label in ("未成交原因是", "未成交原因", "原因是", "理由是", "原因", "理由"): if rest.startswith(label): rest = rest[len(label) :].lstrip(",,。.;;::、-/— ") break if rest in _QUESTION_TAILS: return "" rest = strip_bot_mentions(rest) if rest in _QUESTION_TAILS: return "" return rest def looks_like_lost_outcome(text: str) -> bool: """成交跟进阶段:这句话是在说未成交。""" blob = (text or "").replace(" ", "") if not blob: return False return any(token in blob for token in _LOST_MARKERS) def spoken_deal_status(text: str) -> str: """ 私聊口播成交结果。先认未成交,再认已成交、协商中。 「WO202609240006已成交」整句就是要关单,不是再打开成交卡。 问句(已成交吗)不算。对不上返回空串。 """ blob = (text or "").replace(" ", "") if not blob or blob[-1] in _QUESTION_TAILS: return "" if looks_like_lost_outcome(blob): return "未成交" if "已成交" in blob: return "已成交" if "协商中" in blob: return "协商中" return "" 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, lost_reason: str = "", ) -> dict[str, Any]: """ 先入主账,再按结果推人或排再问。 返回 ok / phase / notify_summary。phase: 已成交 done;未成交无原因 wait_lost_reason;未成交已带原因 done;协商中 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"} from agent.policy.event_exception import WAIT_LOST_REASON, clear_wait, start_wait inline_reason = (lost_reason or "").strip() if status == "未成交" and inline_reason: captured = capture_lost_reason(ledger=ledger, work_order_no=no, reason=inline_reason) if captured.get("ok"): phase = "done" else: phase = "wait_lost_reason" start_wait(ledger, work_order_no=no, wait_kind=WAIT_LOST_REASON) elif status == "未成交": phase = "wait_lost_reason" start_wait(ledger, work_order_no=no, wait_kind=WAIT_LOST_REASON) elif status == "协商中": phase = "wait_deal" clear_wait(ledger, work_order_no=no) else: phase = "done" clear_wait(ledger, work_order_no=no) 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 = strip_bot_mentions(reason or "") 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"} out = patcher(work_order_no=no, lost_reason=text) or {"ok": False} if isinstance(out, dict): out.setdefault("lost_reason", text) if out.get("ok"): from agent.policy.event_exception import clear_wait clear_wait(ledger, work_order_no=no) return out