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

成交走询价消息服务;陆运/海运私聊下一句能记下原因;协商中回执写明顺延三个工作日和下次 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
+21 -2
View File
@@ -43,6 +43,15 @@ class ChannelRuntime:
secret=settings.wecom_secret,
agent_id=agent_id,
)
notify_id = int(getattr(settings, "wecom_notify_agent_id", "") or "0") or 0
notify_secret = str(getattr(settings, "wecom_notify_secret", "") or "").strip()
self._notify_client = None
if notify_id and notify_secret:
self._notify_client = WeComAppClient(
corp_id=settings.wecom_corp_id,
secret=notify_secret,
agent_id=notify_id,
)
@property
def store(self):
@@ -105,10 +114,20 @@ class ChannelRuntime:
logger.warning("outbox 过期丢弃 id=%s", item.id)
self._store.complete_outbound(item.id)
continue
result = self._client.send_outbound(
payload = getattr(item, "payload", None) or {}
client = self._client
if payload.get("wecom_app") == "notify":
if self._notify_client is None:
logger.error("询价消息服务未配置,成交通知丢弃 id=%s", item.id)
self._store.complete_outbound(
item.id, error="notify_app_missing", permanent=True
)
continue
client = self._notify_client
result = client.send_outbound(
touser=item.touser,
content=item.content,
payload=getattr(item, "payload", None) or {},
payload=payload,
)
if result.ok:
self._store.complete_outbound(item.id)
+3
View File
@@ -60,6 +60,9 @@ class Settings(BaseSettings):
wecom_corp_id: str = Field(default="", alias="WECOM_CORP_ID")
wecom_agent_id: str = Field(default="1000010", alias="WECOM_AGENT_ID")
wecom_secret: str = Field(default="", alias="WECOM_SECRET")
# 现网「询价消息服务」:只推成交通知,不走询价小助手 1000010。
wecom_notify_agent_id: str = Field(default="", alias="WECOM_NOTIFY_AGENT_ID")
wecom_notify_secret: str = Field(default="", alias="WECOM_NOTIFY_SECRET")
wecom_archive_seat_user_ids: str = Field(
default="",
alias="WECOM_ARCHIVE_SEAT_USER_IDS",
@@ -27,7 +27,12 @@ from agent.policy.air_group_ops import (
resolve_cabin_ticket,
)
from agent.policy.air_text_flow import get_air_text_flow
from agent.policy.sea_group_ops import group_client_of, send_group_text, ticket_view
from agent.policy.sea_group_ops import (
capture_group_lost_reason,
group_client_of,
send_group_text,
ticket_view,
)
from agent.routing.air_group_intent import (
INTENT_ACTIVATE,
INTENT_CONFIRM_TMS,
@@ -233,6 +238,14 @@ def _handle_bound(
injected_quote: Optional[dict[str, Any]],
) -> str:
chat_id = message.chat_id
lost = capture_group_lost_reason(
flow=engine,
ticket=ticket,
sender_id=message.sender_id,
text=message.content or "",
)
if lost:
return lost
facts = dict(ticket_view(ticket).get("collab_facts") or {})
if intent == INTENT_PICK_OPTION or (
intent == INTENT_OTHER and str(facts.get("__pending_air_lock") or "") == "1"
@@ -17,6 +17,7 @@ from agent.policy.sea_group_ops import (
apply_group_deal,
apply_product_quote,
apply_tms_quote_choice,
capture_group_lost_reason,
dissolve_group,
group_client_of,
handle_group_file,
@@ -138,6 +139,16 @@ def handle_group_inbound(
classify_group_text,
)
lost = capture_group_lost_reason(
flow=engine,
ticket=ticket,
sender_id=message.sender_id,
text=message.content or "",
sess=sess,
)
if lost:
return lost
intent = classify_group_text(message.content or "", injected=injected_intent)
logger.info(
"group_inbound 文字 chat=%s sender=%s intent=%s chars=%s",
@@ -198,6 +198,8 @@ def handle_text_other(message: InboundMessage, *, store=None) -> str:
sess = engine.session_for_action(
message.sender_id, message.raw, text=message.content or ""
)
if sess and sess.phase == "wait_lost_reason":
return handle_text_inquiry(message, store=msg_store)
btn = engine.match_button(message.content or "", sess)
if btn:
return handle_text_inquiry(message, store=msg_store)
+17
View File
@@ -111,6 +111,7 @@ class WorkerRuntime:
threading.Thread(target=self._recognition_loop, name="recog-loop", daemon=True),
threading.Thread(target=self._archive_loop, name="archive-loop", daemon=True),
threading.Thread(target=self._wshoto_poll_loop, name="wshoto-poll", daemon=True),
threading.Thread(target=self._deal_followup_loop, name="deal-followup", daemon=True),
threading.Thread(target=self._pdf_loop, name="pdf-loop", daemon=True),
]
for t in self._threads:
@@ -273,6 +274,22 @@ class WorkerRuntime:
wait = 1.0 if has_hot_room() else interval
wait_wshoto_poll(wait)
def _deal_followup_loop(self) -> None:
"""
HTTP 槽轮询协商再问。独立线程,不占回调、不与 soffice 串队。
"""
from agent.jobs.deal_followup_poll import poll_due_negotiate
while not self._stop.is_set():
try:
self.http_jobs.run(
lambda: poll_due_negotiate(settings=self.settings),
label="deal-followup",
)
except Exception:
logger.exception("协商再问轮询失败")
self._stop.wait(60.0)
def _pdf_loop(self) -> None:
"""仅 LibreOffice 单槽消费 PDF Stream。"""
if not self.redis:
@@ -0,0 +1,120 @@
"""
协商中到期再问:Worker HTTP 槽轮询,回到当初那个会话。
本文件职责:认领主账到期单、发成交跟进。不在回调线程跑。
禁止:群解散后改发私聊;占 LibreOffice 槽;把六态写进 Redis。
"""
from __future__ import annotations
import logging
from typing import Any, Optional
from agent.config import Settings, get_settings
from agent.policy import inquiry_copy as copy
logger = logging.getLogger(__name__)
def poll_due_negotiate(*, ledger: Any = None, settings: Optional[Settings] = None) -> int:
"""
认领到期协商中工单并再问一次。
返回发出条数。主账已清空 next_followup_at,本轮失败也不再自动补发。
"""
cfg = settings or get_settings()
book = ledger
if book is None:
if str(getattr(cfg, "ledger_backend", "http") or "http").lower() == "memory":
return 0
from agent.ledger.http_ledger import HttpLedger
book = HttpLedger()
claimer = getattr(book, "claim_due_negotiate", None)
if not callable(claimer):
return 0
rows = claimer() or []
sent = 0
for row in rows:
if not isinstance(row, dict):
continue
try:
if _reask_one(row, settings=cfg):
sent += 1
except Exception:
logger.exception("协商再问失败 wo=%s", row.get("work_order_no") or row.get("workOrderNo"))
return sent
def _reask_one(row: dict[str, Any], *, settings: Settings) -> bool:
no = str(row.get("work_order_no") or row.get("workOrderNo") or "").strip()
if not no:
return False
chat_id = str(row.get("collab_chat_id") or row.get("collabChatId") or "").strip()
sales = str(row.get("sales_wecom_id") or row.get("salesWecomId") or "").strip()
name = str(row.get("sales_name") or row.get("salesName") or "").strip() or "销售"
facts = dict(row.get("facts") or {})
quote = dict(row.get("quote") or {})
line = str(row.get("business_line") or row.get("businessLine") or "").strip().upper()
if chat_id:
ok = _send_group_reask(
chat_id=chat_id,
work_order_no=no,
sales_name=name,
facts=facts,
quote=quote,
business_line=line,
settings=settings,
)
if not ok:
logger.warning("协商再问原群失败且不补发私聊 wo=%s chat=%s", no, chat_id)
return ok
if not sales:
logger.warning("协商再问无销售 userid wo=%s", no)
return False
from agent.channel.queue import get_message_store
text = copy.deal_card(work_order_no=no, quote=quote, facts=facts)
payload = copy.deal_wecom_payload(work_order_no=no, quote=quote, facts=facts)
ok, info = get_message_store().enqueue_outbound(
touser=sales,
content=text,
dedupe_key=f"deal-reask:{no}",
payload=payload,
)
if not ok:
logger.warning("协商再问私聊入队失败 wo=%s info=%s", no, info)
return bool(ok)
def _send_group_reask(
*,
chat_id: str,
work_order_no: str,
sales_name: str,
facts: dict[str, str],
quote: dict[str, Any],
business_line: str,
settings: Settings,
) -> bool:
text = copy.group_deal_followup(sales_name=sales_name, work_order_no=work_order_no)
if business_line == "AIR":
from agent.channel.aibot.reply import AibotReplyClient
out = AibotReplyClient().send_group(chat_id=chat_id, content=text)
return bool(out.get("ok"))
from agent.channel.outbox.sender import WeComAppClient
client = WeComAppClient(
corp_id=settings.wecom_corp_id,
secret=settings.wecom_secret,
agent_id=int(settings.wecom_agent_id or "0") or 0,
)
out = client.send_group(chat_id=chat_id, content=text)
if not out.get("ok"):
return False
carder = getattr(client, "send_group_card", None)
if callable(carder):
payload = copy.deal_wecom_payload(work_order_no=work_order_no, quote=quote, facts=facts)
carder(chat_id=chat_id, template_card=payload.get("template_card") or {})
return True
+36 -5
View File
@@ -193,15 +193,46 @@ class HttpLedger:
out.setdefault("needsPdf", str(format).lower() == "pdf")
return out
def transition(self, *, work_order_no: str, to_status: str) -> dict[str, Any]:
out = self._post(
"/inquiry/agent/ticket/transition",
{"workOrderNo": work_order_no, "toStatus": to_status},
)
def transition(
self,
*,
work_order_no: str,
to_status: str,
remark: str = "",
lost_reason: str = "",
next_followup_at: str = "",
deal_notify_summary: str = "",
) -> dict[str, Any]:
body: dict[str, Any] = {"workOrderNo": work_order_no, "toStatus": to_status}
if remark:
body["remark"] = remark
if lost_reason:
body["lostReason"] = lost_reason
if next_followup_at:
body["nextFollowupAt"] = next_followup_at
if deal_notify_summary:
body["dealNotifySummary"] = deal_notify_summary
out = self._post("/inquiry/agent/ticket/transition", body)
if not out.get("ok") and out.get("status"):
out["ok"] = True
return out if out.get("ok") else {"ok": False, "error": out.get("error") or "transition_failed"}
def patch_lost_reason(self, *, work_order_no: str, lost_reason: str) -> dict[str, Any]:
"""未成交后记下原因。不改六态。"""
out = self._post(
"/inquiry/agent/ticket/patchLostReason",
{"workOrderNo": work_order_no, "lostReason": lost_reason},
)
return out if out.get("ok") else {"ok": False, "error": out.get("error") or "patch_lost_reason_failed"}
def claim_due_negotiate(self) -> list[dict[str, Any]]:
"""认领到期协商再问,主账会清空再问时刻防重发。"""
out = self._post("/inquiry/agent/ticket/claimDueNegotiate", {})
rows = out.get("tickets") or out.get("result") or []
if isinstance(rows, list):
return [dict(x) for x in rows if isinstance(x, dict)]
return []
def match_sea_staff(self, *, origin: str, destination: str) -> list[dict[str, Any]]:
"""主账按线路返回启用的海运产品岗。"""
out = self._post(
+69 -2
View File
@@ -41,6 +41,9 @@ class MemoryTicket:
collab_ended: bool = False
attachments: list[dict[str, Any]] = field(default_factory=list)
quote_file_ready: bool = False
lost_reason: str = ""
next_followup_at: str = ""
deal_notify_summary: str = ""
class MemoryLedger:
@@ -207,14 +210,70 @@ class MemoryLedger:
"status": ticket.status,
}
def transition(self, *, work_order_no: str, to_status: str) -> dict[str, Any]:
def transition(
self,
*,
work_order_no: str,
to_status: str,
remark: str = "",
lost_reason: str = "",
next_followup_at: str = "",
deal_notify_summary: str = "",
) -> dict[str, Any]:
_ = remark
with self._lock:
ticket = self._tickets.get(work_order_no)
if ticket is None:
return {"ok": False, "error": "ticket_not_found"}
ticket.status = to_status
if to_status == "未成交":
ticket.next_followup_at = ""
if lost_reason:
ticket.lost_reason = lost_reason
elif to_status == "已成交":
ticket.next_followup_at = ""
if deal_notify_summary:
ticket.deal_notify_summary = deal_notify_summary
elif to_status == "协商中":
ticket.next_followup_at = next_followup_at
return {"ok": True, "status": ticket.status}
def patch_lost_reason(self, *, work_order_no: str, lost_reason: str) -> dict[str, Any]:
reason = (lost_reason or "").strip()
with self._lock:
ticket = self._tickets.get(work_order_no)
if ticket is None:
return {"ok": False, "error": "ticket_not_found"}
if ticket.status != "未成交":
return {"ok": False, "error": "not_lost", "status": ticket.status}
if not reason:
return {"ok": False, "error": "reason_blank"}
ticket.lost_reason = reason
return {"ok": True, "work_order_no": work_order_no, "lost_reason": reason}
def claim_due_negotiate(self) -> list[dict[str, Any]]:
now = datetime.now(_SHANGHAI).strftime("%Y-%m-%d %H:%M:%S")
hits: list[dict[str, Any]] = []
with self._lock:
for ticket in self._tickets.values():
due = (ticket.next_followup_at or "").strip()
if ticket.status != "协商中" or not due or due > now:
continue
ticket.next_followup_at = ""
hits.append(
{
"work_order_no": ticket.work_order_no,
"sales_wecom_id": ticket.sales_wecom_id,
"sales_name": "",
"business_line": ticket.business_line,
"collab_chat_id": ticket.collab_chat_id,
"status": ticket.status,
"facts": dict(ticket.facts),
"quote": dict(ticket.quote),
}
)
return hits
def get_ticket(self, *, work_order_no: str) -> MemoryTicket | None:
with self._lock:
return self._tickets.get(work_order_no)
@@ -282,8 +341,12 @@ class MemoryLedger:
status = ticket.status
quote = dict(ticket.quote or {})
wait_phase = "done"
if status in {"已成交", "未成交", "协商中", "已关闭"}:
if status in {"已成交", "已关闭"}:
wait_phase = "done"
elif status == "未成交":
wait_phase = "wait_lost_reason" if not (ticket.lost_reason or "").strip() else "done"
elif status == "协商中":
wait_phase = "wait_deal"
elif status == "已报价" and quote:
# 文件已发给销售 → 成交跟进;否则海运协同卡、空运方案卡
if ticket.quote_file_ready or quote.get("quoteFileIssued"):
@@ -306,6 +369,10 @@ class MemoryLedger:
"wait_phase": wait_phase,
"collab_chat_id": ticket.collab_chat_id,
"collab_facts": dict(ticket.collab_facts),
"lost_reason": ticket.lost_reason,
"lostReason": ticket.lost_reason,
"deal_notify_summary": ticket.deal_notify_summary,
"next_followup_at": ticket.next_followup_at,
}
def upsert_staff(self, row: dict[str, Any]) -> None:
+19 -3
View File
@@ -539,12 +539,28 @@ def apply_air_deal(
copy.ticket_locked(work_order_no=ticket_no(ticket), status=ticket_status(ticket)),
)
return "ticket_locked"
result = flow.ledger.transition(work_order_no=ticket_no(ticket), to_status=outcome) or {}
from agent.policy.deal_outcome import finish_deal_outcome
from agent.policy.sea_group_ops import facts_of_group
view = ticket_view(ticket)
result = finish_deal_outcome(
ledger=flow.ledger,
work_order_no=ticket_no(ticket),
outcome=outcome,
facts=facts_of_group(view),
quote=dict(view.get("quote") or {}),
business_line="AIR",
sales_name=str(view.get("sales_name") or ""),
)
if not result.get("ok"):
send_group_text(group_client_of(flow), chat_id, "主账状态更新失败,成交结果未入账。")
return "ledger_fail"
send_group_text(group_client_of(flow), chat_id, copy.deal_ack(outcome, ticket_no(ticket)))
return "done"
send_group_text(
group_client_of(flow),
chat_id,
copy.deal_ack(outcome, ticket_no(ticket), str(result.get("next_followup_at") or "")),
)
return str(result.get("phase") or "done")
def apply_air_lock(
+54 -3
View File
@@ -322,6 +322,8 @@ class AirTextInquiryFlow:
allowed = (copy.BTN_ADOPT_EXCEL, copy.BTN_ADOPT_PDF, copy.BTN_REJECT)
elif phase == "wait_deal":
allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
elif phase == "wait_lost_reason":
allowed = ()
return FlowSession(
sender_id=sender_id,
thread_id=sender_id,
@@ -333,11 +335,17 @@ class AirTextInquiryFlow:
status=status,
allowed=allowed,
wait_version=1,
invited_sales_name=str(view.get("sales_name") or view.get("salesName") or ""),
collab_chat_id=str(view.get("collab_chat_id") or ""),
)
def _infer_wait_phase(self, *, status: str, quote: dict[str, Any]) -> str:
if status in {"已成交", "未成交", "协商中", "已关闭"}:
if status in {"已成交", "已关闭"}:
return "done"
if status == "未成交":
return "wait_lost_reason"
if status == "协商中":
return "wait_deal"
if status == "已报价" and quote:
return "wait_adopt"
return "tms_miss"
@@ -413,6 +421,9 @@ class AirTextInquiryFlow:
# 成交卡已经绑在报价文件那条出站上。这里再发一张独立卡,
# 会在文件还在上传时被另一条出站线程抢走,销售又先看到成交跟进。
return "wait_deal"
if sess.phase == "wait_lost_reason":
reply(copy.deal_ack("未成交", sess.work_order_no))
return "wait_lost_reason"
if sess.phase == "done":
reply(copy.deal_ack(sess.status or "已关闭", sess.work_order_no))
return "done"
@@ -462,6 +473,13 @@ class AirTextInquiryFlow:
reply=reply,
card_meta=inbound_raw,
)
# 未成交后下一句任意文字即原因;完整新询价(注入字段)则空着原因开新单。
if (
sess
and sess.phase == "wait_lost_reason"
and not (injected_facts and injected_mode)
):
return self._capture_lost_reason(sess, text, reply)
snap = extract_inquiry_snapshot(
text,
@@ -1056,15 +1074,48 @@ class AirTextInquiryFlow:
) -> str:
"""点成交/未成交/协商中:先整卡置灰,再入主账。"""
self._grey_clicked_card(reply, card_meta, replace_name=status)
result = self._ledger.transition(work_order_no=sess.work_order_no, to_status=status)
from agent.policy.deal_outcome import finish_deal_outcome
result = finish_deal_outcome(
ledger=self._ledger,
work_order_no=sess.work_order_no,
outcome=status,
facts=sess.facts,
quote=sess.quote,
business_line=sess.business_line,
sales_name=sess.invited_sales_name,
)
if not result.get("ok"):
reply("主账状态更新失败,成交结果未入账。")
return "ledger_fail"
phase = str(result.get("phase") or "done")
sess.status = status
sess.phase = phase
if phase == "wait_deal":
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
else:
sess.allowed = ()
sess.wait_version += 1
self._save(sess)
reply(copy.deal_ack(status, sess.work_order_no, str(result.get("next_followup_at") or "")))
return phase
def _capture_lost_reason(self, sess: FlowSession, text: str, reply: ReplyFn) -> str:
"""私聊未成交后收下原因,不再追问。"""
from agent.policy.deal_outcome import capture_lost_reason
result = capture_lost_reason(
ledger=self._ledger,
work_order_no=sess.work_order_no,
reason=text,
)
if not result.get("ok"):
reply("未成交原因没有记下,请再发一句。")
return "wait_lost_reason"
sess.phase = "done"
sess.allowed = ()
self._save(sess)
reply(copy.deal_ack(status, sess.work_order_no))
reply(copy.lost_reason_ack(text))
return "done"
def mark_wait_deal(self, sender_id: str, work_order_no: str) -> None:
+210
View File
@@ -0,0 +1,210 @@
"""
成交跟进纯规则:工作日顺延、线路对负责人。
本文件职责:给私聊/群聊成交共用,不发企微、不写主账。
禁止:在回调线程算完还同步推负责人;把六态写进本文件。
"""
from __future__ import annotations
from datetime import date, datetime, timedelta, time
from typing import Any, Iterable
from zoneinfo import ZoneInfo
from agent.schema.tms_air_query import resolve_airport_code
SHANGHAI = ZoneInfo("Asia/Shanghai")
# 法定休息日(不含调休上班)。调休日放进 MAKEUP_WORKDAYS,周末也算工作日。
_HOLIDAYS = frozenset(
{
# 2026
date(2026, 1, 1),
date(2026, 1, 2),
date(2026, 1, 3),
date(2026, 2, 15),
date(2026, 2, 16),
date(2026, 2, 17),
date(2026, 2, 18),
date(2026, 2, 19),
date(2026, 2, 20),
date(2026, 2, 21),
date(2026, 2, 22),
date(2026, 2, 23),
date(2026, 4, 4),
date(2026, 4, 5),
date(2026, 4, 6),
date(2026, 5, 1),
date(2026, 5, 2),
date(2026, 5, 3),
date(2026, 5, 4),
date(2026, 5, 5),
date(2026, 6, 19),
date(2026, 6, 20),
date(2026, 6, 21),
date(2026, 9, 25),
date(2026, 9, 26),
date(2026, 9, 27),
date(2026, 10, 1),
date(2026, 10, 2),
date(2026, 10, 3),
date(2026, 10, 4),
date(2026, 10, 5),
date(2026, 10, 6),
date(2026, 10, 7),
date(2026, 10, 8),
# 2027 元旦预留
date(2027, 1, 1),
date(2027, 1, 2),
date(2027, 1, 3),
}
)
# 周末调休上班:算工作日。
_MAKEUP_WORKDAYS = frozenset(
{
date(2026, 1, 4),
date(2026, 2, 14),
date(2026, 2, 28),
date(2026, 5, 9),
date(2026, 10, 10),
}
)
def is_workday(day: date) -> bool:
"""周六日默认休息;法定假休息;调休上班算工作日。"""
if day in _HOLIDAYS:
return False
if day in _MAKEUP_WORKDAYS:
return True
return day.weekday() < 5
def shift_workdays(start: date, count: int) -> date:
"""
从 start 当天之后数 count 个工作日。
成交选协商中:顺延 3 个工作日后,第四个工作日再问,故 count=4。
"""
if count < 1:
return start
cur = start
seen = 0
while seen < count:
cur = cur + timedelta(days=1)
if is_workday(cur):
seen += 1
return cur
def next_followup_at(now: datetime | None = None) -> datetime:
"""第四个工作日 Asia/Shanghai 09:00。"""
base = now.astimezone(SHANGHAI) if now else datetime.now(SHANGHAI)
day = shift_workdays(base.date(), 4)
return datetime.combine(day, time(9, 0), tzinfo=SHANGHAI)
def _split_routes(raw: str) -> list[str]:
out: list[str] = []
for part in (raw or "").replace(",", ",").replace("、", ",").split(","):
t = part.strip()
if t:
out.append(t)
return out
def air_route_keys(facts: dict[str, str] | None) -> set[str]:
"""本单可对上的空运线路名:三字码对 + 目的港。"""
src = dict(facts or {})
origin = resolve_airport_code(str(src.get("起运港") or src.get("originCode") or ""))
dest = resolve_airport_code(str(src.get("目的港") or src.get("destinationCode") or ""))
keys: set[str] = set()
if dest:
keys.add(dest)
if origin and dest:
keys.add(f"{origin}-{dest}")
return keys
def _is_waicai(facts: dict[str, str] | None) -> bool:
src = dict(facts or {})
for key in ("外采", "是否外采", "采购方式"):
val = str(src.get(key) or "").strip().lower()
if val in {"1", "是", "true", "yes", "外采"}:
return True
blob = "".join(str(v) for v in src.values())
return "外采" in blob
def leader_matches(
*,
role_code: str,
routes: str,
is_leader: bool,
business_line: str,
facts: dict[str, str] | None,
) -> bool:
"""
员工管理「是负责人 + 负责线路对得上」。
海运:海运全线。陆运:线路类别整词。空运:三字码对 / 目的港 / 全线;外采须工单标明。
"""
if not is_leader:
return False
line = (business_line or "").strip().upper()
code = (role_code or "").strip().lower()
tokens = _split_routes(routes)
if line == "SEA":
return code == "sea" and "海运全线" in tokens
if line == "LAND":
want = str((facts or {}).get("线路类别") or "").strip()
return code == "land" and bool(want) and want in tokens
if line == "AIR":
if code != "air":
return False
if "全线" in tokens:
return True
if "外采-空运" in tokens and _is_waicai(facts):
return True
keys = air_route_keys(facts)
return any(t in keys for t in tokens)
return False
def pick_leaders(
staff: Iterable[dict[str, Any]],
*,
business_line: str,
facts: dict[str, str] | None,
) -> list[dict[str, str]]:
"""从员工行筛出该推的负责人(启用且有企微 id)。"""
out: list[dict[str, str]] = []
seen: set[str] = set()
for row in staff:
if str(row.get("status") or "active").lower() != "active":
continue
wecom = str(row.get("wecomId") or row.get("wecom_id") or "").strip()
if not wecom or wecom in seen:
continue
leader_flag = row.get("isLeader")
if leader_flag is None:
leader_flag = row.get("is_leader")
is_leader = leader_flag in {True, 1, "1", "true", "True"}
if not leader_matches(
role_code=str(row.get("roleCode") or row.get("role_code") or ""),
routes=str(row.get("routes") or ""),
is_leader=bool(is_leader),
business_line=business_line,
facts=facts,
):
continue
seen.add(wecom)
out.append(
{
"name": str(row.get("name") or "").strip(),
"wecomId": wecom,
"roleCode": str(row.get("roleCode") or row.get("role_code") or ""),
}
)
return out
+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}
+94 -2
View File
@@ -1732,12 +1732,104 @@ def group_deal_mention(*, sales_name: str, work_order_no: str) -> str:
return group_deal_followup(sales_name=sales_name, work_order_no=work_order_no)
def deal_ack(outcome: str, work_order_no: str) -> str:
def deal_ack(outcome: str, work_order_no: str, next_followup_at: str = "") -> str:
"""成交三态回执。协商中写明顺延三个工作日和下次再问的月日 9:00。"""
if outcome == "已成交":
return f"已记录为「已成交」。工单 {work_order_no} 已关闭智能体跟进。"
if outcome == "未成交":
return "已记录为「未成交」。请补充未成交原因。"
return f"已记录为「协商中」。工单 {work_order_no} 跟进周期已顺延。"
when = _followup_month_day(next_followup_at)
if when:
return f"已记录为「协商中」。已顺延三个工作日,{when}的9:00再次发起成交状态询问。"
return "已记录为「协商中」。已顺延三个工作日,第四个工作日早上9:00再次发起成交状态询问。"
def _followup_month_day(raw: str) -> str:
"""把主账再问时刻收成「9月24日」。"""
from datetime import datetime
text = (raw or "").strip()
if not text:
return ""
for fmt, n in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d", 10)):
try:
dt = datetime.strptime(text[:n], fmt)
return f"{dt.month}月{dt.day}日"
except ValueError:
continue
return ""
def lost_reason_ack(reason: str = "") -> str:
"""收下未成交原因后回显原句,不再追问。"""
text = (reason or "").strip()
if text.endswith(("。", ".", "!", "!", "?", "?")):
return f"已记下未成交原因:{text}"
return f"已记下未成交原因:{text}。" if text else "已记下未成交原因。"
def deal_notify_card(
*,
work_order_no: str,
sales_name: str,
facts: dict[str, str] | None,
quote: dict[str, Any] | None,
business_line: str,
) -> str:
"""成交通知文字兜底:方案卡压缩。不写销售,避免标题出现「销售 销售」。"""
no = (work_order_no or "").strip()
_ = sales_name
mode = scheme_transport_mode(business_line)
rows = scheme_horizontal_rows(
work_order_no=no,
facts=dict(facts or {}),
quote=dict(quote or {}),
transport_mode=mode,
)
lines = [f"成交通知 工单 {no}"]
lines.extend(f"{row['keyname']}:{row['value']}" for row in rows)
return "\n".join(lines)
def deal_notify_wecom_payload(
*,
work_order_no: str,
sales_name: str,
facts: dict[str, str] | None,
quote: dict[str, Any] | None,
business_line: str,
) -> dict[str, Any]:
"""
成交通知卡:走询价消息服务,不走询价小助手。
字段按报价方案卡压缩,避免超过 6 格。标题不写销售。
"""
no = (work_order_no or "").strip()
_ = sales_name
mode = scheme_transport_mode(business_line)
rows = scheme_horizontal_rows(
work_order_no=no,
facts=dict(facts or {}),
quote=dict(quote or {}),
transport_mode=mode,
)
from agent.config import get_settings
base = str(getattr(get_settings(), "public_base_url", "") or "").strip().rstrip("/")
action_url = base or "https://work.weixin.qq.com"
return {
"msgtype": "template_card",
"wecom_app": "notify",
"template_card": {
"card_type": "text_notice",
"source": {"desc": "成交通知", "desc_color": 0},
"main_title": {
"title": f"成交通知 工单 {no}",
},
"horizontal_content_list": rows,
"card_action": {"type": 1, "url": action_url},
},
}
def air_need_work_order() -> str:
@@ -91,6 +91,13 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
reply=reply,
sess=sess,
)
# 未成交后下一句任意文字即原因。不能走下面的 new_over_old,否则会当新询价丢掉。
if (
sess
and sess.phase == "wait_lost_reason"
and not (injected_facts and injected_mode)
):
return self._capture_lost_reason(sess, text, reply)
if sess and phase not in _LAND_FRONT:
# 查价后半段书签还在时,销售又发新陆运:只走陆运前半段。
# 禁止再 super 去海运 on_text,海运会把 LAND 转回来,递归到死、一句都不回。
+66 -7
View File
@@ -64,6 +64,7 @@ def ticket_view(ticket: object) -> dict[str, Any]:
view.get("productNames") or view.get("product_names") or [],
)
view.setdefault("sales_name", view.get("salesName") or view.get("sales_name") or "")
view.setdefault("lost_reason", view.get("lostReason") or view.get("lost_reason") or "")
facts = dict(view.get("collab_facts") or view.get("collabFacts") or {})
view.setdefault("collab_facts", facts)
flag = str(facts.get("__ended") or view.get("collab_ended") or view.get("collabEnded") or "").strip().lower()
@@ -89,6 +90,7 @@ def ticket_view(ticket: object) -> dict[str, Any]:
"product_ids": list(getattr(ticket, "product_ids", []) or []),
"product_names": list(getattr(ticket, "product_names", []) or []),
"sales_name": str(getattr(ticket, "sales_name", "") or ""),
"lost_reason": str(getattr(ticket, "lost_reason", "") or ""),
}
@@ -987,18 +989,75 @@ def apply_group_deal(
if not (quote.get("total") or _quote_fee_rows(quote) or status_now in {"已报价", "协商中"}):
send_group_text(client, chat_id, copy.group_deal_need_quote())
return "deal_need_quote"
writer = getattr(flow.ledger, "transition", None)
if callable(writer):
result = writer(work_order_no=no, to_status=outcome) or {}
if not result.get("ok"):
send_group_text(client, chat_id, "主账状态更新失败,成交结果未入账。")
return "ledger_fail"
from agent.policy.deal_outcome import finish_deal_outcome
result = finish_deal_outcome(
ledger=flow.ledger,
work_order_no=no,
outcome=outcome,
facts=facts_of_group(view, sess),
quote=quote,
business_line=str(view.get("business_line") or (sess.business_line if sess else "SEA") or "SEA"),
sales_name=str(view.get("sales_name") or (getattr(sess, "invited_sales_name", "") if sess else "") or ""),
)
if not result.get("ok"):
send_group_text(client, chat_id, "主账状态更新失败,成交结果未入账。")
return "ledger_fail"
phase = str(result.get("phase") or "done")
if sess is not None:
sess.status = outcome
sess.phase = phase
if phase == "wait_deal":
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
else:
sess.allowed = ()
flow._save(sess)
send_group_text(client, chat_id, copy.deal_ack(outcome, no, str(result.get("next_followup_at") or "")))
return phase
def facts_of_group(view: dict[str, Any], sess: Any = None) -> dict[str, str]:
"""主账字段打底,书签覆盖;给负责人匹配和成交通知卡用。"""
facts = dict(view.get("facts") or {})
if sess is not None and getattr(sess, "facts", None):
facts.update(dict(sess.facts or {}))
return facts
def capture_group_lost_reason(
*,
flow: Any,
ticket: object,
sender_id: str,
text: str,
sess: Any = None,
) -> str:
"""
群里未成交后,本单销售下一句任意文字即原因。
空运群是否要 @ 由入站通道先拦,这里不再判。
"""
ticket = hydrate_ticket(flow, ticket)
view = ticket_view(ticket)
no = ticket_no(ticket)
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "")
client = group_client_of(flow)
if ticket_status(ticket) != "未成交":
return ""
if str(view.get("lost_reason") or "").strip():
return ""
if not is_sales(ticket, sender_id):
return ""
from agent.policy.deal_outcome import capture_lost_reason
result = capture_lost_reason(ledger=flow.ledger, work_order_no=no, reason=text)
if not result.get("ok"):
send_group_text(client, chat_id, "未成交原因没有记下,请再发一句。")
return "wait_lost_reason"
if sess is not None:
sess.phase = "done"
sess.allowed = ()
flow._save(sess)
send_group_text(client, chat_id, copy.deal_ack(outcome, no))
send_group_text(client, chat_id, copy.lost_reason_ack(text))
return "done"
@@ -85,6 +85,13 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
reply=reply,
card_meta=inbound_raw,
)
# 未成交后下一句任意文字即原因,不能先抽字段当成新询价。
if (
sess
and sess.phase == "wait_lost_reason"
and not (injected_facts and injected_mode)
):
return self._capture_lost_reason(sess, text, reply)
snap = extract_inquiry_snapshot(
text,
+144
View File
@@ -0,0 +1,144 @@
"""
成交跟进:工作日顺延、线路对负责人、未成交收下原因(不打外网)。
"""
from __future__ import annotations
import unittest
from datetime import date, datetime
from zoneinfo import ZoneInfo
from agent.policy.deal_followup import (
SHANGHAI,
air_route_keys,
leader_matches,
next_followup_at,
shift_workdays,
)
class TestWorkdays(unittest.TestCase):
def test_skip_weekend(self) -> None:
# 2026-09-18 周五,顺延 3 个工作日 → 21、22、23,第四个工作日 24
self.assertEqual(shift_workdays(date(2026, 9, 18), 3), date(2026, 9, 23))
self.assertEqual(shift_workdays(date(2026, 9, 18), 4), date(2026, 9, 24))
def test_skip_national_day(self) -> None:
# 2026-09-30 周三,国庆休 → 第四个工作日落到节后
fourth = shift_workdays(date(2026, 9, 30), 4)
self.assertGreaterEqual(fourth, date(2026, 10, 9))
self.assertNotEqual(fourth.weekday(), 5)
self.assertNotEqual(fourth.weekday(), 6)
def test_next_followup_is_9am(self) -> None:
when = next_followup_at(datetime(2026, 9, 18, 15, 30, tzinfo=SHANGHAI))
self.assertEqual(when.hour, 9)
self.assertEqual(when.minute, 0)
self.assertEqual(when.tzinfo, SHANGHAI)
self.assertEqual(when.date(), date(2026, 9, 24))
class TestAirRouteKeys(unittest.TestCase):
def test_pair_and_dest(self) -> None:
keys = air_route_keys({"起运港": "SZX", "目的港": "KUL"})
self.assertIn("SZX-KUL", keys)
self.assertIn("KUL", keys)
def test_chinese_city_projects_iata(self) -> None:
keys = air_route_keys({"起运港": "深圳", "目的港": "吉隆坡"})
self.assertIn("SZX-KUL", keys)
self.assertIn("KUL", keys)
class TestLeaderMatch(unittest.TestCase):
def test_sea_all_line(self) -> None:
self.assertTrue(
leader_matches(
role_code="sea",
routes="海运全线",
is_leader=True,
business_line="SEA",
facts={},
)
)
self.assertFalse(
leader_matches(
role_code="sea",
routes="海运全线",
is_leader=False,
business_line="SEA",
facts={},
)
)
def test_land_route_category(self) -> None:
self.assertTrue(
leader_matches(
role_code="land",
routes="国内长途/零担,东南亚",
is_leader=True,
business_line="LAND",
facts={"线路类别": "国内长途/零担"},
)
)
self.assertFalse(
leader_matches(
role_code="land",
routes="东南亚",
is_leader=True,
business_line="LAND",
facts={"线路类别": "国内长途/零担"},
)
)
def test_air_pair_dest_and_all(self) -> None:
facts = {"起运港": "SZX", "目的港": "KUL"}
self.assertTrue(
leader_matches(
role_code="air",
routes="SZX-KUL",
is_leader=True,
business_line="AIR",
facts=facts,
)
)
self.assertTrue(
leader_matches(
role_code="air",
routes="KUL",
is_leader=True,
business_line="AIR",
facts=facts,
)
)
self.assertTrue(
leader_matches(
role_code="air",
routes="全线",
is_leader=True,
business_line="AIR",
facts=facts,
)
)
self.assertFalse(
leader_matches(
role_code="air",
routes="外采-空运",
is_leader=True,
business_line="AIR",
facts=facts,
)
)
self.assertTrue(
leader_matches(
role_code="air",
routes="外采-空运",
is_leader=True,
business_line="AIR",
facts={**facts, "外采": "1"},
)
)
if __name__ == "__main__":
unittest.main()
+247
View File
@@ -0,0 +1,247 @@
"""
成交三态:入账、推负责人、收下未成交原因、协商中不停会话。
"""
from __future__ import annotations
import unittest
from agent.ledger.memory_ledger import MemoryLedger
from agent.policy import inquiry_copy as copy
from agent.policy.air_text_flow import AirTextInquiryFlow, FlowSession
from agent.policy.deal_outcome import capture_lost_reason, finish_deal_outcome
from agent.policy.sea_text_flow import SeaTextInquiryFlow
class TestDealOutcome(unittest.TestCase):
def setUp(self) -> None:
self.ledger = MemoryLedger()
created = self.ledger.create_ticket(
sender_id="sales1",
business_line="SEA",
facts={"起运港": "上海", "目的港": "洛杉矶", "品名": "衣服"},
)
self.wo = str(created.get("work_order_no") or "")
self.ledger.transition(work_order_no=self.wo, to_status="已报价")
self.ledger.upsert_staff(
{
"name": "海运负责人",
"wecomId": "lead-sea",
"roleCode": "sea",
"routes": "海运全线",
"isLeader": 1,
"status": "active",
}
)
def test_deal_notifies_leaders(self) -> None:
queued: list[dict] = []
def enqueue(*, touser, content, dedupe_key, payload):
queued.append(
{
"touser": touser,
"content": content,
"dedupe_key": dedupe_key,
"payload": payload,
}
)
return True, "ok"
out = finish_deal_outcome(
ledger=self.ledger,
work_order_no=self.wo,
outcome="已成交",
facts={"起运港": "上海", "目的港": "洛杉矶"},
quote={"total": "1000", "currency": "USD"},
business_line="SEA",
sales_name="吴吉林",
enqueue=enqueue,
notify_ready=True,
)
self.assertTrue(out.get("ok"))
self.assertEqual(out.get("phase"), "done")
self.assertIn("海运负责人", str(out.get("notify_summary") or ""))
self.assertEqual(queued[0]["touser"], "lead-sea")
self.assertEqual(queued[0]["payload"].get("wecom_app"), "notify")
ticket = self.ledger.get_ticket(work_order_no=self.wo)
self.assertEqual(ticket.status, "已成交")
self.assertEqual(ticket.next_followup_at, "")
def test_deal_notify_card_omits_sales_label(self) -> None:
"""成交通知不要「销售」二字:标题副标和正文都不写。"""
text = copy.deal_notify_card(
work_order_no="WO202609180008",
sales_name="吴吉林",
facts={"起运地": "泉州", "目的地": "南京", "线路类别": "国内城配/拖车/打包"},
quote={},
business_line="LAND",
)
self.assertNotIn("销售", text)
payload = copy.deal_notify_wecom_payload(
work_order_no="WO202609180008",
sales_name="吴吉林",
facts={"起运地": "泉州", "目的地": "南京", "线路类别": "国内城配/拖车/打包"},
quote={},
business_line="LAND",
)
title = (payload.get("template_card") or {}).get("main_title") or {}
blob = str(title.get("title") or "") + str(title.get("desc") or "")
self.assertNotIn("销售", blob)
self.assertNotIn("desc", title)
def test_deal_without_leader_still_closes(self) -> None:
self.ledger.upsert_staff(
{
"name": "不是负责人",
"wecomId": "lead-sea",
"roleCode": "sea",
"routes": "海运全线",
"isLeader": 0,
"status": "active",
}
)
out = finish_deal_outcome(
ledger=self.ledger,
work_order_no=self.wo,
outcome="已成交",
facts={},
quote={},
business_line="SEA",
notify_ready=True,
)
self.assertEqual(out.get("notify_summary"), "无匹配负责人")
self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).status, "已成交")
def test_lost_waits_for_reason(self) -> None:
out = finish_deal_outcome(
ledger=self.ledger,
work_order_no=self.wo,
outcome="未成交",
facts={},
quote={},
business_line="SEA",
)
self.assertEqual(out.get("phase"), "wait_lost_reason")
ticket = self.ledger.get_ticket(work_order_no=self.wo)
self.assertEqual(ticket.status, "未成交")
self.assertEqual(ticket.lost_reason, "")
view = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1")
self.assertEqual(view.get("wait_phase"), "wait_lost_reason")
captured = capture_lost_reason(
ledger=self.ledger, work_order_no=self.wo, reason="客人比价了"
)
self.assertTrue(captured.get("ok"))
ticket = self.ledger.get_ticket(work_order_no=self.wo)
self.assertEqual(ticket.lost_reason, "客人比价了")
view = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1")
self.assertEqual(view.get("wait_phase"), "done")
def test_lost_reason_ack_repeats_user_text(self) -> None:
self.assertEqual(copy.lost_reason_ack("客户不要了"), "已记下未成交原因:客户不要了。")
self.assertEqual(copy.lost_reason_ack("价格高。"), "已记下未成交原因:价格高。")
def test_negotiate_ack_names_followup_day(self) -> None:
text = copy.deal_ack("协商中", "WO202609190007", "2026-09-24 09:00:00")
self.assertEqual(
text,
"已记录为「协商中」。已顺延三个工作日,9月24日的9:00再次发起成交状态询问。",
)
self.assertNotIn("跟进周期已顺延", text)
def test_negotiate_keeps_wait_deal(self) -> None:
out = finish_deal_outcome(
ledger=self.ledger,
work_order_no=self.wo,
outcome="协商中",
facts={},
quote={},
business_line="SEA",
)
self.assertEqual(out.get("phase"), "wait_deal")
ticket = self.ledger.get_ticket(work_order_no=self.wo)
self.assertEqual(ticket.status, "协商中")
self.assertTrue(ticket.next_followup_at)
view = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1")
self.assertEqual(view.get("wait_phase"), "wait_deal")
again = finish_deal_outcome(
ledger=self.ledger,
work_order_no=self.wo,
outcome="协商中",
facts={},
quote={},
business_line="SEA",
)
self.assertEqual(again.get("phase"), "wait_deal")
self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).status, "协商中")
self.assertTrue(again.get("next_followup_at"))
def test_private_next_text_is_lost_reason(self) -> None:
flow = AirTextInquiryFlow(self.ledger)
sess = FlowSession(
sender_id="sales1",
thread_id="sales1",
phase="wait_deal",
business_line="SEA",
work_order_no=self.wo,
allowed=(copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE),
)
flow._save(sess)
replies: list[str] = []
phase = flow.on_button(
sender_id="sales1",
action=copy.BTN_LOST,
reply=lambda t, extra=None: replies.append(t),
)
self.assertEqual(phase, "wait_lost_reason")
phase = flow.on_text(
sender_id="sales1",
text="价格高",
reply=lambda t, extra=None: replies.append(t),
)
self.assertEqual(phase, "done")
self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).lost_reason, "价格高")
self.assertTrue(any(t == "已记下未成交原因:价格高。" for t in replies))
def test_sea_private_next_text_is_lost_reason(self) -> None:
flow = SeaTextInquiryFlow(self.ledger)
flow._save(
FlowSession(
sender_id="sales1",
thread_id="sales1",
phase="wait_deal",
business_line="SEA",
work_order_no=self.wo,
allowed=(copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE),
)
)
replies: list[str] = []
phase = flow.on_button(
sender_id="sales1",
action=copy.BTN_LOST,
reply=lambda t, extra=None: replies.append(t),
)
self.assertEqual(phase, "wait_lost_reason")
phase = flow.on_text(
sender_id="sales1",
text="客人比价了",
reply=lambda t, extra=None: replies.append(t),
)
self.assertEqual(phase, "done")
self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).lost_reason, "客人比价了")
def test_claim_due_negotiate_clears_schedule(self) -> None:
finish_deal_outcome(
ledger=self.ledger,
work_order_no=self.wo,
outcome="协商中",
facts={},
quote={},
business_line="SEA",
)
ticket = self.ledger.get_ticket(work_order_no=self.wo)
ticket.next_followup_at = "2020-01-01 09:00:00"
rows = self.ledger.claim_due_negotiate()
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["work_order_no"], self.wo)
self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).next_followup_at, "")
@@ -26,6 +26,7 @@ os.environ.setdefault("LEDGER_BACKEND", "memory")
from agent.ledger.memory_ledger import MemoryLedger
from agent.policy import inquiry_copy as copy
from agent.policy.air_text_flow import FlowSession
from agent.policy.land_text_flow import LandTextInquiryFlow
@@ -623,5 +624,46 @@ class LandTextPullTests(unittest.TestCase):
self.assertTrue(is_product(ticket, "u1", flow=self.flow))
class LandLostReasonTests(unittest.TestCase):
"""陆运私聊未成交后,下一句任意文字要记下原因,不能当新询价丢掉。"""
def test_land_private_next_text_is_lost_reason(self) -> None:
ledger = MemoryLedger()
created = ledger.create_ticket(
sender_id="WuJiLin",
business_line="LAND",
facts={"起运地": "泉州", "目的地": "南京", "线路类别": "国内城配/拖车/打包"},
)
wo = str(created.get("work_order_no") or "")
ledger.transition(work_order_no=wo, to_status="已报价")
flow = LandTextInquiryFlow(ledger)
flow._save(
FlowSession(
sender_id="WuJiLin",
thread_id="WuJiLin",
phase="wait_deal",
business_line="LAND",
work_order_no=wo,
allowed=(copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE),
)
)
replies: list[str] = []
phase = flow.on_button(
sender_id="WuJiLin",
action=copy.BTN_LOST,
reply=lambda t, extra=None: replies.append(t),
)
self.assertEqual(phase, "wait_lost_reason")
self.assertEqual(ledger.get_ticket(work_order_no=wo).status, "未成交")
phase = flow.on_text(
sender_id="WuJiLin",
text="价格高",
reply=lambda t, extra=None: replies.append(t),
)
self.assertEqual(phase, "done")
self.assertEqual(ledger.get_ticket(work_order_no=wo).lost_reason, "价格高")
self.assertTrue(any(t == "已记下未成交原因:价格高。" for t in replies))
if __name__ == "__main__":
unittest.main()
+28 -1
View File
@@ -1522,9 +1522,36 @@ class SeaGroupOpsTests(unittest.TestCase):
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "done")
self.assertEqual(phase, "wait_lost_reason")
self.assertEqual(self.ledger.get_ticket(work_order_no=wo).status, "未成交")
def test_sales_next_text_is_lost_reason(self) -> None:
wo, chat = self._open_group()
handle_group_collab(
InboundMessage(
sender_id="sales1",
message_id="lost-1",
content="未成交",
chat_id=chat,
chat_type="group",
),
flow=self.flow,
ledger=self.ledger,
)
phase = handle_group_collab(
InboundMessage(
sender_id="sales1",
message_id="lost-2",
content="客人比价了",
chat_id=chat,
chat_type="group",
),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "done")
self.assertEqual(self.ledger.get_ticket(work_order_no=wo).lost_reason, "客人比价了")
def test_sales_can_close_deal_by_text(self) -> None:
"""群里销售说成交,要入账并回话,不能装没听见。相近说法也认。"""
wo, chat = self._open_group()