From 0a3c74e3a16c7476a5e2a77921f9f98bc762cbdb Mon Sep 17 00:00:00 2001 From: jillion <439912596@qq.com> Date: Tue, 15 Sep 2026 14:26:09 +0800 Subject: [PATCH] =?UTF-8?q?=E8=90=BD=E5=9C=B0=E6=B5=B7=E8=BF=90=E7=A7=81?= =?UTF-8?q?=E8=81=8A=E6=96=87=E5=AD=97=E8=AF=A2=E4=BB=B7=E5=85=A8=E6=B5=81?= =?UTF-8?q?=E7=A8=8B=EF=BC=8C=E6=B5=B7=E8=BF=90=E5=A4=9A=E9=A1=B9=E8=B4=B9?= =?UTF-8?q?=E7=94=A8=E6=94=B9=E8=B5=B0=E6=96=87=E6=9C=AC=E6=98=8E=E7=BB=86?= =?UTF-8?q?=EF=BC=8C=E9=81=BF=E5=85=8D=E4=BC=81=E5=BE=AE=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E5=BC=95=E7=94=A8=E6=9D=A1=E6=88=AA=E6=96=AD=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .env.example | 2 + inquiry-agent/agent/channel/outbox/sender.py | 95 ++ inquiry-agent/agent/config/__init__.py | 4 + inquiry-agent/agent/handlers/__init__.py | 2 +- inquiry-agent/agent/handlers/card_action.py | 14 +- .../agent/handlers/continue_thread.py | 34 +- inquiry-agent/agent/handlers/group_collab.py | 93 ++ inquiry-agent/agent/handlers/text_inquiry.py | 31 +- inquiry-agent/agent/ledger/http_ledger.py | 83 +- inquiry-agent/agent/ledger/memory_ledger.py | 135 ++- inquiry-agent/agent/llm/extract_text.py | 23 +- .../agent/llm/mode_extract_fields.py | 3 +- inquiry-agent/agent/policy/air_text_flow.py | 71 +- inquiry-agent/agent/policy/inquiry_copy.py | 386 +++++++- inquiry-agent/agent/policy/sea_text_flow.py | 617 +++++++++++++ inquiry-agent/agent/quote_templates/match.py | 10 +- inquiry-agent/agent/routing/dispatch.py | 6 + inquiry-agent/agent/schema/field_validate.py | 35 + inquiry-agent/agent/schema/fingerprint.py | 2 +- inquiry-agent/agent/schema/tms_sea_query.py | 115 +++ inquiry-agent/tests/test_air_text_inquiry.py | 3 + inquiry-agent/tests/test_continue_ticket.py | 35 + .../tests/test_quote_template_match.py | 18 + inquiry-agent/tests/test_sea_text_inquiry.py | 836 ++++++++++++++++++ .../controller/InquiryAgentController.java | 74 +- .../modules/inquiry/entity/InquiryTicket.java | 21 + .../service/IInquiryStaffRoleService.java | 15 + .../service/IInquiryWorkOrderService.java | 21 + .../impl/InquiryQuoteTemplateServiceImpl.java | 15 +- .../impl/InquiryStaffRoleServiceImpl.java | 45 + .../impl/InquiryWorkOrderServiceImpl.java | 261 +++++- .../inquiry/staff/SeaStaffMatcher.java | 63 ++ .../template/QuoteTemplateMatcher.java | 8 +- .../inquiry/tms/InquiryTmsQueryService.java | 143 ++- .../inquiry/tms/SeaTmsFieldNormalizer.java | 146 +++ .../inquiry/tms/TmsV2RequestMapper.java | 35 + .../inquiry/staff/SeaStaffMatcherTest.java | 64 ++ .../template/QuoteTemplateMatcherTest.java | 31 + .../tms/InquiryTmsQueryServiceTest.java | 50 ++ .../tms/SeaTmsFieldNormalizerTest.java | 58 ++ .../inquiry/tms/TmsV2RequestMapperTest.java | 47 + 41 files changed, 3702 insertions(+), 48 deletions(-) create mode 100644 inquiry-agent/agent/handlers/group_collab.py create mode 100644 inquiry-agent/agent/policy/sea_text_flow.py create mode 100644 inquiry-agent/agent/schema/tms_sea_query.py create mode 100644 inquiry-agent/tests/test_sea_text_inquiry.py create mode 100644 inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/staff/SeaStaffMatcher.java create mode 100644 inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/SeaTmsFieldNormalizer.java create mode 100644 inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/staff/SeaStaffMatcherTest.java create mode 100644 inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryServiceTest.java create mode 100644 inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/SeaTmsFieldNormalizerTest.java diff --git a/.env.example b/.env.example index 7b8b2db..94f937e 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,8 @@ WECOM_CALLBACK_TOKEN= WECOM_ENCODING_AES_KEY= WECOM_CALLBACK_REPLY_TEXT=询价智能体接通成功。已收到:{content} WECOM_OPS_AGENT_ID=1000014 +# 会话存档席:后台员工管理里「询价机器人」的企微 userid,每个协同群必拉。可逗号分隔多个。 +WECOM_ARCHIVE_SEAT_USER_IDS= DEV_INJECT_ENABLED=true # 本机端口(切流前并存:勿占用现网 8080/8810/8813) diff --git a/inquiry-agent/agent/channel/outbox/sender.py b/inquiry-agent/agent/channel/outbox/sender.py index 9f2cdbb..132d801 100644 --- a/inquiry-agent/agent/channel/outbox/sender.py +++ b/inquiry-agent/agent/channel/outbox/sender.py @@ -218,6 +218,101 @@ class WeComAppClient: ) return media_id, WeComSendResult(ok=True, errcode=0, raw=data) + def create_group(self, *, name: str, userids: list[str]) -> dict[str, Any]: + """ + 创建企微应用群(appchat/create),群名=工单号。 + + 成员须含销售、产品、会话存档账号(询价机器人);应用自己会进群。失败回 ok=False,不抛给回调。 + """ + users = [str(x).strip() for x in userids if str(x).strip()] + if not name or len(users) < 2: + return {"ok": False, "error": "name_or_users", "chat_id": ""} + try: + token = self._get_token() + except Exception as exc: # noqa: BLE001 + logger.exception("建群取 token 失败") + return {"ok": False, "error": str(exc), "chat_id": ""} + url = f"{self._api_base}/cgi-bin/appchat/create" + body = {"name": name, "userlist": users} + try: + with httpx.Client(timeout=self._timeout) as client: + resp = client.post(url, params={"access_token": token}, json=body) + data = resp.json() + except Exception as exc: # noqa: BLE001 + logger.exception("企微建群网络失败") + return {"ok": False, "error": str(exc), "chat_id": ""} + errcode = int(data.get("errcode") or 0) + chat_id = str(data.get("chatid") or data.get("chat_id") or "") + if errcode != 0 or not chat_id: + logger.warning("企微建群失败 errcode=%s", errcode) + return { + "ok": False, + "error": str(data.get("errmsg") or f"errcode_{errcode}"), + "chat_id": "", + } + logger.info("企微群已创建 name=%s chat=%s", name, chat_id) + return {"ok": True, "chat_id": chat_id, "error": ""} + + def add_group_members(self, *, chat_id: str, userids: list[str]) -> dict[str, Any]: + """ + 往已有应用群补人(appchat/update add_user_list)。 + + 用于已建群补拉会话存档账号。已在群里的人企微会忽略。不抛给回调。 + """ + users = [str(x).strip() for x in userids if str(x).strip()] + if not chat_id or not users: + return {"ok": False, "error": "chat_or_users"} + try: + token = self._get_token() + except Exception as exc: # noqa: BLE001 + logger.exception("补拉群成员取 token 失败") + return {"ok": False, "error": str(exc)} + url = f"{self._api_base}/cgi-bin/appchat/update" + body = {"chatid": chat_id, "add_user_list": users} + try: + with httpx.Client(timeout=self._timeout) as client: + resp = client.post(url, params={"access_token": token}, json=body) + data = resp.json() + except Exception as exc: # noqa: BLE001 + logger.exception("企微补拉群成员网络失败") + return {"ok": False, "error": str(exc)} + errcode = int(data.get("errcode") or 0) + if errcode != 0: + logger.warning("企微补拉群成员失败 errcode=%s", errcode) + return {"ok": False, "error": str(data.get("errmsg") or f"errcode_{errcode}")} + logger.info("企微群已补人 chat=%s", chat_id) + return {"ok": True, "error": ""} + + def send_group(self, *, chat_id: str, content: str) -> dict[str, Any]: + """ + 向应用群发文本。必须走 appchat/send,不能当私聊 message/send。 + + 失败回 ok=False,由调用方决定是否重试;不抛给回调线程。 + """ + if not chat_id or not content: + return {"ok": False, "error": "chat_or_content"} + if not self._corp_id or not self._secret: + return {"ok": False, "error": "WECOM_CORP_ID/SECRET 未配置"} + try: + token = self._get_token() + except Exception as exc: # noqa: BLE001 + logger.exception("群消息取 token 失败") + return {"ok": False, "error": str(exc)} + url = f"{self._api_base}/cgi-bin/appchat/send" + body = {"chatid": chat_id, "msgtype": "text", "text": {"content": content}} + try: + with httpx.Client(timeout=self._timeout) as client: + resp = client.post(url, params={"access_token": token}, json=body) + data = resp.json() + except Exception as exc: # noqa: BLE001 + logger.exception("企微群消息网络失败") + return {"ok": False, "error": str(exc)} + errcode = int(data.get("errcode") or 0) + if errcode != 0: + logger.warning("企微群消息失败 errcode=%s", errcode) + return {"ok": False, "error": str(data.get("errmsg") or f"errcode_{errcode}")} + return {"ok": True, "error": ""} + def send_text(self, *, touser: str, content: str) -> WeComSendResult: """ 向指定企微 userid 发送文本应用消息。 diff --git a/inquiry-agent/agent/config/__init__.py b/inquiry-agent/agent/config/__init__.py index f53face..c6567a8 100644 --- a/inquiry-agent/agent/config/__init__.py +++ b/inquiry-agent/agent/config/__init__.py @@ -60,6 +60,10 @@ 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") + wecom_archive_seat_user_ids: str = Field( + default="", + alias="WECOM_ARCHIVE_SEAT_USER_IDS", + ) wecom_callback_token: str = Field(default="", alias="WECOM_CALLBACK_TOKEN") wecom_encoding_aes_key: str = Field(default="", alias="WECOM_ENCODING_AES_KEY") wecom_callback_reply_text: str = Field( diff --git a/inquiry-agent/agent/handlers/__init__.py b/inquiry-agent/agent/handlers/__init__.py index 146b9fc..48bb83f 100644 --- a/inquiry-agent/agent/handlers/__init__.py +++ b/inquiry-agent/agent/handlers/__init__.py @@ -2,7 +2,7 @@ Handler 包:一类意图 / 一类卡片一个模块。 禁止:单文件处理全部卡片;禁止把 Schema 校验复制粘贴进每个 handler(放 schema/policy)。 -当前:文字询价空运闭环 + echo 联调残留。 +当前:文字询价空运/海运闭环 + 海运群协同字段 + echo 联调残留。 """ from agent.handlers.echo_text import handle_echo_text diff --git a/inquiry-agent/agent/handlers/card_action.py b/inquiry-agent/agent/handlers/card_action.py index fcee1d4..293a145 100644 --- a/inquiry-agent/agent/handlers/card_action.py +++ b/inquiry-agent/agent/handlers/card_action.py @@ -1,5 +1,5 @@ """ -意图:询价卡片按钮(复制工单号 / 跳过协同 / 采用 / 不采用 / 成交)。 +意图:询价卡片按钮(复制工单号 / 拉产品进群 / 跳过协同 / 采用 / 不采用 / 成交)。 本文件职责:校验当前会话允许的动作后交给流程执行。 禁止:不校验 wait/允许集合就改主账;禁止一个函数处理未声明的新按钮。 @@ -13,10 +13,20 @@ from typing import Any from agent.channel.queue import get_message_store from agent.policy import inquiry_copy as copy from agent.policy.air_text_flow import get_air_text_flow +from agent.policy.sea_text_flow import get_sea_text_flow logger = logging.getLogger(__name__) +def _pick_card_flow(sender_id: str, payload: dict | None): + """点卡:海运书签能找回工单就走海运,否则空运。""" + sea = get_sea_text_flow() + sess = sea.session_for_action(sender_id, payload) + if sess and (sess.business_line or "").upper() == "SEA": + return sea + return get_air_text_flow() + + def handle_card_action( *, sender_id: str, @@ -36,7 +46,7 @@ def handle_card_action( _ = thread_id if not (sender_id or "").strip(): return "reject_no_sender" - engine = flow or get_air_text_flow() + engine = flow or _pick_card_flow(sender_id, payload) sess = engine.session_for_action(sender_id, payload) if sess and wait_version and sess.wait_version != wait_version: logger.info("card waitVersion 不匹配 expect=%s got=%s", sess.wait_version, wait_version) diff --git a/inquiry-agent/agent/handlers/continue_thread.py b/inquiry-agent/agent/handlers/continue_thread.py index 1279cf6..46122f8 100644 --- a/inquiry-agent/agent/handlers/continue_thread.py +++ b/inquiry-agent/agent/handlers/continue_thread.py @@ -14,10 +14,38 @@ from agent.channel.queue import get_message_store from agent.channel.wecom.models import InboundMessage from agent.policy import inquiry_copy as copy from agent.policy.air_text_flow import get_air_text_flow +from agent.policy.sea_text_flow import get_sea_text_flow logger = logging.getLogger(__name__) +def _pick_continue_flow(*, sender_id: str, work_order_no: str, flow=None): + """ + 续办按工单选空运/海运流程。 + + 海运书签或主账业务线是 SEA 必须走海运 Owner。 + 一律走空运会把已报价海运重放成空运协同卡(件数/毛重/包装)。 + 单测传入 flow 时不改选路,保持原断言。 + """ + if flow is not None: + return flow + sea = get_sea_text_flow() + no = (work_order_no or "").strip() + hit = sea.session_by_ticket(sender_id, no) if no else None + if hit and (hit.business_line or "").upper() == "SEA": + return sea + getter = getattr(sea.ledger, "get_for_agent", None) + if callable(getter) and no: + try: + view = getter(work_order_no=no, sender_id=sender_id) or {} + except Exception: + logger.warning("continue_thread 读主账业务线失败 wo=%s", no) + view = {} + if str(view.get("business_line") or "").upper() == "SEA": + return sea + return get_air_text_flow() + + def handle_continue_thread( message: InboundMessage, *, @@ -34,7 +62,10 @@ def handle_continue_thread( logger.warning("continue_thread 拒绝:无 sender_id") return "reject_no_sender" msg_store = store or get_message_store() - engine = flow or get_air_text_flow() + wo = (thread_id or "").strip() or copy.extract_work_order_no(message.content or "") + engine = _pick_continue_flow( + sender_id=message.sender_id, work_order_no=wo, flow=flow + ) replies: list[str] = [] def reply(text: str, extra: dict | None = None) -> str: @@ -48,7 +79,6 @@ def handle_continue_thread( logger.info("continue_thread 出站 ok=%s info=%s", ok, info) return info if ok else "" - wo = (thread_id or "").strip() or copy.extract_work_order_no(message.content or "") phase = engine.resume_historical( sender_id=message.sender_id, work_order_no=wo, diff --git a/inquiry-agent/agent/handlers/group_collab.py b/inquiry-agent/agent/handlers/group_collab.py new file mode 100644 index 0000000..687965c --- /dev/null +++ b/inquiry-agent/agent/handlers/group_collab.py @@ -0,0 +1,93 @@ +""" +意图:海运协同群里补协同字段。 + +本文件职责:群聊文字 → 只抽协同六字段 → 静默写主账。 +禁止:回「已记下」;把闲聊/报价金额写成报价版本;改工单六态。 +""" + +from __future__ import annotations + +import logging + +from agent.channel.wecom.models import InboundMessage +from agent.llm.extract_text import extract_collab_fields +from agent.policy.sea_text_flow import get_sea_text_flow +from agent.schema.field_validate import SEA_COLLAB_FIELDS + +logger = logging.getLogger(__name__) + + +def handle_group_collab( + message: InboundMessage, + *, + flow=None, + ledger=None, +) -> str: + """ + 处理一条群聊文字。 + + 必须能对上已绑定工单,且发言人是被拉进来的销售或产品。 + 识别到协同字段才写库;否则忽略。不回复。 + """ + if (message.chat_type or "") != "group": + return "group_ignore" + if not message.has_sender(): + return "reject_no_sender" + engine = flow or get_sea_text_flow() + book = ledger or engine.ledger + chat_id = (message.chat_id or "").strip() + if not chat_id: + return "group_ignore" + + ticket = _find_ticket(book, chat_id) + if ticket is None: + return "group_ignore" + if not _sender_allowed(ticket, message.sender_id): + logger.info("group_collab 忽略非成员 sender=%s chat=%s", message.sender_id, chat_id) + return "group_ignore" + + facts = extract_collab_fields(message.content or "") + if not facts: + return "group_ignore" + + if isinstance(ticket, dict): + wo = str(ticket.get("work_order_no") or "") + else: + wo = str(getattr(ticket, "work_order_no", "") or "") + if not wo: + return "group_ignore" + + engine.apply_collab_facts( + work_order_no=wo, + facts=facts, + sender_id=message.sender_id, + ) + logger.info( + "group_collab 写入 wo=%s keys=%s sender=%s", + wo, + sorted(k for k in facts if k in SEA_COLLAB_FIELDS), + message.sender_id, + ) + return "collab_field_saved" + + +def _find_ticket(book: object, chat_id: str): + finder = getattr(book, "find_by_collab_chat", None) + if callable(finder): + return finder(chat_id=chat_id) + return None + + +def _sender_allowed(ticket: object, sender_id: str) -> bool: + uid = (sender_id or "").strip() + if not uid: + return False + if isinstance(ticket, dict): + sales = str(ticket.get("sales_wecom_id") or ticket.get("salesWecomId") or "") + members = ticket.get("collab_member_ids") or ticket.get("memberIds") or [] + else: + sales = str(getattr(ticket, "sales_wecom_id", "") or "") + members = list(getattr(ticket, "collab_member_ids", []) or []) + if uid == sales: + return True + return uid in {str(x) for x in members} diff --git a/inquiry-agent/agent/handlers/text_inquiry.py b/inquiry-agent/agent/handlers/text_inquiry.py index f9d6569..3d3f249 100644 --- a/inquiry-agent/agent/handlers/text_inquiry.py +++ b/inquiry-agent/agent/handlers/text_inquiry.py @@ -1,5 +1,5 @@ """ -意图:普通文字询价(空运闭环)。 +意图:普通文字询价(空运 / 海运闭环)。 本文件职责:入站文字 → 流程编排;回复只写入 outbox。 禁止:在本文件抽字段语义(交给 extract);禁止直连 TMS;禁止回调线程外的额外阻塞。 @@ -13,11 +13,29 @@ from typing import Any, Optional from agent.channel.queue import get_message_store from agent.channel.wecom.models import InboundMessage from agent.config import get_settings +from agent.llm.extract_text import detect_transport_mode from agent.policy.air_text_flow import get_air_text_flow +from agent.policy.sea_text_flow import get_sea_text_flow logger = logging.getLogger(__name__) +def _pick_text_flow(*, sender_id: str, text: str, injected_mode: str = ""): + """ + 按当前书签或本句运输方式选空运/海运流程。 + + 已有海运书签优先;新句写了海运走海运;其余仍走空运(含补问运输方式)。 + """ + sea = get_sea_text_flow() + sea_sess = sea.session_of(sender_id) + if sea_sess and (sea_sess.business_line or "").upper() == "SEA": + return sea + mode = (injected_mode or detect_transport_mode(text) or "").upper() + if mode == "SEA": + return sea + return get_air_text_flow() + + def handle_text_inquiry( message: InboundMessage, *, @@ -39,7 +57,11 @@ def handle_text_inquiry( return "reject_no_sender" settings = get_settings() msg_store = store or get_message_store() - engine = flow or get_air_text_flow() + engine = flow or _pick_text_flow( + sender_id=message.sender_id, + text=message.content or "", + injected_mode=injected_mode, + ) replies: list[str] = [] def reply(text: str, extra: dict | None = None) -> str: @@ -75,12 +97,13 @@ def handle_text_other(message: InboundMessage, *, store=None) -> str: 闲聊/其它:若有待点按钮则当按钮;否则回首次单聊话术。 """ from agent.policy import inquiry_copy as copy - from agent.policy.air_text_flow import get_air_text_flow if not message.has_sender(): return "reject_no_sender" msg_store = store or get_message_store() - engine = get_air_text_flow() + engine = _pick_text_flow( + sender_id=message.sender_id, text=message.content or "" + ) sess = engine.session_for_action( message.sender_id, message.raw, text=message.content or "" ) diff --git a/inquiry-agent/agent/ledger/http_ledger.py b/inquiry-agent/agent/ledger/http_ledger.py index 3993a67..aaa09da 100644 --- a/inquiry-agent/agent/ledger/http_ledger.py +++ b/inquiry-agent/agent/ledger/http_ledger.py @@ -102,10 +102,20 @@ class HttpLedger: return {"ok": False, "found": False, "owned": False, "work_order_no": work_order_no} return out - def query_tms(self, *, work_order_no: str, facts: dict[str, str]) -> dict[str, Any]: + def query_tms( + self, + *, + work_order_no: str, + facts: dict[str, str], + business_line: str = "", + ) -> dict[str, Any]: + body: dict[str, Any] = {"work_order_no": work_order_no, "facts": facts} + line = (business_line or str((facts or {}).get("业务线") or "")).strip() + if line: + body["businessLine"] = line out = self._post( "/inquiry/agent/tms/query", - {"work_order_no": work_order_no, "facts": facts}, + body, idem=f"tms:{work_order_no}", ) if "classification" not in out and not out.get("ok"): @@ -182,3 +192,72 @@ class HttpLedger: 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 match_sea_staff(self, *, origin: str, destination: str) -> list[dict[str, Any]]: + """主账按线路返回启用的海运产品岗。""" + out = self._post( + "/inquiry/agent/staff/matchSea", + {"origin": origin, "destination": destination}, + ) + rows = out.get("staff") or out.get("result") or [] + if isinstance(rows, list): + return [dict(x) for x in rows if isinstance(x, dict)] + return [] + + def get_staff_by_wecom_id(self, *, wecom_id: str) -> dict[str, Any]: + out = self._post("/inquiry/agent/staff/byWecom", {"wecomId": wecom_id}) + if out.get("name") or out.get("wecomId"): + return out + return {} + + def find_staff_by_name(self, *, name: str) -> dict[str, Any]: + """按后台员工姓名取企微 userid。会话存档账号「询价机器人」走这里。""" + out = self._post("/inquiry/agent/staff/byName", {"name": name}) + if out.get("name") or out.get("wecomId"): + return out + return {} + + def bind_collab_group( + self, + *, + work_order_no: str, + chat_id: str, + member_ids: list[str], + product_names: list[str] | None = None, + ) -> dict[str, Any]: + out = self._post( + "/inquiry/agent/ticket/bindCollabGroup", + { + "workOrderNo": work_order_no, + "chatId": chat_id, + "memberIds": member_ids, + "productNames": product_names or [], + }, + idem=f"bind-group:{work_order_no}", + ) + out.setdefault("ok", bool(out.get("chat_id") or out.get("chatId"))) + return out + + def patch_collab_facts( + self, + *, + work_order_no: str, + facts: dict[str, str], + sender_id: str = "", + ) -> dict[str, Any]: + return self._post( + "/inquiry/agent/ticket/patchCollabFacts", + { + "workOrderNo": work_order_no, + "facts": facts, + "senderId": sender_id, + }, + idem=f"collab-facts:{work_order_no}:{sorted(facts)}", + ) + + def find_by_collab_chat(self, *, chat_id: str) -> dict[str, Any] | None: + """用协同群 chat_id 找回工单。对不上返回 None。""" + out = self._post("/inquiry/agent/ticket/byCollabChat", {"chatId": chat_id}) + if not out.get("found") and not out.get("work_order_no"): + return None + return out diff --git a/inquiry-agent/agent/ledger/memory_ledger.py b/inquiry-agent/agent/ledger/memory_ledger.py index 98873c2..c893ec9 100644 --- a/inquiry-agent/agent/ledger/memory_ledger.py +++ b/inquiry-agent/agent/ledger/memory_ledger.py @@ -33,6 +33,10 @@ class MemoryTicket: status: str = "询价中" quote: dict[str, Any] = field(default_factory=dict) quote_version: int = 0 + collab_chat_id: str = "" + collab_facts: dict[str, str] = field(default_factory=dict) + collab_member_ids: list[str] = field(default_factory=list) + quote_file_ready: bool = False class MemoryLedger: @@ -57,6 +61,9 @@ class MemoryLedger: } ] self.last_render: dict[str, Any] = {} + self._sea_staff: list[dict[str, Any]] = [] + self._staff_by_wecom: dict[str, dict[str, Any]] = {} + self._staff_by_name: dict[str, dict[str, Any]] = {} def _next_no(self) -> str: self._seq += 1 @@ -135,7 +142,14 @@ class MemoryLedger: "status": "询价中", } - def query_tms(self, *, work_order_no: str, facts: dict[str, str]) -> dict[str, Any]: + def query_tms( + self, + *, + work_order_no: str, + facts: dict[str, str], + business_line: str = "", + ) -> dict[str, Any]: + _ = business_line dest = str(facts.get("目的港") or "") force = str(facts.get("tms_force") or "") if force == "1002" or force == "1003" or "无价" in dest or "歧义" in dest: @@ -243,6 +257,10 @@ class MemoryLedger: "fileBase64": "UEsDBAoAAAAAA", "needsPdf": False, } + with self._lock: + ticket = self._tickets.get((work_order_no or "").strip()) + if ticket is not None: + ticket.quote_file_ready = True self.last_render = out return out @@ -261,7 +279,13 @@ class MemoryLedger: if status in {"已成交", "未成交", "协商中", "已关闭"}: wait_phase = "done" elif status == "已报价" and quote: - wait_phase = "wait_adopt" + # 文件已发给销售 → 成交跟进;否则海运协同卡、空运方案卡 + if ticket.quote_file_ready or quote.get("quoteFileIssued"): + wait_phase = "wait_deal" + elif (ticket.business_line or "").upper() == "SEA": + wait_phase = "wait_collab" + else: + wait_phase = "wait_adopt" else: wait_phase = "tms_miss" return { @@ -274,4 +298,111 @@ class MemoryLedger: "facts": dict(ticket.facts), "quote": quote, "wait_phase": wait_phase, + "collab_chat_id": ticket.collab_chat_id, + "collab_facts": dict(ticket.collab_facts), } + + def upsert_staff(self, row: dict[str, Any]) -> None: + """单测注入任意岗位(含会话存档账号「询价机器人」)。""" + data = dict(row) + uid = str(data.get("wecomId") or data.get("wecom_id") or "").strip() + name = str(data.get("name") or "").strip() + with self._lock: + if uid: + self._staff_by_wecom[uid] = data + if name: + self._staff_by_name[name] = data + + def set_sea_staff(self, rows: list[dict[str, Any]]) -> None: + """单测注入海运产品岗。""" + with self._lock: + self._sea_staff = [dict(x) for x in rows] + for row in self._sea_staff: + uid = str(row.get("wecomId") or row.get("wecom_id") or "").strip() + name = str(row.get("name") or "").strip() + if uid: + self._staff_by_wecom[uid] = dict(row) + if name: + self._staff_by_name[name] = dict(row) + + def match_sea_staff(self, *, origin: str, destination: str) -> list[dict[str, Any]]: + """ + 岗位=产品·海运、启用:写了「海运·全部线路」必拉; + 写了具体港口的,对得上本单起运港/目的港才拉。 + """ + origin = (origin or "").strip() + dest = (destination or "").strip() + hits: list[dict[str, Any]] = [] + with self._lock: + for row in self._sea_staff: + if str(row.get("roleCode") or row.get("role_code") or "") != "sea": + continue + if str(row.get("status") or "active") != "active": + continue + routes = str(row.get("routes") or "") + if "海运·全部线路" in routes or "海运全线" in routes: + hits.append(dict(row)) + continue + if origin and origin in routes: + hits.append(dict(row)) + continue + if dest and dest in routes: + hits.append(dict(row)) + return hits + + def get_staff_by_wecom_id(self, *, wecom_id: str) -> dict[str, Any]: + """按企微 userid 取员工展示名。""" + with self._lock: + return dict(self._staff_by_wecom.get((wecom_id or "").strip()) or {}) + + def find_staff_by_name(self, *, name: str) -> dict[str, Any]: + """按员工姓名取企微 userid。""" + with self._lock: + return dict(self._staff_by_name.get((name or "").strip()) or {}) + + def bind_collab_group( + self, + *, + work_order_no: str, + chat_id: str, + member_ids: list[str], + product_names: list[str] | None = None, + ) -> dict[str, Any]: + """记下协同群,不改六态。""" + _ = product_names + with self._lock: + ticket = self._tickets.get(work_order_no) + if ticket is None: + return {"ok": False, "error": "ticket_not_found"} + ticket.collab_chat_id = (chat_id or "").strip() + ticket.collab_member_ids = [str(x) for x in member_ids] + return {"ok": True, "chat_id": ticket.collab_chat_id} + + def patch_collab_facts( + self, + *, + work_order_no: str, + facts: dict[str, str], + sender_id: str = "", + ) -> dict[str, Any]: + """合并协同字段。不改六态、不写报价版本。""" + _ = sender_id + with self._lock: + ticket = self._tickets.get(work_order_no) + if ticket is None: + return {"ok": False, "error": "ticket_not_found"} + ticket.collab_facts.update( + {k: v for k, v in (facts or {}).items() if str(v or "").strip()} + ) + return {"ok": True, "facts": dict(ticket.collab_facts)} + + def find_by_collab_chat(self, *, chat_id: str) -> MemoryTicket | None: + """用工微群 chat_id 找回工单。""" + raw = (chat_id or "").strip() + if not raw: + return None + with self._lock: + for ticket in self._tickets.values(): + if ticket.collab_chat_id == raw: + return ticket + return None diff --git a/inquiry-agent/agent/llm/extract_text.py b/inquiry-agent/agent/llm/extract_text.py index a7e3b85..431b6ec 100644 --- a/inquiry-agent/agent/llm/extract_text.py +++ b/inquiry-agent/agent/llm/extract_text.py @@ -22,11 +22,13 @@ _MODE_WORDS = ( # 仅「键:值」一行一条,键必须是合同字段或展示名 _LABEL_LINE = re.compile( r"^(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|" - r"包装方式|包装类型|货量|整柜或拼柜|箱型箱量|报价日期|车型数量)\s*[::]\s*(.+)$" + r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型数量|" + r"贸易条款|货好时间|HS编码|HS|是否含油|是否含电|是否含磁)\s*[::]\s*(.+)$" ) _LABEL_INLINE = re.compile( r"(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|" - r"包装方式|包装类型|货量|整柜或拼柜|箱型箱量|报价日期|车型数量)\s*[::]\s*(\S+)" + r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型数量|" + r"贸易条款|货好时间|HS编码|HS|是否含油|是否含电|是否含磁)\s*[::]\s*(\S+)" ) @@ -114,3 +116,20 @@ def extract_inquiry_snapshot( "facts": facts, "source": source, } + + +def extract_collab_fields(text: str) -> dict[str, str]: + """ + 群里协同字段:只收带标签的非空值。 + + 闲聊、报价金额、附件本轮不进这里。HS 折成 HS编码。 + """ + from agent.schema.field_validate import SEA_COLLAB_FIELDS, normalize_facts + + labeled = normalize_facts(parse_labeled_facts(text)) + out: dict[str, str] = {} + for key in SEA_COLLAB_FIELDS: + val = (labeled.get(key) or "").strip() + if val: + out[key] = val + return out diff --git a/inquiry-agent/agent/llm/mode_extract_fields.py b/inquiry-agent/agent/llm/mode_extract_fields.py index ee171ad..47c988f 100644 --- a/inquiry-agent/agent/llm/mode_extract_fields.py +++ b/inquiry-agent/agent/llm/mode_extract_fields.py @@ -121,8 +121,9 @@ def build_extract_messages(text: str) -> list[dict[str, str]]: "你是询价字段抽取器。只根据销售原话抽取字段,调用 extract_inquiry_fields。" "未出现的字段必须空串,禁止编造港口、重量、日期。" "空运件数/毛重/体积/包装方式;海运货量与箱型箱量;陆运按二级类型。" + "海运时 15件/20吨/3箱 必须写入货量,不要只写到件数把货量留空。" "原话已带单位必须抽取,不要因为没写「毛重:」就留空:" - "10件/3pcs→件数;100KGS/180kg/1.5KG/100公斤→毛重;2CBM/4.2cbm/2.5立方/2方→体积;" + "10件/3pcs→空运件数;海运同样数字+件也要写入货量;100KGS/180kg/1.5KG/100公斤→毛重;2CBM/4.2cbm/2.5立方/2方→体积;" "托盘/散货/卡板/纸箱/木箱→包装方式(销售说什么记什么,散货也要抽)。" "9月15日/9月15号/2026-09-15→报价日期,规范成 yyyy-MM-dd,未写年用今年。" "「从南京飞吉隆坡」起运港=南京、目的港=吉隆坡;「北京飞吉隆坡」起运港=北京。" diff --git a/inquiry-agent/agent/policy/air_text_flow.py b/inquiry-agent/agent/policy/air_text_flow.py index ed36c68..2c55dc5 100644 --- a/inquiry-agent/agent/policy/air_text_flow.py +++ b/inquiry-agent/agent/policy/air_text_flow.py @@ -45,11 +45,16 @@ class FlowSession: status: str = "" allowed: tuple[str, ...] = () immutable_text: str = "" + collab_chat_id: str = "" + collab_facts: dict[str, str] = field(default_factory=dict) + invited_userids: tuple[str, ...] = () + invited_sales_name: str = "" + invited_product_names: tuple[str, ...] = () class AirTextInquiryFlow: """ - 空运文字询价 Owner(本轮产品范围);海运/陆运先补问运输方式或陆运二级。 + 空运文字询价 Owner。海运走 SeaTextInquiryFlow,不要再往本文件堆海运。陆运仍补问二级。 同一 sender 新开询价会覆盖「当前书签」(旧单留在主账)。 价格卡按工单号另存一份当时会话:点哪张卡就生成哪张卡当时的报价单。 @@ -69,6 +74,25 @@ class AirTextInquiryFlow: with self._lock: return self._by_sender.get(sender_id) + def session_by_ticket(self, sender_id: str, work_order_no: str) -> Optional[FlowSession]: + """ + 只查进程内该书签,不读主账。 + + 续办选空运/海运流程用:海运已跳过协同时必须找回 wait_adopt, + 不能因为主账把已报价海运一律收成 wait_collab 就套空运协同卡。 + """ + no = (work_order_no or "").strip().upper() + if not (sender_id or "").strip() or not no: + return None + with self._lock: + hit = self._by_ticket.get(no) + if hit and hit.sender_id == sender_id: + return hit + for key, sess in self._by_ticket.items(): + if (key or "").upper() == no and sess.sender_id == sender_id: + return sess + return None + def session_for_action( self, sender_id: str, @@ -152,6 +176,7 @@ class AirTextInquiryFlow: else: keep = None if keep: + self._align_resume_phase(keep) return self._replay_wait(keep, reply, source="session") getter = getattr(self._ledger, "get_for_agent", None) if not callable(getter): @@ -165,6 +190,7 @@ class AirTextInquiryFlow: reply("这张工单不属于当前账号,不能继续。") return "ticket_forbidden" sess = self._session_from_ledger(sender_id=sender_id, view=view) + self._align_resume_phase(sess) self._save(sess) return self._replay_wait(sess, reply, source="ledger") @@ -204,6 +230,20 @@ class AirTextInquiryFlow: return "wait_adopt" return "tms_miss" + def _align_resume_phase(self, sess: FlowSession) -> None: + """ + 报价文件已经发给销售,续办必须回到成交跟进。 + + 主账六态仍是「已报价」,不能据此重放到协同卡或生成报价单。 + Redis 就绪键由出文件时写入;主账 wait_phase=wait_deal 也认。 + """ + if sess.phase not in {"wait_collab", "wait_adopt", "wait_file"}: + return + issued = bool((sess.quote or {}).get("quoteFileIssued")) + if issued or self._quote_file_already_out(sess.work_order_no): + logger.info("resume 报价文件已出,回到成交跟进 wo=%s was=%s", sess.work_order_no, sess.phase) + self._promote_wait_deal(sess) + def _replay_wait(self, sess: FlowSession, reply: ReplyFn, *, source: str) -> str: """按书签重出当时节点,不改六态。""" logger.info( @@ -213,18 +253,21 @@ class AirTextInquiryFlow: source, ) if sess.phase == "wait_adopt": + mode = copy.scheme_transport_mode(sess.business_line) reply( copy.scheme_card( work_order_no=sess.work_order_no, status=sess.status or "已报价", facts=sess.facts, quote=sess.quote, + transport_mode=mode, ), copy.scheme_wecom_payload( work_order_no=sess.work_order_no, facts=sess.facts, quote=sess.quote, status=sess.status or "已报价", + transport_mode=mode, ), ) return "wait_adopt" @@ -349,9 +392,23 @@ class AirTextInquiryFlow: if mode == "LAND": reply(copy.ASK_LAND_SUBTYPE) return "need_land_subtype" - # 海运本轮只提示先走空运闭环(不半做海运查价) - reply("本轮先开通空运文字询价。请按空运描述需求,或补充为「空运」。") - return "sea_deferred" + if mode == "SEA": + # 海运交给海运 Owner,空运不再回「本轮先开通空运」 + from agent.policy.sea_text_flow import get_sea_text_flow + + return get_sea_text_flow().on_text( + sender_id=sender_id, + text=text, + reply=reply, + injected_facts=facts, + injected_mode=mode, + b_result=b_result, + allow_b=allow_b, + chat_fn=chat_fn, + inbound_raw=inbound_raw, + ) + reply("请补充运输方式:空运、海运或陆运。") + return "need_mode" check = validate_required_fields(facts=facts, business_line="AIR") facts = dict(check["facts"]) @@ -640,18 +697,21 @@ class AirTextInquiryFlow: sess.allowed = (copy.BTN_ADOPT_EXCEL, copy.BTN_ADOPT_PDF, copy.BTN_REJECT) sess.wait_version += 1 self._save(sess) + mode = copy.scheme_transport_mode(sess.business_line) reply( copy.scheme_card( work_order_no=sess.work_order_no, status=sess.status or "已报价", facts=sess.facts, quote=sess.quote, + transport_mode=mode, ), copy.scheme_wecom_payload( work_order_no=sess.work_order_no, facts=sess.facts, quote=sess.quote, status=sess.status or "已报价", + transport_mode=mode, ), ) return "wait_adopt" @@ -763,6 +823,9 @@ class AirTextInquiryFlow: file_extra, ) self._wait_outbound_sent(str(out_id or ""), work_order_no=sess.work_order_no, what="报价文件") + from agent.redis_coord.quote_file import mark_quote_file_ready + + mark_quote_file_ready(sess.work_order_no) sess.phase = "wait_deal" sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE) sess.wait_version += 1 diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py index 2fc0371..95d49e3 100644 --- a/inquiry-agent/agent/policy/inquiry_copy.py +++ b/inquiry-agent/agent/policy/inquiry_copy.py @@ -13,7 +13,7 @@ import time import uuid from typing import Any, Iterable -from agent.schema.field_validate import AIR_REQUIRED_FIELDS +from agent.schema.field_validate import AIR_REQUIRED_FIELDS, SEA_COLLAB_FIELDS, SEA_REQUIRED_FIELDS # 空运补问:与合同 8 项一致。报价日期默认当天,不进待补充。 _AIR_EXAMPLES = { @@ -30,11 +30,27 @@ _AIR_CLARIFY = tuple( (key, label, _AIR_EXAMPLES[key]) for key, label in AIR_REQUIRED_FIELDS ) +# 海运补问:与合同 7 项一致。报价日期默认当天,不进待补充。 +_SEA_EXAMPLES = { + "起运港": "上海", + "目的港": "洛杉矶", + "品名": "普货", + "货量": "20吨", + "整柜或拼柜": "整柜", + "箱型箱量": "1x40HQ", + "报价日期": "2026-09-15", +} +_SEA_CLARIFY = tuple( + (key, label, _SEA_EXAMPLES[key]) for key, label in SEA_REQUIRED_FIELDS +) + # 文字按钮(销售点卡或原样回复均可) BTN_SKIP_COLLAB = "不需要协同,直接生成报价单" BTN_SKIP_COLLAB_OLD = "不需要协同,直接生成基础报价单" BTN_SKIP_COLLAB_KEY = "skip_collab" BTN_SKIP_COLLAB_DONE = "已提交" +BTN_PULL_COLLAB = "拉产品进群协同" +BTN_PULL_COLLAB_KEY = "pull_collab" BTN_ADOPT_EXCEL = "采用,生成Excel报价单" BTN_ADOPT_PDF = "采用,生成PDF报价单" BTN_REJECT = "不采用" @@ -104,6 +120,14 @@ def _air_known_value(facts: dict[str, str], key: str) -> str: return (src.get(key) or "").strip() +def _sea_known_value(facts: dict[str, str], key: str) -> str: + """海运已识别字段的销售侧展示值。""" + src = facts or {} + if key == "货量": + return (src.get("货量") or src.get("货物数量") or "").strip() + return (src.get(key) or "").strip() + + def ask_clarify( *, facts: dict[str, str] | None = None, @@ -119,11 +143,14 @@ def ask_clarify( skip = {"报价日期"} missing = [k for k in missing_keys if k and k not in skip] missing_set = set(missing) + sea = (transport_mode or "").strip() == "海运" + rows = _SEA_CLARIFY if sea else _AIR_CLARIFY + known = _sea_known_value if sea else _air_known_value lines = ["请确认并补充以下信息:"] if transport_mode: lines.append(f"运输方式:{transport_mode}") - for key, label, _example in _AIR_CLARIFY: - val = _air_known_value(src, key) + for key, label, _example in rows: + val = known(src, key) if val: lines.append(f"{label}:{val}") lines.append("") @@ -132,7 +159,7 @@ def ask_clarify( if "运输方式" in missing_set: any_missing = True lines.append("运输方式:(如:空运)") - for key, label, example in _AIR_CLARIFY: + for key, label, example in rows: if key in missing_set: any_missing = True lines.append(f"{label}:(如:{example})") @@ -155,6 +182,10 @@ def ask_missing(display_names: Iterable[str]) -> str: "体积(CBM)": "体积", "包装类型": "包装方式", "包装方式": "包装方式", + "货物数量": "货量", + "货量": "货量", + "整柜或拼柜": "整柜或拼柜", + "箱型箱量": "箱型箱量", } keys: list[str] = [] for name in display_names: @@ -207,6 +238,21 @@ def inquiry_field_lines( cargo = (facts.get("品名") or "-").strip() or "-" pack = (facts.get("包装方式") or facts.get("包装类型") or "-").strip() or "-" quote_date = (facts.get("报价日期") or "-").strip() or "-" + if (transport_mode or "").strip() == "海运": + qty = (facts.get("货量") or facts.get("货物数量") or "-").strip() or "-" + load = (facts.get("整柜或拼柜") or "-").strip() or "-" + box = (facts.get("箱型箱量") or "-").strip() or "-" + return [ + f"工单号:{work_order_no}", + f"运输方式:{transport_mode}", + f"起运港:{origin}", + f"目的港:{dest}", + f"品名:{cargo}", + f"货物数量:{qty}", + f"整柜或拼柜:{load}", + f"箱型箱量:{box}", + f"报价日期:{quote_date}", + ] return [ f"工单号:{work_order_no}", f"运输方式:{transport_mode}", @@ -221,14 +267,22 @@ def inquiry_field_lines( ] -def inquiry_card(*, work_order_no: str, facts: dict[str, str], first_or_same: str = "first") -> str: +def inquiry_card( + *, + work_order_no: str, + facts: dict[str, str], + first_or_same: str = "first", + transport_mode: str = "空运", +) -> str: """ 建单后的询价确认。 无标题、无 [test]。first_or_same 保留兼容,不再对销售展示。 """ _ = first_or_same - lines = inquiry_field_lines(work_order_no=work_order_no, facts=facts) + lines = inquiry_field_lines( + work_order_no=work_order_no, facts=facts, transport_mode=transport_mode + ) lines.append("工单已创建,正在查询TMS报价。") return "\n".join(lines) @@ -241,6 +295,10 @@ def canonical_button(raw: str) -> str: # 新卡 EventKey 带工单号:skip_collab:WO…,避免点旧卡落到最新单 if text.startswith(f"{BTN_SKIP_COLLAB_KEY}:"): return BTN_SKIP_COLLAB + if text in {BTN_PULL_COLLAB, BTN_PULL_COLLAB_KEY} or text.startswith( + f"{BTN_PULL_COLLAB_KEY}:" + ): + return BTN_PULL_COLLAB if text in {BTN_ADOPT_EXCEL, BTN_ADOPT_EXCEL_CARD, BTN_ADOPT_EXCEL_KEY} or text.startswith( f"{BTN_ADOPT_EXCEL_KEY}:" ): @@ -328,6 +386,7 @@ def _work_order_in_token(raw: str) -> str: return "" for prefix in ( f"{BTN_SKIP_COLLAB_KEY}:", + f"{BTN_PULL_COLLAB_KEY}:", f"{BTN_ADOPT_EXCEL_KEY}:", f"{BTN_ADOPT_PDF_KEY}:", f"{BTN_REJECT_KEY}:", @@ -416,6 +475,51 @@ def quote_area_fee_text(quote: dict[str, Any] | None) -> str: return "\n".join(fee_lines) +# 企微引用条过长会被客户端裁成省略号。海运常有 6~8 项费用,必须走文本明细。 +_QUOTE_AREA_SAFE_CHARS = 80 + + +def quote_area_safe_text(quote: dict[str, Any] | None) -> str: + """ + 卡片引用条:短报价原样;超长只留合计,避免半截费用名。 + + 完整明细由 sea_quote_detail_text 另发一条文本。空运卡仍用 quote_area_fee_text。 + """ + full = quote_area_fee_text(quote) + if len(full) <= _QUOTE_AREA_SAFE_CHARS: + return full + src = dict(quote or {}) + header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip() + total = money_text(str(src.get("total") or "-").strip() or "-", header_ccy) + n = len(quote_fee_lines(src)) + if n: + return f"总销售价合计:{total}\n共{n}项费用,详见上一条明细" + return f"总销售价合计:{total}" + + +def sea_quote_detail_text( + *, + work_order_no: str, + quote: dict[str, Any], + first_or_same: str = "first", + history_work_order_no: str = "", + status: str = "已报价", +) -> str: + """海运完整价格明细文本。不受卡片引用条字数限制。""" + src = dict(quote or {}) + source = str(src.get("source_label") or "TMS标准报价").replace(" ", "") + lines = [source, f"{work_order_no}价格明细"] + lines.extend( + quote_detail_lines( + src, + first_or_same=first_or_same, + history_work_order_no=history_work_order_no, + status=status, + ) + ) + return "\n".join(lines) + + def collab_hint(work_order_no: str) -> str: """价格卡协同说明:无「内部协同」前缀,紧挨生成报价单按钮上方。""" return ( @@ -681,7 +785,230 @@ def skip_collab_ack() -> str: return "好的,已跳过内部协同。正在按 TMS 标准报价生成报价单草稿…" -def scheme_card(*, work_order_no: str, status: str, facts: dict[str, str], quote: dict[str, Any]) -> str: +def sea_collab_ask() -> str: + """海运有价卡协同问句。""" + return "是否拉产品人员进群协同?" + + +def sea_miss_ask() -> str: + """海运无价卡协同问句。""" + return "未查到合适报价,是否需要拉内部产品人员进群,进行手工协同报价?" + + +def sea_tms_hit_card( + *, + work_order_no: str, + quote: dict[str, Any], + first_or_same: str = "first", + history_work_order_no: str = "", + status: str = "已报价", +) -> str: + """海运有价:价格明细 + 拉群问句 + 两个按钮。空运卡片函数保持不动。""" + src = dict(quote or {}) + source = str(src.get("source_label") or "TMS标准报价").replace(" ", "") + lines = [source, f"{work_order_no}价格明细"] + lines.extend( + quote_detail_lines( + src, + first_or_same=first_or_same, + history_work_order_no=history_work_order_no, + status=status, + ) + ) + lines.append("") + lines.append(sea_collab_ask()) + lines.append(BTN_PULL_COLLAB) + lines.append(BTN_SKIP_COLLAB) + return "\n".join(lines) + + +def sea_tms_hit_wecom_payload( + *, + work_order_no: str, + quote: dict[str, Any], + first_or_same: str = "first", + history_work_order_no: str = "", + status: str = "已报价", +) -> dict[str, Any]: + """海运有价企微卡:价格明细 + 问句 + 拉群 / 跳过协同。""" + _ = first_or_same, history_work_order_no, status + src = dict(quote or {}) + source = str(src.get("source_label") or "TMS标准报价").replace(" ", "") + meta = "\n".join( + [ + f"时效:{tms_eta_display(src)}", + f"报价有效期:{tms_valid_until_display(src)}", + "", + sea_collab_ask(), + ] + ) + return { + "msgtype": "template_card", + "template_card": { + "card_type": "button_interaction", + "source": {"desc": source, "desc_color": 0}, + "main_title": {"title": f"{work_order_no}价格明细"}, + "quote_area": { + "type": 0, + "quote_text": quote_area_safe_text(src), + }, + "sub_title_text": meta, + "task_id": card_task_id("quote", work_order_no), + "button_list": [ + { + "text": BTN_PULL_COLLAB, + "style": 1, + "key": f"{BTN_PULL_COLLAB_KEY}:{work_order_no}", + }, + { + "text": BTN_SKIP_COLLAB, + "style": 1, + "key": f"{BTN_SKIP_COLLAB_KEY}:{work_order_no}", + }, + ], + }, + } + + +def sea_tms_miss(*, work_order_no: str) -> str: + """海运无价文字兜底:工单号、暂无匹配、询价中、拉群问句。""" + no = (work_order_no or "").strip() + return "\n".join( + [ + f"询价工单 {no}", + "暂无匹配报价", + "工单状态:询价中", + sea_miss_ask(), + BTN_PULL_COLLAB, + ] + ) + + +def sea_tms_miss_wecom_payload(*, work_order_no: str, action_url: str = "") -> dict[str, Any]: + """ + 海运无价一张卡:工单号、暂无匹配、询价中、拉群问句、一个按钮。 + + 禁止再写「请手动拉人到原有群」。 + """ + no = (work_order_no or "").strip() + card: dict[str, Any] = { + "card_type": "button_interaction", + "source": {"desc": "TMS查价结果", "desc_color": 0}, + "main_title": { + "title": f"询价工单 {no}", + "desc": "暂无匹配报价", + }, + "sub_title_text": "\n".join( + [ + "需求已解析并创建工单。", + sea_miss_ask(), + ] + ), + "horizontal_content_list": [ + {"keyname": "工单状态", "value": "询价中"}, + {"keyname": "查价结果", "value": "暂无匹配报价"}, + ], + "task_id": card_task_id("miss", no), + "button_list": [ + { + "text": BTN_PULL_COLLAB, + "style": 1, + "key": f"{BTN_PULL_COLLAB_KEY}:{no}", + } + ], + } + url = (action_url or "").strip() + if url: + card["card_action"] = {"type": 1, "url": url} + return {"msgtype": "template_card", "template_card": card} + + +def _role_label(name: str, role: str) -> str: + """姓名前加岗位,避免「销售甲、乙」被读成两人都是销售。已带岗位前缀的不再重复加。""" + text = (name or "").strip() + if not text: + return role + if text.startswith(role): + return text + return f"{role}{text}" + + +def sea_group_created(*, work_order_no: str, sales_name: str, product_names: list[str]) -> str: + """拉群成功后的私聊。已邀请写成「销售某某、产品某某」,岗位不能省略。""" + no = (work_order_no or "").strip() + sales = _role_label(sales_name, "销售") + products = "、".join( + _role_label(n, "产品") for n in (product_names or []) if (n or "").strip() + ) or "产品" + return ( + f"协同群已创建,群名与工单号一致:{no}。" + f"已邀请:{sales}、{products}。请在群聊中继续确认报价。" + ) + + +def sea_group_exists(*, work_order_no: str) -> str: + """同一工单已有群,不再新建。""" + no = (work_order_no or "").strip() + return f"协同群已在,群名与工单号一致:{no}。请到该群继续确认报价。" + + +def sea_group_no_staff() -> str: + """没有可拉的产品人员,不建空群。""" + return "没有可拉的产品人员。请先在后台「员工管理」维护本线路的海运产品岗后再试。" + + +def sea_group_create_fail(*, reason: str = "") -> str: + """建群失败,按钮保持可点。""" + extra = (reason or "").strip() + if extra: + return f"建群失败:{extra}。请稍后重试。" + return "建群失败,请稍后重试。" + + +def sea_group_brief( + *, + work_order_no: str, + facts: dict[str, str], + quote: dict[str, Any] | None, + collab_facts: dict[str, str] | None, + sales_name: str, + product_names: list[str], + has_price: bool, +) -> str: + """ + 群里自动摘要:@销售 @产品 + 工单号 + 询价字段 + 协同字段 + TMS 报价。 + + 协同没填写 -。无价写明暂无匹配报价。 + """ + no = (work_order_no or "").strip() + sales = (sales_name or "").strip() or "销售" + products = [n.strip() for n in (product_names or []) if (n or "").strip()] + at_bits = [f"@{sales}"] + [f"@{n}" for n in products] + lines = [" ".join(at_bits), f"工单号:{no}", "询价字段:"] + lines.extend( + inquiry_field_lines(work_order_no=no, facts=facts, transport_mode="海运")[1:] + ) + lines.append("协同补充字段:") + collab = dict(collab_facts or {}) + for key in SEA_COLLAB_FIELDS: + val = (collab.get(key) or "").strip() or "-" + lines.append(f"{key}:{val}") + lines.append("TMS报价:") + if has_price and quote: + lines.extend(quote_detail_lines(quote)) + else: + lines.append("TMS 暂无匹配报价 / 未查到合适报价") + return "\n".join(lines) + + +def scheme_card( + *, + work_order_no: str, + status: str, + facts: dict[str, str], + quote: dict[str, Any], + transport_mode: str = "空运", +) -> str: """ 报价方案文本兜底(模板卡发失败时仍给销售看)。 @@ -692,7 +1019,9 @@ def scheme_card(*, work_order_no: str, status: str, facts: dict[str, str], quote # 方案卡不展示询价「报价日期」,改展示 TMS 报价有效期 body = "\n".join( line - for line in inquiry_field_lines(work_order_no=work_order_no, facts=facts) + for line in inquiry_field_lines( + work_order_no=work_order_no, facts=facts, transport_mode=transport_mode + ) if not line.startswith("报价日期:") ) eta = tms_eta_display(src) @@ -718,16 +1047,31 @@ def _clip(raw: str, limit: int) -> str: return text if len(text) <= limit else text[: limit - 1] + "…" +def scheme_transport_mode(business_line: str = "", fallback: str = "空运") -> str: + """会话业务线 → 方案卡运输方式。海运不得再写成空运。""" + raw = (business_line or "").strip() + line = raw.upper() + if line == "SEA" or raw == "海" or "海运" in raw: + return "海运" + if line == "LAND" or raw == "陆" or "陆运" in raw: + return "陆运" + if line == "AIR" or raw == "空" or "空运" in raw: + return "空运" + return (fallback or "空运").strip() or "空运" + + def scheme_horizontal_rows( *, work_order_no: str, facts: dict[str, str], quote: dict[str, Any], + transport_mode: str = "空运", ) -> list[dict[str, str]]: """ 卡片字段:一行一项,每格独立字数,不会像副标题整段截成 8CB…。 企微 horizontal 最多 6 条。运输方式写进航线行,避免丢字段。 + 海运用货物数量/整柜或拼柜/箱型箱量,禁止套空运件数毛重体积包装。 不用 sub_title 堆字段(它会排到列表前面,且超长必截断)。 """ src = facts or {} @@ -735,6 +1079,19 @@ def scheme_horizontal_rows( origin = (src.get("起运港") or "-").strip() or "-" dest = (src.get("目的港") or "-").strip() or "-" cargo = (src.get("品名") or "-").strip() or "-" + mode = (transport_mode or "空运").strip() or "空运" + if mode == "海运": + qty = (src.get("货量") or src.get("货物数量") or "-").strip() or "-" + load = (src.get("整柜或拼柜") or "-").strip() or "-" + box = (src.get("箱型箱量") or "-").strip() or "-" + return [ + {"keyname": "工单号", "value": _clip(work_order_no, 26)}, + {"keyname": "航线", "value": _clip(f"海运 {origin}→{dest}", 26)}, + {"keyname": "货物", "value": _clip(f"{cargo};{qty}", 26)}, + {"keyname": "箱型", "value": _clip(f"{load};{box}", 26)}, + {"keyname": "时效", "value": _clip(tms_eta_display(q), 26)}, + {"keyname": "报价有效期", "value": _clip(tms_valid_until_display(q), 26)}, + ] pieces = _with_unit(str(src.get("件数") or ""), "件") weight = _with_unit(str(src.get("毛重") or ""), "KGS") volume = _with_unit(str(src.get("体积") or ""), "CBM") @@ -755,11 +1112,13 @@ def scheme_wecom_payload( facts: dict[str, str], quote: dict[str, Any], status: str = "已报价", + transport_mode: str = "空运", ) -> dict[str, Any]: """ 一张报价方案卡:标题带工单号、引用条、字段、三个短按钮。 按钮文案必须短:生成Excel / 生成PDF / 不采用。长文案横排会被裁。 + 海运/空运字段分开,禁止海运卡再出件数毛重体积。 """ _ = status src = dict(quote or {}) @@ -772,10 +1131,17 @@ def scheme_wecom_payload( "main_title": {"title": f"{work_order_no}报价方案"}, "quote_area": { "type": 0, - "quote_text": quote_area_fee_text(src), + "quote_text": ( + quote_area_safe_text(src) + if (transport_mode or "").strip() == "海运" + else quote_area_fee_text(src) + ), }, "horizontal_content_list": scheme_horizontal_rows( - work_order_no=work_order_no, facts=facts, quote=src + work_order_no=work_order_no, + facts=facts, + quote=src, + transport_mode=transport_mode, ), "task_id": card_task_id("scheme", work_order_no), "button_list": [ diff --git a/inquiry-agent/agent/policy/sea_text_flow.py b/inquiry-agent/agent/policy/sea_text_flow.py new file mode 100644 index 0000000..79fbc41 --- /dev/null +++ b/inquiry-agent/agent/policy/sea_text_flow.py @@ -0,0 +1,617 @@ +""" +海运文字询价确定性流程。 + +本文件职责:海运七项补问 → 建单出确认卡 → 主账查 TMS → 有价/无价卡 → 跳过协同或拉群。 +空运 Owner 仍在 air_text_flow,禁止把海运再堆回去。 +调用:handler 在 Worker/inbox 线程;禁止回调线程同步跑。 +禁止:直连 TMS;本地拆箱型;用户文字冒充群报价版本。 +""" + +from __future__ import annotations + +import logging +import threading +from typing import Any, Optional + +from agent.ledger.http_ledger import HttpLedger +from agent.ledger.memory_ledger import MemoryLedger +from agent.llm.extract_text import extract_inquiry_snapshot +from agent.policy import inquiry_copy as copy +from agent.policy.air_text_flow import ( + AirTextInquiryFlow, + FlowSession, + ReplyFn, + build_ledger, +) +from agent.schema.field_validate import validate_required_fields +from agent.schema.tms_sea_query import assemble_sea_query + +logger = logging.getLogger(__name__) + +# 会话存档账号:每个协同群必拉。私聊「已邀请」不写这些人。 +# 姓名与后台员工管理一致;userid 以员工表/环境变量为准,源码不写死。 +ARCHIVE_SEAT_NAMES = ("询价机器人",) + + +class SeaTextInquiryFlow(AirTextInquiryFlow): + """ + 海运文字询价 Owner。 + + 复用空运后半段(跳过协同 / 出方案卡 / Excel PDF / 成交),只改查价前与卡片、拉群。 + """ + + def __init__(self, ledger: Any = None, group_client: Any = None) -> None: + super().__init__(ledger=ledger) + self._group_client = group_client + + def on_text( + self, + *, + sender_id: str, + text: str, + reply: ReplyFn, + injected_facts: Optional[dict[str, Any]] = None, + injected_mode: str = "", + b_result: Optional[dict[str, Any]] = None, + allow_b: bool = False, + chat_fn: Optional[Any] = None, + inbound_raw: Optional[dict[str, Any]] = None, + ) -> str: + """ + 处理一句海运私聊文字。返回阶段名供单测断言。 + + 先认按钮,再抽字段。运输方式必须是 SEA 才建单。 + """ + if not (sender_id or "").strip(): + return "reject_no_sender" + sess = self.session_for_action(sender_id, inbound_raw, text=text) + btn = self.match_button(text, sess) + if btn: + return self.on_button( + sender_id=sender_id, + action=btn, + reply=reply, + card_meta=inbound_raw, + ) + + snap = extract_inquiry_snapshot( + text, + injected_facts=injected_facts, + injected_mode=injected_mode, + b_result=b_result, + allow_b=allow_b, + chat_fn=chat_fn, + ) + mode = (snap.get("business_line") or "").upper() + facts = dict(snap.get("facts") or {}) + if sess and sess.phase in {"clarify", "need_mode"}: + from agent.llm.extract_text import detect_transport_mode + from agent.schema.field_validate import harvest_oral_measures + + merged = dict(sess.facts) + merged.update(facts) + facts = harvest_oral_measures(text, merged) + mode = mode or (sess.business_line or "").upper() + if not mode: + mode = detect_transport_mode(f"{sess.immutable_text or ''}\n{text}") + + if not mode: + reply(copy.ASK_TRANSPORT) + self._save( + FlowSession( + sender_id=sender_id, + thread_id=sender_id, + phase="need_mode", + business_line="", + facts=facts, + immutable_text=sess.immutable_text if sess and sess.immutable_text else text, + ) + ) + return "need_mode" + + if mode != "SEA": + if mode == "LAND": + reply(copy.ASK_LAND_SUBTYPE) + return "need_land_subtype" + # 空运交给空运 Owner,避免海运流程半做空运 + from agent.policy.air_text_flow import get_air_text_flow + + return get_air_text_flow().on_text( + sender_id=sender_id, + text=text, + reply=reply, + injected_facts=facts, + injected_mode=mode, + b_result=b_result, + allow_b=allow_b, + chat_fn=chat_fn, + inbound_raw=inbound_raw, + ) + + check = validate_required_fields(facts=facts, business_line="SEA") + facts = dict(check["facts"]) + tms_ready = assemble_sea_query(facts) + facts = dict(tms_ready["facts"]) + if not check["ok"] or not tms_ready["ok"]: + keys = list(check["missing"] or []) + for key in tms_ready.get("missing") or []: + if key not in keys: + keys.append(key) + reply( + copy.ask_clarify( + facts=facts, + missing_keys=keys, + transport_mode="海运", + ) + ) + self._save( + FlowSession( + sender_id=sender_id, + thread_id=sender_id, + phase="clarify", + business_line="SEA", + facts=facts, + immutable_text=sess.immutable_text if sess and sess.immutable_text else text, + wait_version=(sess.wait_version + 1) if sess else 1, + ) + ) + return "clarify" + + return self._create_and_quote( + sender_id=sender_id, + facts=facts, + text=text, + reply=reply, + ) + + def _create_and_quote( + self, + *, + sender_id: str, + facts: dict[str, str], + text: str, + reply: ReplyFn, + ) -> str: + """建海运工单、先出确认卡、再查 TMS。""" + import uuid + + self._save( + FlowSession( + sender_id=sender_id, + thread_id=sender_id, + phase="quoting", + business_line="SEA", + facts=facts, + immutable_text=text, + ) + ) + created = self._ledger.create_ticket( + sender_id=sender_id, + business_line="SEA", + facts=facts, + idempotency_key=f"create:{sender_id}:{uuid.uuid4().hex}", + ) + no = str(created.get("work_order_no") or "").strip() + if not no: + reply("建单失败,请稍后把刚才那条再发一次。海运字段已经记下。") + return "ledger_fail" + first, history_wo = self._resolve_similar(created, facts=facts, current_no=no) + out_id = reply( + copy.inquiry_card( + work_order_no=no, + facts=facts, + first_or_same=first, + transport_mode="海运", + ) + ) or "" + self._wait_inquiry_sent(str(out_id), work_order_no=no) + + tms = self._ledger.query_tms(work_order_no=no, facts=facts, business_line="SEA") + sess = FlowSession( + sender_id=sender_id, + thread_id=sender_id, + business_line="SEA", + facts=facts, + work_order_no=no, + first_or_same=first, + history_work_order_no=history_wo, + immutable_text=text, + wait_version=1, + ) + if not tms.get("ok"): + if tms.get("classification") == "TMS_QUERY_INCOMPLETE": + logger.warning("tms.query 未出站 wo=%s class=%s", no, tms.get("classification")) + reply("TMS 查询失败,请稍后重试。工单已创建:" + no) + sess.phase = "tms_tech" + self._save(sess) + return "tms_tech" + if not tms.get("has_price"): + self._emit_tms_miss(reply, no) + sess.phase = "tms_miss" + sess.status = "询价中" + sess.allowed = (copy.BTN_PULL_COLLAB,) + self._save(sess) + return "tms_miss" + + quote = tms["quote"] + up = self._ledger.upsert_quote(work_order_no=no, quote=quote, to_status="已报价") + if not up.get("ok"): + reply("主账写入报价失败,不能当作已报价。") + sess.phase = "ledger_fail" + self._save(sess) + return "ledger_fail" + sess.quote = quote + sess.status = "已报价" + sess.phase = "wait_collab" + sess.allowed = (copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB) + sess.wait_version += 1 + self._save(sess) + self._emit_sea_hit( + reply, + work_order_no=no, + quote=quote, + first_or_same=first, + history_work_order_no=history_wo, + status="已报价", + ) + return "wait_collab" + + def _resolve_similar( + self, + created: dict[str, Any], + *, + facts: dict[str, str], + current_no: str, + ) -> tuple[str, str]: + history = str(created.get("similar_work_order_no") or "").strip() + first = str(created.get("first_or_same") or "first") + if first == "same" and history and history != current_no: + return "same", history + finder = getattr(self._ledger, "find_similar_open", None) + if not callable(finder): + return "first", "" + try: + similar = finder( + facts=facts, business_line="SEA", exclude_work_order_no=current_no + ) or {} + except TypeError: + similar = finder(facts=facts, business_line="SEA") or {} + except Exception: + logger.warning("find_similar 失败,按首次询价展示 current=%s", current_no) + return "first", "" + history = str(similar.get("work_order_no") or "").strip() + if similar.get("found") and history and history != current_no: + return "same", history + return "first", "" + + def _emit_tms_miss(self, reply: ReplyFn, work_order_no: str) -> None: + """海运无价:一张带拉群按钮的卡,不再发手动拉人群句。""" + from agent.config import get_settings + + reply( + copy.sea_tms_miss(work_order_no=work_order_no), + copy.sea_tms_miss_wecom_payload( + work_order_no=work_order_no, + action_url=(get_settings().public_base_url or "").strip(), + ), + ) + + def _session_from_ledger(self, *, sender_id: str, view: dict[str, Any]) -> FlowSession: + sess = super()._session_from_ledger(sender_id=sender_id, view=view) + if sess.business_line == "SEA" and sess.phase == "wait_collab": + sess.allowed = (copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB) + if sess.business_line == "SEA" and sess.phase == "tms_miss": + sess.allowed = (copy.BTN_PULL_COLLAB,) + sess.collab_chat_id = str(view.get("collab_chat_id") or "") + sess.collab_facts = dict(view.get("collab_facts") or {}) + return sess + + def _emit_sea_hit( + self, + reply: ReplyFn, + *, + work_order_no: str, + quote: dict[str, Any], + first_or_same: str = "first", + history_work_order_no: str = "", + status: str = "已报价", + ) -> None: + """ + 先发完整价格明细文本,再发带按钮的卡。 + + 企微只发卡时,引用条超长会被截断;文本不受限。 + """ + reply( + copy.sea_quote_detail_text( + work_order_no=work_order_no, + quote=quote, + first_or_same=first_or_same, + history_work_order_no=history_work_order_no, + status=status, + ) + ) + reply( + copy.sea_tms_hit_card( + work_order_no=work_order_no, + quote=quote, + first_or_same=first_or_same, + history_work_order_no=history_work_order_no, + status=status, + ), + copy.sea_tms_hit_wecom_payload( + work_order_no=work_order_no, + quote=quote, + first_or_same=first_or_same, + history_work_order_no=history_work_order_no, + status=status, + ), + ) + + def _replay_wait(self, sess: FlowSession, reply: ReplyFn, *, source: str) -> str: + if sess.business_line == "SEA" and sess.phase == "wait_collab": + self._emit_sea_hit( + reply, + work_order_no=sess.work_order_no, + quote=sess.quote, + first_or_same=sess.first_or_same, + history_work_order_no=sess.history_work_order_no, + status=sess.status or "已报价", + ) + return "wait_collab" + if sess.business_line == "SEA" and sess.phase == "tms_miss": + self._emit_tms_miss(reply, sess.work_order_no) + return "tms_miss" + return super()._replay_wait(sess, reply, source=source) + + def on_button( + self, + *, + sender_id: str, + action: str, + reply: ReplyFn, + card_meta: Optional[dict[str, Any]] = None, + ) -> str: + sess = self.session_for_action(sender_id, card_meta) + if sess and action == copy.BTN_PULL_COLLAB and action in sess.allowed: + return self._pull_collab_group(sess, reply, card_meta=card_meta) + return super().on_button( + sender_id=sender_id, action=action, reply=reply, card_meta=card_meta + ) + + def _pull_collab_group( + self, + sess: FlowSession, + reply: ReplyFn, + card_meta: Optional[dict[str, Any]] = None, + ) -> str: + """ + 点「拉产品进群协同」:按线路拉产品人,新建群,私聊回执,按钮置灰。 + + 没人可拉 / 建群失败:说明原因,按钮保持可点。已有群:不新建,置灰。 + """ + no = (sess.work_order_no or "").strip() + existing = (sess.collab_chat_id or "").strip() + if not existing: + getter = getattr(self._ledger, "get_ticket", None) + ticket = getter(work_order_no=no) if callable(getter) else None + existing = str(getattr(ticket, "collab_chat_id", "") or "") + if existing: + sess.collab_chat_id = existing + sess.allowed = () + sess.phase = "collab_group" + self._save(sess) + self._grey_clicked_card(reply, card_meta) + self._ensure_archive_seats_in_group(existing) + reply(copy.sea_group_exists(work_order_no=no)) + return "collab_group" + + staff = self._match_sea_staff(sess) + if not staff: + reply(copy.sea_group_no_staff()) + return sess.phase or "wait_collab" + + userids = [sess.sender_id] + product_names: list[str] = [] + product_ids: list[str] = [] + for row in staff: + uid = str(row.get("wecomId") or row.get("wecom_id") or "").strip() + name = str(row.get("name") or "").strip() or uid + if uid and uid not in userids: + userids.append(uid) + product_ids.append(uid) + product_names.append(name) + if not product_ids: + reply(copy.sea_group_no_staff()) + return sess.phase or "wait_collab" + + # 销售 + 产品之后,必须再拉会话存档账号;没有也不挡建群。 + for uid in self._archive_seat_userids(): + if uid not in userids: + userids.append(uid) + + client = self._group_client or _default_group_client() + created = client.create_group(name=no, userids=userids) or {} + if not created.get("ok") or not created.get("chat_id"): + reply(copy.sea_group_create_fail(reason=str(created.get("error") or ""))) + return sess.phase or "wait_collab" + + chat_id = str(created.get("chat_id") or "").strip() + sales_name = self._sales_display_name(sess.sender_id) + binder = getattr(self._ledger, "bind_collab_group", None) + if callable(binder): + binder( + work_order_no=no, + chat_id=chat_id, + member_ids=userids, + product_names=product_names, + ) + + brief = copy.sea_group_brief( + work_order_no=no, + facts=sess.facts, + quote=sess.quote, + collab_facts=sess.collab_facts, + sales_name=sales_name, + product_names=product_names, + has_price=bool(sess.quote and sess.status == "已报价"), + ) + client.send_group(chat_id=chat_id, content=brief) + + sess.collab_chat_id = chat_id + sess.invited_userids = tuple(userids) + sess.invited_sales_name = sales_name + sess.invited_product_names = tuple(product_names) + sess.phase = "collab_group" + sess.allowed = () + sess.wait_version += 1 + self._save(sess) + self._grey_clicked_card(reply, card_meta) + reply( + copy.sea_group_created( + work_order_no=no, + sales_name=sales_name, + product_names=product_names, + ) + ) + return "collab_group" + + def _match_sea_staff(self, sess: FlowSession) -> list[dict[str, Any]]: + """主账按起运/目的匹配启用的海运产品岗。""" + matcher = getattr(self._ledger, "match_sea_staff", None) + if not callable(matcher): + return [] + try: + rows = matcher( + origin=str((sess.facts or {}).get("起运港") or ""), + destination=str((sess.facts or {}).get("目的港") or ""), + ) or [] + except Exception: + logger.exception("匹配海运产品人员失败 wo=%s", sess.work_order_no) + return [] + return [dict(x) for x in rows if isinstance(x, dict)] + + def _archive_seat_userids(self) -> list[str]: + """ + 会话存档账号 userid 列表。 + + 先读环境变量 WECOM_ARCHIVE_SEAT_USER_IDS,再按姓名「询价机器人」查员工管理。 + 查不到只记日志,不把建群判失败。 + """ + ids: list[str] = [] + from agent.config import get_settings + + seat = str(getattr(get_settings(), "wecom_archive_seat_user_ids", "") or "").strip() + for part in seat.replace(";", ",").split(","): + uid = part.strip() + if uid and uid not in ids: + ids.append(uid) + finder = getattr(self._ledger, "find_staff_by_name", None) + if callable(finder): + for name in ARCHIVE_SEAT_NAMES: + try: + row = finder(name=name) or {} + except Exception: + logger.exception("查会话存档账号失败 name=%s", name) + continue + uid = str(row.get("wecomId") or row.get("wecom_id") or "").strip() + if uid and uid not in ids: + ids.append(uid) + if not ids: + logger.warning("协同群缺少会话存档账号:员工管理未找到「询价机器人」且未配置 WECOM_ARCHIVE_SEAT_USER_IDS") + return ids + + def _ensure_archive_seats_in_group(self, chat_id: str) -> None: + """已有群补拉会话存档账号;失败只记日志,不改销售侧已有群提示。""" + seats = self._archive_seat_userids() + if not chat_id or not seats: + return + client = self._group_client or _default_group_client() + adder = getattr(client, "add_group_members", None) + if not callable(adder): + return + try: + adder(chat_id=chat_id, userids=seats) + except Exception: + logger.exception("已有协同群补拉会话存档失败 chat=%s", chat_id) + + def _sales_display_name(self, sender_id: str) -> str: + """私聊「已邀请」用的销售名;没有展示名就用 userid。""" + finder = getattr(self._ledger, "get_staff_by_wecom_id", None) + if callable(finder): + try: + row = finder(wecom_id=sender_id) or {} + name = str(row.get("name") or "").strip() + if name: + return name + except Exception: + logger.warning("查销售姓名失败 sender=%s", sender_id) + return sender_id + + def apply_collab_facts( + self, + *, + work_order_no: str, + facts: dict[str, str], + sender_id: str, + ) -> dict[str, Any]: + """ + 群里静默写入协同字段。不改六态、不回复、不出报价版本。 + + 只合并非空协同键。 + """ + from agent.schema.field_validate import SEA_COLLAB_FIELDS + + no = (work_order_no or "").strip() + cleaned = { + k: str(v).strip() + for k, v in (facts or {}).items() + if k in SEA_COLLAB_FIELDS and str(v or "").strip() + } + if not no or not cleaned: + return {"ok": False, "updated": []} + with self._lock: + sess = self._by_ticket.get(no) + if sess: + merged = dict(sess.collab_facts) + merged.update(cleaned) + sess.collab_facts = merged + self._save(sess) + writer = getattr(self._ledger, "patch_collab_facts", None) + if callable(writer): + writer(work_order_no=no, facts=cleaned, sender_id=sender_id) + logger.info("sea.collab_fields wo=%s keys=%s by=%s", no, sorted(cleaned), sender_id) + return {"ok": True, "updated": sorted(cleaned)} + + +def _default_group_client() -> Any: + """生产走企微应用建群;未配置时返回会失败的空客户端。""" + from agent.channel.outbox.sender import WeComAppClient + from agent.config import get_settings + + settings = get_settings() + return WeComAppClient( + corp_id=settings.wecom_corp_id, + secret=settings.wecom_secret, + agent_id=int(settings.wecom_agent_id or "1000010"), + ) + + +_FLOW: Optional[SeaTextInquiryFlow] = None +_FLOW_GUARD = threading.Lock() + + +def get_sea_text_flow() -> SeaTextInquiryFlow: + """进程内一份海运流程;测试可 new 独立实例。""" + global _FLOW + with _FLOW_GUARD: + if _FLOW is None: + _FLOW = SeaTextInquiryFlow() + return _FLOW + + +def reset_sea_text_flow_for_test() -> SeaTextInquiryFlow: + """单测重置全局海运实例(强制内存账本)。""" + global _FLOW + with _FLOW_GUARD: + _FLOW = SeaTextInquiryFlow(MemoryLedger()) + return _FLOW diff --git a/inquiry-agent/agent/quote_templates/match.py b/inquiry-agent/agent/quote_templates/match.py index bd4c654..2b6932b 100644 --- a/inquiry-agent/agent/quote_templates/match.py +++ b/inquiry-agent/agent/quote_templates/match.py @@ -41,10 +41,18 @@ def normalize_biz(raw: str) -> str: def _as_keywords(raw: Any) -> list[str]: + """摊开关键词。列表里若整段是 [海运,整柜] 也要拆开,与 Java asKeywords 一致。""" if raw is None: return [] if isinstance(raw, (list, tuple)): - return [str(x).strip() for x in raw if str(x).strip()] + out: list[str] = [] + for x in raw: + s = str(x).strip() + if s.startswith("["): + out.extend(_as_keywords(s)) + elif s: + out.append(s) + return out text = str(raw).strip() if not text or text.lower() == "null": return [] diff --git a/inquiry-agent/agent/routing/dispatch.py b/inquiry-agent/agent/routing/dispatch.py index 36e4d91..809a9d2 100644 --- a/inquiry-agent/agent/routing/dispatch.py +++ b/inquiry-agent/agent/routing/dispatch.py @@ -13,6 +13,7 @@ from typing import Callable, Optional from agent.channel.wecom.models import InboundMessage from agent.handlers import abnormal, card_action, close_deal, continue_thread, new_inquiry from agent.handlers.echo_text import handle_echo_text +from agent.handlers.group_collab import handle_group_collab from agent.handlers.text_inquiry import handle_text_inquiry, handle_text_other from agent.routing.decision import RouteDecision @@ -134,6 +135,11 @@ def dispatch_inbound(message: InboundMessage, decision: RouteDecision) -> str: 返回:实际调用的 intent 名,或 unknown_intent / skipped。 副作用:仅 handler 内产生;本函数不做业务。 """ + if (message.chat_type or "") == "group": + phase = handle_group_collab(message) + logger.info("dispatch group_collab phase=%s chat=%s", phase, message.chat_id) + return "group_collab" + if not message.has_sender() and decision.intent != "reject_no_sender": logger.warning("dispatch 拒绝:无 sender_id") HANDLER_REGISTRY["reject_no_sender"](message) diff --git a/inquiry-agent/agent/schema/field_validate.py b/inquiry-agent/agent/schema/field_validate.py index 3cd0f35..abd9536 100644 --- a/inquiry-agent/agent/schema/field_validate.py +++ b/inquiry-agent/agent/schema/field_validate.py @@ -32,6 +32,21 @@ AIR_REQUIRED_FIELDS = ( ) AIR_DISPLAY = {key: label for key, label in AIR_REQUIRED_FIELDS} +# 海运询价必填 7 项:销售侧名称。内部键「货量」对外叫「货物数量」。 +SEA_REQUIRED_FIELDS = ( + ("起运港", "起运港"), + ("目的港", "目的港"), + ("品名", "品名"), + ("货量", "货物数量"), + ("整柜或拼柜", "整柜或拼柜"), + ("箱型箱量", "箱型箱量"), + ("报价日期", "报价日期"), +) +SEA_DISPLAY = {key: label for key, label in SEA_REQUIRED_FIELDS} + +# 协同补充字段:拉群后可静默写入,不挡首次查价。 +SEA_COLLAB_FIELDS = ("贸易条款", "货好时间", "HS编码", "是否含油", "是否含电", "是否含磁") + # 口语里已经带上 TMS 文档规定的单位时,补进空字段。 # 只认「数字+单位」,不猜港口,不把长宽高换算成立方。 _RE_PIECES = re.compile(r"(? dic return harvest_named_air_route(raw, merged) +def project_sea_cargo_qty(facts: dict[str, str] | None) -> dict[str, str]: + """ + 海运货物数量投影:原话「15件」常被抽成空运「件数」。 + + 货量已有则不覆盖。件数有、货量空时,货量用件数。不猜吨位、不改空运校验。 + """ + merged = dict(facts or {}) + qty = (merged.get("货量") or merged.get("货物数量") or "").strip() + pieces = (merged.get("件数") or "").strip() + if not qty and pieces: + merged["货量"] = pieces + return merged + + def apply_quote_date_default(facts: dict[str, str]) -> dict[str, str]: """销售未提供报价日期时填当天;已有则不覆盖。""" merged = dict(facts) @@ -192,6 +223,8 @@ def display_name(internal_key: str, business_line: str = "AIR") -> str: line = (business_line or "").upper() if line == "AIR": return AIR_DISPLAY.get(internal_key, internal_key) + if line == "SEA": + return SEA_DISPLAY.get(internal_key, internal_key) return internal_key @@ -212,6 +245,8 @@ def validate_required_fields( contract = require_formal_contract("inquiry-required-fields-v1") normalized = apply_quote_date_default(normalize_facts(facts)) line = (business_line or "").strip().upper() + if line == "SEA": + normalized = project_sea_cargo_qty(normalized) if line not in {"SEA", "AIR", "LAND"}: return { "ok": False, diff --git a/inquiry-agent/agent/schema/fingerprint.py b/inquiry-agent/agent/schema/fingerprint.py index d5cfde9..2a92995 100644 --- a/inquiry-agent/agent/schema/fingerprint.py +++ b/inquiry-agent/agent/schema/fingerprint.py @@ -33,7 +33,7 @@ def inquiry_fingerprint(facts: dict[str, Any], *, business_line: str) -> str: 空运箱型不在空运必填里;海运箱型箱量若有则纳入。 """ line = (business_line or "").strip().upper() - keys = ["起运港", "目的港", "品名", "件数", "货量", "毛重", "体积", "箱型箱量", "报价日期"] + keys = ["起运港", "目的港", "品名", "件数", "货量", "毛重", "体积", "箱型箱量", "整柜或拼柜", "报价日期"] parts = [line] for key in keys: parts.append(str(facts.get(key) or "").strip()) diff --git a/inquiry-agent/agent/schema/tms_sea_query.py b/inquiry-agent/agent/schema/tms_sea_query.py new file mode 100644 index 0000000..7bf668f --- /dev/null +++ b/inquiry-agent/agent/schema/tms_sea_query.py @@ -0,0 +1,115 @@ +""" +海运查价参数:对照 TMS V2 /quote/v2/query 是否带全。 + +本文件职责:询价卡片出现前,确认海运七项能拼出查价正文。 +港口只带名称,不做港口字典对应;箱型箱量保持原文字段,containerItems 只透传模型投影。 +禁止:本地拆箱型;因对不上港口字典跳过 TMS;改六态。 +""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +from agent.schema.field_validate import apply_quote_date_default + +logger = logging.getLogger(__name__) + + +def _load_type(raw: str) -> str: + """整柜/拼柜原文透传;出站可带 FCL/LCL 提示,不拒绝口语。""" + text = (raw or "").strip() + return text + + +def parse_container_items(facts: dict[str, Any] | None) -> list[dict[str, Any]]: + """ + 只收下 DeepSeek B 已投影的 containerItems。 + + 本地不得从「箱型箱量」字符串拆 type/quantity。 + """ + src = dict(facts or {}) + raw = src.get("containerItems") + if isinstance(raw, str) and raw.strip(): + try: + raw = json.loads(raw) + except json.JSONDecodeError: + return [] + if not isinstance(raw, list): + return [] + items: list[dict[str, Any]] = [] + for row in raw: + if isinstance(row, dict): + items.append(dict(row)) + return items + + +def assemble_sea_query(facts: dict[str, Any] | None) -> dict[str, Any]: + """ + 把已采集海运字段拼成 TMS 查价参数。 + + 不齐则 ok=False,missing 给销售补问;不建单、不出卡片。 + 齐了也不因没有港口代码拦网。报价日期未说则填当天。 + """ + src = apply_quote_date_default( + {str(k): str(v) for k, v in dict(facts or {}).items() if v is not None} + ) + origin = str(src.get("起运港") or "").strip() + dest = str(src.get("目的港") or "").strip() + cargo = str(src.get("品名") or "").strip() + qty = str(src.get("货量") or src.get("货物数量") or "").strip() + load = _load_type(str(src.get("整柜或拼柜") or "")) + box = str(src.get("箱型箱量") or "").strip() + quote_date = str(src.get("报价日期") or "").strip() + + missing: list[str] = [] + missing_display: list[str] = [] + pairs = ( + ("起运港", "起运港", origin), + ("目的港", "目的港", dest), + ("品名", "品名", cargo), + ("货量", "货物数量", qty), + ("整柜或拼柜", "整柜或拼柜", load), + ("箱型箱量", "箱型箱量", box), + ("报价日期", "报价日期", quote_date), + ) + for key, label, val in pairs: + if not val: + missing.append(key) + missing_display.append(label) + + ok = not missing + out_facts = dict(src) + if origin: + out_facts["起运港"] = origin + if dest: + out_facts["目的港"] = dest + if cargo: + out_facts["品名"] = cargo + if qty: + out_facts["货量"] = qty + if load: + out_facts["整柜或拼柜"] = load + if box: + out_facts["箱型箱量"] = box + if quote_date: + out_facts["报价日期"] = quote_date + items = parse_container_items(src) + if items: + out_facts["containerItems"] = items + + logger.info( + "tms.sea.assemble ok=%s origin=%s dest=%s load=%s missing=%s", + ok, + origin or "-", + dest or "-", + load or "-", + missing, + ) + return { + "ok": ok, + "missing": missing, + "missing_display": missing_display, + "facts": out_facts, + } diff --git a/inquiry-agent/tests/test_air_text_inquiry.py b/inquiry-agent/tests/test_air_text_inquiry.py index aa35180..5977e0b 100644 --- a/inquiry-agent/tests/test_air_text_inquiry.py +++ b/inquiry-agent/tests/test_air_text_inquiry.py @@ -45,6 +45,9 @@ COMPLETE_AIR = { class AirTextInquiryTests(unittest.TestCase): def setUp(self) -> None: + from agent.redis_coord.runtime import shutdown_redis_runtime + + shutdown_redis_runtime() self.flow = AirTextInquiryFlow(MemoryLedger()) self.replies: list[str] = [] self.extras: list[dict] = [] diff --git a/inquiry-agent/tests/test_continue_ticket.py b/inquiry-agent/tests/test_continue_ticket.py index a94aed8..c37cdd2 100644 --- a/inquiry-agent/tests/test_continue_ticket.py +++ b/inquiry-agent/tests/test_continue_ticket.py @@ -41,6 +41,11 @@ COMPLETE_AIR = { class ContinueTicketTests(unittest.TestCase): + def setUp(self) -> None: + from agent.redis_coord.runtime import shutdown_redis_runtime + + shutdown_redis_runtime() + def test_model_contract_accepts_continue_intent(self) -> None: digest = "a" * 64 parsed = parse_ordered_actions_arguments( @@ -182,6 +187,36 @@ class ContinueTicketTests(unittest.TestCase): self.assertEqual(phase, "wait_adopt") self.assertTrue(any("报价方案" in x for x in replies)) + def test_resume_after_quote_file_is_deal(self) -> None: + ledger = MemoryLedger() + created = ledger.create_ticket( + sender_id="u6", business_line="AIR", facts=COMPLETE_AIR + ) + wo = created["work_order_no"] + ledger.upsert_quote( + work_order_no=wo, + quote={"total": "USD 1", "eta": "-", "valid_until": "-"}, + to_status="已报价", + ) + ledger.render_quote( + work_order_no=wo, + business_line="AIR", + facts=COMPLETE_AIR, + quote={"total": "USD 1"}, + ) + flow = AirTextInquiryFlow(ledger) + replies: list[str] = [] + + def reply(text: str, extra=None) -> None: + replies.append(text) + _ = extra + + phase = flow.resume_historical( + sender_id="u6", work_order_no=wo, reply=reply + ) + self.assertEqual(phase, "wait_deal") + self.assertTrue(any("成交" in x or copy.BTN_DEAL in x for x in replies)) + def test_resume_missing_wo_asks(self) -> None: flow = AirTextInquiryFlow(MemoryLedger()) replies: list[str] = [] diff --git a/inquiry-agent/tests/test_quote_template_match.py b/inquiry-agent/tests/test_quote_template_match.py index 7976738..359bcd8 100644 --- a/inquiry-agent/tests/test_quote_template_match.py +++ b/inquiry-agent/tests/test_quote_template_match.py @@ -53,6 +53,24 @@ class QuoteTemplateMatchTests(unittest.TestCase): self.assertIsNone(hit) self.assertIn("IT运维", FAIL_MESSAGE) + def test_broken_bracket_keyword_still_hits_sea(self) -> None: + hit = pick_quote_template( + templates=[ + { + "templateId": "s1", + "name": "海运报价单模板", + "bizType": "海运", + "keywords": ["[海运,整柜,FCL]"], + "isDefault": False, + }, + ], + business_line="SEA", + facts={"整柜或拼柜": "整柜", "目的港": "巴生西港"}, + ) + self.assertIsNotNone(hit) + self.assertEqual(hit.match_type, "keyword") + self.assertEqual(hit.name, "海运报价单模板") + def test_sea_ignored_for_air(self) -> None: hit = pick_quote_template( templates=[ diff --git a/inquiry-agent/tests/test_sea_text_inquiry.py b/inquiry-agent/tests/test_sea_text_inquiry.py new file mode 100644 index 0000000..3b33fcd --- /dev/null +++ b/inquiry-agent/tests/test_sea_text_inquiry.py @@ -0,0 +1,836 @@ +""" +海运文字询价主路径单测(不连企微 / 真实 TMS / 8180)。 + +覆盖:海运补问、询价确认卡、有价/无价卡、跳过协同、拉群、群里静默补协同字段。 +空运话术不得出现在海运路径。 +""" + +from __future__ import annotations + +import os +import sys +import unittest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +os.environ.setdefault("YTD_ENV", "test") +os.environ.setdefault("REDIS_BACKEND", "memory") +os.environ.setdefault("CHECKPOINT_BACKEND", "memory") +os.environ.setdefault("MESSAGE_STORE_BACKEND", "memory") +os.environ.setdefault("LLM_DATA_USAGE_CONFIRMED", "false") +os.environ.setdefault("LLM_ALLOW_NETWORK", "false") +os.environ.setdefault("LEDGER_BACKEND", "memory") + +from agent.channel.wecom.models import InboundMessage +from agent.handlers.group_collab import handle_group_collab +from agent.handlers.text_inquiry import handle_text_inquiry +from agent.ledger.memory_ledger import MemoryLedger +from agent.policy import inquiry_copy as copy +from agent.policy.sea_text_flow import SeaTextInquiryFlow, reset_sea_text_flow_for_test +from agent.schema.field_validate import validate_required_fields +from agent.schema.tms_sea_query import assemble_sea_query + + +COMPLETE_SEA = { + "起运港": "上海", + "目的港": "洛杉矶", + "品名": "普货", + "货量": "20吨", + "整柜或拼柜": "整柜", + "箱型箱量": "1x40HQ", +} + + +class FakeGroupClient: + """单测假企微建群:不打外网。""" + + def __init__(self) -> None: + self.created: list[dict] = [] + self.sent: list[dict] = [] + self.fail = False + self.next_chat_id = "wr_sea_1" + + def create_group(self, *, name: str, userids: list[str]) -> dict: + if self.fail: + return {"ok": False, "error": "wecom_create_fail", "chat_id": ""} + self.created.append({"name": name, "userids": list(userids)}) + return {"ok": True, "chat_id": self.next_chat_id, "error": ""} + + def add_group_members(self, *, chat_id: str, userids: list[str]) -> dict: + self.created.append({"name": "", "userids": list(userids), "chat_id": chat_id, "add": True}) + return {"ok": True} + + def send_group(self, *, chat_id: str, content: str) -> dict: + self.sent.append({"chat_id": chat_id, "content": content}) + return {"ok": True} + + +class SeaTextInquiryTests(unittest.TestCase): + def setUp(self) -> None: + self.ledger = MemoryLedger() + self.ledger.set_quote_templates( + [ + { + "templateId": "mem-default-sea", + "name": "海运默认", + "bizType": "海运", + "keywords": [], + "isDefault": True, + } + ] + ) + self.groups = FakeGroupClient() + self.flow = SeaTextInquiryFlow(self.ledger, group_client=self.groups) + self.replies: list[str] = [] + self.extras: list[dict] = [] + + def _reply(self, text: str, extra=None) -> None: + self.replies.append(text) + self.extras.append(dict(extra) if extra else {}) + + def test_sea_pieces_fill_cargo_qty(self) -> None: + facts = { + "起运港": "上海", + "目的港": "洛杉矶", + "品名": "普货", + "件数": "15件", + "整柜或拼柜": "整柜", + "箱型箱量": "1x40HQ", + } + check = validate_required_fields(facts=facts, business_line="SEA") + self.assertTrue(check["ok"]) + self.assertEqual(check["facts"]["货量"], "15件") + self.assertNotIn("货量", check["missing"]) + + def test_sea_missing_container_only_asks_container(self) -> None: + facts = { + "起运港": "上海", + "目的港": "洛杉矶", + "品名": "茶叶", + "货量": "20吨", + "整柜或拼柜": "整柜", + } + check = validate_required_fields(facts=facts, business_line="SEA") + self.assertFalse(check["ok"]) + self.assertEqual(check["missing"], ["箱型箱量"]) + self.assertIn("箱型箱量", check["missing_display"]) + self.assertNotIn("件数", check["missing"]) + self.assertNotIn("毛重", check["missing"]) + text = copy.ask_clarify( + facts=check["facts"], + missing_keys=check["missing"], + transport_mode="海运", + ) + self.assertIn("运输方式:海运", text) + self.assertIn("起运港:上海", text) + self.assertIn("货物数量:20吨", text) + self.assertIn("箱型箱量:(如", text) + self.assertNotIn("件数", text) + self.assertNotIn("重量", text) + self.assertNotIn("报价日期:(如", text) + self.assertIn("报价日期:", text) + + def test_complete_sea_inquiry_card_uses_seven_fields(self) -> None: + phase = self.flow.on_text( + sender_id="s1", + text="海运完整", + reply=self._reply, + injected_facts=COMPLETE_SEA, + injected_mode="SEA", + ) + self.assertEqual(phase, "wait_collab") + joined = "\n".join(self.replies) + self.assertIn("运输方式:海运", joined) + self.assertIn("起运港:上海", joined) + self.assertIn("目的港:洛杉矶", joined) + self.assertIn("货物数量:20吨", joined) + self.assertIn("整柜或拼柜:整柜", joined) + self.assertIn("箱型箱量:1x40HQ", joined) + self.assertIn("正在查询TMS报价", joined) + self.assertNotIn("件数:", joined.split("正在查询TMS报价")[0]) + self.assertNotIn("本轮先开通空运", joined) + sess = self.flow.session_of("s1") + self.assertEqual(sess.business_line, "SEA") + self.assertEqual(sess.status, "已报价") + + def test_handler_routes_sea_not_air_deferred(self) -> None: + reset_sea_text_flow_for_test() + from agent.channel.queue import MemoryMessageStore + + store = MemoryMessageStore() + msg = InboundMessage( + sender_id="s2", + message_id="m-sea-1", + content="海运询价", + ) + phase = handle_text_inquiry( + msg, + injected_facts=COMPLETE_SEA, + injected_mode="SEA", + flow=self.flow, + store=store, + ) + self.assertEqual(phase, "wait_collab") + self.assertNotEqual(phase, "sea_deferred") + + +class SeaTmsCardTests(unittest.TestCase): + def setUp(self) -> None: + self.ledger = MemoryLedger() + self.groups = FakeGroupClient() + self.flow = SeaTextInquiryFlow(self.ledger, group_client=self.groups) + self.replies: list[str] = [] + self.extras: list[dict] = [] + + def _reply(self, text: str, extra=None) -> None: + self.replies.append(text) + self.extras.append(dict(extra) if extra else {}) + + def test_assemble_sea_query_does_not_need_air_measures(self) -> None: + ready = assemble_sea_query(COMPLETE_SEA) + self.assertTrue(ready["ok"]) + self.assertEqual(ready["missing"], []) + self.assertNotIn("件数", ready.get("missing") or []) + + def test_long_fee_lines_go_to_text_not_clipped_card(self) -> None: + """海运多项费用:完整明细必须在文本里,卡片引用条不得半截。""" + long_quote = { + "total": "2287.65", + "currency": "CNY", + "eta": "1天", + "valid_until": "2026-09-30", + "source_label": "TMS 标准报价", + "fee_lines": [ + {"name": "海运费", "amount": "100.00", "currency": "USD"}, + {"name": "燃油附加费", "amount": "15.00", "currency": "USD"}, + {"name": "操作费", "amount": "680.00", "currency": "CNY"}, + {"name": "文件费", "amount": "450.00", "currency": "CNY"}, + {"name": "封条费(SEAI)", "amount": "50.00", "currency": "CNY"}, + {"name": "设备费", "amount": "50.00", "currency": "CNY"}, + {"name": "报关费", "amount": "250.00", "currency": "CNY"}, + ], + } + text = copy.sea_quote_detail_text( + work_order_no="WO202609150029", quote=long_quote + ) + self.assertIn("海运费:USD 100.00", text) + self.assertIn("报关费:CNY 250.00", text) + self.assertIn("总销售价合计:CNY 2287.65", text) + card_text = copy.quote_area_safe_text(long_quote) + self.assertIn("总销售价合计:CNY 2287.65", card_text) + self.assertNotIn("海运费:USD 100.00\n燃油附加费", card_text) + self.assertLessEqual(len(card_text), 80) + payload = copy.sea_tms_hit_wecom_payload( + work_order_no="WO202609150029", quote=long_quote + ) + self.assertEqual( + payload["template_card"]["quote_area"]["quote_text"], card_text + ) + + def test_sea_hit_card_has_price_and_two_buttons(self) -> None: + phase = self.flow.on_text( + sender_id="s1", + text="海运有价", + reply=self._reply, + injected_facts=COMPLETE_SEA, + injected_mode="SEA", + ) + self.assertEqual(phase, "wait_collab") + joined = "\n".join(self.replies) + self.assertIn("价格明细", joined) + self.assertIn("是否拉产品人员进群协同", joined) + self.assertIn(copy.BTN_PULL_COLLAB, joined) + self.assertIn(copy.BTN_SKIP_COLLAB, joined) + 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]) + + def test_sea_miss_one_card_with_pull_button(self) -> None: + facts = dict(COMPLETE_SEA) + facts["目的港"] = "无价港" + phase = self.flow.on_text( + sender_id="s1", + text="海运无价", + reply=self._reply, + injected_facts=facts, + injected_mode="SEA", + ) + self.assertEqual(phase, "tms_miss") + sess = self.flow.session_of("s1") + self.assertEqual(sess.status, "询价中") + joined = "\n".join(self.replies) + self.assertIn("暂无匹配报价", joined) + self.assertIn("询价中", joined) + self.assertIn("未查到合适报价", joined) + self.assertIn("是否需要拉内部产品人员进群", joined) + self.assertIn(copy.BTN_PULL_COLLAB, 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"].get("button_list") or []] + self.assertEqual(buttons, [copy.BTN_PULL_COLLAB]) + + def test_sea_tech_fail_is_not_miss(self) -> None: + facts = dict(COMPLETE_SEA) + facts["tms_force"] = "tech" + phase = self.flow.on_text( + sender_id="s1", + text="海运技术失败", + reply=self._reply, + injected_facts=facts, + injected_mode="SEA", + ) + self.assertEqual(phase, "tms_tech") + self.assertIn("TMS 查询失败", "\n".join(self.replies)) + self.assertNotIn(copy.BTN_PULL_COLLAB, "\n".join(self.replies)) + + +class SeaSkipAndPullTests(unittest.TestCase): + def setUp(self) -> None: + os.environ["WECOM_ARCHIVE_SEAT_USER_IDS"] = "" + from agent.config import get_settings + + get_settings.cache_clear() + self.ledger = MemoryLedger() + self.ledger.set_quote_templates( + [ + { + "templateId": "mem-default-sea", + "name": "海运默认", + "bizType": "海运", + "keywords": [], + "isDefault": True, + } + ] + ) + self.groups = FakeGroupClient() + self.flow = SeaTextInquiryFlow(self.ledger, group_client=self.groups) + self.replies: list[str] = [] + self.extras: list[dict] = [] + + def _reply(self, text: str, extra=None) -> str: + self.replies.append(text) + self.extras.append(dict(extra) if extra else {}) + return "" + + def _quote_first(self, sender: str = "s1") -> None: + self.flow.on_text( + sender_id=sender, + text="海运有价", + reply=self._reply, + injected_facts=COMPLETE_SEA, + injected_mode="SEA", + ) + + def test_skip_collab_reuses_air_scheme(self) -> None: + self._quote_first() + phase = self.flow.on_button( + sender_id="s1", + action=copy.BTN_SKIP_COLLAB, + reply=self._reply, + card_meta={"response_code": "rc-sea-1"}, + ) + self.assertEqual(phase, "wait_adopt") + self.assertIn("已跳过内部协同", "\n".join(self.replies)) + scheme = "\n".join(x for x in self.replies if "报价方案" in x) + self.assertIn("运输方式:海运", scheme) + self.assertIn("货物数量:", scheme) + self.assertIn("整柜或拼柜:", scheme) + self.assertIn("箱型箱量:", scheme) + self.assertNotIn("件数:", scheme) + self.assertNotIn("重量:", scheme) + self.assertNotIn("体积:", scheme) + self.assertNotIn("包装类型:", scheme) + cards = [ + x.get("template_card") or {} + for x in self.extras + if (x.get("template_card") or {}).get("main_title", {}).get("title", "").endswith("报价方案") + ] + self.assertTrue(cards) + rows = {r["keyname"]: r["value"] for r in cards[0].get("horizontal_content_list") or []} + self.assertIn("海运", rows.get("航线", "")) + self.assertIn("箱型", rows) + self.assertNotIn("包装", rows) + again = self.flow.on_button( + sender_id="s1", action=copy.BTN_SKIP_COLLAB, reply=self._reply + ) + self.assertEqual(again, "card_discard") + + def test_pull_group_success_greys_buttons(self) -> None: + self.ledger.set_sea_staff( + [ + { + "name": "张产品", + "wecomId": "prod1", + "roleCode": "sea", + "routes": "海运·全部线路", + "status": "active", + } + ] + ) + self._quote_first() + wo = self.flow.session_of("s1").work_order_no + phase = self.flow.on_button( + sender_id="s1", + action=copy.BTN_PULL_COLLAB, + reply=self._reply, + card_meta={"response_code": "rc-pull-1"}, + ) + self.assertEqual(phase, "collab_group") + joined = "\n".join(self.replies) + self.assertIn("协同群已创建", joined) + self.assertIn(wo, joined) + self.assertIn("销售", joined) + self.assertIn("产品张产品", joined) + self.assertEqual(self.groups.created[0]["name"], wo) + self.assertIn("prod1", self.groups.created[0]["userids"]) + self.assertIn("s1", self.groups.created[0]["userids"]) + self.assertNotIn("AI001", self.groups.created[0]["userids"]) + self.assertTrue(self.groups.sent) + group_text = self.groups.sent[0]["content"] + self.assertIn(wo, group_text) + self.assertIn("起运港", group_text) + self.assertIn("贸易条款", group_text) + self.assertIn("TMS", group_text) + sess = self.flow.session_of("s1") + self.assertNotIn(copy.BTN_PULL_COLLAB, sess.allowed) + self.assertNotIn(copy.BTN_SKIP_COLLAB, sess.allowed) + again = self.flow.on_button( + sender_id="s1", action=copy.BTN_SKIP_COLLAB, reply=self._reply + ) + self.assertEqual(again, "card_discard") + + def test_pull_group_includes_archive_bot(self) -> None: + self.ledger.upsert_staff( + { + "name": "询价机器人", + "wecomId": "AI001", + "roleCode": "sales", + "status": "active", + } + ) + self.ledger.set_sea_staff( + [ + { + "name": "张产品", + "wecomId": "prod1", + "roleCode": "sea", + "routes": "海运全线", + "status": "active", + } + ] + ) + self._quote_first() + self.flow.on_button( + sender_id="s1", + action=copy.BTN_PULL_COLLAB, + reply=self._reply, + ) + userids = self.groups.created[0]["userids"] + self.assertIn("AI001", userids) + self.assertIn("s1", userids) + self.assertIn("prod1", userids) + joined = "\n".join(self.replies) + self.assertIn("产品张产品", joined) + self.assertNotIn("产品询价机器人", joined) + self.assertNotIn("销售询价机器人", joined) + + def test_pull_no_staff_keeps_button(self) -> None: + self.ledger.set_sea_staff([]) + self._quote_first() + phase = self.flow.on_button( + sender_id="s1", + action=copy.BTN_PULL_COLLAB, + reply=self._reply, + ) + self.assertEqual(phase, "wait_collab") + self.assertIn("没有可拉的产品人员", "\n".join(self.replies)) + self.assertEqual(self.groups.created, []) + self.assertIn(copy.BTN_PULL_COLLAB, self.flow.session_of("s1").allowed) + + def test_pull_already_has_group_no_second(self) -> None: + self.ledger.set_sea_staff( + [ + { + "name": "李产品", + "wecomId": "prod2", + "roleCode": "sea", + "routes": "上海", + "status": "active", + } + ] + ) + self._quote_first() + self.flow.on_button( + sender_id="s1", + action=copy.BTN_PULL_COLLAB, + reply=self._reply, + ) + n = len(self.groups.created) + self.flow.session_of("s1").allowed = (copy.BTN_PULL_COLLAB,) + phase = self.flow.on_button( + sender_id="s1", + action=copy.BTN_PULL_COLLAB, + reply=self._reply, + ) + self.assertEqual(phase, "collab_group") + self.assertEqual(len(self.groups.created), n) + self.assertIn("群已在", "\n".join(self.replies)) + + def test_miss_group_message_writes_no_quote(self) -> None: + self.ledger.set_sea_staff( + [ + { + "name": "王产品", + "wecomId": "prod3", + "roleCode": "sea", + "routes": "海运·全部线路", + "status": "active", + } + ] + ) + facts = dict(COMPLETE_SEA) + facts["目的港"] = "无价港" + self.flow.on_text( + sender_id="s1", + text="海运无价", + reply=self._reply, + injected_facts=facts, + injected_mode="SEA", + ) + self.flow.on_button( + sender_id="s1", + action=copy.BTN_PULL_COLLAB, + reply=self._reply, + ) + self.assertTrue(self.groups.sent) + self.assertIn("暂无匹配报价", self.groups.sent[0]["content"]) + + +class SeaGroupCollabFieldTests(unittest.TestCase): + def setUp(self) -> None: + self.ledger = MemoryLedger() + self.groups = FakeGroupClient() + self.flow = SeaTextInquiryFlow(self.ledger, group_client=self.groups) + self.replies: list[str] = [] + + def _reply(self, text: str, extra=None) -> None: + self.replies.append(text) + + def test_group_text_writes_incoterm_silently(self) -> None: + self.ledger.set_sea_staff( + [ + { + "name": "赵产品", + "wecomId": "prod4", + "roleCode": "sea", + "routes": "海运·全部线路", + "status": "active", + } + ] + ) + self.flow.on_text( + sender_id="sales1", + text="海运有价", + reply=self._reply, + injected_facts=COMPLETE_SEA, + injected_mode="SEA", + ) + self.flow.on_button( + sender_id="sales1", + action=copy.BTN_PULL_COLLAB, + reply=self._reply, + ) + wo = self.flow.session_of("sales1").work_order_no + ticket = self.ledger.get_ticket(work_order_no=wo) + chat_id = ticket.collab_chat_id + n = len(self.replies) + phase = handle_group_collab( + InboundMessage( + sender_id="prod4", + message_id="g1", + content="贸易条款:FOB", + chat_id=chat_id, + chat_type="group", + ), + flow=self.flow, + ledger=self.ledger, + ) + self.assertEqual(phase, "collab_field_saved") + self.assertEqual(len(self.replies), n) + self.assertEqual(ticket.collab_facts.get("贸易条款"), "FOB") + + def test_group_price_chat_is_ignored(self) -> None: + self.ledger.set_sea_staff( + [ + { + "name": "赵产品", + "wecomId": "prod4", + "roleCode": "sea", + "routes": "海运·全部线路", + "status": "active", + } + ] + ) + self.flow.on_text( + sender_id="sales1", + text="海运有价", + reply=self._reply, + injected_facts=COMPLETE_SEA, + injected_mode="SEA", + ) + self.flow.on_button( + sender_id="sales1", + action=copy.BTN_PULL_COLLAB, + reply=self._reply, + ) + wo = self.flow.session_of("sales1").work_order_no + ticket = self.ledger.get_ticket(work_order_no=wo) + phase = handle_group_collab( + InboundMessage( + sender_id="prod4", + message_id="g2", + content="报价 1200 美元可以做", + chat_id=ticket.collab_chat_id, + chat_type="group", + ), + flow=self.flow, + ledger=self.ledger, + ) + self.assertEqual(phase, "group_ignore") + self.assertFalse(ticket.quote_version > 1 and "1200" in str(ticket.quote)) + + +class SeaContinueTests(unittest.TestCase): + def setUp(self) -> None: + from agent.redis_coord.runtime import shutdown_redis_runtime + + shutdown_redis_runtime() + self.ledger = MemoryLedger() + self.ledger.set_quote_templates( + [ + { + "templateId": "mem-default-sea", + "name": "海运默认", + "bizType": "海运", + "keywords": [], + "isDefault": True, + } + ] + ) + self.flow = SeaTextInquiryFlow(self.ledger) + self.replies: list[str] = [] + self.extras: list[dict] = [] + + def _reply(self, text: str, extra=None) -> str: + self.replies.append(text) + self.extras.append(dict(extra) if extra else {}) + return "" + + def test_continue_after_skip_replays_sea_scheme_not_air(self) -> None: + """已跳过协同后再续办:必须海运方案卡,不能套空运件数包装。""" + from unittest.mock import patch + + from agent.channel.queue import MemoryMessageStore + from agent.handlers.continue_thread import handle_continue_thread + from agent.policy.air_text_flow import AirTextInquiryFlow + + self.flow.on_text( + sender_id="c1", + text="海运有价", + reply=self._reply, + injected_facts=COMPLETE_SEA, + injected_mode="SEA", + ) + self.flow.on_button( + sender_id="c1", + action=copy.BTN_SKIP_COLLAB, + reply=self._reply, + card_meta={"response_code": "rc-cont-1"}, + ) + wo = self.flow.session_of("c1").work_order_no + store = MemoryMessageStore() + msg = InboundMessage( + sender_id="c1", + message_id="m-cont-sea", + content=f"继续工单{wo}", + ) + air_trap = AirTextInquiryFlow(MemoryLedger()) + with patch( + "agent.handlers.continue_thread.get_sea_text_flow", return_value=self.flow + ), patch( + "agent.handlers.continue_thread.get_air_text_flow", return_value=air_trap + ): + phase = handle_continue_thread(msg, thread_id=wo, store=store) + self.assertEqual(phase, "wait_adopt") + cards = [ + (item.payload or {}).get("template_card") or {} + for item in store._outbox.values() + ] + titles = [ + (c.get("main_title") or {}).get("title") or "" for c in cards if c + ] + self.assertTrue(any(t == f"{wo}报价方案" for t in titles)) + rows = {} + for card in cards: + if (card.get("main_title") or {}).get("title") == f"{wo}报价方案": + rows = { + r["keyname"]: r["value"] + for r in card.get("horizontal_content_list") or [] + } + self.assertIn("海运", rows.get("航线", "")) + self.assertIn("箱型", rows) + self.assertNotIn("包装", rows) + + def test_continue_from_ledger_uses_sea_collab_card(self) -> None: + """进程无书签:主账海运已报价应回收海运协同卡,不是空运卡。""" + from unittest.mock import patch + + from agent.channel.queue import MemoryMessageStore + from agent.handlers.continue_thread import handle_continue_thread + from agent.policy.air_text_flow import AirTextInquiryFlow + + created = self.ledger.create_ticket( + sender_id="c2", business_line="SEA", facts=COMPLETE_SEA + ) + wo = created["work_order_no"] + self.ledger.upsert_quote( + work_order_no=wo, + quote={ + "total": "USD 1,200", + "eta": "18天", + "valid_until": "2026-09-20", + "source_label": "TMS 标准报价", + }, + to_status="已报价", + ) + empty = SeaTextInquiryFlow(self.ledger) + store = MemoryMessageStore() + msg = InboundMessage( + sender_id="c2", + message_id="m-cont-ledger", + content=f"继续工单{wo}", + ) + air_trap = AirTextInquiryFlow(MemoryLedger()) + with patch( + "agent.handlers.continue_thread.get_sea_text_flow", return_value=empty + ), patch( + "agent.handlers.continue_thread.get_air_text_flow", return_value=air_trap + ): + phase = handle_continue_thread(msg, thread_id=wo, store=store) + self.assertEqual(phase, "wait_collab") + sess = empty.session_by_ticket("c2", wo) + self.assertIsNotNone(sess) + self.assertEqual(sess.business_line, "SEA") + joined = "\n".join(item.content for item in store._outbox.values()) + self.assertIn(copy.BTN_PULL_COLLAB, joined) + self.assertIn("是否拉产品人员进群协同", joined) + self.assertNotIn("包装方式", joined) + + def test_continue_after_quote_file_returns_deal(self) -> None: + """报价单已发出后再续办:必须成交跟进,不能退回生成报价单。""" + from unittest.mock import patch + + from agent.channel.queue import MemoryMessageStore + from agent.handlers.continue_thread import handle_continue_thread + from agent.policy.air_text_flow import AirTextInquiryFlow + + self.flow.on_text( + sender_id="c3", + text="海运有价", + reply=self._reply, + injected_facts=COMPLETE_SEA, + injected_mode="SEA", + ) + self.flow.on_button( + sender_id="c3", + action=copy.BTN_SKIP_COLLAB, + reply=self._reply, + card_meta={"response_code": "rc-deal-1"}, + ) + wo = self.flow.session_of("c3").work_order_no + self.flow.on_button( + sender_id="c3", + action=copy.BTN_ADOPT_EXCEL, + reply=self._reply, + card_meta={"event_key": f"adopt_excel:{wo}", "response_code": "rc-xl"}, + ) + self.assertEqual(self.flow.session_of("c3").phase, "wait_deal") + empty = SeaTextInquiryFlow(self.ledger) + store = MemoryMessageStore() + msg = InboundMessage( + sender_id="c3", + message_id="m-cont-deal", + content=f"继续工单{wo}", + ) + air_trap = AirTextInquiryFlow(MemoryLedger()) + with patch( + "agent.handlers.continue_thread.get_sea_text_flow", return_value=empty + ), patch( + "agent.handlers.continue_thread.get_air_text_flow", return_value=air_trap + ): + phase = handle_continue_thread(msg, thread_id=wo, store=store) + self.assertEqual(phase, "wait_deal") + joined = "\n".join(item.content for item in store._outbox.values()) + self.assertTrue(any(x in joined for x in (copy.BTN_DEAL, "成交"))) + self.assertNotIn(copy.BTN_SKIP_COLLAB, joined) + + +class SeaGroupCopyTests(unittest.TestCase): + def test_scheme_card_uses_sea_fields(self) -> None: + facts = { + "起运港": "上海", + "目的港": "洛杉矶", + "品名": "普货", + "货量": "15件", + "整柜或拼柜": "整柜", + "箱型箱量": "1x40HQ", + "报价日期": "2026-09-15", + } + quote = {"total": "USD 1200", "eta": "18天", "valid_until": "2026-09-20"} + text = copy.scheme_card( + work_order_no="WO202609150021", + status="已报价", + facts=facts, + quote=quote, + transport_mode="海运", + ) + self.assertIn("运输方式:海运", text) + self.assertIn("货物数量:15件", text) + self.assertIn("箱型箱量:1x40HQ", text) + self.assertNotIn("件数:", text) + self.assertNotIn("包装类型:", text) + rows = { + x["keyname"]: x["value"] + for x in copy.scheme_horizontal_rows( + work_order_no="WO202609150021", + facts=facts, + quote=quote, + transport_mode="海运", + ) + } + self.assertIn("海运 上海→洛杉矶", rows["航线"]) + self.assertIn("15件", rows["货物"]) + self.assertIn("1x40HQ", rows["箱型"]) + self.assertNotIn("包装", rows) + + def test_invited_names_carry_role(self) -> None: + text = copy.sea_group_created( + work_order_no="WO202609150019", + sales_name="吴季林", + product_names=["罗小华"], + ) + self.assertEqual( + text, + "协同群已创建,群名与工单号一致:WO202609150019。" + "已邀请:销售吴季林、产品罗小华。请在群聊中继续确认报价。", + ) + + +if __name__ == "__main__": + unittest.main() 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 52f82af..5319785 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 @@ -34,6 +34,9 @@ public class InquiryAgentController { @Autowired private IInquiryQuoteTemplateService quoteTemplateService; + @Autowired + private org.jeecg.modules.inquiry.service.IInquiryStaffRoleService staffRoleService; + @Value("${inquiry.agent.callback-token:${AGENT_CALLBACK_TOKEN:}}") private String agentToken; @@ -122,7 +125,12 @@ public class InquiryAgentController { Map facts = asMap(body == null ? null : body.get("facts")); @SuppressWarnings("unchecked") Map quote = asMap(body == null ? null : body.get("quote")); - return Result.OK(quoteTemplateService.renderForAgent(workOrderNo, businessLine, facts, quote, sales)); + Map rendered = quoteTemplateService.renderForAgent( + workOrderNo, businessLine, facts, quote, sales); + if (rendered != null && Boolean.TRUE.equals(rendered.get("ok"))) { + workOrderService.markQuoteFileIssued(workOrderNo); + } + return Result.OK(rendered); } @ApiOperation("经主账查真 TMS(有价回报价 / 无价回暂无报价 / 技术失败)") @@ -135,6 +143,70 @@ public class InquiryAgentController { return Result.OK(workOrderService.queryTms(body)); } + @ApiOperation("海运拉群:按起运/目的匹配启用的产品·海运") + @PostMapping("/staff/matchSea") + public Result> matchSea(@RequestBody Map body, + HttpServletRequest http) { + if (!authed(http)) { + return Result.error(401, "unauthorized"); + } + String origin = firstStr(body, "origin", "起运港"); + String dest = firstStr(body, "destination", "目的港"); + Map out = new java.util.LinkedHashMap<>(); + out.put("staff", staffRoleService.matchSeaStaff(origin, dest)); + return Result.OK(out); + } + + @ApiOperation("按企微 userid 取员工展示名") + @PostMapping("/staff/byWecom") + public Result> staffByWecom(@RequestBody Map body, + HttpServletRequest http) { + if (!authed(http)) { + return Result.error(401, "unauthorized"); + } + return Result.OK(staffRoleService.findDisplayByWecomId(firstStr(body, "wecomId", "wecom_id"))); + } + + @ApiOperation("按员工姓名取企微 userid(会话存档询价机器人)") + @PostMapping("/staff/byName") + public Result> staffByName(@RequestBody Map body, + HttpServletRequest http) { + if (!authed(http)) { + return Result.error(401, "unauthorized"); + } + return Result.OK(staffRoleService.findDisplayByName(firstStr(body, "name"))); + } + + @ApiOperation("记下海运协同群(不改六态)") + @PostMapping("/ticket/bindCollabGroup") + public Result> bindCollabGroup(@RequestBody Map body, + HttpServletRequest http) { + if (!authed(http)) { + return Result.error(401, "unauthorized"); + } + return Result.OK(workOrderService.bindCollabGroup(body)); + } + + @ApiOperation("合并协同字段(不改六态、不写报价版本)") + @PostMapping("/ticket/patchCollabFacts") + public Result> patchCollabFacts(@RequestBody Map body, + HttpServletRequest http) { + if (!authed(http)) { + return Result.error(401, "unauthorized"); + } + return Result.OK(workOrderService.patchCollabFacts(body)); + } + + @ApiOperation("按协同群 chat_id 找回工单(只读)") + @PostMapping("/ticket/byCollabChat") + public Result> byCollabChat(@RequestBody Map body, + HttpServletRequest http) { + if (!authed(http)) { + return Result.error(401, "unauthorized"); + } + return Result.OK(workOrderService.findByCollabChat(firstStr(body, "chatId", "chat_id"))); + } + private boolean authed(HttpServletRequest http) { String got = http.getHeader("X-Agent-Token"); if (StringUtils.isBlank(got)) { 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 b346ceb..e686155 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 @@ -118,6 +118,27 @@ public class InquiryTicket implements Serializable { @ApiModelProperty("业务版本") private Integer version; + @ApiModelProperty("海运货量原文") + private String cargoQty; + + @ApiModelProperty("整柜或拼柜") + private String fclLcl; + + @ApiModelProperty("箱型箱量原文") + private String containerSpec; + + @ApiModelProperty("询价字段快照 JSON") + private String factsJson; + + @ApiModelProperty("协同字段快照 JSON") + private String collabFactsJson; + + @ApiModelProperty("协同群 chat_id") + private String collabChatId; + + @ApiModelProperty("协同群成员 userid JSON") + private String collabMemberIds; + private String createBy; @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss") diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryStaffRoleService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryStaffRoleService.java index 6794c80..66fe194 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryStaffRoleService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryStaffRoleService.java @@ -30,4 +30,19 @@ public interface IInquiryStaffRoleService extends IService { * @return 同步计数 */ StaffWecomSyncResult syncFromWeCom(String operator); + + /** + * 海运拉群:启用的产品·海运,按起运/目的匹配线路。 + */ + java.util.List> matchSeaStaff(String origin, String destination); + + /** + * 按企微 userid 取展示名。 + */ + java.util.Map findDisplayByWecomId(String wecomId); + + /** + * 按员工姓名取启用账号(会话存档「询价机器人」用)。 + */ + java.util.Map findDisplayByName(String name); } 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 1013166..63e3bbc 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 @@ -68,4 +68,25 @@ public interface IInquiryWorkOrderService extends IService { * 必须校验 salesWecomId 与工单发起人一致。 */ Map getForAgent(String workOrderNo, String salesWecomId); + + /** + * 报价文件已发给销售:写入报价载荷 quoteFileIssued,续办据此回到成交跟进。 + * 不改六态(仍是已报价,直到点成交/未成交/协商中)。 + */ + void markQuoteFileIssued(String workOrderNo); + + /** + * 记下海运协同群。不改六态。 + */ + Map bindCollabGroup(Map body); + + /** + * 合并协同字段。不改六态、不写报价版本。 + */ + Map patchCollabFacts(Map body); + + /** + * 用协同群 chat_id 找回工单。只读。 + */ + Map findByCollabChat(String chatId); } diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java index 3f330eb..466392c 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java @@ -661,15 +661,24 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl parseKeywords(String json) { if (StringUtils.isBlank(json)) { return Collections.emptyList(); } try { - return JSON.parseArray(json, String.class); - } catch (Exception e) { - return Collections.singletonList(json); + List parsed = JSON.parseArray(json, String.class); + if (parsed != null && !parsed.isEmpty()) { + return QuoteTemplateMatcher.asKeywords(parsed); + } + } catch (Exception ignored) { + // 非法 JSON:按方括号/逗号拆 } + return QuoteTemplateMatcher.asKeywords(json); } private static List extractUnmapped(String mappingJson) { 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 ad49692..4fbdd0b 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 @@ -132,4 +132,49 @@ public class InquiryStaffRoleServiceImpl extends ServiceImpl> matchSeaStaff(String origin, String destination) { + List rows = list(new QueryWrapper() + .eq("role_code", "sea") + .eq("status", "active")); + return org.jeecg.modules.inquiry.staff.SeaStaffMatcher.match(rows, origin, destination); + } + + @Override + public Map findDisplayByWecomId(String wecomId) { + InquiryStaffRole row = findByWecomId(wecomId); + Map out = new HashMap<>(); + if (row == null) { + return out; + } + out.put("name", row.getName()); + out.put("wecomId", row.getWecomId()); + out.put("roleCode", row.getRoleCode()); + return out; + } + + @Override + public Map findDisplayByName(String name) { + Map out = new HashMap<>(); + if (StringUtils.isBlank(name)) { + return out; + } + InquiryStaffRole row = getOne(new QueryWrapper() + .eq("name", name.trim()) + .eq("status", "active") + .isNotNull("wecom_id") + .ne("wecom_id", "") + .last("LIMIT 1"), false); + if (row == null) { + return out; + } + out.put("name", row.getName()); + out.put("wecomId", row.getWecomId()); + out.put("roleCode", row.getRoleCode()); + return out; + } } diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryWorkOrderServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryWorkOrderServiceImpl.java index ede3b7d..ba57d53 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryWorkOrderServiceImpl.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryWorkOrderServiceImpl.java @@ -301,6 +301,10 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl quote = new LinkedHashMap<>(); List quotes = quoteMapper.selectList(new LambdaQueryWrapper() .eq(InquiryQuote::getWorkOrderNo, no) @@ -545,7 +571,14 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl collabFacts = new LinkedHashMap<>(); + if (StringUtils.isNotBlank(ticket.getCollabFactsJson())) { + JSONObject parsed = JSON.parseObject(ticket.getCollabFactsJson()); + if (parsed != null) { + for (String key : parsed.keySet()) { + if (parsed.get(key) != null) { + collabFacts.put(key, String.valueOf(parsed.get(key))); + } + } + } + } + out.put("collab_facts", collabFacts); + out.put("collab_member_ids", parseMemberIds(ticket.getCollabMemberIds())); return out; } + /** + * 报价文件已发给销售。只打载荷标记,不改六态。 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void markQuoteFileIssued(String workOrderNo) { + String no = StringUtils.trimToEmpty(workOrderNo); + if (StringUtils.isBlank(no)) { + return; + } + List quotes = quoteMapper.selectList(new LambdaQueryWrapper() + .eq(InquiryQuote::getWorkOrderNo, no) + .orderByDesc(InquiryQuote::getCreateTime) + .last("LIMIT 1")); + if (quotes == null || quotes.isEmpty()) { + return; + } + InquiryQuote row = quotes.get(0); + JSONObject payload = StringUtils.isBlank(row.getPayloadJson()) + ? new JSONObject(true) + : JSON.parseObject(row.getPayloadJson()); + if (payload == null) { + payload = new JSONObject(true); + } + payload.put("quoteFileIssued", true); + row.setPayloadJson(payload.toJSONString()); + row.setUpdateTime(new Date()); + quoteMapper.updateById(row); + } + + /** + * 续办停点:文件已发出或已有附件,就不再回到出方案/协同。 + */ + private boolean quoteFileAlreadyIssued(String workOrderNo, Map quote) { + Object flag = quote == null ? null : quote.get("quoteFileIssued"); + if (Boolean.TRUE.equals(flag) || "true".equalsIgnoreCase(String.valueOf(flag))) { + return true; + } + Long n = attachmentMapper.selectCount(new LambdaQueryWrapper() + .eq(InquiryAttachment::getWorkOrderNo, workOrderNo)); + return n != null && n > 0; + } + + /** + * 记下海运协同群 chat_id 与成员。不改六态、不写报价版本。 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Map bindCollabGroup(Map body) { + String no = text(body, "workOrderNo", "work_order_no"); + String chatId = text(body, "chatId", "chat_id"); + InquiryTicket ticket = findByWorkOrderNo(no); + Map out = new HashMap<>(); + if (ticket == null || StringUtils.isBlank(chatId)) { + out.put("ok", false); + out.put("error", "ticket_or_chat_missing"); + return out; + } + ticket.setCollabChatId(chatId); + ticket.setCollabMemberIds(memberIdsJson(body)); + ticket.setUpdateTime(new Date()); + updateById(ticket); + appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "bind_collab_group", "agent", chatId); + out.put("ok", true); + out.put("chat_id", chatId); + out.put("work_order_no", no); + return out; + } + + /** + * 合并协同六字段。只写非空键;不改六态。 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Map patchCollabFacts(Map body) { + String no = text(body, "workOrderNo", "work_order_no"); + InquiryTicket ticket = findByWorkOrderNo(no); + Map out = new HashMap<>(); + if (ticket == null) { + out.put("ok", false); + out.put("error", "ticket_not_found"); + return out; + } + Map merged = new LinkedHashMap<>(); + if (StringUtils.isNotBlank(ticket.getCollabFactsJson())) { + JSONObject old = JSON.parseObject(ticket.getCollabFactsJson()); + if (old != null) { + for (String key : old.keySet()) { + if (old.get(key) != null) { + merged.put(key, String.valueOf(old.get(key))); + } + } + } + } + Object raw = body == null ? null : body.get("facts"); + if (raw instanceof Map) { + for (Map.Entry e : ((Map) raw).entrySet()) { + if (e.getKey() == null || e.getValue() == null) { + continue; + } + String val = String.valueOf(e.getValue()).trim(); + if (!val.isEmpty()) { + merged.put(String.valueOf(e.getKey()), val); + } + } + } + ticket.setCollabFactsJson(JSON.toJSONString(merged)); + ticket.setUpdateTime(new Date()); + updateById(ticket); + out.put("ok", true); + out.put("facts", merged); + return out; + } + + /** + * 按协同群找回工单。群里补字段用。只读。 + */ + @Override + public Map findByCollabChat(String chatId) { + Map out = new LinkedHashMap<>(); + String id = StringUtils.trimToEmpty(chatId); + out.put("found", false); + if (StringUtils.isBlank(id)) { + return out; + } + InquiryTicket ticket = getOne(new LambdaQueryWrapper() + .eq(InquiryTicket::getCollabChatId, id) + .last("LIMIT 1"), false); + if (ticket == null) { + return out; + } + out.put("found", true); + out.put("ok", true); + out.put("work_order_no", ticket.getWorkOrderNo()); + out.put("sales_wecom_id", StringUtils.defaultString(ticket.getSalesWecomId())); + out.put("salesWecomId", StringUtils.defaultString(ticket.getSalesWecomId())); + out.put("collab_chat_id", StringUtils.defaultString(ticket.getCollabChatId())); + out.put("collab_member_ids", parseMemberIds(ticket.getCollabMemberIds())); + out.put("memberIds", parseMemberIds(ticket.getCollabMemberIds())); + Map collabFacts = new LinkedHashMap<>(); + if (StringUtils.isNotBlank(ticket.getCollabFactsJson())) { + JSONObject parsed = JSON.parseObject(ticket.getCollabFactsJson()); + if (parsed != null) { + for (String key : parsed.keySet()) { + if (parsed.get(key) != null) { + collabFacts.put(key, String.valueOf(parsed.get(key))); + } + } + } + } + out.put("collab_facts", collabFacts); + return out; + } + + private static String memberIdsJson(Map body) { + if (body == null) { + return "[]"; + } + Object raw = body.get("memberIds"); + if (raw == null) { + raw = body.get("member_ids"); + } + if (raw instanceof java.util.Collection) { + return JSON.toJSONString(raw); + } + if (raw instanceof String && StringUtils.isNotBlank((String) raw)) { + return ((String) raw).trim(); + } + return "[]"; + } + + private static List parseMemberIds(String raw) { + List ids = new ArrayList<>(); + if (StringUtils.isBlank(raw)) { + return ids; + } + try { + com.alibaba.fastjson.JSONArray arr = JSON.parseArray(raw); + if (arr != null) { + for (int i = 0; i < arr.size(); i++) { + String uid = StringUtils.trimToEmpty(arr.getString(i)); + if (!uid.isEmpty()) { + ids.add(uid); + } + } + return ids; + } + } catch (Exception ignore) { + // 兼容逗号分隔旧值 + } + for (String part : raw.split("[,;]")) { + String uid = StringUtils.trimToEmpty(part); + if (!uid.isEmpty()) { + ids.add(uid); + } + } + return ids; + } + + private static String text(Map body, String... keys) { + if (body == null || keys == null) { + return ""; + } + for (String key : keys) { + Object val = body.get(key); + if (val != null && StringUtils.isNotBlank(String.valueOf(val))) { + return String.valueOf(val).trim(); + } + } + return ""; + } + private InquiryTicket findByWorkOrderNo(String workOrderNo) { return getOne(new LambdaQueryWrapper() .eq(InquiryTicket::getWorkOrderNo, workOrderNo) diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/staff/SeaStaffMatcher.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/staff/SeaStaffMatcher.java new file mode 100644 index 0000000..029c09b --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/staff/SeaStaffMatcher.java @@ -0,0 +1,63 @@ +package org.jeecg.modules.inquiry.staff; + +import org.apache.commons.lang3.StringUtils; +import org.jeecg.modules.inquiry.entity.InquiryStaffRole; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 海运拉群人员匹配:只看员工管理里岗位=产品·海运且启用的人。 + *

