""" 成交三态落地:改主账六态、推负责人、收下未成交原因、排协商再问。 本文件职责:私聊/群聊共用。不在回调线程等 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 = _transition_deal_status( writer, work_order_no=no, to_status=status, extra=extra, ledger=ledger ) 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 _transition_deal_status( writer: Callable[..., dict[str, Any]], *, work_order_no: str, to_status: str, extra: dict[str, Any], ledger: Any = None, ) -> dict[str, Any]: """ 写入成交三态。若主账仍停在询价中,先迁已报价再迁目标态。 运行证据: - WO022/WO023:文件已发仍停询价中,直迁未成交被六态拒绝; - HTTP 500 时 http_ledger 常只回 http_500(正文里的「询价中」丢失), 不能只靠错误文案判断,要以工单当前状态为准。 """ no = (work_order_no or "").strip() status = (to_status or "").strip() result = writer(work_order_no=no, to_status=status, **extra) or {} if result.get("ok"): return result if status not in {"已成交", "未成交", "协商中"}: return result err = str(result.get("error") or result.get("message") or "") cur = "" getter = getattr(ledger, "get_for_agent", None) if ledger is not None else None if callable(getter): try: snap = getter(work_order_no=no, sender_id="") or {} cur = str(snap.get("status") or "").strip() except Exception: cur = "" # 状态确认询价中,或错误文案带询价中,或仅有 http_5xx 时仍尝试补已报价。 should_promote = ( cur == "询价中" or "询价中" in err or err.startswith("http_5") or err in {"transition_failed", "ledger_error"} ) if not should_promote: return result promoted = writer( work_order_no=no, to_status="已报价", remark="成交跟进前补已报价", ) or {} if not promoted.get("ok"): return result return writer(work_order_no=no, to_status=status, **extra) or {} def ensure_ticket_quoted(ledger: Any, *, work_order_no: str) -> dict[str, Any]: """ 报价文件发出 / 进入成交跟进时,保证主账至少是「已报价」。 幂等:已离开「询价中」则不再迁态(已报价→已报价会被六态拒绝)。 """ no = (work_order_no or "").strip() writer = getattr(ledger, "transition", None) if not no or not callable(writer): return {"ok": False, "error": "no_transition"} getter = getattr(ledger, "get_for_agent", None) if callable(getter): try: snap = getter(work_order_no=no, sender_id="") or {} except Exception: snap = {} st = str(snap.get("status") or "").strip() if st and st != "询价中": return {"ok": True, "status": st, "skipped": True} return writer(work_order_no=no, to_status="已报价", remark="报价文件已出") 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