diff --git a/.env.example b/.env.example index 9746396..d562e09 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,9 @@ WECOM_CALLBACK_TOKEN= WECOM_ENCODING_AES_KEY= WECOM_CALLBACK_REPLY_TEXT=询价智能体接通成功。已收到:{content} WECOM_OPS_AGENT_ID=1000014 +# 现网「询价消息服务」= 运维应用 1000014(与 WECOM_OPERATIONS_* 同一套)。空则只成交不推。 +WECOM_NOTIFY_AGENT_ID=1000014 +WECOM_NOTIFY_SECRET= # 会话存档席:后台员工管理里「询价机器人」的企微 userid,每个协同群必拉。可逗号分隔多个。 WECOM_ARCHIVE_SEAT_USER_IDS= # 微盛会话存档(Worker 按存档席拉群消息)。游标只写 REDIS_KEY_PREFIX,禁止推现网官方 seq。 diff --git a/inquiry-agent/agent/channel/runtime.py b/inquiry-agent/agent/channel/runtime.py index 4aa30bf..9bfbbbe 100644 --- a/inquiry-agent/agent/channel/runtime.py +++ b/inquiry-agent/agent/channel/runtime.py @@ -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) diff --git a/inquiry-agent/agent/config/__init__.py b/inquiry-agent/agent/config/__init__.py index 0fb7cf4..342ed53 100644 --- a/inquiry-agent/agent/config/__init__.py +++ b/inquiry-agent/agent/config/__init__.py @@ -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", diff --git a/inquiry-agent/agent/handlers/air_group_collab.py b/inquiry-agent/agent/handlers/air_group_collab.py index e604193..c087f82 100644 --- a/inquiry-agent/agent/handlers/air_group_collab.py +++ b/inquiry-agent/agent/handlers/air_group_collab.py @@ -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" diff --git a/inquiry-agent/agent/handlers/group_collab.py b/inquiry-agent/agent/handlers/group_collab.py index c223be9..ecce1c7 100644 --- a/inquiry-agent/agent/handlers/group_collab.py +++ b/inquiry-agent/agent/handlers/group_collab.py @@ -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", diff --git a/inquiry-agent/agent/handlers/text_inquiry.py b/inquiry-agent/agent/handlers/text_inquiry.py index b966dec..4102d4d 100644 --- a/inquiry-agent/agent/handlers/text_inquiry.py +++ b/inquiry-agent/agent/handlers/text_inquiry.py @@ -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) diff --git a/inquiry-agent/agent/jobs/__init__.py b/inquiry-agent/agent/jobs/__init__.py index 7c91fb4..8eced99 100644 --- a/inquiry-agent/agent/jobs/__init__.py +++ b/inquiry-agent/agent/jobs/__init__.py @@ -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: diff --git a/inquiry-agent/agent/jobs/deal_followup_poll.py b/inquiry-agent/agent/jobs/deal_followup_poll.py new file mode 100644 index 0000000..62175f0 --- /dev/null +++ b/inquiry-agent/agent/jobs/deal_followup_poll.py @@ -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 diff --git a/inquiry-agent/agent/ledger/http_ledger.py b/inquiry-agent/agent/ledger/http_ledger.py index fc19513..b4f1df4 100644 --- a/inquiry-agent/agent/ledger/http_ledger.py +++ b/inquiry-agent/agent/ledger/http_ledger.py @@ -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( diff --git a/inquiry-agent/agent/ledger/memory_ledger.py b/inquiry-agent/agent/ledger/memory_ledger.py index 2c3f3b3..a7abb7b 100644 --- a/inquiry-agent/agent/ledger/memory_ledger.py +++ b/inquiry-agent/agent/ledger/memory_ledger.py @@ -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: diff --git a/inquiry-agent/agent/policy/air_group_ops.py b/inquiry-agent/agent/policy/air_group_ops.py index fb04f40..3ebd6dd 100644 --- a/inquiry-agent/agent/policy/air_group_ops.py +++ b/inquiry-agent/agent/policy/air_group_ops.py @@ -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( diff --git a/inquiry-agent/agent/policy/air_text_flow.py b/inquiry-agent/agent/policy/air_text_flow.py index 45320f9..77e1e6a 100644 --- a/inquiry-agent/agent/policy/air_text_flow.py +++ b/inquiry-agent/agent/policy/air_text_flow.py @@ -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: diff --git a/inquiry-agent/agent/policy/deal_followup.py b/inquiry-agent/agent/policy/deal_followup.py new file mode 100644 index 0000000..d9f9863 --- /dev/null +++ b/inquiry-agent/agent/policy/deal_followup.py @@ -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 diff --git a/inquiry-agent/agent/policy/deal_outcome.py b/inquiry-agent/agent/policy/deal_outcome.py new file mode 100644 index 0000000..8ce841e --- /dev/null +++ b/inquiry-agent/agent/policy/deal_outcome.py @@ -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} diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py index 6e62153..6e8e8ff 100644 --- a/inquiry-agent/agent/policy/inquiry_copy.py +++ b/inquiry-agent/agent/policy/inquiry_copy.py @@ -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: diff --git a/inquiry-agent/agent/policy/land_text_flow.py b/inquiry-agent/agent/policy/land_text_flow.py index a06fd3b..231b309 100644 --- a/inquiry-agent/agent/policy/land_text_flow.py +++ b/inquiry-agent/agent/policy/land_text_flow.py @@ -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 转回来,递归到死、一句都不回。 diff --git a/inquiry-agent/agent/policy/sea_group_ops.py b/inquiry-agent/agent/policy/sea_group_ops.py index a2d2749..aff261a 100644 --- a/inquiry-agent/agent/policy/sea_group_ops.py +++ b/inquiry-agent/agent/policy/sea_group_ops.py @@ -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" diff --git a/inquiry-agent/agent/policy/sea_text_flow.py b/inquiry-agent/agent/policy/sea_text_flow.py index 03b680b..78cc529 100644 --- a/inquiry-agent/agent/policy/sea_text_flow.py +++ b/inquiry-agent/agent/policy/sea_text_flow.py @@ -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, diff --git a/inquiry-agent/tests/test_deal_followup.py b/inquiry-agent/tests/test_deal_followup.py new file mode 100644 index 0000000..c9f55cc --- /dev/null +++ b/inquiry-agent/tests/test_deal_followup.py @@ -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() diff --git a/inquiry-agent/tests/test_deal_outcome.py b/inquiry-agent/tests/test_deal_outcome.py new file mode 100644 index 0000000..ff82d84 --- /dev/null +++ b/inquiry-agent/tests/test_deal_outcome.py @@ -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, "") diff --git a/inquiry-agent/tests/test_land_text_inquiry.py b/inquiry-agent/tests/test_land_text_inquiry.py index b3c3007..d395926 100644 --- a/inquiry-agent/tests/test_land_text_inquiry.py +++ b/inquiry-agent/tests/test_land_text_inquiry.py @@ -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() diff --git a/inquiry-agent/tests/test_sea_text_inquiry.py b/inquiry-agent/tests/test_sea_text_inquiry.py index 0d790f8..e8e483e 100644 --- a/inquiry-agent/tests/test_sea_text_inquiry.py +++ b/inquiry-agent/tests/test_sea_text_inquiry.py @@ -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() diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryAgentController.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryAgentController.java index 4f24f9d..a5722eb 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryAgentController.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryAgentController.java @@ -122,6 +122,10 @@ public class InquiryAgentController { Object to = body == null ? null : body.get("toStatus"); req.setWorkOrderNo(no == null ? "" : String.valueOf(no)); req.setToStatus(to == null ? "" : String.valueOf(to)); + req.setRemark(firstStr(body, "remark")); + req.setLostReason(firstStr(body, "lostReason", "lost_reason")); + req.setNextFollowupAt(firstStr(body, "nextFollowupAt", "next_followup_at")); + req.setDealNotifySummary(firstStr(body, "dealNotifySummary", "deal_notify_summary")); try { org.jeecg.modules.inquiry.entity.InquiryTicket ticket = workOrderService.transition(req, "agent"); @@ -135,6 +139,31 @@ public class InquiryAgentController { } } + @ApiOperation("智能体补未成交原因(不改六态)") + @PostMapping("/ticket/patchLostReason") + public Result> patchLostReason(@RequestBody Map body, + HttpServletRequest http) { + if (!authed(http)) { + return Result.error(401, "unauthorized"); + } + return Result.OK(workOrderService.patchLostReason( + firstStr(body, "workOrderNo", "work_order_no"), + firstStr(body, "lostReason", "lost_reason"), + "agent")); + } + + @ApiOperation("认领到期协商再问(Worker HTTP 槽)") + @PostMapping("/ticket/claimDueNegotiate") + public Result> claimDueNegotiate(HttpServletRequest http) { + if (!authed(http)) { + return Result.error(401, "unauthorized"); + } + Map out = new java.util.LinkedHashMap<>(); + out.put("ok", true); + out.put("tickets", workOrderService.claimDueNegotiate()); + return Result.OK(out); + } + @ApiOperation("智能体续办读单(只读,校验发起人)") @PostMapping("/ticket/get") public Result> get(@RequestBody Map body, diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/domain/TicketStatuses.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/domain/TicketStatuses.java index 9a9345a..64009c7 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/domain/TicketStatuses.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/domain/TicketStatuses.java @@ -30,6 +30,7 @@ public final class TicketStatuses { /** * from -> 允许的 to 集合。 * 终态(已成交/未成交/已关闭)默认不可再流转,除非显式「关闭」从非终态进入已关闭。 + * 协商中允许再进协商中:只顺延再问,不重开询价。 */ private static final Map> ALLOWED; @@ -37,7 +38,8 @@ public final class TicketStatuses { Map> m = new HashMap<>(); m.put(INQUIRING, setOf(QUOTED, NEGOTIATING, CLOSED)); m.put(QUOTED, setOf(NEGOTIATING, DEAL, LOST, CLOSED)); - m.put(NEGOTIATING, setOf(QUOTED, DEAL, LOST, CLOSED)); + // 协商中可再选协商中:顺延下一轮再问,次数不限。 + m.put(NEGOTIATING, setOf(QUOTED, DEAL, LOST, CLOSED, NEGOTIATING)); m.put(DEAL, Collections.emptySet()); m.put(LOST, Collections.emptySet()); m.put(CLOSED, Collections.emptySet()); diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/dto/TicketTransitionRequest.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/dto/TicketTransitionRequest.java index 008df18..c83f1c2 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/dto/TicketTransitionRequest.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/dto/TicketTransitionRequest.java @@ -20,4 +20,13 @@ public class TicketTransitionRequest { @ApiModelProperty("备注/关单原因") private String remark; + + @ApiModelProperty("未成交原因(仅未成交)") + private String lostReason; + + @ApiModelProperty("协商中下次再问时间") + private String nextFollowupAt; + + @ApiModelProperty("成交通知结果摘要") + private String dealNotifySummary; } diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryTicket.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryTicket.java index e686155..e386d73 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryTicket.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryTicket.java @@ -115,6 +115,28 @@ public class InquiryTicket implements Serializable { @ApiModelProperty("关单原因") private String closeReason; + /** + * 未成交原因:销售下一句任意文字;没回则空着。 + * 不是 closeReason,后台要单独展示。 + */ + @ApiModelProperty("未成交原因") + private String lostReason; + + /** + * 协商中下次再问时刻(第四个工作日 09:00)。 + * 中途改成交/未成交或再问发出后清空。 + */ + @ApiModelProperty("协商中下次再问时间") + @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss") + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date nextFollowupAt; + + /** + * 成交通知结果:已推哪些负责人 / 无匹配负责人 / 通知未发出。 + */ + @ApiModelProperty("成交通知结果") + private String dealNotifySummary; + @ApiModelProperty("业务版本") private Integer version; diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryWorkOrderService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryWorkOrderService.java index 29be276..b8c0991 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryWorkOrderService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryWorkOrderService.java @@ -109,4 +109,15 @@ public interface IInquiryWorkOrderService extends IService { * 空运群激活/切换当前工单。一单一群,未结案才能绑。不改六态。 */ Map activateAirGroup(Map body); + + /** + * 未成交后补原因。已成交/其它六态拒绝。不改六态。 + */ + Map patchLostReason(String workOrderNo, String lostReason, String operator); + + /** + * 认领已到期的协商中再问:清空 nextFollowupAt,避免 Worker 重复发。 + * 只改到期行,不加全局锁。 + */ + java.util.List> claimDueNegotiate(); } diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryStaffRoleServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryStaffRoleServiceImpl.java index e88e78c..929b68b 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryStaffRoleServiceImpl.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryStaffRoleServiceImpl.java @@ -174,6 +174,9 @@ public class InquiryStaffRoleServiceImpl extends ServiceImpl collabFacts = new LinkedHashMap<>(); if (StringUtils.isNotBlank(ticket.getCollabFactsJson())) { @@ -773,6 +799,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl patchLostReason(String workOrderNo, String lostReason, String operator) { + Map out = new LinkedHashMap<>(); + String no = StringUtils.trimToEmpty(workOrderNo); + String reason = StringUtils.left(StringUtils.trimToEmpty(lostReason), 500); + out.put("ok", false); + if (StringUtils.isBlank(no) || StringUtils.isBlank(reason)) { + out.put("error", "workOrderNo_or_reason_blank"); + return out; + } + InquiryTicket ticket = findByWorkOrderNo(no); + if (ticket == null) { + out.put("error", "ticket_not_found"); + return out; + } + if (!TicketStatuses.LOST.equals(ticket.getStatus())) { + out.put("error", "not_lost"); + out.put("status", ticket.getStatus()); + return out; + } + ticket.setLostReason(reason); + ticket.setUpdateBy(StringUtils.defaultIfBlank(operator, "agent")); + ticket.setUpdateTime(new Date()); + updateById(ticket); + appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "lost_reason", operator, reason); + out.put("ok", true); + out.put("work_order_no", no); + out.put("lost_reason", reason); + return out; + } + + /** + * 认领到期协商再问:状态仍是协商中且 next_followup_at 已到。 + * 清空再问时刻,Worker 只发这一轮;销售再选协商中才会排下一轮。 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public List> claimDueNegotiate() { + Date now = new Date(); + List due = list(new LambdaQueryWrapper() + .eq(InquiryTicket::getStatus, TicketStatuses.NEGOTIATING) + .isNotNull(InquiryTicket::getNextFollowupAt) + .le(InquiryTicket::getNextFollowupAt, now) + .last("LIMIT 50")); + List> out = new ArrayList<>(); + if (due == null || due.isEmpty()) { + return out; + } + for (InquiryTicket ticket : due) { + ticket.setNextFollowupAt(null); + ticket.setUpdateBy("agent"); + ticket.setUpdateTime(now); + updateById(ticket); + appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "negotiate_followup", "agent", "协商再问已发出"); + out.add(toDueNegotiateRow(ticket)); + } + return out; + } + + /** + * Worker 再问需要的最小快照:会话通道、字段、报价。 + */ + private Map toDueNegotiateRow(InquiryTicket ticket) { + Map row = new LinkedHashMap<>(); + row.put("work_order_no", ticket.getWorkOrderNo()); + row.put("workOrderNo", ticket.getWorkOrderNo()); + row.put("sales_wecom_id", StringUtils.defaultString(ticket.getSalesWecomId())); + row.put("salesWecomId", StringUtils.defaultString(ticket.getSalesWecomId())); + row.put("sales_name", StringUtils.defaultString(ticket.getSalesName())); + row.put("business_line", StringUtils.defaultString(ticket.getBusinessLine())); + row.put("collab_chat_id", StringUtils.defaultString(ticket.getCollabChatId())); + row.put("status", ticket.getStatus()); + Map facts = new LinkedHashMap<>(); + if (StringUtils.isNotBlank(ticket.getFactsJson())) { + JSONObject stored = JSON.parseObject(ticket.getFactsJson()); + if (stored != null) { + for (String key : stored.keySet()) { + if (stored.get(key) != null) { + facts.put(key, String.valueOf(stored.get(key))); + } + } + } + } + if (StringUtils.isNotBlank(ticket.getOrigin())) { + facts.putIfAbsent("起运港", ticket.getOrigin()); + } + if (StringUtils.isNotBlank(ticket.getDestination())) { + facts.putIfAbsent("目的港", ticket.getDestination()); + } + row.put("facts", facts); + Map quote = new LinkedHashMap<>(); + List quotes = quoteMapper.selectList(new LambdaQueryWrapper() + .eq(InquiryQuote::getWorkOrderNo, ticket.getWorkOrderNo()) + .orderByDesc(InquiryQuote::getCreateTime) + .last("LIMIT 1")); + if (quotes != null && !quotes.isEmpty() && StringUtils.isNotBlank(quotes.get(0).getPayloadJson())) { + JSONObject parsed = JSON.parseObject(quotes.get(0).getPayloadJson()); + if (parsed != null) { + quote.putAll(parsed); + } + } + row.put("quote", quote); + return row; + } + + private static Date parseDateTime(String raw) { + if (StringUtils.isBlank(raw)) { + return null; + } + String text = raw.trim().replace("T", " "); + if (text.length() >= 19) { + text = text.substring(0, 19); + } + try { + return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(text); + } catch (Exception e) { + return parseDate(raw); + } + } + private void appendLog(InquiryTicket ticket, String from, String to, String action, String operator, String remark) { InquiryTicketStatusLog log = new InquiryTicketStatusLog(); log.setTicketId(ticket.getId()); diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_ticket_deal_followup_v1.sql b/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_ticket_deal_followup_v1.sql new file mode 100644 index 0000000..f177a2f --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_ticket_deal_followup_v1.sql @@ -0,0 +1,14 @@ +-- 成交跟进:未成交原因 / 协商再问时刻 / 成交通知摘要。 +-- 仅改 inquiry_robot;幂等需手工确认列是否已存在。 +-- 库:inquiry_robot + +SET NAMES utf8mb4; + +ALTER TABLE `inq_ticket` + ADD COLUMN `lost_reason` varchar(500) NULL COMMENT '未成交原因,不是关单原因' AFTER `close_reason`; + +ALTER TABLE `inq_ticket` + ADD COLUMN `next_followup_at` datetime NULL COMMENT '协商中下次再问(工作日 09:00)' AFTER `lost_reason`; + +ALTER TABLE `inq_ticket` + ADD COLUMN `deal_notify_summary` varchar(500) NULL COMMENT '成交通知:已推负责人/无匹配/未发出' AFTER `next_followup_at`; diff --git a/inquiry-backend/src/views/inquiry/workOrder/index.vue b/inquiry-backend/src/views/inquiry/workOrder/index.vue index 68df437..127afb7 100644 --- a/inquiry-backend/src/views/inquiry/workOrder/index.vue +++ b/inquiry-backend/src/views/inquiry/workOrder/index.vue @@ -48,6 +48,9 @@ type TicketRow = { volumeCbm: string; goodsName: string; updateTime: string; + lostReason: string; + nextFollowupAt: string; + dealNotifySummary: string; }; const rows = ref([]); @@ -265,6 +268,9 @@ function mapTicket(t: any, quotes: any[] = [], attachments: any[] = [], timeline volumeCbm: fmtMeasure(t.volumeCbm, "CBM"), goodsName: dash(t.goodsName), updateTime: fmtTime(t.updateTime || t.createTime), + lostReason: dash(t.lostReason || t.lost_reason), + nextFollowupAt: fmtTime(t.nextFollowupAt || t.next_followup_at), + dealNotifySummary: dash(t.dealNotifySummary || t.deal_notify_summary), }; } @@ -630,6 +636,18 @@ function refresh() {
{{ current.eventException }}
{{ current.systemException }}
{{ current.systemExceptionReason }}
+
+ {{ current.updateTime }} +
+
+ {{ current.dealNotifySummary }} +
+
+ {{ current.lostReason }} +
+
+ {{ current.nextFollowupAt }} +