+ * 「海运·全部线路 / 海运全线」必拉;写了具体港口的,对得上本单起运港或目的港才拉。 + * 禁止:按销售 userid 伪造名单;不查企微通讯录。 + */ +public final class SeaStaffMatcher { + + private SeaStaffMatcher() { + } + + /** + * @param rows 员工岗位行 + * @param origin 本单起运港 + * @param destination 本单目的港 + * @return 给智能体的 {name, wecomId, routes} + */ + public static List> match(List rows, String origin, String destination) { + List> hits = new ArrayList<>(); + if (rows == null) { + return hits; + } + String from = StringUtils.trimToEmpty(origin); + String to = StringUtils.trimToEmpty(destination); + for (InquiryStaffRole row : rows) { + if (row == null) { + continue; + } + if (!"sea".equalsIgnoreCase(StringUtils.trimToEmpty(row.getRoleCode()))) { + continue; + } + if (!"active".equalsIgnoreCase(StringUtils.defaultIfBlank(row.getStatus(), "active"))) { + continue; + } + if (StringUtils.isBlank(row.getWecomId())) { + continue; + } + String routes = StringUtils.defaultString(row.getRoutes()); + if (routes.contains("海运·全部线路") || routes.contains("海运全线") + || (StringUtils.isNotBlank(from) && routes.contains(from)) + || (StringUtils.isNotBlank(to) && routes.contains(to))) { + Map item = new LinkedHashMap<>(); + item.put("name", StringUtils.defaultString(row.getName())); + item.put("wecomId", row.getWecomId().trim()); + item.put("roleCode", "sea"); + item.put("routes", routes); + item.put("status", "active"); + hits.add(item); + } + } + return hits; + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateMatcher.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateMatcher.java index edf1e30..f025fd6 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateMatcher.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateMatcher.java @@ -130,6 +130,10 @@ public final class QuoteTemplateMatcher { /** * 模板 keywordsJson / 列表字段转关键词;空串不参与匹配。 + *

+ * 后台/seed 偶发把未加引号的 {@code [海运,整柜,FCL]} 整段塞进列表一项, + * 或 JSON 解析失败后把原文当成单个词。这两种都要摊开,否则「海运」对不上、 + * 又没设默认模板时,销售会看到固定失败话术。 */ public static List asKeywords(Object raw) { List out = new ArrayList<>(); @@ -139,7 +143,9 @@ public final class QuoteTemplateMatcher { if (raw instanceof Collection) { for (Object it : (Collection) raw) { String s = it == null ? "" : String.valueOf(it).trim(); - if (StringUtils.isNotBlank(s)) { + if (s.startsWith("[")) { + out.addAll(asKeywords(s)); + } else if (StringUtils.isNotBlank(s)) { out.add(s); } } diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java index 5a05632..6f8ea63 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java @@ -1,6 +1,8 @@ package org.jeecg.modules.inquiry.tms; import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.jeecg.modules.inquiry.domain.TicketStatuses; @@ -25,8 +27,9 @@ import java.util.TimeZone; /** * 智能体查价门面:询价必填齐了就必须打真 TMS,再按 TMS 回包分类。 *

- * 职责:销售侧起运/目的原文、品名、件数、重量、体积、包装、报价日期、工单号齐了就出站。 + * 职责:询价必填齐了就出站。空运看件数/重量/体积/包装;海运看货量/整柜或拼柜/箱型箱量,不拦空运量尺。 * 机场三字码能投影就带上;对不上也必须出站,禁止本地判「暂无报价」。 + * 业务线以工单为准,禁止海运请求被默认成 AIR。 * 分类:1 且有价 → 报价;TMS 已回 1002 / 无 quoteFound / 1003 → 暂无报价; * HTTP 超时/熔断/5xx → 技术失败。 * 禁止:因 originCode 对不上就 skip;fixture 假价;LOCK/RELEASE;Python 直连 TMS。 @@ -53,8 +56,18 @@ public class InquiryTmsQueryService { */ public Map queryFromAgent(Map body) { Map facts = extractFacts(body); - String workOrderNo = text(first(body.get("work_order_no"), facts.get("work_order_no"))); - String line = text(first(facts.get("业务线"), facts.get("businessLine"), "AIR")).toUpperCase(); + String workOrderNo = text(first( + body == null ? null : body.get("work_order_no"), + body == null ? null : body.get("workOrderNo"), + facts.get("work_order_no"))); + InquiryTicket ticket = findTicket(workOrderNo); + if (ticket != null) { + mergeTicketFacts(facts, ticket); + } + String line = resolveTransportLine( + body, + facts, + ticket == null ? "" : ticket.getBusinessLine()); if (!properties.queryConfigured() || !gateway.isConfigured()) { return tech("TMS 未配置,拒绝假价"); @@ -68,12 +81,23 @@ public class InquiryTmsQueryService { segment.put("origin", origin); segment.put("destination", destination); segment.put("cargoName", text(facts.get("品名"))); - segment.put("quantity", TmsV2RequestMapper.asInteger(facts.get("件数"))); - segment.put("weightKg", TmsV2RequestMapper.asPositiveDecimal(facts.get("毛重"))); - segment.put("volumeCbm", TmsV2RequestMapper.asPositiveDecimal(facts.get("体积"))); - fillAirportCode(segment, "originCode", text(first(facts.get("originCode"), origin))); - fillAirportCode(segment, "destinationCode", text(first(facts.get("destinationCode"), destination))); - fillPackageType(segment, text(first(facts.get("packageType"), facts.get("包装方式"), facts.get("包装类型")))); + if ("SEA".equals(line)) { + // 海运不拦空运件数/重量/体积/包装;港口只带名称,没有港口字典也不跳过。 + segment.put("cargoQty", first(facts.get("货量"), facts.get("货物数量"))); + segment.put("loadType", facts.get("整柜或拼柜")); + segment.put("containerSpec", facts.get("箱型箱量")); + Object items = facts.get("containerItems"); + if (items != null) { + segment.put("containerItems", items); + } + } else { + segment.put("quantity", TmsV2RequestMapper.asInteger(facts.get("件数"))); + segment.put("weightKg", TmsV2RequestMapper.asPositiveDecimal(facts.get("毛重"))); + segment.put("volumeCbm", TmsV2RequestMapper.asPositiveDecimal(facts.get("体积"))); + fillAirportCode(segment, "originCode", text(first(facts.get("originCode"), origin))); + fillAirportCode(segment, "destinationCode", text(first(facts.get("destinationCode"), destination))); + fillPackageType(segment, text(first(facts.get("packageType"), facts.get("包装方式"), facts.get("包装类型")))); + } String quoteDate = TmsV2RequestMapper.normalizeQuoteDate(facts.get("报价日期")); if (quoteDate == null) { @@ -82,7 +106,9 @@ public class InquiryTmsQueryService { quoteDate = fmt.format(new Date()); } - List missing = missingAirQuery(workOrderNo, quoteDate, segment); + List missing = "SEA".equals(line) + ? missingSeaQuery(workOrderNo, quoteDate, segment, facts) + : missingAirQuery(workOrderNo, quoteDate, segment); if (!missing.isEmpty()) { log.warn("tms.query skip incomplete wo={} missing={}", workOrderNo, missing); return incomplete(missing); @@ -362,6 +388,40 @@ public class InquiryTmsQueryService { * 出站前只拦销售侧必填真缺。originCode / destinationCode 不是拦网: * 对不上机场也要查,TMS 自己回无价或 1003。 */ + /** + * 海运出站必填:工单号、报价日期、起运/目的名称、品名、货量、整柜或拼柜、箱型箱量。 + * 不要求机场三字码、件数、重量、体积、包装。 + */ + private static List missingSeaQuery(String workOrderNo, String quoteDate, + Map segment, Map facts) { + List missing = new ArrayList<>(); + if (workOrderNo == null || !workOrderNo.matches("WO\\d{12}")) { + missing.add("workOrderNo"); + } + if (quoteDate == null || quoteDate.isEmpty()) { + missing.add("quoteDate"); + } + for (String key : new String[] {"origin", "destination", "cargoName"}) { + if (segment.get(key) == null || "".equals(segment.get(key))) { + missing.add(key); + } + } + if (blank(facts.get("货量")) && blank(facts.get("货物数量"))) { + missing.add("货量"); + } + if (blank(facts.get("整柜或拼柜"))) { + missing.add("整柜或拼柜"); + } + if (blank(facts.get("箱型箱量"))) { + missing.add("箱型箱量"); + } + return missing; + } + + private static boolean blank(Object raw) { + return raw == null || String.valueOf(raw).trim().isEmpty(); + } + private static List missingAirQuery(String workOrderNo, String quoteDate, Map segment) { List missing = new ArrayList<>(); if (workOrderNo == null || !workOrderNo.matches("WO\\d{12}")) { @@ -439,6 +499,69 @@ public class InquiryTmsQueryService { return out; } + /** + * 业务线:请求体 / 字段快照 / 工单,禁止无业务线时把海运当成空运拦网。 + */ + static String resolveTransportLine(Map body, Map facts, String ticketLine) { + String line = text(first( + body == null ? null : body.get("businessLine"), + body == null ? null : body.get("business_line"), + facts == null ? null : facts.get("业务线"), + facts == null ? null : facts.get("businessLine"), + facts == null ? null : facts.get("business_line"), + ticketLine + )).toUpperCase(); + if ("SEA".equals(line) || "AIR".equals(line) || "LAND".equals(line)) { + return line; + } + return "AIR"; + } + + private InquiryTicket findTicket(String workOrderNo) { + if (StringUtils.isBlank(workOrderNo) || workOrderService == null) { + return null; + } + return workOrderService.getOne(new LambdaQueryWrapper() + .eq(InquiryTicket::getWorkOrderNo, workOrderNo) + .last("LIMIT 1"), false); + } + + /** + * 工单已落库的海运事实补进查价 facts,避免智能体漏传货量/箱型。 + */ + static void mergeTicketFacts(Map facts, InquiryTicket ticket) { + if (facts == null || ticket == null) { + return; + } + putIfBlank(facts, "起运港", ticket.getOrigin()); + putIfBlank(facts, "目的港", ticket.getDestination()); + putIfBlank(facts, "品名", ticket.getGoodsName()); + putIfBlank(facts, "货量", ticket.getCargoQty()); + putIfBlank(facts, "整柜或拼柜", ticket.getFclLcl()); + putIfBlank(facts, "箱型箱量", ticket.getContainerSpec()); + if (ticket.getQuoteDate() != null && blank(facts.get("报价日期"))) { + SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd"); + fmt.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai")); + facts.put("报价日期", fmt.format(ticket.getQuoteDate())); + } + if (StringUtils.isNotBlank(ticket.getFactsJson())) { + JSONObject stored = JSON.parseObject(ticket.getFactsJson()); + if (stored != null) { + for (String key : stored.keySet()) { + if (blank(facts.get(key)) && stored.get(key) != null) { + facts.put(key, stored.get(key)); + } + } + } + } + } + + private static void putIfBlank(Map facts, String key, String val) { + if (blank(facts.get(key)) && StringUtils.isNotBlank(val)) { + facts.put(key, val); + } + } + @SuppressWarnings("unchecked") private static Map extractFacts(Map body) { if (body == null) { diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/SeaTmsFieldNormalizer.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/SeaTmsFieldNormalizer.java new file mode 100644 index 0000000..367bf68 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/SeaTmsFieldNormalizer.java @@ -0,0 +1,146 @@ +package org.jeecg.modules.inquiry.tms; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 海运出站前把销售口语整理成 TMS V2 字段。 + *

+ * 对照接口文档 6.3:FCL 必须带 containerType + containerQty(如 40HQ / 1), + * loadType 只能是 FCL/LCL,cargoQty 必须是数字。销售卡片仍看原文「整柜 / 1x40HQ / 15件」。 + * 禁止:把口语原样扔给 TMS;在这里猜港口。 + */ +public final class SeaTmsFieldNormalizer { + + /** 1x40HQ / 2*40高柜 */ + private static final Pattern QTY_X_TYPE = Pattern.compile( + "(?i)(\\d+)\\s*[xX×*]\\s*(20|40|45)\\s*['\"′尺]?(HQ|GP|NOR|RH|HC|高柜|平柜|柜)?"); + + /** 40HQ*2 */ + private static final Pattern TYPE_X_QTY = Pattern.compile( + "(?i)(20|40|45)\\s*['\"′尺]?(HQ|GP|NOR|RH|HC|高柜|平柜)?\\s*[xX×*]\\s*(\\d+)"); + + /** 2个40尺高柜 */ + private static final Pattern CN_GE = Pattern.compile( + "(?i)(\\d+)\\s*个\\s*(20|40|45)\\s*['\"′尺]?(HQ|GP|高柜|平柜|柜)?"); + + /** 单独写 40HQ / 40高柜,箱量按 1 */ + private static final Pattern BARE_TYPE = Pattern.compile( + "(?i)(20|40|45)\\s*['\"′尺]?(HQ|GP|NOR|RH|HC|高柜|平柜)"); + + private SeaTmsFieldNormalizer() { + } + + /** + * 整柜/拼柜 → FCL/LCL。已经是 FCL/LCL 的原样(大写)。 + */ + public static String loadType(Object raw) { + if (raw == null) { + return null; + } + String text = String.valueOf(raw).trim(); + if (text.isEmpty()) { + return null; + } + String upper = text.toUpperCase(Locale.ROOT); + if ("FCL".equals(upper) || text.contains("整柜") || text.contains("整箱")) { + return "FCL"; + } + if ("LCL".equals(upper) || text.contains("拼柜") || text.contains("拼箱")) { + return "LCL"; + } + return upper; + } + + /** + * 货物数量:15件 / 20吨 → 15 / 20。带单位会 1001。 + */ + public static BigDecimal cargoQty(Object raw) { + return TmsV2RequestMapper.asPositiveDecimal(raw); + } + + /** + * 把「1x40HQ」「2个40尺高柜」「1*20GP+1*40HQ」拆成 TMS 箱型列表。 + * + * @return 每项含 containerType、containerQty;拆不出则空列表,不编造。 + */ + public static List> parseContainers(Object raw) { + List> items = new ArrayList<>(); + if (raw == null) { + return items; + } + String text = String.valueOf(raw).trim(); + if (text.isEmpty()) { + return items; + } + collect(items, QTY_X_TYPE.matcher(text), 1, 2, 3); + if (!items.isEmpty()) { + return items; + } + Matcher typeFirst = TYPE_X_QTY.matcher(text); + while (typeFirst.find()) { + addItem(items, typeFirst.group(1), typeFirst.group(2), typeFirst.group(3)); + } + if (!items.isEmpty()) { + return items; + } + collect(items, CN_GE.matcher(text), 1, 2, 3); + if (!items.isEmpty()) { + return items; + } + Matcher bare = BARE_TYPE.matcher(text); + if (bare.find()) { + addItem(items, bare.group(1), bare.group(2), "1"); + } + return items; + } + + private static void collect(List> items, Matcher matcher, + int qtyGroup, int sizeGroup, int suffixGroup) { + while (matcher.find()) { + addItem(items, matcher.group(sizeGroup), matcher.group(suffixGroup), matcher.group(qtyGroup)); + } + } + + private static void addItem(List> items, String size, String suffix, String qty) { + String type = toTmsType(size, suffix); + Integer count = TmsV2RequestMapper.asInteger(qty); + if (type == null || count == null || count < 1) { + return; + } + Map row = new LinkedHashMap<>(); + row.put("containerType", type); + row.put("containerQty", count); + items.add(row); + } + + /** + * 口语箱型落到 TMS 码:40HQ / 40GP / 20GP / 45HQ。 + */ + static String toTmsType(String size, String suffix) { + if (size == null) { + return null; + } + String foot = size.trim(); + String tag = suffix == null ? "" : suffix.trim().toUpperCase(Locale.ROOT); + if (tag.contains("高") || "HQ".equals(tag) || "HC".equals(tag)) { + return foot + "HQ"; + } + if (tag.contains("NOR")) { + return foot + "NOR"; + } + if (tag.contains("RH")) { + return foot + "RH"; + } + if (tag.contains("平") || "GP".equals(tag) || "柜".equals(suffix) || tag.isEmpty()) { + return foot + "GP"; + } + return foot + tag; + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapper.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapper.java index 613cfb5..f0a725f 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapper.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapper.java @@ -15,6 +15,7 @@ import java.util.regex.Pattern; * 对照《友通达询价智能体TMS系统对接开发接口文档V1.0.1》5.2 / 2.1 / 6.3: * quantity 必须是 JSON 整数;weightKg / volumeCbm 必须是 JSON 小数且大于 0; * 带「KGS」「CBM」的字符串会触发业务码 1001(参数格式错误)。 + * 海运同样:整柜/1x40HQ/15件 必须先整理成 FCL + 40HQ + 1 + 数字货量,否则 TMS 回 1001。 * 报价日期:文档漏了该请求字段,实际 TMS 使用 quoteDate,格式 yyyy-MM-dd(如 2026-09-07)。 * 禁止:inquiryTime=此刻(会查成无匹配);在这里做字典模糊匹配或猜测机场代码。 */ @@ -76,9 +77,43 @@ public final class TmsV2RequestMapper { put(target, "weightKg", asPositiveDecimal(first(source, "weightKg"))); put(target, "volumeCbm", asPositiveDecimal(first(source, "volumeCbm"))); put(target, "packageType", first(source, "packageType", "packagingType")); + // 海运出站:口语必须先整理。FCL 要 containerType+containerQty,loadType 只能 FCL/LCL, + // cargoQty 只能是数字。销售侧「整柜 / 1x40HQ / 15件」原样留在工单,不在这里改。 + applySeaFields(source, target); return compact(target); } + /** + * 把海运口语字段写成 TMS 查询字段。空运段没有这些键则不写。 + */ + private static void applySeaFields(Map source, Map target) { + Object cargoQty = first(source, "cargoQty", "货量"); + Object loadRaw = first(source, "loadType", "整柜或拼柜"); + Object spec = first(source, "containerSpec", "箱型箱量", "containerTypeQty"); + if (cargoQty == null && loadRaw == null && spec == null + && first(source, "containerType") == null + && first(source, "containerItems") == null) { + return; + } + put(target, "cargoQty", SeaTmsFieldNormalizer.cargoQty(cargoQty)); + put(target, "loadType", SeaTmsFieldNormalizer.loadType(loadRaw)); + Object readyItems = first(source, "containerItems"); + List> items = SeaTmsFieldNormalizer.parseContainers(spec); + if (readyItems instanceof List && !((List) readyItems).isEmpty()) { + put(target, "containerItems", readyItems); + } else if (items.size() > 1) { + put(target, "containerItems", items); + } + Object type = first(source, "containerType"); + Object qty = first(source, "containerQty"); + if (type == null && !items.isEmpty()) { + type = items.get(0).get("containerType"); + qty = items.get(0).get("containerQty"); + } + put(target, "containerType", type); + put(target, "containerQty", asInteger(qty)); + } + /** * 报价日期规范成 yyyy-MM-dd。解析不了则不传,避免 1001。 */ diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/staff/SeaStaffMatcherTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/staff/SeaStaffMatcherTest.java new file mode 100644 index 0000000..507943c --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/staff/SeaStaffMatcherTest.java @@ -0,0 +1,64 @@ +package org.jeecg.modules.inquiry.staff; + +import org.jeecg.modules.inquiry.entity.InquiryStaffRole; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 海运拉群人员匹配:不连库、不打企微。 + *

+ * 「海运·全部线路」必拉;写了具体港口的,对得上本单起运/目的才拉;停用不拉。 + */ +public class SeaStaffMatcherTest { + + @Test + public void allRoutesMustPull() { + InquiryStaffRole row = staff("sea", "active", "p1", "张产品", "海运·全部线路"); + List> hits = SeaStaffMatcher.match( + Arrays.asList(row), "上海", "洛杉矶"); + assertEquals(1, hits.size()); + assertEquals("p1", hits.get(0).get("wecomId")); + } + + @Test + public void specificPortPullsWhenMatch() { + InquiryStaffRole row = staff("sea", "active", "p2", "李产品", "上海,宁波"); + List> hits = SeaStaffMatcher.match( + Arrays.asList(row), "上海", "汉堡"); + assertEquals(1, hits.size()); + } + + @Test + public void specificPortSkipsWhenMismatch() { + InquiryStaffRole row = staff("sea", "active", "p3", "王产品", "深圳"); + List> hits = SeaStaffMatcher.match( + Arrays.asList(row), "上海", "洛杉矶"); + assertTrue(hits.isEmpty()); + } + + @Test + public void disabledOrWrongRoleSkipped() { + InquiryStaffRole disabled = staff("sea", "disabled", "p4", "停用", "海运·全部线路"); + InquiryStaffRole air = staff("air", "active", "p5", "空运岗", "海运·全部线路"); + List> hits = SeaStaffMatcher.match( + Arrays.asList(disabled, air), "上海", "洛杉矶"); + assertTrue(hits.isEmpty()); + } + + private static InquiryStaffRole staff(String role, String status, String wecomId, + String name, String routes) { + InquiryStaffRole row = new InquiryStaffRole(); + row.setRoleCode(role); + row.setStatus(status); + row.setWecomId(wecomId); + row.setName(name); + row.setRoutes(routes); + return row; + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateMatcherTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateMatcherTest.java index a87bde7..042c426 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateMatcherTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateMatcherTest.java @@ -51,6 +51,37 @@ public class QuoteTemplateMatcherTest { assertNull(QuoteTemplateMatcher.pick(list, "AIR", facts, null)); } + @Test + public void brokenBracketKeywordStillHitsSea() { + List> list = new ArrayList<>(); + Map sea = new HashMap<>(); + sea.put("name", "海运报价单模板"); + sea.put("bizType", "海运"); + sea.put("isDefault", false); + List broken = new ArrayList<>(); + broken.add("[海运,整柜,FCL]"); + sea.put("keywords", broken); + list.add(sea); + Map facts = new HashMap<>(); + facts.put("整柜或拼柜", "整柜"); + Map hit = QuoteTemplateMatcher.pick(list, "SEA", facts, null); + assertNotNull(hit); + assertEquals("keyword", hit.get("matchType")); + assertEquals("海运报价单模板", hit.get("name")); + } + + @Test + public void seaFallsBackToDefaultWhenKeywordMisses() { + List> list = new ArrayList<>(); + list.add(tpl("海运默认", "海运", true, "洛杉矶")); + Map facts = new HashMap<>(); + facts.put("目的港", "纽约港"); + Map hit = QuoteTemplateMatcher.pick(list, "SEA", facts, null); + assertNotNull(hit); + assertEquals("default", hit.get("matchType")); + assertEquals("海运默认", hit.get("name")); + } + @Test public void seaTemplateIgnoredForAir() { List> list = new ArrayList<>(); diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryServiceTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryServiceTest.java new file mode 100644 index 0000000..8859090 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryServiceTest.java @@ -0,0 +1,50 @@ +package org.jeecg.modules.inquiry.tms; + +import org.jeecg.modules.inquiry.entity.InquiryTicket; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * 海运查价身份:不打真实 TMS。 + *

+ * 工单已是 SEA 时,即使请求没带业务线,也不能默认成 AIR。 + */ +public class InquiryTmsQueryServiceTest { + + @Test + public void ticketSeaWinsWhenBodyOmitsLine() { + Map body = new HashMap<>(); + body.put("work_order_no", "WO202609150001"); + String line = InquiryTmsQueryService.resolveTransportLine(body, new HashMap<>(), "SEA"); + assertEquals("SEA", line); + } + + @Test + public void bodyBusinessLineWins() { + Map body = new HashMap<>(); + body.put("businessLine", "SEA"); + String line = InquiryTmsQueryService.resolveTransportLine(body, new HashMap<>(), "AIR"); + assertEquals("SEA", line); + } + + @Test + public void mergeTicketFillsSeaFacts() { + InquiryTicket ticket = new InquiryTicket(); + ticket.setOrigin("上海"); + ticket.setDestination("洛杉矶"); + ticket.setGoodsName("普货"); + ticket.setCargoQty("20吨"); + ticket.setFclLcl("整柜"); + ticket.setContainerSpec("1x40HQ"); + Map facts = new HashMap<>(); + InquiryTmsQueryService.mergeTicketFacts(facts, ticket); + assertEquals("上海", facts.get("起运港")); + assertEquals("20吨", facts.get("货量")); + assertEquals("整柜", facts.get("整柜或拼柜")); + assertEquals("1x40HQ", facts.get("箱型箱量")); + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/SeaTmsFieldNormalizerTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/SeaTmsFieldNormalizerTest.java new file mode 100644 index 0000000..ddcd8e8 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/SeaTmsFieldNormalizerTest.java @@ -0,0 +1,58 @@ +package org.jeecg.modules.inquiry.tms; + +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 海运口语 → TMS 字段。不连 TMS。 + */ +public class SeaTmsFieldNormalizerTest { + + @Test + public void loadTypeFclLcl() { + assertEquals("FCL", SeaTmsFieldNormalizer.loadType("整柜")); + assertEquals("LCL", SeaTmsFieldNormalizer.loadType("拼柜")); + assertEquals("FCL", SeaTmsFieldNormalizer.loadType("FCL")); + } + + @Test + public void cargoQtyStripsUnit() { + assertEquals(new BigDecimal("15"), SeaTmsFieldNormalizer.cargoQty("15件")); + assertEquals(new BigDecimal("20"), SeaTmsFieldNormalizer.cargoQty("20吨")); + } + + @Test + public void parseOneByFortyHq() { + List> items = SeaTmsFieldNormalizer.parseContainers("1x40HQ"); + assertEquals(1, items.size()); + assertEquals("40HQ", items.get(0).get("containerType")); + assertEquals(1, items.get(0).get("containerQty")); + } + + @Test + public void parseChineseHighCube() { + List> items = SeaTmsFieldNormalizer.parseContainers("2个40尺高柜"); + assertEquals(1, items.size()); + assertEquals("40HQ", items.get(0).get("containerType")); + assertEquals(2, items.get(0).get("containerQty")); + } + + @Test + public void parseTwoTypes() { + List> items = SeaTmsFieldNormalizer.parseContainers("1x20GP+1x40HQ"); + assertEquals(2, items.size()); + assertEquals("20GP", items.get(0).get("containerType")); + assertEquals("40HQ", items.get(1).get("containerType")); + } + + @Test + public void unknownTextNotInvented() { + assertTrue(SeaTmsFieldNormalizer.parseContainers("随便写").isEmpty()); + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapperTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapperTest.java index 171b1ff..2cf2536 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapperTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapperTest.java @@ -58,4 +58,51 @@ public class TmsV2RequestMapperTest { assertEquals(new java.math.BigDecimal("2"), segs.get(0).get("volumeCbm")); assertFalse(String.valueOf(segs.get(0).get("weightKg")).contains("KGS")); } + + @Test + public void seaFieldsPassThroughWithoutAirMeasures() { + Map seg = new HashMap<>(); + seg.put("origin", "上海"); + seg.put("destination", "洛杉矶"); + seg.put("cargoName", "普货"); + seg.put("cargoQty", "20吨"); + seg.put("loadType", "整柜"); + List> items = Collections.singletonList( + Collections.singletonMap("type", "40HQ")); + seg.put("containerItems", items); + Map internal = new HashMap<>(); + internal.put("transportMode", "SEA"); + internal.put("quoteDate", "2026-09-15"); + internal.put("segments", Collections.singletonList(seg)); + Map body = TmsV2RequestMapper.quote(internal); + @SuppressWarnings("unchecked") + List> segs = (List>) body.get("segments"); + assertEquals(new java.math.BigDecimal("20"), segs.get(0).get("cargoQty")); + assertEquals("FCL", segs.get(0).get("loadType")); + assertEquals(items, segs.get(0).get("containerItems")); + assertFalse(segs.get(0).containsKey("quantity")); + assertFalse(segs.get(0).containsKey("weightKg")); + } + + @Test + public void oralSeaSpecBecomesTmsContainerFields() { + Map seg = new HashMap<>(); + seg.put("origin", "上海港"); + seg.put("destination", "巴生西港"); + seg.put("cargoName", "机器设备"); + seg.put("cargoQty", "15件"); + seg.put("loadType", "整柜"); + seg.put("containerSpec", "1x40HQ"); + Map internal = new HashMap<>(); + internal.put("transportMode", "SEA"); + internal.put("quoteDate", "2026-09-15"); + internal.put("segments", Collections.singletonList(seg)); + Map body = TmsV2RequestMapper.quote(internal); + @SuppressWarnings("unchecked") + List> segs = (List>) body.get("segments"); + assertEquals(new java.math.BigDecimal("15"), segs.get(0).get("cargoQty")); + assertEquals("FCL", segs.get(0).get("loadType")); + assertEquals("40HQ", segs.get(0).get("containerType")); + assertEquals(Integer.valueOf(1), segs.get(0).get("containerQty")); + } }