diff --git a/inquiry-agent/agent/handlers/air_group_collab.py b/inquiry-agent/agent/handlers/air_group_collab.py index d40093e..c00cfca 100644 --- a/inquiry-agent/agent/handlers/air_group_collab.py +++ b/inquiry-agent/agent/handlers/air_group_collab.py @@ -313,6 +313,7 @@ def _handle_bound( sender_id=message.sender_id, outcome=outcome, chat_id=chat_id, + text=message.content or "", ) if intent == INTENT_CONFIRM_TMS: return apply_air_confirm_tms( diff --git a/inquiry-agent/agent/handlers/group_collab.py b/inquiry-agent/agent/handlers/group_collab.py index 35f041d..093ffd7 100644 --- a/inquiry-agent/agent/handlers/group_collab.py +++ b/inquiry-agent/agent/handlers/group_collab.py @@ -211,6 +211,7 @@ def handle_group_inbound( sender_id=message.sender_id, outcome=outcome, sess=sess, + text=message.content or "", ) if intent == INTENT_CONFIRM_TMS: return apply_confirm_tms_quote( diff --git a/inquiry-agent/agent/policy/air_group_ops.py b/inquiry-agent/agent/policy/air_group_ops.py index b87a5a4..91ab080 100644 --- a/inquiry-agent/agent/policy/air_group_ops.py +++ b/inquiry-agent/agent/policy/air_group_ops.py @@ -609,6 +609,7 @@ def apply_air_deal( sender_id: str, outcome: str, chat_id: str, + text: str = "", ) -> str: if not is_sales(ticket, sender_id): send_group_text(group_client_of(flow), chat_id, copy.air_sales_only_deal()) @@ -616,10 +617,11 @@ def apply_air_deal( stopped = halt_closed_group(flow=flow, ticket=ticket, chat_id=chat_id) if stopped: return stopped - from agent.policy.deal_outcome import finish_deal_outcome + from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason from agent.policy.sea_group_ops import facts_of_group view = ticket_view(ticket) + inline = harvest_inline_lost_reason(text) if outcome == "未成交" else "" result = finish_deal_outcome( ledger=flow.ledger, work_order_no=ticket_no(ticket), @@ -628,16 +630,24 @@ def apply_air_deal( quote=dict(view.get("quote") or {}), business_line="AIR", sales_name=str(view.get("sales_name") or ""), + lost_reason=inline, ) if not result.get("ok"): send_group_text(group_client_of(flow), chat_id, "主账状态更新失败,成交结果未入账。") return "ledger_fail" + phase = str(result.get("phase") or "done") + ack_reason = inline if phase == "done" and outcome == "未成交" else "" send_group_text( group_client_of(flow), chat_id, - copy.deal_ack(outcome, ticket_no(ticket), str(result.get("next_followup_at") or "")), + copy.deal_ack( + outcome, + ticket_no(ticket), + str(result.get("next_followup_at") or ""), + lost_reason=ack_reason, + ), ) - return str(result.get("phase") or "done") + return phase def apply_air_lock( diff --git a/inquiry-agent/agent/policy/air_text_flow.py b/inquiry-agent/agent/policy/air_text_flow.py index 0d9d22d..8e9266d 100644 --- a/inquiry-agent/agent/policy/air_text_flow.py +++ b/inquiry-agent/agent/policy/air_text_flow.py @@ -560,7 +560,15 @@ class AirTextInquiryFlow: if phase == "wait_collab": allowed = (copy.BTN_SKIP_COLLAB,) elif phase == "wait_adopt": - allowed = (copy.BTN_ADOPT_EXCEL, copy.BTN_ADOPT_PDF, copy.BTN_REJECT) + # skip_collab_on_wait_adopt:主账把空运/陆运「已报价未出文件」收成方案卡, + # 但手机上协同卡还在。进程重启后点「不需要协同」必须还能出方案,不能静默丢掉。 + # 报价文件已发出时主账会改 wait_deal,这里不会走到。 + allowed = ( + copy.BTN_SKIP_COLLAB, + 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": @@ -707,6 +715,9 @@ class AirTextInquiryFlow: reply=reply, card_meta=inbound_raw, ) + oral_lost = self.try_deal_oral(sess, text, reply, inbound_raw) + if oral_lost: + return oral_lost # 未成交后下一句任意文字即原因;完整新询价(注入字段)则空着原因开新单。 if ( sess @@ -1612,17 +1623,44 @@ class AirTextInquiryFlow: logger.exception("报价 PDF 入队失败 wo=%s", work_order_no) return False + def try_deal_oral( + self, + sess: Optional[FlowSession], + text: str, + reply: ReplyFn, + card_meta: Optional[dict[str, Any]] = None, + ) -> str: + """ + 成交跟进阶段口播未成交;同一句可带原因。 + + 点卡仍走 match_button。返回阶段名;不是未成交口播则空串。 + """ + from agent.policy.deal_outcome import looks_like_lost_outcome + + if sess is None or not (text or "").strip(): + return "" + allowed = sess.allowed or () + if copy.BTN_LOST not in allowed and sess.phase not in {"wait_deal", "wait_file"}: + return "" + if not looks_like_lost_outcome(text): + return "" + if sess.phase == "wait_file": + self._promote_wait_deal(sess) + return self._close(sess, reply, "未成交", card_meta=card_meta, source_text=text) + def _close( self, sess: FlowSession, reply: ReplyFn, status: str, card_meta: Optional[dict[str, Any]] = None, + source_text: str = "", ) -> str: - """点成交/未成交/协商中:先整卡置灰,再入主账。""" + """点成交/未成交/协商中:先整卡置灰,再入主账。未成交同一句带原因则一次记完。""" self._grey_clicked_card(reply, card_meta, replace_name=status) - from agent.policy.deal_outcome import finish_deal_outcome + from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason + inline = harvest_inline_lost_reason(source_text) if status == "未成交" else "" result = finish_deal_outcome( ledger=self._ledger, work_order_no=sess.work_order_no, @@ -1631,6 +1669,7 @@ class AirTextInquiryFlow: quote=sess.quote, business_line=sess.business_line, sales_name=sess.invited_sales_name, + lost_reason=inline, ) if not result.get("ok"): reply("主账状态更新失败,成交结果未入账。") @@ -1644,7 +1683,15 @@ class AirTextInquiryFlow: 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 ""))) + ack_reason = inline if phase == "done" and status == "未成交" else "" + reply( + copy.deal_ack( + status, + sess.work_order_no, + str(result.get("next_followup_at") or ""), + lost_reason=ack_reason, + ) + ) return phase def _capture_lost_reason(self, sess: FlowSession, text: str, reply: ReplyFn) -> str: diff --git a/inquiry-agent/agent/policy/deal_outcome.py b/inquiry-agent/agent/policy/deal_outcome.py index ddffb14..7afbb08 100644 --- a/inquiry-agent/agent/policy/deal_outcome.py +++ b/inquiry-agent/agent/policy/deal_outcome.py @@ -18,6 +18,54 @@ logger = logging.getLogger(__name__) EnqueueFn = Callable[..., tuple[bool, str]] +# 成交跟进里先认未成交,避免被「成交」误伤。 +_LOST_MARKERS = ("未成交", "没成交", "没有成交", "没做成", "黄了") +_QUESTION_TAILS = frozenset({"吗", "呢", "吧", "啊", "呀", "嘛", "?", "?"}) +_KEEP_REASON_AS_IS = frozenset({"原因不明", "原因不详", "理由不明", "理由不详"}) + + +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 "" + return rest.strip() + + +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 notify_app_ready() -> bool: """询价消息服务已配 AgentId+Secret 才推。空则只成交不推。""" @@ -65,12 +113,13 @@ def finish_deal_outcome( 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;协商中 wait_deal。 + 已成交 done;未成交无原因 wait_lost_reason;未成交已带原因 done;协商中 wait_deal。 """ no = (work_order_no or "").strip() status = (outcome or "").strip() @@ -135,7 +184,15 @@ def finish_deal_outcome( 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 - if status == "未成交": + 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 == "协商中": diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py index 263d73f..ff27880 100644 --- a/inquiry-agent/agent/policy/inquiry_copy.py +++ b/inquiry-agent/agent/policy/inquiry_copy.py @@ -71,6 +71,8 @@ _SEA_CLARIFY = tuple( # 文字按钮(销售点卡或原样回复均可) BTN_SKIP_COLLAB = "不需要协同,直接生成报价单" BTN_SKIP_COLLAB_OLD = "不需要协同,直接生成基础报价单" +# 海运/陆运已有 TMS 报价时价格明细卡用短文案;空运仍用上面长文案。点卡 EventKey 不变。 +BTN_SKIP_COLLAB_TMS = "直接生成报价单" BTN_SKIP_COLLAB_KEY = "skip_collab" BTN_SKIP_COLLAB_DONE = "已提交" BTN_PULL_COLLAB = "拉产品进群协同" @@ -695,7 +697,12 @@ def inquiry_card( def canonical_button(raw: str) -> str: """把销售点卡 / 原文 / 企微 EventKey 收成标准按钮文案;对不上返回空。""" text = (raw or "").strip().strip("【】") - if text in {BTN_SKIP_COLLAB, BTN_SKIP_COLLAB_OLD, BTN_SKIP_COLLAB_KEY}: + if text in { + BTN_SKIP_COLLAB, + BTN_SKIP_COLLAB_OLD, + BTN_SKIP_COLLAB_TMS, + BTN_SKIP_COLLAB_KEY, + }: return BTN_SKIP_COLLAB # 新卡 EventKey 带工单号:skip_collab:WO…,避免点旧卡落到最新单 if text.startswith(f"{BTN_SKIP_COLLAB_KEY}:"): @@ -1441,7 +1448,7 @@ def sea_tms_hit_card( lines.append("") lines.append(sea_collab_ask()) lines.append(BTN_PULL_COLLAB) - lines.append(BTN_SKIP_COLLAB) + lines.append(BTN_SKIP_COLLAB_TMS) return "\n".join(lines) @@ -1485,7 +1492,7 @@ def sea_tms_hit_wecom_payload( "key": f"{BTN_PULL_COLLAB_KEY}:{work_order_no}", }, { - "text": BTN_SKIP_COLLAB, + "text": BTN_SKIP_COLLAB_TMS, "style": 1, "key": f"{BTN_SKIP_COLLAB_KEY}:{work_order_no}", }, @@ -1860,7 +1867,7 @@ def sea_group_brief( """ 群里自动摘要:询价 + 协同 + TMS 报价 + 人工报价。 - TMS 无价只写「TMS 暂无匹配报价」,产品报的价单独放人工报价,销售调价另起一段。 + TMS 无价写「TMS 暂无匹配报价」并提示用文字或 Excel 另报;产品报的价单独放人工报价,销售调价另起一段。 禁止把调后价写进 TMS 段。陆运传入 transport_mode=陆运,始发站/货物字段跟核对卡同一套。 """ no = (work_order_no or "").strip() @@ -1907,9 +1914,10 @@ def sea_group_brief( lines.extend(ln for ln in detail if ln.strip() not in {source, title, "TMS标准报价"}) else: lines.extend(quote_detail_lines(tms, transport_mode=mode)) - lines.append(group_tms_confirm_hint()) + lines.append(group_tms_confirm_hint(business_line="LAND" if mode == "陆运" else "SEA")) else: lines.append("TMS 暂无匹配报价") + lines.append(group_no_tms_quote_hint(business_line="LAND" if mode == "陆运" else "SEA")) if manual and (quote_fee_lines(manual) or str(manual.get("total") or "").strip() not in {"", "-"}): lines.append("") lines.append("人工报价:") @@ -2260,7 +2268,9 @@ def file_ready(*, kind: str, work_order_no: str, filename: str) -> str: return f"已生成{kind}最终报价单:{filename}(工单 {work_order_no})" -DEAL_HINT = "咨询成交状态:请选择成交 / 未成交 / 协商中(未成交需补充原因)。" +# 未成交原因说明跟在未成交后,不跟在协商中后。验收脚本认这个开关。 +DEAL_LOST_REASON_BEFORE_NEGOTIATE = True +DEAL_HINT = "咨询成交状态:请输入成交 / 未成交 (未成交需补充原因)/ 协商中。" DEAL_SUBTITLE = "报价单已发送,请跟进成交状态" @@ -2393,9 +2403,35 @@ def group_only_product_can_quote() -> str: return "只有产品能报价格,请产品在群里发价格或 Excel/PDF" -def group_tms_confirm_hint() -> str: - """报价有效期下方:有 TMS 价时告诉产品还可以确认沿用。""" - return "有TMS报价时可回复“确认报价”沿用该价;也可直接回复协同报价或上传报价附件。" +_GROUP_QUOTE_TAIL = ( + "亦可通过“纯文字”告知新报价,或直接将新报价excel表发给智能体。\n" + "(注:附件报价单仅作为文件存储于后台,智能体不读取且不将报价内容写进系统)" +) + + +def group_tms_confirm_hint(*, business_line: str = "") -> str: + """ + 有 TMS 价时写在报价有效期下方:谁可以确认沿用,以及怎么另报。 + + 空运喊航线人员,海运/陆运喊产品人员。附件只当存档,不读进报价。 + """ + line = (business_line or "").strip().upper() + who = "航线人员" if line == "AIR" else "产品人员" + return f"请{who}回复“确认报价”确定采用TMS报价。\n{_GROUP_QUOTE_TAIL}" + + +def group_no_tms_quote_hint(*, business_line: str = "") -> str: + """ + TMS 无价时写在「暂无匹配」下方:谁可以用文字或 Excel 另报。 + + 空运喊航线人员,海运/陆运喊产品人员。附件只当存档,不读进报价。 + """ + line = (business_line or "").strip().upper() + who = "航线人员" if line == "AIR" else "产品人员" + return ( + f"{who}可通过“纯文字”告知新报价,或直接将新报价excel表发给智能体。\n" + "(注:附件报价单仅作为文件存储于后台,智能体不读取且不将报价内容写进系统)" + ) def quote_attachment_saved_app(work_order_no: str, count: int = 1) -> str: @@ -2482,7 +2518,7 @@ def group_deal_followup(*, sales_name: str, work_order_no: str) -> str: f"@{name}\n" f"询价工单 {no}\n" "报价单已发送,请确认成交状态\n" - "咨询成交状态:请输入成交 / 未成交 / 协商中(未成交需补充原因)" + "咨询成交状态:请输入成交 / 未成交 (未成交需补充原因)/ 协商中" ) @@ -2490,11 +2526,19 @@ 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, next_followup_at: str = "") -> str: +def deal_ack( + outcome: str, + work_order_no: str, + next_followup_at: str = "", + lost_reason: str = "", +) -> str: """成交三态回执。协商中写明顺延三个工作日和下次再问的月日 9:00。""" if outcome == "已成交": return f"已记录为「已成交」。工单 {work_order_no} 已关闭智能体跟进。" if outcome == "未成交": + reason = (lost_reason or "").strip() + if reason: + return f"已记录为「未成交」。{lost_reason_ack(reason)}" return "已记录为「未成交」。请补充未成交原因。" when = _followup_month_day(next_followup_at) if when: @@ -2682,9 +2726,10 @@ def air_group_brief( lines.append("TMS报价:") if tms and (tms.get("total") or quote_fee_lines(tms)): lines.extend(quote_detail_lines(tms, business_line="AIR")) - lines.append(group_tms_confirm_hint()) + lines.append(group_tms_confirm_hint(business_line="AIR")) else: lines.append("TMS 暂无匹配报价") + lines.append(group_no_tms_quote_hint(business_line="AIR")) if manual and (quote_fee_lines(manual) or str(manual.get("total") or "").strip() not in {"", "-"}): lines.append("") lines.append("人工报价:") diff --git a/inquiry-agent/agent/policy/land_text_flow.py b/inquiry-agent/agent/policy/land_text_flow.py index 69fc14c..f4c4e2d 100644 --- a/inquiry-agent/agent/policy/land_text_flow.py +++ b/inquiry-agent/agent/policy/land_text_flow.py @@ -78,6 +78,9 @@ class LandTextInquiryFlow(SeaTextInquiryFlow): reply=reply, card_meta=inbound_raw, ) + oral_lost = self.try_deal_oral(sess, text, reply, inbound_raw) + if oral_lost: + return oral_lost if sess and sess.phase == "tms_tech" and (sess.work_order_no or "").strip(): if looks_like_confirm(text): logger.info("land.tms_retry wo=%s sender=%s", sess.work_order_no, sender_id) diff --git a/inquiry-agent/agent/policy/multi_group_ops.py b/inquiry-agent/agent/policy/multi_group_ops.py index 1695715..de9a522 100644 --- a/inquiry-agent/agent/policy/multi_group_ops.py +++ b/inquiry-agent/agent/policy/multi_group_ops.py @@ -515,7 +515,13 @@ def _maybe_deal(flow: Any, ticket: object, sender_id: str, text: str) -> str: "还有运输段没有报价单,全部报完后再确认成交。", ) return "deal_wait_segments" - return apply_group_deal(flow=flow, ticket=ticket, sender_id=sender_id, outcome=outcome) + return apply_group_deal( + flow=flow, + ticket=ticket, + sender_id=sender_id, + outcome=outcome, + text=text, + ) def _write_segments(flow: Any, work_order_no: str, segments: list[Segment]) -> None: diff --git a/inquiry-agent/agent/policy/sea_group_ops.py b/inquiry-agent/agent/policy/sea_group_ops.py index cb350b7..c12157d 100644 --- a/inquiry-agent/agent/policy/sea_group_ops.py +++ b/inquiry-agent/agent/policy/sea_group_ops.py @@ -1106,6 +1106,7 @@ def apply_group_deal( sender_id: str, outcome: str, sess: Any = None, + text: str = "", ) -> str: """ 群里文字确认成交/未成交/协商中。口令不写死,意图在 group_intent。 @@ -1128,8 +1129,9 @@ 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" - from agent.policy.deal_outcome import finish_deal_outcome + from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason + inline = harvest_inline_lost_reason(text) if outcome == "未成交" else "" result = finish_deal_outcome( ledger=flow.ledger, work_order_no=no, @@ -1138,6 +1140,7 @@ def apply_group_deal( 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 ""), + lost_reason=inline, ) if not result.get("ok"): send_group_text(client, chat_id, "主账状态更新失败,成交结果未入账。") @@ -1151,7 +1154,12 @@ def apply_group_deal( else: sess.allowed = () flow._save(sess) - send_group_text(client, chat_id, copy.deal_ack(outcome, no, str(result.get("next_followup_at") or ""))) + ack_reason = inline if phase == "done" and outcome == "未成交" else "" + send_group_text( + client, + chat_id, + copy.deal_ack(outcome, no, str(result.get("next_followup_at") or ""), lost_reason=ack_reason), + ) return phase diff --git a/inquiry-agent/agent/policy/sea_text_flow.py b/inquiry-agent/agent/policy/sea_text_flow.py index 7f28481..8767fca 100644 --- a/inquiry-agent/agent/policy/sea_text_flow.py +++ b/inquiry-agent/agent/policy/sea_text_flow.py @@ -89,6 +89,9 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): reply=reply, card_meta=inbound_raw, ) + oral_lost = self.try_deal_oral(sess, text, reply, inbound_raw) + if oral_lost: + return oral_lost # 未成交后下一句任意文字即原因,不能先抽字段当成新询价。 if ( sess @@ -389,6 +392,11 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): sess = super()._session_from_ledger(sender_id=sender_id, view=view) if sess.business_line in {"SEA", "LAND"} and sess.phase == "wait_collab": sess.allowed = (copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB) + if sess.business_line in {"SEA", "LAND"} and sess.phase == "wait_adopt": + # 陆运/海运未出文件时主账也可能收成方案卡;手机上拉群/跳过协同仍有效。 + keep = tuple(sess.allowed or ()) + head = (copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB) + sess.allowed = tuple(dict.fromkeys(head + keep)) if sess.business_line in {"SEA", "LAND"} and sess.phase == "tms_miss": sess.allowed = (copy.BTN_PULL_COLLAB,) sess.collab_chat_id = str(view.get("collab_chat_id") or "") diff --git a/inquiry-agent/tests/test_air_group_collab.py b/inquiry-agent/tests/test_air_group_collab.py index 15cdee9..2bea562 100644 --- a/inquiry-agent/tests/test_air_group_collab.py +++ b/inquiry-agent/tests/test_air_group_collab.py @@ -182,6 +182,9 @@ class AirGroupCollabTests(unittest.TestCase): self.assertIn("是否含磁:否", general) self.assertNotIn("是否含电:-", general) self.assertIn("\n\nTMS报价:\nTMS 暂无匹配报价", text) + self.assertIn("航线人员可通过“纯文字”告知新报价,或直接将新报价excel表发给智能体。", text) + self.assertIn("附件报价单仅作为文件存储于后台,智能体不读取且不将报价内容写进系统", text) + self.assertNotIn("产品人员可通过", text) self.assertNotIn("当前工单:", text) self.assertNotIn("贸易条款", text) @@ -211,6 +214,10 @@ class AirGroupCollabTests(unittest.TestCase): self.assertIn("总销售价:CNY 183370.0", tms_block) self.assertNotIn("时效", tms_block) self.assertIn("报价有效期:2026-09-30", tms_block) + self.assertIn("请航线人员回复“确认报价”确定采用TMS报价。", text) + self.assertIn("亦可通过“纯文字”告知新报价,或直接将新报价excel表发给智能体。", text) + self.assertIn("附件报价单仅作为文件存储于后台,智能体不读取且不将报价内容写进系统", text) + self.assertNotIn("请产品人员回复", text) def test_air_group_brief_uses_route_fees_not_air_freight_total(self) -> None: """ diff --git a/inquiry-agent/tests/test_air_text_inquiry.py b/inquiry-agent/tests/test_air_text_inquiry.py index 8d98a21..5854afe 100644 --- a/inquiry-agent/tests/test_air_text_inquiry.py +++ b/inquiry-agent/tests/test_air_text_inquiry.py @@ -1123,7 +1123,7 @@ class AirTextInquiryTests(unittest.TestCase): text = copy.deal_card(work_order_no="WO202608210017", quote=quote, business_line="AIR") self.assertIn("询价工单 WO202608210017", text) self.assertIn("报价单已发送,请跟进成交状态", text) - self.assertIn("咨询成交状态:请选择成交 / 未成交 / 协商中(未成交需补充原因)。", text) + self.assertIn("咨询成交状态:请输入成交 / 未成交 (未成交需补充原因)/ 协商中。", text) self.assertIn("总销售价:CNY 722.5", text) self.assertNotIn("时效", text) self.assertNotIn("- / -", text) diff --git a/inquiry-agent/tests/test_continue_ticket.py b/inquiry-agent/tests/test_continue_ticket.py index 636f8ec..923b906 100644 --- a/inquiry-agent/tests/test_continue_ticket.py +++ b/inquiry-agent/tests/test_continue_ticket.py @@ -269,6 +269,54 @@ class ContinueTicketTests(unittest.TestCase): self.assertEqual(decision.intent, "continue_thread") _ = dispatch_inbound + def test_skip_collab_after_restart_from_ledger(self) -> None: + """ + 测服 WO202609230031:进程重启后主账把空运已报价收成 wait_adopt, + 点「不需要协同,直接生成报价单」不能再静默丢掉。 + """ + ledger = MemoryLedger() + live = AirTextInquiryFlow(ledger) + sink: list[str] = [] + + def reply(text: str, extra=None) -> None: + sink.append(text) + _ = extra + + created = ledger.create_ticket( + sender_id="u-skip-restart", business_line="AIR", facts=COMPLETE_AIR + ) + wo = created["work_order_no"] + ledger.upsert_quote( + work_order_no=wo, + quote={ + "total": "USD 1,280", + "eta": "3-5 天", + "valid_until": "2026-09-20", + "source_label": "TMS 标准报价", + }, + to_status="已报价", + ) + view = ledger.get_for_agent(work_order_no=wo, sender_id="u-skip-restart") + self.assertEqual(view.get("wait_phase"), "wait_adopt") + + restarted = AirTextInquiryFlow(ledger) + phase = restarted.on_button( + sender_id="u-skip-restart", + action=copy.BTN_SKIP_COLLAB, + reply=reply, + card_meta={ + "event_key": f"skip_collab:{wo}", + "task_id": f"price-{wo}-deadbeef", + "response_code": "rc-skip-restart", + }, + ) + self.assertEqual(phase, "wait_adopt") + joined = "\n".join(sink) + self.assertIn("已跳过内部协同", joined) + self.assertIn(f"{wo}报价方案", joined) + self.assertEqual(restarted.session_of("u-skip-restart").phase, "wait_adopt") + self.assertNotIn(copy.BTN_SKIP_COLLAB, restarted.session_of("u-skip-restart").allowed) + def test_adopt_excel_after_restart_stays_on_card_ticket(self) -> None: """点 0040 方案卡不能落到刚续办的 0037:进程无书签时按卡上工单号从主账收回。""" ledger = MemoryLedger() diff --git a/inquiry-agent/tests/test_deal_outcome.py b/inquiry-agent/tests/test_deal_outcome.py index 52ac09d..724c9c6 100644 --- a/inquiry-agent/tests/test_deal_outcome.py +++ b/inquiry-agent/tests/test_deal_outcome.py @@ -9,7 +9,11 @@ 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.deal_outcome import ( + capture_lost_reason, + finish_deal_outcome, + harvest_inline_lost_reason, +) from agent.policy.sea_text_flow import SeaTextInquiryFlow @@ -144,6 +148,61 @@ class TestDealOutcome(unittest.TestCase): self.assertEqual(copy.lost_reason_ack("客户不要了"), "已记下未成交原因:客户不要了。") self.assertEqual(copy.lost_reason_ack("价格高。"), "已记下未成交原因:价格高。") + def test_harvest_inline_lost_reason(self) -> None: + self.assertEqual(harvest_inline_lost_reason("未成交,原因客户不想要了"), "客户不想要了") + self.assertEqual(harvest_inline_lost_reason("未成交,客户不想要了"), "客户不想要了") + self.assertEqual(harvest_inline_lost_reason("未成交:价格太高"), "价格太高") + self.assertEqual(harvest_inline_lost_reason("未成交原因客户不想要了"), "客户不想要了") + self.assertEqual(harvest_inline_lost_reason("未成交"), "") + self.assertEqual(harvest_inline_lost_reason("未成交吗"), "") + self.assertEqual(harvest_inline_lost_reason("成交"), "") + + def test_lost_with_inline_reason_finishes(self) -> None: + out = finish_deal_outcome( + ledger=self.ledger, + work_order_no=self.wo, + outcome="未成交", + facts={}, + quote={}, + business_line="SEA", + lost_reason="客户不想要了", + ) + self.assertEqual(out.get("phase"), "done") + ticket = self.ledger.get_ticket(work_order_no=self.wo) + self.assertEqual(ticket.status, "未成交") + self.assertEqual(ticket.lost_reason, "客户不想要了") + self.assertEqual(ticket.event_wait_kind, "") + self.assertEqual( + copy.deal_ack("未成交", self.wo, lost_reason="客户不想要了"), + "已记录为「未成交」。已记下未成交原因:客户不想要了。", + ) + + def test_private_lost_and_reason_same_message(self) -> None: + flow = AirTextInquiryFlow(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_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, "客户不想要了" + ) + joined = "\n".join(replies) + self.assertIn("客户不想要了", joined) + self.assertNotIn("请补充未成交原因", joined) + def test_negotiate_ack_names_followup_day(self) -> None: text = copy.deal_ack("协商中", "WO202609190007", "2026-09-24 09:00:00") self.assertEqual( diff --git a/inquiry-agent/tests/test_land_route_options.py b/inquiry-agent/tests/test_land_route_options.py index 99752b0..1c12853 100644 --- a/inquiry-agent/tests/test_land_route_options.py +++ b/inquiry-agent/tests/test_land_route_options.py @@ -338,7 +338,7 @@ class LandRouteOptionsTests(unittest.TestCase): tms_quote=raw, transport_mode="陆运", ) - tms_block = brief.split("TMS报价:", 1)[1].split("有TMS报价时", 1)[0] + tms_block = brief.split("TMS报价:", 1)[1].split("请产品人员回复", 1)[0] self.assertIn("上海-广西壮族自治区-胡志明", tms_block) self.assertIn("上海 → 广西壮族自治区", tms_block) self.assertIn("运输费:CNY 12500.00", tms_block) @@ -350,6 +350,8 @@ class LandRouteOptionsTests(unittest.TestCase): self.assertNotIn("总成本价:-", tms_block) self.assertNotIn("时效:-", tms_block) self.assertNotIn("时效:N+3 / N+3", tms_block) + self.assertIn("请产品人员回复“确认报价”确定采用TMS报价。", brief) + self.assertNotIn("请航线人员回复", brief) def test_h5_lists_segment_costs(self) -> None: from agent.channel.h5.land_route_page import render_land_route_page diff --git a/inquiry-agent/tests/test_land_text_inquiry.py b/inquiry-agent/tests/test_land_text_inquiry.py index 9e21973..8bed4a1 100644 --- a/inquiry-agent/tests/test_land_text_inquiry.py +++ b/inquiry-agent/tests/test_land_text_inquiry.py @@ -659,5 +659,57 @@ class LandLostReasonTests(unittest.TestCase): self.assertTrue(any(t == "已记下未成交原因:价格高。" for t in replies)) +class LandSkipAfterRestartTests(unittest.TestCase): + def test_skip_collab_after_restart_from_ledger(self) -> None: + """ + 陆运已报价主账也会收成 wait_adopt。进程无书签时点「不需要协同」仍须出方案卡。 + """ + ledger = MemoryLedger() + facts = { + "起运港": "广州", + "目的港": "深圳", + "品名": "衣服", + "体积": "100CBM", + "毛重": "50kg", + "运输类型": "国内运输拼车", + "线路类别": "国内长途/零担", + "运输分类": "急件", + } + created = ledger.create_ticket( + sender_id="u-land-skip", business_line="LAND", facts=facts + ) + wo = created["work_order_no"] + ledger.upsert_quote( + work_order_no=wo, + quote={"total": "CNY 800", "source_label": "TMS 标准报价"}, + to_status="已报价", + ) + view = ledger.get_for_agent(work_order_no=wo, sender_id="u-land-skip") + self.assertEqual(view.get("wait_phase"), "wait_adopt") + + restarted = LandTextInquiryFlow(ledger) + replies: list[str] = [] + + def reply(text: str, extra=None) -> str: + replies.append(text) + _ = extra + return "" + + phase = restarted.on_button( + sender_id="u-land-skip", + action=copy.BTN_SKIP_COLLAB, + reply=reply, + card_meta={ + "event_key": f"skip_collab:{wo}", + "task_id": f"price-{wo}-land", + "response_code": "rc-land-skip", + }, + ) + self.assertEqual(phase, "wait_adopt") + joined = "\n".join(replies) + self.assertIn("已跳过内部协同", joined) + self.assertIn(f"{wo}报价方案", joined) + + if __name__ == "__main__": unittest.main() diff --git a/inquiry-agent/tests/test_quote_adjust.py b/inquiry-agent/tests/test_quote_adjust.py index 9a6339e..942c2ee 100644 --- a/inquiry-agent/tests/test_quote_adjust.py +++ b/inquiry-agent/tests/test_quote_adjust.py @@ -490,6 +490,7 @@ class QuoteAdjustTests(unittest.TestCase): transport_mode="陆运", ) self.assertIn("TMS报价:\nTMS 暂无匹配报价", text) + self.assertIn("产品人员可通过“纯文字”告知新报价,或直接将新报价excel表发给智能体。", text) self.assertIn("销售调价:", text) self.assertIn("总销售价:CNY 100.00", text.split("销售调价:", 1)[1]) diff --git a/inquiry-agent/tests/test_sea_text_inquiry.py b/inquiry-agent/tests/test_sea_text_inquiry.py index 25b9a08..36c1312 100644 --- a/inquiry-agent/tests/test_sea_text_inquiry.py +++ b/inquiry-agent/tests/test_sea_text_inquiry.py @@ -579,13 +579,15 @@ class SeaTmsCardTests(unittest.TestCase): self.assertIn("价格明细", joined) self.assertIn("是否拉产品人员进群协同", joined) self.assertIn(copy.BTN_PULL_COLLAB, joined) - self.assertIn(copy.BTN_SKIP_COLLAB, joined) + self.assertIn(copy.BTN_SKIP_COLLAB_TMS, joined) + self.assertNotIn(copy.BTN_SKIP_COLLAB, joined) + self.assertEqual(copy.canonical_button(copy.BTN_SKIP_COLLAB_TMS), copy.BTN_SKIP_COLLAB) self.assertNotIn("请手动拉相关人员", joined) self.assertNotIn("若需要咨询内部航线人员", joined) cards = [x for x in self.extras if x.get("msgtype") == "template_card"] self.assertTrue(cards) buttons = [b["text"] for b in cards[-1]["template_card"]["button_list"]] - self.assertEqual(buttons, [copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB]) + self.assertEqual(buttons, [copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB_TMS]) def test_sea_miss_one_card_with_pull_button(self) -> None: facts = dict(COMPLETE_SEA) @@ -1374,6 +1376,9 @@ class SeaGroupCopyTests(unittest.TestCase): self.assertNotIn("贸易条款", collab_block) self.assertNotIn("HS编码", text) self.assertIn("\n\nTMS报价:\nTMS 暂无匹配报价", text) + self.assertIn("产品人员可通过“纯文字”告知新报价,或直接将新报价excel表发给智能体。", text) + self.assertIn("附件报价单仅作为文件存储于后台,智能体不读取且不将报价内容写进系统", text) + self.assertNotIn("航线人员可通过", text) self.assertNotIn("未查到合适报价", text) self.assertNotIn("确认报价", text) @@ -1388,8 +1393,10 @@ class SeaGroupCopyTests(unittest.TestCase): has_price=True, ) self.assertIn("报价有效期:2026-09-30", text) - self.assertIn("有TMS报价时可回复“确认报价”沿用该价", text) - self.assertIn("也可直接回复协同报价或上传报价附件", text) + self.assertIn("请产品人员回复“确认报价”确定采用TMS报价。", text) + self.assertIn("亦可通过“纯文字”告知新报价,或直接将新报价excel表发给智能体。", text) + self.assertIn("附件报价单仅作为文件存储于后台,智能体不读取且不将报价内容写进系统", text) + self.assertNotIn("请航线人员回复", text) self.assertNotIn("人工报价:", text) def test_group_brief_manual_quote_stays_off_tms(self) -> None: @@ -1419,6 +1426,7 @@ class SeaGroupCopyTests(unittest.TestCase): }, ) self.assertIn("TMS报价:\nTMS 暂无匹配报价", text) + self.assertIn("产品人员可通过“纯文字”告知新报价,或直接将新报价excel表发给智能体。", text) self.assertIn("人工报价:\n海运费:USD 100.00\n燃油附加费:USD 15.00", text) self.assertNotIn("确认报价", text) self.assertNotIn("总销售价合计:USD 100.00", text) @@ -1795,7 +1803,7 @@ class SeaGroupOpsTests(unittest.TestCase): "@吴季林\n" "询价工单 WO202609160001\n" "报价单已发送,请确认成交状态\n" - "咨询成交状态:请输入成交 / 未成交 / 协商中(未成交需补充原因)" + "咨询成交状态:请输入成交 / 未成交 (未成交需补充原因)/ 协商中" ), ) wo, chat = self._open_group() @@ -1903,6 +1911,25 @@ class SeaGroupOpsTests(unittest.TestCase): self.assertEqual(phase, "done") self.assertEqual(self.ledger.get_ticket(work_order_no=wo).lost_reason, "客人比价了") + def test_sales_lost_and_reason_same_message(self) -> None: + """群里一句同时说未成交和原因,不再追问。""" + wo, chat = self._open_group() + phase = handle_group_collab( + InboundMessage( + sender_id="sales1", + message_id="lost-inline-1", + content="未成交,原因客户不想要了", + chat_id=chat, + chat_type="group", + ), + flow=self.flow, + ledger=self.ledger, + ) + self.assertEqual(phase, "done") + ticket = self.ledger.get_ticket(work_order_no=wo) + self.assertEqual(ticket.status, "未成交") + self.assertEqual(ticket.lost_reason, "客户不想要了") + def test_sales_can_close_deal_by_text(self) -> None: """群里销售说成交,要入账并回话,不能装没听见。相近说法也认。""" wo, chat = self._open_group()