diff --git a/inquiry-agent/agent/jobs/deal_followup_poll.py b/inquiry-agent/agent/jobs/deal_followup_poll.py index bf7a7ad..1a41039 100644 --- a/inquiry-agent/agent/jobs/deal_followup_poll.py +++ b/inquiry-agent/agent/jobs/deal_followup_poll.py @@ -39,14 +39,14 @@ def poll_due_negotiate(*, ledger: Any = None, settings: Optional[Settings] = Non if not isinstance(row, dict): continue try: - if _reask_one(row, settings=cfg): + if _reask_one(row, settings=cfg, ledger=book): 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: +def _reask_one(row: dict[str, Any], *, settings: Settings, ledger: Any = None) -> bool: no = str(row.get("work_order_no") or row.get("workOrderNo") or "").strip() if not no: return False @@ -68,7 +68,9 @@ def _reask_one(row: dict[str, Any], *, settings: Settings) -> bool: ) if not ok: logger.warning("协商再问原群失败且不补发私聊 wo=%s chat=%s", no, chat_id) - return ok + return False + _start_deal_wait(ledger, no) + return True if not sales: logger.warning("协商再问无销售 userid wo=%s", no) return False @@ -84,7 +86,16 @@ def _reask_one(row: dict[str, Any], *, settings: Settings) -> bool: ) if not ok: logger.warning("协商再问私聊入队失败 wo=%s info=%s", no, info) - return bool(ok) + return False + _start_deal_wait(ledger, no) + return True + + +def _start_deal_wait(ledger: Any, work_order_no: str) -> None: + """再问发出后重新盯成交跟进。ledger 可空。""" + from agent.policy.event_exception import WAIT_DEAL, start_wait + + start_wait(ledger, work_order_no=work_order_no, wait_kind=WAIT_DEAL) def _send_group_reask( diff --git a/inquiry-agent/agent/jobs/pdf_job.py b/inquiry-agent/agent/jobs/pdf_job.py index ea43fd3..a6cd4af 100644 --- a/inquiry-agent/agent/jobs/pdf_job.py +++ b/inquiry-agent/agent/jobs/pdf_job.py @@ -163,7 +163,14 @@ def _deliver_quote_file(data: dict[str, Any], result: LibreOfficeResult) -> None if wo: try: from agent.policy.air_text_flow import get_air_text_flow + from agent.policy.event_exception import WAIT_DEAL, start_wait + flow = get_air_text_flow() + start_wait( + getattr(flow, "_ledger", None) or getattr(flow, "ledger", None), + work_order_no=wo, + wait_kind=WAIT_DEAL, + ) get_air_text_flow().mark_wait_deal(touser, wo) except Exception: logger.exception("PDF 出文件后未能切到成交跟进 inquiry=%s", wo) diff --git a/inquiry-agent/agent/policy/air_group_ops.py b/inquiry-agent/agent/policy/air_group_ops.py index 541c272..ebe939c 100644 --- a/inquiry-agent/agent/policy/air_group_ops.py +++ b/inquiry-agent/agent/policy/air_group_ops.py @@ -138,6 +138,9 @@ def activate_ticket( if speak: send_air_brief_and_sheet(flow=flow, ticket=ticket, chat_id=chat_id) _watch_archive_room(chat_id, no) + from agent.policy.event_exception import WAIT_AIR_QUOTE, start_wait + + start_wait(flow.ledger, work_order_no=no, wait_kind=WAIT_AIR_QUOTE) return ticket, "activated" @@ -447,6 +450,9 @@ def apply_air_file( return "ledger_fail" view["collab_chat_id"] = chat_id view["quote"] = quote + from agent.policy.event_exception import clear_wait + + clear_wait(flow.ledger, work_order_no=no) emit_group_deal(flow=flow, ticket=view, quote=quote) return "airline_file_quote" @@ -532,6 +538,9 @@ def apply_air_confirm_tms(*, flow: Any, ticket: object, sender_id: str, chat_id: business_line="AIR", ) notify_quote_attachment_saved(flow, chat_id, no, count=1) + from agent.policy.event_exception import clear_wait + + clear_wait(flow.ledger, work_order_no=no) emit_group_deal(flow=flow, ticket=view, quote=quote) return "quote_confirmed" @@ -802,6 +811,9 @@ def _commit_quote( if not up.get("ok"): send_group_text(group_client_of(flow), chat_id, "主账写入报价失败。") return "ledger_fail" + from agent.policy.event_exception import clear_wait + + clear_wait(flow.ledger, work_order_no=no) ticket2 = current_ticket(flow, chat_id) or ticket view = ticket_view(ticket2) view["collab_chat_id"] = chat_id diff --git a/inquiry-agent/agent/policy/air_text_flow.py b/inquiry-agent/agent/policy/air_text_flow.py index 24bcf6d..92c13d8 100644 --- a/inquiry-agent/agent/policy/air_text_flow.py +++ b/inquiry-agent/agent/policy/air_text_flow.py @@ -1076,6 +1076,9 @@ class AirTextInquiryFlow: return self._skip_collab(sess, reply, card_meta=card_meta) if action == copy.BTN_REJECT: self._grey_clicked_card(reply, card_meta) + from agent.policy.event_exception import clear_wait + + clear_wait(self._ledger, work_order_no=sess.work_order_no) sess.phase = "wait_adjust" sess.allowed = () sess.wait_version += 1 @@ -1128,6 +1131,9 @@ class AirTextInquiryFlow: transport_mode=mode, ), ) + from agent.policy.event_exception import WAIT_ADOPT, start_wait + + start_wait(self._ledger, work_order_no=sess.work_order_no, wait_kind=WAIT_ADOPT) return "wait_adopt" def _grey_clicked_card( @@ -1186,6 +1192,9 @@ class AirTextInquiryFlow: reply(str(result.get("message") or copy.quote_template_missing())) return "wait_adopt" filename = str(result.get("fileName") or f"{sess.work_order_no}_quote.{fmt}") + from agent.policy.event_exception import clear_wait + + clear_wait(self._ledger, work_order_no=sess.work_order_no) file_extra = self._quote_file_payload(result, filename=filename) if kind == "PDF" and result.get("needsPdf"): queued = self._enqueue_quote_pdf( @@ -1264,6 +1273,9 @@ class AirTextInquiryFlow: sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE) sess.wait_version += 1 self._save(sess) + from agent.policy.event_exception import WAIT_DEAL, start_wait + + start_wait(self._ledger, work_order_no=sess.work_order_no, wait_kind=WAIT_DEAL) return "wait_deal" def _emit_deal(self, reply: ReplyFn, sess: FlowSession) -> None: @@ -1423,6 +1435,9 @@ class AirTextInquiryFlow: sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE) sess.wait_version += 1 self._save(sess) + from agent.policy.event_exception import WAIT_DEAL, start_wait + + start_wait(self._ledger, work_order_no=sess.work_order_no, wait_kind=WAIT_DEAL) def _refresh_wait_file(self, sess: FlowSession) -> None: """ diff --git a/inquiry-agent/agent/policy/deal_outcome.py b/inquiry-agent/agent/policy/deal_outcome.py index 8ce841e..ddffb14 100644 --- a/inquiry-agent/agent/policy/deal_outcome.py +++ b/inquiry-agent/agent/policy/deal_outcome.py @@ -133,12 +133,17 @@ def finish_deal_outcome( result = writer(work_order_no=no, to_status=status, **extra) or {} if not result.get("ok"): return {"ok": False, "error": result.get("error") or "transition_failed", "phase": "ledger_fail"} + from agent.policy.event_exception import WAIT_LOST_REASON, clear_wait, start_wait + if 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, @@ -157,4 +162,9 @@ def capture_lost_reason(*, ledger: Any, work_order_no: str, reason: str) -> dict 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} + out = patcher(work_order_no=no, lost_reason=text) or {"ok": False} + if out.get("ok"): + from agent.policy.event_exception import clear_wait + + clear_wait(ledger, work_order_no=no) + return out diff --git a/inquiry-agent/agent/policy/handoff_human.py b/inquiry-agent/agent/policy/handoff_human.py index 2074cae..646ebd9 100644 --- a/inquiry-agent/agent/policy/handoff_human.py +++ b/inquiry-agent/agent/policy/handoff_human.py @@ -273,6 +273,9 @@ def try_commit_air_group_handoff(flow: Any, *, chat_id: str, work_order_no: str) else copy.HANDOFF_INTENT_REASON, ) sess.status = HANDOFF_STATUS + from agent.policy.event_exception import clear_wait + + clear_wait(book, work_order_no=real) sess.handoff_committed = True sess.handoff_offer = "" sess.phase = "handoff_done" diff --git a/inquiry-agent/agent/policy/sea_group_ops.py b/inquiry-agent/agent/policy/sea_group_ops.py index 410f7b6..736b324 100644 --- a/inquiry-agent/agent/policy/sea_group_ops.py +++ b/inquiry-agent/agent/policy/sea_group_ops.py @@ -1028,6 +1028,9 @@ def emit_group_deal( ) if callable(carder): carder(chat_id=chat_id, template_card=payload.get("template_card") or {}) + from agent.policy.event_exception import WAIT_DEAL, start_wait + + start_wait(getattr(flow, "ledger", None), work_order_no=no, wait_kind=WAIT_DEAL) def apply_group_deal( @@ -1485,6 +1488,10 @@ def _save_product_quote( saved = writer(work_order_no=no, quote=persist, to_status="已报价") or {} if not saved.get("ok") and saved.get("error"): logger.warning("产品报价未入主账 wo=%s err=%s", no, saved.get("error")) + elif (kind or "").strip() != "adjust": + from agent.policy.event_exception import clear_wait + + clear_wait(flow.ledger, work_order_no=no) if sess is not None: sess.quote = dict(quote) sess.status = "已报价" diff --git a/inquiry-agent/agent/policy/sea_text_flow.py b/inquiry-agent/agent/policy/sea_text_flow.py index 631c306..7f28481 100644 --- a/inquiry-agent/agent/policy/sea_text_flow.py +++ b/inquiry-agent/agent/policy/sea_text_flow.py @@ -670,6 +670,10 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): sess.wait_version += 1 self._save(sess) self._grey_clicked_card(reply, card_meta) + from agent.policy.event_exception import WAIT_PRODUCT_QUOTE, start_wait + + # 系统建群成功:开始盯产品正式报价。 + start_wait(self._ledger, work_order_no=no, wait_kind=WAIT_PRODUCT_QUOTE) reply( copy.sea_group_created( work_order_no=no, @@ -811,6 +815,9 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): product_ids=product_ids, ) sess.status = HANDOFF_STATUS + from agent.policy.event_exception import clear_wait + + clear_wait(self._ledger, work_order_no=real) sess.collab_chat_id = chat_id sess.handoff_committed = True sess.handoff_offer = "" diff --git a/inquiry-agent/tests/test_air_group_collab.py b/inquiry-agent/tests/test_air_group_collab.py index ded89e8..87044c3 100644 --- a/inquiry-agent/tests/test_air_group_collab.py +++ b/inquiry-agent/tests/test_air_group_collab.py @@ -205,6 +205,9 @@ class AirGroupCollabTests(unittest.TestCase): ledger=self.ledger, ) self.assertEqual(phase, "activated") + ticket = self.ledger.get_ticket(work_order_no=wo1) + self.assertEqual(ticket.event_wait_kind, "air_quote") + self.assertTrue(ticket.event_wait_started_at) joined = "\n".join(self._texts()) self.assertIn(f"工单号:{wo1}", joined) self.assertIn("询价字段:", joined) @@ -386,6 +389,8 @@ class AirGroupCollabTests(unittest.TestCase): ) self.assertEqual(phase, "quoted") ticket = self.ledger.get_ticket(work_order_no=wo) + self.assertEqual(ticket.event_wait_kind, "deal") + self.assertTrue(ticket.event_wait_started_at) self.assertEqual(ticket.status, "已报价") self.assertEqual(ticket.quote.get("total"), "USD 900") joined = "\n".join(self._texts()) diff --git a/inquiry-agent/tests/test_deal_outcome.py b/inquiry-agent/tests/test_deal_outcome.py index ff82d84..52ac09d 100644 --- a/inquiry-agent/tests/test_deal_outcome.py +++ b/inquiry-agent/tests/test_deal_outcome.py @@ -67,6 +67,7 @@ class TestDealOutcome(unittest.TestCase): ticket = self.ledger.get_ticket(work_order_no=self.wo) self.assertEqual(ticket.status, "已成交") self.assertEqual(ticket.next_followup_at, "") + self.assertEqual(ticket.event_wait_kind, "") def test_deal_notify_card_omits_sales_label(self) -> None: """成交通知不要「销售」二字:标题副标和正文都不写。""" @@ -126,6 +127,7 @@ class TestDealOutcome(unittest.TestCase): 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, "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( @@ -134,6 +136,7 @@ class TestDealOutcome(unittest.TestCase): self.assertTrue(captured.get("ok")) ticket = self.ledger.get_ticket(work_order_no=self.wo) self.assertEqual(ticket.lost_reason, "客人比价了") + self.assertEqual(ticket.event_wait_kind, "") view = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1") self.assertEqual(view.get("wait_phase"), "done") @@ -150,6 +153,7 @@ class TestDealOutcome(unittest.TestCase): self.assertNotIn("跟进周期已顺延", text) def test_negotiate_keeps_wait_deal(self) -> None: + self.ledger.start_event_wait(work_order_no=self.wo, wait_kind="deal") out = finish_deal_outcome( ledger=self.ledger, work_order_no=self.wo, @@ -161,6 +165,7 @@ class TestDealOutcome(unittest.TestCase): self.assertEqual(out.get("phase"), "wait_deal") ticket = self.ledger.get_ticket(work_order_no=self.wo) self.assertEqual(ticket.status, "协商中") + self.assertEqual(ticket.event_wait_kind, "") 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") @@ -245,3 +250,39 @@ class TestDealOutcome(unittest.TestCase): 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, "") + + def test_reask_starts_deal_wait(self) -> None: + """协商再问发出后,重新盯销售成交跟进。""" + import importlib.util + import os + + path = os.path.join( + os.path.dirname(__file__), "..", "agent", "jobs", "deal_followup_poll.py" + ) + spec = importlib.util.spec_from_file_location("deal_followup_poll_ee", path) + self.assertIsNotNone(spec) + self.assertIsNotNone(spec.loader) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + class Cfg: + wecom_corp_id = "" + wecom_secret = "" + wecom_agent_id = "0" + + mod._send_group_reask = lambda **kwargs: True + ok = mod._reask_one( + { + "work_order_no": self.wo, + "collab_chat_id": "wr1", + "sales_name": "吴吉林", + "facts": {}, + "quote": {}, + "business_line": "SEA", + }, + settings=Cfg(), + ledger=self.ledger, + ) + self.assertTrue(ok) + ticket = self.ledger.get_ticket(work_order_no=self.wo) + self.assertEqual(ticket.event_wait_kind, "deal") diff --git a/inquiry-agent/tests/test_sea_text_inquiry.py b/inquiry-agent/tests/test_sea_text_inquiry.py index e124309..f05aa25 100644 --- a/inquiry-agent/tests/test_sea_text_inquiry.py +++ b/inquiry-agent/tests/test_sea_text_inquiry.py @@ -676,6 +676,9 @@ class SeaSkipAndPullTests(unittest.TestCase): card_meta={"response_code": "rc-sea-1"}, ) self.assertEqual(phase, "wait_adopt") + ticket = self.ledger.get_ticket(work_order_no=self.flow.session_of("s1").work_order_no) + self.assertEqual(ticket.event_wait_kind, "adopt") + self.assertTrue(ticket.event_wait_started_at) self.assertIn("已跳过内部协同", "\n".join(self.replies)) scheme = "\n".join(x for x in self.replies if "报价方案" in x) self.assertIn("运输方式:海运", scheme) @@ -722,6 +725,9 @@ class SeaSkipAndPullTests(unittest.TestCase): card_meta={"response_code": "rc-pull-1"}, ) self.assertEqual(phase, "collab_group") + ticket = self.ledger.get_ticket(work_order_no=wo) + self.assertEqual(ticket.event_wait_kind, "product_quote") + self.assertTrue(ticket.event_wait_started_at) joined = "\n".join(self.replies) self.assertIn("协同群已创建", joined) self.assertIn(wo, joined) @@ -1587,6 +1593,8 @@ class SeaGroupOpsTests(unittest.TestCase): ) self.assertEqual(phase, "product_file_quote") ticket = self.ledger.get_ticket(work_order_no=wo) + self.assertEqual(ticket.event_wait_kind, "deal") + self.assertTrue(ticket.event_wait_started_at) self.assertEqual(ticket.attachments[0]["file_name"], "产品报价.pdf") self.assertEqual(ticket.status, "已报价") texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"]