338 lines
12 KiB
Python
338 lines
12 KiB
Python
"""
|
||
成交三态落地:改主账六态、推负责人、收下未成交原因、排协商再问。
|
||
|
||
本文件职责:私聊/群聊共用。不在回调线程等 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
|