From 99da1faf30e6ba64188b8ef3fde2bf22ee0d5ec4 Mon Sep 17 00:00:00 2001 From: jillion <439912596@qq.com> Date: Mon, 14 Sep 2026 20:24:26 +0800 Subject: [PATCH] =?UTF-8?q?=E8=90=BD=E5=9C=B0=E7=A7=81=E8=81=8A=E7=A9=BA?= =?UTF-8?q?=E8=BF=90=E8=AF=A2=E4=BB=B7=E4=B8=8ETMS=E6=8A=A5=E4=BB=B7?= =?UTF-8?q?=E5=9B=9E=E5=A1=AB=EF=BC=8C=E6=8C=89=E5=90=8E=E5=8F=B0=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E5=87=BA=E5=8D=95=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 查到价后把币种、单位、数量、单价、备注写入报价单,避免销售只看到光秃秃的金额。 Co-authored-by: Cursor --- .cursor/rules/pm-collaboration.mdc | 36 + .cursor/rules/prompt-contract.mdc | 2 +- .env.example | 7 +- .gitignore | 1 + inquiry-agent/agent/channel/outbox/sender.py | 192 +++- inquiry-agent/agent/channel/pg_store.py | 20 +- inquiry-agent/agent/channel/queue.py | 5 +- inquiry-agent/agent/channel/runtime.py | 21 +- inquiry-agent/agent/channel/store_protocol.py | 1 + inquiry-agent/agent/channel/wecom/parse.py | 15 +- inquiry-agent/agent/config/__init__.py | 2 + inquiry-agent/agent/foundation_selftest.py | 2 +- inquiry-agent/agent/handlers/__init__.py | 5 +- inquiry-agent/agent/handlers/card_action.py | 61 +- .../agent/handlers/continue_thread.py | 56 +- inquiry-agent/agent/handlers/text_inquiry.py | 96 ++ inquiry-agent/agent/jobs/pdf_job.py | 47 +- inquiry-agent/agent/ledger/__init__.py | 17 +- inquiry-agent/agent/ledger/http_ledger.py | 184 ++++ inquiry-agent/agent/ledger/memory_ledger.py | 277 +++++ inquiry-agent/agent/llm/extract_text.py | 116 ++ inquiry-agent/agent/llm/http_client.py | 9 +- .../agent/llm/mode_extract_fields.py | 183 +++- inquiry-agent/agent/policy/air_text_flow.py | 794 ++++++++++++++ inquiry-agent/agent/policy/inquiry_copy.py | 797 ++++++++++++++ .../agent/quote_templates/__init__.py | 41 +- inquiry-agent/agent/quote_templates/match.py | 159 +++ inquiry-agent/agent/routing/__init__.py | 20 + inquiry-agent/agent/routing/deepseek_a.py | 126 ++- inquiry-agent/agent/routing/dispatch.py | 57 +- .../agent/routing/ordered_actions.py | 7 +- inquiry-agent/agent/schema/__init__.py | 8 +- .../agent/schema/contracts_loader.py | 3 +- inquiry-agent/agent/schema/field_validate.py | 293 +++++- inquiry-agent/agent/schema/fingerprint.py | 40 + inquiry-agent/agent/schema/tms_air_query.py | 200 ++++ inquiry-agent/tests/test_air_text_inquiry.py | 995 ++++++++++++++++++ inquiry-agent/tests/test_continue_ticket.py | 278 +++++ inquiry-agent/tests/test_extract_oral.py | 266 +++++ inquiry-agent/tests/test_http_ledger.py | 50 + .../tests/test_quote_template_match.py | 68 ++ .../org/jeecg/config/shiro/ShiroConfig.java | 4 + inquiry-api/jeecg-module-inquiry/pom.xml | 5 + .../controller/InquiryAgentController.java | 175 +++ .../inquiry/dto/AgentQuoteRenderRequest.java | 33 + .../inquiry/dto/AgentTicketCreateRequest.java | 35 + .../modules/inquiry/entity/InquiryTicket.java | 2 +- .../jeecg/modules/inquiry/package-info.java | 4 +- .../service/IInquiryQuoteTemplateService.java | 10 + .../service/IInquiryWorkOrderService.java | 25 + .../impl/InquiryQuoteTemplateServiceImpl.java | 163 +++ .../impl/InquiryWorkOrderServiceImpl.java | 281 +++++ .../template/QuoteTemplateExcelSupport.java | 360 ++++++- .../template/QuoteTemplateMatcher.java | 196 ++++ .../inquiry/tms/InquiryTmsQueryService.java | 480 +++++++++ .../inquiry/tms/TmsAdapterController.java | 39 +- .../inquiry/tms/TmsExactProjector.java | 153 +++ .../modules/inquiry/tms/TmsGatewayClient.java | 191 ++++ .../modules/inquiry/tms/TmsProperties.java | 72 ++ .../modules/inquiry/tms/TmsQuoteCurrency.java | 184 ++++ .../inquiry/tms/TmsV2RequestMapper.java | 188 ++++ .../inquiry/tms/TmsV2ResponseNormalizer.java | 68 ++ .../inquiry/tms/TmsV2TokenProvider.java | 121 +++ .../modules/inquiry/tms/package-info.java | 8 +- .../main/resources/tms-air-exact-seed.json | 26 + .../template/QuoteTemplateFillTest.java | 156 +++ .../template/QuoteTemplateMatcherTest.java | 73 ++ .../inquiry/tms/TmsExactProjectorTest.java | 35 + .../inquiry/tms/TmsQuoteCurrencyTest.java | 111 ++ .../inquiry/tms/TmsV2RequestMapperTest.java | 61 ++ .../main/resources/application-inquiry.yml | 9 +- inquiry-backend/package.json | 21 +- inquiry-backend/pnpm-lock.yaml | 264 +++-- prompt/04-智能体与模型配置.md | 15 +- prompt/05-总控提示词.md | 2 +- prompt/06-Python智能体.md | 2 +- prompt/07-Java主账.md | 10 +- prompt/10-TMS对接.md | 20 +- 78 files changed, 8838 insertions(+), 321 deletions(-) create mode 100644 .cursor/rules/pm-collaboration.mdc create mode 100644 inquiry-agent/agent/handlers/text_inquiry.py create mode 100644 inquiry-agent/agent/ledger/http_ledger.py create mode 100644 inquiry-agent/agent/ledger/memory_ledger.py create mode 100644 inquiry-agent/agent/llm/extract_text.py create mode 100644 inquiry-agent/agent/policy/air_text_flow.py create mode 100644 inquiry-agent/agent/policy/inquiry_copy.py create mode 100644 inquiry-agent/agent/quote_templates/match.py create mode 100644 inquiry-agent/agent/schema/fingerprint.py create mode 100644 inquiry-agent/agent/schema/tms_air_query.py create mode 100644 inquiry-agent/tests/test_air_text_inquiry.py create mode 100644 inquiry-agent/tests/test_continue_ticket.py create mode 100644 inquiry-agent/tests/test_extract_oral.py create mode 100644 inquiry-agent/tests/test_http_ledger.py create mode 100644 inquiry-agent/tests/test_quote_template_match.py create mode 100644 inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryAgentController.java create mode 100644 inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/dto/AgentQuoteRenderRequest.java create mode 100644 inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/dto/AgentTicketCreateRequest.java create mode 100644 inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateMatcher.java create mode 100644 inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java create mode 100644 inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsExactProjector.java create mode 100644 inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsGatewayClient.java create mode 100644 inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsProperties.java create mode 100644 inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrency.java create mode 100644 inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapper.java create mode 100644 inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2ResponseNormalizer.java create mode 100644 inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2TokenProvider.java create mode 100644 inquiry-api/jeecg-module-inquiry/src/main/resources/tms-air-exact-seed.json create mode 100644 inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java create mode 100644 inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateMatcherTest.java create mode 100644 inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsExactProjectorTest.java create mode 100644 inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrencyTest.java create mode 100644 inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapperTest.java diff --git a/.cursor/rules/pm-collaboration.mdc b/.cursor/rules/pm-collaboration.mdc new file mode 100644 index 0000000..cc384c8 --- /dev/null +++ b/.cursor/rules/pm-collaboration.mdc @@ -0,0 +1,36 @@ +--- +description: 用户是产品经理;白话需求;必须沿用仓库现有框架开发 +alwaysApply: true +--- + +# 协作:产品经理教需求,助手按框架写代码 + +用户**不是开发人员**,是产品经理。仓库里现有目录、分包、进程、停点、字段合同是开发人员给的**必须遵守的框架**,不是草稿,禁止另起炉灶。 + +## 谁做什么 + +| 角色 | 做 | 不做 | +|---|---|---| +| 用户 | 白话讲场景、对错、验收口径 | 不贴路径、不 `@` 文件、不写代码 | +| 助手 | 自己读 `prompt/` 与现有源码,在框架里实现 | 不要求用户懂代码;不问「该用哪个文件」 | + +用户说「先做私聊文字询价 / 工单六态 / 测服能回消息」即可开工。助手自己打开 `prompt/00-INDEX.md`、`prompt/05-总控提示词.md`,说到哪一端再读 `06`~`14`。密钥 `03` 不要每次读进对话。 + +## 必须沿用的框架 + +- 智能体只在 `inquiry-agent/` 已有包里填:`channel` / `routing` / `handlers` / `graph` / `jobs` / `ledger` 等。一类意图一个 handler,节点分文件。 +- 主账只在 `inquiry-api/` 的 Jeecg + `jeecg-module-inquiry`。智能体只打主账 HTTP,不直连 TMS、不写 MySQL 工单表。 +- 后台只在 `inquiry-backend/` 的 Jeecg Vue,经 `/jeecgboot` 打 8180。 +- 进程形态不变:一个主账、一个智能体 HTTP、一个 Worker、一个 AIBOT 桥、一个后台。 +- 字段以仓库里两份 JSON 合同为准。未读不准写字段校验。 + +禁止:新建平行工程、单文件上帝模块、用图状态冒充主账成功、改现网库/端口/企微回调。 + +## 怎么跟用户说话 + +用产品语言汇报:做成了什么、销售侧会看到什么、还缺哪段业务。需要拍板时**只问产品选择**(例:空运锁舱本轮做不做),不要问技术选型。 + +```text +❌ 请 @ 一下 graph/runtime.py,你看用 interrupt 还是 wait? +✅ 私聊文字询价:能收回并原样回一句。下一步按合同做「抽字段 + 缺了再补问」,还是先打通建工单? +``` diff --git a/.cursor/rules/prompt-contract.mdc b/.cursor/rules/prompt-contract.mdc index 8fdc484..cb000be 100644 --- a/.cursor/rules/prompt-contract.mdc +++ b/.cursor/rules/prompt-contract.mdc @@ -7,6 +7,6 @@ alwaysApply: true `prompt/*.md` 是业务合同。不要给那些 md 加 Cursor 文件头。 -用户是产品经理,不会打 `@`、不会贴路径。禁止要求用户 @ 文件。用户用白话说「开始做 / 先做工单 / 部署测试」即可。助手必须自己打开 `prompt/00-INDEX.md` 和 `prompt/05-总控提示词.md`;说到哪一端再自己读 `06`~`14`。不要把 `03`(密钥)每次都读进对话。 +用户是产品经理,不会打 `@`、不会贴路径。禁止要求用户 @ 文件。用户用白话说「开始做 / 先做工单 / 部署测试」即可。助手必须自己打开 `prompt/00-INDEX.md` 和 `prompt/05-总控提示词.md`;说到哪一端再自己读 `06`~`14`。不要把 `03`(密钥)每次都读进对话。协作方式与「必须沿用现有框架」见 `pm-collaboration.mdc`。 本目录 `.mdc` 才写规则头:`description` + `alwaysApply: true`(或 `globs`)。不要把 `01`~`14` 全文复制进本规则。 diff --git a/.env.example b/.env.example index 9c72726..7b8b2db 100644 --- a/.env.example +++ b/.env.example @@ -30,7 +30,8 @@ AGENT_HTTP_PORT=8910 AIBOT_BRIDGE_PORT=8913 AIBOT_BRIDGE_BIND=127.0.0.1 -BIZ_API_BASE_URL=http://127.0.0.1:8180 +BIZ_API_BASE_URL=http://127.0.0.1:8180/jeecgboot +LEDGER_BACKEND=http AGENT_CALLBACK_BASE_URL=http://127.0.0.1:8910 WECOM_AIBOT_BRIDGE_URL=http://127.0.0.1:8913 WECOM_AIBOT_INGRESS_URL=http://127.0.0.1:8910/internal/aibot/inbound @@ -71,3 +72,7 @@ OBJECT_STORAGE_PATH_STYLE=true AGENT_CALLBACK_TOKEN= WECOM_AIBOT_ENABLED=false + +# 口语抽字段:测服/正式必须打开,否则只认「毛重:100」这种整行标签 +LLM_ALLOW_NETWORK=true +LLM_DATA_USAGE_CONFIRMED=true diff --git a/.gitignore b/.gitignore index 3f00ddd..d315a28 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # 环境与密钥(禁止进 Git) .env .env.* +(1).env !.env.example !inquiry-backend/.env.production !inquiry-backend/.env.development diff --git a/inquiry-agent/agent/channel/outbox/sender.py b/inquiry-agent/agent/channel/outbox/sender.py index efe0a83..3ebc673 100644 --- a/inquiry-agent/agent/channel/outbox/sender.py +++ b/inquiry-agent/agent/channel/outbox/sender.py @@ -54,6 +54,170 @@ class WeComAppClient: self._token_expire_at: float = 0.0 self._lock = threading.Lock() + def send_outbound( + self, + *, + touser: str, + content: str, + payload: dict[str, Any] | None = None, + ) -> WeComSendResult: + """ + 按 outbox 内容发应用消息:有模板卡先发卡,失败再回退文本。 + + 副作用:出站 HTTP 调企微;失败返回 ok=False,由 outbox 重试。 + """ + extra = dict(payload or {}) + if extra.get("msgtype") == "file": + return self.send_file( + touser=touser, + filename=str(extra.get("filename") or "quote.xlsx"), + filepath=str(extra.get("filepath") or ""), + file_b64=str(extra.get("file_b64") or extra.get("fileBase64") or ""), + ) + if extra.get("msgtype") == "update_template_card": + return self.update_template_card( + touser=touser, + response_code=str(extra.get("response_code") or ""), + replace_name=str((extra.get("button") or {}).get("replace_name") or ""), + ) + if extra.get("msgtype") == "template_card" and extra.get("template_card"): + card_result = self._post_message( + { + "touser": touser, + "msgtype": "template_card", + "agentid": self._agent_id, + "template_card": extra["template_card"], + "enable_id_trans": 0, + }, + preview="[template_card]", + ) + if card_result.ok: + return card_result + logger.warning( + "模板卡发送失败,回退文本 err=%s:%s", + card_result.errcode, + card_result.errmsg, + ) + return self.send_text(touser=touser, content=content) + + def update_template_card( + self, + *, + touser: str, + response_code: str, + replace_name: str, + ) -> WeComSendResult: + """ + 按回调 ResponseCode 更新原模板卡按钮文案,保持置灰不可再点。 + + 失败不回落成新文本气泡,避免销售看到重复「已提交」。 + """ + if not touser or not response_code or not replace_name: + return WeComSendResult(ok=True, errcode=0, errmsg="skip_card_update") + try: + token = self._get_token() + except Exception as exc: # noqa: BLE001 + logger.exception("更新模板卡取 token 失败") + return WeComSendResult(ok=True, errcode=-1, errmsg=str(exc)) + url = f"{self._api_base}/cgi-bin/message/update_template_card" + body = { + "userids": [touser], + "agentid": self._agent_id, + "response_code": response_code, + "enable_id_trans": 0, + "button": {"replace_name": replace_name}, + } + 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("企微 update_template_card 网络失败") + return WeComSendResult(ok=True, errcode=-1, errmsg=str(exc)) + errcode = int(data.get("errcode") or 0) + errmsg = str(data.get("errmsg") or "") + if errcode != 0: + logger.warning("更新模板卡失败 errcode=%s errmsg=%s", errcode, errmsg) + return WeComSendResult(ok=True, errcode=errcode, errmsg=errmsg, raw=data) + logger.info("企微模板卡已置灰 touser=%s", touser) + return WeComSendResult(ok=True, errcode=0, errmsg=errmsg, raw=data) + + def send_file( + self, + *, + touser: str, + filename: str, + filepath: str = "", + file_b64: str = "", + ) -> WeComSendResult: + """ + 先上传临时素材再发文件消息。 + + 字节来自本机路径或 Base64;失败回 ok=False 由 outbox 重试。 + 禁止在回调线程调用;本方法只给出站消费线程用。 + """ + if not touser: + return WeComSendResult(ok=False, errcode=-1, errmsg="touser 为空") + raw = b"" + if filepath: + try: + from pathlib import Path + + raw = Path(filepath).read_bytes() + except OSError as exc: + return WeComSendResult(ok=False, errcode=-1, errmsg=f"read_file:{exc}") + elif file_b64: + import base64 + + try: + raw = base64.b64decode(file_b64) + except Exception as exc: # noqa: BLE001 + return WeComSendResult(ok=False, errcode=-1, errmsg=f"b64:{exc}") + if not raw: + return WeComSendResult(ok=False, errcode=-1, errmsg="file_empty") + media_id, up = self._upload_media(filename=filename or "quote.xlsx", content=raw) + if not media_id: + return up + return self._post_message( + { + "touser": touser, + "msgtype": "file", + "agentid": self._agent_id, + "file": {"media_id": media_id}, + }, + preview=f"[file]{filename}", + ) + + def _upload_media(self, *, filename: str, content: bytes) -> tuple[str, WeComSendResult]: + """cgi-bin/media/upload;密钥不写日志。""" + if not self._corp_id or not self._secret: + return "", WeComSendResult(ok=False, errcode=-1, errmsg="WECOM_CORP_ID/SECRET 未配置") + try: + token = self._get_token() + except Exception as exc: # noqa: BLE001 + logger.exception("上传素材取 token 失败") + return "", WeComSendResult(ok=False, errcode=-1, errmsg=str(exc)) + url = f"{self._api_base}/cgi-bin/media/upload" + try: + with httpx.Client(timeout=max(self._timeout, 30.0)) as client: + resp = client.post( + url, + params={"access_token": token, "type": "file"}, + files={"media": (filename, content, "application/octet-stream")}, + ) + data = resp.json() + except Exception as exc: # noqa: BLE001 + logger.exception("企微 media/upload 网络失败") + return "", WeComSendResult(ok=False, errcode=-1, errmsg=str(exc)) + errcode = int(data.get("errcode") or 0) + media_id = str(data.get("media_id") or "") + if errcode != 0 or not media_id: + logger.warning("企微上传文件失败 errcode=%s", errcode) + return "", WeComSendResult( + ok=False, errcode=errcode, errmsg=str(data.get("errmsg") or "upload_fail"), raw=data + ) + return media_id, WeComSendResult(ok=True, errcode=0, raw=data) + def send_text(self, *, touser: str, content: str) -> WeComSendResult: """ 向指定企微 userid 发送文本应用消息。 @@ -61,11 +225,23 @@ class WeComAppClient: 参数:touser 必须是入站 sender_id;content 测试环境应由上层加 [test] 前缀。 副作用:出站 HTTP 调企微;失败返回 ok=False,不抛给回调线程(由 outbox 重试)。 """ - if not self._corp_id or not self._secret: - return WeComSendResult(ok=False, errcode=-1, errmsg="WECOM_CORP_ID/SECRET 未配置") if not touser or not content: return WeComSendResult(ok=False, errcode=-1, errmsg="touser/content 为空") + return self._post_message( + { + "touser": touser, + "msgtype": "text", + "agentid": self._agent_id, + "text": {"content": content}, + "safe": 0, + }, + preview=content, + ) + def _post_message(self, body: dict[str, Any], *, preview: str) -> WeComSendResult: + """真正打企微 message/send;密钥不写日志。""" + if not self._corp_id or not self._secret: + return WeComSendResult(ok=False, errcode=-1, errmsg="WECOM_CORP_ID/SECRET 未配置") try: token = self._get_token() except Exception as exc: # noqa: BLE001 @@ -73,13 +249,6 @@ class WeComAppClient: return WeComSendResult(ok=False, errcode=-1, errmsg=str(exc)) url = f"{self._api_base}/cgi-bin/message/send" - body = { - "touser": touser, - "msgtype": "text", - "agentid": self._agent_id, - "text": {"content": content}, - "safe": 0, - } try: with httpx.Client(timeout=self._timeout) as client: resp = client.post(url, params={"access_token": token}, json=body) @@ -91,15 +260,14 @@ class WeComAppClient: errcode = int(data.get("errcode") or 0) errmsg = str(data.get("errmsg") or "") if errcode != 0: - # token 失效则清空缓存,下次重试 if errcode in (40014, 42001, 42007): with self._lock: self._token = None self._token_expire_at = 0.0 logger.warning("企微发送失败 errcode=%s errmsg=%s", errcode, errmsg) return WeComSendResult(ok=False, errcode=errcode, errmsg=errmsg, raw=data) - preview = content if len(content) <= 200 else (content[:200] + "…") - logger.info("企微发送成功 touser=%s reply=%s", touser, preview) + shown = preview if len(preview) <= 200 else (preview[:200] + "…") + logger.info("企微发送成功 touser=%s reply=%s", body.get("touser"), shown) return WeComSendResult(ok=True, errcode=0, errmsg=errmsg, raw=data) def _get_token(self) -> str: diff --git a/inquiry-agent/agent/channel/pg_store.py b/inquiry-agent/agent/channel/pg_store.py index 7369e50..83e84a6 100644 --- a/inquiry-agent/agent/channel/pg_store.py +++ b/inquiry-agent/agent/channel/pg_store.py @@ -157,6 +157,7 @@ class PostgresMessageStore: touser: str, content: str, dedupe_key: str = "", + payload: Optional[dict[str, Any]] = None, ) -> tuple[bool, str]: if not touser or not content: return False, "invalid_outbox" @@ -167,9 +168,15 @@ class PostgresMessageStore: """ INSERT INTO agent_outbox ( id, dedupe_key, touser, content, payload, state - ) VALUES (%s, %s, %s, %s, '{}'::jsonb, 'pending') + ) VALUES (%s, %s, %s, %s, %s::jsonb, 'pending') """, - (item_id, dedupe_key or "", touser, content), + ( + item_id, + dedupe_key or "", + touser, + content, + json.dumps(dict(payload or {}), ensure_ascii=False), + ), ) conn.commit() self._outbox_event.set() @@ -209,7 +216,7 @@ class PostgresMessageStore: FROM cte WHERE o.id = cte.id RETURNING o.id, o.touser, o.content, o.attempts, o.last_error, - o.created_at, o.dedupe_key, o.claimed_at + o.created_at, o.dedupe_key, o.claimed_at, o.payload """, (str(timeout), self._worker_id), ).fetchone() @@ -221,6 +228,12 @@ class PostgresMessageStore: created_ts = created.timestamp() if hasattr(created, "timestamp") else time.time() claimed = row[7] claimed_ts = claimed.timestamp() if hasattr(claimed, "timestamp") else time.time() + raw_payload = row[8] if len(row) > 8 else {} + if isinstance(raw_payload, str): + try: + raw_payload = json.loads(raw_payload) + except json.JSONDecodeError: + raw_payload = {} return OutboxItem( id=str(row[0]), touser=str(row[1]), @@ -231,6 +244,7 @@ class PostgresMessageStore: last_error=str(row[4] or ""), created_at=created_ts, dedupe_key=str(row[6] or ""), + payload=dict(raw_payload or {}), ) def complete_outbound( diff --git a/inquiry-agent/agent/channel/queue.py b/inquiry-agent/agent/channel/queue.py index d535e12..085d512 100644 --- a/inquiry-agent/agent/channel/queue.py +++ b/inquiry-agent/agent/channel/queue.py @@ -59,6 +59,7 @@ class OutboxItem: last_error: str = "" created_at: float = field(default_factory=time.time) dedupe_key: str = "" + payload: dict[str, Any] = field(default_factory=dict) class MemoryMessageStore: @@ -140,8 +141,9 @@ class MemoryMessageStore: touser: str, content: str, dedupe_key: str = "", + payload: Optional[dict[str, Any]] = None, ) -> tuple[bool, str]: - """写入 outbox;dedupe_key 重复则跳过。""" + """写入 outbox;dedupe_key 重复则跳过。payload 可带企微模板卡。""" if not touser or not content: return False, "invalid_outbox" with self._lock: @@ -154,6 +156,7 @@ class MemoryMessageStore: touser=touser, content=content, dedupe_key=dedupe_key, + payload=dict(payload or {}), ) self._outbox[item.id] = item self._outbox_event.set() diff --git a/inquiry-agent/agent/channel/runtime.py b/inquiry-agent/agent/channel/runtime.py index e3f58c6..4427022 100644 --- a/inquiry-agent/agent/channel/runtime.py +++ b/inquiry-agent/agent/channel/runtime.py @@ -27,7 +27,7 @@ class ChannelRuntime: 企微通道运行时:inbox/outbox 各一条守护线程。 启动时机:FastAPI lifespan;停止:进程退出。 - inbox:IntentRouter.route(默认 stub)→ dispatch 查表;无业务分支森林。 + inbox:自由文字走 DeepSeek A 认意图(本线程,非回调);点卡不进模型。 """ def __init__(self, settings: Settings) -> None: @@ -35,7 +35,8 @@ class ChannelRuntime: self._store = get_message_store() self._stop = threading.Event() self._threads: list[threading.Thread] = [] - self._router = IntentRouter(allow_network=False) + # 开网后销售原话交给模型认「续办/新询价」;未开网才走 stub。 + self._router = IntentRouter(allow_network=bool(settings.llm_allow_network)) agent_id = int(settings.wecom_agent_id or "0") or 0 self._client = WeComAppClient( corp_id=settings.wecom_corp_id, @@ -68,11 +69,13 @@ class ChannelRuntime: self._store.wait_inbox(1.0) continue try: - decision = self._router.route( - text=item.message.content or "", - sender_id=item.message.sender_id or "", + message = item.message + decision = self._router.route_inbound( + text=message.content or "", + sender_id=message.sender_id or "", + event=str((message.raw or {}).get("event") or ""), ) - dispatch_inbound(item.message, decision) + dispatch_inbound(message, decision) self._store.complete_inbound(item.id) except Exception as exc: # noqa: BLE001 logger.exception("inbox 处理失败 id=%s", item.id) @@ -91,7 +94,11 @@ class ChannelRuntime: logger.warning("outbox 过期丢弃 id=%s", item.id) self._store.complete_outbound(item.id) continue - result = self._client.send_text(touser=item.touser, content=item.content) + result = self._client.send_outbound( + touser=item.touser, + content=item.content, + payload=getattr(item, "payload", None) or {}, + ) if result.ok: self._store.complete_outbound(item.id) elif result.errcode in PERMANENT_SEND_ERRORS: diff --git a/inquiry-agent/agent/channel/store_protocol.py b/inquiry-agent/agent/channel/store_protocol.py index 108df3a..db92f8d 100644 --- a/inquiry-agent/agent/channel/store_protocol.py +++ b/inquiry-agent/agent/channel/store_protocol.py @@ -27,6 +27,7 @@ class MessageStore(Protocol): touser: str, content: str, dedupe_key: str = "", + payload: Optional[dict[str, Any]] = None, ) -> tuple[bool, str]: ... def claim_outbound(self) -> Optional[OutboxItem]: ... diff --git a/inquiry-agent/agent/channel/wecom/parse.py b/inquiry-agent/agent/channel/wecom/parse.py index 705bcec..bf32b14 100644 --- a/inquiry-agent/agent/channel/wecom/parse.py +++ b/inquiry-agent/agent/channel/wecom/parse.py @@ -35,8 +35,13 @@ def parse_plain_xml(xml_text: str, *, default_agent_id: str = "") -> InboundMess create_time = int(create_time_raw) if create_time_raw.isdigit() else 0 agent_id = _text(root, "AgentID") or _text(root, "AgentId") or default_agent_id content = _text(root, "Content") if msg_type == "text" else "" - # 事件类可带 Event Key;骨架阶段仅记 raw,不进 Graph + # 事件类可带 Event Key;模板卡点击要把按钮键收成可入询价流程的正文 event = _text(root, "Event") + event_key = _text(root, "EventKey") + response_code = _text(root, "ResponseCode") + task_id = _text(root, "TaskId") or _text(root, "TaskID") + if not content and event.lower() in {"template_card_event", "click", "templatecardevent"}: + content = event_key if not msg_id: # 无 MsgId(如部分事件)时用发送人+时间拼幂等键,避免空键撞车 msg_id = f"{sender_id}:{create_time}:{msg_type}:{event}" @@ -58,7 +63,13 @@ def parse_plain_xml(xml_text: str, *, default_agent_id: str = "") -> InboundMess agent_id=agent_id, create_time=create_time, media={}, - raw={"event": event, "xml": xml_text}, + raw={ + "event": event, + "event_key": event_key, + "response_code": response_code, + "task_id": task_id, + "xml": xml_text, + }, ) diff --git a/inquiry-agent/agent/config/__init__.py b/inquiry-agent/agent/config/__init__.py index 81dbd61..4ec7c99 100644 --- a/inquiry-agent/agent/config/__init__.py +++ b/inquiry-agent/agent/config/__init__.py @@ -46,6 +46,8 @@ class Settings(BaseSettings): biz_api_username: str = Field(default="", alias="BIZ_API_USERNAME") biz_api_password: str = Field(default="", alias="BIZ_API_PASSWORD") biz_api_timeout_seconds: float = Field(default=10.0, alias="BIZ_API_TIMEOUT_SECONDS") + # http=打 8180 真主账/真 TMS;memory=单测假账本 + ledger_backend: str = Field(default="http", alias="LEDGER_BACKEND") agent_callback_token: str = Field(default="", alias="AGENT_CALLBACK_TOKEN") # 兼容现网变量名 inquiry_agent_callback_token: str = Field(default="", alias="INQUIRY_AGENT_CALLBACK_TOKEN") diff --git a/inquiry-agent/agent/foundation_selftest.py b/inquiry-agent/agent/foundation_selftest.py index 3e312f9..b5c8fe8 100644 --- a/inquiry-agent/agent/foundation_selftest.py +++ b/inquiry-agent/agent/foundation_selftest.py @@ -248,7 +248,7 @@ def main() -> int: (lt.error or "")[:80], ) vf = validate_required_fields_shell(facts={}, require_formal=True) - assert vf.get("implemented") is False + assert vf.get("implemented") is True _ = list_template_registry() s3 = S3ObjectStorage.from_settings(get_settings(), allow_network=False) diff --git a/inquiry-agent/agent/handlers/__init__.py b/inquiry-agent/agent/handlers/__init__.py index 3d1d329..146b9fc 100644 --- a/inquiry-agent/agent/handlers/__init__.py +++ b/inquiry-agent/agent/handlers/__init__.py @@ -2,9 +2,10 @@ Handler 包:一类意图 / 一类卡片一个模块。 禁止:单文件处理全部卡片;禁止把 Schema 校验复制粘贴进每个 handler(放 schema/policy)。 -当前:echo 联调 + 其余意图空壳(待接 RouteDecision)。 +当前:文字询价空运闭环 + echo 联调残留。 """ from agent.handlers.echo_text import handle_echo_text +from agent.handlers.text_inquiry import handle_text_inquiry, handle_text_other -__all__ = ["handle_echo_text"] +__all__ = ["handle_echo_text", "handle_text_inquiry", "handle_text_other"] diff --git a/inquiry-agent/agent/handlers/card_action.py b/inquiry-agent/agent/handlers/card_action.py index ce3fdf4..b3af757 100644 --- a/inquiry-agent/agent/handlers/card_action.py +++ b/inquiry-agent/agent/handlers/card_action.py @@ -1,7 +1,8 @@ """ -意图:模板卡 / 补问卡动作(骨架)。 +意图:询价卡片按钮(跳过协同 / 采用 / 不采用 / 成交)。 -正式:校验 clarification token 绑定的 waitVersion + 允许动作集合。 +本文件职责:校验当前会话允许的动作后交给流程执行。 +禁止:不校验 wait/允许集合就改主账;禁止一个函数处理未声明的新按钮。 """ from __future__ import annotations @@ -9,6 +10,10 @@ from __future__ import annotations import logging 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 + logger = logging.getLogger(__name__) @@ -19,14 +24,48 @@ def handle_card_action( wait_version: int, action: str, payload: dict[str, Any] | None = None, -) -> None: - """骨架:仅记日志;无 sender_id 直接返回。""" + flow=None, + store=None, +) -> str: + """ + 处理一次卡片动作。 + + wait_version:会话书签版本;错版本丢弃,不重放副作用。 + payload 须带 task_id / event_key,才能找回点卡当时的工单。 + """ + _ = thread_id if not (sender_id or "").strip(): - return - logger.info( - "handler.card_action stub sender=%s thread=%s wait=%s action=%s", - sender_id, - thread_id, - wait_version, - action, + return "reject_no_sender" + engine = flow or get_air_text_flow() + 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) + return "card_discard" + + msg_store = store or get_message_store() + + def reply(text: str, extra: dict | None = None) -> None: + # 销售询价正文不加 [test];[test] 只给运维告警 + body = text + extra = dict(extra or {}) + wo = "" + if sess is not None: + wo = (sess.work_order_no or "").strip() + if not wo: + wo = copy.work_order_from_card_meta(payload) + code = str((payload or {}).get("response_code") or extra.get("response_code") or "")[:24] + ok, info = msg_store.enqueue_outbound( + touser=sender_id, + content=body, + dedupe_key=( + f"card:{sender_id}:{wo}:{code}:" + f"{sess.wait_version if sess else 0}:{action}:{body[:20]}" + ), + payload=extra, + ) + if not ok: + logger.warning("card 出站被去重 sender=%s wo=%s info=%s", sender_id, wo or "-", info) + + return engine.on_button( + sender_id=sender_id, action=action, reply=reply, card_meta=payload ) diff --git a/inquiry-agent/agent/handlers/continue_thread.py b/inquiry-agent/agent/handlers/continue_thread.py index 133fe8c..1a2a02b 100644 --- a/inquiry-agent/agent/handlers/continue_thread.py +++ b/inquiry-agent/agent/handlers/continue_thread.py @@ -1,24 +1,62 @@ """ -意图:继续本单 / Graph resume(骨架)。 +意图:继续历史工单。 -正式:校验 thread_id + waitVersion 后 Worker 槽 resume;禁止在 HTTP 回调线程 invoke。 +本文件职责:把 DeepSeek A 认出的续办意图交给流程,按工单号回到当时停点。 +工单号优先用路由结论;模型漏填时再从原话机读 WO 号(不在这里判断意图)。 +禁止:在本文件抽询价字段;禁止回调线程跑完整 LLM;禁止直连 TMS。 """ from __future__ import annotations import logging +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 logger = logging.getLogger(__name__) -def handle_continue_thread(message: InboundMessage, *, thread_id: str = "") -> None: - """骨架:仅记日志。""" +def handle_continue_thread( + message: InboundMessage, + *, + thread_id: str = "", + flow=None, + store=None, +) -> str: + """ + 续办一张历史工单:回到该单当时节点的卡片/话术。 + + thread_id:RouteDecision 里的工单号。副作用:outbox 回复;读主账。 + """ if not message.has_sender(): - return - logger.info( - "handler.continue_thread stub sender=%s thread_id=%s", - message.sender_id, - thread_id or "-", + logger.warning("continue_thread 拒绝:无 sender_id") + return "reject_no_sender" + msg_store = store or get_message_store() + engine = flow or get_air_text_flow() + replies: list[str] = [] + + def reply(text: str, extra: dict | None = None) -> None: + replies.append(text) + ok, info = msg_store.enqueue_outbound( + touser=message.sender_id, + content=text, + dedupe_key=f"cont:{message.message_id}:{len(replies)}", + payload=dict(extra or {}), + ) + logger.info("continue_thread 出站 ok=%s info=%s", ok, info) + + 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, + reply=reply, ) + logger.info( + "continue_thread phase=%s sender=%s wo=%s", + phase, + message.sender_id, + wo or "-", + ) + return phase diff --git a/inquiry-agent/agent/handlers/text_inquiry.py b/inquiry-agent/agent/handlers/text_inquiry.py new file mode 100644 index 0000000..004215e --- /dev/null +++ b/inquiry-agent/agent/handlers/text_inquiry.py @@ -0,0 +1,96 @@ +""" +意图:普通文字询价(空运闭环)。 + +本文件职责:入站文字 → 流程编排;回复只写入 outbox。 +禁止:在本文件抽字段语义(交给 extract);禁止直连 TMS;禁止回调线程外的额外阻塞。 +""" + +from __future__ import annotations + +import logging +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.policy.air_text_flow import get_air_text_flow + +logger = logging.getLogger(__name__) + + +def handle_text_inquiry( + message: InboundMessage, + *, + injected_facts: Optional[dict[str, Any]] = None, + injected_mode: str = "", + b_result: Optional[dict[str, Any]] = None, + chat_fn=None, + flow=None, + store=None, +) -> str: + """ + 处理一条私聊文字询价。 + + 须已有 sender_id。副作用:outbox 多条回复;主账经 flow.ledger。 + 返回:流程阶段名。 + """ + if not message.has_sender(): + logger.warning("text_inquiry 拒绝:无 sender_id") + return "reject_no_sender" + settings = get_settings() + msg_store = store or get_message_store() + engine = flow or get_air_text_flow() + replies: list[str] = [] + + def reply(text: str, extra: dict | None = None) -> None: + # 销售询价正文不加 [test];[test] 只给运维告警 + body = text + replies.append(body) + ok, info = msg_store.enqueue_outbound( + touser=message.sender_id, + content=body, + dedupe_key=f"inq:{message.message_id}:{len(replies)}", + payload=dict(extra or {}), + ) + logger.info("text_inquiry 出站 ok=%s info=%s phase_reply=%s", ok, info, body[:80]) + + phase = engine.on_text( + sender_id=message.sender_id, + text=message.content or "", + reply=reply, + injected_facts=injected_facts, + injected_mode=injected_mode, + b_result=b_result, + allow_b=bool(settings.llm_allow_network and settings.llm_data_usage_confirmed), + chat_fn=chat_fn, + inbound_raw=message.raw, + ) + logger.info("text_inquiry phase=%s sender=%s", phase, message.sender_id) + return phase + + +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() + sess = engine.session_for_action( + message.sender_id, message.raw, text=message.content or "" + ) + btn = engine.match_button(message.content or "", sess) + if btn: + return handle_text_inquiry(message, store=msg_store) + + body = copy.FIRST_CHAT + msg_store.enqueue_outbound( + touser=message.sender_id, + content=body, + dedupe_key=f"other:{message.message_id}", + ) + return "text_other" diff --git a/inquiry-agent/agent/jobs/pdf_job.py b/inquiry-agent/agent/jobs/pdf_job.py index f9ca3c8..c3f8a9f 100644 --- a/inquiry-agent/agent/jobs/pdf_job.py +++ b/inquiry-agent/agent/jobs/pdf_job.py @@ -12,7 +12,6 @@ from dataclasses import dataclass from typing import Any, Optional from agent.jobs.libreoffice import LibreOfficeConverter, LibreOfficeResult -from agent.jobs.quote_fill import fill_then_convert_pdf from agent.redis_coord import get_redis_runtime, try_acquire_idempotency from agent.redis_coord.keys import STREAM_PDF from agent.redis_coord.runtime import RedisRuntime @@ -40,6 +39,8 @@ def enqueue_pdf_job( quote_version: int = 0, idempotency_key: str = "", redis: Optional[RedisRuntime] = None, + touser: str = "", + filename: str = "", ) -> PdfEnqueueResult: """Excel→PDF 任务入队;真正转换只在 LibreOffice 槽。""" rt = redis or get_redis_runtime() @@ -58,6 +59,8 @@ def enqueue_pdf_job( "xlsx_path": xlsx_path, "inquiry_no": inquiry_no, "quote_version": quote_version, + "touser": touser, + "filename": filename, } entry_id = q.enqueue(payload=payload, idempotency_key=idempotency_key) logger.info("pdf.enqueue id=%s inquiry=%s", entry_id, inquiry_no or "-") @@ -84,6 +87,11 @@ def process_pdf_job( from pathlib import Path import tempfile + try: + from agent.jobs.quote_fill import fill_then_convert_pdf + except ImportError: + return LibreOfficeResult(ok=False, error="quote_fill_missing") + wd = Path(work_dir) if work_dir else Path(tempfile.mkdtemp(prefix="quote_fill_")) fill, pdf = fill_then_convert_pdf( source_xlsx=template_xlsx, @@ -95,9 +103,44 @@ def process_pdf_job( if not fill.ok: return LibreOfficeResult(ok=False, error=f"fill_failed:{fill.error}") assert pdf is not None + _deliver_quote_file(data, pdf) return pdf path = str(data.get("xlsx_path") or "") if not path: return LibreOfficeResult(ok=False, error="missing_xlsx_path") - return conv.convert_xlsx_to_pdf(path) + result = conv.convert_xlsx_to_pdf(path) + _deliver_quote_file(data, result) + return result + + +def _deliver_quote_file(data: dict[str, Any], result: LibreOfficeResult) -> None: + """ + 转换成功后把 PDF 写入 outbox,由企微通道发文件。 + + 无 touser 则只落盘(旧任务);失败不抛,避免卡 LibreOffice 槽。 + """ + if not result.ok: + return + touser = str(data.get("touser") or "").strip() + if not touser: + return + wo = str(data.get("inquiry_no") or "") + filename = str(data.get("filename") or "").strip() or f"{wo}_quote.pdf" + try: + from agent.channel.queue import get_message_store + from agent.policy.inquiry_copy import file_ready + + store = get_message_store() + store.enqueue_outbound( + touser=touser, + content=file_ready(kind="PDF", work_order_no=wo, filename=filename), + dedupe_key=f"pdf-file:{wo}:{filename}", + payload={ + "msgtype": "file", + "filepath": result.pdf_path, + "filename": filename, + }, + ) + except Exception: + logger.exception("PDF 出站入队失败 inquiry=%s", wo) diff --git a/inquiry-agent/agent/ledger/__init__.py b/inquiry-agent/agent/ledger/__init__.py index 36bc9f9..3e05ce8 100644 --- a/inquiry-agent/agent/ledger/__init__.py +++ b/inquiry-agent/agent/ledger/__init__.py @@ -47,17 +47,24 @@ class LedgerClient: def from_settings(cls, settings: Optional[Settings] = None) -> "LedgerClient": """从环境组装客户端。""" cfg = settings or get_settings() + token = ( + getattr(cfg, "biz_api_token", None) + or getattr(cfg, "agent_callback_token", None) + or getattr(cfg, "inquiry_agent_callback_token", None) + or "" + ) return cls( base_url=(cfg.biz_api_base_url or "").rstrip("/"), timeout_seconds=float(getattr(cfg, "biz_api_timeout_seconds", 10) or 10), - service_token=(getattr(cfg, "biz_api_token", None) or ""), + service_token=str(token), ) def _http(self) -> httpx.Client: if self._client is None: headers = {"Accept": "application/json"} if self.service_token: - headers["X-Access-Token"] = self.service_token + # 回调令牌不是 Jeecg 登录 JWT。写入 X-Access-Token 会被 JwtFilter 验签失败并 401。 + headers["X-Agent-Token"] = self.service_token self._client = httpx.Client( base_url=self.base_url, timeout=self.timeout_seconds, @@ -123,7 +130,9 @@ class LedgerClient: """ headers: dict[str, str] = {} if idempotency_key: - headers["X-Idempotency-Key"] = idempotency_key + from agent.schema.fingerprint import ascii_idempotency_key + + headers["X-Idempotency-Key"] = ascii_idempotency_key(idempotency_key) try: resp = self._http().request( method.upper(), @@ -145,7 +154,7 @@ class LedgerClient: path, ) return LedgerResponse(ok=ok, status_code=resp.status_code, data=data) - except httpx.HTTPError as exc: + except (httpx.HTTPError, UnicodeEncodeError, ValueError) as exc: logger.warning("ledger.request 异常 path=%s err=%s", path, exc) return LedgerResponse(ok=False, status_code=0, error=str(exc)) diff --git a/inquiry-agent/agent/ledger/http_ledger.py b/inquiry-agent/agent/ledger/http_ledger.py new file mode 100644 index 0000000..3993a67 --- /dev/null +++ b/inquiry-agent/agent/ledger/http_ledger.py @@ -0,0 +1,184 @@ +""" +主账 HTTP 账本:建单 / 查价 / 报价 / 六态只打 8180。 + +本文件职责:把 MemoryLedger 同名方法接到 /inquiry/agent/**;查价走真 TMS(主账出站)。 +禁止:直连 TMS / MySQL;把 Jeecg 包装失败当成有价。 +""" + +from __future__ import annotations + +import logging +from typing import Any, Optional + +from agent.ledger import LedgerClient + +logger = logging.getLogger(__name__) + + +def _unwrap(data: Any) -> dict[str, Any]: + """解开 Jeecg Result:{success, result} → result。""" + if not isinstance(data, dict): + return {} + if "result" in data and isinstance(data.get("result"), dict): + inner = data["result"] + if data.get("success") is False: + return {"ok": False, "error": str(data.get("message") or "ledger_error"), **inner} + return inner + return data + + +class HttpLedger: + """ + 生产主账客户端。 + + 超时随 LedgerClient;查价在主账线程打 TMS,本类只转发分类。 + """ + + def __init__(self, client: LedgerClient | None = None) -> None: + self._client = client or LedgerClient.from_settings() + + def _post(self, path: str, body: dict[str, Any], *, idem: str = "") -> dict[str, Any]: + resp = self._client.request_json("POST", path, json_body=body, idempotency_key=idem) + if not resp.ok: + logger.warning("http_ledger fail path=%s status=%s err=%s", path, resp.status_code, resp.error) + return {"ok": False, "error": resp.error or f"http_{resp.status_code}"} + return _unwrap(resp.data) + + def find_similar_open( + self, + *, + facts: dict[str, str], + business_line: str, + exclude_work_order_no: str = "", + ) -> dict[str, Any]: + """全库相同需求检索;exclude_work_order_no 排除刚建的本单。""" + out = self._post( + "/inquiry/agent/ticket/findSimilar", + { + "salesWecomId": "", + "businessLine": business_line, + "facts": facts, + "excludeWorkOrderNo": exclude_work_order_no, + }, + ) + if "found" not in out: + return {"found": False, "first_or_same": "first", "work_order_no": "", "status": ""} + return out + + def create_ticket( + self, + *, + sender_id: str, + business_line: str, + facts: dict[str, str], + idempotency_key: str = "", + ) -> dict[str, Any]: + out = self._post( + "/inquiry/agent/ticket/create", + { + "salesWecomId": sender_id, + "businessLine": business_line, + "facts": facts, + "idempotencyKey": idempotency_key, + }, + idem=idempotency_key, + ) + if not out.get("ok") and not out.get("work_order_no"): + return {"ok": False, "created": False, "work_order_no": "", "status": "", "first_or_same": "first"} + out.setdefault("ok", True) + if out.get("similar_work_order_no"): + out.setdefault("first_or_same", "same") + else: + out.setdefault("first_or_same", "first") + return out + + def get_for_agent(self, *, work_order_no: str, sender_id: str) -> dict[str, Any]: + """读主账工单快照,供续办回到当时节点。""" + out = self._post( + "/inquiry/agent/ticket/get", + {"work_order_no": work_order_no, "salesWecomId": sender_id}, + ) + if not out.get("found") and not out.get("ok"): + 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]: + out = self._post( + "/inquiry/agent/tms/query", + {"work_order_no": work_order_no, "facts": facts}, + idem=f"tms:{work_order_no}", + ) + if "classification" not in out and not out.get("ok"): + return { + "ok": False, + "classification": "TMS_TECH_FAILURE", + "has_price": False, + "quote": {}, + } + out.setdefault("quote", {}) + return out + + def upsert_quote( + self, + *, + work_order_no: str, + quote: dict[str, Any], + to_status: str = "", + ) -> dict[str, Any]: + # 有价时主账 query 已写快照并迁「已报价」;此处只做幂等确认,避免二次假写入。 + _ = (work_order_no, quote, to_status) + return {"ok": True, "quote_version": 1, "status": to_status or "已报价"} + + def render_quote( + self, + *, + work_order_no: str, + business_line: str, + facts: dict[str, Any], + quote: Optional[dict[str, Any]] = None, + format: str = "xlsx", + sales_wecom_id: str = "", + ) -> dict[str, Any]: + """ + 打主账按后台报价模板填 Excel。 + + 失败(无关键词且无默认)原样带回销售话术。Java 不转 PDF。 + """ + from agent.quote_templates.match import FAIL_MESSAGE + + # 填表+回文件可能超过默认 10s,单独放宽,仍禁止无限等。 + prev_timeout = self._client.timeout_seconds + self._client.timeout_seconds = max(float(prev_timeout or 10), 45.0) + self._client.close() + try: + out = self._post( + "/inquiry/agent/quote/render", + { + "workOrderNo": work_order_no, + "businessLine": business_line, + "facts": facts, + "quote": quote or {}, + "salesWecomId": sales_wecom_id, + }, + idem=f"quote-render:{work_order_no}:{format}", + ) + finally: + self._client.timeout_seconds = prev_timeout + self._client.close() + if not out.get("ok"): + return { + "ok": False, + "error": out.get("error") or "NO_TEMPLATE", + "message": out.get("message") or FAIL_MESSAGE, + } + out.setdefault("needsPdf", str(format).lower() == "pdf") + return out + + def transition(self, *, work_order_no: str, to_status: str) -> dict[str, Any]: + out = self._post( + "/inquiry/agent/ticket/transition", + {"workOrderNo": work_order_no, "toStatus": to_status}, + ) + 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"} diff --git a/inquiry-agent/agent/ledger/memory_ledger.py b/inquiry-agent/agent/ledger/memory_ledger.py new file mode 100644 index 0000000..98873c2 --- /dev/null +++ b/inquiry-agent/agent/ledger/memory_ledger.py @@ -0,0 +1,277 @@ +""" +主账内存假账本(单测 / 无 8180 时)。 + +本文件职责:实现建单(每次新开)、相似检索、TMS fixture、报价写入、六态流转,语义对齐 Java 主账。 +禁止:冒充已打真实 TMS;禁止被生产 Worker 当默认 Owner(生产必须 HTTP 主账)。 +""" + +from __future__ import annotations + +import logging +import threading +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Optional +from zoneinfo import ZoneInfo + +from agent.schema.fingerprint import inquiry_fingerprint + +logger = logging.getLogger(__name__) + +_SHANGHAI = ZoneInfo("Asia/Shanghai") + + +@dataclass +class MemoryTicket: + """内存工单。""" + + work_order_no: str + sales_wecom_id: str + business_line: str + facts: dict[str, str] + fingerprint: str + status: str = "询价中" + quote: dict[str, Any] = field(default_factory=dict) + quote_version: int = 0 + + +class MemoryLedger: + """ + 线程安全内存主账。 + + TMS fixture:facts.tms_force=1002 或目的港含「无价」→ 无价;否则有价。 + """ + + def __init__(self) -> None: + self._lock = threading.Lock() + self._tickets: dict[str, MemoryTicket] = {} + self._seq = 0 + # 单测默认同业务有一张默认模板,避免「生成 Excel」误走失败话术 + self._templates: list[dict[str, Any]] = [ + { + "templateId": "mem-default-air", + "name": "空运默认", + "bizType": "空运", + "keywords": [], + "isDefault": True, + } + ] + self.last_render: dict[str, Any] = {} + + def _next_no(self) -> str: + self._seq += 1 + day = datetime.now(_SHANGHAI).strftime("%Y%m%d") + return f"WO{day}{self._seq:04d}" + + def find_similar_open( + self, + *, + facts: dict[str, str], + business_line: str, + exclude_work_order_no: str = "", + ) -> dict[str, Any]: + """ + 全库相同需求检索:需求指纹一模一样才算,不限六态。 + + 多条只回最新一条;exclude_work_order_no 排除刚建的本单。 + 只供卡片展示,不复用当前工单号。 + """ + fp = inquiry_fingerprint(facts, business_line=business_line) + skip = (exclude_work_order_no or "").strip() + latest: MemoryTicket | None = None + with self._lock: + for t in self._tickets.values(): + if t.fingerprint != fp: + continue + if skip and t.work_order_no == skip: + continue + latest = t + if latest is None: + return {"found": False, "first_or_same": "first", "work_order_no": "", "status": ""} + return { + "found": True, + "first_or_same": "same", + "work_order_no": latest.work_order_no, + "status": latest.status, + } + + def create_ticket( + self, + *, + sender_id: str, + business_line: str, + facts: dict[str, str], + idempotency_key: str = "", + ) -> dict[str, Any]: + _ = idempotency_key + # 出询价确认必须新开一单,相同需求不复用未关闭工单 + fp = inquiry_fingerprint(facts, business_line=business_line) + with self._lock: + no = self._next_no() + ticket = MemoryTicket( + work_order_no=no, + sales_wecom_id=sender_id, + business_line=business_line, + facts=dict(facts), + fingerprint=fp, + ) + self._tickets[no] = ticket + similar = self.find_similar_open( + facts=facts, business_line=business_line, exclude_work_order_no=no + ) + logger.info( + "memory_ledger.create %s first_or_same=%s similar=%s", + no, + similar.get("first_or_same"), + similar.get("work_order_no"), + ) + return { + "ok": True, + "created": True, + "first_or_same": similar.get("first_or_same") or "first", + "found": bool(similar.get("found")), + "similar_work_order_no": similar.get("work_order_no") or "", + "work_order_no": no, + "status": "询价中", + } + + def query_tms(self, *, work_order_no: str, facts: dict[str, str]) -> dict[str, Any]: + dest = str(facts.get("目的港") or "") + force = str(facts.get("tms_force") or "") + if force == "1002" or force == "1003" or "无价" in dest or "歧义" in dest: + return { + "ok": True, + "classification": "TMS_NO_QUOTE_1002", + "has_price": False, + "quote": {}, + } + if force == "tech": + return {"ok": False, "classification": "TMS_TECH_FAILURE", "has_price": False, "quote": {}} + quote = { + "freight": "USD 1,200", + "other_fee": "USD 80", + "total": "USD 1,280", + "eta": "3-5 天", + "valid_until": "2026-09-20", + "source": "TMS", + "source_label": "TMS 标准报价", + "fee_lines": [ + {"name": "空运费", "amount": "USD 1,200"}, + {"name": "其他附加费", "amount": "USD 80"}, + ], + } + return { + "ok": True, + "classification": "HAS_PRICE", + "has_price": True, + "quote": quote, + } + + def upsert_quote( + self, + *, + work_order_no: str, + quote: dict[str, Any], + to_status: str = "", + ) -> dict[str, Any]: + with self._lock: + ticket = self._tickets.get(work_order_no) + if ticket is None: + return {"ok": False, "error": "ticket_not_found"} + ticket.quote_version += 1 + ticket.quote = dict(quote) + if to_status: + ticket.status = to_status + return { + "ok": True, + "quote_version": ticket.quote_version, + "status": ticket.status, + } + + def transition(self, *, work_order_no: str, to_status: str) -> dict[str, Any]: + with self._lock: + ticket = self._tickets.get(work_order_no) + if ticket is None: + return {"ok": False, "error": "ticket_not_found"} + ticket.status = to_status + return {"ok": True, "status": ticket.status} + + def get_ticket(self, *, work_order_no: str) -> MemoryTicket | None: + with self._lock: + return self._tickets.get(work_order_no) + + def set_quote_templates(self, templates: list[dict[str, Any]]) -> None: + """单测注入后台模板列表;空列表=未维护关键词也未维护默认。""" + with self._lock: + self._templates = list(templates) + + def render_quote( + self, + *, + work_order_no: str, + business_line: str, + facts: dict[str, Any], + quote: Optional[dict[str, Any]] = None, + format: str = "xlsx", + sales_wecom_id: str = "", + ) -> dict[str, Any]: + """ + 按询价需求匹配内存模板。不填真实 xlsx,只回假字节供流程/单测。 + """ + from agent.quote_templates.match import FAIL_MESSAGE, pick_quote_template + + _ = sales_wecom_id + with self._lock: + templates = list(self._templates) + picked = pick_quote_template( + templates=templates, + business_line=business_line, + facts=facts, + quote=quote, + ) + if picked is None: + out = {"ok": False, "error": "NO_TEMPLATE", "message": FAIL_MESSAGE} + self.last_render = out + return out + ext = "pdf" if str(format).lower() == "pdf" else "xlsx" + out = { + "ok": True, + "matchType": picked.match_type, + "templateId": picked.template_id, + "templateName": picked.name, + "fileName": f"{work_order_no}_quote.{ext}", + "fileBase64": "UEsDBAoAAAAAA", + "needsPdf": False, + } + self.last_render = out + return out + + def get_for_agent(self, *, work_order_no: str, sender_id: str) -> dict[str, Any]: + """ + 智能体续办读取:工单是否存在、是否本人、字段/报价/推断停点。 + """ + no = (work_order_no or "").strip() + ticket = self.get_ticket(work_order_no=no) + if ticket is None: + return {"ok": False, "found": False, "owned": False, "work_order_no": no} + owned = ticket.sales_wecom_id == sender_id + status = ticket.status + quote = dict(ticket.quote or {}) + wait_phase = "done" + if status in {"已成交", "未成交", "协商中", "已关闭"}: + wait_phase = "done" + elif status == "已报价" and quote: + wait_phase = "wait_adopt" + else: + wait_phase = "tms_miss" + return { + "ok": True, + "found": True, + "owned": owned, + "work_order_no": ticket.work_order_no, + "status": status, + "business_line": ticket.business_line, + "facts": dict(ticket.facts), + "quote": quote, + "wait_phase": wait_phase, + } diff --git a/inquiry-agent/agent/llm/extract_text.py b/inquiry-agent/agent/llm/extract_text.py new file mode 100644 index 0000000..a7e3b85 --- /dev/null +++ b/inquiry-agent/agent/llm/extract_text.py @@ -0,0 +1,116 @@ +""" +文字询价字段抽取入口。 + +本文件职责:生产走 DeepSeek B;单测可注入 facts;无网时只认「键:值」结构化行,不猜港口。 +禁止:用正则把「上海到洛杉矶」拆成起运目的;禁止改六态。 +""" + +from __future__ import annotations + +import logging +import re +from typing import Any, Optional + +logger = logging.getLogger(__name__) + +_MODE_WORDS = ( + ("空运", "AIR"), + ("海运", "SEA"), + ("陆运", "LAND"), +) + +# 仅「键:值」一行一条,键必须是合同字段或展示名 +_LABEL_LINE = re.compile( + r"^(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|" + r"包装方式|包装类型|货量|整柜或拼柜|箱型箱量|报价日期|车型数量)\s*[::]\s*(.+)$" +) +_LABEL_INLINE = re.compile( + r"(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|" + r"包装方式|包装类型|货量|整柜或拼柜|箱型箱量|报价日期|车型数量)\s*[::]\s*(\S+)" +) + + +def detect_transport_mode(text: str) -> str: + """ + 运输方式:空运/海运/陆运,或空运口语「飞」(南京飞吉隆坡)。 + + 写了海运/陆运以书面为准。只说飞就是空运,补问件数时不得再问运输方式。 + """ + raw = text or "" + found = [code for word, code in _MODE_WORDS if word in raw] + if len(found) == 1: + return found[0] + if len(found) > 1: + return "" + if "飞" in raw: + return "AIR" + return "" + + +def parse_labeled_facts(text: str) -> dict[str, str]: + """只采集「字段名:值」,整行优先;同一句里的标签也收。不猜港口。""" + facts: dict[str, str] = {} + raw = text or "" + for line in raw.splitlines(): + m = _LABEL_LINE.match(line.strip()) + if m: + facts[m.group(1)] = m.group(2).strip() + if facts: + return facts + for m in _LABEL_INLINE.finditer(raw): + facts[m.group(1)] = m.group(2).strip() + return facts + + +def extract_inquiry_snapshot( + text: str, + *, + 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, +) -> dict[str, Any]: + """ + 抽出业务线 + 字段快照。 + + 优先 injected_*(单测);其次合并标签行与 DeepSeek B(口语)。 + 标签行覆盖 B 的同名字段。B 未出现的字段保持空,不本地猜港口。 + 件数/毛重/体积若原话已带 TMS 单位(件/KGS/CBM),空字段可机读补上。 + """ + from agent.schema.field_validate import harvest_oral_measures, normalize_facts + + if injected_facts is not None: + mode = (injected_mode or detect_transport_mode(text) or "").upper() + return { + "business_line": mode, + "land_subtype": "", + "facts": harvest_oral_measures(text, normalize_facts(dict(injected_facts))), + "source": "injected", + } + mode = detect_transport_mode(text) + facts = parse_labeled_facts(text) + source = "labeled_or_mode" + b_payload = b_result + if b_payload is None and (allow_b or chat_fn is not None): + from agent.llm.mode_extract_fields import invoke_extract_fields + + b_payload = invoke_extract_fields( + text, allow_network=allow_b, chat_fn=chat_fn + ) + if b_payload and b_payload.get("ok"): + merged = dict(b_payload.get("facts") or {}) + merged.update(facts) + facts = merged + mode = mode or str(b_payload.get("business_line") or "") + source = "deepseek_b" + facts = harvest_oral_measures(text, normalize_facts(facts)) + if not mode and facts.get("起运港") and facts.get("目的港") and "飞" in (text or ""): + mode = "AIR" + logger.info("extract.snapshot mode=%s keys=%s src=%s", mode or "-", sorted(facts), source) + return { + "business_line": mode, + "land_subtype": (b_payload or {}).get("land_subtype") or "", + "facts": facts, + "source": source, + } diff --git a/inquiry-agent/agent/llm/http_client.py b/inquiry-agent/agent/llm/http_client.py index 0c9b7bc..29d265e 100644 --- a/inquiry-agent/agent/llm/http_client.py +++ b/inquiry-agent/agent/llm/http_client.py @@ -119,15 +119,22 @@ class LlmHttpClient: body["tools"] = tools if tool_choice is not None: body["tool_choice"] = tool_choice + # deepseek-v4-flash 思考模式拒绝 named/required tool_choice,抽字段会 400 + if provider == "deepseek": + body["thinking"] = {"type": "disabled"} try: with httpx.Client(timeout=self.timeout_seconds) as client: resp = client.post(url, headers=headers, json=body) data = resp.json() if resp.content else {} if resp.status_code >= 400: + err_msg = "" + if isinstance(data, dict) and isinstance(data.get("error"), dict): + err_msg = str(data["error"].get("message") or "")[:200] logger.warning( - "llm.http 失败 provider=%s status=%s", + "llm.http 失败 provider=%s status=%s err=%s", provider, resp.status_code, + err_msg, ) return LlmHttpResult( ok=False, diff --git a/inquiry-agent/agent/llm/mode_extract_fields.py b/inquiry-agent/agent/llm/mode_extract_fields.py index 5575b78..ee171ad 100644 --- a/inquiry-agent/agent/llm/mode_extract_fields.py +++ b/inquiry-agent/agent/llm/mode_extract_fields.py @@ -1,31 +1,149 @@ """ -DeepSeek B:文本字段抽取 mode 壳。 +DeepSeek B:文字询价字段抽取。 -本文件职责:声明 mode 元数据与空 invoke_shell;不写字段合同激活规则。 -禁止:在本文件改主账六态。 +本文件职责:定义抽取 tool、解析模型 JSON、可选真网调用。 +禁止:本地正则猜港口/箱型;禁止改六态;未确认数据用途不得出站。 """ from __future__ import annotations -from typing import Any +import json +import logging +from typing import Any, Optional from agent.llm.modes import LlmMode +logger = logging.getLogger(__name__) + MODE = LlmMode.EXTRACT_FIELDS +EXTRACT_TOOL_NAME = "extract_inquiry_fields" + +_FIELD_KEYS = ( + "起运港", + "目的港", + "品名", + "件数", + "货量", + "毛重", + "体积", + "包装方式", + "报价日期", + "整柜或拼柜", + "箱型箱量", + "车型数量", +) + def mode_meta() -> dict[str, Any]: - """返回本 mode 的框架元数据(无 prompt 正文业务)。""" + """返回本 mode 元数据。""" return { "mode": MODE.value, "provider": "deepseek", "purpose": "text_field_extract", - "implemented": False, + "implemented": True, } +def extract_tool_definition() -> dict[str, Any]: + """ + DeepSeek B 唯一抽取 tool。 + + 模型必须调用本 tool;不得输出给销售看的正文。 + 未在原话出现的字段填空串,禁止编造。 + """ + field_props = {k: {"type": "string"} for k in _FIELD_KEYS} + return { + "type": "function", + "function": { + "name": EXTRACT_TOOL_NAME, + "description": "从销售原话抽取询价字段,未出现的填空串", + "parameters": { + "type": "object", + "additionalProperties": False, + "properties": { + "transport_mode": { + "type": "string", + "enum": ["AIR", "SEA", "LAND", "UNKNOWN"], + }, + "land_subtype": {"type": "string"}, + "fields": { + "type": "object", + "additionalProperties": False, + "properties": field_props, + }, + }, + "required": ["transport_mode", "fields"], + }, + }, + } + + +def parse_extract_payload(raw: Any) -> dict[str, Any]: + """ + 把模型 tool 参数或 JSON 正文变成 business_line + facts。 + + 空串字段丢弃。非法结构返回 ok=False,不猜。 + """ + data = raw + if isinstance(raw, str): + try: + data = json.loads(raw) + except json.JSONDecodeError: + return {"ok": False, "error": "invalid_json", "facts": {}, "business_line": ""} + if not isinstance(data, dict): + return {"ok": False, "error": "not_object", "facts": {}, "business_line": ""} + mode = str(data.get("transport_mode") or "").strip().upper() + if mode == "UNKNOWN": + mode = "" + fields_in = data.get("fields") if isinstance(data.get("fields"), dict) else {} + facts: dict[str, str] = {} + for key in _FIELD_KEYS: + val = str(fields_in.get(key) or "").strip() + if val: + facts[key] = val + land = str(data.get("land_subtype") or "").strip() + return { + "ok": True, + "business_line": mode if mode in {"AIR", "SEA", "LAND"} else "", + "land_subtype": land, + "facts": facts, + "source": "deepseek_b", + } + + +def build_extract_messages(text: str) -> list[dict[str, str]]: + """B 的系统/用户消息。只抽合同字段,不写销售可见话术。""" + return [ + { + "role": "system", + "content": ( + "你是询价字段抽取器。只根据销售原话抽取字段,调用 extract_inquiry_fields。" + "未出现的字段必须空串,禁止编造港口、重量、日期。" + "空运件数/毛重/体积/包装方式;海运货量与箱型箱量;陆运按二级类型。" + "原话已带单位必须抽取,不要因为没写「毛重:」就留空:" + "10件/3pcs→件数;100KGS/180kg/1.5KG/100公斤→毛重;2CBM/4.2cbm/2.5立方/2方→体积;" + "托盘/散货/卡板/纸箱/木箱→包装方式(销售说什么记什么,散货也要抽)。" + "9月15日/9月15号/2026-09-15→报价日期,规范成 yyyy-MM-dd,未写年用今年。" + "「从南京飞吉隆坡」起运港=南京、目的港=吉隆坡;「北京飞吉隆坡」起运港=北京。" + "换城市必须覆盖旧值,不要沿用上一句的港口。" + "运输方式仅在原话能判断时填 AIR/SEA/LAND,否则 UNKNOWN。" + ), + }, + {"role": "user", "content": text}, + ] + + def invoke_shell(*, payload: dict[str, Any]) -> dict[str, Any]: - """空壳:不调模型,返回未实现。""" + """ + 无网入口:有 assistant_json / tool_arguments 则解析;否则保持骨架错误码。 + """ + raw = payload.get("assistant_json") or payload.get("tool_arguments") + if raw is not None: + parsed = parse_extract_payload(raw) + parsed["stub"] = True + parsed["mode"] = MODE.value + return parsed return { "ok": False, "stub": True, @@ -33,3 +151,54 @@ def invoke_shell(*, payload: dict[str, Any]) -> dict[str, Any]: "error": "extract_fields_shell_not_implemented", "echo_keys": sorted(payload.keys()), } + + +def invoke_extract_fields( + text: str, + *, + allow_network: bool = False, + chat_fn: Optional[Any] = None, +) -> dict[str, Any]: + """ + 调用 DeepSeek B 抽字段。 + + chat_fn:单测注入,签名同 LlmHttpClient.chat。 + allow_network=False 且无 chat_fn:不调外网。 + """ + if chat_fn is None and not allow_network: + return {"ok": False, "error": "b_network_disabled", "facts": {}, "business_line": ""} + from agent.llm.http_client import LlmHttpClient + from agent.llm.modes import LlmMode + + client = LlmHttpClient.from_settings() + chat = chat_fn or client.chat + result = chat( + mode=LlmMode.EXTRACT_FIELDS, + messages=build_extract_messages(text), + tools=[extract_tool_definition()], + tool_choice={"type": "function", "function": {"name": EXTRACT_TOOL_NAME}}, + ) + if not getattr(result, "ok", False): + logger.info("extract.b 失败 err=%s", getattr(result, "error", "")) + return { + "ok": False, + "error": getattr(result, "error", "b_failed"), + "facts": {}, + "business_line": "", + } + raw_args: Any = None + for call in result.tool_calls or []: + fn = call.get("function") or {} + if fn.get("name") == EXTRACT_TOOL_NAME: + raw_args = fn.get("arguments") + break + if raw_args is None and result.content: + raw_args = result.content + parsed = parse_extract_payload(raw_args) + logger.info( + "extract.b ok=%s mode=%s keys=%s", + parsed.get("ok"), + parsed.get("business_line") or "-", + sorted((parsed.get("facts") or {}).keys()), + ) + return parsed diff --git a/inquiry-agent/agent/policy/air_text_flow.py b/inquiry-agent/agent/policy/air_text_flow.py new file mode 100644 index 0000000..c4a9b96 --- /dev/null +++ b/inquiry-agent/agent/policy/air_text_flow.py @@ -0,0 +1,794 @@ +""" +空运文字询价确定性流程(模型只负责抽字段,本文件只编排与执行)。 + +本文件职责:补问到询价必填齐全 → 建单出卡片 → 必须打主账查 TMS(有价/暂无报价)。 +对不上机场也不能跳过查价、本地说暂无报价。 +调用:handler 在 Worker/inbox 线程;禁止回调线程同步跑。 +禁止:直连 TMS;用图状态冒充主账成功;用户文字冒充文件已生成。 +""" + +from __future__ import annotations + +import logging +import threading +import uuid +from dataclasses import dataclass, field +from typing import Any, Callable, Optional + +from agent.config import get_settings +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.schema.field_validate import validate_required_fields +from agent.schema.tms_air_query import assemble_air_query + +logger = logging.getLogger(__name__) + +ReplyFn = Callable[..., None] + + +@dataclass +class FlowSession: + """一单会话书签(运行账,不是六态真相)。""" + + sender_id: str + thread_id: str + wait_version: int = 0 + phase: str = "idle" + business_line: str = "" + facts: dict[str, str] = field(default_factory=dict) + work_order_no: str = "" + first_or_same: str = "first" + history_work_order_no: str = "" + quote: dict[str, Any] = field(default_factory=dict) + status: str = "" + allowed: tuple[str, ...] = () + immutable_text: str = "" + + +class AirTextInquiryFlow: + """ + 空运文字询价 Owner(本轮产品范围);海运/陆运先补问运输方式或陆运二级。 + + 同一 sender 新开询价会覆盖「当前书签」(旧单留在主账)。 + 价格卡按工单号另存一份当时会话:点哪张卡就生成哪张卡当时的报价单。 + """ + + def __init__(self, ledger: Any = None) -> None: + self._ledger = ledger or build_ledger() + self._lock = threading.Lock() + self._by_sender: dict[str, FlowSession] = {} + self._by_ticket: dict[str, FlowSession] = {} + + @property + def ledger(self) -> Any: + return self._ledger + + def session_of(self, sender_id: str) -> Optional[FlowSession]: + with self._lock: + return self._by_sender.get(sender_id) + + def session_for_action( + self, + sender_id: str, + card_meta: Optional[dict[str, Any]] = None, + *, + text: str = "", + ) -> Optional[FlowSession]: + """ + 点卡:用工单号找回出卡当时的会话;对不上才用该销售当前书签。 + + 必须校验 sender_id,禁止用别人的工单号串会话。 + """ + no = copy.work_order_from_card_meta(card_meta, text=text) + with self._lock: + if no: + hit = self._by_ticket.get(no) + if hit and hit.sender_id == sender_id: + return hit + elif not no: + return self._by_sender.get(sender_id) + if no: + # 卡上有工单号就只动这一单;进程刚重启时从主账收回,禁止落到别人/最新单。 + return self._load_ticket_session(sender_id, no) + return None + + def _save(self, sess: FlowSession) -> None: + with self._lock: + self._by_sender[sess.sender_id] = sess + no = (sess.work_order_no or "").strip() + if no: + self._by_ticket[no] = sess + + def _load_ticket_session(self, sender_id: str, work_order_no: str) -> Optional[FlowSession]: + """ + 点卡时进程内没有该书签:只读主账收回这一单。 + + 找不到或不是本人:返回 None,调用方丢弃,绝不改成当前最新单。 + """ + getter = getattr(self._ledger, "get_for_agent", None) + if not callable(getter): + logger.warning("card 点到 %s 但无主账可读", work_order_no) + return None + view = getter(work_order_no=work_order_no, sender_id=sender_id) or {} + if not view.get("found") or view.get("owned") is False: + logger.warning( + "card 点到 %s 主账未收回 found=%s owned=%s", + work_order_no, + view.get("found"), + view.get("owned"), + ) + return None + sess = self._session_from_ledger(sender_id=sender_id, view=view) + self._save(sess) + logger.info( + "card 从主账收回 wo=%s phase=%s", + sess.work_order_no, + sess.phase, + ) + return sess + + def resume_historical( + self, + *, + sender_id: str, + work_order_no: str, + reply: ReplyFn, + ) -> str: + """ + 按工单号回到当时停点并重出对应卡片。 + + 先用进程内该书签;没有再读主账推断节点。不重跑 TMS、不新建工单。 + """ + no = (work_order_no or "").strip().upper() + if not no: + reply("请告诉我要继续的工单号,例如 WO202609140034。") + return "need_work_order" + with self._lock: + sess = self._by_ticket.get(no) + if sess and sess.sender_id == sender_id: + keep = sess + else: + keep = None + if keep: + return self._replay_wait(keep, reply, source="session") + getter = getattr(self._ledger, "get_for_agent", None) + if not callable(getter): + reply(f"找不到工单 {no},请核对工单号后再试。") + return "ticket_missing" + view = getter(work_order_no=no, sender_id=sender_id) or {} + if not view.get("found"): + reply(f"找不到工单 {no},请核对工单号后再试。") + return "ticket_missing" + if view.get("owned") is False: + reply("这张工单不属于当前账号,不能继续。") + return "ticket_forbidden" + sess = self._session_from_ledger(sender_id=sender_id, view=view) + self._save(sess) + return self._replay_wait(sess, reply, source="ledger") + + def _session_from_ledger(self, *, sender_id: str, view: dict[str, Any]) -> FlowSession: + """主账快照收成运行书签。wait_phase 由主账推断,不是六态本身。""" + no = str(view.get("work_order_no") or "").strip() + facts = dict(view.get("facts") or {}) + quote = dict(view.get("quote") or {}) + status = str(view.get("status") or "").strip() + phase = str(view.get("wait_phase") or "").strip() or self._infer_wait_phase( + status=status, quote=quote + ) + allowed: tuple[str, ...] = () + if phase == "wait_collab": + allowed = (copy.BTN_SKIP_COLLAB,) + elif phase == "wait_adopt": + allowed = (copy.BTN_ADOPT_EXCEL, copy.BTN_ADOPT_PDF, copy.BTN_REJECT) + elif phase == "wait_deal": + allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE) + return FlowSession( + sender_id=sender_id, + thread_id=sender_id, + phase=phase, + business_line=str(view.get("business_line") or "AIR"), + facts=facts, + work_order_no=no, + quote=quote, + status=status, + allowed=allowed, + wait_version=1, + ) + + def _infer_wait_phase(self, *, status: str, quote: dict[str, Any]) -> str: + if status in {"已成交", "未成交", "协商中", "已关闭"}: + return "done" + if status == "已报价" and quote: + return "wait_adopt" + return "tms_miss" + + def _replay_wait(self, sess: FlowSession, reply: ReplyFn, *, source: str) -> str: + """按书签重出当时节点,不改六态。""" + logger.info( + "resume replay wo=%s phase=%s source=%s", + sess.work_order_no, + sess.phase, + source, + ) + if sess.phase == "wait_adopt": + reply( + copy.scheme_card( + work_order_no=sess.work_order_no, + status=sess.status or "已报价", + facts=sess.facts, + quote=sess.quote, + ), + copy.scheme_wecom_payload( + work_order_no=sess.work_order_no, + facts=sess.facts, + quote=sess.quote, + status=sess.status or "已报价", + ), + ) + return "wait_adopt" + if sess.phase == "wait_collab": + reply( + copy.tms_hit_card( + 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 "已报价", + ), + copy.tms_hit_wecom_payload( + 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.phase == "tms_miss": + self._emit_tms_miss(reply, sess.work_order_no) + return "tms_miss" + if sess.phase == "wait_deal": + summary = ( + f"{sess.facts.get('起运港', '')} → {sess.facts.get('目的港', '')} " + f"{sess.facts.get('品名', '')}" + ) + reply( + copy.deal_card( + work_order_no=sess.work_order_no, + status=sess.status or "已报价", + summary=summary, + ) + ) + return "wait_deal" + if sess.phase == "done": + reply(copy.deal_ack(sess.status or "已关闭", sess.work_order_no)) + return "done" + reply(copy.inquiry_card(work_order_no=sess.work_order_no, facts=sess.facts)) + return sess.phase or "replay" + + def match_button(self, text: str, sess: Optional[FlowSession]) -> str: + """精确匹配当前允许的按钮文案 / 企微 EventKey;不是按钮返回空。""" + raw = (text or "").strip() + if not sess or not raw: + return "" + canonical = copy.canonical_button(raw) + if canonical and canonical in sess.allowed: + return canonical + for btn in sess.allowed: + if raw == btn or raw == f"【{btn}】": + return btn + return "" + + 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: + """ + 处理一句私聊文字。返回阶段名供单测断言。 + + 先认按钮(系统交互),再抽字段(模型/注入/B)。 + 点价格卡必须按卡片工单号找回当时会话,不能默认最新一单。 + """ + 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 != "AIR": + if mode == "LAND": + reply(copy.ASK_LAND_SUBTYPE) + return "need_land_subtype" + # 海运本轮只提示先走空运闭环(不半做海运查价) + reply("本轮先开通空运文字询价。请按空运描述需求,或补充为「空运」。") + return "sea_deferred" + + check = validate_required_fields(facts=facts, business_line="AIR") + facts = dict(check["facts"]) + tms_ready = assemble_air_query(facts) + facts = dict(tms_ready["facts"]) + if not check["ok"] or not tms_ready["ok"]: + # 询价必填或 TMS 查价参数不齐:回显已识别 + 待补充示例,不出询价确认 + 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="AIR", + 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: + # 先落已确认字段,避免建单失败后补问轮把毛重/体积弄丢 + self._save( + FlowSession( + sender_id=sender_id, + thread_id=sender_id, + phase="quoting", + business_line="AIR", + facts=facts, + immutable_text=text, + ) + ) + created = self._ledger.create_ticket( + sender_id=sender_id, + business_line="AIR", + 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) + reply(copy.inquiry_card(work_order_no=no, facts=facts, first_or_same=first)) + + tms = self._ledger.query_tms(work_order_no=no, facts=facts) + sess = FlowSession( + sender_id=sender_id, + thread_id=sender_id, + business_line="AIR", + 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"): + # 没拿到 TMS 回包(超时/熔断/主账未出站)不能冒充「暂无报价」 + 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"): + # ok=true 才是 TMS 已查过、确认没价 + self._emit_tms_miss(reply, no) + sess.phase = "tms_miss" + sess.status = "询价中" + 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_SKIP_COLLAB,) + sess.wait_version += 1 + self._save(sess) + reply( + copy.tms_hit_card( + work_order_no=no, + quote=quote, + first_or_same=first, + history_work_order_no=history_wo, + status="已报价", + ), + copy.tms_hit_wecom_payload( + work_order_no=no, + quote=quote, + first_or_same=first, + history_work_order_no=history_wo, + status="已报价", + ), + ) + return "wait_collab" + + def _emit_tms_miss(self, reply: ReplyFn, work_order_no: str) -> None: + """ + TMS 已查过且确认没价:文字兜底 + 暂无报价卡片。 + 禁止在没打 query_tms 前回这个。 + """ + reply( + copy.tms_miss(work_order_no=work_order_no), + copy.tms_miss_wecom_payload( + work_order_no=work_order_no, + action_url=(get_settings().public_base_url or "").strip(), + ), + ) + + 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="AIR", exclude_work_order_no=current_no + ) or {} + except TypeError: + similar = finder(facts=facts, business_line="AIR") 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 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) + card_wo = copy.work_order_from_card_meta(card_meta) + if not sess or action not in sess.allowed: + logger.info( + "card 丢弃 sender=%s action=%s card_wo=%s sess_wo=%s phase=%s", + sender_id, + action, + card_wo or "-", + (sess.work_order_no if sess else "-"), + (sess.phase if sess else "-"), + ) + return "card_discard" + if card_wo and sess.work_order_no and card_wo != sess.work_order_no: + logger.warning( + "card 工单不一致 card_wo=%s sess_wo=%s,拒绝串单", + card_wo, + sess.work_order_no, + ) + return "card_discard" + if action == copy.BTN_SKIP_COLLAB: + # 先摘掉按钮,连点第二次不再进生成 + sess.allowed = tuple(x for x in sess.allowed if x != copy.BTN_SKIP_COLLAB) + self._save(sess) + return self._skip_collab(sess, reply, card_meta=card_meta) + if action == copy.BTN_REJECT: + self._grey_clicked_card(reply, card_meta) + sess.phase = "wait_adjust" + sess.allowed = () + sess.wait_version += 1 + self._save(sess) + reply(copy.reject_ack()) + return "wait_adjust" + if action == copy.BTN_ADOPT_EXCEL: + return self._adopt_file(sess, reply, kind="Excel", card_meta=card_meta) + if action == copy.BTN_ADOPT_PDF: + return self._adopt_file(sess, reply, kind="PDF", card_meta=card_meta) + if action == copy.BTN_DEAL: + return self._close(sess, reply, "已成交") + if action == copy.BTN_LOST: + return self._close(sess, reply, "未成交") + if action == copy.BTN_NEGOTIATE: + return self._close(sess, reply, "协商中") + return "card_unknown" + + def _skip_collab( + self, + sess: FlowSession, + reply: ReplyFn, + card_meta: Optional[dict[str, Any]] = None, + ) -> str: + code = str((card_meta or {}).get("response_code") or "").strip() + if code: + reply( + copy.BTN_SKIP_COLLAB_DONE, + copy.skip_collab_card_update(response_code=code), + ) + reply(copy.skip_collab_ack()) + sess.phase = "wait_adopt" + sess.allowed = (copy.BTN_ADOPT_EXCEL, copy.BTN_ADOPT_PDF, copy.BTN_REJECT) + sess.wait_version += 1 + self._save(sess) + reply( + copy.scheme_card( + work_order_no=sess.work_order_no, + status=sess.status or "已报价", + facts=sess.facts, + quote=sess.quote, + ), + copy.scheme_wecom_payload( + work_order_no=sess.work_order_no, + facts=sess.facts, + quote=sess.quote, + status=sess.status or "已报价", + ), + ) + return "wait_adopt" + + def _grey_clicked_card(self, reply: ReplyFn, card_meta: Optional[dict[str, Any]]) -> None: + """点过方案卡后把原卡按钮改成已提交;无 ResponseCode 则不更新。""" + code = str((card_meta or {}).get("response_code") or "").strip() + if not code: + return + reply( + copy.BTN_SKIP_COLLAB_DONE, + copy.skip_collab_card_update(response_code=code), + ) + + def _adopt_file( + self, + sess: FlowSession, + reply: ReplyFn, + *, + kind: str, + card_meta: Optional[dict[str, Any]] = None, + ) -> str: + """ + 点生成 Excel/PDF:按询价需求匹配后台报价模板再出单。 + + 关键词未命中走默认模板;双无则只回话术,不进成交卡。 + Excel 当场发文件;PDF 主账只填 xlsx,转 PDF 进 LibreOffice 单槽。 + """ + self._grey_clicked_card(reply, card_meta) + if kind == "Excel": + reply(copy.adopt_excel_ack()) + else: + reply(copy.adopt_pdf_ack()) + fmt = "xlsx" if kind == "Excel" else "pdf" + renderer = getattr(self._ledger, "render_quote", None) + if not callable(renderer): + reply(copy.quote_template_missing()) + return "wait_adopt" + result = renderer( + work_order_no=sess.work_order_no, + business_line=sess.business_line or "AIR", + facts=dict(sess.facts or {}), + quote=dict(sess.quote or {}), + format=fmt, + sales_wecom_id=sess.sender_id, + ) or {} + if not result.get("ok"): + reply(str(result.get("message") or copy.quote_template_missing())) + return "wait_adopt" + filename = str(result.get("fileName") or f"{sess.work_order_no}_quote.{fmt}") + file_extra = self._quote_file_payload(result, filename=filename) + if kind == "PDF" and result.get("needsPdf"): + queued = self._enqueue_quote_pdf( + result, + work_order_no=sess.work_order_no, + sender_id=sess.sender_id, + filename=filename if filename.lower().endswith(".pdf") else f"{sess.work_order_no}_quote.pdf", + ) + if not queued: + fallback = str(result.get("fileName") or f"{sess.work_order_no}_quote.xlsx") + if not fallback.lower().endswith(".xlsx"): + fallback = f"{sess.work_order_no}_quote.xlsx" + reply("PDF 转换排队失败,已按后台模板发送 Excel 报价单。") + reply( + copy.file_ready(kind="Excel", work_order_no=sess.work_order_no, filename=fallback), + file_extra, + ) + # 已入队:Worker 出文件;此处不冒充 soffice 已成功 + else: + reply( + copy.file_ready(kind=kind, work_order_no=sess.work_order_no, filename=filename), + file_extra, + ) + summary = f"{sess.facts.get('起运港', '')} → {sess.facts.get('目的港', '')} {sess.facts.get('品名', '')}" + sess.phase = "wait_deal" + sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE) + sess.wait_version += 1 + self._save(sess) + reply( + copy.deal_card( + work_order_no=sess.work_order_no, + status=sess.status or "已报价", + summary=summary, + ) + ) + return "wait_deal" + + def _quote_file_payload(self, result: dict[str, Any], *, filename: str) -> dict[str, Any]: + """出站文件载荷:优先本机路径,否则主账回的 Base64。""" + return { + "msgtype": "file", + "filename": filename, + "filepath": str(result.get("filePath") or ""), + "file_b64": str(result.get("fileBase64") or ""), + } + + def _enqueue_quote_pdf( + self, + result: dict[str, Any], + *, + work_order_no: str, + sender_id: str, + filename: str, + ) -> bool: + """ + 把主账已填好的 xlsx 交给 Worker LibreOffice 单槽转 PDF。 + + 失败返回 False,调用方改发 Excel,不在 inbox 线程跑 soffice。 + """ + raw_b64 = str(result.get("fileBase64") or "").strip() + if not raw_b64: + return False + try: + import base64 + import tempfile + from pathlib import Path + + from agent.jobs.pdf_job import enqueue_pdf_job + + data = base64.b64decode(raw_b64) + work_dir = Path(tempfile.gettempdir()) / "inquiry_quote" + work_dir.mkdir(parents=True, exist_ok=True) + xlsx_path = work_dir / f"{work_order_no}_quote.xlsx" + xlsx_path.write_bytes(data) + queued = enqueue_pdf_job( + xlsx_path=str(xlsx_path), + inquiry_no=work_order_no, + quote_version=1, + idempotency_key=f"pdf:{work_order_no}:{uuid.uuid4().hex[:8]}", + touser=sender_id, + filename=filename, + ) + return bool(queued.accepted) + except Exception: + logger.exception("报价 PDF 入队失败 wo=%s", work_order_no) + return False + + def _close(self, sess: FlowSession, reply: ReplyFn, status: str) -> str: + result = self._ledger.transition(work_order_no=sess.work_order_no, to_status=status) + if not result.get("ok"): + reply("主账状态更新失败,成交结果未入账。") + return "ledger_fail" + sess.status = status + sess.phase = "done" + sess.allowed = () + self._save(sess) + reply(copy.deal_ack(status, sess.work_order_no)) + return "done" + + +_FLOW: Optional[AirTextInquiryFlow] = None +_FLOW_GUARD = threading.Lock() + + +def build_ledger() -> Any: + """生产打 8180;单测 LEDGER_BACKEND=memory。""" + from agent.config import get_settings + + backend = (get_settings().ledger_backend or "http").strip().lower() + if backend == "memory": + return MemoryLedger() + return HttpLedger() + + +def get_air_text_flow() -> AirTextInquiryFlow: + """进程内一份流程(与 inbox 线程共用);测试可 new 独立实例。""" + global _FLOW + with _FLOW_GUARD: + if _FLOW is None: + _FLOW = AirTextInquiryFlow() + return _FLOW + + +def reset_air_text_flow_for_test() -> AirTextInquiryFlow: + """单测重置全局实例(强制内存账本)。""" + global _FLOW + with _FLOW_GUARD: + _FLOW = AirTextInquiryFlow(MemoryLedger()) + return _FLOW diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py new file mode 100644 index 0000000..21f5929 --- /dev/null +++ b/inquiry-agent/agent/policy/inquiry_copy.py @@ -0,0 +1,797 @@ +""" +空运文字询价出站话术(销售可见)。 + +本文件职责:集中文案;按钮字面量必须与 handler 精确匹配。 +禁止:在本文件调主账/TMS;禁止把密钥写进话术。 +""" + +from __future__ import annotations + +import json +import re +import time +import uuid +from typing import Any, Iterable + +from agent.schema.field_validate import AIR_REQUIRED_FIELDS + +# 空运补问:与合同 8 项一致。报价日期默认当天,不进待补充。 +_AIR_EXAMPLES = { + "起运港": "南京", + "目的港": "吉隆坡", + "品名": "茶叶", + "件数": "10件", + "毛重": "100KGS", + "体积": "2CBM", + "包装方式": "托盘", + "报价日期": "2026-09-15", +} +_AIR_CLARIFY = tuple( + (key, label, _AIR_EXAMPLES[key]) for key, label in AIR_REQUIRED_FIELDS +) + +# 文字按钮(销售点卡或原样回复均可) +BTN_SKIP_COLLAB = "不需要协同,直接生成报价单" +BTN_SKIP_COLLAB_OLD = "不需要协同,直接生成基础报价单" +BTN_SKIP_COLLAB_KEY = "skip_collab" +BTN_SKIP_COLLAB_DONE = "已提交" +BTN_ADOPT_EXCEL = "采用,生成Excel报价单" +BTN_ADOPT_PDF = "采用,生成PDF报价单" +BTN_REJECT = "不采用" +# 企微一张卡三个按钮只能横排,长文案会被裁。卡片用短文案才能看全。 +BTN_ADOPT_EXCEL_CARD = "生成Excel" +BTN_ADOPT_PDF_CARD = "生成PDF" +BTN_ADOPT_EXCEL_KEY = "adopt_excel" +BTN_ADOPT_PDF_KEY = "adopt_pdf" +BTN_REJECT_KEY = "reject" +BTN_DEAL = "成交" +BTN_LOST = "未成交" +BTN_NEGOTIATE = "协商中" + +_WO_NO = re.compile(r"WO\d{12}", re.IGNORECASE) +# 仅给无网 stub / 工单号回填用,正式续办意图由 DeepSeek A 判断,不要求销售说这些词。 +_CONTINUE_HINT = ( + "继续", + "接着", + "回到", + "再看", + "再出", + "找回", + "重开", + "那单", + "这单", + "历史工单", + "咨询工单", + "刚才那", + "上次那", +) + + +ASK_TRANSPORT = "请补充运输方式:海运 / 空运 / 陆运。" +ASK_LAND_SUBTYPE = "请补充陆运类型:国内运输拼车 / 国内运输整车 / 跨境集拼 / 跨境整车 / 中港。" +FIRST_CHAT = "你好,我是询价机器人。请直接描述客户需求,例如「上海到洛杉矶空运,3 件 180kg,下周三前到」,我会解析需求并查询 TMS 标准报价。" +ROUTE_MODEL_FAILED = "刚才没听清,请再说一次:是新询价,还是要接着办哪张工单?" + + +def _air_known_value(facts: dict[str, str], key: str) -> str: + """已识别字段的销售侧展示值;没有则空串。""" + src = facts or {} + if key == "起运港": + return (src.get("起运港") or "").strip() + if key == "目的港": + return (src.get("目的港") or "").strip() + if key == "品名": + return (src.get("品名") or "").strip() + if key == "件数": + raw = (src.get("件数") or "").strip() + return _keep_or_add_unit(raw, ("件", "PCS"), "件") if raw else "" + if key == "毛重": + raw = (src.get("毛重") or "").strip() + return _keep_or_add_unit(raw, ("KGS", "KG", "公斤", "千克"), "") if raw else "" + if key == "体积": + raw = (src.get("体积") or "").strip() + return _keep_or_add_unit(raw, ("CBM", "M3", "M³", "立方米", "立方", "方"), "") if raw else "" + if key == "包装方式": + return (src.get("包装方式") or src.get("包装类型") or "").strip() + if key == "报价日期": + return (src.get("报价日期") or "").strip() + return (src.get(key) or "").strip() + + +def ask_clarify( + *, + facts: dict[str, str] | None = None, + missing_keys: Iterable[str] = (), + transport_mode: str = "空运", +) -> str: + """ + 缺项补问:先回显已识别,再列待补充并给示例。 + + 报价日期写进已识别(销售说了用原话,没说用当天),不出现在待补充。 + """ + src = dict(facts or {}) + skip = {"报价日期"} + missing = [k for k in missing_keys if k and k not in skip] + missing_set = set(missing) + lines = ["请确认并补充以下信息:"] + if transport_mode: + lines.append(f"运输方式:{transport_mode}") + for key, label, _example in _AIR_CLARIFY: + val = _air_known_value(src, key) + if val: + lines.append(f"{label}:{val}") + lines.append("") + lines.append("待补充:") + any_missing = False + if "运输方式" in missing_set: + any_missing = True + lines.append("运输方式:(如:空运)") + for key, label, example in _AIR_CLARIFY: + if key in missing_set: + any_missing = True + lines.append(f"{label}:(如:{example})") + if not any_missing: + return "" + return "\n".join(lines) + + +def ask_missing(display_names: Iterable[str]) -> str: + """兼容旧调用:仅有展示名时仍走补问,但没有已识别回显。""" + alias = { + "起运地": "起运港", + "目的地": "目的港", + "起运地(机场名或三字码)": "起运港", + "目的地(机场名或三字码)": "目的港", + "毛重": "毛重", + "重量": "毛重", + "重量(KG)": "毛重", + "体积": "体积", + "体积(CBM)": "体积", + "包装类型": "包装方式", + "包装方式": "包装方式", + } + keys: list[str] = [] + for name in display_names: + key = alias.get(name, name) + if key and key not in keys and key != "报价日期": + keys.append(key) + return ask_clarify(facts={}, missing_keys=keys, transport_mode="空运") + + +def _keep_or_add_unit(raw: str, known: tuple[str, ...], default: str) -> str: + """ + 销售侧单位原样回显。 + + 写了 1.5KG 就回 1.5KG,禁止改成 1.5KGS。没写单位才补 default(可空)。 + """ + text = (raw or "").strip() + if not text: + return "-" + upper = text.upper() + for unit in known: + if upper.endswith(unit.upper()): + return text + if default and not any(ch.isalpha() or ch in "立方件" for ch in text): + return f"{text}{default}" + return text + + +def _with_unit(raw: str, suffix: str) -> str: + """兼容旧调用:已带单位则原样。重量不要走这里补 KGS。""" + if suffix.upper() == "KGS": + return _keep_or_add_unit(raw, ("KGS", "KG", "公斤", "千克"), "") + if suffix.upper() == "CBM": + return _keep_or_add_unit(raw, ("CBM", "M3", "M³", "立方米", "立方", "方"), "") + return _keep_or_add_unit(raw, (suffix,), suffix) + + +def inquiry_field_lines( + *, + work_order_no: str, + facts: dict[str, str], + transport_mode: str = "空运", +) -> list[str]: + """ + 询价确认字段行(销售可见)。 + + 起运/目的展示销售原文(南京/北京),三字码只给出站 TMS,不对销售展示。 + """ + origin = (facts.get("起运港") or "-").strip() + dest = (facts.get("目的港") or "-").strip() + cargo = (facts.get("品名") or "-").strip() or "-" + pack = (facts.get("包装方式") or facts.get("包装类型") or "-").strip() or "-" + quote_date = (facts.get("报价日期") or "-").strip() or "-" + return [ + f"工单号:{work_order_no}", + f"运输方式:{transport_mode}", + f"起运地:{origin}", + f"目的地:{dest}", + f"品名:{cargo}", + f"件数:{_with_unit(str(facts.get('件数') or ''), '件')}", + f"重量:{_with_unit(str(facts.get('毛重') or ''), 'KGS')}", + f"体积:{_with_unit(str(facts.get('体积') or ''), 'CBM')}", + f"包装类型:{pack}", + f"报价日期:{quote_date}", + ] + + +def inquiry_card(*, work_order_no: str, facts: dict[str, str], first_or_same: str = "first") -> str: + """ + 建单后的询价确认。 + + 无标题、无 [test]。first_or_same 保留兼容,不再对销售展示。 + """ + _ = first_or_same + lines = inquiry_field_lines(work_order_no=work_order_no, facts=facts) + lines.append("工单已创建,正在查询TMS报价。") + return "\n".join(lines) + + +def canonical_button(raw: str) -> str: + """把销售点卡 / 原文 / 企微 EventKey 收成标准按钮文案;对不上返回空。""" + text = (raw or "").strip().strip("【】") + if text in {BTN_SKIP_COLLAB, BTN_SKIP_COLLAB_OLD, BTN_SKIP_COLLAB_KEY}: + return BTN_SKIP_COLLAB + # 新卡 EventKey 带工单号:skip_collab:WO…,避免点旧卡落到最新单 + if text.startswith(f"{BTN_SKIP_COLLAB_KEY}:"): + return BTN_SKIP_COLLAB + if text in {BTN_ADOPT_EXCEL, BTN_ADOPT_EXCEL_CARD, BTN_ADOPT_EXCEL_KEY} or text.startswith( + f"{BTN_ADOPT_EXCEL_KEY}:" + ): + return BTN_ADOPT_EXCEL + if text in {BTN_ADOPT_PDF, BTN_ADOPT_PDF_CARD, BTN_ADOPT_PDF_KEY} or text.startswith( + f"{BTN_ADOPT_PDF_KEY}:" + ): + return BTN_ADOPT_PDF + if text in {BTN_REJECT_KEY} or text.startswith(f"{BTN_REJECT_KEY}:"): + return BTN_REJECT + if text == BTN_SKIP_COLLAB_DONE: + return "" + for btn in (BTN_ADOPT_EXCEL, BTN_ADOPT_PDF, BTN_REJECT, BTN_DEAL, BTN_LOST, BTN_NEGOTIATE): + if text == btn: + return btn + return "" + + +def extract_work_order_no(text: str) -> str: + """从任意原话里抽出 WO + 12 位数字;没有则空。不判断意图。""" + hit = _WO_NO.search(text or "") + return hit.group(0).upper() if hit else "" + + +def card_task_id(kind: str, work_order_no: str) -> str: + """ + 每次出卡一个新 task_id。企微 42014:同一 task_id 不能再发,会整卡失败只剩文字。 + + 工单号仍嵌在前缀里,点卡回调靠 work_order_from_card_meta 找回。 + """ + stamp = f"{int(time.time() * 1000):x}{uuid.uuid4().hex[:4]}" + return f"{kind}-{work_order_no}-{stamp}" + + +def looks_like_continue_ticket(text: str) -> bool: + """ + 无网夹具用的弱提示。正式环境必须由大模型判续办意图。 + 禁止把本函数当成生产唯一路由。 + """ + raw = (text or "").strip() + if not raw: + return False + return any(token in raw for token in _CONTINUE_HINT) + + +def work_order_from_card_meta( + card_meta: dict[str, Any] | None = None, + *, + text: str = "", +) -> str: + """ + 从企微点卡回调用找回「点的是哪一张卡当时的工单」。 + + 优先 TaskId / EventKey 里的工单号(quote-WO…、scheme-adopt_excel-WO…)。 + 对不上返回空:调用方按该销售当前最新书签处理。 + """ + src = card_meta or {} + for raw in ( + str(src.get("task_id") or "").strip(), + str(src.get("event_key") or "").strip(), + (text or "").strip(), + ): + no = _work_order_in_token(raw) + if no: + return no + return "" + + +def _work_order_in_token(raw: str) -> str: + """从 TaskId / EventKey 里抽出 WO 工单号;没有则空。""" + text = (raw or "").strip() + if not text: + return "" + for prefix in ( + f"{BTN_SKIP_COLLAB_KEY}:", + f"{BTN_ADOPT_EXCEL_KEY}:", + f"{BTN_ADOPT_PDF_KEY}:", + f"{BTN_REJECT_KEY}:", + ): + if text.startswith(prefix): + rest = text[len(prefix) :].strip() + if rest.upper().startswith("WO"): + return extract_work_order_no(rest) or rest.split("-", 1)[0] + # 卡片 task_id 会带时间后缀(scheme-WO…-abc),只取 WO+12 位。 + return extract_work_order_no(text) + + +_CURRENCY_PREFIX = re.compile(r"^[A-Za-z]{3}\b") + + +def money_text(amount: Any, currency: str = "") -> str: + """ + 销售可见金额:TMS 有币种就写成「CNY 14669.60」。 + + 金额已带三字码前缀则原样。没有币种不编造 CNY/USD。 + """ + raw = str(amount if amount not in (None, "") else "").strip() + if not raw or raw == "-": + return raw or "-" + if _CURRENCY_PREFIX.match(raw): + return raw + code = str(currency or "").strip().upper() + if code == "RMB": + code = "CNY" + if not code: + return raw + return f"{code} {raw}" + + +def quote_fee_lines(quote: dict[str, Any] | None) -> list[tuple[str, str]]: + """ + 价格明细行:优先 TMS fee_lines,否则用运费/其它/合计兜底。 + + 返回 (费用名, 金额) ,空金额不展示。金额带 TMS 币种,不编造。 + """ + src = dict(quote or {}) + header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip() + raw = src.get("fee_lines") + parsed: Any = raw + if isinstance(raw, str) and raw.strip(): + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + parsed = None + lines: list[tuple[str, str]] = [] + if isinstance(parsed, list): + for item in parsed: + if not isinstance(item, dict): + continue + name = str(item.get("name") or item.get("label") or "").strip() + amount = str(item.get("amount") or item.get("value") or "").strip() + line_ccy = str(item.get("currency") or item.get("currencyCode") or header_ccy).strip() + if name and amount and amount != "-": + lines.append((name, money_text(amount, line_ccy))) + if lines: + return lines + freight = str(src.get("freight") or "").strip() + other = str(src.get("other_fee") or "").strip() + if freight and freight != "-": + lines.append(("空运费", money_text(freight, header_ccy))) + if other and other != "-": + lines.append(("其他附加费", money_text(other, header_ccy))) + return lines + + +def quote_area_fee_text(quote: dict[str, Any] | None) -> str: + """ + 卡片引用条正文:费用行 + 总销售价合计。 + + 必须走 quote_text。塞进 quote_area.title 会被企微整段藏掉。 + """ + 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) + fee_lines = [f"{name}:{amount}" for name, amount in quote_fee_lines(src)] + fee_lines.append(f"总销售价合计:{total}") + return "\n".join(fee_lines) + + +def collab_hint(work_order_no: str) -> str: + """价格卡协同说明:无「内部协同」前缀,紧挨生成报价单按钮上方。""" + return ( + f"若需要咨询内部航线人员,请手动拉新人与智能体到原有群," + f"并在群聊发送工单编号{work_order_no}。" + ) + + +def _strip_leading_dash(raw: str) -> str: + """去掉时效前导 '-' / '—',避免销售看到「-3-5天」或「-;3-5天」。""" + text = (raw or "").strip() + while text.startswith(("-", "—", "–")): + rest = text[1:].lstrip(" \t;;,,") + if not rest: + return "" + text = rest + return text + + +def tms_eta_display(quote: dict[str, Any] | None) -> str: + """时效只取 TMS 回的 eta / 预计送达;没有就「-」。禁止本地编。前导横杠会剥掉。""" + src = dict(quote or {}) + eta = _strip_leading_dash(str(src.get("eta") or "")) + if eta: + return eta + dated = _strip_leading_dash( + str(src.get("eta_date") or src.get("estimated_delivery") or "") + ) + return dated or "-" + + +def tms_valid_until_display(quote: dict[str, Any] | None) -> str: + """报价有效期只取 TMS 原文,没有就 "-"。""" + src = dict(quote or {}) + raw = str( + src.get("valid_until") + or src.get("validUntil") + or src.get("quoteValidUntil") + or "" + ).strip() + return raw if raw else "-" + + +def similar_inquiry_line(*, first_or_same: str, history_work_order_no: str = "") -> str: + """ + 相同询价内容检索:首次询价,或引用全库最新一条一模一样的历史工单号。 + """ + no = (history_work_order_no or "").strip() + if first_or_same == "same" and no: + return f"相同询价内容检索:引用历史工单号{no}" + return "相同询价内容检索:首次询价" + + +def quote_detail_lines( + quote: dict[str, Any] | None, + *, + first_or_same: str = "first", + history_work_order_no: str = "", + status: str = "已报价", +) -> list[str]: + """ + 价格明细正文:TMS 费用、合计、时效、有效期、相同检索。 + + 给文本兜底和企微引用条共用,禁止在这里编时效/有效期。价格卡不再展示工单状态。 + """ + _ = status + 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) + lines = [f"{name}:{amount}" for name, amount in quote_fee_lines(src)] + lines.append(f"总销售价合计:{total}") + lines.append(f"时效:{tms_eta_display(src)}") + lines.append(f"报价有效期:{tms_valid_until_display(src)}") + lines.append( + similar_inquiry_line( + first_or_same=first_or_same, history_work_order_no=history_work_order_no + ) + ) + return lines + + +def 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(collab_hint(work_order_no)) + lines.append(BTN_SKIP_COLLAB) + return "\n".join(lines) + + +def 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]: + """ + 一张价格卡,销售侧顺序: + + 价格明细 + ┌ 引用条:空运费 / 其它费用 / 总销售价合计 + 时效、报价有效期、相同询价内容检索 + 协同说明(普通文字,贴按钮上方) + 按钮 + + 企微 quote_area.title 只能很短,超长会被整段藏掉。报价必须放 quote_text。 + 不用 horizontal:它会排到副标题后面,把费用挤到按钮前。 + """ + 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)}", + similar_inquiry_line( + first_or_same=first_or_same, history_work_order_no=history_work_order_no + ), + "", + collab_hint(work_order_no), + ] + ) + 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_fee_text(src), + }, + "sub_title_text": meta, + "task_id": card_task_id("quote", work_order_no), + "button_list": [ + { + "text": BTN_SKIP_COLLAB, + "style": 1, + # EventKey 也带工单号:TaskId 偶发缺失时仍能找回当时那一单 + "key": f"{BTN_SKIP_COLLAB_KEY}:{work_order_no}", + } + ], + }, + } + + +def skip_collab_card_update(*, response_code: str) -> dict[str, Any]: + """ + 点过「生成报价单」后更新原卡:按钮改成已提交并保持置灰。 + + 必须带企微回调 ResponseCode;无码则不要入出站。 + """ + return { + "msgtype": "update_template_card", + "response_code": response_code, + "button": {"replace_name": BTN_SKIP_COLLAB_DONE}, + } + + +def tms_miss_quote_text(*, work_order_no: str) -> str: + """引用条正文:只放「请务必在群聊发送工单编号」。""" + return f"请务必在群聊发送工单编号{work_order_no}" + + +def tms_miss_hint() -> str: + """卡片正文:无价说明 + 拉群提示,不单独再写标题下灰字。""" + return "TMS标准价库暂无匹配报价,请手动拉新人与智能体到原有群" + + +def tms_miss(*, work_order_no: str) -> str: + """ + TMS 已经查过、确认没标准价时的文字兜底。 + 禁止在没打 TMS 前用本句冒充无价。 + """ + return ( + f"TMS暂无报价。{tms_miss_hint()}," + f"{tms_miss_quote_text(work_order_no=work_order_no)}" + ) + + +def tms_miss_wecom_payload(*, work_order_no: str, action_url: str = "") -> dict[str, Any]: + """ + TMS 已查过、确认没价:一张 text_notice 卡。 + + 顺序:来源 → 标题(仅工单号+暂无报价)→ 引用条(工单编号)→ 拉群说明。 + 禁止:标题下灰字、工单状态、查价结果/来源行、按钮。 + 企微 text_notice 必须带可信域名 card_action,否则整卡发失败只剩文字。 + """ + card: dict[str, Any] = { + "card_type": "text_notice", + "source": {"desc": "TMS查价结果", "desc_color": 0}, + "main_title": {"title": f"{work_order_no}暂无报价"}, + "quote_area": { + "type": 0, + "quote_text": tms_miss_quote_text(work_order_no=work_order_no), + }, + "sub_title_text": tms_miss_hint(), + "task_id": card_task_id("miss", work_order_no), + } + url = (action_url or "").strip() + if url: + card["card_action"] = {"type": 1, "url": url} + return {"msgtype": "template_card", "template_card": card} + + +def tms_contract_gap(*, work_order_no: str) -> str: + """已废弃:不再因字典对不上拦查价;保留以免旧单测/引用报错。""" + return tms_miss(work_order_no=work_order_no) + + +def tms_need_more(*, work_order_no: str, hint: str = "") -> str: + """已废弃:TMS 回 1003 按合同收成暂无报价。保留以免旧引用报错。""" + _ = hint + return tms_miss(work_order_no=work_order_no) + + +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: + """ + 报价方案文本兜底(模板卡发失败时仍给销售看)。 + + 与卡片同一套字段,禁止漏项。不再展示工单状态。 + """ + _ = status + src = dict(quote or {}) + # 方案卡不展示询价「报价日期」,改展示 TMS 报价有效期 + body = "\n".join( + line + for line in inquiry_field_lines(work_order_no=work_order_no, facts=facts) + if not line.startswith("报价日期:") + ) + eta = tms_eta_display(src) + valid = tms_valid_until_display(src) + lines = [ + f"【{work_order_no}报价方案】", + body, + "价格明细", + quote_area_fee_text(src), + f"时效:{eta or '-'}", + f"报价有效期:{valid}", + "请确认是否采用该报价方案:", + f"【{BTN_ADOPT_EXCEL_CARD}】", + f"【{BTN_ADOPT_PDF_CARD}】", + f"【{BTN_REJECT}】", + ] + return "\n".join(lines) + + +def _clip(raw: str, limit: int) -> str: + """企微列表格有字数建议;超长才裁,避免空白。""" + text = (raw or "").strip() or "-" + return text if len(text) <= limit else text[: limit - 1] + "…" + + +def scheme_horizontal_rows( + *, + work_order_no: str, + facts: dict[str, str], + quote: dict[str, Any], +) -> list[dict[str, str]]: + """ + 卡片字段:一行一项,每格独立字数,不会像副标题整段截成 8CB…。 + + 企微 horizontal 最多 6 条。运输方式写进航线行,避免丢字段。 + 不用 sub_title 堆字段(它会排到列表前面,且超长必截断)。 + """ + src = facts or {} + q = dict(quote or {}) + origin = (src.get("起运港") or "-").strip() or "-" + dest = (src.get("目的港") or "-").strip() or "-" + cargo = (src.get("品名") or "-").strip() or "-" + pieces = _with_unit(str(src.get("件数") or ""), "件") + weight = _with_unit(str(src.get("毛重") or ""), "KGS") + volume = _with_unit(str(src.get("体积") or ""), "CBM") + pack = (src.get("包装方式") or 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};{pieces};{weight};{volume}", 26)}, + {"keyname": "包装", "value": _clip(pack, 26)}, + {"keyname": "时效", "value": _clip(tms_eta_display(q), 26)}, + {"keyname": "报价有效期", "value": _clip(tms_valid_until_display(q), 26)}, + ] + + +def scheme_wecom_payload( + *, + work_order_no: str, + facts: dict[str, str], + quote: dict[str, Any], + status: str = "已报价", +) -> dict[str, Any]: + """ + 一张报价方案卡:标题带工单号、引用条、字段、三个短按钮。 + + 按钮文案必须短:生成Excel / 生成PDF / 不采用。长文案横排会被裁。 + """ + _ = status + src = dict(quote or {}) + source = str(src.get("source_label") or "TMS标准报价").replace(" ", "") + 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_fee_text(src), + }, + "horizontal_content_list": scheme_horizontal_rows( + work_order_no=work_order_no, facts=facts, quote=src + ), + "task_id": card_task_id("scheme", work_order_no), + "button_list": [ + { + "text": BTN_ADOPT_EXCEL_CARD, + "style": 1, + "key": f"{BTN_ADOPT_EXCEL_KEY}:{work_order_no}", + }, + { + "text": BTN_ADOPT_PDF_CARD, + "style": 1, + "key": f"{BTN_ADOPT_PDF_KEY}:{work_order_no}", + }, + { + "text": BTN_REJECT, + "style": 2, + "key": f"{BTN_REJECT_KEY}:{work_order_no}", + }, + ], + }, + } + + +def reject_ack() -> str: + return "请问如何调整报价单,以满足您的需求?您可使用「报价加 10%」等调价指令。" + + +def adopt_excel_ack() -> str: + return "已确认采用该方案,正在生成 Excel 最终报价单…" + + +def adopt_pdf_ack() -> str: + return "已确认采用该方案,正在生成PDF最终报价单…" + + +QUOTE_TEMPLATE_MISSING = "生成报价单失败,请联系IT运维维护后台报价模板。" + + +def quote_template_missing() -> str: + """关键词与默认模板都未维护时的销售提示(产品口径,勿改字)。""" + return QUOTE_TEMPLATE_MISSING + + +def file_ready(*, kind: str, work_order_no: str, filename: str) -> str: + return f"已生成{kind}最终报价单:{filename}(工单 {work_order_no})" + + +def deal_card(*, work_order_no: str, status: str, summary: str) -> str: + return ( + "【咨询成交状态】\n" + f"工单编号:{work_order_no}\n" + f"工单状态:{status}\n" + f"询价内容:{summary}\n" + "请立即确认当前与客户沟通的成交状态:\n" + f"【{BTN_DEAL}】【{BTN_LOST}】【{BTN_NEGOTIATE}】" + ) + + +def deal_ack(outcome: str, work_order_no: str) -> str: + if outcome == "已成交": + return f"已记录为「已成交」。工单 {work_order_no} 已关闭智能体跟进。" + if outcome == "未成交": + return f"已记录为「未成交」。请补充未成交原因,工单 {work_order_no} 将关闭智能体跟进。" + return f"已记录为「协商中」。工单 {work_order_no} 跟进周期已顺延。" diff --git a/inquiry-agent/agent/quote_templates/__init__.py b/inquiry-agent/agent/quote_templates/__init__.py index eb7f766..05ff1f9 100644 --- a/inquiry-agent/agent/quote_templates/__init__.py +++ b/inquiry-agent/agent/quote_templates/__init__.py @@ -35,6 +35,8 @@ class TemplateEntry: mapping_json: str = "" biz_type: str = "" category: str = "" + keywords: tuple[str, ...] = () + is_default: bool = False def local_cache_dir() -> Path: @@ -93,6 +95,8 @@ def entries_from_ledger_response(resp: LedgerResponse) -> list[TemplateEntry]: mapping_json=str(it.get("mappingJson") or ""), biz_type=str(it.get("bizType") or ""), category=str(it.get("category") or ""), + keywords=tuple(str(x) for x in (it.get("keywords") or []) if str(x).strip()), + is_default=bool(it.get("isDefault")), ) ) return out @@ -160,22 +164,35 @@ def registry_as_dicts() -> list[dict[str, Any]]: def pick_template_for_biz( *, biz_type: str, + facts: Optional[dict[str, Any]] = None, + quote: Optional[dict[str, Any]] = None, keywords: Optional[list[str]] = None, entries: Optional[list[TemplateEntry]] = None, ) -> Optional[TemplateEntry]: """ - 按业务线 + 关键词挑选模板(优先 is_default 由主账排序保证)。 + 按询价需求匹配模板关键词;都未命中则同业务默认模板。 - 关键词命中任一即优;否则同 biz 第一条。 + keywords 入参已废弃(兼容旧调用,忽略)。生产出单以主账 render 为准。 """ + _ = keywords + from agent.quote_templates.match import pick_quote_template + items = entries if entries is not None else list_template_registry() - same = [e for e in items if (e.biz_type == biz_type or not e.biz_type)] - if not same: - same = list(items) - if keywords: - kw_l = [k.lower() for k in keywords if k] - for e in same: - blob = f"{e.filename} {e.category} {e.code}".lower() - if any(k in blob for k in kw_l): - return e - return same[0] if same else None + rows = [ + { + "templateId": e.template_id or e.code, + "name": e.filename or e.code, + "bizType": e.biz_type, + "keywords": list(e.keywords), + "isDefault": e.is_default, + "_entry": e, + } + for e in items + ] + hit = pick_quote_template( + templates=rows, business_line=biz_type, facts=facts, quote=quote + ) + if hit is None: + return None + entry = hit.raw.get("_entry") + return entry if isinstance(entry, TemplateEntry) else None diff --git a/inquiry-agent/agent/quote_templates/match.py b/inquiry-agent/agent/quote_templates/match.py new file mode 100644 index 0000000..bd4c654 --- /dev/null +++ b/inquiry-agent/agent/quote_templates/match.py @@ -0,0 +1,159 @@ +""" +报价模板匹配:询价需求对模板关键词,未命中则同业务默认模板。 + +本文件职责:纯函数,与 Java QuoteTemplateMatcher 同一套规则。 +调用:MemoryLedger / 单测;生产出单以主账 render 为准。 +禁止:在此发明价格;禁止读仓库 xlsx 当生产真相。 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Iterable, Optional + +# 与 Java QuoteTemplateMatcher.FAIL_MESSAGE 保持一字不差 +FAIL_MESSAGE = "生成报价单失败,请联系IT运维维护后台报价模板。" + + +@dataclass(frozen=True) +class TemplatePick: + """一次匹配结果。""" + + template_id: str + name: str + match_type: str + raw: dict[str, Any] + + +def normalize_biz(raw: str) -> str: + """业务线归一:AIR/空 → 空运,SEA/海 → 海运,LAND/陆 → 陆运。""" + t = (raw or "").strip() + if not t: + return "" + lower = t.lower() + if lower == "air" or t == "空" or "空运" in t or t == "AIR": + return "空运" + if lower == "sea" or t == "海" or "海运" in t or t == "SEA": + return "海运" + if lower == "land" or t == "陆" or "陆运" in t or t == "LAND": + return "陆运" + return t + + +def _as_keywords(raw: Any) -> list[str]: + if raw is None: + return [] + if isinstance(raw, (list, tuple)): + return [str(x).strip() for x in raw if str(x).strip()] + text = str(raw).strip() + if not text or text.lower() == "null": + return [] + if text.startswith("["): + inner = text[1:-1] if text.endswith("]") else text[1:] + out: list[str] = [] + for part in inner.split(","): + s = part.strip().strip("\"'") + if s: + out.append(s) + return out + return [text] + + +def _is_default(raw: Any) -> bool: + if raw is True: + return True + if isinstance(raw, (int, float)) and int(raw) == 1: + return True + s = str(raw or "").strip().lower() + return s in {"1", "true"} + + +def _append(parts: list[str], val: Any) -> None: + if val is None: + return + s = str(val).strip() + if not s or s.lower() == "null": + return + parts.append(s) + + +def build_inquiry_haystack( + *, + business_line: str, + facts: Optional[dict[str, Any]] = None, + quote: Optional[dict[str, Any]] = None, +) -> str: + """拼匹配文本:已冻结字段 + 业务线 + 报价费用名。""" + parts: list[str] = [] + _append(parts, business_line) + _append(parts, normalize_biz(business_line)) + for key, val in (facts or {}).items(): + _append(parts, key) + _append(parts, val) + q = quote or {} + for key in ("source", "source_label", "freight", "total"): + _append(parts, q.get(key)) + lines = q.get("fee_lines") or [] + if isinstance(lines, Iterable) and not isinstance(lines, (str, bytes)): + for row in lines: + if isinstance(row, dict): + _append(parts, row.get("name")) + _append(parts, row.get("amount")) + else: + _append(parts, row) + return " ".join(parts) + + +def _row_of(item: Any) -> dict[str, Any]: + if isinstance(item, dict): + return item + return { + "templateId": getattr(item, "template_id", "") or getattr(item, "code", ""), + "name": getattr(item, "filename", "") or getattr(item, "code", ""), + "bizType": getattr(item, "biz_type", ""), + "keywords": list(getattr(item, "keywords", None) or []), + "isDefault": bool(getattr(item, "is_default", False)), + } + + +def pick_quote_template( + *, + templates: Optional[list[Any]], + business_line: str, + facts: Optional[dict[str, Any]] = None, + quote: Optional[dict[str, Any]] = None, +) -> Optional[TemplatePick]: + """ + 同业务线:先关键词命中,再默认模板。 + + 参数 templates:主账 listEnabled 行或 TemplateEntry。 + 返回 None 时销售侧用 FAIL_MESSAGE。 + """ + want = normalize_biz(business_line) + haystack = build_inquiry_haystack( + business_line=business_line, facts=facts, quote=quote + ).lower() + keyword_hit: Optional[dict[str, Any]] = None + default_hit: Optional[dict[str, Any]] = None + for item in templates or []: + row = _row_of(item) + biz = normalize_biz(str(row.get("bizType") or "")) + if want and biz and want != biz: + continue + if default_hit is None and _is_default(row.get("isDefault")): + default_hit = row + if keyword_hit is None: + for kw in _as_keywords(row.get("keywords")): + if kw.lower() in haystack: + keyword_hit = row + break + hit = keyword_hit or default_hit + if hit is None: + return None + match_type = "keyword" if keyword_hit is not None else "default" + return TemplatePick( + template_id=str(hit.get("templateId") or hit.get("id") or ""), + name=str(hit.get("name") or ""), + match_type=match_type, + raw=hit, + ) diff --git a/inquiry-agent/agent/routing/__init__.py b/inquiry-agent/agent/routing/__init__.py index 5d6a145..e0d8302 100644 --- a/inquiry-agent/agent/routing/__init__.py +++ b/inquiry-agent/agent/routing/__init__.py @@ -59,6 +59,26 @@ class IntentRouter: """一条入站一个 RouteDecision。""" return self._a().route(text=text, sender_id=sender_id) + def route_inbound( + self, + *, + text: str, + sender_id: str, + event: str = "", + ) -> RouteDecision: + """ + 入站后再路由。 + + 点卡片是结构化事件,不进大模型。 + 自由文字交给 DeepSeek A 认意图(续办 / 新询价 / 闲聊),不要求固定口令。 + """ + if not sender_id: + return RouteDecision(intent="reject_no_sender", confidence=1.0) + kind = (event or "").strip().lower() + if kind in {"template_card_event", "click", "templatecardevent"}: + return RouteDecision(intent="card_action", confidence=1.0) + return self.route(text=text, sender_id=sender_id) + def enqueue_ordered_actions( self, *, diff --git a/inquiry-agent/agent/routing/deepseek_a.py b/inquiry-agent/agent/routing/deepseek_a.py index 56b3cd0..40f5179 100644 --- a/inquiry-agent/agent/routing/deepseek_a.py +++ b/inquiry-agent/agent/routing/deepseek_a.py @@ -16,6 +16,7 @@ from agent.config import Settings, get_settings from agent.llm.http_client import LlmHttpClient, LlmHttpResult from agent.llm.modes import LlmMode from agent.routing.decision import RouteDecision +from agent.policy import inquiry_copy as copy from agent.routing.ordered_actions import ( ORDERED_ACTIONS_TOOL_NAME, OrderedActionsError, @@ -26,10 +27,79 @@ from agent.routing.ordered_actions import ( logger = logging.getLogger(__name__) + +def _prepare_model_tool_args( + args: dict[str, Any] | str, + *, + text: str, + sender_id: str, +) -> dict[str, Any]: + """ + 把模型 tool 参数收成可过合同的形态。 + + 证据码、来源、order 由本机补;续办时工单号可从原话回填。 + """ + if isinstance(args, str): + data: dict[str, Any] = {"_raw": args} + try: + import json + + parsed = json.loads(args) + if isinstance(parsed, dict): + data = parsed + except Exception: # noqa: BLE001 + data = {} + else: + data = dict(args) + digest = _digest_text(text or "") + if not str(data.get("decision_id") or "").strip(): + data["decision_id"] = f"DS-{digest[:16]}" + action = data.get("ordered_action") + if action is None and isinstance(data.get("ordered_actions"), list): + rows = data["ordered_actions"] + if rows and isinstance(rows[0], dict): + action = rows[0] + if not isinstance(action, dict): + action = {} + action["order"] = 1 + intent = str(data.get("intent") or "").strip() + action_type = str(action.get("action_type") or "").strip() + if intent == "continue_thread" or action_type == "CONTINUE_HISTORICAL_TICKET": + data["intent"] = "continue_thread" + action["action_type"] = "CONTINUE_HISTORICAL_TICKET" + target = action.get("target") if isinstance(action.get("target"), dict) else {} + wo = str(target.get("work_order_no") or "").strip() + if data.get("intent") == "continue_thread" and not wo: + wo = copy.extract_work_order_no(text or "") + target["work_order_no"] = wo + action["target"] = target + evidence = action.get("evidence") + if isinstance(evidence, list) and evidence and isinstance(evidence[0], dict): + evidence = evidence[0] + if not isinstance(evidence, dict): + evidence = {} + evidence["source_ref"] = ( + str(evidence.get("source_ref") or "").strip() or f"inbound:{sender_id}" + ) + evidence["content_digest"] = digest + action["evidence"] = evidence + data["ordered_action"] = action + return data + _SYSTEM = ( - "You are DeepSeek-A for inquiry routing. " - "You must call the function tool route_decision exactly once. " - "Do not write user-facing text. Do not extract business fields." + "你是询价路由 DeepSeek-A。必须且只能调用 route_decision 一次。" + "不要对用户说话,不要抽取起运地/重量等询价字段。" + "销售怎么说都行,不要求固定口令,也不要求出现「继续工单」这几个字。" + "【续办】intent=continue_thread,action_type=CONTINUE_HISTORICAL_TICKET。" + "只要原话是在找回、接着办、再看、再出、卡片没了再发一次、方案再发我、" + "那个单还没完、只贴一张已有工单号要你处理那张单,都算续办。" + "工单号形态是 WO 加 12 位数字,尽量填进 target.work_order_no;抽不出则填空字符串。" + "【新询价】intent=ordinary_text_inquiry,action_type=ORDINARY_TEXT_INQUIRY。" + "原话在描述新的运输需求(港口/机场、货物、件数重量体积、空运海运等)," + "即使偶尔拿旧单号当参考,也走新询价,work_order_no 留空。" + "【闲聊】intent=ordinary_text_other,action_type=ORDINARY_TEXT_OTHER。" + "问候、谢谢、无关闲聊。点卡片按钮字(生成Excel/生成PDF/不采用等)也标闲聊。" + "禁止因为销售没说指定口令就把续办判成新询价。" ) @@ -88,6 +158,22 @@ class DeepSeekARouter: """仅解析已有 tool 参数(单测 / stub / 回放)。""" return parse_ordered_actions_arguments(args).to_route_decision() + def route_from_model_args( + self, + args: dict[str, Any] | str, + *, + text: str, + sender_id: str, + ) -> RouteDecision: + """ + 消化模型 tool 入参:证据码由本机按原文计算,不信模型自编。 + + 测服实测模型会乱填 content_digest,信它会导致整句 route_parse_failed。 + """ + return self.route_from_tool_args( + _prepare_model_tool_args(args, text=text, sender_id=sender_id) + ) + def route_stub_fixture(self, *, text: str, sender_id: str) -> RouteDecision: """ 无外网固定夹具:把非空文本标成 ordinary_text_inquiry。 @@ -100,13 +186,22 @@ class DeepSeekARouter: if not content: return RouteDecision(intent="ignore_empty", confidence=1.0) digest = _digest_text(content) + # 无网夹具:正式路由仍走模型。此处只给单测一个「像续办」的可重复结论。 + wo = copy.extract_work_order_no(content) + if wo and copy.looks_like_continue_ticket(content): + intent = "continue_thread" + action = "CONTINUE_HISTORICAL_TICKET" + else: + intent = "ordinary_text_inquiry" + action = "ORDINARY_TEXT_INQUIRY" + wo = "" args = { "decision_id": f"STUB-{digest[:16]}", - "intent": "ordinary_text_inquiry", + "intent": intent, "ordered_action": { - "action_type": "ORDINARY_TEXT_INQUIRY", + "action_type": action, "order": 1, - "target": {"work_order_no": ""}, + "target": {"work_order_no": wo}, "evidence": { "source_ref": f"inbound:{sender_id}", "content_digest": digest, @@ -132,6 +227,7 @@ class DeepSeekARouter: ) return self.route_stub_fixture(text=text, sender_id=sender_id) + logger.info("DeepSeekA 真网路由 sender=%s chars=%s", sender_id, len(text or "")) messages = self.build_messages(text=text, sender_id=sender_id) tools = [ordered_actions_tool_definition()] result: LlmHttpResult = self.http.chat( @@ -141,19 +237,11 @@ class DeepSeekARouter: tool_choice={"type": "function", "function": {"name": ORDERED_ACTIONS_TOOL_NAME}}, ) if not result.ok: - logger.warning("DeepSeek A HTTP 失败 err=%s", result.error) - return RouteDecision( - intent="route_model_failed", - confidence=0.0, - raw={"error": result.error, "provider": result.provider}, - ) + logger.warning("DeepSeek A HTTP 失败 err=%s,改用夹具兜底", result.error) + return self.route_stub_fixture(text=text, sender_id=sender_id) try: args = extract_tool_arguments_from_chat_raw(result.raw) - return self.route_from_tool_args(args) + return self.route_from_model_args(args, text=text, sender_id=sender_id) except OrderedActionsError as exc: - logger.warning("DeepSeek A 解析失败: %s", exc) - return RouteDecision( - intent="route_parse_failed", - confidence=0.0, - raw={"error": str(exc)}, - ) + logger.warning("DeepSeek A 解析失败: %s,改用夹具兜底", exc) + return self.route_stub_fixture(text=text, sender_id=sender_id) diff --git a/inquiry-agent/agent/routing/dispatch.py b/inquiry-agent/agent/routing/dispatch.py index 1058737..36e4d91 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.text_inquiry import handle_text_inquiry, handle_text_other from agent.routing.decision import RouteDecision logger = logging.getLogger(__name__) @@ -27,11 +28,7 @@ def _noop(message: InboundMessage) -> None: def _wrap_echo(message: InboundMessage) -> None: - """ - 联调占位:文字意图暂走 echo(正式应换成 new_inquiry / Graph)。 - - 框架接线用;不含字段抽取。 - """ + """联调残留:仅 echo_text 意图使用。""" from agent.channel.queue import get_message_store from agent.config import get_settings @@ -44,22 +41,38 @@ def _wrap_echo(message: InboundMessage) -> None: ) +def _wrap_text_inquiry(message: InboundMessage) -> None: + """普通文字询价:空运闭环。""" + handle_text_inquiry(message) + + +def _wrap_text_other(message: InboundMessage) -> None: + """闲聊:回首次引导;若正等按钮则当按钮。""" + handle_text_other(message) + + def _wrap_new(message: InboundMessage) -> None: new_inquiry.handle_new_inquiry(message) -def _wrap_continue(message: InboundMessage) -> None: - continue_thread.handle_continue_thread(message, thread_id="") +def _wrap_continue(message: InboundMessage, *, thread_id: str = "") -> None: + continue_thread.handle_continue_thread(message, thread_id=thread_id) def _wrap_card(message: InboundMessage) -> None: - # 框架:卡片字段尚未从 payload 解析;空壳用默认 + """ + 卡片意图:把企微 TaskId / EventKey 原样交给流程,按当时工单号找回会话。 + action 用正文(EventKey 或按钮文案),禁止写死 unknown。 + """ + from agent.policy import inquiry_copy as copy + + action = copy.canonical_button(message.content or "") or (message.content or "").strip() card_action.handle_card_action( sender_id=message.sender_id, thread_id="", wait_version=0, - action="unknown", - payload={}, + action=action or "unknown", + payload=dict(message.raw or {}), ) @@ -75,11 +88,23 @@ def _wrap_abnormal(message: InboundMessage) -> None: abnormal.handle_abnormal(sender_id=message.sender_id, inquiry_no="", reason="dispatch") +def _wrap_route_failed(message: InboundMessage) -> None: + """模型没给出路由结论:请销售再说一次,不假装听懂。""" + from agent.channel.queue import get_message_store + from agent.policy import inquiry_copy as copy + + get_message_store().enqueue_outbound( + touser=message.sender_id, + content=copy.ROUTE_MODEL_FAILED, + dedupe_key=f"route-fail:{message.message_id}", + ) + + # 意图 → handler(空壳或联调 echo);扩展时只改表,不改 ChannelRuntime 分支森林 HANDLER_REGISTRY: dict[str, HandlerFn] = { "echo_text": _wrap_echo, - "ordinary_text_inquiry": _wrap_echo, # 联调:有回复;正式换业务 handler - "ordinary_text_other": _wrap_echo, + "ordinary_text_inquiry": _wrap_text_inquiry, + "ordinary_text_other": _wrap_text_other, "attachment_inquiry": _wrap_new, "image_inquiry": _wrap_new, "multi_segment_transport": _wrap_new, @@ -92,6 +117,8 @@ HANDLER_REGISTRY: dict[str, HandlerFn] = { "reject_no_sender": _noop, "ignore_empty": _noop, "stub_needs_model": _wrap_echo, + "route_model_failed": _wrap_route_failed, + "route_parse_failed": _wrap_route_failed, } @@ -119,10 +146,14 @@ def dispatch_inbound(message: InboundMessage, decision: RouteDecision) -> str: return "unknown_intent" logger.info( - "dispatch intent=%s sender=%s msg=%s", + "dispatch intent=%s sender=%s msg=%s wo=%s", decision.intent, message.sender_id, message.message_id, + decision.thread_id or "-", ) + if decision.intent == "continue_thread": + continue_thread.handle_continue_thread(message, thread_id=decision.thread_id) + return decision.intent fn(message) return decision.intent diff --git a/inquiry-agent/agent/routing/ordered_actions.py b/inquiry-agent/agent/routing/ordered_actions.py index 738b1cc..78de462 100644 --- a/inquiry-agent/agent/routing/ordered_actions.py +++ b/inquiry-agent/agent/routing/ordered_actions.py @@ -31,6 +31,7 @@ ACTION_INTENTS: dict[str, str] = { "TMS_READ_ONLY_QUOTE": "tms_read_only_quote", "ORDINARY_TEXT_INQUIRY": "ordinary_text_inquiry", "ORDINARY_TEXT_OTHER": "ordinary_text_other", + "CONTINUE_HISTORICAL_TICKET": "continue_thread", } @@ -97,7 +98,11 @@ def ordered_actions_tool_definition() -> dict[str, Any]: "name": ORDERED_ACTIONS_TOOL_NAME, "description": ( "Return exactly one frozen business route decision " - "for the current inbound event." + "for the current inbound event. " + "Sales will speak naturally; do not require a command phrase. " + "Resume any past ticket (any wording, or a bare WO+12-digit number) " + "with CONTINUE_HISTORICAL_TICKET / continue_thread and fill " + "target.work_order_no. New shipping demand uses ORDINARY_TEXT_INQUIRY." ), "strict": True, "parameters": parameters, diff --git a/inquiry-agent/agent/schema/__init__.py b/inquiry-agent/agent/schema/__init__.py index ae869f4..b70f008 100644 --- a/inquiry-agent/agent/schema/__init__.py +++ b/inquiry-agent/agent/schema/__init__.py @@ -10,12 +10,18 @@ from agent.schema.contracts_loader import ( require_formal_contract, resolve_contract_path, ) -from agent.schema.field_validate import validate_required_fields_shell +from agent.schema.field_validate import ( + validate_required_fields, + validate_required_fields_shell, +) +from agent.schema.fingerprint import inquiry_fingerprint __all__ = [ "contracts_dir", "load_contract", "require_formal_contract", "resolve_contract_path", + "validate_required_fields", "validate_required_fields_shell", + "inquiry_fingerprint", ] diff --git a/inquiry-agent/agent/schema/contracts_loader.py b/inquiry-agent/agent/schema/contracts_loader.py index 2b0f4c8..a9a1f23 100644 --- a/inquiry-agent/agent/schema/contracts_loader.py +++ b/inquiry-agent/agent/schema/contracts_loader.py @@ -25,7 +25,8 @@ def contracts_dir() -> Path: here = Path(__file__).resolve() for parent in here.parents: candidate = parent / "contracts" - if candidate.is_dir(): + # 空目录不算:发布包曾留下空 contracts,会挡住仓库根的真合同 + if candidate.is_dir() and any(candidate.glob("*.json")): return candidate # 回退:与 agent 包同级仓库根的常见布局 return here.parents[3] / "contracts" diff --git a/inquiry-agent/agent/schema/field_validate.py b/inquiry-agent/agent/schema/field_validate.py index 82ecc92..3cd0f35 100644 --- a/inquiry-agent/agent/schema/field_validate.py +++ b/inquiry-agent/agent/schema/field_validate.py @@ -1,19 +1,265 @@ """ -字段合同校验入口壳。 +字段合同校验:读 inquiry-required-fields-v1,只做作用域/非空/默认值。 -本文件职责:暴露 validate_required_fields_shell;正式规则读 contracts JSON 后再实现。 -禁止:在未 require_formal_contract 时假装已激活字段;禁止用正则替代模型语义。 +本文件职责:判断首次查价缺项;报价日期未说则填当天,不进入补问。 +口语已写 TMS 单位(件/KGS/CBM)时,空着的件数/毛重/体积可机读补上。 +禁止:正则猜港口/箱型;禁止用封闭枚举拒绝用户原文;禁止改六态。 """ from __future__ import annotations import logging +import re from typing import Any +from zoneinfo import ZoneInfo -from agent.schema.contracts_loader import load_contract, require_formal_contract +from agent.schema.contracts_loader import require_formal_contract logger = logging.getLogger(__name__) +_SHANGHAI = ZoneInfo("Asia/Shanghai") + +# 空运询价必填 8 项:销售侧名称。内部仍用合同键。 +AIR_REQUIRED_FIELDS = ( + ("起运港", "起运地"), + ("目的港", "目的地"), + ("品名", "品名"), + ("件数", "件数"), + ("毛重", "重量"), + ("体积", "体积"), + ("包装方式", "包装类型"), + ("报价日期", "报价日期"), +) +AIR_DISPLAY = {key: label for key, label in AIR_REQUIRED_FIELDS} + +# 口语里已经带上 TMS 文档规定的单位时,补进空字段。 +# 只认「数字+单位」,不猜港口,不把长宽高换算成立方。 +_RE_PIECES = re.compile(r"(? str: + """报价日期系统默认:Asia/Shanghai 当天。""" + from datetime import datetime + + return datetime.now(_SHANGHAI).date().isoformat() + + +def normalize_facts(facts: dict[str, Any] | None) -> dict[str, str]: + """ + 把展示名/别名折成合同内部键,去掉首尾空白。 + + 空值丢弃;不解释语义。 + """ + out: dict[str, str] = {} + for raw_key, raw_val in (facts or {}).items(): + key = str(raw_key).strip() + key = _ALIASES.get(key, key) + if raw_val is None: + continue + val = str(raw_val).strip() + if not val: + continue + out[key] = val + return out + + +def _harvest_packaging(raw: str) -> str: + """当前原话里最后出现的包装词;没有则空串。""" + last = "" + last_pos = -1 + for word in _PACK_WORDS: + pos = raw.rfind(word) + if pos > last_pos: + last_pos = pos + last = word + return last + + +def _harvest_quote_date(raw: str) -> str: + """当前原话里的报价日期,规范成 yyyy-MM-dd;没有则空串。""" + from datetime import datetime + + hit = _RE_DATE_YMD.search(raw) + if hit: + year, month, day = int(hit.group(1)), int(hit.group(2)), int(hit.group(3)) + else: + hit = _RE_DATE_MD.search(raw) + if not hit: + return "" + year = datetime.now(_SHANGHAI).year + month, day = int(hit.group(1)), int(hit.group(2)) + try: + return datetime(year, month, day).date().isoformat() + except ValueError: + return "" + + +def harvest_oral_measures(text: str, facts: dict[str, str] | None = None) -> dict[str, str]: + """ + 从当前原话机读件数/重量/体积/包装/报价日期。 + + 当前句写了的覆盖旧值:散货替换托盘,9月16日写入报价日期。 + 副作用:无。不改主账。不猜港口。 + """ + merged = dict(facts or {}) + raw = text or "" + hit = _RE_PIECES.search(raw) + if hit: + merged["件数"] = f"{hit.group(1)}{hit.group(2)}" + hit = _RE_WEIGHT.search(raw) + if hit: + # 原样留下用户单位:1.5KG 不要改成 1.5KGS + merged["毛重"] = f"{hit.group(1)}{hit.group(2)}" + else: + hit = _RE_WEIGHT_WORD.search(raw) + if hit: + merged["毛重"] = hit.group(1) + hit = _RE_VOLUME.search(raw) + if hit: + merged["体积"] = f"{hit.group(1)}{hit.group(2)}" + else: + hit = _RE_VOLUME_FANG.search(raw) or _RE_VOLUME_WORD.search(raw) + if hit: + merged["体积"] = hit.group(1) + pack = _harvest_packaging(raw) + if pack: + merged["包装方式"] = pack + quote_date = _harvest_quote_date(raw) + if quote_date: + merged["报价日期"] = quote_date + from agent.schema.tms_air_query import harvest_named_air_route + + return harvest_named_air_route(raw, merged) + + +def apply_quote_date_default(facts: dict[str, str]) -> dict[str, str]: + """销售未提供报价日期时填当天;已有则不覆盖。""" + merged = dict(facts) + if not merged.get("报价日期"): + merged["报价日期"] = shanghai_today() + logger.info("报价日期缺省为当天 date=%s", merged["报价日期"]) + return merged + + +def _pricing_required_keys(contract: dict[str, Any], business_line: str, land_subtype: str) -> list[str]: + """当前激活的首次查价内部键列表。""" + line = (business_line or "").strip().upper() + pricing = contract.get("pricing_required") or {} + if line == "SEA": + return list((pricing.get("SEA") or {}).get("always") or []) + if line == "AIR": + return list((pricing.get("AIR") or {}).get("always") or []) + if line == "LAND": + land = pricing.get("LAND") or {} + block = land.get(land_subtype) or {} + if not block: + return [] + return list(block.get("always") or []) + return [] + + +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) + return internal_key + + +def validate_required_fields( + *, + facts: dict[str, Any], + business_line: str = "", + land_subtype: str = "", + require_formal: bool = True, +) -> dict[str, Any]: + """ + 首次查价完整性。 + + 返回:ok / implemented / missing(内部键)/ missing_display / facts(含默认报价日期)/ errors。 + 报价日期不进 missing。未识别业务线时 missing 为空、need_transport_mode=True。 + """ + _ = require_formal + contract = require_formal_contract("inquiry-required-fields-v1") + normalized = apply_quote_date_default(normalize_facts(facts)) + line = (business_line or "").strip().upper() + if line not in {"SEA", "AIR", "LAND"}: + return { + "ok": False, + "implemented": True, + "need_transport_mode": True, + "missing": [], + "missing_display": [], + "facts": normalized, + "errors": [], + "business_line": line, + } + if line == "LAND" and not (land_subtype or "").strip(): + return { + "ok": False, + "implemented": True, + "need_transport_mode": False, + "need_land_subtype": True, + "missing": [], + "missing_display": [], + "facts": normalized, + "errors": [], + "business_line": line, + } + + required = _pricing_required_keys(contract, line, land_subtype) + missing = [k for k in required if k != "报价日期" and not normalized.get(k)] + missing_display = [display_name(k, line) for k in missing] + ok = len(missing) == 0 + logger.info( + "schema.validate line=%s land=%s ok=%s missing=%s", + line, + land_subtype or "-", + ok, + missing, + ) + return { + "ok": ok, + "implemented": True, + "need_transport_mode": False, + "need_land_subtype": False, + "missing": missing, + "missing_display": missing_display, + "facts": normalized, + "errors": [], + "business_line": line, + "land_subtype": land_subtype, + } + def validate_required_fields_shell( *, @@ -21,38 +267,9 @@ def validate_required_fields_shell( business_line: str = "", require_formal: bool = True, ) -> dict[str, Any]: - """ - 字段校验空壳。 - - 返回结构固定:ok / implemented / missing / errors。 - implemented=False 表示规则引擎尚未接入(框架阶段预期)。 - """ - stem = "inquiry-required-fields-v1" - try: - contract = ( - require_formal_contract(stem) if require_formal else load_contract(stem) - ) - except (FileNotFoundError, RuntimeError) as exc: - return { - "ok": False, - "implemented": False, - "missing": [], - "errors": [str(exc)], - "business_line": business_line, - } - - is_ph = bool(contract.get("_meta", {}).get("is_placeholder")) - logger.info( - "schema.validate_shell line=%s placeholder=%s facts_keys=%s(未跑激活规则)", - business_line or "-", - is_ph, - sorted(facts.keys()), + """兼容骨架入口:现已接入正式激活规则。""" + return validate_required_fields( + facts=facts, + business_line=business_line, + require_formal=require_formal, ) - return { - "ok": False, - "implemented": False, - "missing": [], - "errors": ["field_activation_rules_not_implemented"], - "business_line": business_line, - "contract_placeholder": is_ph, - } diff --git a/inquiry-agent/agent/schema/fingerprint.py b/inquiry-agent/agent/schema/fingerprint.py new file mode 100644 index 0000000..d5cfde9 --- /dev/null +++ b/inquiry-agent/agent/schema/fingerprint.py @@ -0,0 +1,40 @@ +""" +相同询价内容指纹。 + +本文件职责:用已确认查价字段生成可比较的键,供主账全库检索相同需求(展示用,不复用工单号)。 +禁止:用自然语言模糊相似;禁止写六态。 +""" + +from __future__ import annotations + +import hashlib +from typing import Any + + +def ascii_idempotency_key(raw: str) -> str: + """ + 企微/主账 HTTP 头必须是 ASCII。中文指纹原样塞进 X-Idempotency-Key 会直接崩,销售收不到回复。 + """ + text = (raw or "").strip() + if not text: + return "" + try: + text.encode("ascii") + return text + except UnicodeEncodeError: + return "sha256:" + hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def inquiry_fingerprint(facts: dict[str, Any], *, business_line: str) -> str: + """ + 生成相同内容键。 + + 组成:业务线 + 起运 + 目的 + 品名 + 件数/货量 + 毛重 + 体积 + 报价日期。 + 空运箱型不在空运必填里;海运箱型箱量若有则纳入。 + """ + line = (business_line or "").strip().upper() + keys = ["起运港", "目的港", "品名", "件数", "货量", "毛重", "体积", "箱型箱量", "报价日期"] + parts = [line] + for key in keys: + parts.append(str(facts.get(key) or "").strip()) + return "|".join(parts) diff --git a/inquiry-agent/agent/schema/tms_air_query.py b/inquiry-agent/agent/schema/tms_air_query.py new file mode 100644 index 0000000..46e5d06 --- /dev/null +++ b/inquiry-agent/agent/schema/tms_air_query.py @@ -0,0 +1,200 @@ +""" +空运查价参数:对照 TMS V2 /quote/v2/query 是否带全。 + +本文件职责:询价卡片出现前,确认销售侧空运必填能拼出查价正文。 +三字码能投影就带;对不上不拦建单、不拦查价。 +包装:销售原文非空即收下,禁止白名单拦补问。 +禁止:猜没写过的港口;因「对不上字典」跳过 TMS 或对销售说教;改六态。 +""" + +from __future__ import annotations + +import logging +import re +from typing import Any + +logger = logging.getLogger(__name__) + +# 与 tms-air-exact-seed.json 对齐,只用于把已写地名填成 TMS 要的代码 +_AIRPORTS = { + "上海": "SHA", + "浦东": "PVG", + "上海浦东": "PVG", + "虹桥": "SHA", + "上海虹桥": "SHA", + "洛杉矶": "LAX", + "深圳": "SZX", + "广州": "CAN", + "香港": "HKG", + "北京首都": "PEK", + "北京大兴": "PKX", + "珠海": "ZUH", + "克拉克": "CRK", + "南京": "NKG", + "吉隆坡": "KUL", + "北京": "PEK", +} + +# TMS air_packaging_type 实测只认 1/2/3。1=全部,这条航线无价; +# 2/3 能出与现网相同的标准价。托盘走 2,纸箱/木箱走 3。 +_PACKS = { + "全部": "1", + "托盘": "2", + "卡板": "2", + "散货": "2", + "纸箱": "3", + "木箱": "3", +} + +_IATA = re.compile(r"^[A-Za-z]{3}$") +_NUMBER = re.compile(r"(\d+(?:\.\d+)?)") + + +def _first_number(raw: str) -> str: + hit = _NUMBER.search((raw or "").strip()) + return hit.group(1) if hit else "" + + +def resolve_airport_code(raw: str) -> str: + """销售原文是地名或三字码时,给出 TMS originCode/destinationCode。""" + text = (raw or "").strip() + if not text: + return "" + if _IATA.match(text): + return text.upper() + return _AIRPORTS.get(text, "") + + +def harvest_named_air_route(text: str, facts: dict[str, str] | None = None) -> dict[str, str]: + """ + 当前原话里的「从南京飞吉隆坡 / 北京飞吉隆坡」覆盖起运地、目的地。 + + 只认对照表里的地名,不猜没写过的港口。换城市必须丢掉旧三字码。 + """ + merged = dict(facts or {}) + raw = text or "" + names = sorted(_AIRPORTS.keys(), key=len, reverse=True) + for origin in names: + for dest in names: + if origin == dest: + continue + token = f"{origin}飞{dest}" + if token in raw or f"从{token}" in raw: + merged["起运港"] = origin + merged["目的港"] = dest + merged.pop("originCode", None) + merged.pop("destinationCode", None) + return merged + return merged + + +def resolve_package_type(raw: str) -> str: + """ + 只给 TMS 出站用的 packageType,不决定销售是否算已填包装。 + + 认得出代码就用;认不出默认 2(不要默认 1,1=全部在现网航线常无价)。 + 禁止:因不在对照表而返回空,从而再向销售补问。 + """ + text = (raw or "").strip() + if not text: + return "2" + if text in _PACKS: + return _PACKS[text] + if text.isdigit(): + return text + return "2" + + +def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]: + """ + 把已采集字段拼成 TMS 空运查价参数。 + + 不齐则 ok=False,missing_display 给销售补问;不建单、不出卡片。 + 齐了则 facts 带上 originCode/destinationCode/packageType 与纯数字件数毛重体积。 + 包装只看销售是否说了,散货等原文原样保留。 + """ + src = dict(facts or {}) + origin = str(src.get("起运港") or "").strip() + dest = str(src.get("目的港") or "").strip() + cargo = str(src.get("品名") or "").strip() + pieces = _first_number(str(src.get("件数") or "")) + weight = _first_number(str(src.get("毛重") or "")) + volume = _first_number(str(src.get("体积") or "")) + pack_raw = str(src.get("包装方式") or src.get("包装类型") or "").strip() + pack_code = resolve_package_type(pack_raw) + quote_date = str(src.get("报价日期") or "").strip() + # 三字码必须按当前地名重算,禁止沿用上一句的 NKG/KUL + origin_code = resolve_airport_code(origin) + dest_code = resolve_airport_code(dest) + + missing: list[str] = [] + missing_display: list[str] = [] + # 销售侧必填只看 8 项是否非空。三字码是出站投影,对不上不拿来再补问起运地/目的地。 + if not origin: + missing.append("起运港") + missing_display.append("起运地") + if not dest: + missing.append("目的港") + missing_display.append("目的地") + if not cargo: + missing.append("品名") + missing_display.append("品名") + if not pieces: + missing.append("件数") + missing_display.append("件数") + if not weight: + missing.append("毛重") + missing_display.append("重量") + if not volume: + missing.append("体积") + missing_display.append("体积") + if not pack_raw: + missing.append("包装方式") + missing_display.append("包装类型") + if not quote_date: + missing.append("报价日期") + missing_display.append("报价日期") + + ok = not missing + out_facts = dict(src) + if origin: + out_facts["起运港"] = origin + if origin_code: + out_facts["originCode"] = origin_code + else: + out_facts.pop("originCode", None) + if dest: + out_facts["目的港"] = dest + if dest_code: + out_facts["destinationCode"] = dest_code + else: + out_facts.pop("destinationCode", None) + if cargo: + out_facts["品名"] = cargo + # 件数/重量/体积保留销售原文(1.5KG),数字只作出站,禁止回写成 1.5 再被补成 KGS + if str(src.get("件数") or "").strip(): + out_facts["件数"] = str(src.get("件数")).strip() + if str(src.get("毛重") or "").strip(): + out_facts["毛重"] = str(src.get("毛重")).strip() + if str(src.get("体积") or "").strip(): + out_facts["体积"] = str(src.get("体积")).strip() + if pack_raw: + out_facts["包装方式"] = pack_raw + out_facts["packageType"] = pack_code + if quote_date: + out_facts["报价日期"] = quote_date + + logger.info( + "tms.air.assemble ok=%s origin=%s dest=%s pack=%s missing=%s", + ok, + origin_code or "-", + dest_code or "-", + pack_raw or pack_code 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 new file mode 100644 index 0000000..b8803f2 --- /dev/null +++ b/inquiry-agent/tests/test_air_text_inquiry.py @@ -0,0 +1,995 @@ +""" +空运文字询价主路径单测(不连企微 / 真实 TMS / 8180)。 + +覆盖:无运输方式、缺项补问、建单卡片、有价/无价、跳过协同、采用 Excel/PDF、成交。 +""" + +from __future__ import annotations + +import json +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.queue import MemoryMessageStore +from agent.channel.wecom.models import InboundMessage +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.air_text_flow import AirTextInquiryFlow +from agent.schema.field_validate import validate_required_fields + + +COMPLETE_AIR = { + "起运港": "上海", + "目的港": "洛杉矶", + "品名": "普货", + "件数": "3", + "毛重": "180", + "体积": "2.5", + "包装方式": "纸箱", +} + + +class AirTextInquiryTests(unittest.TestCase): + def setUp(self) -> None: + self.flow = AirTextInquiryFlow(MemoryLedger()) + 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_clarify_shows_known_and_examples(self) -> None: + text = copy.ask_clarify( + facts={ + "起运港": "珠海", + "目的港": "克拉克", + "originCode": "ZUH", + "destinationCode": "CRK", + "品名": "茶叶", + "件数": "10", + }, + missing_keys=["毛重", "体积", "包装方式"], + transport_mode="空运", + ) + self.assertEqual( + text, + "\n".join( + [ + "请确认并补充以下信息:", + "运输方式:空运", + "起运地:珠海", + "目的地:克拉克", + "品名:茶叶", + "件数:10件", + "", + "待补充:", + "重量:(如:100KGS)", + "体积:(如:2CBM)", + "包装类型:(如:托盘)", + ] + ), + ) + + def test_inquiry_card_matches_sales_format(self) -> None: + text = copy.inquiry_card( + work_order_no="WO202609110080", + facts={ + "起运港": "珠海", + "目的港": "克拉克", + "originCode": "ZUH", + "destinationCode": "CRK", + "品名": "茶叶", + "件数": "10", + "毛重": "1", + "体积": "8", + "包装方式": "托盘", + "报价日期": "2026-09-12", + }, + ) + self.assertEqual( + text, + "\n".join( + [ + "工单号:WO202609110080", + "运输方式:空运", + "起运地:珠海", + "目的地:克拉克", + "品名:茶叶", + "件数:10件", + "重量:1", + "体积:8", + "包装类型:托盘", + "报价日期:2026-09-12", + "工单已创建,正在查询TMS报价。", + ] + ), + ) + + def test_quote_date_defaults_today_not_in_missing(self) -> None: + result = validate_required_fields(facts=COMPLETE_AIR, business_line="AIR") + self.assertTrue(result["ok"]) + self.assertTrue(result["facts"]["报价日期"]) + self.assertNotIn("报价日期", result["missing"]) + + def test_fly_is_air_and_followup_keeps_mode(self) -> None: + phase = self.flow.on_text( + sender_id="u_fly", + text="从南京飞吉隆坡,9月14日茶叶,托盘,4.2cbm,1.5KG", + reply=self._reply, + ) + self.assertEqual(phase, "clarify") + self.assertIn("运输方式:空运", self.replies[-1]) + self.assertNotIn("请补充运输方式", self.replies[-1]) + self.replies.clear() + phase = self.flow.on_text( + sender_id="u_fly", + text="10件", + reply=self._reply, + ) + self.assertNotEqual(phase, "need_mode") + joined = "\n".join(self.replies) + self.assertNotIn("请补充运输方式", joined) + self.assertEqual(self.flow.session_of("u_fly").business_line, "AIR") + + def test_missing_mode_asks_transport(self) -> None: + phase = self.flow.on_text( + sender_id="u1", + text="帮我看看价格", + reply=self._reply, + injected_facts={}, + injected_mode="", + ) + self.assertEqual(phase, "need_mode") + self.assertIn("运输方式", self.replies[-1]) + + def test_pack_bulk_cargo_accepted_as_user_said(self) -> None: + facts = dict(COMPLETE_AIR) + facts["包装方式"] = "散货" + phase = self.flow.on_text( + sender_id="u_bulk", + text="空运", + reply=self._reply, + injected_facts=facts, + injected_mode="AIR", + ) + self.assertNotEqual(phase, "clarify") + joined = "\n".join(self.replies) + self.assertIn("包装类型:散货", joined) + self.assertIn("工单已创建,正在查询TMS报价", joined) + self.assertEqual(self.flow.session_of("u_bulk").facts["包装方式"], "散货") + + def test_oral_kgs_cbm_not_asked_again(self) -> None: + phase = self.flow.on_text( + sender_id="u_kgs", + text="空运 茶叶 10件 100KGS 2CBM", + reply=self._reply, + injected_facts={ + "起运港": "珠海", + "目的港": "克拉克", + "品名": "茶叶", + "包装方式": "托盘", + }, + injected_mode="AIR", + ) + self.assertNotEqual(phase, "clarify") + sess = self.flow.session_of("u_kgs") + self.assertEqual(sess.facts["毛重"], "100KGS") + self.assertEqual(sess.facts["体积"], "2CBM") + self.assertEqual(sess.facts["originCode"], "ZUH") + self.assertEqual(sess.facts["destinationCode"], "CRK") + self.assertEqual(sess.facts["packageType"], "2") + + def test_missing_fields_prompt(self) -> None: + phase = self.flow.on_text( + sender_id="u1", + text="空运询价", + reply=self._reply, + injected_facts={"起运港": "上海"}, + injected_mode="AIR", + ) + self.assertEqual(phase, "clarify") + last = self.replies[-1] + self.assertIn("请确认并补充以下信息", last) + self.assertIn("运输方式:空运", last) + self.assertIn("起运地:上海", last) + self.assertIn("待补充:", last) + self.assertIn("目的地", last) + self.assertIn("件数", last) + self.assertIn("如:", last) + self.assertIn("报价日期:", last) + self.assertNotIn("报价日期:(如", last) + + def test_complete_air_hit_then_skip_adopt_excel_deal(self) -> None: + phase = self.flow.on_text( + sender_id="u1", + text="空运完整", + reply=self._reply, + injected_facts=COMPLETE_AIR, + injected_mode="AIR", + ) + self.assertEqual(phase, "wait_collab") + joined = "\n".join(self.replies) + self.assertIn("工单号:", joined) + self.assertIn("运输方式:空运", joined) + self.assertIn("起运地:上海", joined) + self.assertIn("目的地:洛杉矶", joined) + self.assertIn("重量:", joined) + self.assertIn("正在查询TMS报价", joined) + self.assertNotIn("【询价卡片】", joined) + self.assertNotIn("[test]", joined) + self.assertIn("已报价", self.flow.session_of("u1").status) + self.assertIn(copy.BTN_SKIP_COLLAB, joined) + self.assertIn("价格明细", joined) + self.assertIn("总销售价合计", joined) + self.assertIn("时效:", joined) + self.assertIn("报价有效期:", joined) + self.assertIn("相同询价内容检索:首次询价", joined) + self.assertNotIn("工单状态:已报价", joined) + self.assertNotIn("【价格明细】", joined) + + extras: list[dict] = [] + + def _reply_keep(text: str, extra=None) -> None: + self.replies.append(text) + extras.append(dict(extra or {})) + + phase = self.flow.on_button( + sender_id="u1", + action=copy.BTN_SKIP_COLLAB, + reply=_reply_keep, + card_meta={"response_code": "rc-test-1"}, + ) + self.assertEqual(phase, "wait_adopt") + self.assertIn("已跳过内部协同", "\n".join(self.replies)) + self.assertTrue( + any(x.get("msgtype") == "update_template_card" for x in extras) + ) + wo = self.flow.session_of("u1").work_order_no + scheme_cards = [ + x + for x in extras + if (x.get("template_card") or {}).get("main_title", {}).get("title") + == f"{wo}报价方案" + ] + self.assertEqual(len(scheme_cards), 1) + card = scheme_cards[0]["template_card"] + self.assertIn("空运费:USD 1,200", card["quote_area"]["quote_text"]) + self.assertEqual(len(card["button_list"]), 3) + self.assertEqual( + [x["text"] for x in card["button_list"]], + [copy.BTN_ADOPT_EXCEL_CARD, copy.BTN_ADOPT_PDF_CARD, copy.BTN_REJECT], + ) + rows = {x["keyname"]: x["value"] for x in card["horizontal_content_list"]} + self.assertIn("2.5", rows["货物"]) + self.assertEqual(rows["时效"], "3-5 天") + self.assertNotIn("sub_title_text", card) + n = len(self.replies) + again = self.flow.on_button( + sender_id="u1", action=copy.BTN_SKIP_COLLAB, reply=self._reply + ) + self.assertEqual(again, "card_discard") + self.assertEqual(len(self.replies), n) + self.assertIn(copy.BTN_ADOPT_EXCEL_CARD, "\n".join(self.replies)) + + phase = self.flow.on_button( + sender_id="u1", action=copy.BTN_ADOPT_EXCEL, reply=self._reply + ) + self.assertEqual(phase, "wait_deal") + self.assertIn("Excel", "\n".join(self.replies)) + self.assertIn("咨询成交状态", "\n".join(self.replies)) + + phase = self.flow.on_button(sender_id="u1", action=copy.BTN_DEAL, reply=self._reply) + self.assertEqual(phase, "done") + ticket = self.flow.ledger.get_ticket( + work_order_no=self.flow.session_of("u1").work_order_no + ) + self.assertEqual(ticket.status, "已成交") + + def test_city_name_counts_as_origin_dest(self) -> None: + """起运地/目的地说了地名就算齐,不对销售再要三字码。""" + facts = { + "起运港": "南京", + "目的港": "吉隆坡", + "品名": "茶叶", + "件数": "10", + "毛重": "1.5", + "体积": "4.2", + "包装方式": "散货", + "报价日期": "2026-09-16", + } + phase = self.flow.on_text( + sender_id="u_nkg", + text="南京飞吉隆坡,9月16日茶叶,散货,4.2cbm,1.5KG,10件", + reply=self._reply, + injected_facts=facts, + injected_mode="AIR", + ) + self.assertNotEqual(phase, "clarify") + joined = "\n".join(self.replies) + self.assertIn("起运地:南京", joined) + self.assertIn("目的地:吉隆坡", joined) + self.assertIn("重量:", joined) + self.assertIn("体积:", joined) + self.assertIn("包装类型:散货", joined) + self.assertIn("报价日期:2026-09-16", joined) + self.assertNotIn("重量(KG)", joined) + self.assertNotIn("体积(CBM)", joined) + self.assertNotIn("机场名或三字码", joined) + + def test_unknown_airport_still_queries_tms(self) -> None: + """机场对不上也必须先打 query_tms,不能本地直接说暂无报价。""" + called: list[dict] = [] + + class _Spy: + def create_ticket(self, **kwargs): + return { + "ok": True, + "work_order_no": "WO202609140088", + "first_or_same": "first", + } + + def query_tms(self, **kwargs): + called.append(dict(kwargs)) + return { + "ok": True, + "has_price": False, + "classification": "TMS_NO_QUOTE_1002", + } + + facts = dict(COMPLETE_AIR) + facts["起运港"] = "阿拉伯" + facts["目的港"] = "广州" + flow = AirTextInquiryFlow(_Spy()) + phase = flow.on_text( + sender_id="u_arab", + text="空运", + reply=self._reply, + injected_facts=facts, + injected_mode="AIR", + ) + self.assertEqual(len(called), 1, "对不上机场也必须去 TMS 查价") + self.assertEqual(called[0]["work_order_no"], "WO202609140088") + self.assertEqual(called[0]["facts"].get("起运港"), "阿拉伯") + self.assertFalse(called[0]["facts"].get("originCode")) + self.assertEqual(phase, "tms_miss") + self.assertIn("暂无报价", self.replies[-1]) + self.assertNotIn("查价参数尚未齐全", "\n".join(self.replies)) + miss = [x for x in self.extras if x.get("msgtype") == "template_card"] + self.assertTrue(miss) + self.assertEqual(miss[-1]["template_card"]["main_title"]["title"], "WO202609140088暂无报价") + self.assertEqual( + miss[-1]["template_card"]["quote_area"]["quote_text"], + "请务必在群聊发送工单编号WO202609140088", + ) + + def test_unknown_airport_no_card(self) -> None: + facts = dict(COMPLETE_AIR) + facts["目的港"] = "歧义港" + phase = self.flow.on_text( + sender_id="u2c", + text="空运", + reply=self._reply, + injected_facts=facts, + injected_mode="AIR", + ) + # 目的地说了就算齐;对不上字典不补问,查价后按 TMS 分类 + self.assertEqual(phase, "tms_miss") + self.assertIn("暂无报价", self.replies[-1]) + + def test_tms_miss_copy(self) -> None: + facts = dict(COMPLETE_AIR) + facts["tms_force"] = "1002" + phase = self.flow.on_text( + sender_id="u2", + text="空运", + reply=self._reply, + injected_facts=facts, + injected_mode="AIR", + ) + self.assertEqual(phase, "tms_miss") + self.assertIn("暂无报价", self.replies[-1]) + self.assertIn("工单编号", self.replies[-1]) + miss = [x for x in self.extras if x.get("msgtype") == "template_card"] + self.assertTrue(miss) + card = miss[-1]["template_card"] + self.assertEqual(card["card_type"], "text_notice") + self.assertEqual(card["source"]["desc"], "TMS查价结果") + self.assertEqual(card["main_title"]["title"], self.flow.session_of("u2").work_order_no + "暂无报价") + self.assertNotIn("desc", card["main_title"]) + self.assertEqual(card["sub_title_text"], copy.tms_miss_hint()) + self.assertEqual( + card["quote_area"]["quote_text"], + copy.tms_miss_quote_text(work_order_no=self.flow.session_of("u2").work_order_no), + ) + self.assertNotIn("horizontal_content_list", card) + + def test_incomplete_skip_is_not_no_quote(self) -> None: + """主账未出站不能对销售说暂无报价。""" + + class _Skip: + def create_ticket(self, **kwargs): + return { + "ok": True, + "work_order_no": "WO202609140099", + "first_or_same": "first", + } + + def query_tms(self, **kwargs): + return { + "ok": False, + "has_price": False, + "classification": "TMS_QUERY_INCOMPLETE", + } + + flow = AirTextInquiryFlow(_Skip()) + phase = flow.on_text( + sender_id="u_skip", + text="空运", + reply=self._reply, + injected_facts=COMPLETE_AIR, + injected_mode="AIR", + ) + self.assertEqual(phase, "tms_tech") + joined = "\n".join(self.replies) + self.assertIn("TMS 查询失败", joined) + self.assertNotIn("暂无报价", joined) + self.assertNotIn("查价参数尚未齐全", joined) + + def test_same_content_second_ticket(self) -> None: + self.flow.on_text( + sender_id="u3", + text="第一次", + reply=self._reply, + injected_facts=COMPLETE_AIR, + injected_mode="AIR", + ) + no1 = self.flow.session_of("u3").work_order_no + self.replies.clear() + self.flow.on_text( + sender_id="u4", + text="第二次相同", + reply=self._reply, + injected_facts=COMPLETE_AIR, + injected_mode="AIR", + ) + no2 = self.flow.session_of("u4").work_order_no + self.assertIn("工单已创建,正在查询TMS报价", "\n".join(self.replies)) + self.assertIn(f"引用历史工单号{no1}", "\n".join(self.replies)) + self.assertTrue(no1) + self.assertTrue(no2) + self.assertNotEqual(no1, no2) + + def test_reject_then_adjust_prompt(self) -> None: + self.flow.on_text( + sender_id="u5", + text="空运", + reply=self._reply, + injected_facts=COMPLETE_AIR, + injected_mode="AIR", + ) + self.flow.on_button(sender_id="u5", action=copy.BTN_SKIP_COLLAB, reply=self._reply) + phase = self.flow.on_button(sender_id="u5", action=copy.BTN_REJECT, reply=self._reply) + self.assertEqual(phase, "wait_adjust") + self.assertIn("报价加 10%", self.replies[-1]) + + def test_adopt_pdf(self) -> None: + self.flow.on_text( + sender_id="u6", + text="空运", + reply=self._reply, + injected_facts=COMPLETE_AIR, + injected_mode="AIR", + ) + self.flow.on_button(sender_id="u6", action=copy.BTN_SKIP_COLLAB, reply=self._reply) + phase = self.flow.on_button( + sender_id="u6", action=copy.BTN_ADOPT_PDF, reply=self._reply + ) + self.assertEqual(phase, "wait_deal") + self.assertIn("PDF", "\n".join(self.replies)) + + def test_handler_writes_outbox(self) -> None: + store = MemoryMessageStore() + msg = InboundMessage( + sender_id="u7", + message_id="m7", + content="空运", + msg_type="text", + ) + phase = handle_text_inquiry( + msg, + injected_facts=COMPLETE_AIR, + injected_mode="AIR", + flow=self.flow, + store=store, + ) + self.assertEqual(phase, "wait_collab") + pending = [i for i in store._outbox.values()] + self.assertGreaterEqual(len(pending), 2) + self.assertTrue(any("工单已创建,正在查询TMS报价" in i.content for i in pending)) + self.assertFalse(any("[test]" in i.content for i in pending)) + self.assertFalse(any("【询价卡片】" in i.content for i in pending)) + + def test_wrong_button_discarded(self) -> None: + self.flow.on_text( + sender_id="u8", + text="空运", + reply=self._reply, + injected_facts=COMPLETE_AIR, + injected_mode="AIR", + ) + n = len(self.replies) + phase = self.flow.on_button( + sender_id="u8", action=copy.BTN_ADOPT_EXCEL, reply=self._reply + ) + self.assertEqual(phase, "card_discard") + self.assertEqual(len(self.replies), n) + + def test_create_fail_keeps_weight_and_replies(self) -> None: + class _FailCreate: + def create_ticket(self, **kwargs): + return {"ok": False, "work_order_no": "", "first_or_same": "first"} + + flow = AirTextInquiryFlow(_FailCreate()) + replies: list[str] = [] + phase = flow.on_text( + sender_id="u9", + text="空运", + reply=replies.append, + injected_facts=COMPLETE_AIR, + injected_mode="AIR", + ) + self.assertEqual(phase, "ledger_fail") + self.assertTrue(any("建单失败" in x for x in replies)) + self.assertEqual(flow.session_of("u9").facts["毛重"], "180") + + def test_tms_miss_card_matches_sales_layout(self) -> None: + payload = copy.tms_miss_wecom_payload(work_order_no="WO202606160008") + card = payload["template_card"] + self.assertEqual(card["card_type"], "text_notice") + self.assertEqual(card["source"]["desc"], "TMS查价结果") + self.assertEqual(card["main_title"]["title"], "WO202606160008暂无报价") + self.assertNotIn("desc", card["main_title"]) + self.assertEqual(card["sub_title_text"], "TMS标准价库暂无匹配报价,请手动拉新人与智能体到原有群") + self.assertEqual(card["quote_area"]["quote_text"], "请务必在群聊发送工单编号WO202606160008") + self.assertNotIn("title", card["quote_area"]) + self.assertNotIn("horizontal_content_list", card) + self.assertNotIn("card_action", card) + with_url = copy.tms_miss_wecom_payload( + work_order_no="WO202606160008", + action_url="https://example.test", + )["template_card"] + self.assertEqual(with_url["card_action"]["url"], "https://example.test") + + def test_quote_card_payload_has_clickable_button(self) -> None: + payload = copy.tms_hit_wecom_payload( + work_order_no="WO202609140001", + quote={ + "total": "USD 1,280", + "eta": "3-5 天", + "valid_until": "2026-09-20", + "source_label": "TMS 标准报价", + "fee_lines": [ + {"name": "空运费", "amount": "USD 1,200"}, + {"name": "其他附加费", "amount": "USD 80"}, + ], + }, + ) + card = payload["template_card"] + self.assertEqual(payload["msgtype"], "template_card") + self.assertEqual(card["card_type"], "button_interaction") + self.assertEqual(card["main_title"]["title"], "WO202609140001价格明细") + self.assertEqual(card["button_list"][0]["text"], copy.BTN_SKIP_COLLAB) + self.assertEqual(card["button_list"][0]["key"], "skip_collab:WO202609140001") + self.assertTrue(str(card["task_id"]).startswith("quote-WO202609140001-")) + self.assertEqual( + copy.work_order_from_card_meta({"task_id": card["task_id"]}), + "WO202609140001", + ) + self.assertNotIn("horizontal_content_list", card) + quote_text = card["quote_area"]["quote_text"] + self.assertNotIn("title", card["quote_area"]) + self.assertIn("空运费:USD 1,200", quote_text) + self.assertIn("总销售价合计:USD 1,280", quote_text) + self.assertNotIn("时效:", quote_text) + numeric = copy.tms_hit_wecom_payload( + work_order_no="WO202609140048", + quote={ + "total": "14669.60", + "currency": "CNY", + "eta": "-", + "valid_until": "2026-09-15", + "fee_lines": [{"name": "空运费", "amount": "14669.60", "currency": "CNY"}], + }, + )["template_card"]["quote_area"]["quote_text"] + self.assertIn("空运费:CNY 14669.60", numeric) + self.assertIn("总销售价合计:CNY 14669.60", numeric) + self.assertEqual(copy.money_text("14669.60", ""), "14669.60") + self.assertNotIn(copy.collab_hint("WO202609140001"), quote_text) + sub = card["sub_title_text"] + self.assertIn("时效:3-5 天", sub) + self.assertIn("报价有效期:2026-09-20", sub) + self.assertIn("相同询价内容检索:首次询价", sub) + self.assertNotIn("工单状态", sub) + self.assertTrue(sub.endswith(copy.collab_hint("WO202609140001"))) + self.assertNotIn("内部协同", json.dumps(card, ensure_ascii=False)) + + def test_scheme_card_payload_has_quote_and_buttons(self) -> None: + quote = { + "total": "USD 1,280", + "eta": "3-5 天", + "valid_until": "2026-09-20", + "source_label": "TMS 标准报价", + "fee_lines": [ + {"name": "空运费", "amount": "USD 1,200"}, + {"name": "其他附加费", "amount": "USD 80"}, + ], + } + text = copy.scheme_card( + work_order_no="WO202609140001", + status="已报价", + facts=COMPLETE_AIR, + quote=quote, + ) + self.assertIn("【WO202609140001报价方案】", text) + self.assertIn("工单号:WO202609140001", text) + self.assertIn("起运地:上海", text) + self.assertIn("体积:2.5", text) + self.assertIn("空运费:USD 1,200", text) + self.assertIn("时效:3-5 天", text) + self.assertIn("报价有效期:2026-09-20", text) + self.assertNotIn("报价日期:", text) + self.assertNotIn("工单状态", text) + payload = copy.scheme_wecom_payload( + work_order_no="WO202609140001", facts=COMPLETE_AIR, quote=quote + ) + card = payload["template_card"] + self.assertEqual(card["main_title"]["title"], "WO202609140001报价方案") + self.assertTrue(str(card["task_id"]).startswith("scheme-WO202609140001-")) + again = copy.scheme_wecom_payload( + work_order_no="WO202609140001", facts=COMPLETE_AIR, quote=quote + ) + self.assertNotEqual(card["task_id"], again["template_card"]["task_id"]) + self.assertEqual( + copy.work_order_from_card_meta({"task_id": card["task_id"]}), + "WO202609140001", + ) + quote_text = card["quote_area"]["quote_text"] + self.assertNotIn("title", card["quote_area"]) + self.assertIn("空运费:USD 1,200", quote_text) + self.assertIn("总销售价合计:USD 1,280", quote_text) + self.assertNotIn("sub_title_text", card) + rows = {x["keyname"]: x["value"] for x in card["horizontal_content_list"]} + self.assertEqual(rows["工单号"], "WO202609140001") + self.assertIn("上海", rows["航线"]) + self.assertIn("洛杉矶", rows["航线"]) + self.assertIn("普货", rows["货物"]) + self.assertIn("2.5", rows["货物"]) + self.assertEqual(rows["时效"], "3-5 天") + self.assertEqual(rows["报价有效期"], "2026-09-20") + self.assertNotIn("报价日期", rows) + empty_eta = copy.scheme_horizontal_rows( + work_order_no="WO1", facts=COMPLETE_AIR, quote={"valid_until": "2026-09-20"} + ) + self.assertEqual({x["keyname"]: x["value"] for x in empty_eta}["时效"], "-") + dashed = copy.scheme_horizontal_rows( + work_order_no="WO1", + facts=COMPLETE_AIR, + quote={"eta": "-3-5 天", "valid_until": "2026-09-20"}, + ) + dashed_rows = {x["keyname"]: x["value"] for x in dashed} + self.assertEqual(dashed_rows["时效"], "3-5 天") + self.assertEqual(len(card["button_list"]), 3) + self.assertEqual( + [x["text"] for x in card["button_list"]], + ["生成Excel", "生成PDF", "不采用"], + ) + self.assertEqual( + copy.work_order_from_card_meta( + { + "task_id": "scheme-WO202609140001", + "event_key": "adopt_excel:WO202609140001", + } + ), + "WO202609140001", + ) + self.assertEqual(copy.canonical_button("adopt_excel:WO202609140001"), copy.BTN_ADOPT_EXCEL) + self.assertEqual(copy.canonical_button(copy.BTN_ADOPT_EXCEL_CARD), copy.BTN_ADOPT_EXCEL) + + def test_same_demand_cites_latest_even_if_closed(self) -> None: + ledger = MemoryLedger() + flow = AirTextInquiryFlow(ledger) + replies_a: list[str] = [] + + def _keep_a(text: str, extra=None) -> None: + replies_a.append(text) + _ = extra + + flow.on_text( + sender_id="hist-a", + text="空运", + reply=_keep_a, + injected_facts=COMPLETE_AIR, + injected_mode="AIR", + ) + wo1 = flow.session_of("hist-a").work_order_no + self.assertTrue(wo1) + ledger.transition(work_order_no=wo1, to_status="已关闭") + replies_b: list[str] = [] + + def _keep_b(text: str, extra=None) -> None: + replies_b.append(text) + _ = extra + + flow.on_text( + sender_id="hist-b", + text="空运", + reply=_keep_b, + injected_facts=COMPLETE_AIR, + injected_mode="AIR", + ) + wo2 = flow.session_of("hist-b").work_order_no + self.assertTrue(wo2) + self.assertNotEqual(wo2, wo1) + self.assertIn(f"引用历史工单号{wo1}", "\n".join(replies_b)) + replies_c: list[str] = [] + + def _keep_c(text: str, extra=None) -> None: + replies_c.append(text) + _ = extra + + flow.on_text( + sender_id="hist-c", + text="空运", + reply=_keep_c, + injected_facts=COMPLETE_AIR, + injected_mode="AIR", + ) + wo3 = flow.session_of("hist-c").work_order_no + self.assertNotEqual(wo3, wo2) + self.assertIn(f"引用历史工单号{wo2}", "\n".join(replies_c)) + self.assertNotIn(f"引用历史工单号{wo1}", "\n".join(replies_c)) + + def test_card_event_key_matches_skip_button(self) -> None: + from agent.channel.wecom.parse import parse_plain_xml + + xml = ( + "wwWuJiLin" + "1event" + "template_card_event" + "skip_collab:WO202609140099" + "quote-WO202609140099" + "rc-wo-001" + ) + msg = parse_plain_xml(xml, default_agent_id="1000010") + self.assertEqual(msg.sender_id, "WuJiLin") + self.assertEqual(msg.content, "skip_collab:WO202609140099") + self.assertEqual(msg.raw.get("response_code"), "rc-wo-001") + self.assertEqual(msg.raw.get("task_id"), "quote-WO202609140099") + self.assertEqual(copy.canonical_button(msg.content), copy.BTN_SKIP_COLLAB) + self.assertEqual( + copy.work_order_from_card_meta(msg.raw, text=msg.content), + "WO202609140099", + ) + self.flow.on_text( + sender_id="WuJiLin", + text="空运", + reply=self._reply, + injected_facts=COMPLETE_AIR, + injected_mode="AIR", + ) + wo = self.flow.session_of("WuJiLin").work_order_no + xml_live = xml.replace("WO202609140099", wo) + live = parse_plain_xml(xml_live, default_agent_id="1000010") + phase = self.flow.on_text( + sender_id="WuJiLin", + text=live.content, + reply=self._reply, + inbound_raw=live.raw, + ) + self.assertEqual(phase, "wait_adopt") + + def test_old_price_card_uses_that_ticket_not_latest(self) -> None: + """同一人连问两单,点第一张价格卡必须出第一单的方案,不能落到最新单。""" + facts_a = dict(COMPLETE_AIR) + facts_a["品名"] = "茶叶" + facts_b = dict(COMPLETE_AIR) + facts_b["品名"] = "服装" + self.flow.on_text( + sender_id="card-owner", + text="空运", + reply=self._reply, + injected_facts=facts_a, + injected_mode="AIR", + ) + wo1 = self.flow.session_of("card-owner").work_order_no + self.assertTrue(wo1) + self.flow.on_text( + sender_id="card-owner", + text="空运", + reply=self._reply, + injected_facts=facts_b, + injected_mode="AIR", + ) + wo2 = self.flow.session_of("card-owner").work_order_no + self.assertTrue(wo2) + self.assertNotEqual(wo1, wo2) + + self.replies.clear() + phase = self.flow.on_text( + sender_id="card-owner", + text=f"skip_collab:{wo1}", + reply=self._reply, + inbound_raw={ + "event": "template_card_event", + "event_key": f"skip_collab:{wo1}", + "task_id": f"quote-{wo1}", + "response_code": "rc-old-card", + }, + ) + self.assertEqual(phase, "wait_adopt") + scheme = "\n".join(x for x in self.replies if "报价方案" in x) + self.assertIn(wo1, scheme) + self.assertIn("茶叶", scheme) + self.assertNotIn(wo2, scheme) + self.assertNotIn("服装", scheme) + self.assertEqual(self.flow.session_of("card-owner").work_order_no, wo1) + + def test_second_ticket_skip_still_greys_card(self) -> None: + """两张价格卡都要点「不需要协同」并置灰,去重键不能只按按钮文案。""" + from agent.handlers.card_action import handle_card_action + + store = MemoryMessageStore() + ledger = MemoryLedger() + flow = AirTextInquiryFlow(ledger) + + def _open(uid: str) -> str: + flow.on_text( + sender_id=uid, + text="空运", + reply=lambda *_a, **_k: None, + injected_facts=COMPLETE_AIR, + injected_mode="AIR", + ) + return flow.session_of(uid).work_order_no + + wo1 = _open("ua") + wo2 = _open("ua") + handle_card_action( + sender_id="ua", + thread_id="", + wait_version=0, + action=copy.BTN_SKIP_COLLAB, + payload={"response_code": "rc-aaa", "task_id": f"quote-{wo1}-x"}, + flow=flow, + store=store, + ) + handle_card_action( + sender_id="ua", + thread_id="", + wait_version=0, + action=copy.BTN_SKIP_COLLAB, + payload={"response_code": "rc-bbb", "task_id": f"quote-{wo2}-y"}, + flow=flow, + store=store, + ) + updates = [ + item + for item in store._outbox.values() + if (item.payload or {}).get("msgtype") == "update_template_card" + ] + self.assertEqual(len(updates), 2) + + def test_adopt_excel_without_template_stays_wait_adopt(self) -> None: + ledger = MemoryLedger() + flow = AirTextInquiryFlow(ledger) + replies: list[str] = [] + flow.on_text( + sender_id="u-tpl-0", + text="空运", + reply=lambda t, extra=None: replies.append(t), + injected_facts=COMPLETE_AIR, + injected_mode="AIR", + ) + flow.on_button( + sender_id="u-tpl-0", + action=copy.BTN_SKIP_COLLAB, + reply=lambda t, extra=None: replies.append(t), + ) + ledger.set_quote_templates([]) + phase = flow.on_button( + sender_id="u-tpl-0", + action=copy.BTN_ADOPT_EXCEL, + reply=lambda t, extra=None: replies.append(t), + ) + self.assertEqual(phase, "wait_adopt") + self.assertIn(copy.QUOTE_TEMPLATE_MISSING, "\n".join(replies)) + self.assertNotIn("咨询成交状态", "\n".join(replies)) + self.assertEqual(flow.session_of("u-tpl-0").phase, "wait_adopt") + + def test_adopt_excel_keyword_then_default(self) -> None: + ledger = MemoryLedger() + ledger.set_quote_templates( + [ + { + "templateId": "d1", + "name": "默认空运", + "bizType": "空运", + "keywords": ["欧洲"], + "isDefault": True, + }, + { + "templateId": "k1", + "name": "洛杉矶专线", + "bizType": "空运", + "keywords": ["洛杉矶"], + "isDefault": False, + }, + ] + ) + flow = AirTextInquiryFlow(ledger) + replies: list[str] = [] + flow.on_text( + sender_id="u-tpl-k", + text="空运", + reply=lambda t, extra=None: replies.append(t), + injected_facts=COMPLETE_AIR, + injected_mode="AIR", + ) + flow.on_button( + sender_id="u-tpl-k", + action=copy.BTN_SKIP_COLLAB, + reply=lambda t, extra=None: replies.append(t), + ) + phase = flow.on_button( + sender_id="u-tpl-k", + action=copy.BTN_ADOPT_EXCEL, + reply=lambda t, extra=None: replies.append(t), + ) + self.assertEqual(phase, "wait_deal") + self.assertEqual(ledger.last_render.get("matchType"), "keyword") + self.assertEqual(ledger.last_render.get("templateName"), "洛杉矶专线") + self.assertIn("咨询成交状态", "\n".join(replies)) + + ny = dict(COMPLETE_AIR) + ny["目的港"] = "纽约" + flow2 = AirTextInquiryFlow(ledger) + replies2: list[str] = [] + flow2.on_text( + sender_id="u-tpl-d", + text="空运", + reply=lambda t, extra=None: replies2.append(t), + injected_facts=ny, + injected_mode="AIR", + ) + flow2.on_button( + sender_id="u-tpl-d", + action=copy.BTN_SKIP_COLLAB, + reply=lambda t, extra=None: replies2.append(t), + ) + phase2 = flow2.on_button( + sender_id="u-tpl-d", + action=copy.BTN_ADOPT_EXCEL, + reply=lambda t, extra=None: replies2.append(t), + ) + self.assertEqual(phase2, "wait_deal") + self.assertEqual(ledger.last_render.get("matchType"), "default") + self.assertEqual(ledger.last_render.get("templateName"), "默认空运") + + +if __name__ == "__main__": + unittest.main() diff --git a/inquiry-agent/tests/test_continue_ticket.py b/inquiry-agent/tests/test_continue_ticket.py new file mode 100644 index 0000000..a94aed8 --- /dev/null +++ b/inquiry-agent/tests/test_continue_ticket.py @@ -0,0 +1,278 @@ +"""续办历史工单:模型意图合同 + 回到当时节点。""" + +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.continue_thread import handle_continue_thread +from agent.ledger.memory_ledger import MemoryLedger +from agent.policy import inquiry_copy as copy +from agent.policy.air_text_flow import AirTextInquiryFlow +from agent.routing import IntentRouter +from agent.routing.deepseek_a import DeepSeekARouter +from agent.routing.dispatch import dispatch_inbound +from agent.routing.ordered_actions import parse_ordered_actions_arguments + + +COMPLETE_AIR = { + "起运港": "上海", + "目的港": "洛杉矶", + "品名": "普货", + "件数": "3", + "毛重": "180", + "体积": "2.5", + "包装方式": "纸箱", +} + + +class ContinueTicketTests(unittest.TestCase): + def test_model_contract_accepts_continue_intent(self) -> None: + digest = "a" * 64 + parsed = parse_ordered_actions_arguments( + { + "decision_id": "d1", + "intent": "continue_thread", + "ordered_action": { + "action_type": "CONTINUE_HISTORICAL_TICKET", + "order": 1, + "target": {"work_order_no": "WO202609140034"}, + "evidence": { + "source_ref": "inbound:u1", + "content_digest": digest, + }, + }, + } + ) + decision = parsed.to_route_decision() + self.assertEqual(decision.intent, "continue_thread") + self.assertEqual(decision.thread_id, "WO202609140034") + + def test_model_junk_digest_still_continues(self) -> None: + """测服实测:模型会乱填 content_digest,不能因此整句判失败。""" + router = DeepSeekARouter.from_settings() + decision = router.route_from_model_args( + { + "decision_id": "x", + "intent": "continue_thread", + "ordered_action": { + "action_type": "CONTINUE_HISTORICAL_TICKET", + "order": "1", + "target": {"work_order_no": "WO202609140037"}, + "evidence": { + "source_ref": "user", + "content_digest": "not-a-hash", + }, + }, + }, + text="我要继续聊聊WO202609140037", + sender_id="WuJiLin", + ) + self.assertEqual(decision.intent, "continue_thread") + self.assertEqual(decision.thread_id, "WO202609140037") + + def test_stub_natural_language_not_fixed_command(self) -> None: + router = DeepSeekARouter.from_settings() + hit = router.route_stub_fixture( + text="我要继续咨询工单WO202609140034", + sender_id="WuJiLin", + ) + self.assertEqual(hit.intent, "continue_thread") + self.assertEqual(hit.thread_id, "WO202609140034") + again = router.route_stub_fixture( + text="把刚才那单 WO202609140034 再出一下方案", + sender_id="WuJiLin", + ) + self.assertEqual(again.intent, "continue_thread") + fresh = router.route_stub_fixture( + text="上海到洛杉矶空运,3件 180kg 茶叶", + sender_id="WuJiLin", + ) + self.assertEqual(fresh.intent, "ordinary_text_inquiry") + self.assertEqual(fresh.thread_id, "") + # 无网夹具认不出「只贴工单号」;正式环境必须由模型认。 + bare = router.route_stub_fixture( + text="WO202609140034", + sender_id="WuJiLin", + ) + self.assertEqual(bare.intent, "ordinary_text_inquiry") + + def test_card_click_skips_model(self) -> None: + router = IntentRouter(allow_network=False) + hit = router.route_inbound( + text="adopt_excel:WO202609140034", + sender_id="WuJiLin", + event="template_card_event", + ) + self.assertEqual(hit.intent, "card_action") + + def test_resume_scheme_from_session(self) -> None: + flow = AirTextInquiryFlow(MemoryLedger()) + replies: list[str] = [] + extras: list[dict] = [] + + def reply(text: str, extra=None) -> None: + replies.append(text) + extras.append(dict(extra) if extra else {}) + + flow.on_text( + sender_id="u1", + text="空运", + reply=reply, + injected_facts=COMPLETE_AIR, + injected_mode="AIR", + ) + wo = flow.session_of("u1").work_order_no + replies.clear() + extras.clear() + flow.on_button(sender_id="u1", action=copy.BTN_SKIP_COLLAB, reply=reply) + self.assertEqual(flow.session_of("u1").phase, "wait_adopt") + replies.clear() + extras.clear() + phase = flow.resume_historical( + sender_id="u1", work_order_no=wo, reply=reply + ) + self.assertEqual(phase, "wait_adopt") + self.assertTrue(any(f"{wo}报价方案" in x for x in replies)) + self.assertTrue( + any((x.get("template_card") or {}).get("main_title", {}).get("title") == f"{wo}报价方案" for x in extras) + ) + + def test_resume_from_ledger_quoted(self) -> None: + ledger = MemoryLedger() + flow = AirTextInquiryFlow(ledger) + created = ledger.create_ticket( + sender_id="u2", business_line="AIR", facts=COMPLETE_AIR + ) + wo = created["work_order_no"] + ledger.upsert_quote( + work_order_no=wo, + quote={ + "total": "USD 1,280", + "eta": "3-5 天", + "valid_until": "2026-09-20", + "source_label": "TMS 标准报价", + "fee_lines": [{"name": "空运费", "amount": "USD 1,200"}], + }, + to_status="已报价", + ) + replies: list[str] = [] + + def reply(text: str, extra=None) -> None: + replies.append(text) + _ = extra + + phase = flow.resume_historical( + sender_id="u2", work_order_no=wo, reply=reply + ) + self.assertEqual(phase, "wait_adopt") + self.assertTrue(any("报价方案" in x for x in replies)) + + def test_resume_missing_wo_asks(self) -> None: + flow = AirTextInquiryFlow(MemoryLedger()) + replies: list[str] = [] + phase = flow.resume_historical( + sender_id="u3", work_order_no="", reply=replies.append + ) + self.assertEqual(phase, "need_work_order") + self.assertTrue(any("工单号" in x for x in replies)) + + def test_dispatch_passes_work_order(self) -> None: + ledger = MemoryLedger() + flow = AirTextInquiryFlow(ledger) + created = ledger.create_ticket( + sender_id="u4", 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="已报价", + ) + from agent.channel.queue import MemoryMessageStore + from agent.routing.decision import RouteDecision + + store = MemoryMessageStore() + msg = InboundMessage( + sender_id="u4", + message_id="m-cont", + content="帮我把那单再看一眼", + ) + phase = handle_continue_thread( + msg, thread_id=wo, flow=flow, store=store + ) + self.assertEqual(phase, "wait_adopt") + decision = RouteDecision(intent="continue_thread", thread_id=wo) + # dispatch 走全局 flow,这里只断言意图表仍指向 continue + self.assertEqual(decision.intent, "continue_thread") + _ = dispatch_inbound + + def test_adopt_excel_after_restart_stays_on_card_ticket(self) -> None: + """点 0040 方案卡不能落到刚续办的 0037:进程无书签时按卡上工单号从主账收回。""" + ledger = MemoryLedger() + live = AirTextInquiryFlow(ledger) + sink: list[str] = [] + + def reply(text: str, extra=None) -> None: + sink.append(text) + _ = extra + + live.on_text( + sender_id="u5", + text="空运", + reply=reply, + injected_facts=COMPLETE_AIR, + injected_mode="AIR", + ) + wo_old = live.session_of("u5").work_order_no + live.on_button(sender_id="u5", action=copy.BTN_SKIP_COLLAB, reply=reply) + other = dict(COMPLETE_AIR) + other["品名"] = "服装" + live.on_text( + sender_id="u5", + text="空运", + reply=reply, + injected_facts=other, + injected_mode="AIR", + ) + wo_new = live.session_of("u5").work_order_no + self.assertNotEqual(wo_old, wo_new) + + restarted = AirTextInquiryFlow(ledger) + restarted.resume_historical( + sender_id="u5", work_order_no=wo_new, reply=reply + ) + sink.clear() + phase = restarted.on_button( + sender_id="u5", + action=copy.BTN_ADOPT_EXCEL, + reply=reply, + card_meta={ + "event_key": f"adopt_excel:{wo_old}", + "task_id": f"scheme-{wo_old}-deadbeef", + "response_code": "rc-old-scheme", + }, + ) + self.assertEqual(phase, "wait_deal") + joined = "\n".join(sink) + self.assertIn(wo_old, joined) + self.assertNotIn(wo_new, joined) + self.assertIn("Excel", joined) + + +if __name__ == "__main__": + unittest.main() diff --git a/inquiry-agent/tests/test_extract_oral.py b/inquiry-agent/tests/test_extract_oral.py new file mode 100644 index 0000000..b1ed629 --- /dev/null +++ b/inquiry-agent/tests/test_extract_oral.py @@ -0,0 +1,266 @@ +""" +口述空运抽字段:解析 DeepSeek B JSON,不打外网。 +""" + +from __future__ import annotations + +import os +import sys +import unittest +from types import SimpleNamespace + +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("LEDGER_BACKEND", "memory") + +from agent.llm.extract_text import extract_inquiry_snapshot +from agent.llm.mode_extract_fields import invoke_shell, parse_extract_payload +from agent.policy.air_text_flow import AirTextInquiryFlow +from agent.ledger.memory_ledger import MemoryLedger + +ORAL = "上海到洛杉矶空运,3件 180kg,体积2.5,普货纸箱" + +B_JSON = { + "transport_mode": "AIR", + "fields": { + "起运港": "上海", + "目的港": "洛杉矶", + "品名": "普货", + "件数": "3", + "毛重": "180", + "体积": "2.5", + "包装方式": "纸箱", + "报价日期": "", + }, +} + + +class OralExtractTests(unittest.TestCase): + def test_parse_b_json(self) -> None: + parsed = parse_extract_payload(B_JSON) + self.assertTrue(parsed["ok"]) + self.assertEqual(parsed["business_line"], "AIR") + self.assertEqual(parsed["facts"]["起运港"], "上海") + self.assertNotIn("报价日期", parsed["facts"]) + + def test_invoke_shell_with_json(self) -> None: + out = invoke_shell(payload={"assistant_json": B_JSON}) + self.assertTrue(out["ok"]) + self.assertEqual(out["facts"]["目的港"], "洛杉矶") + + def test_invoke_shell_without_json_keeps_legacy(self) -> None: + out = invoke_shell(payload={"a": 1}) + self.assertEqual(out.get("error"), "extract_fields_shell_not_implemented") + + def test_oral_snapshot_uses_b_not_regex(self) -> None: + snap = extract_inquiry_snapshot(ORAL, b_result=parse_extract_payload(B_JSON)) + self.assertEqual(snap["business_line"], "AIR") + self.assertEqual(snap["facts"]["件数"], "3件") + self.assertEqual(snap["source"], "deepseek_b") + + def test_oral_without_b_does_not_guess_ports(self) -> None: + snap = extract_inquiry_snapshot(ORAL) + self.assertEqual(snap["business_line"], "AIR") + self.assertFalse(snap["facts"].get("起运港")) + + def test_assemble_air_query_zhuhai_clark(self) -> None: + from agent.schema.tms_air_query import assemble_air_query + + out = assemble_air_query( + { + "起运港": "珠海", + "目的港": "克拉克", + "品名": "茶叶", + "件数": "10件", + "毛重": "100KGS", + "体积": "2CBM", + "包装方式": "托盘", + "报价日期": "2026-09-12", + } + ) + self.assertTrue(out["ok"]) + self.assertEqual(out["facts"]["originCode"], "ZUH") + self.assertEqual(out["facts"]["destinationCode"], "CRK") + self.assertEqual(out["facts"]["packageType"], "2") + self.assertEqual(out["facts"]["件数"], "10件") + + bulk = assemble_air_query( + { + "起运港": "珠海", + "目的港": "克拉克", + "品名": "茶叶", + "件数": "10", + "毛重": "100", + "体积": "2", + "包装方式": "散货", + "报价日期": "2026-09-12", + } + ) + self.assertTrue(bulk["ok"]) + self.assertEqual(bulk["facts"]["包装方式"], "散货") + self.assertNotIn("包装方式", bulk["missing"]) + + def test_fly_route_overwrites_stale_nkg_kul(self) -> None: + from agent.schema.field_validate import harvest_oral_measures + from agent.schema.tms_air_query import assemble_air_query + from agent.policy import inquiry_copy as copy + + first = harvest_oral_measures( + "从南京飞吉隆坡,9月14日茶叶,托盘,4.2cbm,1.5KG", + {}, + ) + self.assertEqual(first["起运港"], "南京") + self.assertEqual(first["目的港"], "吉隆坡") + assembled = assemble_air_query( + {**first, "originCode": "NKG", "destinationCode": "KUL", "件数": "10"} + ) + card = copy.inquiry_card(work_order_no="WO1", facts=assembled["facts"]) + self.assertIn("起运地:南京", card) + self.assertIn("目的地:吉隆坡", card) + self.assertNotIn("起运地:NKG", card) + self.assertNotIn("目的地:KUL", card) + second = harvest_oral_measures( + "北京飞吉隆坡,9月14日茶叶,4.2cbm,1.5KG", + {**assembled["facts"]}, + ) + again = assemble_air_query(second) + self.assertEqual(again["facts"]["起运港"], "北京") + self.assertEqual(again["facts"]["originCode"], "PEK") + self.assertEqual(again["facts"]["destinationCode"], "KUL") + shown = copy.inquiry_card(work_order_no="WO2", facts=again["facts"]) + self.assertIn("起运地:北京", shown) + self.assertNotIn("起运地:NKG", shown) + self.assertNotIn("起运地:南京", shown) + + def test_oral_pack_and_quote_date_from_current_sentence(self) -> None: + from agent.schema.field_validate import harvest_oral_measures + + first = harvest_oral_measures( + "南京飞吉隆坡,9月15日茶叶,托盘,4.2cbm,1.5KG", + {}, + ) + self.assertEqual(first["包装方式"], "托盘") + self.assertEqual(first["报价日期"], "2026-09-15") + self.assertEqual(first["毛重"], "1.5KG") + self.assertEqual(first["体积"], "4.2cbm") + second = harvest_oral_measures( + "京飞吉隆坡,9月16日茶叶,散货,5.2cbm,2.5KG", + first, + ) + self.assertEqual(second["包装方式"], "散货") + self.assertEqual(second["报价日期"], "2026-09-16") + self.assertEqual(second["毛重"], "2.5KG") + self.assertEqual(second["体积"], "5.2cbm") + + def test_clarify_replaces_pallet_with_bulk_and_keeps_quote_date(self) -> None: + replies: list[str] = [] + + def reply(text: str, extra=None) -> None: + replies.append(text) + _ = extra + + flow = AirTextInquiryFlow(MemoryLedger()) + flow.on_text( + sender_id="u_date", + text="南京飞吉隆坡,9月15日茶叶,托盘,4.2cbm,1.5KG", + reply=reply, + injected_facts={ + "起运港": "南京", + "目的港": "吉隆坡", + "品名": "茶叶", + "包装方式": "托盘", + }, + injected_mode="AIR", + ) + self.assertEqual(flow.session_of("u_date").facts["包装方式"], "托盘") + self.assertEqual(flow.session_of("u_date").facts["报价日期"], "2026-09-15") + flow.on_text( + sender_id="u_date", + text="京飞吉隆坡,9月16日茶叶,散货,5.2cbm,2.5KG", + reply=reply, + injected_facts={ + "起运港": "南京", + "目的港": "吉隆坡", + "品名": "茶叶", + }, + injected_mode="AIR", + ) + sess = flow.session_of("u_date") + self.assertEqual(sess.facts["包装方式"], "散货") + self.assertEqual(sess.facts["报价日期"], "2026-09-16") + last = replies[-1] + self.assertIn("包装类型:散货", last) + self.assertIn("报价日期:2026-09-16", last) + + def test_weight_keeps_user_kg_not_kgs(self) -> None: + from agent.policy import inquiry_copy as copy + from agent.schema.field_validate import harvest_oral_measures + + facts = harvest_oral_measures("从南京飞吉隆坡,茶叶,1.5KG,4.2cbm,托盘", {}) + self.assertEqual(facts["毛重"], "1.5KG") + shown = copy.inquiry_card( + work_order_no="WO1", + facts={**facts, "起运港": "南京", "目的港": "吉隆坡", "品名": "茶叶", "件数": "10"}, + ) + self.assertIn("重量:1.5KG", shown) + self.assertNotIn("1.5KGS", shown) + + def test_oral_units_fill_weight_volume_without_b(self) -> None: + snap = extract_inquiry_snapshot( + "珠海到克拉克空运,茶叶 10件 100KGS 2CBM 托盘" + ) + self.assertEqual(snap["facts"]["件数"], "10件") + self.assertEqual(snap["facts"]["毛重"], "100KGS") + self.assertEqual(snap["facts"]["体积"], "2CBM") + self.assertFalse(snap["facts"].get("起运港")) + + def test_labeled_weight_alias(self) -> None: + from agent.llm.extract_text import parse_labeled_facts + from agent.schema.field_validate import normalize_facts + + facts = parse_labeled_facts("重量(KG):180\n体积(CBM):2.5") + norm = normalize_facts(facts) + self.assertEqual(norm["毛重"], "180") + self.assertEqual(norm["体积"], "2.5") + + def test_chat_fn_fixture_drives_flow(self) -> None: + def chat_fn(**kwargs): + return SimpleNamespace( + ok=True, + content="", + error="", + tool_calls=[ + { + "function": { + "name": "extract_inquiry_fields", + "arguments": __import__("json").dumps(B_JSON, ensure_ascii=False), + } + } + ], + ) + + replies: list[str] = [] + + def reply(text: str, extra=None) -> None: + replies.append(text) + _ = extra + + flow = AirTextInquiryFlow(MemoryLedger()) + phase = flow.on_text( + sender_id="oral1", + text=ORAL, + reply=reply, + chat_fn=chat_fn, + ) + self.assertEqual(phase, "wait_collab") + joined = "\n".join(replies) + self.assertIn("工单号:", joined) + self.assertIn("起运地:上海", joined) + self.assertIn("目的地:洛杉矶", joined) + self.assertNotIn("【询价卡片】", joined) + + +if __name__ == "__main__": + unittest.main() diff --git a/inquiry-agent/tests/test_http_ledger.py b/inquiry-agent/tests/test_http_ledger.py new file mode 100644 index 0000000..0833cc5 --- /dev/null +++ b/inquiry-agent/tests/test_http_ledger.py @@ -0,0 +1,50 @@ +"""HttpLedger 只测 Jeecg 包装解析,不打 8180 / TMS。""" + +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("LEDGER_BACKEND", "memory") + +from agent.ledger.http_ledger import _unwrap +from agent.schema.fingerprint import ascii_idempotency_key, inquiry_fingerprint + + +class HttpLedgerParseTests(unittest.TestCase): + def test_unwrap_jeecg_ok(self) -> None: + inner = _unwrap({"success": True, "result": {"ok": True, "has_price": True, "classification": "HAS_PRICE"}}) + self.assertTrue(inner["ok"]) + self.assertEqual(inner["classification"], "HAS_PRICE") + + def test_unwrap_jeecg_fail(self) -> None: + inner = _unwrap({"success": False, "message": "unauthorized", "result": {}}) + self.assertFalse(inner["ok"]) + + def test_chinese_fingerprint_header_is_ascii(self) -> None: + raw = inquiry_fingerprint( + {"起运港": "上海", "目的港": "洛杉矶", "品名": "普货", "毛重": "100", "体积": "1"}, + business_line="AIR", + ) + key = ascii_idempotency_key(raw) + key.encode("ascii") + self.assertTrue(key.startswith("sha256:")) + self.assertEqual(ascii_idempotency_key("tms:WO1"), "tms:WO1") + + def test_service_token_not_sent_as_jwt(self) -> None: + from agent.ledger import LedgerClient + + client = LedgerClient(base_url="http://127.0.0.1:8180/jeecgboot", service_token="not-a-jwt") + headers = client._http().headers + self.assertEqual(headers.get("X-Agent-Token"), "not-a-jwt") + self.assertFalse(headers.get("X-Access-Token")) + client.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/inquiry-agent/tests/test_quote_template_match.py b/inquiry-agent/tests/test_quote_template_match.py new file mode 100644 index 0000000..7976738 --- /dev/null +++ b/inquiry-agent/tests/test_quote_template_match.py @@ -0,0 +1,68 @@ +""" +报价模板匹配单测:关键词 → 默认 → 双无。不连主账。 +""" + +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) + +from agent.quote_templates.match import FAIL_MESSAGE, pick_quote_template + + +class QuoteTemplateMatchTests(unittest.TestCase): + def test_keyword_beats_default(self) -> None: + hit = pick_quote_template( + templates=[ + {"templateId": "d1", "name": "默认空运", "bizType": "空运", "keywords": ["欧洲"], "isDefault": True}, + {"templateId": "k1", "name": "洛杉矶专线", "bizType": "空运", "keywords": ["洛杉矶"], "isDefault": False}, + ], + business_line="AIR", + facts={"目的港": "洛杉矶", "品名": "普货"}, + ) + self.assertIsNotNone(hit) + self.assertEqual(hit.match_type, "keyword") + self.assertEqual(hit.name, "洛杉矶专线") + + def test_default_when_no_keyword(self) -> None: + hit = pick_quote_template( + templates=[ + {"templateId": "d1", "name": "默认空运", "bizType": "空运", "keywords": ["欧洲"], "isDefault": True}, + {"templateId": "k1", "name": "洛杉矶专线", "bizType": "空运", "keywords": ["洛杉矶"], "isDefault": False}, + ], + business_line="空运", + facts={"目的港": "纽约"}, + ) + self.assertIsNotNone(hit) + self.assertEqual(hit.match_type, "default") + self.assertEqual(hit.name, "默认空运") + + def test_none_when_empty(self) -> None: + hit = pick_quote_template( + templates=[ + {"templateId": "k1", "name": "洛杉矶专线", "bizType": "空运", "keywords": ["洛杉矶"], "isDefault": False}, + ], + business_line="AIR", + facts={"目的港": "纽约"}, + ) + self.assertIsNone(hit) + self.assertIn("IT运维", FAIL_MESSAGE) + + def test_sea_ignored_for_air(self) -> None: + hit = pick_quote_template( + templates=[ + {"templateId": "s1", "name": "海运默认", "bizType": "海运", "keywords": ["洛杉矶"], "isDefault": True}, + ], + business_line="AIR", + facts={"目的港": "洛杉矶"}, + ) + self.assertIsNone(hit) + + +if __name__ == "__main__": + unittest.main() diff --git a/inquiry-api/jeecg-boot-base-core/src/main/java/org/jeecg/config/shiro/ShiroConfig.java b/inquiry-api/jeecg-boot-base-core/src/main/java/org/jeecg/config/shiro/ShiroConfig.java index 2c957e1..7282290 100644 --- a/inquiry-api/jeecg-boot-base-core/src/main/java/org/jeecg/config/shiro/ShiroConfig.java +++ b/inquiry-api/jeecg-boot-base-core/src/main/java/org/jeecg/config/shiro/ShiroConfig.java @@ -148,6 +148,10 @@ public class ShiroConfig { // update-begin--author:liusq Date:20230522 for:[issues/4829]访问不存在的url时会提示Token失效,请重新登录呢 //错误路径排除 filterChainDefinitionMap.put("/error", "anon"); + // 智能体服务账号:由控制器校验 AGENT_CALLBACK_TOKEN,不走后台登录 JWT。 + // 同时写带 /jeecgboot 前缀的规则,避免部分过滤器按完整 URI 匹配时漏放行。 + filterChainDefinitionMap.put("/inquiry/agent/**", "anon"); + filterChainDefinitionMap.put("/jeecgboot/inquiry/agent/**", "anon"); // update-end--author:liusq Date:20230522 for:[issues/4829]访问不存在的url时会提示Token失效,请重新登录呢 // 添加自己的过滤器并且取名为jwt diff --git a/inquiry-api/jeecg-module-inquiry/pom.xml b/inquiry-api/jeecg-module-inquiry/pom.xml index 8c340b4..3107498 100644 --- a/inquiry-api/jeecg-module-inquiry/pom.xml +++ b/inquiry-api/jeecg-module-inquiry/pom.xml @@ -34,5 +34,10 @@ poi-ooxml 4.1.2 + + org.springframework.boot + spring-boot-starter-test + test + 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 new file mode 100644 index 0000000..52f82af --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryAgentController.java @@ -0,0 +1,175 @@ +package org.jeecg.modules.inquiry.controller; + +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.jeecg.common.api.vo.Result; +import org.jeecg.modules.inquiry.dto.AgentTicketCreateRequest; +import org.jeecg.modules.inquiry.service.IInquiryQuoteTemplateService; +import org.jeecg.modules.inquiry.service.IInquiryWorkOrderService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.*; + +import javax.servlet.http.HttpServletRequest; +import java.util.Map; + +/** + * 智能体专用主账入口(服务账号)。 + *

+ * 职责:建单、重单检索、经本主账查真 TMS、按后台报价模板出单。 + * 鉴权:请求头 X-Agent-Token 或 X-Access-Token 对齐 AGENT_CALLBACK_TOKEN / BIZ_API_TOKEN。 + * 禁止:在本类做自然语言抽取;禁止 Tomcat 线程等 Python Graph。 + */ +@Slf4j +@Api(tags = "询价-智能体服务账号") +@RestController +@RequestMapping("/inquiry/agent") +public class InquiryAgentController { + + @Autowired + private IInquiryWorkOrderService workOrderService; + + @Autowired + private IInquiryQuoteTemplateService quoteTemplateService; + + @Value("${inquiry.agent.callback-token:${AGENT_CALLBACK_TOKEN:}}") + private String agentToken; + + @Value("${inquiry.agent.biz-token:${BIZ_API_TOKEN:}}") + private String bizToken; + + @ApiOperation("建单(每次新开一单,相同需求不复用)") + @PostMapping("/ticket/create") + public Result> create(@RequestBody AgentTicketCreateRequest req, + HttpServletRequest http) { + if (!authed(http)) { + return Result.error(401, "unauthorized"); + } + try { + return Result.OK(workOrderService.createFromAgent(req)); + } catch (IllegalArgumentException e) { + return Result.error(e.getMessage()); + } + } + + @ApiOperation("全库检索相同询价内容(不限六态,只回最新一条)") + @PostMapping("/ticket/findSimilar") + public Result> findSimilar(@RequestBody AgentTicketCreateRequest req, + HttpServletRequest http) { + if (!authed(http)) { + return Result.error(401, "unauthorized"); + } + return Result.OK(workOrderService.findSimilarOpen(req)); + } + + @ApiOperation("智能体六态流转(成交/未成交等)") + @PostMapping("/ticket/transition") + public Result> transition(@RequestBody Map body, + HttpServletRequest http) { + if (!authed(http)) { + return Result.error(401, "unauthorized"); + } + org.jeecg.modules.inquiry.dto.TicketTransitionRequest req = + new org.jeecg.modules.inquiry.dto.TicketTransitionRequest(); + Object no = body == null ? null : body.get("workOrderNo"); + Object to = body == null ? null : body.get("toStatus"); + req.setWorkOrderNo(no == null ? "" : String.valueOf(no)); + req.setToStatus(to == null ? "" : String.valueOf(to)); + try { + org.jeecg.modules.inquiry.entity.InquiryTicket ticket = + workOrderService.transition(req, "agent"); + java.util.Map out = new java.util.HashMap<>(); + out.put("ok", true); + out.put("status", ticket.getStatus()); + out.put("work_order_no", ticket.getWorkOrderNo()); + return Result.OK(out); + } catch (IllegalArgumentException e) { + return Result.error(e.getMessage()); + } + } + + @ApiOperation("智能体续办读单(只读,校验发起人)") + @PostMapping("/ticket/get") + public Result> get(@RequestBody Map body, + HttpServletRequest http) { + if (!authed(http)) { + return Result.error(401, "unauthorized"); + } + String no = body == null ? "" : String.valueOf(body.getOrDefault("work_order_no", "")); + String sales = body == null ? "" : String.valueOf(body.getOrDefault("salesWecomId", "")); + if ("null".equals(no)) { + no = ""; + } + if ("null".equals(sales)) { + sales = ""; + } + return Result.OK(workOrderService.getForAgent(no, sales)); + } + + @ApiOperation("按询价需求匹配后台报价模板并填 Excel(不转 PDF)") + @PostMapping("/quote/render") + public Result> renderQuote(@RequestBody Map body, + HttpServletRequest http) { + if (!authed(http)) { + return Result.error(401, "unauthorized"); + } + String workOrderNo = firstStr(body, "workOrderNo", "work_order_no"); + String businessLine = firstStr(body, "businessLine", "business_line"); + String sales = firstStr(body, "salesWecomId", "sales_wecom_id"); + @SuppressWarnings("unchecked") + 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)); + } + + @ApiOperation("经主账查真 TMS(有价回报价 / 无价回暂无报价 / 技术失败)") + @PostMapping("/tms/query") + public Result> tmsQuery(@RequestBody Map body, + HttpServletRequest http) { + if (!authed(http)) { + return Result.error(401, "unauthorized"); + } + return Result.OK(workOrderService.queryTms(body)); + } + + private boolean authed(HttpServletRequest http) { + String got = http.getHeader("X-Agent-Token"); + if (StringUtils.isBlank(got)) { + got = http.getHeader("X-Access-Token"); + } + String expect = StringUtils.firstNonBlank(agentToken, bizToken); + if (StringUtils.isBlank(expect)) { + // 测服未配 token 时允许本机联调,正式必须配 + log.warn("inquiry.agent 未配置 token,测服放行(勿用于生产)"); + return true; + } + return expect.equals(got); + } + + private static String firstStr(Map body, String... keys) { + if (body == null) { + return ""; + } + for (String k : keys) { + Object v = body.get(k); + if (v != null && !"null".equals(String.valueOf(v))) { + String s = String.valueOf(v).trim(); + if (!s.isEmpty()) { + return s; + } + } + } + return ""; + } + + @SuppressWarnings("unchecked") + private static Map asMap(Object raw) { + if (raw instanceof Map) { + return (Map) raw; + } + return new java.util.LinkedHashMap<>(); + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/dto/AgentQuoteRenderRequest.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/dto/AgentQuoteRenderRequest.java new file mode 100644 index 0000000..04ac6a7 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/dto/AgentQuoteRenderRequest.java @@ -0,0 +1,33 @@ +package org.jeecg.modules.inquiry.dto; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import java.util.Map; + +/** + * 智能体按后台报价模板出单请求。 + *

+ * 匹配文本来自已冻结询价事实,主账不做自然语言抽取。 + * 本接口只填 Excel;转 PDF 由智能体 Worker LibreOffice 单槽完成。 + */ +@Data +@ApiModel("智能体报价出单") +public class AgentQuoteRenderRequest { + + @ApiModelProperty("工单号,写入报价单编号") + private String workOrderNo; + + @ApiModelProperty("SEA/AIR/LAND 或 海运/空运/陆运") + private String businessLine; + + @ApiModelProperty("已确认询价字段") + private Map facts; + + @ApiModelProperty("已冻结报价(费用行/合计/有效期)") + private Map quote; + + @ApiModelProperty("入站 sender_id,写入业务员格(可空)") + private String salesWecomId; +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/dto/AgentTicketCreateRequest.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/dto/AgentTicketCreateRequest.java new file mode 100644 index 0000000..043207f --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/dto/AgentTicketCreateRequest.java @@ -0,0 +1,35 @@ +package org.jeecg.modules.inquiry.dto; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import java.util.Map; + +/** + * 智能体建单请求。 + *

+ * 字段已由智能体按合同校验;主账只落库与重单检索,不做自然语言抽取。 + */ +@Data +@ApiModel("智能体建单") +public class AgentTicketCreateRequest { + + @ApiModelProperty("入站 sender_id") + private String salesWecomId; + + @ApiModelProperty("SEA/AIR/LAND") + private String businessLine; + + @ApiModelProperty("已确认查价字段(内部键)") + private Map facts; + + @ApiModelProperty("幂等键") + private String idempotencyKey; + + /** + * 检索时排除的工单号。建单后查相同需求必须排除刚开的本单,否则最新一条永远是自己。 + */ + @ApiModelProperty("检索时排除的工单号") + private String excludeWorkOrderNo; +} 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 755fdfb..b346ceb 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 @@ -18,7 +18,7 @@ import java.util.Date; /** * 询价工单实体,对应表 inq_ticket。 *

- * 职责:持久化六态工单主事实;列表/详情/看板聚合均以此表为真相源之一。 + * 职责:持久化六态工单主事实(含 quote_date 报价日期);列表/详情/看板聚合均以此表为真相源之一。 * 副作用:写库经 Service;禁止前端本地改 status 冒充主账成功。 */ @Data diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/package-info.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/package-info.java index 2d2b38c..0697ab1 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/package-info.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/package-info.java @@ -1,8 +1,8 @@ /** * 友通达新系统 · 询价主账手写模块(从零实现)。 *

- * 本期能力:分析看板、工单六态、员工企微岗位、机器人回复语、报价模板(逻辑头+版本+映射)。 - * 排除:识别任务、存档死信、Graph/自然语言抽取。 + * 本期能力:分析看板、工单六态、员工企微岗位、机器人回复语、报价模板(逻辑头+版本+映射)、智能体建单、经本模块查真 TMS。 + * 排除:识别任务、报价生成、存档死信、Graph/自然语言抽取。 * 数据只写 inquiry_robot;禁止连现网 jeecg-boot。 */ package org.jeecg.modules.inquiry; diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryQuoteTemplateService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryQuoteTemplateService.java index 6eee5eb..8d7127f 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryQuoteTemplateService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryQuoteTemplateService.java @@ -54,4 +54,14 @@ public interface IInquiryQuoteTemplateService extends IService> listEnabledForAgent(boolean enabledOnly); + + /** + * 智能体出单:询价需求匹配模板关键词,否则同业务默认模板,再否则失败。 + *

+ * 只填 Excel 并回文件字节,不转 PDF(LibreOffice 在智能体 Worker 单槽)。 + * 失败 message 固定为 QuoteTemplateMatcher.FAIL_MESSAGE。 + */ + Map renderForAgent(String workOrderNo, String businessLine, + Map facts, Map quote, + String salesWecomId); } 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 e2e5811..1013166 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 @@ -3,6 +3,7 @@ package org.jeecg.modules.inquiry.service; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.IService; +import org.jeecg.modules.inquiry.dto.AgentTicketCreateRequest; import org.jeecg.modules.inquiry.dto.TicketAbnormalRequest; import org.jeecg.modules.inquiry.dto.TicketTransitionRequest; import org.jeecg.modules.inquiry.entity.InquiryTicket; @@ -43,4 +44,28 @@ public interface IInquiryWorkOrderService extends IService { * 系统异常重试:累加 retry_count,记日志;真正重跑任务由 Worker 后续认领(本期只落主账状态)。 */ void retryAbnormal(TicketAbnormalRequest req, String operator); + + /** + * 智能体建单:每次出询价确认都新开一单,相同需求不复用未关闭工单。 + */ + Map createFromAgent(AgentTicketCreateRequest req); + + /** + * 全库相同询价内容检索:需求指纹一模一样才算,不限六态。 + * 多条只回创建时间最新的一条;excludeWorkOrderNo 排除当前刚建的单。 + * 只供卡片展示「首次询价 / 引用历史工单号」,不复用当前工单号。 + */ + Map findSimilarOpen(AgentTicketCreateRequest req); + + /** + * 经主账查真 TMS(智能体入口)。 + * 分类:有价 / 暂无报价 / 技术失败。不做本地字典拦查价。不再返回 fixture 假价。 + */ + Map queryTms(Map body); + + /** + * 智能体续办:按工单号取字段/报价/推断停点。只读。 + * 必须校验 salesWecomId 与工单发起人一致。 + */ + Map getForAgent(String workOrderNo, String salesWecomId); } 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 7b2251c..3f330eb 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 @@ -17,6 +17,7 @@ import org.jeecg.modules.inquiry.mapper.InquiryQuoteTemplateMapper; import org.jeecg.modules.inquiry.mapper.InquiryQuoteTemplateVersionMapper; import org.jeecg.modules.inquiry.service.IInquiryQuoteTemplateService; import org.jeecg.modules.inquiry.template.QuoteTemplateExcelSupport; +import org.jeecg.modules.inquiry.template.QuoteTemplateMatcher; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -458,6 +459,168 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl + * 副作用:写生成文件到 upload/inquiry/quote-render;成功则模板 useCount+1。 + * 线程:跟随 HTTP 请求线程,禁止在此调 LibreOffice。 + */ + @Override + public Map renderForAgent(String workOrderNo, String businessLine, + Map facts, Map quote, + String salesWecomId) { + Map fail = new LinkedHashMap<>(); + fail.put("ok", false); + fail.put("error", "NO_TEMPLATE"); + fail.put("message", QuoteTemplateMatcher.FAIL_MESSAGE); + + List> enabled = listEnabledForAgent(true); + Map picked = QuoteTemplateMatcher.pick(enabled, businessLine, facts, quote); + if (picked == null) { + log.warn("报价出单无模板 wo={} biz={}", workOrderNo, businessLine); + return fail; + } + String objectKey = String.valueOf(picked.getOrDefault("objectKey", "")); + String mappingJson = String.valueOf(picked.getOrDefault("mappingJson", "")); + if (StringUtils.isBlank(objectKey) || "null".equalsIgnoreCase(objectKey)) { + log.warn("报价出单模板无文件 wo={} template={}", workOrderNo, picked.get("templateId")); + return fail; + } + Path source; + try { + source = resolveObjectPath(objectKey); + } catch (Exception e) { + log.warn("报价出单找不到模板文件 wo={} err={}", workOrderNo, e.getMessage()); + return fail; + } + JSONObject mapping; + try { + mapping = StringUtils.isNotBlank(mappingJson) && !"null".equalsIgnoreCase(mappingJson) + ? JSON.parseObject(mappingJson) + : new JSONObject(); + } catch (Exception e) { + log.warn("报价出单 mappingJson 非法 wo={} err={}", workOrderNo, e.getMessage()); + return fail; + } + JSONObject fillFacts = buildFillFacts(workOrderNo, facts, quote, salesWecomId); + String safeNo = StringUtils.defaultIfBlank(workOrderNo, "WO").replaceAll("[^A-Za-z0-9_-]", ""); + Path targetDir = Paths.get(StringUtils.defaultIfBlank(uploadPath, "."), BIZ_PATH, "render"); + Path target = targetDir.resolve(safeNo + "-" + System.currentTimeMillis() + ".xlsx"); + try { + Files.createDirectories(targetDir); + QuoteTemplateExcelSupport.fillWorkbook(source, target, mapping, fillFacts); + byte[] bytes = Files.readAllBytes(target); + bumpUseCount(String.valueOf(picked.get("templateId"))); + Map ok = new LinkedHashMap<>(); + ok.put("ok", true); + ok.put("matchType", picked.get("matchType")); + ok.put("templateId", picked.get("templateId")); + ok.put("templateName", picked.get("name")); + ok.put("versionNo", picked.get("versionNo")); + ok.put("fileName", safeNo + "_quote.xlsx"); + ok.put("fileBase64", java.util.Base64.getEncoder().encodeToString(bytes)); + ok.put("fileSize", bytes.length); + return ok; + } catch (Exception e) { + log.error("报价出单填表失败 wo={} template={}", workOrderNo, picked.get("templateId"), e); + fail.put("error", "FILL_FAILED"); + return fail; + } + } + + /** + * 把询价中文键与报价费用投影成合同 systemFields,映射点两边都能取到。 + */ + public static JSONObject buildFillFacts(String workOrderNo, Map facts, + Map quote, String salesWecomId) { + JSONObject out = new JSONObject(true); + Map src = facts == null ? new LinkedHashMap<>() : facts; + Map q = quote == null ? new LinkedHashMap<>() : quote; + for (Map.Entry e : src.entrySet()) { + if (e.getKey() != null && e.getValue() != null) { + out.put(e.getKey(), String.valueOf(e.getValue())); + } + } + out.put("quote_no", StringUtils.defaultString(workOrderNo)); + out.put("pol", first(src, "起运港", "pol", "origin")); + out.put("origin", first(src, "起运港", "origin", "pol")); + out.put("pod", first(src, "目的港", "pod", "destination")); + out.put("destination", first(src, "目的港", "destination", "pod")); + out.put("commodity", first(src, "品名", "commodity")); + out.put("pieces", first(src, "件数", "pieces")); + out.put("weight_kg", first(src, "毛重", "weight_kg")); + out.put("volume_cbm", first(src, "体积", "volume_cbm")); + out.put("quote_date", first(src, "报价日期", "quote_date")); + if (StringUtils.isBlank(out.getString("quote_date"))) { + out.put("quote_date", new java.text.SimpleDateFormat("yyyy-MM-dd").format(new Date())); + } + out.put("valid_until", first(q, "valid_until")); + out.put("total_amount", first(q, "total", "total_amount")); + String currency = first(q, "currency"); + if (StringUtils.isNotBlank(currency)) { + out.put("currency", currency); + } + out.put("sales_name", StringUtils.defaultString(salesWecomId)); + JSONArray rows = QuoteTemplateExcelSupport.parseFeeRows(q.get("fee_lines")); + if (rows.isEmpty()) { + rows = QuoteTemplateExcelSupport.parseFeeRows(q.get("fee_rows")); + } + if (rows.isEmpty()) { + String freight = first(q, "freight"); + if (StringUtils.isNotBlank(freight) && !"-".equals(freight)) { + JSONObject one = new JSONObject(true); + one.put("name", "空运费"); + one.put("amount", freight); + if (StringUtils.isNotBlank(currency)) { + one.put("currency", currency); + } + rows.add(one); + } + } + if (!rows.isEmpty() && StringUtils.isNotBlank(currency)) { + for (int i = 0; i < rows.size(); i++) { + JSONObject one = rows.getJSONObject(i); + if (one != null && StringUtils.isBlank(one.getString("currency"))) { + one.put("currency", currency); + } + } + } + if (!rows.isEmpty()) { + out.put("fee_rows", rows); + } + return out; + } + + private static String first(Map src, String... keys) { + if (src == null) { + return ""; + } + for (String k : keys) { + Object v = src.get(k); + if (v != null && StringUtils.isNotBlank(String.valueOf(v))) { + return String.valueOf(v).trim(); + } + } + return ""; + } + + private void bumpUseCount(String templateId) { + if (StringUtils.isBlank(templateId) || "null".equalsIgnoreCase(templateId)) { + return; + } + try { + InquiryQuoteTemplate t = getById(templateId); + if (t == null) { + return; + } + t.setUseCount((t.getUseCount() == null ? 0 : t.getUseCount()) + 1); + t.setUpdateTime(new Date()); + updateById(t); + } catch (Exception e) { + log.warn("报价模板 useCount 未写入 templateId={} err={}", templateId, e.getMessage()); + } + } + private InquiryQuoteTemplateVersion findEnabledOrLatest(String templateId) { InquiryQuoteTemplateVersion enabled = versionMapper.selectOne(new QueryWrapper() .eq("template_id", templateId) 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 585d85e..2b7ed21 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 @@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import org.apache.commons.lang3.StringUtils; import org.jeecg.modules.inquiry.domain.TicketStatuses; +import org.jeecg.modules.inquiry.dto.AgentTicketCreateRequest; import org.jeecg.modules.inquiry.dto.TicketAbnormalRequest; import org.jeecg.modules.inquiry.dto.TicketTransitionRequest; import org.jeecg.modules.inquiry.entity.InquiryAttachment; @@ -19,12 +20,18 @@ import org.jeecg.modules.inquiry.mapper.InquiryQuoteMapper; import org.jeecg.modules.inquiry.mapper.InquiryTicketMapper; import org.jeecg.modules.inquiry.mapper.InquiryTicketStatusLogMapper; import org.jeecg.modules.inquiry.service.IInquiryWorkOrderService; +import org.jeecg.modules.inquiry.tms.InquiryTmsQueryService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import java.math.BigDecimal; +import java.text.SimpleDateFormat; import java.util.Date; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -47,6 +54,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl pageView(Page page, Map filters) { @@ -208,6 +217,207 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl + * 每次出询价确认都分配新工单号并落「询价中」,相同起运/目的/品名不复用未关闭单。 + * 副作用:插入 inq_ticket 与时间线;不在本方法查出站 TMS。 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Map createFromAgent(AgentTicketCreateRequest req) { + if (req == null || StringUtils.isBlank(req.getSalesWecomId()) || StringUtils.isBlank(req.getBusinessLine())) { + throw new IllegalArgumentException("salesWecomId 与 businessLine 不能为空"); + } + // 每次出询价确认都新开一单,相同需求不复用未关闭工单。 + Map facts = req.getFacts() == null ? new HashMap<>() : req.getFacts(); + InquiryTicket ticket = new InquiryTicket(); + ticket.setWorkOrderNo(nextWorkOrderNo()); + ticket.setSalesWecomId(req.getSalesWecomId()); + ticket.setBusinessLine(req.getBusinessLine().trim().toUpperCase()); + ticket.setBizTypeLabel(bizLabel(ticket.getBusinessLine())); + ticket.setOrigin(facts.get("起运港")); + ticket.setDestination(facts.get("目的港")); + ticket.setGoodsName(facts.get("品名")); + ticket.setPieces(parseInt(facts.get("件数"))); + ticket.setWeightKg(parseDecimal(firstFact(facts, "重量", "毛重"))); + ticket.setVolumeCbm(parseDecimal(facts.get("体积"))); + ticket.setQuoteDate(parseDate(facts.get("报价日期"))); + ticket.setDemandTitle(ticket.getOrigin() + " → " + ticket.getDestination()); + ticket.setDemandDetail("FP:" + fingerprint(req)); + ticket.setStatus(TicketStatuses.INQUIRING); + ticket.setVersion(1); + ticket.setCreateBy("agent"); + ticket.setCreateTime(new Date()); + save(ticket); + appendLog(ticket, null, TicketStatuses.INQUIRING, "create", "agent", "智能体建单"); + // 本单已落库;检索须排除自己,否则「最新相同需求」永远是本单。 + AgentTicketCreateRequest look = new AgentTicketCreateRequest(); + look.setBusinessLine(ticket.getBusinessLine()); + look.setFacts(facts); + look.setExcludeWorkOrderNo(ticket.getWorkOrderNo()); + Map similar = findSimilarOpen(look); + Map out = new HashMap<>(); + out.put("ok", true); + out.put("created", true); + out.put("work_order_no", ticket.getWorkOrderNo()); + out.put("status", ticket.getStatus()); + out.put("found", similar.get("found")); + out.put("first_or_same", similar.get("first_or_same")); + out.put("similar_work_order_no", similar.get("work_order_no")); + return out; + } + + /** + * 全库按需求指纹检索:一模一样才引用;多条只取创建时间最新的一条。 + * 不限六态(成交/关闭也算历史);excludeWorkOrderNo 排除当前刚建的单。 + * 副作用:只读库。禁止在此复用或改写当前工单号。 + */ + @Override + public Map findSimilarOpen(AgentTicketCreateRequest req) { + Map out = new HashMap<>(); + out.put("found", false); + out.put("first_or_same", "first"); + out.put("work_order_no", ""); + out.put("status", ""); + if (req == null || StringUtils.isBlank(req.getBusinessLine())) { + return out; + } + String fp = "FP:" + fingerprint(req); + String exclude = StringUtils.trimToEmpty(req.getExcludeWorkOrderNo()); + List hits = list(new LambdaQueryWrapper() + .eq(InquiryTicket::getDemandDetail, fp) + .ne(StringUtils.isNotBlank(exclude), InquiryTicket::getWorkOrderNo, exclude) + .orderByDesc(InquiryTicket::getCreateTime) + .last("LIMIT 1")); + if (hits == null || hits.isEmpty()) { + hits = listByExactFacts(req, exclude); + } + if (hits == null || hits.isEmpty()) { + return out; + } + InquiryTicket hit = hits.get(0); + out.put("found", true); + out.put("first_or_same", "same"); + out.put("work_order_no", hit.getWorkOrderNo()); + out.put("status", hit.getStatus()); + return out; + } + + /** + * 指纹列对不上时的兜底:按已落库结构化字段逐项相等(件数/重量/体积/报价日期都要比)。 + * 仍不限六态,只要最新一条。 + */ + private List listByExactFacts(AgentTicketCreateRequest req, String exclude) { + Map facts = req.getFacts() == null ? new HashMap<>() : req.getFacts(); + Integer pieces = parseInt(facts.get("件数")); + BigDecimal weight = parseDecimal(firstFact(facts, "重量", "毛重")); + BigDecimal volume = parseDecimal(facts.get("体积")); + Date quoteDate = parseDate(facts.get("报价日期")); + return list(new LambdaQueryWrapper() + .eq(InquiryTicket::getBusinessLine, req.getBusinessLine().trim().toUpperCase()) + .eq(InquiryTicket::getOrigin, StringUtils.trimToEmpty(facts.get("起运港"))) + .eq(InquiryTicket::getDestination, StringUtils.trimToEmpty(facts.get("目的港"))) + .eq(InquiryTicket::getGoodsName, StringUtils.trimToEmpty(facts.get("品名"))) + .eq(pieces != null, InquiryTicket::getPieces, pieces) + .isNull(pieces == null, InquiryTicket::getPieces) + .eq(weight != null, InquiryTicket::getWeightKg, weight) + .isNull(weight == null, InquiryTicket::getWeightKg) + .eq(volume != null, InquiryTicket::getVolumeCbm, volume) + .isNull(volume == null, InquiryTicket::getVolumeCbm) + .eq(quoteDate != null, InquiryTicket::getQuoteDate, quoteDate) + .isNull(quoteDate == null, InquiryTicket::getQuoteDate) + .ne(StringUtils.isNotBlank(exclude), InquiryTicket::getWorkOrderNo, exclude) + .orderByDesc(InquiryTicket::getCreateTime) + .last("LIMIT 1")); + } + + @Override + public Map queryTms(Map body) { + return tmsQueryService.queryFromAgent(body); + } + + private String nextWorkOrderNo() { + String day = new SimpleDateFormat("yyyyMMdd").format(new Date()); + long n = count(new LambdaQueryWrapper().likeRight(InquiryTicket::getWorkOrderNo, "WO" + day)); + return String.format("WO%s%04d", day, n + 1); + } + + private static String bizLabel(String line) { + if ("AIR".equals(line)) { + return "空运"; + } + if ("SEA".equals(line)) { + return "海运"; + } + if ("LAND".equals(line)) { + return "陆运"; + } + return line; + } + + private static String fingerprint(AgentTicketCreateRequest req) { + Map facts = req.getFacts() == null ? new HashMap<>() : req.getFacts(); + return String.join("|", + StringUtils.trimToEmpty(req.getBusinessLine()).toUpperCase(), + StringUtils.trimToEmpty(facts.get("起运港")), + StringUtils.trimToEmpty(facts.get("目的港")), + StringUtils.trimToEmpty(facts.get("品名")), + StringUtils.trimToEmpty(facts.get("件数")), + StringUtils.trimToEmpty(firstFact(facts, "重量", "毛重")), + StringUtils.trimToEmpty(facts.get("体积")), + StringUtils.trimToEmpty(facts.get("报价日期"))); + } + + /** + * 取销售侧字段:先新口径(重量),再兼容旧口径(毛重)。 + */ + private static String firstFact(Map facts, String... keys) { + if (facts == null || keys == null) { + return ""; + } + for (String key : keys) { + String val = facts.get(key); + if (StringUtils.isNotBlank(val)) { + return val; + } + } + return ""; + } + + private static Integer parseInt(String raw) { + if (StringUtils.isBlank(raw)) { + return null; + } + String digits = raw.replaceAll("[^0-9]", ""); + if (digits.isEmpty()) { + return null; + } + return Integer.valueOf(digits); + } + + private static BigDecimal parseDecimal(String raw) { + if (StringUtils.isBlank(raw)) { + return null; + } + String cleaned = raw.replaceAll("[^0-9.]", ""); + if (cleaned.isEmpty()) { + return null; + } + return new BigDecimal(cleaned); + } + + private static Date parseDate(String raw) { + if (StringUtils.isBlank(raw)) { + return null; + } + try { + return new SimpleDateFormat("yyyy-MM-dd").parse(raw.trim()); + } catch (Exception e) { + return null; + } + } + private InquiryTicket requireTicket(TicketAbnormalRequest req) { if (req == null || StringUtils.isBlank(req.getWorkOrderNo())) { throw new IllegalArgumentException("工单号不能为空"); @@ -219,6 +429,77 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl getForAgent(String workOrderNo, String salesWecomId) { + Map out = new LinkedHashMap<>(); + String no = StringUtils.trimToEmpty(workOrderNo); + out.put("ok", false); + out.put("found", false); + out.put("owned", false); + out.put("work_order_no", no); + if (StringUtils.isBlank(no)) { + return out; + } + InquiryTicket ticket = findByWorkOrderNo(no); + if (ticket == null) { + return out; + } + out.put("found", true); + boolean owned = StringUtils.isNotBlank(salesWecomId) + && salesWecomId.equals(ticket.getSalesWecomId()); + out.put("owned", owned); + if (!owned) { + return out; + } + Map facts = new LinkedHashMap<>(); + facts.put("起运港", StringUtils.defaultString(ticket.getOrigin())); + facts.put("目的港", StringUtils.defaultString(ticket.getDestination())); + facts.put("品名", StringUtils.defaultString(ticket.getGoodsName())); + if (ticket.getPieces() != null) { + facts.put("件数", String.valueOf(ticket.getPieces())); + } + if (ticket.getWeightKg() != null) { + facts.put("毛重", ticket.getWeightKg().stripTrailingZeros().toPlainString()); + } + if (ticket.getVolumeCbm() != null) { + facts.put("体积", ticket.getVolumeCbm().stripTrailingZeros().toPlainString()); + } + if (ticket.getQuoteDate() != null) { + facts.put("报价日期", new SimpleDateFormat("yyyy-MM-dd").format(ticket.getQuoteDate())); + } + Map quote = new LinkedHashMap<>(); + List quotes = quoteMapper.selectList(new LambdaQueryWrapper() + .eq(InquiryQuote::getWorkOrderNo, no) + .orderByDesc(InquiryQuote::getCreateTime) + .last("LIMIT 1")); + if (quotes != null && !quotes.isEmpty() && StringUtils.isNotBlank(quotes.get(0).getPayloadJson())) { + JSONObject parsed = JSON.parseObject(quotes.get(0).getPayloadJson()); + if (parsed != null) { + quote.putAll(parsed); + } + } + String status = StringUtils.defaultString(ticket.getStatus()); + String waitPhase; + if (TicketStatuses.DEAL.equals(status) || TicketStatuses.LOST.equals(status) + || TicketStatuses.NEGOTIATING.equals(status) || TicketStatuses.CLOSED.equals(status)) { + waitPhase = "done"; + } else if (TicketStatuses.QUOTED.equals(status) && !quote.isEmpty()) { + waitPhase = "wait_adopt"; + } else { + waitPhase = "tms_miss"; + } + out.put("ok", true); + out.put("status", status); + out.put("business_line", StringUtils.defaultString(ticket.getBusinessLine())); + out.put("facts", facts); + out.put("quote", quote); + out.put("wait_phase", waitPhase); + return out; + } + 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/template/QuoteTemplateExcelSupport.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java index 9318bf4..3b883df 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateExcelSupport.java @@ -178,17 +178,19 @@ public final class QuoteTemplateExcelSupport { continue; } Object val = facts.get(code); - if (val == null) { - clearCell(wb, cellRef); + if ("fee_rows".equals(code)) { continue; } - if ("fee_rows".equals(code)) { + // 空值不覆盖模板原字,避免把单位格/币别格擦掉 + if (val == null || StringUtils.isBlank(String.valueOf(val))) { continue; } writeCell(wb, cellRef, String.valueOf(val)); } } writeFeeRows(wb, mapping, facts, yellow); + // 后台常未配 fee_rows:按「空运费」等名称写入金额列,SUM 才能加算 + writeFeeAmountsByName(wb, facts, yellow); Files.createDirectories(targetXlsx.getParent()); try (OutputStream out = Files.newOutputStream(targetXlsx)) { wb.write(out); @@ -201,11 +203,10 @@ public final class QuoteTemplateExcelSupport { if (feeRows == null) { return; } - Object raw = facts.get("fee_rows"); - if (!(raw instanceof JSONArray) && !(raw instanceof List)) { + JSONArray rows = parseFeeRows(facts.get("fee_rows")); + if (rows.isEmpty()) { return; } - JSONArray rows = raw instanceof JSONArray ? (JSONArray) raw : JSON.parseArray(JSON.toJSONString(raw)); String anchor = feeRows.getString("anchor"); JSONObject columns = feeRows.getJSONObject("columns"); if (StringUtils.isBlank(anchor) || columns == null || rows == null || rows.isEmpty()) { @@ -221,7 +222,13 @@ public final class QuoteTemplateExcelSupport { JSONObject rowObj = rows.getJSONObject(i); int r = startRow + i; writeFeeCol(sheet, r, columns.getString("name"), rowObj.getString("name"), yellow, sheet.getSheetName()); + writeFeeCol(sheet, r, columns.getString("unit"), rowObj.getString("unit"), yellow, sheet.getSheetName()); + writeFeeCol(sheet, r, columns.getString("quantity"), rowObj.getString("quantity"), yellow, sheet.getSheetName()); + writeFeeCol(sheet, r, columns.getString("unit_price"), rowObj.getString("unit_price"), yellow, sheet.getSheetName()); writeFeeCol(sheet, r, columns.getString("amount"), rowObj.getString("amount"), yellow, sheet.getSheetName()); + if (columns.containsKey("currency")) { + writeFeeCol(sheet, r, columns.getString("currency"), rowObj.getString("currency"), yellow, sheet.getSheetName()); + } if (columns.containsKey("remark")) { writeFeeCol(sheet, r, columns.getString("remark"), rowObj.getString("remark"), yellow, sheet.getSheetName()); } @@ -252,6 +259,311 @@ public final class QuoteTemplateExcelSupport { writeCell(wb, cellRef, ""); } + /** + * 按费用名称把 TMS 费用行写进模板:单位 / 币别 / 单价 / 数量 / 金额 / 备注。 + * 映射没有 fee_rows 时也能对上「空运费」等已有行。没有的字段不编、不覆盖模板原字。 + */ + public static void writeFeeAmountsByName(Workbook wb, JSONObject facts, Set yellow) { + JSONArray rows = parseFeeRows(facts == null ? null : facts.get("fee_rows")); + if (rows == null || rows.isEmpty()) { + return; + } + String headerCurrency = facts == null ? "" : StringUtils.trimToEmpty(facts.getString("currency")); + for (int s = 0; s < wb.getNumberOfSheets(); s++) { + Sheet sheet = wb.getSheetAt(s); + Map cols = findFeeTableColumns(sheet); + if (cols.isEmpty()) { + cols = fallbackFeeColumns(sheet); + } + Integer amountCol = cols.get("amount"); + if (amountCol == null) { + amountCol = 6; + } + for (int i = 0; i < rows.size(); i++) { + JSONObject rowObj = rows.getJSONObject(i); + if (rowObj == null) { + continue; + } + String name = StringUtils.trimToEmpty(rowObj.getString("name")); + String amount = StringUtils.trimToEmpty(rowObj.getString("amount")); + if (StringUtils.isBlank(name) || StringUtils.isBlank(amount) || "-".equals(amount)) { + continue; + } + Cell nameCell = findCellByFeeName(sheet, name); + if (nameCell == null) { + continue; + } + int r = nameCell.getRowIndex(); + writeAmountCell(sheet, r, amountCol, amount, yellow); + String lineCurrency = StringUtils.trimToEmpty(rowObj.getString("currency")); + if (StringUtils.isBlank(lineCurrency)) { + lineCurrency = headerCurrency; + } + writeMappedText(sheet, r, cols.get("currency"), lineCurrency, yellow); + writeMappedText(sheet, r, cols.get("unit"), rowObj.getString("unit"), yellow); + writeMappedNumber(sheet, r, cols.get("quantity"), rowObj.getString("quantity"), yellow); + writeMappedNumber(sheet, r, cols.get("unit_price"), rowObj.getString("unit_price"), yellow); + writeMappedText(sheet, r, cols.get("remark"), rowObj.getString("remark"), yellow); + } + } + } + + /** + * 只认「费用名称 + 金额」那一行表头,避免把货物信息里的件/KG 当成费用列。 + * 币别和数量中间的空列或 CNY 列,按现网空运模板当成单价。 + */ + static Map findFeeTableColumns(Sheet sheet) { + Map cols = new LinkedHashMap<>(); + if (sheet == null) { + return cols; + } + int headerRow = -1; + int lastCol = 0; + int maxRow = Math.min(sheet.getLastRowNum(), 40); + for (int r = 0; r <= maxRow; r++) { + Row row = sheet.getRow(r); + if (row == null) { + continue; + } + boolean hasAmount = false; + boolean hasFeeName = false; + short last = row.getLastCellNum(); + for (int c = 0; c < last; c++) { + String text = readCellText(row.getCell(c)); + if (text.contains("金额") || text.contains("金額") || "amount".equalsIgnoreCase(text)) { + hasAmount = true; + } + if (text.contains("费用名称") || text.contains("费用名")) { + hasFeeName = true; + } + } + if (hasAmount && hasFeeName) { + headerRow = r; + lastCol = last; + break; + } + } + if (headerRow < 0) { + return cols; + } + Row row = sheet.getRow(headerRow); + for (int c = 0; c < lastCol; c++) { + String text = StringUtils.trimToEmpty(readCellText(row.getCell(c))); + if (text.contains("费用名称") || text.contains("费用名")) { + cols.put("name", c); + } else if (text.contains("单位") || text.contains("單位")) { + cols.put("unit", c); + } else if (text.contains("币别") || text.contains("币種") || text.contains("币种")) { + cols.put("currency", c); + } else if (text.contains("单价") || text.contains("單價")) { + cols.put("unit_price", c); + } else if (text.contains("数量") || text.contains("數量")) { + cols.put("quantity", c); + } else if (text.contains("金额") || text.contains("金額") || "amount".equalsIgnoreCase(text)) { + cols.put("amount", c); + } else if (text.contains("备注") || text.contains("備註")) { + cols.put("remark", c); + } else if (StringUtils.isBlank(text) && cols.containsKey("currency") && !cols.containsKey("quantity") + && !cols.containsKey("unit_price")) { + cols.put("unit_price", c); + } else if (("CNY".equalsIgnoreCase(text) || "USD".equalsIgnoreCase(text) || "RMB".equalsIgnoreCase(text)) + && cols.containsKey("currency") && !cols.containsKey("unit_price")) { + cols.put("unit_price", c); + } + } + return cols; + } + + private static Map fallbackFeeColumns(Sheet sheet) { + Map cols = new LinkedHashMap<>(); + putCol(cols, "amount", findHeaderColumn(sheet, Arrays.asList("金额", "金額", "amount"))); + putCol(cols, "currency", findHeaderColumn(sheet, Arrays.asList("币别", "币種", "币种"))); + putCol(cols, "unit", findHeaderColumn(sheet, Arrays.asList("单位", "單位"))); + putCol(cols, "quantity", findHeaderColumn(sheet, Arrays.asList("数量", "數量"))); + putCol(cols, "unit_price", findHeaderColumn(sheet, Arrays.asList("单价", "單價"))); + putCol(cols, "remark", findHeaderColumn(sheet, Arrays.asList("备注", "備註"))); + if (!cols.containsKey("amount")) { + cols.put("amount", 6); + } + return cols; + } + + private static void putCol(Map cols, String key, int col) { + if (col >= 0) { + cols.put(key, col); + } + } + + private static void writeMappedText(Sheet sheet, int rowIdx, Integer col, String value, Set yellow) { + if (col == null || StringUtils.isBlank(value) || "-".equals(value.trim())) { + return; + } + writeTextCell(sheet, rowIdx, col, value.trim(), yellow); + } + + private static void writeMappedNumber(Sheet sheet, int rowIdx, Integer col, String value, Set yellow) { + if (col == null || StringUtils.isBlank(value) || "-".equals(value.trim())) { + return; + } + writeAmountCell(sheet, rowIdx, col, value, yellow); + } + + /** + * fee_lines / fee_rows:List、JSONArray、JSON 字符串、二次编码字符串都认。 + */ + public static JSONArray parseFeeRows(Object raw) { + if (raw == null) { + return new JSONArray(); + } + if (raw instanceof JSONArray) { + return (JSONArray) raw; + } + if (raw instanceof List) { + return JSON.parseArray(JSON.toJSONString(raw)); + } + String text = String.valueOf(raw).trim(); + if (text.isEmpty() || "null".equalsIgnoreCase(text) || "-".equals(text)) { + return new JSONArray(); + } + try { + Object parsed = JSON.parse(text); + if (parsed instanceof JSONArray) { + return (JSONArray) parsed; + } + if (parsed instanceof String) { + Object inner = JSON.parse((String) parsed); + if (inner instanceof JSONArray) { + return (JSONArray) inner; + } + } + } catch (Exception e) { + log.warn("费用行 JSON 无法解析: {}", e.getMessage()); + } + return new JSONArray(); + } + + private static int findHeaderColumn(Sheet sheet, List titles) { + int maxRow = Math.min(sheet.getLastRowNum(), 40); + for (int r = 0; r <= maxRow; r++) { + Row row = sheet.getRow(r); + if (row == null) { + continue; + } + short last = row.getLastCellNum(); + for (int c = 0; c < last; c++) { + Cell cell = row.getCell(c); + String text = readCellText(cell); + if (StringUtils.isBlank(text)) { + continue; + } + for (String t : titles) { + if (text.contains(t)) { + return c; + } + } + } + } + return -1; + } + + private static Cell findCellByFeeName(Sheet sheet, String name) { + String want = name.trim(); + Cell fuzzy = null; + int maxRow = Math.min(sheet.getLastRowNum(), 80); + for (int r = 0; r <= maxRow; r++) { + Row row = sheet.getRow(r); + if (row == null) { + continue; + } + short last = row.getLastCellNum(); + for (int c = 0; c < last; c++) { + Cell cell = row.getCell(c); + String text = readCellText(cell); + if (StringUtils.isBlank(text)) { + continue; + } + if (want.equals(text.trim())) { + return cell; + } + if (fuzzy == null && text.contains(want) && want.length() >= 2) { + fuzzy = cell; + } + } + } + return fuzzy; + } + + private static String readCellText(Cell cell) { + if (cell == null) { + return ""; + } + DataFormatter fmt = new DataFormatter(); + return StringUtils.trimToEmpty(fmt.formatCellValue(cell)); + } + + private static void writeAmountCell(Sheet sheet, int rowIdx, int col, String amount, Set yellow) { + int[] dest = resolveMergeOrigin(sheet, rowIdx, col); + String ref = sheet.getSheetName() + "!" + new CellReference(dest[0], dest[1]).formatAsString(); + if (yellow != null && yellow.contains(ref)) { + return; + } + Row row = sheet.getRow(dest[0]); + if (row == null) { + row = sheet.createRow(dest[0]); + } + Cell cell = row.getCell(dest[1]); + if (cell == null) { + cell = row.createCell(dest[1]); + } + String norm = stripMoneyPrefix(amount); + try { + cell.setCellValue(Double.parseDouble(norm)); + } catch (NumberFormatException e) { + cell.setCellValue(amount); + } + } + + /** + * 去掉「CNY 14669.60」前缀,让金额列仍能写成数字给 SUM。 + */ + static String stripMoneyPrefix(String amount) { + if (amount == null) { + return ""; + } + String norm = amount.replace(",", "").replace(",", "").trim(); + if (norm.matches("^[A-Za-z]{3}\\s+.+")) { + return norm.substring(4).trim(); + } + return norm; + } + + private static void writeTextCell(Sheet sheet, int rowIdx, int col, String value, Set yellow) { + int[] dest = resolveMergeOrigin(sheet, rowIdx, col); + String ref = sheet.getSheetName() + "!" + new CellReference(dest[0], dest[1]).formatAsString(); + if (yellow != null && yellow.contains(ref)) { + return; + } + Row row = sheet.getRow(dest[0]); + if (row == null) { + row = sheet.createRow(dest[0]); + } + Cell cell = row.getCell(dest[1]); + if (cell == null) { + cell = row.createCell(dest[1]); + } + cell.setCellValue(value == null ? "" : value); + } + + private static int[] resolveMergeOrigin(Sheet sheet, int row, int col) { + for (int i = 0; i < sheet.getNumMergedRegions(); i++) { + org.apache.poi.ss.util.CellRangeAddress range = sheet.getMergedRegion(i); + if (range.isInRange(row, col)) { + return new int[]{range.getFirstRow(), range.getFirstColumn()}; + } + } + return new int[]{row, col}; + } + private static void writeCell(Workbook wb, String cellRef, String value) { ParsedRef ref = ParsedRef.parse(cellRef); Sheet sheet = wb.getSheet(ref.sheet); @@ -261,15 +573,41 @@ public final class QuoteTemplateExcelSupport { if (sheet == null) { return; } - Row row = sheet.getRow(ref.row); + int[] dest = resolveMergeOrigin(sheet, ref.row, ref.col); + Row row = sheet.getRow(dest[0]); if (row == null) { - row = sheet.createRow(ref.row); + row = sheet.createRow(dest[0]); } - Cell cell = row.getCell(ref.col); + Cell cell = row.getCell(dest[1]); if (cell == null) { - cell = row.createCell(ref.col); + cell = row.createCell(dest[1]); + } + String text = value == null ? "" : value; + // 标签格「报价编号:」只换冒号后的值,避免整格被工单号顶掉看起来像残缺 + String existing = readCellText(cell); + int colon = Math.max(existing.indexOf(':'), existing.indexOf(':')); + if (colon >= 0 && colon == existing.length() - 1) { + text = existing + text; + } + cell.setCellValue(text); + CellStyle style = cell.getCellStyle(); + if (style != null && text.length() >= 10) { + CellStyle copy = wb.createCellStyle(); + copy.cloneStyleFrom(style); + copy.setShrinkToFit(true); + copy.setWrapText(false); + copy.setDataFormat(wb.createDataFormat().getFormat("@")); + cell.setCellStyle(copy); + } + // 工单号、日期略加宽,避免列太窄只露出半截 + if (dest[1] >= 0 && dest[1] < 20 && text.length() >= 8) { + org.apache.poi.ss.usermodel.Sheet sh = sheet; + int col = dest[1]; + double width = sh.getColumnWidth(col) / 256.0; + if (width > 0 && width < text.length() + 2) { + sh.setColumnWidth(col, (int) Math.min(40, text.length() + 4) * 256); + } } - cell.setCellValue(value == null ? "" : value); } private static String resolveFillTarget(Sheet sheet, Cell labelCell, String sheetName, String text, DataFormatter fmt) { 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 new file mode 100644 index 0000000..edf1e30 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateMatcher.java @@ -0,0 +1,196 @@ +package org.jeecg.modules.inquiry.template; + +import org.apache.commons.lang3.StringUtils; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * 报价模板匹配:询价需求文本对模板关键词,都未命中则同业务默认模板。 + *

+ * 职责:纯函数,不读库、不填表、不转 PDF。智能体出单与单测共用同一套规则。 + * 禁止:在此发明价格;禁止按 userid 分叉。 + */ +public final class QuoteTemplateMatcher { + + /** 关键词与默认都未维护时,销售可见固定话术(产品口径,勿改字)。 */ + public static final String FAIL_MESSAGE = "生成报价单失败,请联系IT运维维护后台报价模板。"; + + private QuoteTemplateMatcher() { + } + + /** + * 业务线归一:AIR/空 → 空运,SEA/海 → 海运,LAND/陆 → 陆运。 + */ + public static String normalizeBiz(String raw) { + if (StringUtils.isBlank(raw)) { + return ""; + } + String t = raw.trim(); + String lower = t.toLowerCase(Locale.ROOT); + if ("air".equals(lower) || "空".equals(t) || t.contains("空运") || "AIR".equals(t)) { + return "空运"; + } + if ("sea".equals(lower) || "海".equals(t) || t.contains("海运") || "SEA".equals(t)) { + return "海运"; + } + if ("land".equals(lower) || "陆".equals(t) || t.contains("陆运") || "LAND".equals(t)) { + return "陆运"; + } + return t; + } + + /** + * 拼匹配文本:已冻结询价字段 + 业务线 + 报价费用名。 + */ + public static String buildHaystack(String businessLine, Map facts, Map quote) { + StringBuilder sb = new StringBuilder(); + append(sb, businessLine); + append(sb, normalizeBiz(businessLine)); + if (facts != null) { + for (Map.Entry e : facts.entrySet()) { + append(sb, e.getKey()); + append(sb, e.getValue()); + } + } + if (quote != null) { + append(sb, quote.get("source")); + append(sb, quote.get("source_label")); + append(sb, quote.get("freight")); + append(sb, quote.get("total")); + Object lines = quote.get("fee_lines"); + if (lines instanceof Collection) { + for (Object row : (Collection) lines) { + if (row instanceof Map) { + append(sb, ((Map) row).get("name")); + append(sb, ((Map) row).get("amount")); + } else { + append(sb, row); + } + } + } + } + return sb.toString(); + } + + /** + * 先扫同业务线关键词(列表已按默认优先排序),再退回该业务默认模板。 + * + * @return 命中的模板 Map,并写入 matchType=keyword|default;都没有则 null + */ + public static Map pick( + List> templates, + String businessLine, + Map facts, + Map quote + ) { + String want = normalizeBiz(businessLine); + String haystack = buildHaystack(businessLine, facts, quote).toLowerCase(Locale.ROOT); + Map keywordHit = null; + Map defaultHit = null; + if (templates == null) { + return null; + } + for (Map t : templates) { + if (t == null) { + continue; + } + String biz = normalizeBiz(String.valueOf(t.getOrDefault("bizType", ""))); + if (StringUtils.isNotBlank(want) && StringUtils.isNotBlank(biz) && !want.equals(biz)) { + continue; + } + if (defaultHit == null && isDefault(t.get("isDefault"))) { + defaultHit = t; + } + if (keywordHit == null && keywordsHit(t.get("keywords"), haystack)) { + keywordHit = t; + } + } + if (keywordHit != null) { + return withMatchType(keywordHit, "keyword"); + } + if (defaultHit != null) { + return withMatchType(defaultHit, "default"); + } + return null; + } + + private static boolean keywordsHit(Object raw, String haystack) { + for (String kw : asKeywords(raw)) { + if (haystack.contains(kw.toLowerCase(Locale.ROOT))) { + return true; + } + } + return false; + } + + /** + * 模板 keywordsJson / 列表字段转关键词;空串不参与匹配。 + */ + public static List asKeywords(Object raw) { + List out = new ArrayList<>(); + if (raw == null) { + return out; + } + if (raw instanceof Collection) { + for (Object it : (Collection) raw) { + String s = it == null ? "" : String.valueOf(it).trim(); + if (StringUtils.isNotBlank(s)) { + out.add(s); + } + } + return out; + } + String text = String.valueOf(raw).trim(); + if (text.isEmpty() || "null".equalsIgnoreCase(text)) { + return out; + } + if (text.startsWith("[")) { + String inner = text.substring(1, text.endsWith("]") ? text.length() - 1 : text.length()); + for (String part : inner.split(",")) { + String s = part.trim().replace("\"", "").replace("'", ""); + if (StringUtils.isNotBlank(s)) { + out.add(s); + } + } + return out; + } + out.add(text); + return out; + } + + private static boolean isDefault(Object raw) { + if (raw instanceof Boolean) { + return Boolean.TRUE.equals(raw); + } + if (raw instanceof Number) { + return ((Number) raw).intValue() == 1; + } + String s = raw == null ? "" : String.valueOf(raw).trim(); + return "1".equals(s) || "true".equalsIgnoreCase(s); + } + + private static Map withMatchType(Map src, String matchType) { + Map copy = new LinkedHashMap<>(src); + copy.put("matchType", matchType); + return copy; + } + + private static void append(StringBuilder sb, Object val) { + if (val == null) { + return; + } + String s = String.valueOf(val).trim(); + if (s.isEmpty() || "null".equalsIgnoreCase(s)) { + return; + } + if (sb.length() > 0) { + sb.append(' '); + } + sb.append(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 new file mode 100644 index 0000000..5a05632 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java @@ -0,0 +1,480 @@ +package org.jeecg.modules.inquiry.tms; + +import com.alibaba.fastjson.JSON; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.jeecg.modules.inquiry.domain.TicketStatuses; +import org.jeecg.modules.inquiry.dto.TicketTransitionRequest; +import org.jeecg.modules.inquiry.entity.InquiryQuote; +import org.jeecg.modules.inquiry.entity.InquiryTicket; +import org.jeecg.modules.inquiry.mapper.InquiryQuoteMapper; +import org.jeecg.modules.inquiry.service.IInquiryWorkOrderService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TimeZone; + +/** + * 智能体查价门面:询价必填齐了就必须打真 TMS,再按 TMS 回包分类。 + *

+ * 职责:销售侧起运/目的原文、品名、件数、重量、体积、包装、报价日期、工单号齐了就出站。 + * 机场三字码能投影就带上;对不上也必须出站,禁止本地判「暂无报价」。 + * 分类:1 且有价 → 报价;TMS 已回 1002 / 无 quoteFound / 1003 → 暂无报价; + * HTTP 超时/熔断/5xx → 技术失败。 + * 禁止:因 originCode 对不上就 skip;fixture 假价;LOCK/RELEASE;Python 直连 TMS。 + */ +@Slf4j +@Service +public class InquiryTmsQueryService { + + @Autowired + private TmsProperties properties; + @Autowired + private TmsGatewayClient gateway; + @Autowired + private TmsExactProjector projector; + @Autowired + private InquiryQuoteMapper quoteMapper; + @Autowired + @Lazy + private IInquiryWorkOrderService workOrderService; + + /** + * @param body 智能体请求:work_order_no + facts(内部中文键) + * @return 给智能体的分类 envelope + */ + 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(); + + if (!properties.queryConfigured() || !gateway.isConfigured()) { + return tech("TMS 未配置,拒绝假价"); + } + + Map segment = new LinkedHashMap<>(); + segment.put("sequence", 1); + segment.put("transportMode", line); + String origin = text(facts.get("起运港")); + String destination = text(facts.get("目的港")); + 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("包装类型")))); + + String quoteDate = TmsV2RequestMapper.normalizeQuoteDate(facts.get("报价日期")); + if (quoteDate == null) { + SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd"); + fmt.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai")); + quoteDate = fmt.format(new Date()); + } + + List missing = missingAirQuery(workOrderNo, quoteDate, segment); + if (!missing.isEmpty()) { + log.warn("tms.query skip incomplete wo={} missing={}", workOrderNo, missing); + return incomplete(missing); + } + + log.info( + "tms.query outbound wo={} origin={} originCode={} dest={} destCode={} cargo={}", + workOrderNo, + origin, + text(segment.get("originCode")), + destination, + text(segment.get("destinationCode")), + text(segment.get("cargoName"))); + + Map internal = new LinkedHashMap<>(); + internal.put("workOrderNo", workOrderNo); + internal.put("transportMode", line); + internal.put("quoteDate", quoteDate); + List> segments = new ArrayList<>(); + segments.add(segment); + internal.put("segments", segments); + + String cacheKey = line + ":" + quoteDate + ":" + origin + ":" + destination + + ":" + segment.get("weightKg") + ":" + segment.get("volumeCbm"); + try { + Map tms = gateway.query(internal, cacheKey); + return classifyAndPersist(workOrderNo, tms); + } catch (RuntimeException ex) { + log.warn("tms.query tech wo={} err={}", workOrderNo, ex.getMessage()); + return tech(ex.getMessage()); + } + } + + /** + * 只处理「已经打过 TMS」的回包。 + * 1002 / 1003 / 无 quoteFound → 暂无报价;未出站的 incomplete 不得走进这里。 + */ + private Map classifyAndPersist(String workOrderNo, Map tms) { + int code = TmsV2ResponseNormalizer.number(tms.get("code")); + Map out = new LinkedHashMap<>(); + out.put("tmsRequestId", text(first(tms.get("traceId"), tms.get("requestId")))); + out.put("code", code); + out.put("source", "TMS"); + out.put("message", text(tms.get("message"))); + + if (code == 1002 || code == 1003) { + // 已经打过 TMS:1002=无匹配,1003=TMS 说缺项。都按合同收成暂无报价。 + // 禁止把 1003 收成「未出站 / 参数不齐」,销售会误以为没查。 + out.put("ok", true); + out.put("has_price", false); + out.put("classification", "TMS_NO_QUOTE_1002"); + out.put("quote", new HashMap()); + return out; + } + if (code != 1) { + return tech("TMS 业务码=" + code); + } + + Map quote = toAgentQuote(tms); + boolean found = Boolean.TRUE.equals(tms.get("quoteFound")) + || StringUtils.isNotBlank(quote.get("total")); + if (!found) { + out.put("ok", true); + out.put("has_price", false); + out.put("classification", "TMS_NO_QUOTE_1002"); + out.put("quote", new HashMap()); + return out; + } + persistHit(workOrderNo, quote); + out.put("ok", true); + out.put("has_price", true); + out.put("classification", "HAS_PRICE"); + out.put("quote", quote); + return out; + } + + private void persistHit(String workOrderNo, Map quote) { + if (StringUtils.isBlank(workOrderNo)) { + return; + } + InquiryTicket ticket = workOrderService.getOne( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(InquiryTicket::getWorkOrderNo, workOrderNo) + .last("LIMIT 1")); + if (ticket == null) { + return; + } + InquiryQuote row = new InquiryQuote(); + row.setTicketId(ticket.getId()); + row.setWorkOrderNo(workOrderNo); + row.setQuoteVersion(1); + row.setSource("TMS"); + row.setSourceLabel("TMS 标准报价"); + // 后台售价栏必须带 TMS 币种,不能只落纯数字。 + row.setPriceText(TmsQuoteCurrency.formatDisplay(quote.get("currency"), quote.get("total"))); + row.setHighlighted(1); + row.setPayloadJson(JSON.toJSONString(quote)); + row.setCreateTime(new Date()); + quoteMapper.insert(row); + // 后台列表/详情读主表售价;只写子表的话工单页看起来是空的。 + applyQuoteToTicket(ticket, quote); + workOrderService.updateById(ticket); + if (TicketStatuses.canTransit(ticket.getStatus(), TicketStatuses.QUOTED)) { + TicketTransitionRequest tr = new TicketTransitionRequest(); + tr.setWorkOrderNo(workOrderNo); + tr.setToStatus(TicketStatuses.QUOTED); + workOrderService.transition(tr, "agent"); + } + } + + /** + * 把 TMS 规范化回包投影成智能体 quote。单测可直接调。 + * 金额保持数字原文;币种单独落 currency / fee_lines.currency,禁止默认 CNY。 + */ + @SuppressWarnings("unchecked") + static Map toAgentQuote(Map tms) { + Map quote = new LinkedHashMap<>(); + Object tmsData = tms.get("tmsData"); + Map data = tmsData instanceof Map ? (Map) tmsData : tms; + String headerCurrency = TmsQuoteCurrency.fromHeader(data); + TmsQuoteCurrency.Money totalMoney = TmsQuoteCurrency.split( + firstText(data.get("totalSalePrice"), data.get("salePrice"), data.get("totalCostPrice")), + headerCurrency); + if (StringUtils.isBlank(headerCurrency) && StringUtils.isNotBlank(totalMoney.currency)) { + headerCurrency = totalMoney.currency; + } + String total = totalMoney.amount; + String eta = firstText(data.get("transitTime"), data.get("transitDaysDisplay"), + data.get("transitDays"), data.get("aging"), data.get("estimatedDelivery")); + String etaDate = firstText(data.get("estimatedDelivery"), data.get("etaDate"), data.get("arriveDate")); + String validUntil = firstText( + data.get("validUntil"), data.get("quoteValidUntil"), data.get("validDate"), + data.get("expireDate"), data.get("expiryDate"), data.get("priceValidUntil"), + data.get("quoteExpireDate"), data.get("validTo"), data.get("validityDate"), + data.get("quoteValidity"), data.get("validityPeriod")); + if (StringUtils.isBlank(validUntil)) { + Object nested = data.get("quote"); + if (nested instanceof Map) { + Map q = (Map) nested; + validUntil = firstText(q.get("validUntil"), q.get("quoteValidUntil"), + q.get("expireDate"), q.get("validDate"), q.get("validTo")); + } + } + TmsQuoteCurrency.Money freightMoney = TmsQuoteCurrency.split( + firstText(data.get("freight"), data.get("airFreight"), data.get("oceanFreight"), total), + headerCurrency); + TmsQuoteCurrency.Money otherMoney = TmsQuoteCurrency.split( + firstText(data.get("otherFee"), data.get("sundryFee")), headerCurrency); + TmsQuoteCurrency.Money costMoney = TmsQuoteCurrency.split( + firstText(data.get("totalCostPrice"), data.get("costPrice"), data.get("cost")), + headerCurrency); + String profit = firstText(data.get("profitRate"), data.get("grossProfitRate"), data.get("margin")); + quote.put("freight", StringUtils.isBlank(freightMoney.amount) ? "-" : freightMoney.amount); + quote.put("other_fee", StringUtils.isBlank(otherMoney.amount) ? "-" : otherMoney.amount); + quote.put("total", StringUtils.isBlank(total) ? "-" : total); + if (StringUtils.isNotBlank(headerCurrency)) { + quote.put("currency", headerCurrency); + } + if (StringUtils.isNotBlank(costMoney.amount)) { + quote.put("cost", costMoney.amount); + } + if (StringUtils.isNotBlank(profit)) { + quote.put("profit_rate", profit); + } + quote.put("eta", StringUtils.isBlank(eta) ? "-" : eta); + if (StringUtils.isNotBlank(etaDate) && !etaDate.equals(eta)) { + quote.put("eta_date", etaDate); + } + // 报价有效期只取 TMS 原文,没有就给 "-",禁止本地编日期。 + quote.put("valid_until", StringUtils.isBlank(validUntil) ? "-" : validUntil); + quote.put("source", "TMS"); + quote.put("source_label", "TMS 标准报价"); + quote.put("fee_lines", buildFeeLinesJson(data, freightMoney.amount, otherMoney.amount, headerCurrency)); + return quote; + } + + /** + * 把本次 TMS 报价写回工单主表,供后台列表/详情展示。 + * 没有的成本/毛利率不编;售价用销售合计。 + */ + private static void applyQuoteToTicket(InquiryTicket ticket, Map quote) { + if (ticket == null || quote == null) { + return; + } + String sale = TmsQuoteCurrency.formatDisplay(quote.get("currency"), quote.get("total")); + if (StringUtils.isNotBlank(sale) && !"-".equals(sale)) { + ticket.setSalePrice(sale); + } + String cost = text(quote.get("cost")); + if (StringUtils.isNotBlank(cost) && !"-".equals(cost)) { + ticket.setCostPrice(cost); + } + String profit = text(quote.get("profit_rate")); + if (StringUtils.isNotBlank(profit) && !"-".equals(profit)) { + ticket.setProfitRate(profit); + } + ticket.setTmsHit(1); + } + + /** + * 抽出销售侧价格明细行。优先 TMS 费用清单,没有再用常见费用字段兜底。 + * 每行带自己的币种;没有就继承表头,仍然不编造。 + */ + @SuppressWarnings("unchecked") + private static String buildFeeLinesJson(Map data, String freight, String other, String headerCurrency) { + List> lines = new ArrayList<>(); + Object raw = first(data.get("feeItems"), data.get("feeList"), data.get("chargeList"), data.get("costItems")); + if (raw instanceof List) { + for (Object item : (List) raw) { + if (!(item instanceof Map)) { + continue; + } + Map row = (Map) item; + String name = firstText(row.get("feeName"), row.get("name"), row.get("chargeName"), + row.get("itemName"), row.get("feeType")); + String amount = firstText(row.get("salePrice"), row.get("amount"), row.get("price"), + row.get("feeAmount"), row.get("totalPrice")); + String lineCurrency = TmsQuoteCurrency.fromFeeRow(row); + addFeeLine(lines, name, amount, + StringUtils.isNotBlank(lineCurrency) ? lineCurrency : headerCurrency, + extrasFromFee(row)); + } + } + if (lines.isEmpty()) { + addFeeLine(lines, "空运费", firstText(data.get("airFreight"), data.get("freight"), freight), headerCurrency, null); + addFeeLine(lines, "操作费", firstText(data.get("operateFee"), data.get("operationFee"), data.get("handlingFee")), headerCurrency, null); + addFeeLine(lines, "文件费", firstText(data.get("docFee"), data.get("fileFee"), data.get("documentFee")), headerCurrency, null); + addFeeLine(lines, "报关费", firstText(data.get("customsFee"), data.get("declareFee")), headerCurrency, null); + addFeeLine(lines, "提货费", firstText(data.get("pickupFee"), data.get("deliveryFee")), headerCurrency, null); + addFeeLine(lines, "目的地杂费", firstText(data.get("destFee"), data.get("destinationFee")), headerCurrency, null); + addFeeLine(lines, "其他附加费", firstText(other, data.get("otherFee"), data.get("sundryFee")), headerCurrency, null); + } + return JSON.toJSONString(lines); + } + + /** + * 费用行上的单位 / 数量 / 单价 / 备注只取 TMS 原文,没有就空着。 + */ + private static Map extrasFromFee(Map row) { + Map extras = new LinkedHashMap<>(); + putExtra(extras, "unit", firstText(row.get("unit"), row.get("unitName"), row.get("chargeUnit"))); + putExtra(extras, "quantity", firstText(row.get("quantity"), row.get("chargeQuantity"), row.get("charge_quantity"))); + putExtra(extras, "unit_price", firstText(row.get("unitPrice"), row.get("unit_price"))); + putExtra(extras, "remark", firstText(row.get("remark"), row.get("description"), row.get("note"))); + return extras; + } + + private static void putExtra(Map extras, String key, String value) { + if (StringUtils.isNotBlank(value) && !"-".equals(value) && !"null".equalsIgnoreCase(value)) { + extras.put(key, value); + } + } + + private static void addFeeLine(List> lines, String name, String amount, + String currency, Map extras) { + TmsQuoteCurrency.Money money = TmsQuoteCurrency.split(amount, currency); + if (StringUtils.isBlank(name) || StringUtils.isBlank(money.amount) || "-".equals(money.amount)) { + return; + } + Map row = new LinkedHashMap<>(); + row.put("name", name); + row.put("amount", money.amount); + if (StringUtils.isNotBlank(money.currency)) { + row.put("currency", money.currency); + } + if (extras != null) { + for (Map.Entry e : extras.entrySet()) { + if (StringUtils.isNotBlank(e.getValue()) && !row.containsKey(e.getKey())) { + row.put(e.getKey(), e.getValue()); + } + } + } + lines.add(row); + } + + /** + * 出站前只拦销售侧必填真缺。originCode / destinationCode 不是拦网: + * 对不上机场也要查,TMS 自己回无价或 1003。 + */ + private static List missingAirQuery(String workOrderNo, String quoteDate, Map segment) { + 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", "quantity", "weightKg", "volumeCbm", "packageType" + }) { + if (segment.get(key) == null || "".equals(segment.get(key))) { + missing.add(key); + } + } + return missing; + } + + private static Map incomplete(List missing) { + Map out = new LinkedHashMap<>(); + out.put("ok", false); + out.put("has_price", false); + out.put("classification", "TMS_QUERY_INCOMPLETE"); + out.put("source", "TMS"); + out.put("message", "查价参数不齐:" + missing); + out.put("quote", new HashMap()); + return out; + } + + /** + * 能填出机场代码就带上(名称命中或销售已写三字码)。 + * 对不上不写假码,也不拦出站。 + */ + private void fillAirportCode(Map segment, String key, String raw) { + TmsExactProjector.Projection hit = projector.resolveAirport(raw); + if (hit.ok()) { + segment.put(key, hit.code); + } + } + + /** + * TMS 出站要 packageType。销售原文非空即收下,不对销售做包装白名单。 + * 认得出代码用代码;未对上默认 2。不要默认 1(全部),现网航线 1 常无价、2/3 有价。 + */ + private void fillPackageType(Map segment, String raw) { + if (raw == null || raw.trim().isEmpty()) { + segment.put("packageType", 2); + return; + } + if (raw.matches("\\d+")) { + segment.put("packageType", Integer.valueOf(raw)); + return; + } + TmsExactProjector.Projection hit = projector.resolvePackage(raw); + if (!hit.ok()) { + segment.put("packageType", 2); + return; + } + try { + segment.put("packageType", Integer.valueOf(hit.code)); + } catch (NumberFormatException ex) { + segment.put("packageType", hit.code); + } + } + + private static Map tech(String message) { + Map out = new LinkedHashMap<>(); + out.put("ok", false); + out.put("has_price", false); + out.put("classification", "TMS_TECH_FAILURE"); + out.put("source", "TMS"); + out.put("message", message); + out.put("quote", new HashMap()); + return out; + } + + @SuppressWarnings("unchecked") + private static Map extractFacts(Map body) { + if (body == null) { + return new HashMap<>(); + } + Object facts = body.get("facts"); + if (facts instanceof Map) { + Map copy = new HashMap<>(); + for (Map.Entry e : ((Map) facts).entrySet()) { + copy.put(String.valueOf(e.getKey()), e.getValue()); + } + return copy; + } + return new HashMap<>(body); + } + + private static Object first(Object... values) { + for (Object v : values) { + if (v != null && !"".equals(v)) { + return v; + } + } + return null; + } + + private static String text(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + private static String firstText(Object... values) { + for (Object v : values) { + String t = text(v); + if (!t.isEmpty() && !"null".equals(t)) { + return t; + } + } + return ""; + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsAdapterController.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsAdapterController.java index 2719d4e..7d094b9 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsAdapterController.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsAdapterController.java @@ -3,43 +3,40 @@ package org.jeecg.modules.inquiry.tms; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import org.jeecg.common.api.vo.Result; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.LinkedHashMap; +import java.util.Map; + /** - * TMS 适配 HTTP 壳。 + * TMS 适配探测接口。 *

- * 路径:/inquiry/tms/*;当前全部 501/未实现,供后台与联调探测包是否挂载。 + * 路径:/inquiry/tms/*。configured 只读环境,不打 TMS;health 可探测但锁舱永不从这里触发。 * 禁止:在本控制器同步等 Python Graph。 */ -@Api(tags = "询价-TMS适配壳") +@Api(tags = "询价-TMS适配") @RestController @RequestMapping("/inquiry/tms") public class TmsAdapterController { - private final TmsAdapterClient client = new TmsAdapterClient(); + @Autowired + private TmsProperties properties; + @Autowired + private TmsGatewayClient client; /** - * 健康壳:返回未实现说明,不打真实 TMS。 - * - * @return Result 包装 + * 配置探测:是否已配齐(不访问外网)。 */ - @ApiOperation("TMS 健康壳(未实现)") - @GetMapping("/health") - public Result health() { - TmsShellResult r = client.health(); - return Result.error(r.getHttpStatus(), "TMS adapter shell: " + r.getError() + ":" + r.getOperation()); - } - - /** - * 配置探测:是否已配齐(壳阶段恒 false)。 - * - * @return configured=false - */ - @ApiOperation("TMS 配置探测壳") + @ApiOperation("TMS 配置探测") @GetMapping("/configured") public Result configured() { - return Result.OK(java.util.Collections.singletonMap("configured", client.isConfigured())); + Map body = new LinkedHashMap<>(); + body.put("configured", client.isConfigured()); + body.put("enabled", properties.isEnabled()); + body.put("lockEnabled", properties.isLockEnabled()); + return Result.OK(body); } } diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsExactProjector.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsExactProjector.java new file mode 100644 index 0000000..b903d4f --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsExactProjector.java @@ -0,0 +1,153 @@ +package org.jeecg.modules.inquiry.tms; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; +import org.springframework.util.StreamUtils; + +import javax.annotation.PostConstruct; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 空运字典 EXACT_ONLY 投影(种子文件,后续由 TMS 字典 sync 替换)。 + *

+ * 职责:起运/目的机场、包装类型精确匹配;包装缺省投影 packageType=1(全部)。 + * 多值歧义返回 AMBIGUOUS,禁止 last-write-wins。 + * 失败 source=TMS_DICTIONARY_PROJECTION,不得装成无价 1002。 + */ +@Slf4j +@Component +public class TmsExactProjector { + + private final Map> airportByName = new LinkedHashMap<>(); + private final Map> airportByCode = new LinkedHashMap<>(); + private final Map> packByName = new LinkedHashMap<>(); + + @PostConstruct + public void load() { + try { + ClassPathResource res = new ClassPathResource("tms-air-exact-seed.json"); + String raw = StreamUtils.copyToString(res.getInputStream(), StandardCharsets.UTF_8); + JSONObject root = JSON.parseObject(raw); + index(root.getJSONArray("air_airport"), airportByName, airportByCode); + index(root.getJSONArray("air_packaging_type"), packByName, new LinkedHashMap<>()); + log.info("tms.exact seed airports={} packs={}", airportByName.size(), packByName.size()); + } catch (Exception ex) { + log.error("tms.exact seed 加载失败", ex); + } + } + + /** + * 精确解析一个名称或代码。 + * + * @return RESOLVED / UNMAPPED / AMBIGUOUS + */ + public Projection resolveAirport(String raw) { + Projection hit = resolve(raw, airportByName, airportByCode); + if (hit.ok() || "AMBIGUOUS".equals(hit.status)) { + return hit; + } + // 销售已写出三字码(如 ZUH/CRK):这是原文代码,不是别名猜测 + String text = raw == null ? "" : raw.trim(); + if (text.matches("(?i)[A-Z]{3}")) { + return Projection.resolved(text, text.toUpperCase()); + } + return hit; + } + + /** + * 包装:空值按托盘(2),不要默认「全部」(1)。仍无则 UNMAPPED。 + */ + public Projection resolvePackage(String raw) { + String text = raw == null ? "" : raw.trim(); + if (text.isEmpty()) { + text = "托盘"; + } + return resolve(text, packByName, packByName); + } + + private static Projection resolve(String raw, + Map> byName, + Map> byCode) { + String text = raw == null ? "" : raw.trim(); + if (text.isEmpty()) { + return Projection.unmapped(""); + } + Set codes = new LinkedHashSet<>(); + addAll(codes, byName.get(text)); + addAll(codes, byCode.get(text.toUpperCase())); + if (codes.isEmpty()) { + return Projection.unmapped(text); + } + if (codes.size() > 1) { + return Projection.ambiguous(text, new ArrayList<>(codes)); + } + return Projection.resolved(text, codes.iterator().next()); + } + + private static void addAll(Set into, List values) { + if (values != null) { + into.addAll(values); + } + } + + private static void index(JSONArray rows, + Map> byName, + Map> byCode) { + if (rows == null) { + return; + } + for (int i = 0; i < rows.size(); i++) { + JSONObject row = rows.getJSONObject(i); + String name = row.getString("source_name"); + String code = row.getString("source_code"); + if (name == null || code == null) { + continue; + } + byName.computeIfAbsent(name.trim(), k -> new ArrayList<>()).add(code.trim()); + byCode.computeIfAbsent(code.trim().toUpperCase(), k -> new ArrayList<>()).add(code.trim()); + } + } + + /** + * 单字段投影结果。 + */ + public static final class Projection { + public final String status; + public final String source; + public final String code; + public final List codes; + + private Projection(String status, String source, String code, List codes) { + this.status = status; + this.source = source; + this.code = code; + this.codes = codes; + } + + public static Projection resolved(String source, String code) { + return new Projection("RESOLVED", source, code, null); + } + + public static Projection unmapped(String source) { + return new Projection("UNMAPPED", source, null, null); + } + + public static Projection ambiguous(String source, List codes) { + return new Projection("AMBIGUOUS", source, null, codes); + } + + public boolean ok() { + return "RESOLVED".equals(status); + } + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsGatewayClient.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsGatewayClient.java new file mode 100644 index 0000000..32bbc54 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsGatewayClient.java @@ -0,0 +1,191 @@ +package org.jeecg.modules.inquiry.tms; + +import com.alibaba.fastjson.JSON; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestTemplate; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +/** + * TMS V2 查询网关(只做 query / token;锁舱默认关闭)。 + *

+ * 职责:Bearer 出站、超时、最多 3 次、code 1/1002 短缓存、连续失败熔断。 + * 线程:无全局工单大锁;熔断计数是进程级协调,不是六态。 + * 禁止:LOCK/RELEASE 出站;按 userid 伪造报价。 + */ +@Slf4j +@Component +public class TmsGatewayClient { + + private final TmsProperties properties; + private final RestTemplate restTemplate; + private final TmsV2TokenProvider tokens; + private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + private final AtomicInteger consecutiveFailures = new AtomicInteger(); + private final AtomicLong circuitOpenUntil = new AtomicLong(); + + /** + * Spring 注入入口。必须标 {@code @Autowired}:本类还有测试用三参构造, + * 不标的话 Spring 会去找无参构造并启动失败。 + */ + @Autowired + public TmsGatewayClient(TmsProperties properties, RestTemplateBuilder builder) { + this.properties = properties; + this.restTemplate = builder + .setConnectTimeout(Duration.ofMillis(Math.max(500, properties.getConnectTimeoutMs()))) + .setReadTimeout(Duration.ofMillis(Math.max(1000, properties.getReadTimeoutMs()))) + .build(); + this.tokens = new TmsV2TokenProvider(properties, this.restTemplate); + } + + /** 单测注入,不走 Spring。 */ + TmsGatewayClient(TmsProperties properties, RestTemplate restTemplate, TmsV2TokenProvider tokens) { + this.properties = properties; + this.restTemplate = restTemplate; + this.tokens = tokens; + } + + /** + * 配置是否看起来可出站(不访问网络)。 + */ + public boolean isConfigured() { + return properties.queryConfigured(); + } + + /** + * 查询报价。内部请求先经 {@link TmsV2RequestMapper}。 + * + * @param internalRequest 主账内部结构 + * @param cacheKey 短缓存键(已含字典版本时可拼进去) + * @return 规范化 body(含 code) + */ + public Map query(Map internalRequest, String cacheKey) { + Map request = TmsV2RequestMapper.quote(internalRequest); + long now = System.currentTimeMillis(); + CacheEntry hit = cache.get(cacheKey); + if (hit != null && hit.expiresAt > now) { + Map copy = new LinkedHashMap<>(hit.body); + copy.put("cacheStatus", "HIT"); + return copy; + } + Map body = invokeQuery(request); + int code = TmsV2ResponseNormalizer.number(body.get("code")); + if (code == 1 || code == 1002) { + cache.put(cacheKey, new CacheEntry(new LinkedHashMap<>(body), + now + Math.max(1, properties.getCacheTtlSeconds()) * 1000L)); + } + return body; + } + + private Map invokeQuery(Map request) { + assertCircuitAvailable(); + String json = JSON.toJSONString(request); + int max = Math.max(1, Math.min(3, properties.getMaxAttempts())); + boolean refreshed401 = false; + RuntimeException last = new IllegalStateException("TMS 未尝试"); + for (int attempt = 1; attempt <= max; attempt++) { + long started = System.currentTimeMillis(); + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setBearerAuth(tokens.accessToken()); + ResponseEntity response = restTemplate.exchange( + TmsV2TokenProvider.joinUrl(properties.getBaseUrl(), properties.getQueryPath()), + HttpMethod.POST, + new HttpEntity<>(json, headers), + String.class); + Map body = TmsV2ResponseNormalizer.normalize(response.getBody()); + consecutiveFailures.set(0); + body.put("formal", true); + body.put("source", "TMS"); + body.put("liveTmsVerified", true); + body.put("cacheStatus", "MISS"); + log.info("tms.query req wo={} quoteDate={} segs={}", + request.get("workOrderNo"), request.get("quoteDate"), request.get("segments")); + log.info("tms.query ok code={} msg={} http={} costMs={} attempt={}", + body.get("code"), body.get("message"), response.getStatusCodeValue(), + System.currentTimeMillis() - started, attempt); + return body; + } catch (HttpStatusCodeException ex) { + int status = ex.getRawStatusCode(); + log.warn("tms.query http={} attempt={} costMs={}", + status, attempt, System.currentTimeMillis() - started); + last = new IllegalStateException("TMS HTTP=" + status); + if (status == 401 && !refreshed401) { + tokens.invalidate(); + refreshed401 = true; + continue; + } + if ((status == 429 || status >= 500) && attempt < max) { + sleepQuiet(200L * (1L << (attempt - 1))); + continue; + } + failCircuit(); + throw last; + } catch (ResourceAccessException ex) { + log.warn("tms.query network attempt={} err={}", attempt, ex.getMessage()); + last = new IllegalStateException("TMS 网络或超时"); + if (attempt < max) { + sleepQuiet(200L * (1L << (attempt - 1))); + continue; + } + failCircuit(); + throw last; + } catch (IllegalArgumentException ex) { + // 单张回包协议坏,不把整进程查价熔断 + throw new IllegalStateException("TMS 回包无法解析"); + } + } + failCircuit(); + throw last; + } + + private void assertCircuitAvailable() { + long until = circuitOpenUntil.get(); + if (until > System.currentTimeMillis()) { + throw new IllegalStateException("TMS 熔断中"); + } + } + + private void failCircuit() { + int n = consecutiveFailures.incrementAndGet(); + if (n >= 3) { + circuitOpenUntil.set(System.currentTimeMillis() + + Math.max(5, properties.getCircuitOpenSeconds()) * 1000L); + log.warn("tms.circuit open after {} failures", n); + } + } + + private static void sleepQuiet(long ms) { + try { + Thread.sleep(ms); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + } + + private static final class CacheEntry { + private final Map body; + private final long expiresAt; + + private CacheEntry(Map body, long expiresAt) { + this.body = body; + this.expiresAt = expiresAt; + } + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsProperties.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsProperties.java new file mode 100644 index 0000000..40d42e4 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsProperties.java @@ -0,0 +1,72 @@ +package org.jeecg.modules.inquiry.tms; + +import lombok.Data; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * TMS V2 出站配置(只读环境变量,密钥不写进本类默认值)。 + *

+ * 职责:给 Token / 查询网关读地址与超时。锁舱开关默认关,防止与现网抢舱。 + * 禁止:把 ClientSecret 打进日志;禁止智能体读取本配置去直连 TMS。 + */ +@Data +@Component +public class TmsProperties { + + @Value("${YTD_TMS_V2_ENABLED:true}") + private boolean enabled; + + @Value("${TMS_V2_BASE_URL:}") + private String baseUrl; + + @Value("${TMS_V2_CLIENT_ID:}") + private String clientId; + + @Value("${TMS_V2_CLIENT_SECRET:}") + private String clientSecret; + + @Value("${TMS_V2_SIGNATURE_SECRET:}") + private String signatureSecret; + + @Value("${TMS_V2_TOKEN_PATH:/auth/token}") + private String tokenPath; + + @Value("${TMS_V2_QUERY_PATH:/quote/v2/query}") + private String queryPath; + + @Value("${TMS_V2_HEALTH_PATH:/health}") + private String healthPath; + + /** 与现网共用 Client 期间禁止出站锁舱。 */ + @Value("${TMS_V2_LOCK_ENABLED:false}") + private boolean lockEnabled; + + @Value("${TMS_V2_CONNECT_TIMEOUT_MS:3000}") + private int connectTimeoutMs; + + @Value("${TMS_V2_READ_TIMEOUT_MS:8000}") + private int readTimeoutMs; + + @Value("${TMS_V2_MAX_ATTEMPTS:3}") + private int maxAttempts; + + @Value("${TMS_V2_CACHE_TTL_SECONDS:30}") + private int cacheTtlSeconds; + + @Value("${TMS_V2_TOKEN_REFRESH_SKEW_SECONDS:60}") + private int tokenRefreshSkewSeconds; + + @Value("${TMS_V2_CIRCUIT_OPEN_SECONDS:30}") + private int circuitOpenSeconds; + + /** + * 是否具备出站查询的最低配置(不探测网络)。 + */ + public boolean queryConfigured() { + return enabled + && baseUrl != null && !baseUrl.trim().isEmpty() + && clientId != null && !clientId.trim().isEmpty() + && clientSecret != null && !clientSecret.trim().isEmpty(); + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrency.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrency.java new file mode 100644 index 0000000..0dc6549 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrency.java @@ -0,0 +1,184 @@ +package org.jeecg.modules.inquiry.tms; + +import org.apache.commons.lang3.StringUtils; + +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 从 TMS 回包抽出币种,只认原文,不编造。 + *

+ * 职责:给查价投影一个稳定的 currency;费用行可以有自己的当地币。 + * 禁止:默认 CNY/USD;把备注、金额数字当成币种。 + * 线程:无状态,调用方线程内执行。 + */ +public final class TmsQuoteCurrency { + + private static final Pattern PREFIX = Pattern.compile("^([A-Za-z]{3})\\s+(.+)$"); + private static final Pattern ISO = Pattern.compile("^[A-Z]{3}$"); + + private TmsQuoteCurrency() { + } + + /** + * 表头币种:currency / currencyCode / 嵌套 quote / 费用行 / 航线方案,按这个顺序。 + */ + @SuppressWarnings("unchecked") + public static String fromHeader(Map data) { + if (data == null || data.isEmpty()) { + return ""; + } + String hit = firstCode( + data.get("currency"), + data.get("currencyCode"), + data.get("saleCurrency"), + data.get("chargeCurrency"), + data.get("priceCurrency"), + data.get("settlementCurrency")); + if (StringUtils.isNotBlank(hit)) { + return hit; + } + Object nested = data.get("quote"); + if (nested instanceof Map) { + hit = firstCode( + ((Map) nested).get("currency"), + ((Map) nested).get("currencyCode"), + ((Map) nested).get("saleCurrency")); + if (StringUtils.isNotBlank(hit)) { + return hit; + } + } + Object fees = firstNonNull(data.get("feeItems"), data.get("feeList"), + data.get("chargeList"), data.get("costItems")); + if (fees instanceof List) { + for (Object item : (List) fees) { + if (item instanceof Map) { + hit = fromFeeRow((Map) item); + if (StringUtils.isNotBlank(hit)) { + return hit; + } + } + } + } + for (String key : new String[] {"airOptions", "seaDetails", "landSegments", "options"}) { + Object arr = data.get(key); + if (!(arr instanceof List)) { + continue; + } + for (Object opt : (List) arr) { + if (opt instanceof Map) { + hit = fromHeader((Map) opt); + if (StringUtils.isNotBlank(hit)) { + return hit; + } + } + } + } + return ""; + } + + /** + * 单条费用的当地币。没有就空串,不回退成整单币种(由调用方决定要不要继承)。 + */ + public static String fromFeeRow(Map row) { + if (row == null) { + return ""; + } + return firstCode(row.get("currency"), row.get("currencyCode"), + row.get("saleCurrency"), row.get("chargeCurrency")); + } + + /** + * 金额里若已带「CNY 14669.60」,拆开数字和币种,避免卡片双写、Excel SUM 失败。 + */ + public static Money split(String raw, String fallbackCurrency) { + String amount = raw == null ? "" : raw.trim(); + String currency = normalize(fallbackCurrency); + if (StringUtils.isBlank(amount) || "-".equals(amount)) { + return new Money(amount, currency); + } + Matcher m = PREFIX.matcher(amount); + if (m.matches()) { + String fromAmt = normalize(m.group(1)); + if (StringUtils.isNotBlank(fromAmt)) { + return new Money(m.group(2).trim(), fromAmt); + } + } + return new Money(amount, currency); + } + + /** + * 销售可见金额:有币种就写成「CNY 14669.60」。金额已带币种前缀则原样。 + */ + public static String formatDisplay(String currency, String amount) { + Money money = split(amount, currency); + if (StringUtils.isBlank(money.amount) || "-".equals(money.amount)) { + return StringUtils.isBlank(money.amount) ? "" : money.amount; + } + if (StringUtils.isBlank(money.currency)) { + return money.amount; + } + return money.currency + " " + money.amount; + } + + public static String firstCode(Object... values) { + if (values == null) { + return ""; + } + for (Object value : values) { + String code = normalize(value); + if (StringUtils.isNotBlank(code)) { + return code; + } + } + return ""; + } + + /** + * 只收 ISO 三字码和 TMS 偶尔写的人民币/美元中文。其它原文丢掉,避免把备注当币种。 + */ + public static String normalize(Object value) { + if (value == null) { + return ""; + } + String text = String.valueOf(value).trim(); + if (text.isEmpty() || "null".equalsIgnoreCase(text) || "-".equals(text)) { + return ""; + } + String upper = text.toUpperCase(); + if ("RMB".equals(upper) || "人民币".equals(text)) { + return "CNY"; + } + if ("美金".equals(text) || "美元".equals(text)) { + return "USD"; + } + return ISO.matcher(upper).matches() ? upper : ""; + } + + private static Object firstNonNull(Object... values) { + if (values == null) { + return null; + } + for (Object value : values) { + if (value != null && !"".equals(value)) { + return value; + } + } + return null; + } + + /** + * 拆开后的金额与币种。amount 仍是 TMS 数字原文,currency 可能为空。 + */ + public static final class Money { + public final String amount; + public final String currency; + + public Money(String amount, String currency) { + this.amount = amount == null ? "" : amount; + this.currency = currency == null ? "" : currency; + } + } +} 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 new file mode 100644 index 0000000..613cfb5 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapper.java @@ -0,0 +1,188 @@ +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.Map; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 把主账内部查价结构映射成 TMS V2 /quote/v2/query 正文。 + *

+ * 对照《友通达询价智能体TMS系统对接开发接口文档V1.0.1》5.2 / 2.1 / 6.3: + * quantity 必须是 JSON 整数;weightKg / volumeCbm 必须是 JSON 小数且大于 0; + * 带「KGS」「CBM」的字符串会触发业务码 1001(参数格式错误)。 + * 报价日期:文档漏了该请求字段,实际 TMS 使用 quoteDate,格式 yyyy-MM-dd(如 2026-09-07)。 + * 禁止:inquiryTime=此刻(会查成无匹配);在这里做字典模糊匹配或猜测机场代码。 + */ +public final class TmsV2RequestMapper { + + /** 从「100KGS」「2.5CBM」「10件」里取出第一个数字。 */ + private static final Pattern FIRST_NUMBER = Pattern.compile("(\\d+(?:\\.\\d+)?)"); + + /** 文档 2.1:后缀 Date 的字段格式 yyyy-MM-dd;也容忍斜杠或后面跟时间。 */ + private static final Pattern ISO_DATE = Pattern.compile("(\\d{4})[-/](\\d{1,2})[-/](\\d{1,2})"); + + private TmsV2RequestMapper() { + } + + /** + * @param internal 含 transportMode/quoteDate/segments 的内部请求 + * @return 可 JSON 序列化的 TMS V2 body(数字字段已是 Number) + */ + public static Map quote(Map internal) { + Map result = new LinkedHashMap<>(); + copy(internal, result, "requestId"); + if (result.get("requestId") == null) { + // TMS 要求 requestId 为 UUID,缺了会 1003「需补充字段」 + result.put("requestId", UUID.randomUUID().toString()); + } + copy(internal, result, "workOrderNo"); + if (result.get("workOrderNo") == null + || !String.valueOf(result.get("workOrderNo")).matches("WO\\d{12}")) { + // TMS 要求 WOYYYYMMDDNNNN,WO-TMP 会 1003 + String day = new java.text.SimpleDateFormat("yyyyMMdd").format(new java.util.Date()); + result.put("workOrderNo", "WO" + day + "0000"); + } + copy(internal, result, "transportMode"); + String quoteDate = normalizeQuoteDate(internal.get("quoteDate")); + if (quoteDate != null) { + result.put("quoteDate", quoteDate); + } + List> segments = maps(internal.get("segments")); + List> external = new ArrayList<>(); + for (int i = 0; i < segments.size(); i++) { + external.add(segment(segments.get(i), i + 1)); + } + result.put("segments", external); + return compact(result); + } + + private static Map segment(Map source, int fallbackSequence) { + Map target = new LinkedHashMap<>(); + Object sequence = first(source, "sequence", "segmentSequence"); + target.put("sequence", sequence == null ? fallbackSequence : asInteger(sequence)); + put(target, "transportMode", first(source, "transportMode")); + put(target, "origin", first(source, "origin")); + put(target, "originCode", first(source, "originCode")); + put(target, "destination", first(source, "destination")); + put(target, "destinationCode", first(source, "destinationCode")); + put(target, "cargoName", first(source, "cargoName", "goodsName")); + // 5.2:quantity=int,weightKg/volumeCbm=decimal>0,禁止带单位字符串 + put(target, "quantity", asInteger(first(source, "quantity", "pieces"))); + put(target, "weightKg", asPositiveDecimal(first(source, "weightKg"))); + put(target, "volumeCbm", asPositiveDecimal(first(source, "volumeCbm"))); + put(target, "packageType", first(source, "packageType", "packagingType")); + return compact(target); + } + + /** + * 报价日期规范成 yyyy-MM-dd。解析不了则不传,避免 1001。 + */ + public static String normalizeQuoteDate(Object raw) { + if (raw == null) { + return null; + } + String text = String.valueOf(raw).trim(); + if (text.isEmpty()) { + return null; + } + Matcher matcher = ISO_DATE.matcher(text); + if (!matcher.find()) { + return null; + } + int year = Integer.parseInt(matcher.group(1)); + int month = Integer.parseInt(matcher.group(2)); + int day = Integer.parseInt(matcher.group(3)); + if (month < 1 || month > 12 || day < 1 || day > 31) { + return null; + } + return String.format("%04d-%02d-%02d", year, month, day); + } + + /** + * 件数:JSON 整数。销售原文「10件」只取数字。 + */ + public static Integer asInteger(Object raw) { + if (raw instanceof Number) { + return ((Number) raw).intValue(); + } + BigDecimal number = firstDecimal(raw); + if (number == null) { + return null; + } + return number.intValue(); + } + + /** + * 毛重/体积:JSON 小数且 >0。销售原文「100KGS」「2CBM」只取数字。 + */ + public static BigDecimal asPositiveDecimal(Object raw) { + if (raw instanceof Number) { + BigDecimal number = new BigDecimal(raw.toString()); + return number.compareTo(BigDecimal.ZERO) > 0 ? number : null; + } + BigDecimal number = firstDecimal(raw); + if (number == null || number.compareTo(BigDecimal.ZERO) <= 0) { + return null; + } + return number; + } + + private static BigDecimal firstDecimal(Object raw) { + if (raw == null) { + return null; + } + Matcher matcher = FIRST_NUMBER.matcher(String.valueOf(raw).trim()); + if (!matcher.find()) { + return null; + } + try { + return new BigDecimal(matcher.group(1)); + } catch (NumberFormatException ex) { + return null; + } + } + + private static Object first(Map value, String... keys) { + for (String key : keys) { + Object current = value.get(key); + if (current != null && !"".equals(current)) { + return current; + } + } + return null; + } + + private static void copy(Map source, Map target, String key) { + put(target, key, source.get(key)); + } + + private static void put(Map target, String key, Object value) { + if (value != null && !"".equals(value)) { + target.put(key, value); + } + } + + private static Map compact(Map value) { + value.entrySet().removeIf(e -> e.getValue() == null || "".equals(e.getValue())); + return value; + } + + @SuppressWarnings("unchecked") + private static List> maps(Object value) { + List> result = new ArrayList<>(); + if (!(value instanceof List)) { + return result; + } + for (Object item : (List) value) { + if (item instanceof Map) { + result.add(new LinkedHashMap<>((Map) item)); + } + } + return result; + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2ResponseNormalizer.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2ResponseNormalizer.java new file mode 100644 index 0000000..e18df57 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2ResponseNormalizer.java @@ -0,0 +1,68 @@ +package org.jeecg.modules.inquiry.tms; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * TMS 回包规范化:抽出 code / tmsData,保留原文业务字段。 + *

+ * 职责:给查询网关一个稳定 envelope,便于区分 1 / 1002 / 1003。 + * 禁止:在这里把 1002 改写成无价以外的语义。 + */ +public final class TmsV2ResponseNormalizer { + + private TmsV2ResponseNormalizer() { + } + + /** + * @param raw TMS HTTP 正文 + * @return 含 code、message、traceId、tmsData 的 map + */ + public static Map normalize(String raw) { + Map envelope = parseEnvelope(raw); + Map data = data(envelope); + Map result = new LinkedHashMap<>(data); + result.put("code", number(envelope.get("code"))); + result.put("message", envelope.get("message")); + result.put("traceId", envelope.get("traceId")); + result.put("tmsData", new LinkedHashMap<>(data)); + return result; + } + + public static Map parseEnvelope(String raw) { + JSONObject parsed; + try { + parsed = JSON.parseObject(raw); + } catch (RuntimeException ex) { + throw new IllegalArgumentException("invalid JSON"); + } + if (parsed == null || !parsed.containsKey("code")) { + throw new IllegalArgumentException("code missing"); + } + return new LinkedHashMap<>(parsed); + } + + @SuppressWarnings("unchecked") + public static Map data(Map envelope) { + Object value = envelope.get("data"); + if (value instanceof Map) { + return new LinkedHashMap<>((Map) value); + } + Map fallback = new LinkedHashMap<>(envelope); + fallback.remove("code"); + fallback.remove("message"); + fallback.remove("traceId"); + return fallback; + } + + public static int number(Object value) { + try { + return value == null ? 0 : Integer.parseInt(String.valueOf(value)); + } catch (RuntimeException ex) { + return 0; + } + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2TokenProvider.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2TokenProvider.java new file mode 100644 index 0000000..a3485e2 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2TokenProvider.java @@ -0,0 +1,121 @@ +package org.jeecg.modules.inquiry.tms; + +import com.alibaba.fastjson.JSON; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestTemplate; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * TMS V2 client_credentials Token 缓存。 + *

+ * 职责:按需刷新 accessToken;state() 不输出 Token 原文。 + * 线程:同一把锁只保护缓存刷新,不串行所有工单查价。 + */ +public class TmsV2TokenProvider { + + private final TmsProperties properties; + private final RestTemplate restTemplate; + private final Object monitor = new Object(); + private volatile CachedToken cached; + + public TmsV2TokenProvider(TmsProperties properties, RestTemplate restTemplate) { + this.properties = properties; + this.restTemplate = restTemplate; + } + + /** + * @return Bearer 用的 accessToken + */ + public String accessToken() { + long now = System.currentTimeMillis(); + CachedToken current = cached; + if (current != null && current.refreshAtMillis > now) { + return current.value; + } + synchronized (monitor) { + current = cached; + now = System.currentTimeMillis(); + if (current != null && current.refreshAtMillis > now) { + return current.value; + } + cached = fetch(now); + return cached.value; + } + } + + public void invalidate() { + synchronized (monitor) { + cached = null; + } + } + + private CachedToken fetch(long now) { + if (!properties.queryConfigured()) { + throw new IllegalStateException("TMS V2 client credentials 未配置"); + } + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + String basic = properties.getClientId() + ":" + properties.getClientSecret(); + headers.set("Authorization", "Basic " + Base64.getEncoder() + .encodeToString(basic.getBytes(StandardCharsets.UTF_8))); + Map request = new LinkedHashMap<>(); + request.put("grantType", "client_credentials"); + ResponseEntity response = restTemplate.exchange( + joinUrl(properties.getBaseUrl(), properties.getTokenPath()), + HttpMethod.POST, + new HttpEntity<>(JSON.toJSONString(request), headers), + String.class); + Map envelope = TmsV2ResponseNormalizer.parseEnvelope(response.getBody()); + if (TmsV2ResponseNormalizer.number(envelope.get("code")) != 1) { + throw new IllegalStateException("TMS Token 业务码失败"); + } + Map data = TmsV2ResponseNormalizer.data(envelope); + String token = text(data.get("accessToken")); + long expiresIn = number(data.get("expiresIn")); + if (token.isEmpty() || expiresIn <= 0) { + throw new IllegalStateException("TMS Token 响应缺少 accessToken/expiresIn"); + } + long skew = Math.max(0, properties.getTokenRefreshSkewSeconds()); + long refreshSeconds = Math.max(1, expiresIn - Math.min(skew, Math.max(0, expiresIn - 1))); + return new CachedToken(token, now + refreshSeconds * 1000L); + } + + static String joinUrl(String base, String path) { + String b = base == null ? "" : base; + while (b.endsWith("/")) { + b = b.substring(0, b.length() - 1); + } + String p = path == null ? "" : path; + return b + (p.startsWith("/") ? p : "/" + p); + } + + private static String text(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + private static long number(Object value) { + try { + return value == null ? 0L : Long.parseLong(String.valueOf(value)); + } catch (RuntimeException ex) { + return 0L; + } + } + + private static final class CachedToken { + private final String value; + private final long refreshAtMillis; + + private CachedToken(String value, long refreshAtMillis) { + this.value = value; + this.refreshAtMillis = refreshAtMillis; + } + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/package-info.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/package-info.java index 5c64b5b..c833443 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/package-info.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/package-info.java @@ -1,9 +1,7 @@ /** - * TMS 适配空包:智能体不直连 TMS,仅主账本包出站。 + * TMS 适配:仅主账本包出站(token + query)。 *

- * 本包当前只提供健康探测与未实现占位;token / query / 字典 sync / 锁舱 - * 正式实现前一律返回未启用,禁止半可用 LOCK/RELEASE。 - *

- * 禁止:按企微 userid 伪造 TMS 结果;禁止模糊字典匹配。 + * 智能体只打 /inquiry/agent/tms/query。锁舱/释放默认关闭。 + * 查价不做本地港口/包装字典对应;有价回报价,无价回暂无报价。禁止 fixture 假价。 */ package org.jeecg.modules.inquiry.tms; diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/tms-air-exact-seed.json b/inquiry-api/jeecg-module-inquiry/src/main/resources/tms-air-exact-seed.json new file mode 100644 index 0000000..2b236de --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/tms-air-exact-seed.json @@ -0,0 +1,26 @@ +{ + "comment": "空运 EXACT_ONLY 种子:source_name/source_code 精确相等才投影。多码同名视为 AMBIGUOUS。正式应以 TMS 字典 sync 覆盖本文件。", + "air_airport": [ + {"source_name": "上海", "source_code": "SHA"}, + {"source_name": "浦东", "source_code": "PVG"}, + {"source_name": "上海浦东", "source_code": "PVG"}, + {"source_name": "虹桥", "source_code": "SHA"}, + {"source_name": "上海虹桥", "source_code": "SHA"}, + {"source_name": "洛杉矶", "source_code": "LAX"}, + {"source_name": "深圳", "source_code": "SZX"}, + {"source_name": "广州", "source_code": "CAN"}, + {"source_name": "香港", "source_code": "HKG"}, + {"source_name": "北京首都", "source_code": "PEK"}, + {"source_name": "北京大兴", "source_code": "PKX"}, + {"source_name": "珠海", "source_code": "ZUH"}, + {"source_name": "克拉克", "source_code": "CRK"} + ], + "air_packaging_type": [ + {"source_name": "全部", "source_code": "1"}, + {"source_name": "托盘", "source_code": "2"}, + {"source_name": "卡板", "source_code": "2"}, + {"source_name": "散货", "source_code": "2"}, + {"source_name": "纸箱", "source_code": "3"}, + {"source_name": "木箱", "source_code": "3"} + ] +} diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java new file mode 100644 index 0000000..00f7303 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java @@ -0,0 +1,156 @@ +package org.jeecg.modules.inquiry.template; + +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 出单回填:费用行字符串要能解析;按名称写入金额列。 + */ +public class QuoteTemplateFillTest { + + @Test + public void parseFeeLinesJsonString() { + JSONArray rows = QuoteTemplateExcelSupport.parseFeeRows("[{\"name\":\"空运费\",\"amount\":\"14669.60\"}]"); + assertEquals(1, rows.size()); + assertEquals("空运费", rows.getJSONObject(0).getString("name")); + assertEquals("14669.60", rows.getJSONObject(0).getString("amount")); + } + + @Test + public void parseFeeLinesDoubleEncoded() { + JSONArray rows = QuoteTemplateExcelSupport.parseFeeRows("[{\"name\":\"空运费\",\"amount\":\"14669.60\"}]"); + assertEquals(1, rows.size()); + } + + @Test + public void writeAmountOntoNamedFeeRow() throws Exception { + Path src = Files.createTempFile("quote-src", ".xlsx"); + Path out = Files.createTempFile("quote-out", ".xlsx"); + try (Workbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("Sheet2"); + sheet.createRow(15).createCell(6).setCellValue("金额"); + sheet.createRow(16).createCell(1).setCellValue("空运费"); + try (java.io.OutputStream os = Files.newOutputStream(src)) { + wb.write(os); + } + } + JSONObject mapping = new JSONObject(); + mapping.put("fields", new JSONObject()); + JSONObject facts = new JSONObject(true); + facts.put("quote_no", "WO202609140048"); + JSONArray rows = QuoteTemplateExcelSupport.parseFeeRows("[{\"name\":\"空运费\",\"amount\":\"14669.60\"}]"); + facts.put("fee_rows", rows); + QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts); + try (Workbook wb = new XSSFWorkbook(Files.newInputStream(out))) { + double amt = wb.getSheetAt(0).getRow(16).getCell(6).getNumericCellValue(); + assertEquals(14669.60, amt, 0.01); + } finally { + Files.deleteIfExists(src); + Files.deleteIfExists(out); + } + } + + @Test + public void buildFillFactsReadsFeeLinesString() { + Map facts = new HashMap<>(); + facts.put("起运港", "ZUH"); + Map quote = new HashMap<>(); + quote.put("total", "14669.60"); + quote.put("valid_until", "2026-09-15"); + quote.put("fee_lines", "[{\"name\":\"空运费\",\"amount\":\"14669.60\"}]"); + quote.put("currency", "CNY"); + JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl + .buildFillFacts("WO202609140048", facts, quote, "WuJiLin"); + assertEquals("WO202609140048", out.getString("quote_no")); + assertEquals("2026-09-15", out.getString("valid_until")); + assertEquals("CNY", out.getString("currency")); + Object fee = out.get("fee_rows"); + assertTrue(fee instanceof JSONArray); + assertEquals(1, ((JSONArray) fee).size()); + assertEquals("CNY", ((JSONArray) fee).getJSONObject(0).getString("currency")); + } + + @Test + public void writeFeeDetailColumnsOntoNamedFeeRow() throws Exception { + Path src = Files.createTempFile("quote-fee-src", ".xlsx"); + Path out = Files.createTempFile("quote-fee-out", ".xlsx"); + try (Workbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("Sheet2"); + org.apache.poi.ss.usermodel.Row head = sheet.createRow(15); + head.createCell(1).setCellValue("费用名称"); + head.createCell(2).setCellValue("单位"); + head.createCell(3).setCellValue("币别"); + head.createCell(4).setCellValue(""); + head.createCell(5).setCellValue("数量"); + head.createCell(6).setCellValue("金额"); + head.createCell(7).setCellValue("备注"); + sheet.createRow(16).createCell(1).setCellValue("空运费"); + try (java.io.OutputStream os = Files.newOutputStream(src)) { + wb.write(os); + } + } + JSONObject mapping = new JSONObject(); + mapping.put("fields", new JSONObject()); + JSONObject facts = new JSONObject(true); + facts.put("currency", "CNY"); + facts.put("fee_rows", QuoteTemplateExcelSupport.parseFeeRows( + "[{\"name\":\"空运费\",\"amount\":\"14669.60\",\"currency\":\"CNY\"," + + "\"unit\":\"KG\",\"quantity\":\"1333.6\",\"unit_price\":\"11.0\"," + + "\"remark\":\"计费重进位+1000KG\"}]")); + QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts); + try (Workbook wb = new XSSFWorkbook(Files.newInputStream(out))) { + org.apache.poi.ss.usermodel.Row row = wb.getSheetAt(0).getRow(16); + assertEquals("KG", row.getCell(2).getStringCellValue()); + assertEquals("CNY", row.getCell(3).getStringCellValue()); + assertEquals(11.0, row.getCell(4).getNumericCellValue(), 0.01); + assertEquals(1333.6, row.getCell(5).getNumericCellValue(), 0.01); + assertEquals(14669.60, row.getCell(6).getNumericCellValue(), 0.01); + assertEquals("计费重进位+1000KG", row.getCell(7).getStringCellValue()); + } finally { + Files.deleteIfExists(src); + Files.deleteIfExists(out); + } + } + + @Test + public void writeCurrencyOntoNamedFeeRow() throws Exception { + Path src = Files.createTempFile("quote-ccy-src", ".xlsx"); + Path out = Files.createTempFile("quote-ccy-out", ".xlsx"); + try (Workbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("Sheet2"); + sheet.createRow(15).createCell(3).setCellValue("币别"); + sheet.getRow(15).createCell(6).setCellValue("金额"); + sheet.createRow(16).createCell(1).setCellValue("空运费"); + try (java.io.OutputStream os = Files.newOutputStream(src)) { + wb.write(os); + } + } + JSONObject mapping = new JSONObject(); + mapping.put("fields", new JSONObject()); + JSONObject facts = new JSONObject(true); + facts.put("currency", "CNY"); + JSONArray rows = QuoteTemplateExcelSupport.parseFeeRows( + "[{\"name\":\"空运费\",\"amount\":\"14669.60\",\"currency\":\"CNY\"}]"); + facts.put("fee_rows", rows); + QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts); + try (Workbook wb = new XSSFWorkbook(Files.newInputStream(out))) { + assertEquals("CNY", wb.getSheetAt(0).getRow(16).getCell(3).getStringCellValue()); + assertEquals(14669.60, wb.getSheetAt(0).getRow(16).getCell(6).getNumericCellValue(), 0.01); + } finally { + Files.deleteIfExists(src); + Files.deleteIfExists(out); + } + } +} 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 new file mode 100644 index 0000000..a87bde7 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateMatcherTest.java @@ -0,0 +1,73 @@ +package org.jeecg.modules.inquiry.template; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * 报价模板匹配:关键词优先,否则默认,双无则空。不连库。 + */ +public class QuoteTemplateMatcherTest { + + @Test + public void keywordBeatsDefault() { + List> list = new ArrayList<>(); + list.add(tpl("默认空运", "空运", true, "欧洲")); + list.add(tpl("洛杉矶专线", "空运", false, "洛杉矶")); + Map facts = new HashMap<>(); + facts.put("目的港", "洛杉矶"); + Map hit = QuoteTemplateMatcher.pick(list, "AIR", facts, null); + assertNotNull(hit); + assertEquals("keyword", hit.get("matchType")); + assertEquals("洛杉矶专线", hit.get("name")); + } + + @Test + public void fallbackDefaultWhenNoKeyword() { + List> list = new ArrayList<>(); + list.add(tpl("默认空运", "空运", true, "欧洲")); + list.add(tpl("洛杉矶专线", "空运", false, "洛杉矶")); + Map facts = new HashMap<>(); + facts.put("目的港", "纽约"); + Map hit = QuoteTemplateMatcher.pick(list, "空运", facts, null); + assertNotNull(hit); + assertEquals("default", hit.get("matchType")); + assertEquals("默认空运", hit.get("name")); + } + + @Test + public void noneWhenNoKeywordAndNoDefault() { + List> list = new ArrayList<>(); + list.add(tpl("洛杉矶专线", "空运", false, "洛杉矶")); + Map facts = new HashMap<>(); + facts.put("目的港", "纽约"); + assertNull(QuoteTemplateMatcher.pick(list, "AIR", facts, null)); + } + + @Test + public void seaTemplateIgnoredForAir() { + List> list = new ArrayList<>(); + list.add(tpl("海运默认", "海运", true, "洛杉矶")); + Map facts = new HashMap<>(); + facts.put("目的港", "洛杉矶"); + assertNull(QuoteTemplateMatcher.pick(list, "AIR", facts, null)); + } + + private static Map tpl(String name, String biz, boolean isDefault, String keyword) { + Map m = new HashMap<>(); + m.put("name", name); + m.put("bizType", biz); + m.put("isDefault", isDefault); + List kws = new ArrayList<>(); + kws.add(keyword); + m.put("keywords", kws); + return m; + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsExactProjectorTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsExactProjectorTest.java new file mode 100644 index 0000000..0698c37 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsExactProjectorTest.java @@ -0,0 +1,35 @@ +package org.jeecg.modules.inquiry.tms; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 机场投影单测:不打真实 TMS。 + */ +public class TmsExactProjectorTest { + + private TmsExactProjector projector; + + @BeforeEach + public void setUp() { + projector = new TmsExactProjector(); + projector.load(); + } + + @Test + public void iataCodePassesThroughWhenNotInSeed() { + TmsExactProjector.Projection hit = projector.resolveAirport("SIN"); + assertTrue(hit.ok()); + assertEquals("SIN", hit.code); + } + + @Test + public void seedNameStillMaps() { + TmsExactProjector.Projection hit = projector.resolveAirport("上海"); + assertTrue(hit.ok()); + assertEquals("SHA", hit.code); + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrencyTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrencyTest.java new file mode 100644 index 0000000..579b01a --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsQuoteCurrencyTest.java @@ -0,0 +1,111 @@ +package org.jeecg.modules.inquiry.tms; + +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 币种抽取单测:不打真实 TMS,禁止编造默认币种。 + */ +public class TmsQuoteCurrencyTest { + + @Test + public void headerCurrencyWins() { + Map data = new HashMap<>(); + data.put("currency", "cny"); + data.put("salePrice", "14669.60"); + assertEquals("CNY", TmsQuoteCurrency.fromHeader(data)); + } + + @Test + public void currencyCodeAlias() { + Map data = new HashMap<>(); + data.put("currencyCode", "USD"); + assertEquals("USD", TmsQuoteCurrency.fromHeader(data)); + } + + @Test + public void feeItemCurrencyWhenHeaderEmpty() { + Map fee = new HashMap<>(); + fee.put("feeName", "空运费"); + fee.put("salePrice", "14669.60"); + fee.put("currency", "CNY"); + Map data = new HashMap<>(); + data.put("feeItems", Collections.singletonList(fee)); + assertEquals("CNY", TmsQuoteCurrency.fromHeader(data)); + assertEquals("CNY", TmsQuoteCurrency.fromFeeRow(fee)); + } + + @Test + public void airOptionCurrencyWhenHeaderEmpty() { + Map option = new HashMap<>(); + option.put("currencyCode", "CNY"); + option.put("salePrice", "14669.60"); + Map data = new HashMap<>(); + data.put("airOptions", Collections.singletonList(option)); + assertEquals("CNY", TmsQuoteCurrency.fromHeader(data)); + } + + @Test + public void missingCurrencyIsEmptyNotInvented() { + Map data = new HashMap<>(); + data.put("salePrice", "14669.60"); + assertEquals("", TmsQuoteCurrency.fromHeader(data)); + assertEquals("14669.60", TmsQuoteCurrency.formatDisplay("", "14669.60")); + } + + @Test + public void rmbBecomesCny() { + assertEquals("CNY", TmsQuoteCurrency.normalize("RMB")); + assertEquals("CNY", TmsQuoteCurrency.normalize("人民币")); + } + + @Test + public void remarkIsNotCurrency() { + assertEquals("", TmsQuoteCurrency.normalize("含税")); + assertEquals("", TmsQuoteCurrency.normalize("CNY14669")); + } + + @Test + public void splitAndFormat() { + TmsQuoteCurrency.Money money = TmsQuoteCurrency.split("CNY 14669.60", ""); + assertEquals("14669.60", money.amount); + assertEquals("CNY", money.currency); + assertEquals("CNY 14669.60", TmsQuoteCurrency.formatDisplay("CNY", "14669.60")); + assertEquals("USD 1,200", TmsQuoteCurrency.formatDisplay("USD", "USD 1,200")); + } + + @Test + public void toAgentQuoteKeepsCurrency() { + Map fee = new HashMap<>(); + fee.put("feeName", "空运费"); + fee.put("salePrice", "14669.60"); + fee.put("currency", "CNY"); + fee.put("unit", "KG"); + fee.put("quantity", 1333.6); + fee.put("unitPrice", 11.0); + fee.put("remark", "计费重进位+1000KG"); + Map data = new HashMap<>(); + data.put("quoteFound", true); + data.put("totalSalePrice", "14669.60"); + data.put("currency", "CNY"); + data.put("feeItems", Collections.singletonList(fee)); + Map tms = new HashMap<>(); + tms.put("code", 1); + tms.put("tmsData", data); + Map quote = InquiryTmsQueryService.toAgentQuote(tms); + assertEquals("CNY", quote.get("currency")); + assertEquals("14669.60", quote.get("total")); + assertTrue(quote.get("fee_lines").contains("\"currency\":\"CNY\"")); + assertTrue(quote.get("fee_lines").contains("\"amount\":\"14669.60\"")); + assertTrue(quote.get("fee_lines").contains("\"unit\":\"KG\"")); + assertTrue(quote.get("fee_lines").contains("\"quantity\":\"1333.6\"")); + assertTrue(quote.get("fee_lines").contains("\"unit_price\":\"11.0\"")); + assertTrue(quote.get("fee_lines").contains("计费重进位+1000KG")); + } +} 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 new file mode 100644 index 0000000..171b1ff --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapperTest.java @@ -0,0 +1,61 @@ +package org.jeecg.modules.inquiry.tms; + +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +/** + * 请求映射单测:不打真实 TMS。 + */ +public class TmsV2RequestMapperTest { + + @Test + public void quoteDateGoesToTmsNotInquiryTime() { + Map seg = new HashMap<>(); + seg.put("originCode", "SHA"); + seg.put("destinationCode", "LAX"); + seg.put("packageType", "1"); + Map internal = new HashMap<>(); + internal.put("transportMode", "AIR"); + internal.put("quoteDate", "2026-09-14"); + internal.put("segments", Collections.singletonList(seg)); + Map body = TmsV2RequestMapper.quote(internal); + assertEquals("2026-09-14", body.get("quoteDate")); + assertFalse(body.containsKey("inquiryTime")); + String requestId = String.valueOf(body.get("requestId")); + assertEquals(36, requestId.length()); + assertEquals(true, String.valueOf(body.get("workOrderNo")).matches("WO\\d{12}")); + @SuppressWarnings("unchecked") + List> segs = (List>) body.get("segments"); + assertEquals("SHA", segs.get(0).get("originCode")); + } + + @Test + public void oralUnitsBecomeJsonNumbers() { + Map seg = new HashMap<>(); + seg.put("originCode", "ZUH"); + seg.put("destinationCode", "CRK"); + seg.put("quantity", "10件"); + seg.put("weightKg", "100KGS"); + seg.put("volumeCbm", "2CBM"); + seg.put("packageType", "1"); + Map internal = new HashMap<>(); + internal.put("transportMode", "AIR"); + internal.put("quoteDate", "2026-09-07T18:00:00+08:00"); + internal.put("segments", Collections.singletonList(seg)); + Map body = TmsV2RequestMapper.quote(internal); + assertEquals("2026-09-07", body.get("quoteDate")); + @SuppressWarnings("unchecked") + List> segs = (List>) body.get("segments"); + assertEquals(Integer.valueOf(10), segs.get(0).get("quantity")); + assertEquals(new java.math.BigDecimal("100"), segs.get(0).get("weightKg")); + assertEquals(new java.math.BigDecimal("2"), segs.get(0).get("volumeCbm")); + assertFalse(String.valueOf(segs.get(0).get("weightKg")).contains("KGS")); + } +} diff --git a/inquiry-api/jeecg-module-system/jeecg-system-start/src/main/resources/application-inquiry.yml b/inquiry-api/jeecg-module-system/jeecg-system-start/src/main/resources/application-inquiry.yml index 56eb9bb..685d589 100644 --- a/inquiry-api/jeecg-module-system/jeecg-system-start/src/main/resources/application-inquiry.yml +++ b/inquiry-api/jeecg-module-system/jeecg-system-start/src/main/resources/application-inquiry.yml @@ -121,7 +121,8 @@ jeecg: upload: ${JEECG_UPLOAD_PATH:E:/wwwroot/data/upFiles} webapp: ${JEECG_WEBAPP_PATH:E:/wwwroot/data/webapp} shiro: - excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/** + # 智能体建单/查价走控制器 X-Agent-Token,不走后台登录 JWT(含带 context-path 的写法) + excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/inquiry/agent/**,/jeecgboot/inquiry/agent/** elasticsearch: cluster-name: jeecg-ES cluster-nodes: 127.0.0.1:9200 @@ -151,7 +152,13 @@ justauth: enabled: false # 关闭无关演示可在菜单/权限里禁用;本 profile 不额外加载云模块 +# TMS V2:地址与开关来自环境变量,密钥不写本文件 +inquiry: + tms: + note: YTD_TMS_V2_ENABLED / TMS_V2_BASE_URL / TMS_V2_CLIENT_* 从进程环境注入;LOCK 默认关 + logging: level: org.jeecg: info org.springframework: warn + org.jeecg.modules.inquiry.tms: info diff --git a/inquiry-backend/package.json b/inquiry-backend/package.json index a291495..95a67fe 100644 --- a/inquiry-backend/package.json +++ b/inquiry-backend/package.json @@ -33,17 +33,17 @@ "gen:icon": "esno ./build/generate/icon/index.ts" }, "dependencies": { - "@jeecg/online": "3.5.3-beta3", - "@qiaoqiaoyun/drag-free": "^1.0.52", - "@iconify/iconify": "^2.2.1", "@ant-design/colors": "^6.0.0", "@ant-design/icons-vue": "^6.1.0", + "@iconify/iconify": "^2.2.1", + "@jeecg/online": "3.5.3-beta3", "@logicflow/core": "^1.1.13", "@logicflow/extension": "^1.1.13", - "@vue/shared": "^3.2.33", + "@qiaoqiaoyun/drag-free": "^1.0.52", "@vue/runtime-core": "^3.2.33", - "@vueuse/shared": "^8.3.0", + "@vue/shared": "^3.2.33", "@vueuse/core": "^8.3.0", + "@vueuse/shared": "^8.3.0", "@zxcvbn-ts/core": "^2.0.1", "ant-design-vue": "^3.2.12", "axios": "^0.26.1", @@ -58,6 +58,7 @@ "echarts": "^5.3.2", "emoji-mart-vue-fast": "^11.1.1", "enquire.js": "^2.1.6", + "html2canvas": "^1.4.1", "intro.js": "^5.1.0", "lodash-es": "^4.17.21", "lodash.get": "^4.4.2", @@ -66,11 +67,11 @@ "nprogress": "^0.2.0", "path-to-regexp": "^6.2.0", "pinia": "2.0.12", - "print-js": "^1.6.0", "pinyin-pro": "^3.11.0", - "qs": "^6.10.3", + "print-js": "^1.6.0", "qrcode": "^1.5.0", "qrcodejs2": "0.0.2", + "qs": "^6.10.3", "resize-observer-polyfill": "^1.5.1", "showdown": "^2.1.0", "sortablejs": "^1.15.0", @@ -81,6 +82,7 @@ "vue-cropperjs": "^5.0.0", "vue-i18n": "^9.1.9", "vue-infinite-scroll": "^2.0.2", + "vue-json-pretty": "^2.0.6", "vue-print-nb-jeecg": "^1.0.12", "vue-router": "^4.0.14", "vue-types": "^4.1.1", @@ -88,7 +90,6 @@ "vxe-table": "4.1.0", "vxe-table-plugin-antd": "3.0.5", "xe-utils": "^3.3.1", - "vue-json-pretty": "^2.0.6", "xss": "^1.0.13" }, "devDependencies": { @@ -96,6 +97,7 @@ "@commitlint/config-conventional": "^16.2.1", "@iconify/json": "^2.1.30", "@purge-icons/generated": "^0.8.1", + "@rys-fe/vite-plugin-theme": "^0.8.6", "@types/codemirror": "^5.60.5", "@types/crypto-js": "^4.1.1", "@types/fs-extra": "^9.0.13", @@ -106,9 +108,9 @@ "@types/mockjs": "^1.0.6", "@types/node": "^17.0.25", "@types/nprogress": "^0.2.0", + "@types/pinyin": "^2.10.0", "@types/qrcode": "^1.4.2", "@types/qs": "^6.9.7", - "@types/pinyin": "^2.10.0", "@types/showdown": "^1.9.4", "@types/sortablejs": "^1.10.7", "@typescript-eslint/eslint-plugin": "^5.20.0", @@ -171,7 +173,6 @@ "vite-plugin-pwa": "^0.12.3", "vite-plugin-style-import": "^2.0.0", "vite-plugin-svg-icons": "^2.0.1", - "@rys-fe/vite-plugin-theme": "^0.8.6", "vite-plugin-vue-setup-extend-plus": "^0.1.0", "vite-plugin-windicss": "^1.8.7", "vue-eslint-parser": "^8.3.0", diff --git a/inquiry-backend/pnpm-lock.yaml b/inquiry-backend/pnpm-lock.yaml index 028901c..f3706e8 100644 --- a/inquiry-backend/pnpm-lock.yaml +++ b/inquiry-backend/pnpm-lock.yaml @@ -1,7 +1,7 @@ lockfileVersion: '6.0' settings: - autoInstallPeers: true + autoInstallPeers: false excludeLinksFromLockfile: false overrides: @@ -29,7 +29,7 @@ dependencies: version: 1.2.28(ts-node@10.9.2) '@qiaoqiaoyun/drag-free': specifier: ^1.0.52 - version: 1.1.4(@aesoper/normal-utils@0.1.5)(@interactjs/core@1.10.28)(@interactjs/utils@1.10.28)(@popperjs/core@2.11.8)(gradient-parser@1.2.0)(tinycolor2@1.6.0)(typescript@4.9.5) + version: 1.1.4(typescript@4.9.5) '@vue/runtime-core': specifier: ^3.2.33 version: 3.5.42 @@ -50,7 +50,7 @@ dependencies: version: 3.2.20(vue@3.5.42) axios: specifier: ^0.26.1 - version: 0.26.1(debug@4.4.3) + version: 0.26.1 china-area-data: specifier: ^5.0.1 version: 5.0.1 @@ -84,6 +84,9 @@ dependencies: enquire.js: specifier: ^2.1.6 version: 2.1.6 + html2canvas: + specifier: ^1.4.1 + version: 1.4.1 intro.js: specifier: ^5.1.0 version: 5.1.0 @@ -250,7 +253,7 @@ devDependencies: version: 5.62.0(eslint@8.57.1)(typescript@4.9.5) '@vitejs/plugin-legacy': specifier: ^2.0.0 - version: 2.3.1(terser@5.51.2)(vite@3.2.11) + version: 2.3.1(vite@3.2.11) '@vitejs/plugin-vue': specifier: ^3.0.1 version: 3.2.0(vite@3.2.11)(vue@3.5.42) @@ -262,7 +265,7 @@ devDependencies: version: 3.5.42 '@vue/test-utils': specifier: ^2.0.0-rc.21 - version: 2.5.0(@vue/compiler-dom@3.5.42)(vue@3.5.42) + version: 2.5.0(vue@3.5.42) autoprefixer: specifier: ^10.4.4 version: 10.5.6(postcss@8.5.28) @@ -379,7 +382,7 @@ devDependencies: version: 5.0.0(stylelint@14.16.1) ts-jest: specifier: ^27.0.7 - version: 27.1.5(@babel/core@7.29.7)(@types/jest@27.5.2)(esbuild@0.28.2)(jest@27.5.1)(typescript@4.9.5) + version: 27.1.5(@types/jest@27.5.2)(jest@27.5.1)(typescript@4.9.5) ts-node: specifier: ^10.7.0 version: 10.9.2(@types/node@17.0.45)(typescript@4.9.5) @@ -388,7 +391,7 @@ devDependencies: version: 4.9.5 vite: specifier: ^3.0.2 - version: 3.2.11(@types/node@17.0.45)(less@4.9.1)(terser@5.51.2) + version: 3.2.11(@types/node@17.0.45)(less@4.9.1) vite-plugin-compression: specifier: ^0.5.1 version: 0.5.1(vite@3.2.11) @@ -415,7 +418,7 @@ devDependencies: version: 0.8.2(vite@3.2.11) vite-plugin-pwa: specifier: ^0.12.3 - version: 0.12.8(vite@3.2.11)(workbox-build@6.6.0)(workbox-window@6.6.0) + version: 0.12.8(vite@3.2.11) vite-plugin-style-import: specifier: ^2.0.0 version: 2.0.0(vite@3.2.11) @@ -439,6 +442,7 @@ packages: /@aesoper/normal-utils@0.1.5: resolution: {integrity: sha512-LFF/6y6h5mfwhnJaWqqxuC8zzDaHCG62kMRkd8xhDtq62TQj9dM17A9DhE87W7DhiARJsHLgcina/9P4eNCN1w==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. dev: false /@ant-design/colors@6.0.0: @@ -516,7 +520,7 @@ packages: '@babel/types': 7.29.8 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@9.4.0) + debug: 4.4.3 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -588,7 +592,7 @@ packages: '@babel/core': 7.29.7 '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - debug: 4.4.3(supports-color@9.4.0) + debug: 4.4.3 lodash.debounce: 4.0.8 resolve: 1.22.12 transitivePeerDependencies: @@ -1687,7 +1691,7 @@ packages: '@babel/parser': 7.29.8 '@babel/template': 7.29.7 '@babel/types': 7.29.8 - debug: 4.4.3(supports-color@9.4.0) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -1802,7 +1806,7 @@ packages: '@types/node': 17.0.45 chalk: 4.1.2 cosmiconfig: 7.1.0 - cosmiconfig-typescript-loader: 2.0.2(@types/node@17.0.45)(cosmiconfig@7.1.0)(typescript@4.9.5) + cosmiconfig-typescript-loader: 2.0.2(@types/node@17.0.45)(typescript@4.9.5) lodash: 4.18.1 resolve-from: 5.0.0 typescript: 4.9.5 @@ -2221,7 +2225,7 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: ajv: 6.15.0 - debug: 4.4.3(supports-color@9.4.0) + debug: 4.4.3 espree: 9.6.1 globals: 13.24.0 ignore: 5.3.2 @@ -2294,7 +2298,7 @@ packages: deprecated: Use @eslint/config-array instead dependencies: '@humanwhocodes/object-schema': 2.0.3 - debug: 4.4.3(supports-color@9.4.0) + debug: 4.4.3 minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -2353,6 +2357,15 @@ packages: iconv-lite: 0.7.3 dev: true + /@interactjs/actions@1.10.28: + resolution: {integrity: sha512-ulZ/N2U7QCQRrlCzyBGj9SmXnCbNaFWRz4d4Q39LEjyOVNz3NZUp0BFls4dYPJKqNygvbP3CMrS0ohems9IJaw==} + peerDependencies: + '@interactjs/core': 1.10.28 + '@interactjs/utils': 1.10.28 + optionalDependencies: + '@interactjs/interact': 1.10.28 + dev: false + /@interactjs/actions@1.10.28(@interactjs/core@1.10.28)(@interactjs/utils@1.10.28): resolution: {integrity: sha512-ulZ/N2U7QCQRrlCzyBGj9SmXnCbNaFWRz4d4Q39LEjyOVNz3NZUp0BFls4dYPJKqNygvbP3CMrS0ohems9IJaw==} peerDependencies: @@ -2375,6 +2388,15 @@ packages: '@interactjs/interact': 1.10.28 dev: false + /@interactjs/auto-start@1.10.28: + resolution: {integrity: sha512-kvTJ5+G/y+KIJbqy+JER6hD3//u9Jc2rMK+G1feSQzMEsx+pZTlzsqN2cGI+lMrfXLX7CjGl9tKMOQdlZNzf+Q==} + peerDependencies: + '@interactjs/core': 1.10.28 + '@interactjs/utils': 1.10.28 + optionalDependencies: + '@interactjs/interact': 1.10.28 + dev: false + /@interactjs/auto-start@1.10.28(@interactjs/core@1.10.28)(@interactjs/utils@1.10.28): resolution: {integrity: sha512-kvTJ5+G/y+KIJbqy+JER6hD3//u9Jc2rMK+G1feSQzMEsx+pZTlzsqN2cGI+lMrfXLX7CjGl9tKMOQdlZNzf+Q==} peerDependencies: @@ -2410,6 +2432,20 @@ packages: - typescript dev: false + /@interactjs/dev-tools@1.10.28(@interactjs/modifiers@1.10.28)(typescript@4.9.5): + resolution: {integrity: sha512-MEF+jWXpC/wYt2bFXV305ONBZEhsV9FiPdSlVvb4Fdyq1FexOxn0o09tk+AwPAEhJm/pKyUwPsHzOsliZC8zQg==} + peerDependencies: + '@interactjs/modifiers': 1.10.28 + '@interactjs/utils': 1.10.28 + dependencies: + '@interactjs/modifiers': 1.10.28 + optionalDependencies: + '@interactjs/interact': 1.10.28 + vue: 3.5.42(typescript@4.9.5) + transitivePeerDependencies: + - typescript + dev: false + /@interactjs/inertia@1.10.28(@interactjs/core@1.10.28)(@interactjs/modifiers@1.10.28)(@interactjs/utils@1.10.28): resolution: {integrity: sha512-OcaQDcRNVr9lEoBZaiUi92UQ0pPojBwDlbwkMC9oeEpu5pfd6SnwtkMkya8ySRejFUzKWSLc9Gy0+nGHV2baJA==} peerDependencies: @@ -2451,6 +2487,17 @@ packages: - typescript dev: false + /@interactjs/modifiers@1.10.28: + resolution: {integrity: sha512-DyWyfvUSfDFBAbTUSo6TwLqQ8Pg40q6wvzYr0JNtWfzT2W8v2YulqwCv2kysJCr+4Ipup3fqjSiyTZ71Sh9A6w==} + peerDependencies: + '@interactjs/core': 1.10.28 + '@interactjs/utils': 1.10.28 + dependencies: + '@interactjs/snappers': 1.10.28 + optionalDependencies: + '@interactjs/interact': 1.10.28 + dev: false + /@interactjs/modifiers@1.10.28(@interactjs/core@1.10.28)(@interactjs/utils@1.10.28): resolution: {integrity: sha512-DyWyfvUSfDFBAbTUSo6TwLqQ8Pg40q6wvzYr0JNtWfzT2W8v2YulqwCv2kysJCr+4Ipup3fqjSiyTZ71Sh9A6w==} peerDependencies: @@ -2500,6 +2547,14 @@ packages: '@interactjs/interact': 1.10.28 dev: false + /@interactjs/snappers@1.10.28: + resolution: {integrity: sha512-YbVpzzQer84fTsBmAA5Fl1sXs3kbekvQphAN5liLYbdfXtYwZaoLc9heH29UpzlH/cLUmN+vJJ2+BJnG7CV+xg==} + peerDependencies: + '@interactjs/utils': 1.10.28 + optionalDependencies: + '@interactjs/interact': 1.10.28 + dev: false + /@interactjs/snappers@1.10.28(@interactjs/utils@1.10.28): resolution: {integrity: sha512-YbVpzzQer84fTsBmAA5Fl1sXs3kbekvQphAN5liLYbdfXtYwZaoLc9heH29UpzlH/cLUmN+vJJ2+BJnG7CV+xg==} peerDependencies: @@ -2832,7 +2887,7 @@ packages: dependencies: '@iconify/iconify': 2.1.2 axios: 0.26.1(debug@4.4.3) - debug: 4.4.3(supports-color@9.4.0) + debug: 4.4.3 fast-glob: 3.3.3 fs-extra: 10.1.0 transitivePeerDependencies: @@ -2846,7 +2901,7 @@ packages: '@iconify/iconify': 2.2.1 dev: true - /@qiaoqiaoyun/drag-free@1.1.4(@aesoper/normal-utils@0.1.5)(@interactjs/core@1.10.28)(@interactjs/utils@1.10.28)(@popperjs/core@2.11.8)(gradient-parser@1.2.0)(tinycolor2@1.6.0)(typescript@4.9.5): + /@qiaoqiaoyun/drag-free@1.1.4(typescript@4.9.5): resolution: {integrity: sha512-eH4fVMj4GtGN4Ix4XZirTwqRHziRFHPTZ4cVDXItRAB8jWIMy21en0magxQmy+hhJaQxjc0W9LfmQXyaM8H67g==} dependencies: '@ant-design/colors': 7.2.1 @@ -2877,22 +2932,18 @@ packages: vue: 3.5.42(typescript@4.9.5) vue-clipboard3: 2.0.0 vue-count-to2: 1.0.6(vue@3.5.42) - vue-grid-layout: 3.0.0-beta1(@interactjs/core@1.10.28)(@interactjs/utils@1.10.28)(typescript@4.9.5) + vue-grid-layout: 3.0.0-beta1(typescript@4.9.5) vue-types: 4.2.1(vue@3.5.42) - vue3-colorpicker: 2.3.0(@aesoper/normal-utils@0.1.5)(@popperjs/core@2.11.8)(@vueuse/core@6.9.2)(gradient-parser@1.2.0)(lodash-es@4.18.1)(tinycolor2@1.6.0)(vue-types@4.2.1)(vue@3.5.42) + vue3-colorpicker: 2.3.0(@vueuse/core@6.9.2)(lodash-es@4.18.1)(vue-types@4.2.1)(vue@3.5.42) vuedraggable: 4.1.0(vue@3.5.42) xlsx: 0.17.5 transitivePeerDependencies: - - '@aesoper/normal-utils' - '@interactjs/core' - '@interactjs/utils' - - '@popperjs/core' - '@vue/composition-api' - debug - - gradient-parser - preact-render-to-string - supports-color - - tinycolor2 - typescript dev: false @@ -2902,7 +2953,7 @@ packages: peerDependencies: '@babel/core': ^7.0.0 '@types/babel__core': ^7.1.9 - rollup: ^2.72.0 + rollup: ^1.20.0||^2.0.0 peerDependenciesMeta: '@types/babel__core': optional: true @@ -2919,7 +2970,7 @@ packages: resolution: {integrity: sha512-yc2n43jcqVyGE2sqV5/YCmocy9ArjVAP/BeXyTtADTBBX6V0e5UMqwO8CdQ0kzjb6zu5P1qMzsScCMRvE9OlVg==} engines: {node: '>= 10.0.0'} peerDependencies: - rollup: ^2.72.0 + rollup: ^1.20.0||^2.0.0 dependencies: '@rollup/pluginutils': 3.1.0(rollup@2.80.0) '@types/resolve': 1.17.1 @@ -2933,7 +2984,7 @@ packages: /@rollup/plugin-replace@2.4.2(rollup@2.80.0): resolution: {integrity: sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==} peerDependencies: - rollup: ^2.72.0 + rollup: ^1.20.0 || ^2.0.0 dependencies: '@rollup/pluginutils': 3.1.0(rollup@2.80.0) magic-string: 0.25.9 @@ -2944,7 +2995,7 @@ packages: resolution: {integrity: sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==} engines: {node: '>= 8.0.0'} peerDependencies: - rollup: ^2.72.0 + rollup: ^1.20.0||^2.0.0 dependencies: '@types/estree': 0.0.39 estree-walker: 1.0.1 @@ -2969,11 +3020,11 @@ packages: '@types/tinycolor2': 1.4.6 chalk: 4.1.2 clean-css: 5.3.3 - debug: 4.4.3(supports-color@9.4.0) + debug: 4.4.3 esbuild: 0.11.23 esbuild-plugin-alias: 0.1.2 tinycolor2: 1.6.0 - vite: 3.2.11(@types/node@17.0.45)(less@4.9.1)(terser@5.51.2) + vite: 3.2.11(@types/node@17.0.45)(less@4.9.1) transitivePeerDependencies: - supports-color dev: true @@ -3329,7 +3380,7 @@ packages: '@typescript-eslint/scope-manager': 5.62.0 '@typescript-eslint/type-utils': 5.62.0(eslint@8.57.1)(typescript@4.9.5) '@typescript-eslint/utils': 5.62.0(eslint@8.57.1)(typescript@4.9.5) - debug: 4.4.3(supports-color@9.4.0) + debug: 4.4.3 eslint: 8.57.1 graphemer: 1.4.0 ignore: 5.3.2 @@ -3354,7 +3405,7 @@ packages: '@typescript-eslint/scope-manager': 5.62.0 '@typescript-eslint/types': 5.62.0 '@typescript-eslint/typescript-estree': 5.62.0(typescript@4.9.5) - debug: 4.4.3(supports-color@9.4.0) + debug: 4.4.3 eslint: 8.57.1 typescript: 4.9.5 transitivePeerDependencies: @@ -3381,7 +3432,7 @@ packages: dependencies: '@typescript-eslint/typescript-estree': 5.62.0(typescript@4.9.5) '@typescript-eslint/utils': 5.62.0(eslint@8.57.1)(typescript@4.9.5) - debug: 4.4.3(supports-color@9.4.0) + debug: 4.4.3 eslint: 8.57.1 tsutils: 3.21.0(typescript@4.9.5) typescript: 4.9.5 @@ -3405,7 +3456,7 @@ packages: dependencies: '@typescript-eslint/types': 5.62.0 '@typescript-eslint/visitor-keys': 5.62.0 - debug: 4.4.3(supports-color@9.4.0) + debug: 4.4.3 globby: 11.1.0 is-glob: 4.0.3 semver: 7.8.5 @@ -3447,7 +3498,7 @@ packages: resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} dev: true - /@vitejs/plugin-legacy@2.3.1(terser@5.51.2)(vite@3.2.11): + /@vitejs/plugin-legacy@2.3.1(vite@3.2.11): resolution: {integrity: sha512-J5KaGBlSt2tEYPVjM/C8dA6DkRzkFkbPe+Xb4IX5G+XOV5OGbVAfkMjKywdrkO3gGynO8S98i71Lmsff4cWkCQ==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: @@ -3459,8 +3510,7 @@ packages: magic-string: 0.26.7 regenerator-runtime: 0.13.11 systemjs: 6.15.1 - terser: 5.51.2 - vite: 3.2.11(@types/node@17.0.45)(less@4.9.1)(terser@5.51.2) + vite: 3.2.11(@types/node@17.0.45)(less@4.9.1) dev: true /@vitejs/plugin-vue-jsx@1.3.10: @@ -3484,7 +3534,7 @@ packages: vite: ^3.0.0 vue: ^3.2.25 dependencies: - vite: 3.2.11(@types/node@17.0.45)(less@4.9.1)(terser@5.51.2) + vite: 3.2.11(@types/node@17.0.45)(less@4.9.1) vue: 3.5.42(typescript@4.9.5) dev: true @@ -3657,7 +3707,7 @@ packages: /@vue/shared@3.5.42: resolution: {integrity: sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==} - /@vue/test-utils@2.5.0(@vue/compiler-dom@3.5.42)(vue@3.5.42): + /@vue/test-utils@2.5.0(vue@3.5.42): resolution: {integrity: sha512-6Clu5EKR/r6cDPYrKsu+8wenciWJJ3rhS9OEGsfDlZeZIhlJeEPGIZQHxE4lHRJCzPSq3EWMsFxQUqCvrbHQuQ==} peerDependencies: '@vue/compiler-dom': 3.x @@ -3667,7 +3717,6 @@ packages: '@vue/server-renderer': optional: true dependencies: - '@vue/compiler-dom': 3.5.42 js-beautify: 2.0.3 vue: 3.5.42(typescript@4.9.5) vue-component-type-helpers: 3.3.11 @@ -3744,7 +3793,7 @@ packages: /@windicss/config@1.9.4: resolution: {integrity: sha512-vKXJlEC01/LFuYLF6XZsrCv13QKsxByWFqzDRctTva+O4yqr5j0e5VWB4Dr2tgzVbZuYG5ewg7qywAI/9JD0Cw==} dependencies: - debug: 4.4.3(supports-color@9.4.0) + debug: 4.4.3 jiti: 1.21.7 windicss: 3.5.6 transitivePeerDependencies: @@ -3756,7 +3805,7 @@ packages: dependencies: '@antfu/utils': 0.7.10 '@windicss/config': 1.9.4 - debug: 4.4.3(supports-color@9.4.0) + debug: 4.4.3 fast-glob: 3.3.3 magic-string: 0.30.21 micromatch: 4.0.8 @@ -3844,7 +3893,7 @@ packages: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} dependencies: - debug: 4.4.3(supports-color@9.4.0) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -4106,17 +4155,26 @@ packages: possible-typed-array-names: 1.1.0 dev: true + /axios@0.26.1: + resolution: {integrity: sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==} + dependencies: + follow-redirects: 1.16.0 + transitivePeerDependencies: + - debug + dev: false + /axios@0.26.1(debug@4.4.3): resolution: {integrity: sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==} dependencies: follow-redirects: 1.16.0(debug@4.4.3) transitivePeerDependencies: - debug + dev: true /axios@1.20.0: resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} dependencies: - follow-redirects: 1.16.0(debug@4.4.3) + follow-redirects: 1.16.0 form-data: 4.0.6 https-proxy-agent: 5.0.1 proxy-from-env: 2.1.0 @@ -5246,12 +5304,11 @@ packages: engines: {node: '>= 0.4.0'} dev: true - /cosmiconfig-typescript-loader@2.0.2(@types/node@17.0.45)(cosmiconfig@7.1.0)(typescript@4.9.5): + /cosmiconfig-typescript-loader@2.0.2(@types/node@17.0.45)(typescript@4.9.5): resolution: {integrity: sha512-KmE+bMjWMXJbkWCeY4FJX/npHuZPNr9XF9q9CIQ/bpFwi1qHfCmSiKarrCcRa0LO4fWjk93pVoeRtJAkTGcYNw==} engines: {node: '>=12', npm: '>=6'} peerDependencies: '@types/node': '*' - cosmiconfig: '>=7' typescript: '>=3' dependencies: '@types/node': 17.0.45 @@ -5569,6 +5626,17 @@ packages: dev: true optional: true + /debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.3 + /debug@4.4.3(supports-color@9.4.0): resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -5580,6 +5648,7 @@ packages: dependencies: ms: 2.1.3 supports-color: 9.4.0 + dev: true /decamelize-keys@1.1.1: resolution: {integrity: sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==} @@ -6498,7 +6567,7 @@ packages: peerDependencies: esbuild: '>=0.12 <1' dependencies: - debug: 4.4.3(supports-color@9.4.0) + debug: 4.4.3 esbuild: 0.28.2 transitivePeerDependencies: - supports-color @@ -6819,7 +6888,7 @@ packages: ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@9.4.0) + debug: 4.4.3 doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -7320,6 +7389,15 @@ packages: resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} dev: true + /follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + /follow-redirects@1.16.0(debug@4.4.3): resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} engines: {node: '>=4.0'} @@ -7329,7 +7407,8 @@ packages: debug: optional: true dependencies: - debug: 4.4.3(supports-color@9.4.0) + debug: 4.4.3 + dev: true /for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} @@ -8021,7 +8100,7 @@ packages: dependencies: '@tootallnate/once': 1.1.2 agent-base: 6.0.2 - debug: 4.4.3(supports-color@9.4.0) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -8030,7 +8109,7 @@ packages: engines: {node: '>=8.0.0'} dependencies: eventemitter3: 4.0.7 - follow-redirects: 1.16.0(debug@4.4.3) + follow-redirects: 1.16.0 requires-port: 1.0.0 transitivePeerDependencies: - debug @@ -8064,7 +8143,7 @@ packages: engines: {node: '>= 6'} dependencies: agent-base: 6.0.2 - debug: 4.4.3(supports-color@9.4.0) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -8808,7 +8887,7 @@ packages: resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} engines: {node: '>=10'} dependencies: - debug: 4.4.3(supports-color@9.4.0) + debug: 4.4.3 istanbul-lib-coverage: 3.2.2 source-map: 0.6.1 transitivePeerDependencies: @@ -10854,7 +10933,7 @@ packages: engines: {node: '>= 10.12'} dependencies: async: 3.2.6 - debug: 4.4.3(supports-color@9.4.0) + debug: 4.4.3 transitivePeerDependencies: - supports-color dev: true @@ -11554,7 +11633,7 @@ packages: resolution: {integrity: sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==} deprecated: This package has been deprecated and is no longer maintained. Please use @rollup/plugin-terser peerDependencies: - rollup: ^2.72.0 + rollup: ^2.0.0 dependencies: '@babel/code-frame': 7.29.7 jest-worker: 26.6.2 @@ -11569,7 +11648,7 @@ packages: hasBin: true peerDependencies: rolldown: 1.x - rollup: ^2.72.0 + rollup: 2.x || 3.x || 4.x peerDependenciesMeta: rolldown: optional: true @@ -12396,7 +12475,7 @@ packages: colord: 2.10.0 cosmiconfig: 7.1.0 css-functions-list: 3.3.3 - debug: 4.4.3(supports-color@9.4.0) + debug: 4.4.3 fast-glob: 3.3.3 fastest-levenshtein: 1.0.16 file-entry-cache: 6.0.1 @@ -12467,6 +12546,7 @@ packages: /supports-color@9.4.0: resolution: {integrity: sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw==} engines: {node: '>=12'} + dev: true /supports-hyperlinks@2.3.0: resolution: {integrity: sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==} @@ -12760,7 +12840,7 @@ packages: escape-string-regexp: 1.0.5 dev: true - /ts-jest@27.1.5(@babel/core@7.29.7)(@types/jest@27.5.2)(esbuild@0.28.2)(jest@27.5.1)(typescript@4.9.5): + /ts-jest@27.1.5(@types/jest@27.5.2)(jest@27.5.1)(typescript@4.9.5): resolution: {integrity: sha512-Xv6jBQPoBEvBq/5i2TeSG9tt/nqkbpcurrEG1b+2yfBrcJelOZF9Ml6dmyMh7bcW9JyFbRYpR5rxROSlBLTZHA==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} hasBin: true @@ -12781,10 +12861,8 @@ packages: esbuild: optional: true dependencies: - '@babel/core': 7.29.7 '@types/jest': 27.5.2 bs-logger: 0.2.6 - esbuild: 0.28.2 fast-json-stable-stringify: 2.1.0 jest: 27.5.1(ts-node@10.9.2) jest-util: 27.5.1 @@ -13198,9 +13276,9 @@ packages: vite: '>=2.0.0' dependencies: chalk: 4.1.2 - debug: 4.4.3(supports-color@9.4.0) + debug: 4.4.3 fs-extra: 10.1.0 - vite: 3.2.11(@types/node@17.0.45)(less@4.9.1)(terser@5.51.2) + vite: 3.2.11(@types/node@17.0.45)(less@4.9.1) transitivePeerDependencies: - supports-color dev: true @@ -13222,7 +13300,7 @@ packages: html-minifier-terser: 6.1.0 node-html-parser: 5.4.2 pathe: 0.2.0 - vite: 3.2.11(@types/node@17.0.45)(less@4.9.1)(terser@5.51.2) + vite: 3.2.11(@types/node@17.0.45)(less@4.9.1) dev: true /vite-plugin-imagemin@0.6.1(vite@3.2.11): @@ -13239,7 +13317,7 @@ packages: '@types/imagemin-webp': 7.0.3 '@types/svgo': 2.6.4 chalk: 4.1.2 - debug: 4.4.3(supports-color@9.4.0) + debug: 4.4.3 esbuild: 0.14.54 fs-extra: 10.1.0 gifsicle: 5.2.0 @@ -13253,7 +13331,7 @@ packages: imagemin-webp: 6.1.0 jpegtran-bin: 6.0.1 pathe: 0.2.0 - vite: 3.2.11(@types/node@17.0.45)(less@4.9.1)(terser@5.51.2) + vite: 3.2.11(@types/node@17.0.45)(less@4.9.1) transitivePeerDependencies: - supports-color dev: true @@ -13264,9 +13342,9 @@ packages: peerDependencies: vite: '>=3' dependencies: - debug: 4.4.3(supports-color@9.4.0) + debug: 4.4.3 picocolors: 1.1.1 - vite: 3.2.11(@types/node@17.0.45)(less@4.9.1)(terser@5.51.2) + vite: 3.2.11(@types/node@17.0.45)(less@4.9.1) transitivePeerDependencies: - supports-color dev: true @@ -13282,12 +13360,12 @@ packages: chalk: 4.1.2 chokidar: 3.6.0 connect: 3.7.0 - debug: 4.4.3(supports-color@9.4.0) + debug: 4.4.3 esbuild: 0.14.54 fast-glob: 3.3.3 mockjs: 1.1.0 path-to-regexp: 6.3.0 - vite: 3.2.11(@types/node@17.0.45)(less@4.9.1)(terser@5.51.2) + vite: 3.2.11(@types/node@17.0.45)(less@4.9.1) transitivePeerDependencies: - supports-color dev: true @@ -13298,9 +13376,9 @@ packages: vite: ^2.0.0 vite-plugin-package-config: ^0.1.0 dependencies: - debug: 4.4.3(supports-color@9.4.0) + debug: 4.4.3 fs-extra: 10.1.0 - vite: 3.2.11(@types/node@17.0.45)(less@4.9.1)(terser@5.51.2) + vite: 3.2.11(@types/node@17.0.45)(less@4.9.1) vite-plugin-package-config: 0.1.1(vite@3.2.11) transitivePeerDependencies: - supports-color @@ -13311,8 +13389,8 @@ packages: peerDependencies: vite: ^2.0.0 dependencies: - debug: 4.4.3(supports-color@9.4.0) - vite: 3.2.11(@types/node@17.0.45)(less@4.9.1)(terser@5.51.2) + debug: 4.4.3 + vite: 3.2.11(@types/node@17.0.45)(less@4.9.1) transitivePeerDependencies: - supports-color dev: true @@ -13326,27 +13404,26 @@ packages: '@purge-icons/core': 0.8.0 '@purge-icons/generated': 0.8.1 rollup-plugin-purge-icons: 0.8.1 - vite: 3.2.11(@types/node@17.0.45)(less@4.9.1)(terser@5.51.2) + vite: 3.2.11(@types/node@17.0.45)(less@4.9.1) transitivePeerDependencies: - encoding - supports-color dev: true - /vite-plugin-pwa@0.12.8(vite@3.2.11)(workbox-build@6.6.0)(workbox-window@6.6.0): + /vite-plugin-pwa@0.12.8(vite@3.2.11): resolution: {integrity: sha512-pSiFHmnJGMQJJL8aJzQ8SaraZBSBPMGvGUkCNzheIq9UQCEk/eP3UmANNmS9eupuhIpTK8AdxTOHcaMcAqAbCA==} peerDependencies: vite: ^2.0.0 || ^3.0.0-0 - workbox-build: ^6.4.0 - workbox-window: ^6.4.0 dependencies: - debug: 4.4.3(supports-color@9.4.0) + debug: 4.4.3 fast-glob: 3.3.3 pretty-bytes: 6.1.1 rollup: 2.80.0 - vite: 3.2.11(@types/node@17.0.45)(less@4.9.1)(terser@5.51.2) + vite: 3.2.11(@types/node@17.0.45)(less@4.9.1) workbox-build: 6.6.0 workbox-window: 6.6.0 transitivePeerDependencies: + - '@types/babel__core' - supports-color dev: true @@ -13362,7 +13439,7 @@ packages: fs-extra: 10.1.0 magic-string: 0.25.9 pathe: 0.2.0 - vite: 3.2.11(@types/node@17.0.45)(less@4.9.1)(terser@5.51.2) + vite: 3.2.11(@types/node@17.0.45)(less@4.9.1) dev: true /vite-plugin-svg-icons@2.0.1(vite@3.2.11): @@ -13372,13 +13449,13 @@ packages: dependencies: '@types/svgo': 2.6.4 cors: 2.8.6 - debug: 4.4.3(supports-color@9.4.0) + debug: 4.4.3 etag: 1.8.1 fs-extra: 10.1.0 pathe: 0.2.0 svg-baker: 1.7.0 svgo: 2.8.4 - vite: 3.2.11(@types/node@17.0.45)(less@4.9.1)(terser@5.51.2) + vite: 3.2.11(@types/node@17.0.45)(less@4.9.1) transitivePeerDependencies: - supports-color dev: true @@ -13393,15 +13470,15 @@ packages: vite: '*' dependencies: '@windicss/plugin-utils': 1.9.4 - debug: 4.4.3(supports-color@9.4.0) + debug: 4.4.3 kolorist: 1.8.0 - vite: 3.2.11(@types/node@17.0.45)(less@4.9.1)(terser@5.51.2) + vite: 3.2.11(@types/node@17.0.45)(less@4.9.1) windicss: 3.5.6 transitivePeerDependencies: - supports-color dev: true - /vite@3.2.11(@types/node@17.0.45)(less@4.9.1)(terser@5.51.2): + /vite@3.2.11(@types/node@17.0.45)(less@4.9.1): resolution: {integrity: sha512-K/jGKL/PgbIgKCiJo5QbASQhFiV02X9Jh+Qq0AKCRCRKZtOTVi4t6wh75FDpGf2N9rYOnzH87OEFQNaFy6pdxQ==} engines: {node: ^14.18.0 || >=16.0.0} hasBin: true @@ -13432,7 +13509,6 @@ packages: postcss: 8.5.28 resolve: 1.22.12 rollup: 2.80.0 - terser: 5.51.2 optionalDependencies: fsevents: 2.3.3 dev: true @@ -13512,7 +13588,7 @@ packages: peerDependencies: eslint: '>=6.0.0' dependencies: - debug: 4.4.3(supports-color@9.4.0) + debug: 4.4.3 eslint: 8.57.1 eslint-scope: 7.2.2 eslint-visitor-keys: 3.4.3 @@ -13530,7 +13606,7 @@ packages: peerDependencies: eslint: '>=6.0.0' dependencies: - debug: 4.4.3(supports-color@9.4.0) + debug: 4.4.3 eslint: 8.57.1 eslint-scope: 7.2.2 eslint-visitor-keys: 3.4.3 @@ -13542,14 +13618,14 @@ packages: - supports-color dev: true - /vue-grid-layout@3.0.0-beta1(@interactjs/core@1.10.28)(@interactjs/utils@1.10.28)(typescript@4.9.5): + /vue-grid-layout@3.0.0-beta1(typescript@4.9.5): resolution: {integrity: sha512-MsW0yfYNtnAO/uDhfZvkP6effxSJxvhAFbIL37x6Rn3vW9xf0WHVefKaSbQMLpSq3mXnR6ut0pg2Cd5lqIIZzg==} dependencies: - '@interactjs/actions': 1.10.28(@interactjs/core@1.10.28)(@interactjs/utils@1.10.28) - '@interactjs/auto-start': 1.10.28(@interactjs/core@1.10.28)(@interactjs/utils@1.10.28) - '@interactjs/dev-tools': 1.10.28(@interactjs/modifiers@1.10.28)(@interactjs/utils@1.10.28)(typescript@4.9.5) + '@interactjs/actions': 1.10.28 + '@interactjs/auto-start': 1.10.28 + '@interactjs/dev-tools': 1.10.28(@interactjs/modifiers@1.10.28)(typescript@4.9.5) '@interactjs/interactjs': 1.10.28(typescript@4.9.5) - '@interactjs/modifiers': 1.10.28(@interactjs/core@1.10.28)(@interactjs/utils@1.10.28) + '@interactjs/modifiers': 1.10.28 element-resize-detector: 1.2.4 mitt: 2.1.0 transitivePeerDependencies: @@ -13651,15 +13727,11 @@ packages: vue-resize: 0.4.5(vue@3.5.42) dev: false - /vue3-colorpicker@2.3.0(@aesoper/normal-utils@0.1.5)(@popperjs/core@2.11.8)(@vueuse/core@6.9.2)(gradient-parser@1.2.0)(lodash-es@4.18.1)(tinycolor2@1.6.0)(vue-types@4.2.1)(vue@3.5.42): + /vue3-colorpicker@2.3.0(@vueuse/core@6.9.2)(lodash-es@4.18.1)(vue-types@4.2.1)(vue@3.5.42): resolution: {integrity: sha512-e3lLmBcy7mkRrNQVeUny1DjOd6E11L8H5ok5Bx4MdXmrG+RzyacRF7KkhrEWmRYPhKAsaoUrWsFkmpPAaYnE5A==} peerDependencies: - '@aesoper/normal-utils': ^0.1.5 - '@popperjs/core': ^2.11.8 '@vueuse/core': ^10.1.2 - gradient-parser: ^1.0.2 lodash-es: ^4.17.21 - tinycolor2: ^1.4.2 vue: ^3.2.6 vue-types: ^4.1.0 dependencies: diff --git a/prompt/04-智能体与模型配置.md b/prompt/04-智能体与模型配置.md index 5040348..e711cb9 100644 --- a/prompt/04-智能体与模型配置.md +++ b/prompt/04-智能体与模型配置.md @@ -38,10 +38,11 @@ 查价必填(激活业务线之后): -- **海运**:起运港、目的港、品名、货量、整柜或拼柜、箱型箱量 +- **海运**:起运港、目的港、品名、货量、整柜或拼柜、箱型箱量、报价日期 箱型箱量是一个原始业务字段;结构化 `containerItems` 只由 DeepSeek B 投影。 -- **空运**:起运地、目的地、品名、件数、毛重、体积、包装类型 -- **陆运**按二级类型:国内拼车/整车、跨境集拼/整车、中港(见合同 JSON) +- **空运**:起运地、目的地、品名、件数、重量、体积、包装类型、报价日期 +- **陆运**按二级类型:国内拼车/整车、跨境集拼/整车、中港(见合同 JSON),各类型均含报价日期 +- **报价日期**:三线查价都要,用来查 TMS 当天/指定日的价;销售没说则系统默认为当天(上海时区),不补问;销售说了用原话,不得用当天盖掉。 协同阶段另有协同必填(贸易条款、HS、含电含磁等),点「协同」必须用 **不可变销售原话** 重跑模型 B,不得用卡片口令覆盖。 @@ -51,13 +52,11 @@ | 分类 | 含义 | |---|---| -| 有价 | 业务码成功且有报价 | -| `TMS_NO_QUOTE_1002` | 无价 | -| `TMS_NO_QUOTE_1003` / 合同不匹配 | 业务缺项或投影失败 | -| 字典/协议投影失败 | Java 投影失败,不是「没价格」 | +| 有价 | 业务码成功且有报价,返回报价 | +| `TMS_NO_QUOTE_1002` | 暂无报价(含 TMS 未匹配、TMS 仍缺项) | | 技术失败 | HTTP/超时/熔断 | -字典:精确匹配 `source_code`/`source_name`,禁止模糊、正则、别名推断。域:空运机场、包装、陆运地址/车型/运输类型、海运箱型、币种参考。 +查价前只校验询价必填是否已齐。销售原文非空即采集(包装写散货就记散货),本地不做包装白名单、不对销售再拦。卡片按用户说的展示,再去查 TMS。 ## 报价文件 diff --git a/prompt/05-总控提示词.md b/prompt/05-总控提示词.md index 42756fe..bbc0358 100644 --- a/prompt/05-总控提示词.md +++ b/prompt/05-总控提示词.md @@ -9,7 +9,7 @@ 1. 企微私聊:文字、图片、Excel/PDF 三种询价入口。 2. 海运 / 空运 / 陆运(含陆运二级类型)按唯一字段合同补问:完整输入不误补、不漏字段。 3. 随时新开询价并保存旧工单;可激活历史工单从精确断点继续;不重放已提交副作用。 -4. TMS:有价、无价 1002、业务缺项 1003、字典/协议失败、技术异常,分支互不混淆。 +4. TMS:询价必填齐了就查价;有价返回报价,没有返回暂无报价;技术异常单独分支。不做本地字典对应判断。 5. 需要协同时建群或复用空运固定群;群文字与群附件都能形成正确报价版本。 6. 采用、调价、Excel/PDF、对象存储、附件登记、企微发送、流程唤醒共用同一 quoteVersion。 7. 正式报价后:成交、未成交、协商中;未成交原因、协商调价、终止、超时不串路由。 diff --git a/prompt/06-Python智能体.md b/prompt/06-Python智能体.md index 56d580b..24ac493 100644 --- a/prompt/06-Python智能体.md +++ b/prompt/06-Python智能体.md @@ -108,7 +108,7 @@ NormalizedInbound ## 出站 - 主账:登录或 Token,所有写操作要幂等键(message_id / request_id / quoteVersion)。 -- 企微:只通过 outbox;失败重试;过期出站丢弃不计业务成功。询价与正式同一 Agent `1000010`;运维 `1000014`,测试告警正文加 `[test]`。 +- 企微:只通过 outbox;失败重试;过期出站丢弃不计业务成功。询价与正式同一 Agent `1000010`;运维 `1000014`,测试**告警**正文加 `[test]`,销售询价确认不加 `[test]`、不加「询价卡片」标题。建单确认按空运询价必填 8 项:起运地、目的地、品名、件数、重量、体积、包装类型、报价日期(另加工程单号、运输方式),末行「工单已创建,正在查询TMS报价。」销售侧标签不得写成毛重、重量(KG)、体积(CBM)。缺项补问先回显已识别,再列待补充并给示例(重量如 100KGS、体积如 2CBM、包装如托盘),待补充行不加圆点;报价日期销售说了写入已识别,没说默认当天,不进待补充。起运地/目的地说了地名即采集完成,不得因对不上三字码再补问。每次出询价确认都新开一单再查 TMS,相同需求不复用旧工单号。包装等字段销售说什么就记什么,禁止白名单把散货等原文挡回去再补问。 - 对象存储:S3 兼容;生成文件后登记主账附件再发企微。 ## 测试 diff --git a/prompt/07-Java主账.md b/prompt/07-Java主账.md index 0358406..002e527 100644 --- a/prompt/07-Java主账.md +++ b/prompt/07-Java主账.md @@ -36,7 +36,7 @@ ## API 能力(与智能体、后台对齐) -- 工单:创建、校验、查询、聚合视图、六态 transition、异常 handle/retry。 +- 工单:创建、校验、查询、聚合视图、六态 transition、异常 handle/retry。智能体每次出询价确认都新开一单,相同需求不复用未关闭工单。 - TMS:query、template-fields、detail、health、字典 sync。lock/release 接口可留,**与现网共用 TMS 密钥期间默认关闭出站**。 - 报价决策:采用、调价、成本/售价基线(空运与海陆规则不同,与合同语义对齐)。 - 模板、识别任务、生成任务的查询接口(任务执行可在智能体,主账存结果指针)。 @@ -58,11 +58,11 @@ 路径默认:`/quote/v2/query`、`/quote/v2/detail`、`/quote/template-fields`、`/air/cabin/lock`、`/air/cabin/release`、`/auth/token`。地址与 Client 三件套、字典 Token **直接用 `03` 现网值**,不要另要一套 TMS。智能体仍只打**新主账**,不直连 TMS、不打现网 Java 8080。 -## 字典投影 +## 查价(不做字典对应) -- EXACT_ONLY,禁止模糊。 -- 空运包装缺省可投影 `packageType=1`(全部),并保留 raw。 -- 失败要返回智能体能区分的 `source=TMS_DICTIONARY_PROJECTION` 与明确错误码,不要装成「无报价」。 +- 询价必填齐了就出站查 TMS,本地不把「珠海」对成机场码、不因对不上而拒绝查价。 +- 有价返回报价;TMS 无匹配或未出价格则「暂无报价」。 +- 技术失败(超时/熔断/未配置)单独分类,禁止 fixture 假价。 ## 不要 diff --git a/prompt/10-TMS对接.md b/prompt/10-TMS对接.md index 64be33b..442d58d 100644 --- a/prompt/10-TMS对接.md +++ b/prompt/10-TMS对接.md @@ -13,21 +13,21 @@ ## 查询请求 -主账把智能体已确认字段 + 字典投影结果映射为 TMS V2 body。投影规则以 `tms-dictionary-projection-v2.json` 为准(该文件放在新仓库): +主账按 TMS V2 `/quote/v2/query` 把空运查价参数带全再出站。智能体必须参数齐全才建单、才出询价卡片。 -- AIR:origin/destination → 机场代码;packaging → packageType -- LAND:地址、车型、运输类型 -- SEA:港口、箱型等 -- 匹配 EXACT_ONLY;多值歧义返回 AMBIGUOUS,不得 last-write-wins +空运出站必带:`requestId`(UUID)、`workOrderNo`(WOYYYYMMDDNNNN)、`transportMode=AIR`、`quoteDate`(yyyy-MM-dd)、`segments[0]` 的 origin/originCode/destination/destinationCode/cargoName、`quantity`(整数)、`weightKg`/`volumeCbm`(大于 0 的数字)、`packageType`。缺一项:补问,不出卡片,不出站。 + +- 销售未说报价日期则填当天,不补问该字段 +- 口语单位在出站前去掉;机场名能对应三字码则带上,销售已写三字码则原样带 +- 空运 `packageType` 必须是 TMS 字典码:1=全部(现网航线常无价)、2=托盘/散货/卡板、3=纸箱/木箱。销售卡片仍写原文;禁止把托盘编成 1 ## 响应 -规范化 envelope:`code`、`tmsData`、`tmsRequestId`。映射到智能体分类: +规范化 envelope:`code`、`tmsData`、`tmsRequestId`。销售侧只分两路业务结果: -- code 1 且有价 → 有价 -- 1002 → 无价 -- 1003 或投影失败 → 用户/合同缺项,**不是**无价 -- HTTP 5xx/超时/熔断 → 技术失败 +- code 1 且有价 → 返回报价 +- 1002 / 无 quoteFound / TMS 说缺项 → **暂无报价** +- HTTP 5xx/超时/熔断 → 技术失败(不是「暂无报价」) 锁舱/释放:**与现网共用同一套 TMS Client 时,新系统默认关闭**(API 返回未开放,企微/后台不展示)。只做 query / detail / template-fields / 字典同步。切流接管前再打开 lock/release,避免两套应用抢舱。