diff --git a/.cursor/rules/coding-standards.mdc b/.cursor/rules/coding-standards.mdc index ad04f64..9e29775 100644 --- a/.cursor/rules/coding-standards.mdc +++ b/.cursor/rules/coding-standards.mdc @@ -23,7 +23,7 @@ alwaysApply: true ## 来自 05 的硬约束 - 功能与现网业务合同等价。主账 = JeecgBoot + 低代码(2026-09-12);不要单文件上帝模块。 -- 后台与主账:Jeecg Vue + JeecgBoot;允许 Online/cgform 出询价相关生产页/API。库 `inquiry_robot`、并存端口 8180。禁止连现网 `jeecg-boot`/8080。 +- 后台与主账:Jeecg Vue + JeecgBoot;允许 Online/cgform 出询价相关生产页/API。库 `inquiry_robot`、主账端口 8080(现网旧 Jeecg 已停)。禁止连现网库 `jeecg-boot`。 - 自然语言只交给模型。代码只做身份、Schema、类型、权限、状态、版本、唯一性、证据、幂等。 - 主账是六态/报价版本/权限 Owner。Graph 成功不能冒充主账成功。 - TMS 只经主账。智能体不直连 TMS、不写 MySQL 工单表。 diff --git a/.cursor/rules/java-ledger.mdc b/.cursor/rules/java-ledger.mdc index 021af4f..a0c5da6 100644 --- a/.cursor/rules/java-ledger.mdc +++ b/.cursor/rules/java-ledger.mdc @@ -10,7 +10,7 @@ alwaysApply: true ## 方式(2026-09-12) -主账 = **JeecgBoot + Online/cgform/代码生成器**(工程目录 `inquiry-api/`)。复杂六态/TMS/报价可保留手写 `jeecg-module-inquiry`(`/inquiry/...`)。系统登录 `/sys/*`。并存端口 **8180**,库 **`inquiry_robot`**,禁止写现网 `jeecg-boot`。 +主账 = **JeecgBoot + Online/cgform/代码生成器**(工程目录 `inquiry-api/`)。复杂六态/TMS/报价可保留手写 `jeecg-module-inquiry`(`/inquiry/...`)。系统登录 `/sys/*`。端口 **8080**(现网旧 Jeecg 已停),库 **`inquiry_robot`**,禁止写现网库 `jeecg-boot`。 独立 `ledger/` Spring Boot 3 空壳不作正式主账。 diff --git a/.cursor/rules/pm-collaboration.mdc b/.cursor/rules/pm-collaboration.mdc index cc384c8..11e0e50 100644 --- a/.cursor/rules/pm-collaboration.mdc +++ b/.cursor/rules/pm-collaboration.mdc @@ -20,7 +20,7 @@ alwaysApply: true - 智能体只在 `inquiry-agent/` 已有包里填:`channel` / `routing` / `handlers` / `graph` / `jobs` / `ledger` 等。一类意图一个 handler,节点分文件。 - 主账只在 `inquiry-api/` 的 Jeecg + `jeecg-module-inquiry`。智能体只打主账 HTTP,不直连 TMS、不写 MySQL 工单表。 -- 后台只在 `inquiry-backend/` 的 Jeecg Vue,经 `/jeecgboot` 打 8180。 +- 后台只在 `inquiry-backend/` 的 Jeecg Vue,经 `/jeecgboot` 打 8080。 - 进程形态不变:一个主账、一个智能体 HTTP、一个 Worker、一个 AIBOT 桥、一个后台。 - 字段以仓库里两份 JSON 合同为准。未读不准写字段校验。 diff --git a/.cursor/rules/vue-admin.mdc b/.cursor/rules/vue-admin.mdc index 06fdd54..3de7e99 100644 --- a/.cursor/rules/vue-admin.mdc +++ b/.cursor/rules/vue-admin.mdc @@ -10,7 +10,7 @@ alwaysApply: true ## 正式入口 -正式前端 = **Jeecg Vue3**(目录 `inquiry-backend/`)。允许 Online/cgform 出询价相关生产页。不要把现网 8080 Jeecg 进程当新系统。 +正式前端 = **Jeecg Vue3**(目录 `inquiry-backend/`)。允许 Online/cgform 出询价相关生产页。主账听 **8080**(现网旧 Jeecg 已停),库仍是 `inquiry_robot`,禁止连旧库 `jeecg-boot`。 ## 页面(08) @@ -18,6 +18,6 @@ alwaysApply: true ## 技术(08) -Jeecg Vue3。`API_URL` 同域 `/jeecgboot` → 新主账 8180。域名 `ai.ytd-scm.com`。禁止写死、禁止 Docker 内网主机名。禁止浏览器直连 TMS/DeepSeek/企微。不本地存六态。不做 TMS 测试开关。构建不含 `.env`。 +Jeecg Vue3。`API_URL` 同域 `/jeecgboot` → 新主账 8080。域名 `ai.ytd-scm.com`。禁止写死、禁止 Docker 内网主机名。禁止浏览器直连 TMS/DeepSeek/企微。不本地存六态。不做 TMS 测试开关。构建不含 `.env`。 验收:Jeecg 登录 → 询价菜单 → 时间线附件 → 无权限无按钮;流量进 `inquiry_robot`。 diff --git a/inquiry-agent/agent/channel/outbox/sender.py b/inquiry-agent/agent/channel/outbox/sender.py index 72cb0f1..727c517 100644 --- a/inquiry-agent/agent/channel/outbox/sender.py +++ b/inquiry-agent/agent/channel/outbox/sender.py @@ -8,10 +8,12 @@ from __future__ import annotations +import json import logging import threading import time from dataclasses import dataclass +from pathlib import Path from typing import Any, Optional import httpx @@ -19,6 +21,7 @@ import httpx logger = logging.getLogger(__name__) + @dataclass class WeComSendResult: """企微发送结果(通道层)。""" @@ -496,7 +499,7 @@ class WeComAppClient: """ 解散应用群:官方无 dismiss,改为 appchat/update 移出成员。 - 不抛给回调。移完人不改工单六态。 + 群主会留在空群壳(企微限制,与历史行为一致)。不抛给回调。不改六态。 """ users = [str(x).strip() for x in (userids or []) if str(x).strip()] if not chat_id or not users: @@ -506,6 +509,7 @@ class WeComAppClient: except Exception as exc: # noqa: BLE001 logger.exception("解散群取 token 失败") return {"ok": False, "error": str(exc)} + before = self._appchat_info(chat_id=chat_id, token=token) url = f"{self._api_base}/cgi-bin/appchat/update" body = {"chatid": chat_id, "del_user_list": users} try: @@ -516,12 +520,39 @@ class WeComAppClient: logger.exception("企微解散群网络失败") return {"ok": False, "error": str(exc)} errcode = int(data.get("errcode") or 0) + after = self._appchat_info(chat_id=chat_id, token=token) if errcode == 0 else {} if errcode != 0: logger.warning("企微解散群失败 errcode=%s", errcode) return {"ok": False, "error": str(data.get("errmsg") or f"errcode_{errcode}")} logger.info("企微群已移出成员 chat=%s", chat_id) return {"ok": True, "error": ""} + def _appchat_info(self, *, chat_id: str, token: str = "") -> dict[str, Any]: + """读应用群群主与成员。失败回空字典,不抛。仅调试/核对用。""" + raw_token = (token or "").strip() + if not raw_token: + try: + raw_token = self._get_token() + except Exception: # noqa: BLE001 + return {} + url = f"{self._api_base}/cgi-bin/appchat/get" + try: + with httpx.Client(timeout=self._timeout) as client: + resp = client.get(url, params={"access_token": raw_token, "chatid": chat_id}) + data = resp.json() + except Exception: # noqa: BLE001 + return {} + if int(data.get("errcode") or 0) != 0: + return {} + info = data.get("chat_info") or data.get("chatinfo") or {} + return { + "owner": str(info.get("owner") or "").strip(), + "userlist": [ + str(x).strip() + for x in (info.get("userlist") or info.get("user_list") or []) + if str(x).strip() + ], + } def _post_appchat(self, body: dict[str, Any]) -> dict[str, Any]: """appchat/send 公共发送。失败不抛。""" if not self._corp_id or not self._secret: diff --git a/inquiry-agent/agent/channel/wshoto/poll.py b/inquiry-agent/agent/channel/wshoto/poll.py index 4931a6b..5c5efb5 100644 --- a/inquiry-agent/agent/channel/wshoto/poll.py +++ b/inquiry-agent/agent/channel/wshoto/poll.py @@ -16,9 +16,12 @@ from agent.channel.queue import get_message_store from agent.channel.wshoto.client import WshotoError, WshotoOpenClient, wshoto_ready from agent.channel.wshoto.parse import archive_record_to_inbound from agent.channel.wshoto.rooms import ( + advance_poll_cursor, + is_room_hot, list_watched_rooms, load_last_seq, mark_room_hot, + plan_rooms_for_poll, save_last_seq, ) from agent.config import Settings, get_settings @@ -26,8 +29,11 @@ from agent.redis_coord.client import RedisClient logger = logging.getLogger(__name__) -# 新群还没有游标时,只回看最近几分钟,避免每轮翻三小时。 -_NEW_ROOM_LOOKBACK_SEC = 180 +# 新群还没有游标时,回看窗口必须盖过「存档入库延迟 + 冷群轮转」。 +# 以前 180 秒:存档晚几分钟到、或本轮预算扫不到该群时,人话永久漏掉,last_seq 也永远建不起来。 +_NEW_ROOM_LOOKBACK_SEC = 3600 +# 微盛「请求频率过高」:本轮立刻停,下一轮再扫,避免把活跃群也拖死。 +_RATE_LIMIT_CODE = 10061 def archive_seat_userids(settings: Optional[Settings] = None) -> list[str]: @@ -146,6 +152,7 @@ def poll_once( page_size=page_size, lookback=lookback, clock=clock, + settings=cfg, ) if extra: records = list(records) + extra @@ -176,6 +183,7 @@ def poll_once( page_size=page_size, lookback=lookback, clock=clock, + settings=cfg, ) if extra: records = list(records) + extra @@ -226,9 +234,10 @@ def _poll_by_scan_users( """ 按人扫新群。空运群是人自建的,存档席没进群时房间名单里没有它。 - 副作用:看到带 @询价小助手 的新房间会登记监听。 + 副作用:看到带 @机器人显示名(含旧名)的新房间会登记监听。 """ from agent.channel.wshoto.rooms import watch_collab_room + from agent.policy.bot_name import text_mentions_bot merged: list[dict[str, Any]] = [] now = int(clock()) @@ -258,7 +267,7 @@ def _poll_by_scan_users( max_seq = seq room = str(rec.get("room_id") or rec.get("roomid") or "").strip() blob = str(rec.get("content") or "") - if room and "询价小助手" in blob: + if room and text_mentions_bot(blob): try: watch_collab_room(room, redis=redis) except Exception: @@ -276,19 +285,37 @@ def _poll_by_rooms( page_size: int, lookback: int, clock: Callable[[], float], + settings: Optional[Settings] = None, ) -> tuple[list[dict[str, Any]], str, int]: - """只拉已登记协同群。应用群不要带存档席 userid,否则管家给空页。""" - rooms = list_watched_rooms(redis=redis) - if not rooms: + """ + 只拉已登记协同群。应用群不要带存档席 userid,否则管家给空页。 + + 热群每轮必扫;冷群按游标轮转。遇微盛限频或时间预算用尽则本轮停,下一轮续扫。 + 禁止把每轮冷群压得太少:销售在冷群发「解散群」时,要等很多轮才会被听见。 + """ + cfg = settings or get_settings() + all_rooms = list_watched_rooms(redis=redis) + if not all_rooms: logger.warning("微盛按房间拉取:还没有登记过的协同群") return [], "rooms", 0 - rooms = sorted( - rooms, - key=lambda r: (0 if load_last_seq(f"room:{r}", redis=redis) <= 0 else 1, r), - ) + max_cold = int(getattr(cfg, "wshoto_rooms_per_poll", 120) or 120) + max_cold = min(200, max(1, max_cold)) + gap_ms = int(getattr(cfg, "wshoto_room_gap_ms", 100) or 100) + gap_ms = min(1000, max(0, gap_ms)) + budget_sec = float(getattr(cfg, "wshoto_poll_budget_sec", 20.0) or 20.0) + budget_sec = min(60.0, max(5.0, budget_sec)) + rooms = plan_rooms_for_poll(all_rooms, max_cold=max_cold, redis=redis) merged: list[dict[str, Any]] = [] floor = 0 + rate_limited = False + budget_hit = False + polled = 0 + cold_polled = 0 + t0 = time.time() for room in rooms: + if (time.time() - t0) >= budget_sec: + budget_hit = True + break scope = f"room:{room}" last = load_last_seq(scope, redis=redis) time_start = 0 @@ -307,7 +334,13 @@ def _poll_by_rooms( ) except WshotoError as exc: logger.warning("微盛房间拉取失败 chat=%s code=%s msg=%s", room, exc.code, exc.msg) + if int(exc.code) == _RATE_LIMIT_CODE: + rate_limited = True + break continue + polled += 1 + if not is_room_hot(room): + cold_polled += 1 max_seq = last for rec in rows: seq = _record_seq(rec) @@ -318,4 +351,17 @@ def _poll_by_rooms( if last > floor: floor = last merged.extend(rows) + if gap_ms > 0: + time.sleep(gap_ms / 1000.0) + # 只推进实际扫过的冷群,限频/超时停住的下轮接着扫。 + advance_poll_cursor(cold_polled, redis=redis) + if rate_limited or budget_hit: + logger.warning( + "微盛房间拉取提前结束 rate=%s budget=%s watched=%s planned=%s polled=%s", + rate_limited, + budget_hit, + len(all_rooms), + len(rooms), + polled, + ) return merged, "rooms", floor diff --git a/inquiry-agent/agent/channel/wshoto/rooms.py b/inquiry-agent/agent/channel/wshoto/rooms.py index 02f3265..30b7dc4 100644 --- a/inquiry-agent/agent/channel/wshoto/rooms.py +++ b/inquiry-agent/agent/channel/wshoto/rooms.py @@ -52,12 +52,98 @@ def mark_room_hot(chat_id: str, *, ttl_sec: float = HOT_WINDOW_SEC) -> None: def has_hot_room() -> bool: """是否还有热窗内的协同群。""" + return bool(list_hot_rooms()) + + +def list_hot_rooms() -> list[str]: + """当前热窗内的协同群(刚建群或刚有人说话)。""" now = time.time() with _hot_lock: stale = [k for k, until in _hot_until.items() if until <= now] for k in stale: _hot_until.pop(k, None) - return any(until > now for until in _hot_until.values()) + return [k for k, until in _hot_until.items() if until > now] + + +def is_room_hot(chat_id: str) -> bool: + """这群是否还在热窗内。""" + raw = (chat_id or "").strip() + if not raw: + return False + now = time.time() + with _hot_lock: + until = _hot_until.get(raw) or 0.0 + if until <= now: + _hot_until.pop(raw, None) + return False + return True + + +def plan_rooms_for_poll( + rooms: list[str], + *, + max_cold: int = 120, + redis: Optional[RedisClient] = None, +) -> list[str]: + """ + 本轮要扫的房间:热群全部优先,冷群按游标轮转。 + + 注意:这里只读游标、不推进。真正扫过多少冷群后,由 advance_poll_cursor 推进, + 避免限频/超时提前停时把没扫到的群直接跳过。 + """ + uniq: list[str] = [] + seen: set[str] = set() + for raw in rooms: + room = (raw or "").strip() + if not room or room in seen: + continue + seen.add(room) + uniq.append(room) + hot = [r for r in uniq if is_room_hot(r)] + hot_set = set(hot) + cold = [r for r in uniq if r not in hot_set] + budget = max(0, int(max_cold)) + if not cold or budget <= 0: + return hot + start = load_poll_cursor(redis=redis) % len(cold) + rotated = cold[start:] + cold[:start] + take = rotated[:budget] + # 热群在前,保证刚发过话/刚建的群本轮一定扫到。 + return hot + [r for r in take if r not in hot_set] + + +def load_poll_cursor(*, redis: Optional[RedisClient] = None) -> int: + """冷群轮转游标。""" + client = _client(redis) + if client is None: + return 0 + raw_cur = client.raw.get(client.key(WSHOTO_LAST_SEQ, "poll_room_cursor")) + if isinstance(raw_cur, bytes): + raw_cur = raw_cur.decode("utf-8") + try: + return int(raw_cur or 0) + except (TypeError, ValueError): + return 0 + + +def advance_poll_cursor( + cold_polled: int, + *, + redis: Optional[RedisClient] = None, +) -> None: + """ + 按本轮实际扫过的冷群数推进游标。 + + 限频或时间预算提前停时,只推进已扫到的,下轮从停住的地方继续。 + """ + n = max(0, int(cold_polled)) + if n <= 0: + return + client = _client(redis) + if client is None: + return + cur = load_poll_cursor(redis=client) + client.raw.set(client.key(WSHOTO_LAST_SEQ, "poll_room_cursor"), str(int(cur + n))) def _client(redis: Optional[RedisClient] = None) -> Optional[RedisClient]: diff --git a/inquiry-agent/agent/config/__init__.py b/inquiry-agent/agent/config/__init__.py index 342ed53..c505458 100644 --- a/inquiry-agent/agent/config/__init__.py +++ b/inquiry-agent/agent/config/__init__.py @@ -45,12 +45,12 @@ class Settings(BaseSettings): default="", alias="INQUIRY_FORM_PUBLIC_BASE_URL", ) - biz_api_base_url: str = Field(default="http://127.0.0.1:8180/jeecgboot", alias="BIZ_API_BASE_URL") + biz_api_base_url: str = Field(default="http://127.0.0.1:8080/jeecgboot", alias="BIZ_API_BASE_URL") biz_api_token: str = Field(default="", alias="BIZ_API_TOKEN") 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=单测假账本 + # http=打 8080 真主账/真 TMS;memory=单测假账本 ledger_backend: str = Field(default="http", alias="LEDGER_BACKEND") agent_callback_token: str = Field(default="", alias="AGENT_CALLBACK_TOKEN") # 兼容现网变量名 @@ -75,6 +75,18 @@ class Settings(BaseSettings): wshoto_poll_enabled: bool = Field(default=True, alias="WSHOTO_POLL_ENABLED") # 群消息靠会话存档,扫得越勤,听到得越快。热窗内还会再压到 1 秒。 wshoto_poll_interval_sec: float = Field(default=2.0, alias="WSHOTO_POLL_INTERVAL_SEC") + # 协同群监听对账间隔(秒)。主账绑群 vs Redis 监听,缺则补、结束则清。 + collab_watch_reconcile_interval_sec: float = Field( + default=900.0, + alias="COLLAB_WATCH_RECONCILE_INTERVAL_SEC", + ) + # 每轮最多扫多少个冷群。热窗内的群每轮必扫。 + # 默认尽量一轮扫完;遇限频会提前停并靠游标续扫。不要压太小,否则冷群要等很多轮才听见「解散群」。 + wshoto_rooms_per_poll: int = Field(default=120, alias="WSHOTO_ROOMS_PER_POLL") + # 单轮房间拉取时间预算(秒)。超时本轮停,下一轮从游标续,避免卡死 Worker。 + wshoto_poll_budget_sec: float = Field(default=20.0, alias="WSHOTO_POLL_BUDGET_SEC") + # 房间之间的最小间隔(毫秒),减轻「请求频率过高」。 + wshoto_room_gap_ms: int = Field(default=100, alias="WSHOTO_ROOM_GAP_MS") wshoto_bootstrap_lookback_sec: int = Field( default=10800, alias="WSHOTO_BOOTSTRAP_LOOKBACK_SEC", diff --git a/inquiry-agent/agent/foundation_selftest.py b/inquiry-agent/agent/foundation_selftest.py index b5c8fe8..85d3525 100644 --- a/inquiry-agent/agent/foundation_selftest.py +++ b/inquiry-agent/agent/foundation_selftest.py @@ -81,7 +81,7 @@ def main() -> int: logger.info("OK: ordered_actions 解析") ledger = LedgerClient.from_settings(settings) - assert "8180" in ledger.base_url or ledger.base_url + assert "8080" in ledger.base_url or ledger.base_url logger.info("OK: ledger client base=%s", ledger.base_url) ping = ledger.ping() logger.info("ledger.ping ok=%s status=%s", ping.ok, ping.status_code) diff --git a/inquiry-agent/agent/handlers/air_group_collab.py b/inquiry-agent/agent/handlers/air_group_collab.py index 1e748f2..c419eeb 100644 --- a/inquiry-agent/agent/handlers/air_group_collab.py +++ b/inquiry-agent/agent/handlers/air_group_collab.py @@ -205,46 +205,6 @@ def handle_air_group( intent, wo or "-", ) - # #region agent log - try: - import json - import time - from pathlib import Path - - from agent.llm.extract_text import labeled_quote_date - - _payload = { - "sessionId": "717349", - "runId": "post-fix", - "hypothesisId": "H1", - "location": "air_group_collab.py:handle_air_group", - "message": "air group inbound classified", - "data": { - "chat_tail": chat_id[-8:], - "sender_tail": (message.sender_id or "")[-8:], - "intent": intent, - "wo": wo or "", - "text_head": (text or "")[:100], - "labeled_qd": labeled_quote_date(text), - "source": str((message.raw or {}).get("source") or ""), - }, - "timestamp": int(time.time() * 1000), - } - _line = json.dumps(_payload, ensure_ascii=False) + "\n" - for _p in ( - Path(__file__).resolve().parents[3] / "debug-717349.log", - Path(r"C:\Users\ytd_extdev_0903\incoming\debug-717349.log"), - Path(r"E:\wwwroot\agent\current\debug-717349.log"), - ): - try: - _p.parent.mkdir(parents=True, exist_ok=True) - with _p.open("a", encoding="utf-8") as _fh: - _fh.write(_line) - except Exception: - pass - except Exception: - pass - # #endregion if intent in {INTENT_LOCK, INTENT_RELEASE, INTENT_RELEASE_ASK}: return _handle_cabin( engine, @@ -314,17 +274,34 @@ def _handle_file(engine, message: InboundMessage) -> str: wo = copy.extract_work_order_no( " ".join([message.content or "", filename]) ) - # 多段已激活的附件也跟当前单,不能再要求先发工单号。 - followed = _follow_activated_multi( - engine, - chat_id=chat_id, - sender_id=message.sender_id, - text=" ".join(part for part in ((message.content or "").strip(), filename) if part), - injected_quote=None, - named_wo=wo, - ) - if followed is not None: - return followed + # 多段附件:落库归段,禁止把文件名当文字报价(会回「没有识别到报价」)。 + from agent.policy.multi_group_ops import apply_multi_file, ticket_is_multi + from agent.policy.sea_group_ops import ticket_no as _ticket_no + + multi_ticket = None + if wo: + getter = getattr(engine.ledger, "get_ticket", None) + raw = getter(work_order_no=wo) if callable(getter) else None + if raw is not None and ticket_is_multi(raw): + multi_ticket = raw + if multi_ticket is None: + bound = current_ticket(engine, chat_id) + if bound is not None and ticket_is_multi(bound): + multi_ticket = bound + if multi_ticket is not None: + logger.info( + "air_group 多段附件 wo=%s file=%s", + _ticket_no(multi_ticket), + filename[:80], + ) + return apply_multi_file( + flow=engine, + ticket=multi_ticket, + chat_id=chat_id, + sender_id=message.sender_id, + filename=filename, + media=media, + ) if wo: ticket, phase = activate_ticket( flow=engine, chat_id=chat_id, work_order_no=wo, speak=False @@ -588,36 +565,6 @@ def _handle_bound( keep_tms=intent == INTENT_KEEP_TMS, ) if intent == INTENT_FIELDS: - # #region agent log - try: - import json - import time - from pathlib import Path - - _p = Path(__file__).resolve().parents[3] / "debug-866775.log" - with _p.open("a", encoding="utf-8") as _fh: - _fh.write( - json.dumps( - { - "sessionId": "866775", - "hypothesisId": "C", - "location": "air_group_collab.py:_handle_bound", - "message": "INTENT_FIELDS path", - "data": { - "text_head": (message.content or "")[:80], - "has_quote_date_label": ( - "报价日期" in (message.content or "") - ), - }, - "timestamp": int(time.time() * 1000), - }, - ensure_ascii=False, - ) - + "\n" - ) - except Exception: - pass - # #endregion return apply_air_fields( flow=engine, ticket=ticket, diff --git a/inquiry-agent/agent/handlers/card_action.py b/inquiry-agent/agent/handlers/card_action.py index 4d7f7d4..0b7b1e8 100644 --- a/inquiry-agent/agent/handlers/card_action.py +++ b/inquiry-agent/agent/handlers/card_action.py @@ -1,7 +1,7 @@ """ 意图:询价卡片按钮(复制工单号 / 拉产品进群 / 跳过协同 / 采用 / 不采用 / 成交)。 -本文件职责:校验当前会话允许的动作后交给流程执行。 +本文件职责:校验当前会话允许的动作后交给流程执行;卡片按钮集中防抖入口。 禁止:不校验 wait/允许集合就改主账;禁止一个函数处理未声明的新按钮。 """ @@ -15,6 +15,7 @@ from agent.policy import inquiry_copy as copy from agent.policy.air_text_flow import get_air_text_flow from agent.policy.land_text_flow import get_land_text_flow from agent.policy.sea_text_flow import get_sea_text_flow +from agent.redis_coord.card_debounce import debounce_key, try_acquire as try_card_debounce logger = logging.getLogger(__name__) @@ -57,6 +58,7 @@ def handle_card_action( wait_version:会话书签版本;错版本丢弃,不重放副作用。 payload 须带 task_id / event_key,才能找回点卡当时的工单。 + 连点:同一 task_id+按钮在防抖 TTL 内只跑一次业务,避免多条智能体提示。 """ _ = thread_id if not (sender_id or "").strip(): @@ -68,6 +70,41 @@ def handle_card_action( return "card_discard" msg_store = store or get_message_store() + wo_hint = "" + if sess is not None: + wo_hint = (sess.work_order_no or "").strip() + if not wo_hint: + wo_hint = copy.work_order_from_card_meta(payload) + # 集中防抖:键用 task_id+动作,不用 ResponseCode(企微每次点击换新码) + dkey = debounce_key( + action=action, + task_id=str((payload or {}).get("task_id") or ""), + work_order_no=wo_hint, + sender_id=sender_id, + event_key=str((payload or {}).get("event_key") or ""), + ) + if not try_card_debounce(dkey): + logger.info( + "card 防抖丢弃 sender=%s action=%s key=%s", + sender_id, + action, + dkey, + ) + # 仍用新 ResponseCode 置灰,避免企微按钮一直可点;不发业务 ack + code = str((payload or {}).get("response_code") or "").strip() + if code: + msg_store.enqueue_outbound( + touser=sender_id, + content=copy.BTN_SKIP_COLLAB_DONE, + dedupe_key=f"card-grey:{sender_id}:{wo_hint}:{code}", + payload=copy.skip_collab_card_update( + response_code=code, + replace_name=action if action in { + copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE + } else "", + ), + ) + return "card_debounced" def reply(text: str, extra: dict | None = None) -> str: # 销售询价正文不加 [test];[test] 只给运维告警 @@ -79,13 +116,16 @@ def handle_card_action( 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] + msgtype = str(extra.get("msgtype") or "text") + # 文本 ack 按工单+动作去重;置灰更新仍带 code(每次 ResponseCode 只能用一次) + if msgtype == "update_template_card": + dedupe_key = f"card-grey:{sender_id}:{wo}:{code}:{action}" + else: + dedupe_key = f"card-ack:{sender_id}:{wo}:{action}:{body[: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]}" - ), + dedupe_key=dedupe_key, payload=extra, ) if not ok: diff --git a/inquiry-agent/agent/handlers/group_collab.py b/inquiry-agent/agent/handlers/group_collab.py index fd7b65b..fe8062f 100644 --- a/inquiry-agent/agent/handlers/group_collab.py +++ b/inquiry-agent/agent/handlers/group_collab.py @@ -103,7 +103,12 @@ def handle_group_inbound( or "" ).upper() if line == "MULTI": - from agent.policy.multi_group_ops import bot_sender_when_air_multi, handle_multi_inbound, sender_mode + from agent.policy.multi_group_ops import ( + apply_multi_file, + bot_sender_when_air_multi, + handle_multi_inbound, + sender_mode, + ) if not ( _sender_allowed(ticket, message.sender_id) @@ -111,6 +116,22 @@ def handle_group_inbound( ): logger.info("group_inbound 多段忽略非成员 sender=%s", message.sender_id) return "group_ignore" + kind_multi = (message.msg_type or "text").lower() + # 附件走归段落库;禁止把文件名塞进文字报价。 + if kind_multi in {"file", "image", "video"}: + media = dict(message.media or {}) + filename = str( + media.get("filename") or media.get("FileName") or (message.content or "") + ).strip() + with bot_sender_when_air_multi(ticket): + return apply_multi_file( + flow=engine, + ticket=ticket, + chat_id=chat_id, + sender_id=message.sender_id, + filename=filename, + media=media, + ) # 会话存档听到的报价也走这里。含空运时群是机器人建的,必须改走桥才能发得出去。 with bot_sender_when_air_multi(ticket): return handle_multi_inbound( @@ -142,6 +163,20 @@ def handle_group_inbound( sess = _session_of_ticket(engine, ticket) if is_handoff_ticket(ticket, sess): + # 转人工后不再跟报价/成交/补字段,但「解散群」仍要执行,否则协同群清不掉。 + from agent.routing.group_intent import INTENT_DISSOLVE, classify_group_text + + kind0 = (message.msg_type or "text").lower() + if kind0 in {"", "text"} and ( + classify_group_text(message.content or "", injected=injected_intent) + == INTENT_DISSOLVE + ): + logger.info( + "group_inbound 转人工仍解散 wo=%s chat=%s", + ticket_no(ticket), + chat_id, + ) + return dissolve_group(flow=engine, ticket=ticket, sess=sess) logger.info( "group_inbound 转人工不再跟 wo=%s chat=%s", ticket_no(ticket), @@ -221,12 +256,13 @@ def handle_group_inbound( text=message.content or "", ) if intent == INTENT_CONFIRM_TMS: - return apply_confirm_tms_quote( + phase = apply_confirm_tms_quote( flow=engine, ticket=ticket, sender_id=message.sender_id, sess=sess, ) + return phase if intent in {INTENT_KEEP_TMS, INTENT_DROP_TMS}: return apply_tms_quote_choice( flow=engine, diff --git a/inquiry-agent/agent/jobs/__init__.py b/inquiry-agent/agent/jobs/__init__.py index 3737806..4bb0933 100644 --- a/inquiry-agent/agent/jobs/__init__.py +++ b/inquiry-agent/agent/jobs/__init__.py @@ -113,6 +113,7 @@ class WorkerRuntime: threading.Thread(target=self._wshoto_poll_loop, name="wshoto-poll", daemon=True), threading.Thread(target=self._deal_followup_loop, name="deal-followup", daemon=True), threading.Thread(target=self._event_exception_loop, name="event-exception", daemon=True), + threading.Thread(target=self._collab_watch_reconcile_loop, name="collab-watch", daemon=True), threading.Thread(target=self._pdf_loop, name="pdf-loop", daemon=True), ] for t in self._threads: @@ -307,6 +308,30 @@ class WorkerRuntime: logger.exception("事件异常巡检失败") self._stop.wait(60.0) + def _collab_watch_reconcile_loop(self) -> None: + """ + HTTP 槽对账协同群监听。独立线程,不占回调、不与 soffice 串队。 + + 缺监听补挂;主账已结束或监听孤儿则清人摘监听,避免「解散群」没人听见。 + """ + from agent.jobs.collab_watch_reconcile import reconcile_collab_watch + + interval = float( + getattr(self.settings, "collab_watch_reconcile_interval_sec", 900.0) or 900.0 + ) + interval = min(3600.0, max(120.0, interval)) + # 启动后先等一截,避开 Worker 冷启动打满企微。 + self._stop.wait(min(90.0, interval)) + while not self._stop.is_set(): + try: + self.http_jobs.run( + lambda: reconcile_collab_watch(settings=self.settings), + label="collab-watch", + ) + except Exception: + logger.exception("协同群监听对账失败") + self._stop.wait(interval) + def _pdf_loop(self) -> None: """仅 LibreOffice 单槽消费 PDF Stream。""" if not self.redis: diff --git a/inquiry-agent/agent/jobs/collab_watch_reconcile.py b/inquiry-agent/agent/jobs/collab_watch_reconcile.py new file mode 100644 index 0000000..0ce4740 --- /dev/null +++ b/inquiry-agent/agent/jobs/collab_watch_reconcile.py @@ -0,0 +1,352 @@ +""" +协同群监听对账:主账绑群 ↔ Redis 监听名单 ↔ 企微成员。 + +本文件职责:Worker HTTP 槽定时跑。缺监听则补挂;已结束/无主账则清人并摘监听。 +禁止:回调线程调用;占 LibreOffice 槽;改六态;一把大锁串行所有工单。 +禁止:listOpenCollab 失败/空列表时把仍有工单映射的活群当孤儿清掉。 +""" + +from __future__ import annotations + +import logging +import time +from typing import Any, Optional + +from agent.config import Settings, get_settings + +logger = logging.getLogger(__name__) + +# 单轮最多清多少个群,避免企微限频拖死 Worker。 +_MAX_DISMISS_PER_RUN = 20 +# 双 Worker 互斥:同一前缀下 10 分钟内只跑一轮。 +_LOCK_TTL_SEC = 600 + + +def reconcile_collab_watch( + *, + ledger: Any = None, + settings: Optional[Settings] = None, + dismiss: bool = True, +) -> dict[str, int]: + """ + 对账一轮。返回计数:rewatched / dismissed / skipped / errors。 + + 主账 listOpenCollab 不可用时,仍可按监听名单侧清理(无单/已结束)。 + """ + cfg = settings or get_settings() + stats = { + "rewatched": 0, + "dismissed": 0, + "skipped": 0, + "empty_unwatched": 0, + "errors": 0, + "listed": 0, + "watched": 0, + } + if str(getattr(cfg, "ledger_backend", "http") or "http").lower() == "memory": + return stats + if not _try_lock(cfg): + stats["skipped"] = 1 + return stats + + book = ledger + if book is None: + from agent.ledger.http_ledger import HttpLedger + + book = HttpLedger() + + from agent.channel.wshoto.rooms import ( + list_watched_rooms, + ticket_no_for_room, + unwatch_collab_room, + watch_collab_room, + ) + + watched = list(list_watched_rooms()) + watched_set = set(watched) + stats["watched"] = len(watched) + + open_rows: list[dict[str, Any]] = [] + list_ok = False + lister = getattr(book, "list_open_collab", None) + if callable(lister): + try: + raw = lister() + # None = 接口失败(与空列表区分);空列表在「仍有监听」时也不能当孤儿清扫依据。 + if raw is None: + list_ok = False + open_rows = [] + stats["errors"] += 1 + logger.error("列出开放协同群失败(返回 None),本轮不做孤儿清群") + else: + open_rows = list(raw or []) + list_ok = True + except Exception: + logger.exception("列出开放协同群失败") + stats["errors"] += 1 + open_rows = [] + list_ok = False + stats["listed"] = len(open_rows) + # listed=0 且仍有监听:多半是接口挂了或返回形态不对,禁止走孤儿清人。 + orphan_dismiss_ok = bool(dismiss) and list_ok and ( + stats["listed"] > 0 or stats["watched"] == 0 + ) + + open_by_chat: dict[str, dict[str, Any]] = {} + for row in open_rows: + if not isinstance(row, dict): + continue + chat = str(row.get("collab_chat_id") or row.get("collabChatId") or "").strip() + if not chat: + continue + open_by_chat[chat] = row + ended = _ended(row) + no = str(row.get("work_order_no") or row.get("workOrderNo") or "").strip() + if ended: + continue + if chat not in watched_set: + try: + watch_collab_room(chat, work_order_no=no) + stats["rewatched"] += 1 + logger.info("对账补挂监听 wo=%s chat=%s", no or "-", chat) + except Exception: + logger.exception("补挂监听失败 chat=%s", chat) + stats["errors"] += 1 + + # 待清:主账已结束(不论是否在监听)+ 监听孤儿(监听有、主账无) + client = _group_client() + dismiss_left = _MAX_DISMISS_PER_RUN if dismiss else 0 + pending: list[tuple[str, str, list[str]]] = [] # chat, wo, members + + for chat, row in open_by_chat.items(): + if not _ended(row): + continue + no = str(row.get("work_order_no") or row.get("workOrderNo") or "").strip() + members = [str(x) for x in (row.get("collab_member_ids") or row.get("memberIds") or []) if x] + pending.append((chat, no, members)) + + for chat in list(watched): + row = open_by_chat.get(chat) + if row is None: + finder = getattr(book, "find_by_collab_chat", None) + if callable(finder): + try: + row = finder(chat_id=chat) + except Exception: + row = None + if isinstance(row, dict) and (row.get("work_order_no") or row.get("workOrderNo") or row.get("found")): + no = str(row.get("work_order_no") or row.get("workOrderNo") or "").strip() + if _ended(row): + # listOpen 未覆盖到的已结束单:补进待清,避免只摘监听不发确认。 + if chat not in open_by_chat: + members = [ + str(x) + for x in (row.get("collab_member_ids") or row.get("memberIds") or []) + if x + ] + pending.append((chat, no, members)) + continue + # 活单:确保房间→工单映射在 + if no and not ticket_no_for_room(chat): + try: + watch_collab_room(chat, work_order_no=no) + stats["rewatched"] += 1 + except Exception: + stats["errors"] += 1 + continue + + # 监听有、主账 list/byChat 对不上:先用 Redis 工单号回查,活单绝不当地孤儿清。 + no = ticket_no_for_room(chat) or "" + live = _lookup_live_ticket(book, work_order_no=no) if no else None + if live is not None: + if _ended(live): + members = [ + str(x) + for x in (live.get("collab_member_ids") or live.get("memberIds") or []) + if x + ] + pending.append((chat, no, members)) + else: + stats["skipped"] += 1 + logger.warning( + "对账跳过疑似孤儿(Redis 工单仍存活)wo=%s chat=%s list_ok=%s listed=%s", + no, + chat, + list_ok, + stats["listed"], + ) + continue + + if not orphan_dismiss_ok: + stats["skipped"] += 1 + continue + + pending.append((chat, no, [])) + + seen_chat: set[str] = set() + for chat, no, members in pending: + if chat in seen_chat: + continue + seen_chat.add(chat) + try: + # 已空只摘监听,不占清人配额,避免历史结束单堵死后面仍有人的群。 + owner_now, users_now = _room_members(client, chat_id=chat, members=members) + extras_now = [u for u in users_now if u and u != owner_now] + if not extras_now: + unwatch_collab_room(chat) + stats["empty_unwatched"] += 1 + continue + if dismiss_left <= 0: + stats["skipped"] += 1 + continue + _dismiss_room(client, chat_id=chat, members=members, work_order_no=no) + unwatch_collab_room(chat) + stats["dismissed"] += 1 + dismiss_left -= 1 + logger.info("对账清群 wo=%s chat=%s", no or "-", chat) + except Exception: + logger.exception("对账清群失败 chat=%s", chat) + stats["errors"] += 1 + + return stats + + +def _ended(row: dict[str, Any]) -> bool: + if bool(row.get("collab_ended") or row.get("collabEnded")): + return True + facts = row.get("collab_facts") or row.get("collabFacts") or {} + if isinstance(facts, dict): + flag = str(facts.get("__ended") or "").strip().lower() + if flag in {"1", "true", "yes"}: + return True + return False + + +def _lookup_live_ticket(book: Any, *, work_order_no: str) -> Optional[dict[str, Any]]: + """ + 用 Redis 里的工单号回查主账。查到则返回可判 ended 的 dict;查不到返回 None。 + + 防止 listOpenCollab/byCollabChat 短暂失败时,把仍存活的协同群当孤儿清掉。 + """ + no = (work_order_no or "").strip() + if not no or book is None: + return None + getter = getattr(book, "get_ticket", None) + if not callable(getter): + return None + try: + ticket = getter(work_order_no=no) + except Exception: + logger.exception("对账按工单回查失败 wo=%s", no) + return None + if ticket is None: + return None + if isinstance(ticket, dict): + row = dict(ticket) + if not (row.get("work_order_no") or row.get("workOrderNo")): + row["work_order_no"] = no + return row + # MemoryTicket / 其它对象:尽量投影成 dict + row = { + "work_order_no": str(getattr(ticket, "work_order_no", None) or no), + "collab_ended": bool(getattr(ticket, "collab_ended", False)), + "collab_chat_id": str(getattr(ticket, "collab_chat_id", None) or ""), + "collab_member_ids": list(getattr(ticket, "collab_member_ids", None) or []), + } + facts = getattr(ticket, "collab_facts", None) or getattr(ticket, "facts", None) + if isinstance(facts, dict): + row["collab_facts"] = facts + return row + + +def _try_lock(settings: Settings) -> bool: + from agent.redis_coord import get_redis_runtime + from agent.redis_coord.idempotency import try_acquire + + try: + runtime = get_redis_runtime(settings) + client = getattr(runtime, "client", None) + if client is None: + return True + return bool( + try_acquire( + client, + scope="collab-watch-reconcile", + idempotency_key="run", + ttl_seconds=_LOCK_TTL_SEC, + ) + ) + except Exception: + logger.exception("对账锁获取失败,本轮仍继续") + return True + + +def _group_client() -> Any: + from agent.policy.sea_group_ops import group_client_of + from agent.policy.sea_text_flow import get_sea_text_flow + + return group_client_of(get_sea_text_flow()) + + +def _room_members(client: Any, *, chat_id: str, members: list[str]) -> tuple[str, list[str]]: + """ + 读企微实时成员;仅在接口失败/无 userlist 字段时退回主账成员。 + + 注意:userlist=[] 是「已空」,不能用 `or members`(空列表在 Python 为假), + 否则会把主账旧成员当成还在群里,白白占满每轮清人配额。 + """ + info: dict[str, Any] = {} + try: + info = client._appchat_info(chat_id=chat_id) or {} + except Exception: + info = {} + raw = info.get("userlist") if isinstance(info, dict) else None + if raw is None: + users = [str(u) for u in (members or []) if u] + else: + users = [str(u) for u in (raw or []) if u] + owner = str((info or {}).get("owner") or "") + return owner, users + + +def _room_already_empty(client: Any, *, chat_id: str, members: list[str]) -> bool: + """ + 群里已无其他人(空群或只剩群主)则视为已解散完成。 + + 这类单不占每轮清人配额,只摘监听即可。 + """ + owner, users = _room_members(client, chat_id=chat_id, members=members) + extras = [u for u in users if u and u != owner] + return not extras + + +def _dismiss_room( + client: Any, + *, + chat_id: str, + members: list[str], + work_order_no: str, +) -> bool: + """ + 清人。返回 True=执行过移人。 + + 成员列表以企微实时 userlist 为准;主账成员可能过期。 + """ + owner, users = _room_members(client, chat_id=chat_id, members=members) + extras = [u for u in users if u and u != owner] + if not extras: + return False + try: + from agent.policy import inquiry_copy as copy + + text = copy.group_dissolved(work_order_no=work_order_no or "-") + send = getattr(client, "send_group", None) + if callable(send): + send(chat_id=chat_id, content=text) + except Exception: + logger.exception("对账清群前确认消息失败 chat=%s", chat_id) + dismiss = getattr(client, "dismiss_group", None) + if callable(dismiss): + dismiss(chat_id=chat_id, userids=users) + return True + return False diff --git a/inquiry-agent/agent/jobs/event_exception_poll.py b/inquiry-agent/agent/jobs/event_exception_poll.py index 27646af..ac13bd4 100644 --- a/inquiry-agent/agent/jobs/event_exception_poll.py +++ b/inquiry-agent/agent/jobs/event_exception_poll.py @@ -7,8 +7,11 @@ from __future__ import annotations +import json import logging +import time from datetime import datetime +from pathlib import Path from typing import Any, Callable, Optional from agent.config import Settings, get_settings @@ -90,10 +93,9 @@ def poll_event_exceptions( test_prefix = False lookup = timeout_lookup or getattr(book, "get_timeout_by_code", None) ready = notify_app_flag(notify_ready) + rows = [r for r in (lister() or []) if isinstance(r, dict)] sent = 0 - for row in lister() or []: - if not isinstance(row, dict): - continue + for row in rows: try: if _handle_one( row, @@ -194,7 +196,8 @@ def _handle_one( if skip_reason: logger.info("事件异常跳过 wo=%s kind=%s reason=%s", no, kind, skip_reason) return False - if workday_elapsed_hours(started, now) < hours: + elapsed = workday_elapsed_hours(started, now) + if elapsed < hours: return False watchers = [dict(x) for x in (row.get("watchers") or []) if isinstance(x, dict)] sales = _ticket_sales(row, watchers) @@ -206,6 +209,7 @@ def _handle_one( names = [str(p.get("name") or "").strip() for p in people if str(p.get("name") or "").strip()] chat_id = str(row.get("collab_chat_id") or row.get("collabChatId") or "").strip() where = conversation_of(wait_kind=kind, collab_chat_id=chat_id) + app_targets = pick_app_targets(people) # 群里用 @。销售超时即使发到私聊,也写成 @销售姓名,不写「您」或「相关人员」。 mention = where == "group" or kind in _SALES_WAIT_KINDS text = copy.event_exception_session_text( @@ -248,6 +252,8 @@ def _handle_one( test_prefix=test_prefix, enqueue=enqueue or _default_enqueue, ) + else: + pass recorder = getattr(book, "record_event_exception", None) if callable(recorder): recorder(work_order_no=no, event_exception=exception_type(kind)) diff --git a/inquiry-agent/agent/jobs/pdf_job.py b/inquiry-agent/agent/jobs/pdf_job.py index a6cd4af..1422212 100644 --- a/inquiry-agent/agent/jobs/pdf_job.py +++ b/inquiry-agent/agent/jobs/pdf_job.py @@ -9,6 +9,7 @@ from __future__ import annotations import logging from dataclasses import dataclass +from pathlib import Path from typing import Any, Optional from agent.jobs.libreoffice import LibreOfficeConverter, LibreOfficeResult @@ -20,6 +21,8 @@ from agent.redis_coord.stream_queue import StreamQueue logger = logging.getLogger(__name__) + + @dataclass class PdfEnqueueResult: accepted: bool @@ -160,6 +163,8 @@ def _deliver_quote_file(data: dict[str, Any], result: LibreOfficeResult) -> None or {}, }, ) + # PDF 出站同时登记主账附件,后台列表/详情才能看到。 + _register_pdf_attachment(wo, filename, result.pdf_path, touser=touser) if wo: try: from agent.policy.air_text_flow import get_air_text_flow @@ -176,3 +181,39 @@ def _deliver_quote_file(data: dict[str, Any], result: LibreOfficeResult) -> None logger.exception("PDF 出文件后未能切到成交跟进 inquiry=%s", wo) except Exception: logger.exception("PDF 出站入队失败 inquiry=%s", wo) + + +def _register_pdf_attachment( + work_order_no: str, + filename: str, + pdf_path: str, + *, + touser: str = "", +) -> None: + """ + PDF 转换成功后把文件字节登记到主账附件表。 + + 失败只打日志,不挡企微发文件。LibreOffice 槽内调用,短 HTTP。 + """ + wo = (work_order_no or "").strip() + path = Path(str(pdf_path or "")) + if not wo or not path.is_file(): + return + try: + from agent.ledger.http_ledger import HttpLedger + + raw = path.read_bytes() + if not raw: + return + name = (filename or "").strip() or f"{wo}_quote.pdf" + if not name.lower().endswith(".pdf"): + name = f"{name}.pdf" + out = HttpLedger().attach_file_bytes( + work_order_no=wo, + file_name=name, + file_bytes=raw, + content_type="application/pdf", + sender_id=touser or "", + ) + 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 3e05ce8..a58358d 100644 --- a/inquiry-agent/agent/ledger/__init__.py +++ b/inquiry-agent/agent/ledger/__init__.py @@ -33,7 +33,7 @@ class LedgerClient: """ 主账客户端。 - base_url:BIZ_API_BASE_URL(新系统多为 http://127.0.0.1:8180/jeecgboot)。 + base_url:BIZ_API_BASE_URL(新系统为 http://127.0.0.1:8080/jeecgboot)。 超时:默认 10s,禁止无限阻塞 Worker 槽。 禁止:在本类拼 TMS HMAC 或写工单 SQL。 """ diff --git a/inquiry-agent/agent/ledger/http_ledger.py b/inquiry-agent/agent/ledger/http_ledger.py index d081687..aa5565d 100644 --- a/inquiry-agent/agent/ledger/http_ledger.py +++ b/inquiry-agent/agent/ledger/http_ledger.py @@ -1,5 +1,5 @@ """ -主账 HTTP 账本:建单 / 查价 / 报价 / 六态只打 8180。 +主账 HTTP 账本:建单 / 查价 / 报价 / 六态只打 8080。 本文件职责:把 MemoryLedger 同名方法接到 /inquiry/agent/**;查价走真 TMS(主账出站)。 禁止:直连 TMS / MySQL;把 Jeecg 包装失败当成有价。 @@ -236,13 +236,15 @@ class HttpLedger: quote: Optional[dict[str, Any]] = None, format: str = "xlsx", sales_wecom_id: str = "", + persist_attachment: bool = False, ) -> dict[str, Any]: """ 打主账按后台报价模板填 Excel。 失败(无关键词且无默认)原样带回销售话术。Java 不转 PDF。 + persist_attachment:True 才写主账附件表(确认落库);激活发群默认 False。 """ - from agent.quote_templates.match import FAIL_MESSAGE + from agent.quote_templates.match import FAIL_MESSAGE, normalize_biz, pick_quote_template from agent.schema.quote_adjust import stamp_prices_for_sheet # 出单前把当前费用写入线路表、海运分段、空运舱位。否则报价单仍是旧价。 @@ -260,8 +262,9 @@ class HttpLedger: "facts": facts, "quote": quote or {}, "salesWecomId": sales_wecom_id, + "persistAttachment": bool(persist_attachment), }, - idem=f"quote-render:{work_order_no}:{format}", + idem=f"quote-render:{work_order_no}:{format}:{int(bool(persist_attachment))}", ) finally: self._client.timeout_seconds = prev_timeout @@ -270,6 +273,7 @@ class HttpLedger: return { "ok": False, "error": out.get("error") or "NO_TEMPLATE", + "failStage": out.get("failStage"), "message": out.get("message") or FAIL_MESSAGE, } from agent.quote_templates.filename import filename_from_render @@ -465,12 +469,16 @@ class HttpLedger: flight_no: str = "", ) -> dict[str, Any]: """锁舱出站。航班号和航司一并带上,主账缺了这两项不能当锁舱失败。""" + from agent.policy.bot_name import strip_bot_mention + + # 群入站常带「@智价通AIQ …」;出站备注只留业务原文,禁止把 @机器人 带给 TMS。 + clean_instruction = strip_bot_mention(instruction_text or "") body: dict[str, Any] = { "workOrderNo": work_order_no, "optionNo": option_no, "operatorUserId": sender_id, "operatorName": operator_name, - "instructionText": instruction_text, + "instructionText": clean_instruction, } if (airline or "").strip(): body["airline"] = airline.strip().upper() @@ -491,6 +499,9 @@ class HttpLedger: operator_name: str = "", instruction_text: str = "", ) -> dict[str, Any]: + from agent.policy.bot_name import strip_bot_mention + + clean_instruction = strip_bot_mention(instruction_text or "") return self._post( "/inquiry/agent/tms/releaseCabin", { @@ -498,7 +509,7 @@ class HttpLedger: "lockId": lock_id, "operatorUserId": sender_id, "operatorName": operator_name, - "instructionText": instruction_text, + "instructionText": clean_instruction, }, idem=f"air-release:{work_order_no}:{lock_id}", ) @@ -510,6 +521,38 @@ class HttpLedger: return None return out + def list_open_collab(self) -> list[dict[str, Any]] | None: + """ + 列出仍绑着协同群的工单(含已结束协同)。 + + Worker 对账监听用。 + 返回 None = 接口失败/形态不对(调用方禁止据此做孤儿清群); + 返回 [] = 确实没有绑群工单。 + """ + try: + out = self._post("/inquiry/agent/ticket/listOpenCollab", {}) + except Exception: + logger.exception("listOpenCollab 调用失败") + return None + if not isinstance(out, dict): + logger.error("listOpenCollab 返回非对象") + return None + # 主账失败常见 success=false / code!=200,不能当成「零条活群」。 + if out.get("success") is False: + logger.error("listOpenCollab 业务失败 code=%s", out.get("code") or out.get("status")) + return None + rows = out.get("tickets") + if rows is None and "result" in out: + result = out.get("result") + if isinstance(result, dict): + rows = result.get("tickets") + elif isinstance(result, list): + rows = result + if not isinstance(rows, list): + logger.error("listOpenCollab 无 tickets 列表字段 keys=%s", sorted(out.keys())[:20]) + return None + return [r for r in rows if isinstance(r, dict)] + def end_collab_group(self, *, work_order_no: str) -> dict[str, Any]: """协同结束,这单不能再拉群。不改六态。""" return self._post( @@ -539,6 +582,34 @@ class HttpLedger: }, ) + def attach_file_bytes( + self, + *, + work_order_no: str, + file_name: str, + file_bytes: bytes, + content_type: str = "", + sender_id: str = "", + ) -> dict[str, Any]: + """ + 报价附件字节写入主账(上传对象存储 + inq_attachment)。 + + 用于 PDF 转换完成后落库;Excel 出单由主账 render 内登记。 + """ + import base64 + + return self._post( + "/inquiry/agent/ticket/attachFileBytes", + { + "workOrderNo": work_order_no, + "fileName": file_name, + "fileBase64": base64.b64encode(file_bytes or b"").decode("ascii"), + "contentType": content_type or "application/octet-stream", + "senderId": sender_id, + }, + idem=f"attach-bytes:{work_order_no}:{file_name}:{len(file_bytes or b'')}", + ) + def upsert_product_quote( self, *, diff --git a/inquiry-agent/agent/ledger/memory_ledger.py b/inquiry-agent/agent/ledger/memory_ledger.py index 6ccc3f7..8a8b401 100644 --- a/inquiry-agent/agent/ledger/memory_ledger.py +++ b/inquiry-agent/agent/ledger/memory_ledger.py @@ -1,5 +1,5 @@ """ -主账内存假账本(单测 / 无 8180 时)。 +主账内存假账本(单测 / 无 8080 时)。 本文件职责:实现建单(每次新开)、相似检索、TMS fixture、报价写入、六态流转,语义对齐 Java 主账。 禁止:冒充已打真实 TMS;禁止被生产 Worker 当默认 Owner(生产必须 HTTP 主账)。 @@ -427,9 +427,11 @@ class MemoryLedger: quote: Optional[dict[str, Any]] = None, format: str = "xlsx", sales_wecom_id: str = "", + persist_attachment: bool = False, ) -> dict[str, Any]: """ 按询价需求匹配内存模板。不填真实 xlsx,只回假字节供流程/单测。 + persist_attachment 与主账同语义:True 表示应登记附件(单测可断言 attachmentSaved)。 """ from agent.quote_templates.match import FAIL_MESSAGE, pick_quote_template @@ -459,6 +461,8 @@ class MemoryLedger: "fileBase64": "UEsDBAoAAAAAA", "needsPdf": False, "facts": dict(facts or {}), + "attachmentSaved": bool(persist_attachment), + "persistAttachmentRequested": bool(persist_attachment), } with self._lock: ticket = self._tickets.get((work_order_no or "").strip()) @@ -745,6 +749,26 @@ class MemoryLedger: ticket.attachments.append(row) return {"ok": True, "file_name": file_name} + def attach_file_bytes( + self, + *, + work_order_no: str, + file_name: str, + file_bytes: bytes, + content_type: str = "", + sender_id: str = "", + ) -> dict[str, Any]: + """单测:字节附件只记元数据,不写真实对象存储。""" + _ = file_bytes + key = f"memory/{work_order_no}/{file_name or 'file.bin'}" + return self.attach_file( + work_order_no=work_order_no, + file_name=file_name, + object_key=key, + content_type=content_type, + sender_id=sender_id, + ) + def upsert_product_quote( self, *, diff --git a/inquiry-agent/agent/llm/extract_text.py b/inquiry-agent/agent/llm/extract_text.py index f793817..556e895 100644 --- a/inquiry-agent/agent/llm/extract_text.py +++ b/inquiry-agent/agent/llm/extract_text.py @@ -137,6 +137,7 @@ _SHEET_CLEAN_KEYS = frozenset( "整柜或拼柜", "箱型箱量", "贸易条款", + "增值服务", "货好时间", "运输分类", "报价日期", @@ -280,7 +281,8 @@ def parse_clarify_paste_facts(text: str) -> dict[str, str]: continue if not val or _CLARIFY_EXAMPLE_LINE.match(val): continue - facts[name] = clip_inquiry_value(val) + clipped = clip_inquiry_value(val) + facts[name] = clipped return {key: val for key, val in facts.items() if val} @@ -522,6 +524,14 @@ def extract_inquiry_snapshot( # 模型常把「郑州-吉隆坡」猜成空运,不能拿来默认。 source = "deepseek_b" facts = harvest_oral_measures(text, normalize_facts(facts)) + if (mode or "").upper() == "LAND": + from agent.schema.field_validate import ( + harvest_oral_land_vehicle, + promote_land_vehicle_from_container, + ) + + facts = promote_land_vehicle_from_container(facts) + facts = harvest_oral_land_vehicle(text, facts) facts = _merge_spoken_collab(text, facts) facts = _merge_optional_inquiry_labels(text, facts) facts = apply_cargo_handover_synonym(text, facts, business_line=mode) @@ -570,12 +580,35 @@ def harvest_inquiry_optional_labels(text: str) -> dict[str, str]: return _harvest_labels(text, _OPTIONAL_INQUIRY_LABELS) +def is_clarify_ui_prompt(raw: str | None) -> bool: + """ + 补问/核对卡上的系统提示,不是任何询价字段的值。 + + 空「货物品名(必填):」后若没换行,下一句「请确认并补充以下信息」会粘成品名 + (多段陆运核对卡运行证据)。报价日期误收「请复制粘贴…」同属此类。 + """ + text = str(raw or "").strip().strip("::") + if not text: + return False + if "请复制粘贴" in text or "告知我以下字段" in text: + return True + if "请确认并补充以下信息" in text: + return True + if "请复制以下内容" in text: + return True + if re.match(r"^Step\s*[12]\s*:", text, flags=re.I): + return True + return False + + def clip_inquiry_value(raw: str) -> str: """ 字段值收到下一个编号项为止。 图上常在货好时间后面接着「4. 希望送到…」「6. 送货地址」。 那些不是询价字段,不能进补问。地址里的「602、603」没有点号后空格,会留下。 + 补问复制清单「10.增值服务」点后可无空格;空贸易条款不能把下一栏标签收成值。 + 空报价日期不能把下一行「请复制粘贴…」收成日期值。 """ text = (raw or "").strip() if not text: @@ -583,6 +616,20 @@ def clip_inquiry_value(raw: str) -> str: text = re.sub(r"[((]如\s*[::][^))]*[))]?\s*$", "", text).strip() if _CLARIFY_EXAMPLE_LINE.match(text): return "" + # 补问 UI 提示不是任何字段的值(空栏后没换行会粘上标题)。 + if is_clarify_ui_prompt(text): + return "" + # 整段就是下一栏「10.增值服务」或「增值服务(非必填):…」→ 本栏未填。 + if _starts_with_inquiry_label(text): + return "" + # 值中间换行后出现「10.增值服务」:切到下一栏之前(点后可无空格)。 + cut_at = -1 + for match in re.finditer(r"(?m)^\s*\d+[\.、.]\s*", text): + if match.start() > 0 and _starts_with_inquiry_label(text[match.start() :]): + cut_at = match.start() + break + if cut_at > 0: + text = text[:cut_at] hit = _NUMBERED_ITEM.search(text) if hit and hit.start() > 0: text = text[: hit.start()] @@ -594,6 +641,37 @@ def clip_inquiry_value(raw: str) -> str: return text +def _starts_with_inquiry_label(text: str) -> bool: + """ + 值是否以「另一询价栏目标签」开头(可带补问编号、冒号、必填标记)。 + + 空起运地被误收成「目的地:CRK」时走这里丢掉,避免 TMS 按脏起运地查价。 + 「10.增值服务」「10.增值服务(非必填):卡转」也算下一栏,不能进贸易条款。 + 「目的地仓库」这种地名不含冒号/必填标记,不算标签,照常保留。 + """ + raw = (text or "").lstrip() + if not raw: + return False + # 补问复制清单编号「10.」可无空格,先剥掉再认标签。 + raw = _CLARIFY_INDEX.sub("", raw, count=1).lstrip() + if not raw: + return False + labels = sorted(_SHEET_CLEAN_KEYS | _CLARIFY_NAMES, key=len, reverse=True) + for label in labels: + if len(label) < 2 or not raw.startswith(label): + continue + if not _label_is_token(raw, 0, label): + continue + rest = raw[len(label) :] + if re.match(r"^\s*$", rest): + return True + if re.match(r"^\s*[::]", rest): + return True + if re.match(r"^\s*[((][^))]*(?:必填|非必填)", rest): + return True + return False + + def clip_sheet_field_value(raw: str) -> str: """ 报价单格子:去掉 /POL、(必填说明)、Terms 和同一行下一列表头,只留紧挨的值。 @@ -612,6 +690,9 @@ def clip_sheet_field_value(raw: str) -> str: if "必填" in text: text = re.sub(r"^[^)\n]*必填[^)\n]*)\s*", "", text) text = re.sub(r"^[\s::]+", "", text) + # 本栏为空却吞进「目的地:CRK」时,整段丢掉(标签在 pos=0,后面切栏逻辑碰不到)。 + if _starts_with_inquiry_label(text): + return "" for label in _SHEET_CLEAN_KEYS: idx = 0 while True: @@ -673,7 +754,11 @@ def harvest_sheet_inquiry_cells(text: str) -> dict[str, str]: for i, (pos, label) in enumerate(kept): begin = pos + len(label) end = kept[i + 1][0] if i + 1 < len(kept) else len(raw) - val = clip_sheet_field_value(raw[begin:end]) + raw_chunk = raw[begin:end] + # 报价日期只认同行:空栏后的粘贴提示不能进格子值。 + if label in {"报价日期", *QUOTE_DATE_SYNONYMS}: + raw_chunk = raw_chunk.split("\n", 1)[0] + val = clip_sheet_field_value(raw_chunk) if val: out[label] = val return out @@ -773,18 +858,21 @@ def _harvest_labels(text: str, labels: tuple[str, ...]) -> dict[str, str]: begin = pos + len(label) end = kept[i + 1][0] if i + 1 < len(kept) else len(raw) chunk = raw[begin:end] - # 增值服务只认冒号后同一行。空行不能把下一行「报价日期(非必填):」收成服务。 - if label.startswith("增值服务"): + # 增值服务 / 报价日期只认冒号后同一行。空行不能把下一行「请复制粘贴…」收成日期。 + if label.startswith("增值服务") or label in {"报价日期", *QUOTE_DATE_SYNONYMS}: chunk = chunk.split("\n", 1)[0] same_line = chunk.split("\n", 1)[0] same_line_body = re.sub(r"^[\s::,,、]+", "", same_line).strip() # 冒号后本行为空 = 未填。禁止跨行把下一栏「目的地:吉隆坡」收成起运地。 - # 核对卡复制粘贴时非必填常整行只有「起运地(非必填):」,下一行才是必填目的地。 - if not same_line_body: + # 核对卡无换行粘贴时「起运地(非必填): 目的地(必填):CRK」也视为未填。 + if not same_line_body or _starts_with_inquiry_label(same_line_body): chunk = "" else: chunk = re.sub(r"^[\s::,,、]+", "", chunk) chunk = re.sub(r"[\s,,、]+$", "", chunk) + # 报价日期同行若误收成「请复制粘贴…」补问提示,必须丢掉(WO202610170068)。 + if label in {"报价日期", *QUOTE_DATE_SYNONYMS}: + chunk = clip_inquiry_value(chunk) if chunk: out[label] = chunk return out @@ -899,11 +987,17 @@ _RE_FEE_LINE = re.compile( r"(?P[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费|[A-Za-z]{2,10})" r"\s*(?:改为|改成|调整为|调整成)?" r"\s*[::=]?" - rf"\s*(?:(?P{_CCY_TOKEN})\s+)?" + # 群里常写「陆运费 CNY1000」币种与数字贴在一起;\s+ 会漏匹配再退回把「段1」当金额。 + rf"\s*(?:(?P{_CCY_TOKEN})\s*)?" r"(?P\d+(?:\.\d+)?)" rf"\s*(?P{_CCY_TOKEN}|元)?", re.I, ) +# 多段报价「段1:」「第一段」只是段号,数字不能当费用金额。 +_RE_SEGMENT_INDEX_LABEL = re.compile( + r"(?:段\s*\d+\s*[::]?|第\s*[一二三四五六七八九\d]+\s*段\s*[::]?)", + re.I, +) # 「文件发改为:500」漏了「费」、中间还有冒号,不能丢掉。 _RE_FEE_CHANGE = re.compile( r"(?P[\u4e00-\u9fff]{2,16}?)" @@ -998,14 +1092,15 @@ def _normalize_fee_project(raw: str) -> str: def _strip_group_bot_mentions(text: str) -> str: """ - 去掉群里 @询价小助手 前缀,避免挡住句首「起运港、」分段标签。 + 去掉群里 @机器人显示名 前缀,避免挡住句首「起运港、」分段标签。 企微协同句常以 @ 开头;不剥的话 project 抽不到,出单只能靠附加费默认「起运港」, - 且二次盖价时更容易丢掉分段。 + 且二次盖价时更容易丢掉分段。显示名以后台为准,过渡期仍剥旧名。 副作用:无。 """ - cleaned = (text or "").replace("@询价小助手", "").replace("询价小助手", "") - return cleaned.strip() + from agent.policy.bot_name import strip_bot_mention + + return strip_bot_mention(text or "").strip() # 报价日期整段:标签+日期。费用抽取前剥掉,避免把年份 2026 当成金额。 @@ -1110,6 +1205,16 @@ def _split_fee_project_prefix(text: str) -> tuple[str, str]: return project, rest +def _strip_segment_index_labels(text: str) -> str: + """ + 去掉「段1:」「第一段」段号标签,避免金额正则吃到段序号。 + + 运行证据:@机器人 段1:陆运费 CNY1000 → 曾抽成 amount=1。 + 副作用:无。 + """ + return _RE_SEGMENT_INDEX_LABEL.sub(" ", text or "").strip() + + def _parse_fee_rows(text: str, baseline_currency: str = "") -> list[dict[str, str]]: """ 按行抽费用:码头操作费100CNY、报关费改为300CNY。 @@ -1233,8 +1338,9 @@ def extract_product_quote( # 费率句若仍落到普通费用行,会把「燃油附加费」配上句首公斤数。先剥掉再抽。 # 必须用剥掉 @询价小助手 后的正文,否则句首「起运港、」分段标签匹配不到。 # 顺带剥掉报价日期/有效期/时效要求,避免年份、天数再抽成金额。 - scrubbed = _strip_rate_and_band_noise( - _strip_non_fee_meta_chunks(stripped or raw) + # 多段「段1:」先去掉,禁止把段序号当成陆运费/空运费金额。 + scrubbed = _strip_segment_index_labels( + _strip_rate_and_band_noise(_strip_non_fee_meta_chunks(stripped or raw)) ) rows = _parse_fee_rows(scrubbed, baseline_currency=base_ccy) if scrubbed.strip() else [] if rows: @@ -1291,7 +1397,6 @@ def extract_product_quote( return out - def _format_percent_rate(raw: str) -> str: """费率展示统一带 %;已有 % 保留。""" text = (raw or "").strip() diff --git a/inquiry-agent/agent/llm/mode_extract_fields.py b/inquiry-agent/agent/llm/mode_extract_fields.py index 3ea8cdb..3b8188f 100644 --- a/inquiry-agent/agent/llm/mode_extract_fields.py +++ b/inquiry-agent/agent/llm/mode_extract_fields.py @@ -125,6 +125,8 @@ def build_extract_messages(text: str) -> list[dict[str, str]]: "你是询价字段抽取器。只根据销售原话抽取字段,调用 extract_inquiry_fields。" "未出现的字段必须空串,禁止编造港口、重量、日期。" "空运件数/毛重/体积/包装方式;海运货量与箱型箱量;陆运按二级类型。" + "陆运整车/中港零担的车型写入车型/数量:45HQ(13.5厢车)、9米6/1、3T;" + "陆运不要把 45HQ/厢车 写成箱型箱量。" "海运时 15件/20吨/3箱 必须写入货量,不要只写到件数把货量留空。" "原话已带单位必须抽取,不要因为没写「毛重:」就留空:" "10件/3pcs→空运件数;海运同样数字+件也要写入货量;100KGS/180kg/1.5KG/100公斤→毛重;2CBM/4.2cbm/2.5立方/2方→体积;" @@ -217,35 +219,4 @@ def invoke_extract_fields( parsed.get("business_line") or "-", sorted((parsed.get("facts") or {}).keys()), ) - # #region agent log - try: - import json - import time - from pathlib import Path - - _facts = dict(parsed.get("facts") or {}) - _log = { - "sessionId": "0593f5", - "runId": "post-fix", - "hypothesisId": "A", - "location": "mode_extract_fields.py:invoke_extract_fields", - "message": "DeepSeek B place fields", - "data": { - "text_head": (text or "")[:80], - "ok": parsed.get("ok"), - "business_line": parsed.get("business_line"), - "land_subtype": parsed.get("land_subtype"), - "起运港": _facts.get("起运港"), - "目的港": _facts.get("目的港"), - "提货地址": _facts.get("提货地址"), - "fact_keys": sorted(_facts.keys()), - }, - "timestamp": int(time.time() * 1000), - } - Path(__file__).resolve().parents[3].joinpath("debug-0593f5.log").open( - "a", encoding="utf-8" - ).write(json.dumps(_log, ensure_ascii=False) + "\n") - except Exception: - pass - # #endregion return parsed diff --git a/inquiry-agent/agent/policy/air_group_ops.py b/inquiry-agent/agent/policy/air_group_ops.py index 05c22ec..eabb419 100644 --- a/inquiry-agent/agent/policy/air_group_ops.py +++ b/inquiry-agent/agent/policy/air_group_ops.py @@ -12,6 +12,8 @@ from __future__ import annotations import json import logging import re +import time +from pathlib import Path from typing import Any, Optional from agent.llm.extract_text import extract_air_collab_fields, extract_product_quote @@ -887,16 +889,19 @@ def apply_air_deal( if not is_sales(ticket, sender_id): send_group_text(group_client_of(flow), chat_id, copy.air_sales_only_deal()) return "not_sales" + view = ticket_view(ticket) + no = ticket_no(ticket) + status_now = ticket_status(ticket) + # 与海运一致:同一终态再确认不刷「不能修改」。 + if status_now in {"已成交", "未成交"} and outcome == status_now: + return "deal_already_recorded" stopped = halt_closed_group(flow=flow, ticket=ticket, chat_id=chat_id) if stopped: return stopped from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason from agent.policy.sea_group_ops import _quote_fee_rows, facts_of_group - view = ticket_view(ticket) - no = ticket_no(ticket) quote = dict(view.get("quote") or {}) - status_now = ticket_status(ticket) usable_total = str(quote.get("total") or "").strip() if usable_total in {"", "-"}: usable_total = "" diff --git a/inquiry-agent/agent/policy/air_quote_copy.py b/inquiry-agent/agent/policy/air_quote_copy.py index 5b6b581..89aa7f1 100644 --- a/inquiry-agent/agent/policy/air_quote_copy.py +++ b/inquiry-agent/agent/policy/air_quote_copy.py @@ -72,9 +72,10 @@ def air_cargo_lines( dest = (src.get("目的港") or src.get("目的地") or "-").strip() or "-" cargo = (src.get("品名") or "-").strip() or "-" pack = (src.get("包装方式") or src.get("包装类型") or "-").strip() or "-" - quote_date = (src.get("报价日期") or "").strip() - if quote_date in {"", "-", "—", "-"}: - quote_date = "-" + from agent.schema.field_validate import is_blank_quote_date + + quote_date_raw = (src.get("报价日期") or "").strip() + quote_date = "-" if is_blank_quote_date(quote_date_raw) else quote_date_raw lines = [ f"工单号:{(work_order_no or '').strip()}", "运输方式:空运", @@ -218,22 +219,10 @@ def _fee_rows(quote: dict[str, Any] | None, facts: dict[str, str] | None) -> lis option = resolve_display_air_option(src, facts) rows: list[dict[str, Any]] = [] if option: - rows.extend(_named_rows(option.get("feeItems") or option.get("fee_items"))) - rows.extend( - _named_rows( - option.get("departureCharges") - or option.get("departure_charges") - or option.get("charges") - ) - ) - rows.extend( - _named_rows( - option.get("destinationCharges") - or option.get("destination_charges") - or option.get("arrivalCharges") - ) - ) - rows = _dedupe(rows) + from agent.schema.air_route_options import option_fee_dicts + + # 与投影同一套:舱位缺 feeItems 时补整单顶层空运费,再拼起运/目的附加费。 + rows = option_fee_dicts(option, src) if not rows and len(parse_air_options(src)) > 1: return [] if not rows: diff --git a/inquiry-agent/agent/policy/air_text_flow.py b/inquiry-agent/agent/policy/air_text_flow.py index bcab9c5..603ea20 100644 --- a/inquiry-agent/agent/policy/air_text_flow.py +++ b/inquiry-agent/agent/policy/air_text_flow.py @@ -835,9 +835,16 @@ class AirTextInquiryFlow: facts = dict(view.get("facts") or {}) quote = dict(view.get("quote") or {}) status = str(view.get("status") or "").strip() + lost_reason = str(view.get("lost_reason") or view.get("lostReason") or "").strip() + tms_quote = dict(view.get("tms_quote") or view.get("tmsQuote") or {}) phase = str(view.get("wait_phase") or "").strip() or self._infer_wait_phase( - status=status, quote=quote + status=status, quote=quote, lost_reason=lost_reason ) + # 主账询价中+舱位备选时 wait_phase 常被收成 tms_miss;续办必须回到选舱。 + if self._quote_pending_cabin(quote) or self._quote_pending_cabin(tms_quote): + phase = "wait_air_route" + if not quote and tms_quote: + quote = dict(tms_quote) allowed: tuple[str, ...] = () if phase == "wait_collab": allowed = (copy.BTN_SKIP_COLLAB,) @@ -855,6 +862,8 @@ class AirTextInquiryFlow: allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE) elif phase == "wait_lost_reason": allowed = () + elif phase == "wait_air_route": + allowed = () return FlowSession( sender_id=sender_id, thread_id=sender_id, @@ -863,6 +872,7 @@ class AirTextInquiryFlow: facts=facts, work_order_no=no, quote=quote, + tms_quote=tms_quote or dict(quote), status=status, allowed=allowed, wait_version=1, @@ -870,17 +880,50 @@ class AirTextInquiryFlow: collab_chat_id=str(view.get("collab_chat_id") or ""), ) - def _infer_wait_phase(self, *, status: str, quote: dict[str, Any]) -> str: + def _infer_wait_phase( + self, *, status: str, quote: dict[str, Any], lost_reason: str = "" + ) -> str: if status in {"已成交", "已关闭"}: return "done" if status == "未成交": - return "wait_lost_reason" + # 原因已记则终态回放;缺原因才继续要原因。 + return "wait_lost_reason" if not (lost_reason or "").strip() else "done" if status == "协商中": return "wait_deal" if status == "已报价" and quote: return "wait_adopt" return "tms_miss" + def _lost_reason_of(self, sess: FlowSession) -> str: + """从主账读未成交原因;续办终态回放要用,不能靠进程内字段。""" + no = (sess.work_order_no or "").strip() + if not no: + return "" + getter = getattr(self._ledger, "get_for_agent", None) + if not callable(getter): + return "" + try: + view = getter(work_order_no=no, sender_id=sess.sender_id) or {} + except Exception: + logger.warning("读未成交原因失败 wo=%s", no) + return "" + return str(view.get("lost_reason") or view.get("lostReason") or "").strip() + + @staticmethod + def _quote_pending_cabin(quote: dict[str, Any] | None) -> bool: + """报价快照是否仍停在空运选舱(pending_cabin_pick=1 或未选多舱位)。""" + src = dict(quote or {}) + flag = str(src.get("pending_cabin_pick") or src.get("pendingCabinPick") or "").strip() + if flag in {"1", "true", "True", "yes"}: + return True + from agent.schema.air_route_options import parse_air_options + + if src.get("selected_air_option") or src.get("selectedAirOption"): + return False + return len(parse_air_options(src)) > 0 and not ( + str(src.get("total") or "").strip() + ) + def _align_resume_phase(self, sess: FlowSession) -> None: """ 报价文件已经发给销售,续办必须回到成交跟进。 @@ -971,8 +1014,23 @@ class AirTextInquiryFlow: if sess.phase == "wait_lost_reason": reply(copy.deal_ack("未成交", sess.work_order_no)) return "wait_lost_reason" + if sess.phase == "wait_air_route": + quote = dict(sess.tms_quote or sess.quote or {}) + return self._offer_air_routes( + sess=sess, + work_order_no=sess.work_order_no, + quote=quote, + reply=reply, + cabin_pick=self._quote_pending_cabin(quote), + ) if sess.phase == "done": - reply(copy.deal_ack(sess.status or "已关闭", sess.work_order_no)) + status = (sess.status or "").strip() or "已关闭" + lost = self._lost_reason_of(sess) if status == "未成交" else "" + # 未成交且原因已记:续办只重播「原因已记、工单已关」,禁止再要原因。 + if status == "未成交" and lost: + reply(copy.lost_reason_ack(lost, work_order_no=sess.work_order_no)) + return "done" + reply(copy.deal_ack(status, sess.work_order_no, lost_reason=lost)) return "done" reply(copy.inquiry_card(work_order_no=sess.work_order_no, facts=sess.facts)) return sess.phase or "replay" @@ -1393,9 +1451,13 @@ class AirTextInquiryFlow: ) options = parse_air_options(quote) + from agent.schema.field_validate import is_blank_quote_date + + # 没填报价日期:不按起运地收口,TMS 返回的舱位全部给销售点。1 条也要点。 # 未选定前六态保持询价中,禁止把备选舱位费写进后台「已报价」。 - if not str((sess.facts or {}).get("报价日期") or "").strip() and options: + # 「(非必填):」占位文案也当没填,必须进选舱。 + if is_blank_quote_date((sess.facts or {}).get("报价日期")) and options: quote = dict(quote) quote["airOptions"] = sort_cabin_options(options) ledger_quote = _ledger_quote_pending_cabin(quote) @@ -1894,6 +1956,7 @@ class AirTextInquiryFlow: if not callable(renderer): reply(copy.quote_template_missing()) return "wait_adopt" + # 私聊采用出单:填好的报价单要进后台附件;激活群发模版另走 persist=False。 result = renderer( work_order_no=sess.work_order_no, business_line=sess.business_line or "AIR", @@ -1901,6 +1964,7 @@ class AirTextInquiryFlow: quote=dict(sess.quote or {}), format=fmt, sales_wecom_id=sess.sender_id, + persist_attachment=True, ) or {} if not result.get("ok"): reply(str(result.get("message") or copy.quote_template_missing())) @@ -2298,7 +2362,7 @@ _FLOW_GUARD = threading.Lock() def build_ledger() -> Any: - """生产打 8180;单测 LEDGER_BACKEND=memory。""" + """生产打 8080;单测 LEDGER_BACKEND=memory。""" from agent.config import get_settings backend = (get_settings().ledger_backend or "http").strip().lower() diff --git a/inquiry-agent/agent/policy/bot_name.py b/inquiry-agent/agent/policy/bot_name.py new file mode 100644 index 0000000..8aebf8d --- /dev/null +++ b/inquiry-agent/agent/policy/bot_name.py @@ -0,0 +1,73 @@ +""" +企微机器人显示名:出站话术与入站 @ 识别共用。 + +本文件职责:从后台 reply_code=system.bot_display_name 读显示名; +失败回退默认「智报通AIQ」;过渡期识别仍认旧名「询价小助手」。 +禁止:每条群消息同步打主账(走 bot_reply 短缓存);禁止当六态。 +""" + +from __future__ import annotations + +from typing import Tuple + +# 后台调用编码:inq_bot_reply.reply_text 只写显示名本身,不要整句。 +REPLY_CODE = "system.bot_display_name" +# 主账空/失败时的默认显示名(产品已定新名)。 +DEFAULT_BOT_DISPLAY_NAME = "智报通AIQ" +# 企微改名过渡:旧习惯 @ 仍要听得见(含「价/报」两版现网名)。 +LEGACY_BOT_DISPLAY_NAMES: Tuple[str, ...] = ("询价小助手", "智价通AIQ", "智报通AIQ") + + +def bot_display_name() -> str: + """ + 当前对外显示名(销售话术里的 @ 名)。 + + 读后台短缓存;空串/停用回退默认。只取首行,防止误配整段话术。 + """ + from agent.policy.bot_reply import lookup_raw + + raw = (lookup_raw(REPLY_CODE) or "").strip() + if not raw: + return DEFAULT_BOT_DISPLAY_NAME + first = raw.splitlines()[0].strip() + return first or DEFAULT_BOT_DISPLAY_NAME + + +def bot_mention_names() -> Tuple[str, ...]: + """ + 入站识别用的全部名字:当前配置名 + 旧名,去重保序。 + + 销售可见话术只用 bot_display_name();识别必须覆盖过渡期旧 @。 + """ + names: list[str] = [] + for item in (bot_display_name(), *LEGACY_BOT_DISPLAY_NAMES): + name = (item or "").strip() + if name and name not in names: + names.append(name) + return tuple(names) + + +def text_mentions_bot(text: str) -> bool: + """原文是否带当前或旧显示名(含不带 @ 的粘贴)。""" + raw = text or "" + return any(name in raw for name in bot_mention_names()) + + +def strip_bot_mention(text: str) -> str: + """ + 去掉 @显示名 与裸显示名(含旧名),便于抽字段/判「仅工单号」、出站 instructionText。 + + 先剥带 @ 的,再剥裸名,避免残留 @;末尾压空白,避免「@机器人 舱位」留下双空格。 + 锁舱/释放出站前必须剥干净,禁止把「@智价通AIQ」原样带给 TMS。 + """ + rest = text or "" + # 长名优先,避免短名先剥留下半截。 + names = sorted(bot_mention_names(), key=len, reverse=True) + for name in names: + rest = rest.replace(f"@{name}", "").replace(name, "") + return " ".join(rest.split()) + + +def at_bot() -> str: + """出站示例用的「@显示名」后缀。""" + return f"@{bot_display_name()}" diff --git a/inquiry-agent/agent/policy/bot_reply.py b/inquiry-agent/agent/policy/bot_reply.py index 8023b60..f0ef633 100644 --- a/inquiry-agent/agent/policy/bot_reply.py +++ b/inquiry-agent/agent/policy/bot_reply.py @@ -58,11 +58,24 @@ def fill(template: str, values: dict[str, Any] | None = None) -> str: return _PLACEHOLDER.sub(_repl, template or "") +def lookup_raw(code: str) -> str: + """ + 按调用编码取原文模板(不填变量)。 + + 供显示名等短配置读取;空串表示停用/未配。走同一份短缓存。 + """ + key = (code or "").strip() + if not key: + return "" + return _lookup(key) + + def render(code: str, default: str, **kwargs: Any) -> str: """ 取已启用回复语并填变量。 停用、库空、主账失败、编码对不上:返回 default(已是拼好的默认句)。 + 自动注入 {{bot_name}}(企微机器人显示名),调用方可不传。 副作用:缓存过期时 GET 主账;超时/失败吞掉,不抛给销售。 """ key = (code or "").strip() @@ -72,7 +85,13 @@ def render(code: str, default: str, **kwargs: Any) -> str: tpl = _lookup(key) if not (tpl or "").strip(): return fallback - return fill(tpl, kwargs) + values = dict(kwargs) + if "bot_name" not in values: + # 延迟导入,避免与 bot_name → lookup_raw 循环在模块加载期炸。 + from agent.policy.bot_name import bot_display_name + + values["bot_name"] = bot_display_name() + return fill(tpl, values) def _lookup(code: str) -> str: diff --git a/inquiry-agent/agent/policy/deal_followup.py b/inquiry-agent/agent/policy/deal_followup.py index c33b5b2..da633c4 100644 --- a/inquiry-agent/agent/policy/deal_followup.py +++ b/inquiry-agent/agent/policy/deal_followup.py @@ -204,6 +204,22 @@ def _is_waicai(facts: dict[str, str] | None) -> bool: return "外采" in blob +def _has_all_line(tokens: list[str], *, line: str) -> bool: + """ + 该负责人的「负责线路」是否覆盖本业务线全部线路。 + + 成交通知只推负责人(isLeader),不群发。后台负责线路选项里的 + 「空运全线 / 海运全线 / 陆运全线」表示这位负责人管整条业务线; + 旧数据可能仍写「全线」。只认整词,避免误伤「外采-空运」等。 + """ + want = { + "SEA": {"海运全线", "全线"}, + "LAND": {"陆运全线", "全线"}, + "AIR": {"空运全线", "全线"}, + }.get((line or "").strip().upper(), set()) + return any(t in want for t in tokens) + + def leader_matches( *, role_code: str, @@ -215,7 +231,8 @@ def leader_matches( """ 员工管理「是负责人 + 负责线路对得上」。 - 海运:海运全线。陆运:线路类别整词。空运:三字码对 / 目的港 / 全线;外采须工单标明。 + 海运:海运全线。陆运:陆运全线或线路类别整词。 + 空运:空运全线 / 三字码对 / 目的港;外采须工单标明。 """ if not is_leader: return False @@ -223,14 +240,18 @@ def leader_matches( code = (role_code or "").strip().lower() tokens = _split_routes(routes) if line == "SEA": - return code == "sea" and "海运全线" in tokens + return code == "sea" and _has_all_line(tokens, line="SEA") if line == "LAND": + if code != "land": + return False + if _has_all_line(tokens, line="LAND"): + return True want = str((facts or {}).get("线路类别") or "").strip() - return code == "land" and bool(want) and want in tokens + return bool(want) and want in tokens if line == "AIR": if code != "air": return False - if "全线" in tokens: + if _has_all_line(tokens, line="AIR"): return True if "外采-空运" in tokens and _is_waicai(facts): return True diff --git a/inquiry-agent/agent/policy/deal_outcome.py b/inquiry-agent/agent/policy/deal_outcome.py index 8f07901..2df8c10 100644 --- a/inquiry-agent/agent/policy/deal_outcome.py +++ b/inquiry-agent/agent/policy/deal_outcome.py @@ -203,6 +203,7 @@ def finish_deal_outcome( quote=quote or {}, business_line=business_line, ) + enqueued = 0 for row in leaders: uid = str(row.get("wecomId") or "").strip() if not uid: @@ -214,7 +215,9 @@ def finish_deal_outcome( dedupe_key=f"deal-notify:{no}:{uid}", payload=dict(payload), ) - if not ok: + if ok: + enqueued += 1 + else: logger.warning("成交通知入队失败 wo=%s uid=%s info=%s", no, uid, info) except Exception: logger.exception("成交通知入队异常 wo=%s uid=%s", no, uid) diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py index 0e61d7f..953f3fd 100644 --- a/inquiry-agent/agent/policy/inquiry_copy.py +++ b/inquiry-agent/agent/policy/inquiry_copy.py @@ -367,7 +367,12 @@ def land_confirm_card(facts: dict[str, str] | None) -> str: ] for key in _land_cargo_keys(land_type, src): label = LAND_DISPLAY.get(key, key) - val = (src.get(key) or "").strip() + if key == "品名": + from agent.schema.land_options import land_cargo_name + + val = land_cargo_name(src) + else: + val = (src.get(key) or "").strip() lines.append(f"{label}(必填):{val}") if land_type in _LAND_CUSTOMS_TYPES: lines.append(f"通关口岸(非必填):{(src.get('通关口岸') or '').strip()}") @@ -534,7 +539,10 @@ def _air_known_value(facts: dict[str, str], key: str) -> str: if key == "包装方式": return (src.get("包装方式") or src.get("包装类型") or "").strip() if key == "报价日期": - return (src.get("报价日期") or "").strip() + from agent.schema.field_validate import is_blank_quote_date + + val = (src.get("报价日期") or "").strip() + return "" if is_blank_quote_date(val) else val from agent.llm.extract_text import clip_inquiry_value return clip_inquiry_value(src.get(key) or "") @@ -650,42 +658,6 @@ def ask_clarify( if key == "起运港": continue val = known(src, key) - # #region agent log - if key == "added_services": - try: - import json as _dbg_json - import time as _dbg_time - from pathlib import Path as _DbgPath - - _payload = { - "sessionId": "5ea5b3", - "runId": "post-fix", - "hypothesisId": "H3", - "location": "inquiry_copy.py:ask_clarify", - "message": "clarify display added_services", - "data": { - "raw_facts": str(src.get("added_services") or ""), - "display_val": val, - "label": label, - }, - "timestamp": int(_dbg_time.time() * 1000), - } - for _p in ( - _DbgPath(__file__).resolve().parents[3] / "debug-5ea5b3.log", - _DbgPath(r"d:/own/友通达询价智能体(新)/debug-5ea5b3.log"), - _DbgPath(r"C:/Users/ytd_extdev_0903/incoming/debug-5ea5b3.log"), - ): - try: - _p.parent.mkdir(parents=True, exist_ok=True) - with _p.open("a", encoding="utf-8") as _f: - _f.write( - _dbg_json.dumps(_payload, ensure_ascii=False) + "\n" - ) - except Exception: - pass - except Exception: - pass - # #endregion if val: lines.append(f"{label}:{val}") lines.append("") @@ -802,10 +774,13 @@ def inquiry_field_lines( dest = (facts.get("目的港") or facts.get("目的地") or "-").strip() cargo = (facts.get("品名") or "-").strip() or "-" pack = (facts.get("包装方式") or facts.get("包装类型") or "-").strip() or "-" + from agent.schema.field_validate import is_blank_quote_date + quote_date = (facts.get("报价日期") or "").strip() - if quote_date in {"-", "—", "-"}: + if is_blank_quote_date(quote_date): quote_date = "" - if (transport_mode or "").strip() == "海运": + mode = (transport_mode or "").strip() + if mode == "海运": qty = (facts.get("货量") or facts.get("货物数量") or "-").strip() or "-" load = (facts.get("整柜或拼柜") or "-").strip() or "-" box = (facts.get("箱型箱量") or "-").strip() or "-" @@ -824,7 +799,7 @@ def inquiry_field_lines( f"运输分类:{klass}", f"报价日期:{quote_date or '-'}", ] - if (transport_mode or "").strip() == "陆运": + if mode == "陆运": land_type = (facts.get("运输类型") or "").strip() lines = [ f"工单号:{work_order_no}", @@ -854,30 +829,6 @@ def inquiry_field_lines( ] # 建单确认和空运群:有内容才多一行。没填不出现。核对卡的空行不在这里。 added = (facts.get("added_services") or facts.get("增值服务") or "").strip() - # #region agent log - try: - import json as _dbg_json - import time as _dbg_time - from pathlib import Path as _DbgPath - - _payload = { - "sessionId": "5ea5b3", - "runId": "pre-fix", - "hypothesisId": "H3", - "location": "inquiry_copy.py:air_confirm_lines", - "message": "display added_services value", - "data": { - "added": added, - "facts_keys": sorted(str(k) for k in (facts or {}).keys()), - }, - "timestamp": int(_dbg_time.time() * 1000), - } - _path = _DbgPath(__file__).resolve().parents[3] / "debug-5ea5b3.log" - with _path.open("a", encoding="utf-8") as _f: - _f.write(_dbg_json.dumps(_payload, ensure_ascii=False) + "\n") - except Exception: - pass - # #endregion if added and added not in {"-", "—", "-"}: lines.insert(-1, f"增值服务:{added}") return lines @@ -1323,18 +1274,21 @@ def collab_hint(work_order_no: str) -> str: """ 空运价格卡协同说明:紧挨「不需要协同,直接生成报价单」上方。 - 后台 inquiring.air_collab_hint;库内模板可用 {{ticket_no}}。 - 无后台模板时用本默认句(已带工单号)。销售侧示例: - …发送“ WO202610060030@询价小助手”,谢谢。 + 后台 inquiring.air_collab_hint;库内模板可用 {{ticket_no}}、{{bot_name}}。 + 无后台模板时用本默认句(已带工单号与当前显示名)。 """ + from agent.policy.bot_name import bot_display_name + no = (work_order_no or "").strip() + name = bot_display_name() return _r( "inquiring.air_collab_hint", ( f"若需要咨询内部航线人员,请手动将相关方拉到空运原有协同群," - f"并在空运协同群中发送“ {no}@询价小助手”,谢谢。" + f"并在空运协同群中发送“ {no}@{name}”,谢谢。" ), ticket_no=no, + bot_name=name, ) @@ -2865,10 +2819,18 @@ def deal_ack( next_followup_at: str = "", lost_reason: str = "", followup_hours: float | None = None, + business_line: str = "", ) -> str: - """成交三态回执。后台 deal.recorded_won / lost.ask_reason / negotiating.recorded。""" + """ + 成交三态回执。后台 deal.recorded_won / lost.ask_reason / negotiating.recorded。 + + 多段联运不锁舱:已成交只回短确认,不要后台模板里「空运可锁舱」括号说明。 + """ no = (work_order_no or "").strip() + line = (business_line or "").strip().upper() if outcome == "已成交": + if line == "MULTI": + return f"工单 {no} 已记录为「已成交」。" return _r( "deal.recorded_won", f"已记录为「已成交」。工单 {no} 已关闭智能体跟进。", @@ -3408,8 +3370,10 @@ def air_text_quote_for_lock_example(work_order_no: str = "") -> str: 附件报价不拆金额、常锁不成;产品口径改为引导航线改发文字价。 示例必须带计费单位(空运常见 KG),避免航线漏写单位导致文字价无法落库。 """ + from agent.policy.bot_name import at_bot + no = (work_order_no or "").strip() or "工单号" - return f"{no},空运段,KG,空运费 单价CNY99.00,数量100 ,金额CNY9900.00@询价小助手" + return f"{no},空运段,KG,空运费 单价CNY99.00,数量100 ,金额CNY9900.00{at_bot()}" def air_attachment_quote_lock_tip(*, work_order_no: str) -> str: diff --git a/inquiry-agent/agent/policy/land_text_flow.py b/inquiry-agent/agent/policy/land_text_flow.py index 11805e5..c8cf611 100644 --- a/inquiry-agent/agent/policy/land_text_flow.py +++ b/inquiry-agent/agent/policy/land_text_flow.py @@ -138,39 +138,6 @@ class LandTextInquiryFlow(SeaTextInquiryFlow): sess.work_order_no or "-", ) sess = None - # #region agent log - try: - import json as _json - import time as _time - from pathlib import Path as _Path - - with _Path(r"d:\own\友通达询价智能体(新)\debug-d331ea.log").open("a", encoding="utf-8") as _dbg: - _dbg.write( - _json.dumps( - { - "sessionId": "d331ea", - "runId": "post-fix", - "hypothesisId": "D", - "location": "land_text_flow.py:on_text:sess_keep", - "message": "land sess keep-or-clear", - "data": { - "phase_in": phase, - "kept_sess": sess is not None, - "in_front": phase in _LAND_FRONT, - "sensitive_hold": ( - str(getattr(sess, "sensitive_hold", "") or "")[:120] if sess else "" - ), - "text": (text or "")[:120], - }, - "timestamp": int(_time.time() * 1000), - }, - ensure_ascii=False, - ) - + "\n" - ) - except Exception: - pass - # #endregion snap = extract_inquiry_snapshot( text, @@ -206,6 +173,14 @@ class LandTextInquiryFlow(SeaTextInquiryFlow): # 还在问运输方式时,重量体积和陆运选项要对照前面几句,不只看「1」。 source = waiting_mode_source(sess, text) facts = harvest_oral_measures(source, merged) + # 陆运:箱型误入要改写;口语 45HQ/厢车/米车补进车型/数量。 + from agent.schema.field_validate import ( + harvest_oral_land_vehicle, + promote_land_vehicle_from_container, + ) + + facts = promote_land_vehicle_from_container(facts) + facts = harvest_oral_land_vehicle(source, facts) # 选项只从原话+能对上枚举的模型值收;禁止模型「陆运」盖掉已选的国内运输拼车。 facts = harvest_land_options(source, facts) b_opt = " ".join( diff --git a/inquiry-agent/agent/policy/multi_group_ops.py b/inquiry-agent/agent/policy/multi_group_ops.py index 963cc8b..da96a03 100644 --- a/inquiry-agent/agent/policy/multi_group_ops.py +++ b/inquiry-agent/agent/policy/multi_group_ops.py @@ -11,6 +11,7 @@ from __future__ import annotations import json import logging +import re from contextlib import contextmanager from typing import Any, Callable, Iterator, Optional @@ -18,10 +19,13 @@ from agent.llm.extract_text import extract_collab_fields, extract_product_quote from agent.policy import inquiry_copy as copy from agent.policy.multi_segments import Segment, group_brief, includes_air, segments_from_payload from agent.policy.sea_group_ops import ( + _archive_file, _quote_currency_hint, apply_group_deal, + filename_is_excel_pdf, group_client_of, is_sales, + notify_quote_attachment_saved, override_group_client, send_filled_sheet, send_group_text, @@ -109,6 +113,128 @@ def sender_mode(flow: Any, sender_id: str) -> str: return _ROLE_MODE.get(role, "") +_RE_SEG_INDEX = re.compile( + r"(?:段\s*(\d+)|第\s*([一二三四五六七八九]|\d+)\s*段)", + re.I, +) +_CN_INDEX = { + "一": 1, + "二": 2, + "三": 3, + "四": 4, + "五": 5, + "六": 6, + "七": 7, + "八": 8, + "九": 9, +} +_FEE_MODE = ( + ("陆运费", "LAND"), + ("空运费", "AIR"), + ("海运费", "SEA"), + # 报价单文件名常写「空运到港报价单」无「费」字。 + ("陆运", "LAND"), + ("空运", "AIR"), + ("海运", "SEA"), +) + + +def resolve_quote_segment( + text: str, + segments: list[Segment], + role_mode: str, +) -> tuple[Optional[Segment], str]: + """ + 多段报价归到哪一段。 + + 1) 正文「段1 / 第一段」;2) 正文写了陆运费/空运费/海运费;3) 岗位。 + 运行证据:林绵勋岗位是 air,发「段1:陆运费」却出了段2空运单。 + 返回 (段, 选用原因)。 + """ + if not segments: + return None, "no_segments" + raw = text or "" + hit = _RE_SEG_INDEX.search(raw) + if hit: + num = hit.group(1) or hit.group(2) or "" + try: + index = int(num) if str(num).isdigit() else _CN_INDEX.get(str(num), 0) + except ValueError: + index = 0 + if index > 0: + for seg in segments: + if seg.index == index: + return seg, f"text_index:{index}" + fee_mode = "" + for word, mode in _FEE_MODE: + if word in raw: + fee_mode = mode + break + role = (role_mode or "").strip().upper() + if fee_mode: + for seg in segments: + if seg.mode == fee_mode: + return seg, f"text_fee:{fee_mode}" + if role: + for seg in segments: + if seg.mode == role: + return seg, f"role:{role}" + return None, "unmatched" + + +def apply_multi_file( + *, + flow: Any, + ticket: object, + chat_id: str, + sender_id: str, + filename: str, + media: Optional[dict[str, Any]] = None, +) -> str: + """ + 多段群附件报价:落 inq_attachment,按文件名/岗位归段,不写正文抽价。 + + 运行证据:石畅畅发「…空运到港报价单.xlsx」时,旧逻辑把文件名当文字报价, + 回「没有识别到报价」,附件也没进后台。 + 副作用:写主账附件+分段报价;可能发群确认/成交跟进。Worker 线程。 + """ + room = (chat_id or "").strip() + no = ticket_no(ticket) + client = group_client_of(flow) + name = (filename or "").strip() + if not filename_is_excel_pdf(name): + send_group_text(client, room, copy.group_only_excel_pdf()) + return "file_type_rejected" + segments = load_segments(ticket) + if not segments: + send_group_text(client, room, f"工单{no}没有可报价的运输段。") + return "multi_no_segments" + role_mode = sender_mode(flow, sender_id) + target, reason = resolve_quote_segment(name, segments, role_mode) + if target is None: + send_group_text(client, room, "没有对上你负责的运输段,请让对应岗位报价。") + return "quote_wrong_role" + _archive_file(flow, no, name, dict(media or {}), sender_id) + notify_quote_attachment_saved(flow, room, no, count=1) + quote = { + "source_label": "产品附件报价", + "file_name": name, + "total": "-", + "raw_text": name, + } + # 附件本身就是报价单:只落段价,不再重发空白模板。 + return _save_segment_quote( + flow=flow, + ticket=ticket, + chat_id=room, + segment=target, + segments=segments, + quote=quote, + sheet_fn=None, + skip_sheet=True, + ) + + def handle_multi_inbound( *, flow: Any, @@ -142,8 +268,9 @@ def handle_multi_inbound( if only_no: _send_activation(flow, ticket, room, segments, sender_id=sender_id, sheet_fn=sheet_fn) return "multi_activated" - mode = sender_mode(flow, sender_id) - target = next((seg for seg in segments if seg.mode == mode), None) + role_mode = sender_mode(flow, sender_id) + target, resolve_reason = resolve_quote_segment(text or "", segments, role_mode) + mode = (target.mode if target is not None else role_mode) or "" fields = {} if target is not None: keys = collab_fields_for_ticket(target.facts, target.mode) @@ -152,7 +279,8 @@ def handle_multi_inbound( _save_collab(flow, ticket, target, fields) ticket = _reload(flow, no) or ticket segments = load_segments(ticket) - target = next((seg for seg in segments if seg.mode == mode), target) + target, resolve_reason = resolve_quote_segment(text or "", segments, role_mode) + mode = (target.mode if target is not None else role_mode) or mode # 段报价币种优先;否则用工单顶层 quote(产品未写币种时继承 TMS) baseline = "" if target is not None: @@ -190,10 +318,21 @@ def handle_multi_inbound( ) -def deal_text(*, work_order_no: str, segments: list[Segment], quotes: dict[str, dict[str, Any]]) -> str: - """整票成交跟进文字。每段各自的价格、有效期,海运陆运再加时效。不加总。""" +def deal_text( + *, + work_order_no: str, + segments: list[Segment], + quotes: dict[str, dict[str, Any]], + sales_name: str = "", +) -> str: + """ + 整票成交跟进文字。开头 @本单销售,与海运 group_deal_followup 一致。 + + 每段各自的价格、有效期,海运陆运再加时效。不加总。 + """ no = (work_order_no or "").strip() - lines = [f"询价工单 {no}", copy.DEAL_SUBTITLE] + name = (sales_name or "").strip() or "销售" + lines = [f"@{name}", f"询价工单 {no}", copy.DEAL_SUBTITLE] for seg in segments: quote = quotes.get(seg.mode) or {} lines.append(f"段{seg.index}:{seg.word()}") @@ -240,6 +379,7 @@ def _save_segment_quote( segments: list[Segment], quote: dict[str, Any], sheet_fn: Optional[SheetFn], + skip_sheet: bool = False, ) -> str: """写入这一段报价并发该段报价单。其他段未齐时不发成交跟进。""" no = ticket_no(ticket) @@ -254,28 +394,63 @@ def _save_segment_quote( "source_label": "分段报价", "deal_version": version, } - writer = getattr(flow.ledger, "upsert_quote", None) - if callable(writer): - writer( + # 分段报价是产品手工价,必须走 upsertProductQuote。 + # 旧代码误走 replaceTmsQuote,主账 inq_quote 不落、六态也不到「已报价」。 + product_writer = getattr(flow.ledger, "upsert_product_quote", None) + if callable(product_writer): + product_writer( work_order_no=no, quote=payload, - to_status="已报价" if all_ready else "", + # 未齐段时保持询价中;空串会被主账默认成「已报价」。 + to_status="已报价" if all_ready else "询价中", + remark="分段报价", + kind="product", ) + else: + writer = getattr(flow.ledger, "upsert_quote", None) + if callable(writer): + writer( + work_order_no=no, + quote=payload, + to_status="已报价" if all_ready else "", + ) ticket = _reload(flow, no) or ticket - _send_sheet( - flow=flow, - ticket=ticket, - chat_id=chat_id, - segment=segment, - quote=quote, - sheet_fn=sheet_fn, - ) + if not skip_sheet: + _send_sheet( + flow=flow, + ticket=ticket, + chat_id=chat_id, + segment=segment, + quote=quote, + sheet_fn=sheet_fn, + ) client = group_client_of(flow) if not all_ready: - send_group_text(client, chat_id, f"段{segment.index}{segment.word()}报价单已发出。其他段报完后一起跟进成交。") + tip = ( + f"段{segment.index}{segment.word()}报价附件已收到。其他段报完后一起跟进成交。" + if skip_sheet + else f"段{segment.index}{segment.word()}报价单已发出。其他段报完后一起跟进成交。" + ) + send_group_text(client, chat_id, tip) return "segment_quoted" - text = deal_text(work_order_no=no, segments=segments, quotes=stored) - send_group_text(client, chat_id, text) + # 与海运一致:正文写 @销售姓名,并带 mention,企微才能真正点到人。 + from agent.policy.sea_group_ops import resolve_group_mentions + + sales_name, _products, _mention_ids = resolve_group_mentions(flow, ticket, None) + view = ticket_view(ticket) + sales_id = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip() + text = deal_text( + work_order_no=no, + segments=segments, + quotes=stored, + sales_name=sales_name, + ) + send_group_text( + client, + chat_id, + text, + mention_userids=[sales_id] if sales_id else None, + ) carder = getattr(client, "send_group_card", None) if callable(carder): carder( @@ -446,15 +621,23 @@ def _send_sheet( quote: dict[str, Any], sheet_fn: Optional[SheetFn], ) -> None: - """按这一段的运输方式匹配模板。失败只记日志,不挡住报价归段。""" + """ + 按这一段的运输方式匹配模板。失败只记日志,不挡住报价归段。 + + 有价段报价单:发群同时写后台附件;激活空白模版(quote 无金额)不落库。 + """ sender = sheet_fn or send_filled_sheet + sheet_ticket = _sheet_ticket(ticket, segment) + # 段上已有金额/明细 → 正式出单,要进 inq_attachment;空 quote 是激活模版。 + persist = _has_money(dict(quote or {})) try: sender( flow=flow, - ticket=_sheet_ticket(ticket, segment), + ticket=sheet_ticket, quote_override=dict(quote or {}), business_line=segment.mode, chat_id=chat_id, + persist_attachment=persist, ) except Exception: logger.exception("多段模板发送失败 wo=%s mode=%s", ticket_no(ticket), segment.mode) diff --git a/inquiry-agent/agent/policy/multi_segments.py b/inquiry-agent/agent/policy/multi_segments.py index 7fb168e..ec48ddd 100644 --- a/inquiry-agent/agent/policy/multi_segments.py +++ b/inquiry-agent/agent/policy/multi_segments.py @@ -182,12 +182,16 @@ def _shared_cargo_facts(header: str) -> dict[str, str]: def _cargo_name_before_segments(raw: str) -> str: """段前货物句里,去掉件数重量体积包装后剩下的那一个品名。""" + from agent.llm.extract_text import is_clarify_ui_prompt + cleaned = _SHARED_NOISE.sub(" ", raw or "") parts: list[str] = [] for part in re.split(r"[,,、;;\n]+", cleaned): token = part.strip(" ::。.") if not token or re.fullmatch(r"[\d.\s*xX×]+", token): continue + if is_clarify_ui_prompt(token): + continue if any(word in token for word in ("到", "飞", "空运", "海运", "陆运", "段")): continue parts.append(token) @@ -606,12 +610,14 @@ def _lacking_indexes(segments: list[Segment]) -> list[int]: def _facts_from_body(body: str, mode: str) -> dict[str, str]: """一段正文里的「字段名:值」。陆运再收运输类型、线路类别和销售侧叫法。""" + from agent.llm.extract_text import clip_inquiry_value + facts = parse_labeled_facts(body) for line in (body or "").splitlines(): matched = _EXTRA_LABEL.match(line.strip()) if not matched: continue - key, val = matched.group(1), matched.group(2).strip() + key, val = matched.group(1), clip_inquiry_value(matched.group(2)) if val and not str(facts.get(key) or "").strip(): facts[key] = val if mode == "LAND": diff --git a/inquiry-agent/agent/policy/quote_adjust_ops.py b/inquiry-agent/agent/policy/quote_adjust_ops.py index 7f9bafc..7894329 100644 --- a/inquiry-agent/agent/policy/quote_adjust_ops.py +++ b/inquiry-agent/agent/policy/quote_adjust_ops.py @@ -322,6 +322,7 @@ def apply_group_adjust( collab = dict(view.get("collab_facts") or {}) if sess is not None and getattr(sess, "collab_facts", None): collab.update(dict(sess.collab_facts or {})) + # 调价后再出一版:正式有价单,登记后台附件。 _render_and_send_sheet( flow=flow, chat_id=room, @@ -335,6 +336,7 @@ def apply_group_adjust( quote=result.quote, sales_id=sender_id, business_line=line, + persist_attachment=True, ) emit_group_deal(flow=flow, ticket=ticket2, sess=sess, quote=result.quote) logger.info("group_adjust wo=%s applied=%s", no, [x.get("name") for x in result.applied]) diff --git a/inquiry-agent/agent/policy/sea_group_ops.py b/inquiry-agent/agent/policy/sea_group_ops.py index eb0b324..9ab2012 100644 --- a/inquiry-agent/agent/policy/sea_group_ops.py +++ b/inquiry-agent/agent/policy/sea_group_ops.py @@ -390,7 +390,11 @@ def send_group_text( content: str, mention_userids: Optional[list[str]] = None, ) -> None: - """群文本可带企微 userid,方便客户端真正点到人。旧客户端无此参数就只发正文。""" + """ + 群文本可带企微 userid,方便客户端真正点到人。旧客户端无此参数就只发正文。 + + 发完后把该群标热:销售接着回「解散群」时,存档轮询会优先扫到,少等几分钟。 + """ if not client or not chat_id or not content: return sender = getattr(client, "send_group", None) @@ -401,6 +405,13 @@ def send_group_text( sender(chat_id=chat_id, content=content, mention_userids=mentions) except TypeError: sender(chat_id=chat_id, content=content) + try: + from agent.channel.wshoto.rooms import mark_room_hot, watch_collab_room + + watch_collab_room(chat_id) + mark_room_hot(chat_id) + except Exception: + logger.exception("发群后登记热窗失败 chat=%s", chat_id) def hydrate_ticket(flow: Any, ticket: object) -> object: @@ -572,11 +583,13 @@ def send_brief_and_sheet( quote_override: Optional[dict[str, Any]] = None, tms_quote: Optional[dict[str, Any]] = None, product_quote: Optional[dict[str, Any]] = None, + persist_attachment: bool = False, ) -> str: """ 群里再发一版摘要 + 填好的海运模板。旧消息不撤。 TMS 和人工报价分段写,无价也发表。 + persist_attachment:正式有价出单传 True,登记后台附件;激活/补字段重发保持 False。 """ ticket = hydrate_ticket(flow, ticket) view = ticket_view(ticket) @@ -638,6 +651,7 @@ def send_brief_and_sheet( sess=sess, quote_override=quote, business_line=line, + persist_attachment=persist_attachment, ) return "brief_and_sheet" @@ -672,11 +686,14 @@ def send_filled_sheet( quote_override: Optional[dict[str, Any]] = None, business_line: str = "", chat_id: str = "", + persist_attachment: bool = False, ) -> dict[str, Any]: """ 只发填好的模板文件,不再发文字摘要。 business_line 决定匹配哪套后台模板;空运必须传 AIR,禁止落到海运默认模板。 + persist_attachment:True 时主账登记 inq_attachment(正式有价出单); + 激活空白模版保持 False,避免后台附件列堆满空白表。 """ incoming_view = ticket_view(ticket) if ticket is not None else {} incoming_facts = dict(incoming_view.get("facts") or {}) @@ -720,6 +737,7 @@ def send_filled_sheet( or "" ), business_line=line or "SEA", + persist_attachment=persist_attachment, ) @@ -795,17 +813,21 @@ def _render_and_send_sheet( quote: dict[str, Any], sales_id: str, business_line: str = "SEA", + persist_attachment: bool = False, ) -> dict[str, Any]: """ 按业务线匹配后台模板、填表、发群文件。 空运必须 business_line=AIR,否则会套上海运默认模板。 + persist_attachment:正式有价出单 True(写 inq_attachment); + 激活空白模版 False(只发企微,不堆后台附件列)。 """ renderer = getattr(flow.ledger, "render_quote", None) client = group_client_of(flow) if not callable(renderer) or not chat_id: return {"ok": False, "error": "no_renderer_or_chat"} line = (business_line or "SEA").strip() or "SEA" + # 激活空白模版:persist=False;产品/分段有价出单:persist=True 写后台附件。 result = renderer( work_order_no=work_order_no, business_line=line, @@ -813,6 +835,7 @@ def _render_and_send_sheet( quote=quote, format="xlsx", sales_wecom_id=sales_id, + persist_attachment=bool(persist_attachment), ) or {} if not result.get("ok"): logger.warning("群填表失败 wo=%s line=%s err=%s", work_order_no, line, result.get("error")) @@ -955,6 +978,7 @@ def apply_product_quote( quote = merge_quote_fees(base, quote) _save_product_quote(flow, no, quote, sess) ticket2 = _reload(flow, chat_id, no) or ticket + # 正式有价出单:Excel 进群的同时登记后台附件,销售在工单列表能看到。 send_brief_and_sheet( flow=flow, ticket=ticket2, @@ -962,6 +986,7 @@ def apply_product_quote( quote_override=quote, tms_quote={}, product_quote=quote, + persist_attachment=True, ) emit_group_deal(flow=flow, ticket=ticket2, sess=sess, quote=quote) return "product_quote_sent" @@ -1005,6 +1030,7 @@ def apply_tms_quote_choice( _clear_pending_quote(no, sess, flow) _save_product_quote(flow, no, quote, sess) ticket2 = _reload(flow, chat_id, no) or ticket + # 正式有价出单:登记后台附件(与产品文字报价同一口径)。 send_brief_and_sheet( flow=flow, ticket=ticket2, @@ -1012,6 +1038,7 @@ def apply_tms_quote_choice( quote_override=quote, tms_quote=base if keep_tms else {}, product_quote=overlay if keep_tms else quote, + persist_attachment=True, ) emit_group_deal(flow=flow, ticket=ticket2, sess=sess, quote=quote) return "product_quote_sent" @@ -1104,6 +1131,7 @@ def _persist_quote_sheet( or "SEA" ).strip() or "SEA" if callable(renderer) and no: + # 确认落库:需要把填好的报价单登记到后台附件。 result = renderer( work_order_no=no, business_line=line, @@ -1121,10 +1149,15 @@ def _persist_quote_sheet( or view.get("salesWecomId") or "" ), + persist_attachment=True, ) or {} if result.get("ok"): filename = filename_from_render(no, result) - object_key = str(result.get("objectKey") or filename) + object_key = str(result.get("objectKey") or "").strip() + # 主账 render 已写 inq_attachment 时不再重复挂一条「只有文件名」的空壳。 + if object_key or result.get("attachmentSaved"): + return + object_key = filename _archive_file(flow, no, filename, {"media_id": object_key}, sender_id) @@ -1168,7 +1201,11 @@ def handle_group_file( def dissolve_group(*, flow: Any, ticket: object, sess: Any = None) -> str: - """解散应用群,这单协同结束,不改六态。""" + """ + 解散应用群,这单协同结束,不改六态。 + + 先发「群已解散」再移人:移完人销售可能已不在群,收不到确认。 + """ view = ticket_view(ticket) no = ticket_no(ticket) chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "") @@ -1176,6 +1213,8 @@ def dissolve_group(*, flow: Any, ticket: object, sess: Any = None) -> str: members = list(view.get("collab_member_ids") or view.get("memberIds") or []) if sess is not None: members = list(sess.invited_userids or members) + # 先回话再移人,避免销售被移出后看不到确认。 + send_group_text(client, chat_id, copy.group_dissolved(work_order_no=no)) dismiss = getattr(client, "dismiss_group", None) if callable(dismiss) and chat_id: dismiss(chat_id=chat_id, userids=members) @@ -1192,7 +1231,6 @@ def dissolve_group(*, flow: Any, ticket: object, sess: Any = None) -> str: if sess is not None: sess.collab_ended = True flow._save(sess) - send_group_text(client, chat_id, copy.group_dissolved(work_order_no=no)) return "group_dissolved" @@ -1263,6 +1301,18 @@ def apply_group_deal( view = ticket_view(ticket) chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "") client = group_client_of(flow) + line = str( + view.get("business_line") + or view.get("businessLine") + or (sess.business_line if sess else "") + or "SEA" + ).strip().upper() + status_now = ticket_status(ticket) or ( + str(getattr(sess, "status", "") or "").strip() if sess is not None else "" + ) + # 同一终态再确认一次(BOT+存档双通道):只静默,不刷「不能修改工单信息」。 + if status_now in {"已成交", "未成交"} and outcome == status_now: + return "deal_already_recorded" stopped = halt_closed_group(flow=flow, ticket=ticket, sess=sess, chat_id=chat_id) if stopped: return stopped @@ -1270,7 +1320,6 @@ def apply_group_deal( send_group_text(client, chat_id, copy.group_deal_sales_only()) return "not_sales" quote = _current_quote(view, sess) - status_now = ticket_status(ticket) or (sess.status if sess else "") usable_total = str(quote.get("total") or "").strip() if usable_total in {"", "-"}: usable_total = "" @@ -1286,7 +1335,7 @@ def apply_group_deal( outcome=outcome, facts=facts_of_group(view, sess), quote=quote, - business_line=str(view.get("business_line") or (sess.business_line if sess else "SEA") or "SEA"), + business_line=line or "SEA", sales_name=str(view.get("sales_name") or (getattr(sess, "invited_sales_name", "") if sess else "") or ""), lost_reason=inline, ) @@ -1303,17 +1352,15 @@ def apply_group_deal( sess.allowed = () flow._save(sess) ack_reason = inline if phase == "done" and outcome == "未成交" else "" - send_group_text( - client, - chat_id, - copy.deal_ack( - outcome, - no, - str(result.get("next_followup_at") or ""), - lost_reason=ack_reason, - followup_hours=result.get("negotiate_hours"), - ), + ack = copy.deal_ack( + outcome, + no, + str(result.get("next_followup_at") or ""), + lost_reason=ack_reason, + followup_hours=result.get("negotiate_hours"), + business_line=line, ) + send_group_text(client, chat_id, ack) return phase diff --git a/inquiry-agent/agent/redis_coord/air_inbound.py b/inquiry-agent/agent/redis_coord/air_inbound.py index aa34ebe..aa49035 100644 --- a/inquiry-agent/agent/redis_coord/air_inbound.py +++ b/inquiry-agent/agent/redis_coord/air_inbound.py @@ -22,12 +22,14 @@ _TTL_SEC = 3 * 60 def _norm_text(text: str) -> str: - body = (text or "").replace("@询价小助手", "").replace("询价小助手", "") + from agent.policy.bot_name import strip_bot_mention + + body = strip_bot_mention(text or "") return "".join(body.split()) def text_fingerprint(chat_id: str, sender_id: str, text: str) -> str: - """同一群同一人同一句(去掉 @询价小助手)。""" + """同一群同一人同一句(去掉 @机器人显示名,含旧名)。""" return f"{(chat_id or '').strip()}|{(sender_id or '').strip()}|{_norm_text(text)}" @@ -56,6 +58,19 @@ def _redis_mark(fp: str) -> None: logger.exception("空运群入站去重写 Redis 失败") +def _redis_claim(fp: str) -> Optional[bool]: + """ + Redis SET NX 抢占。True=抢到;False=已被占;None=Redis 不可用。 + """ + try: + client = get_redis_runtime().client + ok = client.raw.set(client.key(AIR_INBOUND_TEXT, fp), "1", nx=True, ex=_TTL_SEC) + return bool(ok) + except Exception: + logger.exception("空运群入站去重抢占 Redis 失败") + return None + + def _file_fresh(fp: str) -> bool: path = _file_path(fp) try: @@ -74,6 +89,27 @@ def _file_mark(fp: str) -> None: logger.exception("空运群入站去重写文件失败") +def _file_claim(fp: str) -> bool: + """文件独占创建;已存在且未过期则失败。""" + path = _file_path(fp) + try: + if path.exists(): + if (time.time() - path.stat().st_mtime) < _TTL_SEC: + return False + try: + path.unlink() + except OSError: + pass + with path.open("x", encoding="ascii") as fd: + fd.write("1") + return True + except FileExistsError: + return False + except OSError: + logger.exception("空运群入站去重文件抢占失败") + return True + + def already_handled_air_text(*, chat_id: str, sender_id: str, text: str) -> bool: """这句是否刚被 BOT 办过。给存档补听用。""" fp = text_fingerprint(chat_id, sender_id, text) @@ -91,6 +127,26 @@ def mark_air_text(*, chat_id: str, sender_id: str, text: str) -> None: _file_mark(fp) +def try_claim_air_text(*, chat_id: str, sender_id: str, text: str) -> bool: + """ + 同一群同一人同一句只办一遍(含 BOT 桥重复推送)。 + + 返回 True=本路应处理;False=已有一路在办/办过,直接跳过。 + 运行证据:多段报价同一句回了两份空运单。 + """ + fp = text_fingerprint(chat_id, sender_id, text) + if not _norm_text(text): + return True + redis_ok = _redis_claim(fp) + if redis_ok is True: + _file_mark(fp) + return True + if redis_ok is False: + return False + # Redis 不可用时退回文件抢占。 + return _file_claim(fp) + + def is_archive_source(raw: Optional[dict[str, Any]]) -> bool: src = str((raw or {}).get("source") or "").strip().lower() return src in {"wshoto_archive", "wshoto", "archive"} @@ -116,13 +172,12 @@ def should_skip_air_text( raw: Optional[dict[str, Any]] = None, ) -> bool: """ - 存档补听到的 @ 句,若 BOT 已经回过就跳过。 + 同一句去重:BOT 桥重复推、存档补听都跳过。 - BOT 自己再 @ 同一工单号仍要再发一版摘要,不在这里拦。 + 用 try_claim:第一路处理,后续同文案在 TTL 内不再回。 """ - if not is_archive_source(raw): - return False - if already_handled_air_text(chat_id=chat_id, sender_id=sender_id, text=text): - logger.info("空运群存档补听跳过,BOT 已办 chat=%s", chat_id) + _ = raw + if not try_claim_air_text(chat_id=chat_id, sender_id=sender_id, text=text): + logger.info("空运群入站去重跳过 chat=%s", chat_id) return True return False diff --git a/inquiry-agent/agent/redis_coord/card_debounce.py b/inquiry-agent/agent/redis_coord/card_debounce.py new file mode 100644 index 0000000..cf7f16a --- /dev/null +++ b/inquiry-agent/agent/redis_coord/card_debounce.py @@ -0,0 +1,102 @@ +""" +卡片按钮集中防抖:同一张卡同一按钮在 TTL 内只处理一次。 + +本文件职责:按 task_id/工单号 + 按钮动作抢占;HTTP/Worker 共用 Redis, +单测无 Redis 时退回进程内字典。短协调,不当六态、不当 checkpoint。 +禁止:用 ResponseCode 当业务键(企微每次点击换新码);禁止全局锁所有工单。 +""" + +from __future__ import annotations + +import logging +import threading +import time +from collections import OrderedDict +from typing import Optional + +from agent.redis_coord.keys import IDEMPOTENCY + +logger = logging.getLogger(__name__) + +# 连点窗口:够挡住企微连点/重试,又不挡稍后改点「成交/未成交」 +_TTL_SEC = 20 +_SCOPE = "card-btn" +_LOCAL_MAX = 2000 + +_local_lock = threading.Lock() +_local_seen: OrderedDict[str, float] = OrderedDict() + + +def debounce_key( + *, + action: str, + task_id: str = "", + work_order_no: str = "", + sender_id: str = "", + event_key: str = "", +) -> str: + """ + 拼防抖业务键。优先 task_id(同卡唯一),否则工单号,再退回 event_key/sender。 + + 不含 ResponseCode:每次点击码不同,不能当去重依据。 + """ + act = (action or "").strip() + tid = (task_id or "").strip() + if tid and act: + return f"{tid}|{act}" + wo = (work_order_no or "").strip() + if wo and act: + return f"{wo}|{act}|{(sender_id or '').strip()}" + ek = (event_key or "").strip() + if ek: + return f"{ek}|{(sender_id or '').strip()}" + return f"{(sender_id or '').strip()}|{act}" + + +def try_acquire(key: str, *, ttl_seconds: int = _TTL_SEC) -> bool: + """ + 尝试占用卡片按钮防抖键。 + + 返回 True=首次(可继续业务);False=TTL 内重复(应丢弃副作用与 ack)。 + Redis 不可用时用进程内字典,保证单测与本机复现脚本也能挡住连点。 + """ + token = (key or "").strip() + if not token: + return True + ttl = max(1, int(ttl_seconds or _TTL_SEC)) + redis_hit = _try_redis(token, ttl) + if redis_hit is not None: + return redis_hit + return _try_local(token, ttl) + + +def _try_redis(key: str, ttl: int) -> Optional[bool]: + try: + from agent.redis_coord.runtime import get_redis_runtime + + client = get_redis_runtime().client + full = client.key(IDEMPOTENCY, _SCOPE, key) + ok = client.raw.set(full, "1", nx=True, ex=ttl) + acquired = bool(ok) + if not acquired: + logger.info("卡片按钮防抖命中重复 key=%s", key) + return acquired + except Exception: + logger.debug("卡片按钮防抖 Redis 不可用,改用进程内", exc_info=True) + return None + + +def _try_local(key: str, ttl: int) -> bool: + now = time.time() + with _local_lock: + stale = [k for k, ts in _local_seen.items() if now - ts >= ttl] + for k in stale: + _local_seen.pop(k, None) + prev = _local_seen.get(key) + if prev is not None and now - prev < ttl: + logger.info("卡片按钮防抖(本地)命中重复 key=%s", key) + return False + _local_seen[key] = now + while len(_local_seen) > _LOCAL_MAX: + _local_seen.popitem(last=False) + return True diff --git a/inquiry-agent/agent/routing/air_group_intent.py b/inquiry-agent/agent/routing/air_group_intent.py index 67af5ee..d20a885 100644 --- a/inquiry-agent/agent/routing/air_group_intent.py +++ b/inquiry-agent/agent/routing/air_group_intent.py @@ -71,12 +71,15 @@ _CONFIRM_NO = ("不需要", "不用", "否") def is_missed_air_bot_at(text: str, *, room_already_air: bool = False) -> bool: """ - 存档里看到用户已经 @ 询价小助手。BOT 长连接没推到时,用这一刀补听。 + 存档里看到用户已经 @ 机器人显示名。BOT 长连接没推到时,用这一刀补听。 - 海运拉人卡片也带「询价小助手」,必须排除。闲聊没有 @ 仍不当业务。 + 显示名以后台 system.bot_display_name 为准,过渡期仍认旧名「询价小助手」。 + 海运拉人卡片也常带显示名,必须排除。闲聊没有 @ 仍不当业务。 """ + from agent.policy.bot_name import text_mentions_bot + raw = (text or "").strip() - if "询价小助手" not in raw: + if not text_mentions_bot(raw): return False if "运输方式:海运" in raw or "运输方式:海运" in raw: return False @@ -226,10 +229,12 @@ def cabin_confirm_answer(text: str) -> str: 只认整句(可带工单号和 @)。「不需要」不能被看成「需要」。 返回 yes / no / 空串。空串表示询问作废,调用方按原句继续办。 """ + from agent.policy.bot_name import strip_bot_mention + raw = text or "" no = copy.extract_work_order_no(raw) rest = raw.replace(no, "").replace(no.lower(), "") - rest = rest.replace("@询价小助手", "").replace("询价小助手", "") + rest = strip_bot_mention(rest) rest = "".join(ch for ch in rest if ch.isalnum() or "\u4e00" <= ch <= "\u9fff") if rest in _CONFIRM_NO: return "no" @@ -252,14 +257,16 @@ def _hint_lock_or_release(text: str) -> str: def _only_work_order(text: str) -> bool: """ - 去掉工单号和 @询价小助手 后几乎没字,当作激活/切单。 + 去掉工单号和 @机器人显示名 后几乎没字,当作激活/切单。 - 销售常按话术复制「工单编号WO…@询价小助手」;残留「工单编号」不算业务意图。 + 销售常按话术复制「工单编号WO…@显示名」;残留「工单编号」不算业务意图。 运行证据:only_wo=false 后走 extract_product_quote→product_quote→航线岗拒绝。 """ + from agent.policy.bot_name import strip_bot_mention + no = copy.extract_work_order_no(text) rest = (text or "").replace(no, "").replace(no.lower(), "") - rest = rest.replace("@询价小助手", "").replace("询价小助手", "") + rest = strip_bot_mention(rest) for noise in ("工单编号", "工单号", "编号"): rest = rest.replace(noise, "") rest = "".join(ch for ch in rest if ch.isalnum() or "\u4e00" <= ch <= "\u9fff") diff --git a/inquiry-agent/agent/routing/group_intent.py b/inquiry-agent/agent/routing/group_intent.py index 2689381..1add7d8 100644 --- a/inquiry-agent/agent/routing/group_intent.py +++ b/inquiry-agent/agent/routing/group_intent.py @@ -248,9 +248,12 @@ def classify_group_text( def _norm_intent_text(text: str) -> str: - """去掉空格和 @询价小助手,方便整句就是「2」时对上选项。""" - blob = (text or "").replace(" ", "").replace(" ", "").lower() - blob = blob.replace("@询价小助手", "").replace("询价小助手", "") + """去掉空格和 @机器人显示名,方便整句就是「2」时对上选项。""" + from agent.policy.bot_name import strip_bot_mention + + # 先剥显示名再 lower:否则「智报通AIQ」会变成「智报通aiq」剥不掉。 + blob = strip_bot_mention(text or "") + blob = blob.replace(" ", "").replace(" ", "").lower() return blob.strip() diff --git a/inquiry-agent/agent/schema/added_services.py b/inquiry-agent/agent/schema/added_services.py index f8d3e26..a655bda 100644 --- a/inquiry-agent/agent/schema/added_services.py +++ b/inquiry-agent/agent/schema/added_services.py @@ -105,50 +105,6 @@ def harvest_added_services(text: str) -> list[str]: events.append((base + at + max(piece.find(extra), 0), extra)) events.sort(key=lambda item: item[0]) result = _dedupe([word for _pos, word in events]) - # #region agent log - try: - import json as _dbg_json - import time as _dbg_time - from pathlib import Path as _DbgPath - - _tails: list[dict[str, str]] = [] - for _m in list(_TC_RE.finditer(raw)) + list(_CN_RE.finditer(raw)): - if _m.group(0) not in {"TC", "tc", "托盘"} and _m.group(0).casefold() != "tc": - continue - _end = _m.end() - _tails.append( - { - "word": _m.group(0), - "after": raw[_end : _end + 12], - } - ) - _payload = { - "sessionId": "5ea5b3", - "runId": "post-fix", - "hypothesisId": "H1", - "location": "added_services.py:harvest_added_services", - "message": "harvest result vs trailing modifiers", - "data": { - "text_preview": (raw or "")[:120], - "found": result, - "match_tails": _tails, - }, - "timestamp": int(_dbg_time.time() * 1000), - } - for _path in ( - _DbgPath(__file__).resolve().parents[3] / "debug-5ea5b3.log", - _DbgPath(r"C:/Users/ytd_extdev_0903/incoming/debug-5ea5b3.log"), - _DbgPath(r"d:/own/友通达询价智能体(新)/debug-5ea5b3.log"), - ): - try: - _path.parent.mkdir(parents=True, exist_ok=True) - with _path.open("a", encoding="utf-8") as _f: - _f.write(_dbg_json.dumps(_payload, ensure_ascii=False) + "\n") - except Exception: - pass - except Exception: - pass - # #endregion return result @@ -187,44 +143,6 @@ def apply_air_value_added( elif not str(merged.get("added_services") or "").strip(): merged.pop("added_services", None) _keep_stated_package(text, merged) - # #region agent log - try: - import json as _dbg_json - import time as _dbg_time - from pathlib import Path as _DbgPath - - _payload = { - "sessionId": "5ea5b3", - "runId": "post-fix", - "hypothesisId": "H2", - "location": "added_services.py:apply_air_value_added", - "message": "apply merge before/after", - "data": { - "text_preview": (text or "")[:120], - "before_added": before_added, - "found": found, - "previous": previous, - "labeled": labeled, - "branch": branch, - "after_added": str(merged.get("added_services") or ""), - "pack": str(merged.get("包装方式") or ""), - }, - "timestamp": int(_dbg_time.time() * 1000), - } - for _path in ( - _DbgPath(__file__).resolve().parents[3] / "debug-5ea5b3.log", - _DbgPath(r"C:/Users/ytd_extdev_0903/incoming/debug-5ea5b3.log"), - _DbgPath(r"d:/own/友通达询价智能体(新)/debug-5ea5b3.log"), - ): - try: - _path.parent.mkdir(parents=True, exist_ok=True) - with _path.open("a", encoding="utf-8") as _f: - _f.write(_dbg_json.dumps(_payload, ensure_ascii=False) + "\n") - except Exception: - pass - except Exception: - pass - # #endregion return merged diff --git a/inquiry-agent/agent/schema/air_route_options.py b/inquiry-agent/agent/schema/air_route_options.py index 7b531c3..c957677 100644 --- a/inquiry-agent/agent/schema/air_route_options.py +++ b/inquiry-agent/agent/schema/air_route_options.py @@ -240,23 +240,221 @@ def _rows_from_list(raw: Any) -> list[tuple[str, str]]: return out -def option_fee_lines(option: dict[str, Any] | None, quote: dict[str, Any] | None = None) -> list[tuple[str, str]]: - """ - 该线路 TMS 回了哪些费用就列哪些。 +def _fee_item_dicts(raw: Any) -> list[dict[str, Any]]: + """TMS 费用数组收成字典;字符串 JSON 也认。无名称的跳过。""" + if isinstance(raw, str) and raw.strip(): + try: + raw = json.loads(raw) + except json.JSONDecodeError: + return [] + if not isinstance(raw, list): + return [] + out: list[dict[str, Any]] = [] + for item in raw: + if not isinstance(item, dict): + continue + name = str( + item.get("chargeName") or item.get("feeName") or item.get("name") or item.get("label") or "" + ).strip() + if not name: + continue + row = dict(item) + row["name"] = name + out.append(row) + return out - 只读本条 departureCharges / feeItems。多条线路时,整单 fee_lines 是最低价方案, - 禁止套到没回费用的线路。仅一条线路时,整单 fee_lines 才算这条的明细。 + +def _top_fee_items_for_option( + quote: dict[str, Any] | None, + option: dict[str, Any] | None, +) -> list[dict[str, Any]]: + """ + 舱位缺 feeItems 时补整单顶层(OMS 空运表价常把空运费放在 data.feeItems)。 + + 顶层带 airOptionNo 时优先本舱位;对不上编号时不能退成空列表(否则只剩增点/拆板)。 + 与主账 InquiryTmsQueryService.extractAirDepartureCharges 口径一致。 + """ + items = _fee_item_dicts((quote or {}).get("feeItems") or (quote or {}).get("fee_items")) + if not items: + return [] + opt_no = str( + (option or {}).get("optionNo") or (option or {}).get("option_no") or "" + ).strip() + tagged = [ + row + for row in items + if str(row.get("airOptionNo") or row.get("air_option_no") or "").strip() + ] + if opt_no and tagged: + matched = [ + row + for row in items + if str(row.get("airOptionNo") or row.get("air_option_no") or "").strip() == opt_no + ] + if matched: + return matched + return items + + +def _has_air_freight_name(rows: list[dict[str, Any]]) -> bool: + """明细里是否已有空运段运费名。""" + for row in rows: + name = str(row.get("name") or "").strip() + if name == "空运费" or name == "航空运费" or (name.endswith("运费") and "空运" in name): + return True + return False + + +def _only_origin_surcharges(rows: list[dict[str, Any]]) -> bool: + """ + 是否只剩起运附加费(增点/拆板等),没有运输费类分项。 + + 有运输费/提送货费时不注入总价空运费,避免和 ZUH 分项叠两套。 + """ + if not rows: + return False + for row in rows: + name = str(row.get("name") or "").strip() + if not name: + continue + if name in {"运输费", "提送货费", "人工费", "空运费", "航空运费"}: + return False + if "运费" in name and "增点" not in name and "拆板" not in name: + return False + return True + + +def _air_freight_from_quote_fee_lines( + quote: dict[str, Any] | None, +) -> list[dict[str, Any]]: + """主账 fee_lines 里已有的空运费行(带单价/单位更好)。""" + rows = _fee_item_dicts( + (quote or {}).get("fee_lines") or (quote or {}).get("fee_rows") + ) + return [row for row in rows if str(row.get("name") or "").strip() in {"空运费", "航空运费"}] + + +def _inject_air_freight_from_option( + option: dict[str, Any] | None, + quote: dict[str, Any] | None, +) -> dict[str, Any] | None: + """ + 舱位/顶层都没有空运费时,用 salePrice 或 unitPrice×计费重补一行。 + + 对齐主账 injectAirFreightFeeLineIfMissing。无可用金额则不补。 """ src = dict(option or {}) - rows = _rows_from_list(src.get("departureCharges") or src.get("departure_charges")) - rows.extend(_rows_from_list(src.get("feeItems") or src.get("fee_items"))) + q = dict(quote or {}) + unit_price = src.get("unitPrice") + if unit_price in (None, "", "-"): + unit_price = src.get("unit_price") or src.get("pricePerKg") or src.get("freightRate") + sale = src.get("salePrice") + if sale in (None, "", "-"): + sale = src.get("sale_price") or q.get("total") or q.get("salePrice") + cw = src.get("chargeableWeightKg") + if cw in (None, "", "-"): + cw = src.get("chargeable_weight_kg") or q.get("chargeableWeightKg") + amount = "" + price = str(unit_price or "").strip() + cw_text = str(cw or "").strip() + if price and cw_text: + try: + amount = str( + (float(str(price).replace(",", "")) * float(cw_text.replace(",", ""))) + ) + # 去掉多余尾零:3334.0 + amount = f"{float(amount):.10g}" + except ValueError: + amount = "" + if not amount: + amount = str(sale or "").strip() + if not amount or amount == "-": + return None + if (not price or price == "-") and cw_text: + try: + price = f"{(float(str(amount).replace(',', '')) / float(cw_text.replace(',', ''))):.10g}" + except ValueError: + price = amount + ccy = str(src.get("currency") or q.get("currency") or "").strip() + row: dict[str, Any] = { + "name": "空运费", + "amount": amount, + "project": "空运段", + } + if ccy: + row["currency"] = ccy + if price and price != "-": + row["unit_price"] = price + row["unitPrice"] = price + if cw_text: + row["quantity"] = cw_text + row["unit"] = "KG" + return row + + +def option_fee_dicts( + option: dict[str, Any] | None, + quote: dict[str, Any] | None = None, +) -> list[dict[str, Any]]: + """ + 该线路费用明细(保留单价/单位/数量),供群摘要与投影写回 fee_lines。 + + 顺序与主账一致:空运段 feeItems → 起运港 departureCharges → 目的地。 + 舱位 feeItems 为空时补整单顶层,避免只剩增点费/拆板费丢掉空运费(WO202610150047)。 + """ + src = dict(option or {}) + q = dict(quote or {}) + cabin = _fee_item_dicts(src.get("feeItems") or src.get("fee_items")) + if not cabin: + cabin = _top_fee_items_for_option(q, src) + dep = _fee_item_dicts( + src.get("departureCharges") or src.get("departure_charges") or src.get("charges") + ) + dest = _fee_item_dicts( + src.get("destinationCharges") + or src.get("destination_charges") + or src.get("arrivalCharges") + ) + rows: list[dict[str, Any]] = [] + seen: set[str] = set() + for bucket in (cabin, dep, dest): + for row in bucket: + key = str(row.get("name") or "").strip().lower() + if not key or key in seen: + continue + seen.add(key) + rows.append(row) + # 仍无空运费:先取主账 fee_lines,再按舱位总价注入(OMS 仅增点/拆板时) + if rows and not _has_air_freight_name(rows): + for row in _air_freight_from_quote_fee_lines(q): + key = str(row.get("name") or "").strip().lower() + if key and key not in seen: + seen.add(key) + rows.insert(0, row) + if not _has_air_freight_name(rows) and _only_origin_surcharges(rows): + extra = _inject_air_freight_from_option(src, q) + if extra: + rows.insert(0, extra) if rows: return rows - q = dict(quote or {}) options = parse_air_options(q) if len(options) > 1: return [] - return _rows_from_list(q.get("fee_lines") or q.get("fee_rows") or q.get("feeItems")) + return _fee_item_dicts(q.get("fee_lines") or q.get("fee_rows") or q.get("feeItems")) + + +def option_fee_lines(option: dict[str, Any] | None, quote: dict[str, Any] | None = None) -> list[tuple[str, str]]: + """ + 该线路 TMS 回了哪些费用就列哪些(名称 + 展示金额)。 + + 明细字典见 option_fee_dicts:舱位缺空运段时会补整单顶层 feeItems。 + """ + out: list[tuple[str, str]] = [] + for row in option_fee_dicts(option, quote): + pair = _fee_row(row) + if pair: + out.append(pair) + return out def option_total(option: dict[str, Any] | None, quote: dict[str, Any] | None = None) -> str: @@ -393,10 +591,13 @@ def apply_selected_option(facts: dict[str, str] | None, option: dict[str, Any] | 工单还没有报价日期时,把这条的航班日期写回去,供报价单和锁舱使用。 销售已经说了日期则不覆盖。 """ + from agent.schema.field_validate import is_blank_quote_date + merged = dict(facts or {}) src = dict(option or {}) flight_date = option_flight_date(src) - if flight_date and not str(merged.get("报价日期") or "").strip(): + # 占位文案「(非必填):」当没填,必须让航班日期盖上去。 + if flight_date and is_blank_quote_date(merged.get("报价日期")): merged["报价日期"] = flight_date code = str(src.get("originAirportCode") or src.get("origin") or "").strip().upper() name = _place_name(code) @@ -417,7 +618,33 @@ def project_selected_quote(quote: dict[str, Any] | None, option: dict[str, Any] """把选中线路投影成价格卡用的 quote。""" base = dict(quote or {}) src = dict(option or {}) - lines = [{"name": n, "amount": a} for n, a in option_fee_lines(src, base)] + # 保留单价/单位/数量,避免后面把「空运费=总价」误判成重复兜底删掉。 + lines: list[dict[str, Any]] = [] + for row in option_fee_dicts(src, base): + pair = _fee_row(row) + if not pair: + continue + name, amount = pair + line = { + "name": name, + "amount": amount, + "currency": str(row.get("currency") or row.get("currencyCode") or "").strip(), + } + unit = str(row.get("unit") or row.get("unitName") or row.get("chargeUnit") or "").strip() + if unit: + line["unit"] = unit + unit_price = row.get("unitPrice") + if unit_price in (None, ""): + unit_price = row.get("unit_price") + if unit_price not in (None, ""): + line["unit_price"] = unit_price + line["unitPrice"] = unit_price + qty = row.get("quantity") + if qty in (None, ""): + qty = row.get("chargeQuantity") or row.get("charge_quantity") + if qty not in (None, ""): + line["quantity"] = qty + lines.append(line) # 必须覆盖整单 fee_lines:空列表也要写回去,否则会残留别的线路空运费。 base["fee_lines"] = lines if not lines: @@ -515,6 +742,24 @@ def _same_amount(left: Any, right: Any) -> bool: return a == b +def _rated_air_freight_line(item: dict[str, Any] | None) -> bool: + """ + 是否为带计价单位的真实空运费行(单价×计费重),而非「空运费=总价」快照兜底。 + + 有 KG 单位、或有单价/数量,都按真实费率行保留。 + """ + src = dict(item or {}) + unit = str(src.get("unit") or src.get("unitName") or src.get("chargeUnit") or "").strip().upper() + unit = unit.replace("/", "").replace("每", "").replace(" ", "") + if unit in {"KG", "KGS", "公斤", "千克"}: + return True + if src.get("unitPrice") not in (None, "", "-") or src.get("unit_price") not in (None, "", "-"): + return True + if src.get("quantity") not in (None, "", "-") or src.get("chargeQuantity") not in (None, "", "-"): + return True + return False + + def drop_air_freight_total_alias(quote: dict[str, Any] | None) -> dict[str, Any]: """ 已经有其它费用分项时,去掉「空运费=总价」这一行兜底。 @@ -542,7 +787,8 @@ def drop_air_freight_total_alias(quote: dict[str, Any] | None) -> dict[str, Any] continue name = str(item.get("name") or item.get("label") or item.get("feeName") or "").strip() amount = item.get("amount") or item.get("value") - if name == "空运费" and _same_amount(amount, total): + # OMS 空运费金额常等于顶层 salePrice,但带 KG 单价/数量,不是「总价兜底」假行。 + if name == "空运费" and _same_amount(amount, total) and not _rated_air_freight_line(item): alias.append(item) continue others.append(item) diff --git a/inquiry-agent/agent/schema/field_validate.py b/inquiry-agent/agent/schema/field_validate.py index fb82a2d..45b0439 100644 --- a/inquiry-agent/agent/schema/field_validate.py +++ b/inquiry-agent/agent/schema/field_validate.py @@ -279,6 +279,15 @@ def normalize_facts(facts: dict[str, Any] | None) -> dict[str, str]: val = str(raw_val).strip() if not val or val in _PLACEHOLDER_DASHES: continue + # 补问提示「请复制粘贴…」等不是日期,入库前丢掉,避免当成已填走出「线路」。 + if key == "报价日期" and is_blank_quote_date(val): + continue + # 「请确认并补充以下信息」等标题不是品名(空栏后没换行会粘上)。 + if key in {"品名", "货物品名"}: + from agent.llm.extract_text import is_clarify_ui_prompt + + if is_clarify_ui_prompt(val): + continue out[key] = val _fold_land_vehicle_qty(out) return out @@ -319,6 +328,29 @@ def _harvest_packaging(raw: str) -> str: return last +def is_blank_quote_date(raw: str | None) -> bool: + """ + 报价日期是否等于「没填」。 + + 核对卡空行会写成「报价日期(非必填):」。若把「(非必填):」收成字段值, + 会挡住选舱写回航班日期(WO202610160033 运行证据)。横杠、装饰词都当空。 + 补问提示「请复制粘贴…」曾被跨行收成日期(核对卡挤行),一律当空。 + """ + text = str(raw or "").strip() + if not text: + return True + if text in {"-", "—", "-", "/", "无", "没有", "暂无"}: + return True + # 补问 UI 文案、下一栏编号,不是日期。 + if "请复制粘贴" in text or "告知我以下字段" in text: + return True + if re.match(r"^\d+[\.、.]", text): + return True + cleaned = re.sub(r"[((]\s*非必填\s*[))]", "", text) + cleaned = cleaned.strip(" \t::,,。;;、") + return not cleaned + + def _quote_date_labeled_value(raw: str) -> str: """ 原话里「报价日期」或同义词后面的值。 @@ -327,20 +359,25 @@ def _quote_date_labeled_value(raw: str) -> str: 能写成 yyyy-MM-dd 就规范;「明天」这类留原文。 没有这些标签则空串,交给句子里的裸日期。 同一句出现多次,用最后一次,避免前面的货好时间把日期抢走。 + 标签后可带「(非必填)」;值为空或只剩装饰词时不当作已填。 + 只认冒号后同一行:空行后面的「请复制粘贴…」不能跨行收成日期。 """ text = raw or "" labels = ("报价日期",) + QUOTE_DATE_SYNONYMS best_at = -1 best_val = "" for label in labels: + # 允许「报价日期(非必填):」;禁止把「(非必填):」收成日期值。 + # [^\S\n] 只吃同行空白,避免 \s 跨行吞粘贴提示。 pattern = re.compile( - rf"(?= best_at: best_at = hit.start() best_val = hit.group(1).strip().strip(",,。;;、") - if not best_val: + if is_blank_quote_date(best_val): return "" return _harvest_quote_date(best_val) or _excel_serial_date(best_val) or best_val @@ -603,6 +640,16 @@ def harvest_spoken_trade_term(text: str) -> dict[str, str]: _CONTAINER_SPEC = re.compile( r"(?i)(20|40|45)\s*['\"′尺]?\s*(HQ|GP|NOR|RH|HC|高柜|平柜|柜)" ) +# 陆运车型口语:45HQ(13.5厢车)、9米6/1、13.5厢车、3T。不收 9.11 日期、20件。 +_RE_LAND_VEHICLE = re.compile( + r"(?i)(?:" + r"(?:20|40|45)\s*(?:HQ|GP|HC|NOR|RH|高柜|平柜)" + r"(?:\s*[((][^))]{1,24}[))])?" + r"|(?:\d+(?:\.\d+)?)\s*米\s*[0-9六]?(?:\s*[//xX×*]\s*\d+)?" + r"|(?:\d+(?:\.\d+)?)\s*厢车" + r"|(? bool: @@ -610,6 +657,42 @@ def looks_like_container_spec(text: str) -> bool: return bool(_CONTAINER_SPEC.search(text or "")) +def promote_land_vehicle_from_container(facts: dict[str, str] | None) -> dict[str, str]: + """ + 陆运时把误落入「箱型箱量」的车型改写到「车型/数量」。 + + DeepSeek B 常把 45HQ/厢车当成海运箱型;陆运必填是车型/数量。 + 已有车型则不覆盖。返回新 dict,不改入参。 + """ + merged = dict(facts or {}) + vehicle = (merged.get("车型/数量") or merged.get("车型数量") or "").strip() + container = (merged.get("箱型箱量") or "").strip() + if not vehicle and container: + merged["车型/数量"] = container + return merged + + +def harvest_oral_land_vehicle(text: str, facts: dict[str, str] | None = None) -> dict[str, str]: + """ + 从陆运原话机读车型/数量。 + + 已有「车型/数量」不覆盖。命中 45HQ(13.5厢车)、9米6、厢车、3T 等。 + 只填空字段,不猜港口。返回新 dict。 + """ + merged = dict(facts or {}) + if (merged.get("车型/数量") or "").strip(): + return merged + raw = _without_clarify_examples(text or "") + hit = _RE_LAND_VEHICLE.search(raw) + if not hit: + return merged + token = hit.group(0).strip() + if not token: + return merged + merged["车型/数量"] = token + return merged + + def project_sea_cargo_qty(facts: dict[str, str] | None) -> dict[str, str]: """ 海运货物数量投影。 @@ -655,6 +738,8 @@ def shape_facts_for_chosen_mode(facts: dict[str, str] | None, business_line: str merged.pop(key, None) return merged if line == "LAND": + # 先把误入箱型箱量的车型改写过来,再删海运外键。 + merged = promote_land_vehicle_from_container(merged) for key in _LAND_DROP_FOREIGN: merged.pop(key, None) return merged diff --git a/inquiry-agent/agent/schema/land_options.py b/inquiry-agent/agent/schema/land_options.py index 7bb1f69..f8f1e89 100644 --- a/inquiry-agent/agent/schema/land_options.py +++ b/inquiry-agent/agent/schema/land_options.py @@ -97,6 +97,10 @@ _TYPE_ALIASES = ( ("国内拼车", "国内运输拼车"), ("国内整车", "国内运输整车"), ("跨境集拼", "跨境集拼"), + # 口语「中越整车」等:先于短词「跨境整车」,避免漏收成国内拼车。 + ("中越整车", "跨境整车"), + ("中缅整车", "跨境整车"), + ("中老整车", "跨境整车"), ("跨境整车", "跨境整车"), ) @@ -108,6 +112,10 @@ _ROUTE_ALIASES = ( ("国内长途/零担", "国内长途/零担"), ("国内长途", "国内长途/零担"), ("国内零担", "国内长途/零担"), + # 中越/中缅/中老线路口语 → 东南亚(越南谅山等)。 + ("中越", "东南亚"), + ("中缅", "东南亚"), + ("中老", "东南亚"), ("东南亚", "东南亚"), ) @@ -446,11 +454,16 @@ def land_cargo_name(facts: dict[str, Any] | None) -> str: 陆运货物品名。六个运输类型都要。 示例行「(如:衣服)」原样贴回不算已填。没有则空串。 + 补问标题「请确认并补充以下信息」不算品名。 """ + from agent.llm.extract_text import is_clarify_ui_prompt + src = facts or {} name = str(src.get("品名") or src.get("货物品名") or "").strip() if not name or _CARGO_PLACEHOLDER.match(name): return "" + if is_clarify_ui_prompt(name): + return "" return name diff --git a/inquiry-agent/agent/worker_app.py b/inquiry-agent/agent/worker_app.py index d6349b4..946f6b3 100644 --- a/inquiry-agent/agent/worker_app.py +++ b/inquiry-agent/agent/worker_app.py @@ -7,9 +7,12 @@ from __future__ import annotations +import json import logging import signal import threading +import time +from pathlib import Path from agent.config import get_settings from agent.graph import get_graph_runtime, shutdown_graph_runtime diff --git a/inquiry-agent/tests/test_activate_sheet_no_attach.py b/inquiry-agent/tests/test_activate_sheet_no_attach.py new file mode 100644 index 0000000..0f7d174 --- /dev/null +++ b/inquiry-agent/tests/test_activate_sheet_no_attach.py @@ -0,0 +1,121 @@ +""" +激活发群模版不得登记主账附件;正式有价出单必须登记。 + +验收: +- 激活路径 _render_and_send_sheet 默认 persist_attachment=False; +- 有价出单显式传 True; +- 确认落库 _persist_quote_sheet 传 True。 +""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any +from unittest.mock import MagicMock + +from agent.policy import sea_group_ops + + +class _Ledger: + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + + def render_quote(self, **kwargs: Any) -> dict[str, Any]: + self.calls.append(dict(kwargs)) + return { + "ok": True, + "fileName": "WO1空运到港报价单", + "fileBase64": "UEsDBAoAAAAAA", + "objectKey": "inquiry/work-order-files/WO1.xlsx", + "attachmentSaved": bool(kwargs.get("persist_attachment")), + "templateId": "t1", + "templateName": "空运到港报价单", + } + + +def test_activate_send_does_not_persist_attachment(monkeypatch) -> None: + ledger = _Ledger() + flow = SimpleNamespace(ledger=ledger) + client = MagicMock() + client.send_group_file.return_value = {"ok": True} + monkeypatch.setattr(sea_group_ops, "group_client_of", lambda _f: client) + monkeypatch.setattr( + sea_group_ops, + "overlay_labeled_facts", + lambda **_k: {"filepath": "", "file_b64": "UEsDBAoAAAAAA"}, + ) + + out = sea_group_ops._render_and_send_sheet( + flow=flow, + chat_id="chat-1", + work_order_no="WO202610170054", + facts={"weight_kg": "11"}, + quote={}, + sales_id="u1", + business_line="AIR", + ) + + assert out.get("ok") is True + assert len(ledger.calls) == 1 + assert ledger.calls[0].get("persist_attachment") is False + + +def test_priced_send_persists_attachment(monkeypatch) -> None: + """正式有价出单:群发 Excel 同时写后台附件(WO102 类缺附件回归)。""" + ledger = _Ledger() + flow = SimpleNamespace(ledger=ledger) + client = MagicMock() + client.send_group_file.return_value = {"ok": True} + monkeypatch.setattr(sea_group_ops, "group_client_of", lambda _f: client) + monkeypatch.setattr( + sea_group_ops, + "overlay_labeled_facts", + lambda **_k: {"filepath": "", "file_b64": "UEsDBAoAAAAAA"}, + ) + + out = sea_group_ops._render_and_send_sheet( + flow=flow, + chat_id="chat-1", + work_order_no="WO202610170102", + facts={"weight_kg": "11"}, + quote={"total": "9900", "currency": "CNY"}, + sales_id="u1", + business_line="AIR", + persist_attachment=True, + ) + + assert out.get("ok") is True + assert len(ledger.calls) == 1 + assert ledger.calls[0].get("persist_attachment") is True + + +def test_confirm_persist_quote_sheet_requests_attachment(monkeypatch) -> None: + ledger = _Ledger() + flow = SimpleNamespace(ledger=ledger) + monkeypatch.setattr(sea_group_ops, "_archive_file", lambda *_a, **_k: None) + + ticket = { + "work_order_no": "WO202610170054", + "business_line": "AIR", + "facts": {"weight_kg": "11"}, + "collab_facts": {}, + "sales_wecom_id": "u1", + "sales_name": "张三", + } + sess = SimpleNamespace( + work_order_no="WO202610170054", + facts={"weight_kg": "11"}, + collab_facts={}, + sender_id="u1", + ) + sea_group_ops._persist_quote_sheet( + flow=flow, + ticket=ticket, + sess=sess, + quote={"total": "100"}, + sender_id="u1", + business_line="AIR", + ) + + assert len(ledger.calls) == 1 + assert ledger.calls[0].get("persist_attachment") is True diff --git a/inquiry-agent/tests/test_air_added_services.py b/inquiry-agent/tests/test_air_added_services.py index 2f5df51..795beea 100644 --- a/inquiry-agent/tests/test_air_added_services.py +++ b/inquiry-agent/tests/test_air_added_services.py @@ -174,6 +174,43 @@ class AirAddedServicesTests(unittest.TestCase): self.assertTrue(assemble_air_query(bare)["ok"]) self.assertNotIn("added_services", assemble_air_query(bare)["facts"]) + def test_empty_trade_terms_does_not_swallow_added_services(self) -> None: + """ + 空运补问复制清单:贸易条款留空时,不能把「10.增值服务」收成贸易条款。 + + 测服复现:核对卡出现「贸易条款(非必填):10.增值服务」。 + """ + multi = ( + "1.起运地(必填):南京\n" + "2.目的地(必填):吉隆坡\n" + "3.品名(必填):普货\n" + "4.件数(必填):10\n" + "5.重量(KG)(必填):21\n" + "6.体积(CBM)(必填):1.1*1.2*1.3\n" + "7.包装类型(必填):散货\n" + "8.贸易条款(非必填):\n" + "9.增值服务(非必填):卡转低消、前置仓\n" + "10.报价日期(非必填):" + ) + same_line = ( + "1.起运地(必填):南京\n" + "2.目的地(必填):吉隆坡\n" + "3.品名(必填):普货\n" + "4.件数(必填):10\n" + "5.重量(KG)(必填):2\n" + "6.体积(CBM)(必填):1.2*1.3*1.5\n" + "7.包装类型(必填):散货\n" + "8.贸易条款(非必填):9.增值服务(非必填):卡转低消、前置仓\n" + "10.报价日期(非必填):" + ) + for text in (multi, same_line): + snap = extract_inquiry_snapshot(text, injected_mode="AIR") + facts = snap["facts"] + trade = str(facts.get("贸易条款") or "") + self.assertFalse(trade, msg=repr(trade)) + self.assertNotIn("增值服务", trade) + self.assertEqual(facts.get("added_services"), "卡转低消、前置仓") + if __name__ == "__main__": unittest.main() diff --git a/inquiry-agent/tests/test_air_group_collab.py b/inquiry-agent/tests/test_air_group_collab.py index 2b35b23..e69de29 100644 --- a/inquiry-agent/tests/test_air_group_collab.py +++ b/inquiry-agent/tests/test_air_group_collab.py @@ -1,2080 +0,0 @@ -""" -空运群内协同:激活当前工单、补字段、航线报价、锁舱/释放、成交。 -""" - -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.channel.wecom.models import InboundMessage -from agent.handlers.air_group_collab import handle_air_group -from agent.ledger.memory_ledger import MemoryLedger -from agent.policy.air_text_flow import AirTextInquiryFlow -from agent.policy import inquiry_copy as copy -from agent.routing.air_group_intent import ( - INTENT_LOCK, - INTENT_RELEASE, - INTENT_RELEASE_ASK, - classify_air_group_text, -) -from agent.routing.dispatch import dispatch_inbound -from agent.routing.decision import RouteDecision - - -class FakeBotGroup: - def __init__(self) -> None: - self.sent: list[dict] = [] - - def send_group(self, *, chat_id: str, content: str, mention_userids=None): - self.sent.append({"chat_id": chat_id, "content": content, "kind": "text"}) - - def send_group_file(self, *, chat_id: str, filename: str, filepath: str = "", file_b64: str = ""): - self.sent.append({"chat_id": chat_id, "filename": filename, "kind": "file"}) - return {"ok": True} - - -class AirSheetQuoteTotalTests(unittest.TestCase): - """群填表报价包必须带 TMS 总销售价,不能把附加费之和当预估费用。""" - - def test_sheet_quote_replaces_fee_sum_total_with_tms_sale_price(self) -> None: - from agent.policy.air_group_ops import _sheet_quote_with_tms_sale_total - - thin = { - "total": "200", - "currency": "CNY", - "fee_lines": [ - {"name": "增点费", "amount": "100", "currency": "CNY"}, - {"name": "拆板费", "amount": "100", "currency": "USD"}, - ], - } - tms = { - "source": "TMS", - "total": "7918.25", - "currency": "CNY", - "chargeableWeightKg": "833.5", - "airOptions": [ - { - "optionNo": "AIR-1", - "salePrice": 7918.25, - "currency": "CNY", - "departureCharges": [ - {"chargeName": "增点费", "unitPrice": 100, "currency": "CNY"}, - {"chargeName": "拆板费", "unitPrice": 100, "currency": "USD"}, - ], - } - ], - } - out = _sheet_quote_with_tms_sale_total(thin, tms=tms, facts={"起运港": "南京", "目的港": "吉隆坡"}) - self.assertIn("7918.25", str(out.get("total") or "")) - self.assertNotEqual(str(out.get("total") or "").strip(), "200") - self.assertTrue(out.get("airOptions") or out.get("selected_air_option")) - - def test_sheet_quote_keeps_product_total_when_not_fee_sum(self) -> None: - from agent.policy.air_group_ops import _sheet_quote_with_tms_sale_total - - product = { - "total": "9000", - "currency": "CNY", - "source_label": "航线报价", - "fee_lines": [ - {"name": "增点费", "amount": "100", "currency": "CNY"}, - {"name": "拆板费", "amount": "100", "currency": "USD"}, - ], - } - tms = { - "source": "TMS", - "total": "7918.25", - "currency": "CNY", - "airOptions": [{"optionNo": "AIR-1", "salePrice": 7918.25, "currency": "CNY"}], - } - out = _sheet_quote_with_tms_sale_total(product, tms=tms, facts={}) - self.assertEqual(str(out.get("total") or "").strip(), "9000") - - -class AirGroupCollabTests(unittest.TestCase): - def setUp(self) -> None: - from agent.redis_coord.air_inbound import reset_air_inbound_for_tests - - reset_air_inbound_for_tests() - self.ledger = MemoryLedger() - self.groups = FakeBotGroup() - self.flow = AirTextInquiryFlow(ledger=self.ledger, group_client=self.groups) - self.ledger.upsert_staff( - { - "name": "WuJiLin", - "wecomId": "sales1", - "roleCode": "sales", - "status": "active", - } - ) - self.ledger.upsert_staff( - { - "name": "HangXian", - "wecomId": "air1", - "roleCode": "air", - "status": "active", - } - ) - self.ledger.set_quote_templates( - [ - { - "templateId": "air-default", - "name": "空运报价", - "bizType": "空运", - "isDefault": True, - }, - { - "templateId": "sea-default", - "name": "海运报价", - "bizType": "海运", - "isDefault": True, - }, - ] - ) - - def _create_air(self, *, sender="sales1", quote=None, status="已报价", quote_date="2026-09-16") -> str: - created = self.ledger.create_ticket( - sender_id=sender, - business_line="AIR", - facts={ - "起运地": "上海", - "目的地": "洛杉矶", - "品名": "配件", - "件数": "3", - "毛重": "100", - "体积": "2", - "包装类型": "纸箱", - "报价日期": quote_date, - }, - ) - wo = created["work_order_no"] - if quote is None: - quote = { - "total": "USD 800", - "source": "TMS", - "source_label": "TMS 标准报价", - "quoteId": "QT-AIR-1", - "airOptions": [ - {"optionNo": "AIR-OPT-01", "airlineCode": "CZ", "airlineName": "南航", "flightNo": "CZ327"} - ], - } - if quote: - self.ledger.upsert_quote(work_order_no=wo, quote=quote, to_status=status) - return wo - - def _msg(self, *, sender: str, text: str, chat: str, mid: str) -> InboundMessage: - return InboundMessage( - sender_id=sender, - message_id=mid, - content=text, - chat_id=chat, - chat_type="group", - raw={"source": "aibot"}, - ) - - def _texts(self) -> list[str]: - return [x.get("content") or "" for x in self.groups.sent] - - def test_attach_bot_client_keeps_req_id_for_quote(self) -> None: - """这次 @ 的 req_id 要带给桥,群里才能引用原消息。""" - from agent.channel.aibot.reply import AibotReplyClient - from agent.handlers.air_group_collab import _attach_bot_client - - engine = AirTextInquiryFlow(ledger=self.ledger, group_client=None) - msg = InboundMessage( - sender_id="air1", - message_id="m_quote", - content="锁舱", - chat_id="wr_air_1", - chat_type="group", - raw={"source": "aibot", "aibot_req_id": "cb_lock_1"}, - ) - _attach_bot_client(engine, msg) - client = engine.group_client() - self.assertIsInstance(client, AibotReplyClient) - self.assertEqual(client._callback_req_id, "cb_lock_1") - - def test_intent_lock_is_not_hardcoded(self) -> None: - self.assertEqual(classify_air_group_text("帮我锁舱 WO202609160001", allow_network=False), INTENT_LOCK) - self.assertEqual(classify_air_group_text("WO202609160001取消舱位", allow_network=False), INTENT_RELEASE) - self.assertEqual(classify_air_group_text("放舱", allow_network=False), INTENT_RELEASE) - self.assertEqual(classify_air_group_text("解锁舱", allow_network=False), INTENT_RELEASE) - self.assertEqual(classify_air_group_text("取消锁舱", allow_network=False), INTENT_RELEASE) - self.assertEqual(classify_air_group_text("WO202609160001释放舱位", allow_network=False), INTENT_RELEASE_ASK) - self.assertEqual(classify_air_group_text("把舱位释放了", allow_network=False), INTENT_RELEASE_ASK) - - def test_airline_fee_wording_is_quote_not_adjust(self) -> None: - """航线写「空运费改为900」是手工报价,不能先判成调价。比例和改总价仍是调价。""" - from agent.routing.air_group_intent import INTENT_ADJUST, INTENT_QUOTE - - self.assertEqual( - classify_air_group_text("空运费改为900USD", allow_network=False), - INTENT_QUOTE, - ) - self.assertEqual( - classify_air_group_text("报关费改为300CNY", allow_network=False), - INTENT_QUOTE, - ) - self.assertEqual( - classify_air_group_text("空运费 9900CNY", allow_network=False), - INTENT_QUOTE, - ) - self.assertEqual( - classify_air_group_text("空运费加10%", allow_network=False), - INTENT_ADJUST, - ) - self.assertEqual( - classify_air_group_text("总销售价改为180", allow_network=False), - INTENT_ADJUST, - ) - - def test_airline_rewritten_fee_does_not_say_only_sales(self) -> None: - """已有报价后,航线再写费用金额,不能提示只有销售能调价。""" - wo = self._create_air() - handle_air_group( - self._msg(sender="air1", text=wo, chat="wr_air_1", mid="fee0"), - flow=self.flow, - ledger=self.ledger, - ) - self.groups.sent.clear() - phase = handle_air_group( - self._msg(sender="air1", text="空运费改为900USD", chat="wr_air_1", mid="fee1"), - flow=self.flow, - ledger=self.ledger, - ) - self.assertNotEqual(phase, "adjust_not_sales") - self.assertNotIn("只有这张单的销售能调价", "\n".join(self._texts())) - self.assertIn(phase, {"wait_tms_choice", "quoted"}) - - def test_sales_percent_still_adjusts(self) -> None: - """销售说加百分之几,仍是调价,不会被改成航线报价。""" - wo = self._create_air() - handle_air_group( - self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="adj0"), - flow=self.flow, - ledger=self.ledger, - ) - self.groups.sent.clear() - phase = handle_air_group( - self._msg(sender="sales1", text="空运费加10%", chat="wr_air_1", mid="adj1"), - flow=self.flow, - ledger=self.ledger, - ) - self.assertNotEqual(phase, "not_airline") - self.assertNotIn("只有这张单的销售能调价", "\n".join(self._texts())) - self.assertIn(phase, {"group_adjusted", "adjust_need_fee", "adjust_none", "adjust_need_lines"}) - - def test_air_group_brief_matches_sea_sections(self) -> None: - text = copy.air_group_brief( - work_order_no="WO202609160009", - facts={ - "起运地": "ZUH", - "目的地": "广州", - "品名": "服务器", - "件数": "1", - "毛重": "10", - "体积": "1", - "包装类型": "纸箱", - "报价日期": "2026-09-16", - }, - quote=None, - collab_facts={}, - has_price=False, - ) - self.assertIn("工单号:WO202609160009", text) - self.assertIn("询价字段:\n运输方式:空运", text) - self.assertIn("起运地:ZUH", text) - self.assertIn("\n\n协同补充字段:\n是否含电:-", text) - self.assertIn("是否含磁:-", text) - general = copy.air_group_brief( - work_order_no="WO202609230017", - facts={ - "起运地": "上海", - "目的地": "洛杉矶", - "品名": "普货", - }, - quote=None, - collab_facts={}, - has_price=False, - ) - self.assertIn("是否含电:否", general) - self.assertIn("是否含磁:否", general) - self.assertNotIn("是否含电:-", general) - self.assertIn("\n\nTMS报价:\nTMS 暂无匹配报价", text) - self.assertIn("航线人员可通过“纯文字”告知新报价,或直接将新报价excel表发给智能体。", text) - self.assertIn("附件报价单仅作为文件存储于后台,智能体不读取且不将报价内容写进系统", text) - self.assertNotIn("产品人员可通过", text) - self.assertNotIn("当前工单:", text) - self.assertNotIn("贸易条款", text) - - def test_air_group_brief_tms_total_only_once(self) -> None: - """群摘要 TMS 段总销售价只一行,禁止重复。""" - tms = { - "source": "TMS", - "source_label": "TMS标准报价", - "total": "183370.0", - "currency": "CNY", - "eta": "-", - "valid_until": "2026-09-30", - "fee_lines": [ - {"name": "运输费", "amount": "11.0", "currency": "CNY"}, - ], - } - text = copy.air_group_brief( - work_order_no="WO202609220018", - facts={"起运港": "珠海", "目的港": "克拉克"}, - quote=tms, - collab_facts={}, - has_price=True, - tms_quote=tms, - ) - tms_block = text.split("TMS报价:", 1)[1] - self.assertEqual(tms_block.count("费用合计:"), 1) - self.assertIn("费用合计:CNY 183370.0", tms_block) - self.assertNotIn("时效", tms_block) - self.assertIn("报价有效期:2026-09-30", tms_block) - self.assertIn("请航线人员回复“确认报价”确定采用TMS报价。", text) - self.assertIn("亦可通过“纯文字”告知新报价,或直接将新报价excel表发给智能体。", text) - self.assertNotIn("询价字段:", text) - self.assertIn("备注:-", text) - - def test_air_group_brief_with_tms_uses_cargo_and_price_shape(self) -> None: - """有 TMS 报价时,群摘要换成货物信息和三种费用写法。普货含电含磁仍是「否」。""" - tms = { - "source": "TMS", - "total": "87150.0", - "currency": "CNY", - "valid_until": "2026-10-31", - "remark": "其他杂费如有实报实销", - "airlineCode": "TH", - "chargeableWeightKg": "8300", - "cargoType": "1:212", - "fee_lines": [ - {"name": "空运费", "currency": "CNY", "unit": "KG", "unitPrice": 11, "amount": "91300"}, - {"name": "文件费", "currency": "CNY", "unit": "票", "amount": "300"}, - ], - } - text = copy.air_group_brief( - work_order_no="WO202610090040", - facts={ - "起运地": "NKG", - "目的地": "KUL", - "品名": "普货", - "件数": "800箱", - "毛重": "8300KG", - "体积": "39CBM", - "包装类型": "散货", - "报价日期": "2026-10-09", - }, - quote=tms, - collab_facts={}, - has_price=True, - tms_quote=tms, - sales_name="吴季林", - airline_names=["高芸"], - ) - self.assertTrue(text.startswith("@吴季林 @高芸")) - self.assertNotIn("询价字段:", text) - self.assertIn("航司:TH", text) - self.assertIn("计费重:8300KG", text) - self.assertIn("货型:1:212", text) - self.assertIn("是否含电:否", text) - self.assertIn("是否含磁:否", text) - tms_block = text.split("TMS报价:", 1)[1] - self.assertIn("空运费:单价CNY11/KG", tms_block) - self.assertIn("文件费:CNY300/票", tms_block) - self.assertIn("备注:其他杂费如有实报实销", tms_block) - self.assertIn("费用合计:CNY 87150.0", tms_block) - self.assertIn("报价有效期:2026-10-31", tms_block) - self.assertIn("请航线人员回复“确认报价”确定采用TMS报价。", text) - self.assertLess(tms_block.find("报价有效期:"), tms_block.find("请航线人员")) - self.assertIn("附件报价单仅作为文件存储于后台,智能体不读取且不将报价内容写进系统", text) - self.assertNotIn("请产品人员回复", text) - - def test_air_group_brief_uses_route_fees_not_air_freight_total(self) -> None: - """ - 群摘要必须跟单聊同一套线路分项。 - 主账快照若只有「空运费=总价」,仍要列出该线路运输费/提送货费/人工费。 - """ - tms = { - "source": "TMS", - "source_label": "TMS标准报价", - "total": "183370.0", - "freight": "183370.0", - "currency": "CNY", - "eta": "-", - "valid_until": "2026-09-30", - "fee_lines": [ - { - "name": "空运费", - "amount": "183370.00", - "currency": "CNY", - } - ], - "airOptions": [ - { - "originAirportCode": "ZUH", - "transferAirportCode": "CHG", - "destinationAirportCode": "CRK", - "salePrice": 183370, - "currency": "CNY", - "departureCharges": [ - {"chargeName": "运输费", "currency": "CNY", "unitPrice": 11.0}, - {"chargeName": "提送货费", "currency": "CNY", "unitPrice": 6.0}, - {"chargeName": "人工费", "currency": "CNY", "unitPrice": 123.0}, - ], - } - ], - } - text = copy.air_group_brief( - work_order_no="WO202609220018", - facts={"起运港": "珠海", "目的港": "克拉克", "originCode": "ZUH"}, - quote=tms, - collab_facts={}, - has_price=True, - tms_quote=tms, - ) - tms_block = text.split("TMS报价:", 1)[1] - self.assertIn("运输费:CNY 11.0", tms_block) - self.assertIn("提送货费:CNY 6.0", tms_block) - self.assertIn("人工费:CNY 123.0", tms_block) - self.assertIn("费用合计:CNY 183370", tms_block) - self.assertEqual(tms_block.count("费用合计:"), 1) - self.assertNotIn("空运费", tms_block) - - def test_air_group_brief_multi_route_does_not_borrow_other_air_freight(self) -> None: - """多条线路:只出对上起运港那条的分项,禁止套整单空运费或另一条的运输费。""" - tms = { - "source": "TMS", - "total": "200040", - "freight": "183370.00", - "currency": "CNY", - "valid_until": "2026-09-30", - "fee_lines": [ - {"name": "空运费", "amount": "183370.00", "currency": "CNY"}, - ], - "airOptions": [ - { - "originAirportCode": "ZUH", - "transferAirportCode": "CHG", - "destinationAirportCode": "CRK", - "salePrice": 183370, - "currency": "CNY", - "departureCharges": [ - {"chargeName": "运输费", "currency": "CNY", "unitPrice": 11.0}, - ], - }, - { - "originAirportCode": "SZX", - "destinationAirportCode": "CRK", - "salePrice": 200040, - "currency": "CNY", - "departureCharges": [], - }, - ], - } - text = copy.air_group_brief( - work_order_no="WO202609220018", - facts={"起运港": "深圳", "目的港": "克拉克", "originCode": "SZX"}, - quote=tms, - collab_facts={}, - has_price=True, - tms_quote=tms, - ) - tms_block = text.split("TMS报价:", 1)[1] - self.assertNotIn("空运费", tms_block) - self.assertNotIn("运输费", tms_block) - self.assertIn("费用合计:CNY 200040", tms_block) - self.assertEqual(tms_block.count("费用合计:"), 1) - - def test_air_group_brief_keeps_sole_air_freight_line(self) -> None: - """TMS 只有空运费、金额等于总价时,群摘要必须写出这一行,不能只剩费用合计。""" - tms = { - "source": "TMS", - "source_label": "TMS标准报价", - "total": "1833.7", - "freight": "1833.70", - "currency": "CNY", - "valid_until": "2026-10-21", - "fee_lines": '[{"name":"空运费","amount":"CNY 1833.70"}]', - "feeItems": [ - { - "feeName": "空运费", - "feeCode": "AIR_FREIGHT", - "amount": 1833.70, - "unitPrice": 11.00, - "quantity": 166.7, - "currency": "CNY", - } - ], - "airOptions": [ - { - "originAirportCode": "CGO", - "destinationAirportCode": "KUL", - "salePrice": 1833.70, - "currency": "CNY", - "departureCharges": [], - } - ], - } - text = copy.air_group_brief( - work_order_no="WO202610080020", - facts={"起运地": "CGO", "目的地": "吉隆坡", "originCode": "CGO"}, - quote=tms, - collab_facts={}, - has_price=True, - tms_quote=tms, - ) - tms_block = text.split("TMS报价:", 1)[1] - self.assertIn("空运费:CNY 1833.70", tms_block) - self.assertLess(tms_block.find("空运费:"), tms_block.find("费用合计:")) - self.assertIn("费用合计:CNY 1833.7", tms_block) - self.assertEqual(tms_block.count("费用合计:"), 1) - self.assertEqual(tms_block.count("费用合计:"), 1) - - def test_work_order_plus_lost_reason_is_deal_not_activate(self) -> None: - """WO@助手 未成交,原因… 必须当关单,不能只激活。""" - from agent.routing.air_group_intent import INTENT_ACTIVATE, INTENT_LOST - - text = "WO202609220014@询价小助手 未成交,原因客户不想送货了" - self.assertEqual(classify_air_group_text(text, allow_network=False), INTENT_LOST) - from unittest.mock import patch - - with patch( - "agent.routing.air_group_intent._classify_with_model", - return_value=INTENT_ACTIVATE, - ): - self.assertEqual( - classify_air_group_text(text, allow_network=True), - INTENT_LOST, - ) - - def test_activate_lost_and_reason_same_message(self) -> None: - wo = self._create_air() - phase = handle_air_group( - self._msg( - sender="sales1", - text=f"{wo}@询价小助手 未成交,原因客户不想送货了", - chat="wr_air_1", - mid="lost-inline-wo", - ), - flow=self.flow, - ledger=self.ledger, - ) - self.assertEqual(phase, "done") - ticket = self.ledger.get_ticket(work_order_no=wo) - self.assertEqual(ticket.status, "未成交") - self.assertEqual(ticket.lost_reason, "客户不想送货了") - joined = "\n".join(self._texts()) - self.assertIn("客户不想送货了", joined) - self.assertNotIn("请补充未成交原因", joined) - - def test_work_order_only_is_activate_not_quote(self) -> None: - from agent.routing.air_group_intent import INTENT_ACTIVATE - - self.assertEqual( - classify_air_group_text("WO202609160002", allow_network=False), - INTENT_ACTIVATE, - ) - self.assertEqual( - classify_air_group_text("@询价小助手 WO202609160002", allow_network=False), - INTENT_ACTIVATE, - ) - # 话术前缀「工单编号」不能当成报价(WO026 复现:误走 product_quote)。 - self.assertEqual( - classify_air_group_text( - "工单编号WO202610070026@询价小助手", - allow_network=False, - ), - INTENT_ACTIVATE, - ) - self.assertEqual( - classify_air_group_text( - "工单编号 WO202610060026@询价小助手", - allow_network=False, - ), - INTENT_ACTIVATE, - ) - from unittest.mock import patch - - with patch( - "agent.routing.air_group_intent._classify_with_model", - side_effect=AssertionError("纯工单号不应再打模型"), - ): - self.assertEqual( - classify_air_group_text( - "@询价小助手 WO202609160002", - allow_network=True, - ), - INTENT_ACTIVATE, - ) - self.assertEqual( - classify_air_group_text( - "工单编号WO202610070026@询价小助手", - allow_network=True, - ), - INTENT_ACTIVATE, - ) - - def test_dispatch_aibot_goes_air(self) -> None: - from agent.routing.dispatch import dispatch_inbound as _disp - - wo = self._create_air() - msg = self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="d1") - # 单测注入 flow,避免打到进程单例 - import agent.handlers.air_group_collab as air_mod - orig = air_mod.get_air_text_flow - - def _fake(): - return self.flow - - air_mod.get_air_text_flow = _fake # type: ignore - try: - name = _disp(msg, RouteDecision(intent="ordinary_text_other")) - finally: - air_mod.get_air_text_flow = orig # type: ignore - self.assertEqual(name, "air_group_collab") - - def test_activate_and_switch(self) -> None: - wo1 = self._create_air() - wo2 = self._create_air() - phase = handle_air_group( - self._msg(sender="sales1", text=wo1, chat="wr_air_1", mid="a1"), - flow=self.flow, - ledger=self.ledger, - ) - self.assertEqual(phase, "activated") - ticket = self.ledger.get_ticket(work_order_no=wo1) - self.assertEqual(ticket.event_wait_kind, "air_quote") - self.assertTrue(ticket.event_wait_started_at) - joined = "\n".join(self._texts()) - self.assertIn(f"工单号:{wo1}", joined) - self.assertIn("询价字段:", joined) - self.assertIn("运输方式:空运", joined) - self.assertIn("协同补充字段:", joined) - self.assertIn("TMS报价:", joined) - self.assertNotIn("当前工单:", joined) - self.assertNotIn("询价要点:", joined) - self.assertTrue(any("@WuJiLin" in t and "@HangXian" in t for t in self._texts())) - self.assertEqual(getattr(self.ledger, "last_render", {}).get("templateId"), "air-default") - kinds = [x.get("kind") for x in self.groups.sent] - self.assertLess(kinds.index("text"), kinds.index("file")) - self.assertEqual(sum(1 for x in self.groups.sent if x.get("kind") == "file"), 1) - phase = handle_air_group( - self._msg(sender="air1", text=wo2, chat="wr_air_1", mid="a2"), - flow=self.flow, - ledger=self.ledger, - ) - self.assertEqual(phase, "activated") - self.assertEqual(self.ledger.find_by_collab_chat(chat_id="wr_air_1").work_order_no, wo2) - - def test_activate_prefills_magnet(self) -> None: - created = self.ledger.create_ticket( - sender_id="sales1", - business_line="AIR", - facts={ - "起运地": "上海", - "目的地": "洛杉矶", - "品名": "配件", - "件数": "3", - "毛重": "100", - "体积": "2", - "包装类型": "纸箱", - "报价日期": "2026-09-16", - "是否含磁": "是", - }, - ) - wo = created["work_order_no"] - self.ledger.upsert_quote( - work_order_no=wo, - quote={"total": "USD 800", "source": "TMS", "quoteId": "QT-AIR-1"}, - to_status="已报价", - ) - handle_air_group( - self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="pre1"), - flow=self.flow, - ledger=self.ledger, - ) - joined = "\n".join(self._texts()) - self.assertIn("是否含磁:是", joined) - self.assertIn("是否含电:-", joined) - - def test_activate_general_cargo_defaults_battery_and_magnet(self) -> None: - """品名是普货:群内激活后含电、含磁都是否,报价单也带上。""" - created = self.ledger.create_ticket( - sender_id="sales1", - business_line="AIR", - facts={ - "起运地": "上海", - "目的地": "洛杉矶", - "品名": "普货", - "件数": "3", - "毛重": "100", - "体积": "2", - "包装类型": "纸箱", - "报价日期": "2026-09-16", - }, - ) - wo = created["work_order_no"] - self.ledger.upsert_quote( - work_order_no=wo, - quote={"total": "USD 800", "source": "TMS", "quoteId": "QT-AIR-1"}, - to_status="已报价", - ) - handle_air_group( - self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="gc1"), - flow=self.flow, - ledger=self.ledger, - ) - ticket = self.ledger.get_ticket(work_order_no=wo) - self.assertEqual(ticket.collab_facts.get("是否含电"), "否") - self.assertEqual(ticket.collab_facts.get("是否含磁"), "否") - joined = "\n".join(self._texts()) - self.assertIn("是否含电:否", joined) - self.assertIn("是否含磁:否", joined) - fill = dict(getattr(self.ledger, "last_render", {}).get("facts") or {}) - self.assertEqual(fill.get("是否含电") or fill.get("has_battery"), "否") - self.assertEqual(fill.get("是否含磁") or fill.get("has_magnet"), "否") - - def test_activate_general_cargo_keeps_explicit_battery(self) -> None: - """普货只补空着的项;私聊已经说含电,激活不能改成否。""" - created = self.ledger.create_ticket( - sender_id="sales1", - business_line="AIR", - facts={ - "起运地": "上海", - "目的地": "洛杉矶", - "品名": "普货", - "件数": "3", - "毛重": "100", - "体积": "2", - "包装类型": "纸箱", - "报价日期": "2026-09-16", - "是否含电": "是", - }, - ) - wo = created["work_order_no"] - self.ledger.upsert_quote( - work_order_no=wo, - quote={"total": "USD 800", "source": "TMS", "quoteId": "QT-AIR-1"}, - to_status="已报价", - ) - handle_air_group( - self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="gc2"), - flow=self.flow, - ledger=self.ledger, - ) - ticket = self.ledger.get_ticket(work_order_no=wo) - self.assertEqual(ticket.collab_facts.get("是否含电"), "是") - self.assertEqual(ticket.collab_facts.get("是否含磁"), "否") - joined = "\n".join(self._texts()) - self.assertIn("是否含电:是", joined) - self.assertIn("是否含磁:否", joined) - - def test_second_group_occupied(self) -> None: - wo = self._create_air() - handle_air_group( - self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="o1"), - flow=self.flow, - ledger=self.ledger, - ) - phase = handle_air_group( - self._msg(sender="sales1", text=wo, chat="wr_air_2", mid="o2"), - flow=self.flow, - ledger=self.ledger, - ) - self.assertEqual(phase, "occupied") - self.assertTrue(any("另一个群" in t for t in self._texts())) - - def test_need_work_order_when_empty(self) -> None: - phase = handle_air_group( - self._msg(sender="sales1", text="空运费100USD", chat="wr_air_1", mid="n1"), - flow=self.flow, - ledger=self.ledger, - injected_intent="product_quote", - ) - self.assertEqual(phase, "need_work_order") - - def test_activated_multi_quote_follows_without_work_order(self) -> None: - """多段激活后,报价不必再带工单号,也不要回「请先 @我」。""" - import json - - created = self.ledger.create_ticket( - sender_id="sales1", - business_line="MULTI", - facts={ - "segments_json": json.dumps( - [ - {"index": 1, "mode": "LAND", "facts": {"起运港": "北京", "目的港": "上海"}}, - {"index": 2, "mode": "AIR", "facts": {"起运港": "上海", "目的港": "北宁"}}, - ], - ensure_ascii=False, - ) - }, - ) - wo = created["work_order_no"] - self.ledger.bind_collab_group( - work_order_no=wo, - chat_id="wr_multi", - member_ids=["sales1", "air1"], - product_ids=["air1"], - product_names=["HangXian"], - ) - phase = handle_air_group( - self._msg(sender="air1", text="空运费 100USD", chat="wr_multi", mid="mq1"), - flow=self.flow, - ledger=self.ledger, - injected_intent="product_quote", - ) - self.assertEqual(phase, "segment_quoted") - joined = "\n".join(self._texts()) - self.assertNotIn("请先 @我", joined) - self.assertNotIn("并发送要跟的工单号", joined) - ticket = self.ledger.get_ticket(work_order_no=wo) - air_quote = (ticket.quote or {}).get("segment_quotes", {}).get("AIR") or {} - self.assertTrue(air_quote.get("total") or air_quote.get("fee_rows")) - - def test_sales_can_fill_battery(self) -> None: - wo = self._create_air() - handle_air_group( - self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="f0"), - flow=self.flow, - ledger=self.ledger, - ) - phase = handle_air_group( - self._msg(sender="sales1", text="含电 不含磁", chat="wr_air_1", mid="f1"), - flow=self.flow, - ledger=self.ledger, - injected_intent="collab_fields", - ) - self.assertEqual(phase, "fields_saved") - ticket = self.ledger.get_ticket(work_order_no=wo) - self.assertEqual(ticket.collab_facts.get("是否含电"), "是") - self.assertEqual(ticket.collab_facts.get("是否含磁"), "否") - joined = "\n".join(self._texts()) - self.assertIn("工单号:", joined) - self.assertIn("询价字段:", joined) - self.assertIn("是否含电:是", joined) - self.assertIn("是否含磁:否", joined) - self.assertNotIn("已记下", joined) - self.assertTrue(any("@WuJiLin" in t and "@HangXian" in t for t in self._texts())) - self.assertEqual(getattr(self.ledger, "last_render", {}).get("templateId"), "air-default") - fill = dict(getattr(self.ledger, "last_render", {}).get("facts") or {}) - self.assertEqual(fill.get("是否含电") or fill.get("has_battery"), "是") - self.assertEqual(fill.get("是否含磁") or fill.get("has_magnet"), "否") - texts = [i for i, x in enumerate(self.groups.sent) if x.get("kind") == "text" and "工单号:" in (x.get("content") or "")] - files = [i for i, x in enumerate(self.groups.sent) if x.get("kind") == "file"] - self.assertTrue(texts and files) - self.assertLess(texts[-1], files[-1]) - self.assertEqual(len(files), 2) - - def test_collab_after_activate_does_not_use_old_land_ticket(self) -> None: - """ - 测服 WO202609230023:空运进原群激活后补含电含磁, - 不能写到同群更早的陆运单 WO202609230007,摘要也不能变成空字段。 - """ - land = self.ledger.create_ticket( - sender_id="sales1", - business_line="LAND", - facts={ - "运输类型": "国内运输整车", - "始发站": "深圳", - "目的站": "上海", - "品名": "配件", - }, - )["work_order_no"] - self.ledger.bind_collab_group( - work_order_no=land, - chat_id="wr_air_1", - member_ids=["sales1"], - ) - air = self._create_air() - handle_air_group( - self._msg(sender="sales1", text=air, chat="wr_air_1", mid="mix0"), - flow=self.flow, - ledger=self.ledger, - ) - self.assertEqual(self.ledger.find_by_collab_chat(chat_id="wr_air_1").work_order_no, air) - self.assertEqual( - str(self.ledger.get_ticket(work_order_no=land).collab_chat_id or ""), - "", - ) - self.groups.sent.clear() - phase = handle_air_group( - self._msg(sender="sales1", text="含电 不含磁", chat="wr_air_1", mid="mix1"), - flow=self.flow, - ledger=self.ledger, - injected_intent="collab_fields", - ) - self.assertEqual(phase, "fields_saved") - air_ticket = self.ledger.get_ticket(work_order_no=air) - land_ticket = self.ledger.get_ticket(work_order_no=land) - self.assertEqual(air_ticket.collab_facts.get("是否含电"), "是") - self.assertEqual(air_ticket.collab_facts.get("是否含磁"), "否") - self.assertFalse(land_ticket.collab_facts.get("是否含电")) - joined = "\n".join(self._texts()) - self.assertIn(f"工单号:{air}", joined) - self.assertNotIn(f"工单号:{land}", joined) - self.assertIn("起运地:上海", joined) - self.assertIn("重量(KG):100", joined) - self.assertNotIn("起运地:-", joined) - - def test_sales_quote_rejected(self) -> None: - wo = self._create_air() - handle_air_group( - self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="q0"), - flow=self.flow, - ledger=self.ledger, - ) - phase = handle_air_group( - self._msg(sender="sales1", text="空运费 100USD", chat="wr_air_1", mid="q1"), - flow=self.flow, - ledger=self.ledger, - injected_intent="product_quote", - ) - self.assertEqual(phase, "not_airline") - - def test_airline_quote_asks_tms_choice(self) -> None: - wo = self._create_air() - handle_air_group( - self._msg(sender="air1", text=wo, chat="wr_air_1", mid="t0"), - flow=self.flow, - ledger=self.ledger, - ) - phase = handle_air_group( - self._msg(sender="air1", text="空运费改为900USD", chat="wr_air_1", mid="t1"), - flow=self.flow, - ledger=self.ledger, - injected_intent="product_quote", - injected_quote={"total": "USD 900", "source_label": "航线报价"}, - ) - self.assertEqual(phase, "wait_tms_choice") - self.assertTrue(any("在 TMS 基础上改" in t or "在TMS基础上改" in t for t in self._texts())) - ticket = self.ledger.get_ticket(work_order_no=wo) - self.assertEqual(ticket.quote.get("total"), "USD 800") - self.assertNotEqual(ticket.quote.get("source_label"), "航线报价") - phase = handle_air_group( - self._msg(sender="air1", text="在TMS基础上改", chat="wr_air_1", mid="t2"), - flow=self.flow, - ledger=self.ledger, - injected_intent="quote_keep_tms", - ) - self.assertEqual(phase, "quoted") - ticket = self.ledger.get_ticket(work_order_no=wo) - self.assertEqual(ticket.quote.get("total"), "USD 900") - self.assertEqual(ticket.quote.get("quoteId"), "QT-AIR-1") - self.assertEqual(ticket.quote.get("source_label"), "航线在TMS上改") - self.assertFalse(ticket.collab_facts.get("__pending_air_quote")) - joined = "\n".join(self._texts()) - self.assertIn("工单号:", joined) - self.assertIn("询价字段:", joined) - self.assertIn("人工报价:", joined) - self.assertIn("报价单已发送,请确认成交状态", joined) - self.assertEqual(getattr(self.ledger, "last_render", {}).get("templateId"), "air-default") - self.assertTrue(any(x.get("kind") == "file" for x in self.groups.sent)) - - def test_reply_2_means_drop_tms_not_price(self) -> None: - """问 1/2 之后,航线回 @询价小助手 2 是选不用 TMS,不能把价格改成 2。""" - wo = self._create_air() - handle_air_group( - self._msg(sender="air1", text=wo, chat="wr_air_1", mid="d0"), - flow=self.flow, - ledger=self.ledger, - ) - phase = handle_air_group( - self._msg(sender="air1", text="空运费 900USD", chat="wr_air_1", mid="d1"), - flow=self.flow, - ledger=self.ledger, - injected_intent="product_quote", - injected_quote={"total": "USD 900", "currency": "USD", "source_label": "航线报价"}, - ) - self.assertEqual(phase, "wait_tms_choice") - phase = handle_air_group( - self._msg(sender="air1", text="@询价小助手 2", chat="wr_air_1", mid="d2"), - flow=self.flow, - ledger=self.ledger, - ) - self.assertEqual(phase, "quoted") - ticket = self.ledger.get_ticket(work_order_no=wo) - self.assertEqual(ticket.quote.get("total"), "USD 900") - self.assertEqual(ticket.quote.get("source_label"), "航线报价") - self.assertNotEqual(ticket.quote.get("quoteId"), "QT-AIR-1") - - def test_airline_quote_without_tms_sends_brief_sheet_deal(self) -> None: - wo = self._create_air(quote={}, status="询价中") - handle_air_group( - self._msg(sender="air1", text=wo, chat="wr_air_1", mid="nq0"), - flow=self.flow, - ledger=self.ledger, - ) - self.groups.sent.clear() - phase = handle_air_group( - self._msg(sender="air1", text="空运费 900USD", chat="wr_air_1", mid="nq1"), - flow=self.flow, - ledger=self.ledger, - injected_intent="product_quote", - injected_quote={"total": "USD 900", "source_label": "航线报价"}, - ) - self.assertEqual(phase, "quoted") - ticket = self.ledger.get_ticket(work_order_no=wo) - self.assertEqual(ticket.event_wait_kind, "deal") - self.assertTrue(ticket.event_wait_started_at) - self.assertEqual(ticket.status, "已报价") - self.assertEqual(ticket.quote.get("total"), "USD 900") - joined = "\n".join(self._texts()) - self.assertIn("工单号:", joined) - self.assertIn("询价字段:", joined) - self.assertIn("TMS 暂无匹配报价", joined) - self.assertIn("人工报价:", joined) - self.assertIn("报价单已发送,请确认成交状态", joined) - self.assertNotIn("航线报价已记下", joined) - self.assertEqual(getattr(self.ledger, "last_render", {}).get("templateId"), "air-default") - self.assertTrue(any(x.get("kind") == "file" for x in self.groups.sent)) - - def test_quote_same_sentence_saves_battery(self) -> None: - wo = self._create_air(quote={}, status="询价中") - handle_air_group( - self._msg(sender="air1", text=wo, chat="wr_air_1", mid="qs0"), - flow=self.flow, - ledger=self.ledger, - ) - phase = handle_air_group( - self._msg(sender="air1", text="含电 空运费900USD", chat="wr_air_1", mid="qs1"), - flow=self.flow, - ledger=self.ledger, - injected_intent="product_quote", - injected_quote={"total": "USD 900", "source_label": "航线报价"}, - ) - self.assertEqual(phase, "quoted") - ticket = self.ledger.get_ticket(work_order_no=wo) - self.assertEqual(ticket.collab_facts.get("是否含电"), "是") - self.assertIn("是否含电:是", "\n".join(self._texts())) - - def test_confirm_tms_saves_sheet_and_deal(self) -> None: - wo = self._create_air() - handle_air_group( - self._msg(sender="air1", text=wo, chat="wr_air_1", mid="ct0"), - flow=self.flow, - ledger=self.ledger, - ) - self.groups.sent.clear() - phase = handle_air_group( - self._msg(sender="air1", text="确认报价", chat="wr_air_1", mid="ct1"), - flow=self.flow, - ledger=self.ledger, - injected_intent="quote_confirm_tms", - ) - self.assertEqual(phase, "quote_confirmed") - ticket = self.ledger.get_ticket(work_order_no=wo) - self.assertEqual(ticket.quote.get("source_label"), "航线确认TMS") - self.assertTrue(any(f"工单{wo}的1个附件已保存到后台工单" in t for t in self._texts())) - self.assertTrue(any("报价单已发送,请确认成交状态" in t for t in self._texts())) - - def test_stranger_cannot_fill_fields(self) -> None: - wo = self._create_air() - handle_air_group( - self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="sf0"), - flow=self.flow, - ledger=self.ledger, - ) - phase = handle_air_group( - self._msg(sender="other1", text="含电 不含磁", chat="wr_air_1", mid="sf1"), - flow=self.flow, - ledger=self.ledger, - injected_intent="collab_fields", - ) - self.assertEqual(phase, "fields_denied") - ticket = self.ledger.get_ticket(work_order_no=wo) - self.assertFalse(ticket.collab_facts.get("是否含电")) - - def test_sales_deal(self) -> None: - wo = self._create_air() - handle_air_group( - self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="c0"), - flow=self.flow, - ledger=self.ledger, - ) - phase = handle_air_group( - self._msg(sender="sales1", text="成交", chat="wr_air_1", mid="c1"), - flow=self.flow, - ledger=self.ledger, - injected_intent="deal_won", - ) - self.assertEqual(phase, "done") - self.assertEqual(self.ledger.get_ticket(work_order_no=wo).status, "已成交") - self.assertTrue(any(f"{wo}请锁舱@HangXian" in t for t in self._texts())) - self.assertTrue(any("已记录为「已成交」" in t for t in self._texts())) - - def test_lock_requires_work_order(self) -> None: - phase = handle_air_group( - self._msg(sender="air1", text="锁舱", chat="wr_air_1", mid="l0"), - flow=self.flow, - ledger=self.ledger, - injected_intent="air_lock", - ) - self.assertEqual(phase, "cabin_need_work_order") - self.assertTrue(any("带上工单号" in t for t in self._texts())) - - def test_lock_without_tms_quote(self) -> None: - wo = self._create_air(quote={}, status="询价中") - phase = handle_air_group( - self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_1", mid="l1"), - flow=self.flow, - ledger=self.ledger, - injected_intent="air_lock", - ) - self.assertEqual(phase, "lock_need_deal") - self.assertTrue(any("请销售@WuJiLin先确认工单「已成交」" in t for t in self._texts())) - self.assertFalse(any("还没有报价,不能锁舱" in t for t in self._texts())) - self.assertFalse(any("AIR-OPT-01" in t for t in self._texts())) - self.assertFalse(any("执行成功" in t for t in self._texts())) - - def test_manual_quote_without_option_asks_fields(self) -> None: - """纯人工价且口令无 AIR-OPT:交主账后仍缺 → lock_incomplete(不空壳成功)。""" - wo = self._create_air( - quote={ - "total": "100", - "source": "manual", - "source_label": "产品报价", - }, - status="已成交", - ) - phase = handle_air_group( - self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_manual", mid="lm1"), - flow=self.flow, - ledger=self.ledger, - injected_intent="air_lock", - ) - self.assertEqual(phase, "wait_lock_params") - phase = handle_air_group( - self._msg( - sender="air1", - text="AIR-OPT01 CZ 航班CZ327", - chat="wr_air_lock_manual", - mid="lm2", - ), - flow=self.flow, - ledger=self.ledger, - injected_intent="air_pick_option", - ) - self.assertEqual(phase, "lock_ok") - self.assertTrue(any("TMS空运锁舱成功" in t for t in self._texts())) - - def test_tms_quote_with_selected_cabin_locks_without_asking_params(self) -> None: - """TMS 已有 selected_air_option:直接锁,不追问舱位/航司/航班(WO014)。""" - wo = self._create_air( - quote={ - "total": "183370", - "source": "TMS", - "source_label": "航线确认TMS", - "quoteId": "AIR-2094721692181016578-2094721692701110273", - "selected_air_option": { - "optionNo": "AIR-OPT01", - "airlineCode": "JG", - "flightNo": "JG2719", - }, - "airOptions": [ - { - "optionNo": "AIR-OPT01", - "airlineCode": "JG", - "flightNo": "JG2719", - }, - { - "optionNo": "AIR-OPT02", - "airlineCode": "RW", - "flightNo": "RW633", - }, - ], - }, - status="已成交", - ) - calls: list[dict] = [] - orig = self.ledger.lock_cabin - - def spy(**kwargs): - calls.append(kwargs) - return orig(**kwargs) - - self.ledger.lock_cabin = spy # type: ignore - phase = handle_air_group( - self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_tms_auto", mid="tmsauto1"), - flow=self.flow, - ledger=self.ledger, - injected_intent="air_lock", - ) - self.assertEqual(phase, "lock_ok") - self.assertEqual(len(calls), 1) - self.assertEqual(calls[0].get("option_no"), "AIR-OPT01") - self.assertEqual(calls[0].get("airline"), "JG") - self.assertEqual(calls[0].get("flight_no"), "JG2719") - self.assertFalse(any("锁舱参数不完整" in t for t in self._texts())) - - def test_lock_ok_reply_includes_route(self) -> None: - """锁舱成功回执带出本次舱位的起运地、目的地、航司、航班日期。""" - wo = self._create_air( - quote={ - "total": "1800", - "source": "TMS", - "source_label": "航线确认TMS", - "quoteId": "AIR-2099695579169554433-2099695579182137346", - "selected_air_option": { - "optionNo": "AIR-OPT01", - "airlineCode": "TH", - "flightNo": "TH123", - "originAirportCode": "NKG", - "destinationAirportCode": "KUL", - "flightDate": "2026-10-15", - }, - }, - status="已成交", - ) - phase = handle_air_group( - self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_route", mid="route1"), - flow=self.flow, - ledger=self.ledger, - injected_intent="air_lock", - ) - self.assertEqual(phase, "lock_ok") - text = self._texts()[-1] - self.assertIn("TMS空运锁舱成功。", text) - self.assertIn("舱位选项:AIR-OPT01", text) - self.assertIn("起运地:NKG", text) - self.assertIn("目的地:KUL", text) - self.assertIn("航司:TH", text) - self.assertIn("航班日期:2026-10-15", text) - - def test_lock_missing_params_asks_and_does_not_call_tms(self) -> None: - """无舱位明细时缺三项才请补,不锁舱、不报 TMS 失败、不通知 IT。""" - wo = self._create_air( - quote={ - "total": "CNY 1800", - "source": "TMS", - "source_label": "TMS 标准报价", - "quoteId": "QT-AIR-23", - }, - status="已成交", - ) - calls: list[dict] = [] - orig = self.ledger.lock_cabin - - def spy(**kwargs): - calls.append(kwargs) - return orig(**kwargs) - - self.ledger.lock_cabin = spy # type: ignore - phase = handle_air_group( - self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_ask", mid="ask1"), - flow=self.flow, - ledger=self.ledger, - injected_intent="air_lock", - ) - self.assertEqual(phase, "wait_lock_params") - self.assertEqual(calls, []) - asked = self._texts()[-1] - self.assertEqual( - asked, - "锁舱参数不完整,缺少:舱位编号、航司、航班号。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。", - ) - self.assertNotIn("已通知", asked) - self.assertNotIn("锁舱失败", asked) - phase = handle_air_group( - self._msg( - sender="air1", - text="AIR-OPT01 CZ 航班CZ327", - chat="wr_air_lock_ask", - mid="ask2", - ), - flow=self.flow, - ledger=self.ledger, - injected_intent="air_pick_option", - ) - self.assertEqual(phase, "lock_ok") - self.assertEqual(len(calls), 1) - self.assertEqual(calls[0].get("option_no"), "AIR-OPT01") - self.assertEqual(calls[0].get("airline"), "CZ") - self.assertEqual(calls[0].get("flight_no"), "CZ327") - - def test_lock_accepts_free_wording_not_only_sample_format(self) -> None: - """补舱位不限格式。点名舱位、航司、航班号,换顺序、用航司中文名,也要锁。""" - wo = self._create_air( - quote={ - "total": "CNY 1800", - "source": "TMS", - "source_label": "TMS 标准报价", - "quoteId": "QT-AIR-24", - }, - status="已成交", - ) - calls: list[dict] = [] - orig = self.ledger.lock_cabin - - def spy(**kwargs): - calls.append(kwargs) - return orig(**kwargs) - - self.ledger.lock_cabin = spy # type: ignore - phase = handle_air_group( - self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_free", mid="free1"), - flow=self.flow, - ledger=self.ledger, - injected_intent="air_lock", - ) - self.assertEqual(phase, "wait_lock_params") - self.assertEqual(calls, []) - phase = handle_air_group( - self._msg( - sender="air1", - text="航班号:CZ327,航司南航,舱位编号是AIR-OPT01", - chat="wr_air_lock_free", - mid="free2", - ), - flow=self.flow, - ledger=self.ledger, - ) - self.assertEqual(phase, "lock_ok") - self.assertEqual(calls[0].get("option_no"), "AIR-OPT01") - self.assertEqual(calls[0].get("airline"), "CZ") - self.assertEqual(calls[0].get("flight_no"), "CZ327") - - def test_lock_sentence_with_labeled_params_locks_immediately(self) -> None: - """同一句里已经说齐三项,直接锁,不再要求按示例格式重说。""" - wo = self._create_air( - quote={ - "total": "CNY 1800", - "source": "manual", - "source_label": "产品报价", - }, - status="已成交", - ) - calls: list[dict] = [] - orig = self.ledger.lock_cabin - - def spy(**kwargs): - calls.append(kwargs) - return orig(**kwargs) - - self.ledger.lock_cabin = spy # type: ignore - phase = handle_air_group( - self._msg( - sender="air1", - text=f"{wo}锁舱,舱位 AIR-OPT02,东方航空,航班号 MU5123", - chat="wr_air_lock_once", - mid="once1", - ), - flow=self.flow, - ledger=self.ledger, - injected_intent="air_lock", - ) - self.assertEqual(phase, "lock_ok") - self.assertEqual(len(calls), 1) - self.assertEqual(calls[0].get("option_no"), "AIR-OPT02") - self.assertEqual(calls[0].get("airline"), "MU") - self.assertEqual(calls[0].get("flight_no"), "MU5123") - self.assertFalse(any("锁舱参数不完整" in t for t in self._texts())) - - def test_deal_then_lock_then_release(self) -> None: - """成交前不能锁。成交后点名航线,锁成功可取消,不再问一次是否成交。""" - wo = self._create_air() - phase = handle_air_group( - self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_1", mid="l2"), - flow=self.flow, - ledger=self.ledger, - injected_intent="air_lock", - ) - self.assertEqual(phase, "lock_need_deal") - self.assertTrue(any("请销售@WuJiLin先确认工单「已成交」" in t for t in self._texts())) - phase = handle_air_group( - self._msg(sender="sales1", text="成交", chat="wr_air_1", mid="l3"), - flow=self.flow, - ledger=self.ledger, - injected_intent="deal_won", - ) - self.assertEqual(phase, "done") - self.assertTrue(any(f"{wo}请锁舱@HangXian" in t for t in self._texts())) - self.groups.sent.clear() - phase = handle_air_group( - self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_1", mid="l4"), - flow=self.flow, - ledger=self.ledger, - injected_intent="air_lock", - ) - self.assertEqual(phase, "lock_ok") - self.assertTrue(any("TMS空运锁舱成功" in t for t in self._texts())) - self.assertFalse(any("请确认成交状态" in t for t in self._texts())) - phase = handle_air_group( - self._msg(sender="air1", text=f"{wo}取消舱位", chat="wr_air_1", mid="l5"), - flow=self.flow, - ledger=self.ledger, - ) - self.assertEqual(phase, "release_ok") - self.assertTrue(any("取消舱位成功" in t for t in self._texts())) - - def test_negotiating_and_lost_cannot_lock(self) -> None: - wo_talk = self._create_air(status="协商中") - phase = handle_air_group( - self._msg(sender="air1", text=f"{wo_talk}锁舱", chat="wr_air_talk", mid="ng1"), - flow=self.flow, - ledger=self.ledger, - injected_intent="air_lock", - ) - self.assertEqual(phase, "lock_negotiating") - self.assertTrue(any("还在协商中,成交后才能锁舱" in t for t in self._texts())) - self.assertFalse(any("先确认工单「已成交」" in t for t in self._texts())) - wo_lost = self._create_air(status="未成交") - self.groups.sent.clear() - phase = handle_air_group( - self._msg(sender="air1", text=f"{wo_lost}锁舱", chat="wr_air_lost", mid="ls1"), - flow=self.flow, - ledger=self.ledger, - injected_intent="air_lock", - ) - self.assertEqual(phase, "lock_lost") - self.assertTrue(any("已是「未成交」,不能锁舱" in t for t in self._texts())) - self.assertFalse(any("先确认工单「已成交」" in t for t in self._texts())) - - def test_lost_can_release(self) -> None: - """未成交不能新锁,但改规则前已经锁上的仍可取消。""" - wo = self._create_air(status="未成交") - self.ledger.patch_collab_facts( - work_order_no=wo, - facts={"__air_lock_id": "CA-OLD-LOCK"}, - sender_id="air1", - ) - before = len(self._texts()) - phase = handle_air_group( - self._msg(sender="air1", text=f"{wo}释放舱位", chat="wr_air_1", mid="r2"), - flow=self.flow, - ledger=self.ledger, - injected_intent="air_release", - ) - self.assertEqual(phase, "release_ok") - added = "\n".join(self._texts()[before:]) - self.assertIn("取消舱位", added) - self.assertNotIn("请确认成交状态", added) - - def test_release_wording_asks_before_cancel(self) -> None: - """说出释放舱位只提问。销售回「是」不算,航线确认后才取消。""" - wo = self._create_air(status="已成交") - handle_air_group( - self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_ask", mid="ask0"), - flow=self.flow, - ledger=self.ledger, - injected_intent="air_lock", - ) - self.groups.sent.clear() - phase = handle_air_group( - self._msg(sender="air1", text=f"{wo}释放舱位", chat="wr_air_ask", mid="ask1"), - flow=self.flow, - ledger=self.ledger, - ) - self.assertEqual(phase, "release_ask") - self.assertIn("是否需要取消舱位?", "\n".join(self._texts())) - self.assertNotIn("已通知TMS执行空运取消舱位成功", "\n".join(self._texts())) - phase = handle_air_group( - self._msg(sender="sales1", text="是", chat="wr_air_ask", mid="ask2"), - flow=self.flow, - ledger=self.ledger, - ) - self.assertEqual(phase, "not_airline") - self.assertNotIn("已通知TMS执行空运取消舱位成功", "\n".join(self._texts())) - phase = handle_air_group( - self._msg(sender="air1", text="是", chat="wr_air_ask", mid="ask3"), - flow=self.flow, - ledger=self.ledger, - ) - self.assertEqual(phase, "release_ok") - self.assertIn("已通知TMS执行空运取消舱位成功", "\n".join(self._texts())) - - def test_release_wording_can_be_declined(self) -> None: - wo = self._create_air(status="已成交") - handle_air_group( - self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_ask_no", mid="no0"), - flow=self.flow, - ledger=self.ledger, - injected_intent="air_lock", - ) - self.groups.sent.clear() - handle_air_group( - self._msg(sender="air1", text=f"{wo}把舱位释放了", chat="wr_air_ask_no", mid="no1"), - flow=self.flow, - ledger=self.ledger, - ) - phase = handle_air_group( - self._msg(sender="air1", text="不用", chat="wr_air_ask_no", mid="no2"), - flow=self.flow, - ledger=self.ledger, - ) - self.assertEqual(phase, "release_ask_dropped") - self.assertIn("已取消这次操作", "\n".join(self._texts())) - self.assertNotIn("已通知TMS执行空运取消舱位成功", "\n".join(self._texts())) - - def test_cancel_cabin_words_release_immediately(self) -> None: - wo = self._create_air(status="已成交") - handle_air_group( - self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_now", mid="now0"), - flow=self.flow, - ledger=self.ledger, - injected_intent="air_lock", - ) - self.groups.sent.clear() - phase = handle_air_group( - self._msg(sender="air1", text=f"{wo}取消舱位", chat="wr_air_now", mid="now1"), - flow=self.flow, - ledger=self.ledger, - ) - self.assertEqual(phase, "release_ok") - joined = "\n".join(self._texts()) - self.assertIn("已通知TMS执行空运取消舱位成功", joined) - self.assertNotIn("是否需要取消舱位?", joined) - - def test_sales_cannot_lock(self) -> None: - wo = self._create_air() - phase = handle_air_group( - self._msg(sender="sales1", text=f"{wo}锁舱", chat="wr_air_1", mid="s1"), - flow=self.flow, - ledger=self.ledger, - injected_intent="air_lock", - ) - self.assertEqual(phase, "not_airline") - - def test_dual_role_can_lock(self) -> None: - self.ledger.upsert_staff( - { - "name": "WuJiLin", - "wecomId": "sales1", - "roleCode": "air", - "status": "active", - } - ) - wo = self._create_air(status="已成交") - phase = handle_air_group( - self._msg(sender="sales1", text=f"{wo}锁舱", chat="wr_air_1", mid="s2"), - flow=self.flow, - ledger=self.ledger, - injected_intent="air_lock", - ) - self.assertEqual(phase, "lock_ok") - - def test_multi_tms_option_locks_first_until_named(self) -> None: - """TMS 有多条舱位且没点名时,直接锁第一条,不再追问三项。""" - wo = self._create_air( - quote={ - "total": "USD 800", - "source": "TMS", - "quoteId": "QT-AIR-2", - "airOptions": [ - {"optionNo": "AIR-OPT-01", "airlineCode": "CZ", "flightNo": "CZ327"}, - {"optionNo": "AIR-OPT-02", "airlineCode": "MU", "flightNo": "MU5123"}, - ], - }, - status="已成交", - ) - phase = handle_air_group( - self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_1", mid="m1"), - flow=self.flow, - ledger=self.ledger, - injected_intent="air_lock", - ) - self.assertEqual(phase, "lock_ok") - ticket = self.ledger.get_ticket(work_order_no=wo) - self.assertEqual(ticket.collab_facts.get("__air_lock_option"), "AIR-OPT-01") - - def _file(self, *, sender: str, filename: str, chat: str, mid: str, text: str = "") -> InboundMessage: - return InboundMessage( - sender_id=sender, - message_id=mid, - content=text, - msg_type="file", - chat_id=chat, - chat_type="group", - media={"filename": filename, "media_id": f"mid-{mid}"}, - raw={"source": "aibot"}, - ) - - def test_file_needs_work_order(self) -> None: - phase = handle_air_group( - self._file(sender="air1", filename="航线报价.xlsx", chat="wr_air_1", mid="af0"), - flow=self.flow, - ledger=self.ledger, - ) - self.assertEqual(phase, "need_work_order") - - def test_sales_excel_archived_not_quote(self) -> None: - wo = self._create_air() - handle_air_group( - self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="af1"), - flow=self.flow, - ledger=self.ledger, - ) - phase = handle_air_group( - self._file(sender="sales1", filename="对账单.xlsx", chat="wr_air_1", mid="af2"), - flow=self.flow, - ledger=self.ledger, - ) - self.assertEqual(phase, "file_archived") - ticket = self.ledger.get_ticket(work_order_no=wo) - self.assertEqual(ticket.attachments[0]["file_name"], "对账单.xlsx") - self.assertNotEqual(ticket.quote.get("source_label"), "航线附件报价") - self.assertTrue(any(f"工单{wo}的1个附件已保存到后台工单" in t for t in self._texts())) - - def test_airline_excel_quotes_and_keeps_tms_id(self) -> None: - wo = self._create_air() - handle_air_group( - self._msg(sender="air1", text=wo, chat="wr_air_1", mid="af3"), - flow=self.flow, - ledger=self.ledger, - ) - phase = handle_air_group( - self._file(sender="air1", filename="航线报价.pdf", chat="wr_air_1", mid="af4"), - flow=self.flow, - ledger=self.ledger, - ) - self.assertEqual(phase, "airline_file_quote") - ticket = self.ledger.get_ticket(work_order_no=wo) - self.assertEqual(ticket.attachments[0]["file_name"], "航线报价.pdf") - self.assertEqual(ticket.quote.get("source_label"), "航线附件报价") - self.assertEqual(ticket.quote.get("quoteId"), "QT-AIR-1") - self.assertEqual(ticket.status, "已报价") - self.assertTrue(any(f"工单{wo}的1个附件已保存到后台工单" in t for t in self._texts())) - self.assertTrue(any("如需锁舱,请航线人员改用纯文字报价" in t for t in self._texts())) - self.assertTrue(any("空运费 单价CNY99.00" in t for t in self._texts())) - self.assertTrue(any("报价单已发送,请确认成交状态" in t for t in self._texts())) - - def test_reactivate_after_airline_file_is_deal_not_blank_sheet(self) -> None: - """航线附件已报价后,再发工单号回到成交跟进,不再要航线报价。""" - wo = self._create_air() - handle_air_group( - self._msg(sender="air1", text=wo, chat="wr_air_1", mid="af3b"), - flow=self.flow, - ledger=self.ledger, - ) - handle_air_group( - self._file(sender="air1", filename="航线报价.pdf", chat="wr_air_1", mid="af4b"), - flow=self.flow, - ledger=self.ledger, - ) - before = len(self.groups.sent) - phase = handle_air_group( - self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="af4c"), - flow=self.flow, - ledger=self.ledger, - ) - self.assertEqual(phase, "activated_deal") - added = self.groups.sent[before:] - texts = "\n".join(x.get("content") or "" for x in added) - self.assertIn("请确认成交状态", texts) - self.assertFalse(any(x.get("kind") == "file" for x in added)) - - def test_reactivate_quoted_tms_file_is_deal_not_sheet(self) -> None: - """TMS 价的报价单已经发出、停在成交跟进:再发工单号只回成交跟进。""" - wo = self._create_air( - quote={ - "total": "CNY 3500.70", - "source": "TMS", - "source_label": "TMS 标准报价", - "quoteId": "QT-AIR-FILE", - "quoteFileIssued": True, - "airOptions": [ - {"optionNo": "AIR-OPT-01", "airlineCode": "CZ", "flightNo": "CZ327"} - ], - } - ) - phase = handle_air_group( - self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="react-deal"), - flow=self.flow, - ledger=self.ledger, - ) - self.assertEqual(phase, "activated_deal") - texts = "\n".join(self._texts()) - self.assertIn("请确认成交状态", texts) - self.assertNotIn("询价字段:", texts) - self.assertFalse(any(x.get("kind") == "file" for x in self.groups.sent)) - - def test_image_rejected(self) -> None: - wo = self._create_air() - handle_air_group( - self._msg(sender="air1", text=wo, chat="wr_air_1", mid="af5"), - flow=self.flow, - ledger=self.ledger, - ) - phase = handle_air_group( - InboundMessage( - sender_id="air1", - message_id="af6", - content="", - msg_type="image", - chat_id="wr_air_1", - chat_type="group", - media={"filename": "现场.jpg", "media_id": "mid-img"}, - raw={"source": "aibot"}, - ), - flow=self.flow, - ledger=self.ledger, - ) - self.assertEqual(phase, "file_type_rejected") - self.assertFalse(self.ledger.get_ticket(work_order_no=wo).attachments) - - def test_archive_file_without_at_quotes(self) -> None: - wo = self._create_air() - handle_air_group( - self._msg(sender="air1", text=wo, chat="wr_air_1", mid="ar0"), - flow=self.flow, - ledger=self.ledger, - ) - msg = self._file(sender="air1", filename="航线报价.xlsx", chat="wr_air_1", mid="ar1") - msg = InboundMessage( - sender_id=msg.sender_id, - message_id=msg.message_id, - content=msg.content, - msg_type="file", - chat_id=msg.chat_id, - chat_type="group", - media=dict(msg.media), - raw={"source": "wshoto_archive"}, - ) - import agent.handlers.air_group_collab as air_mod - orig = air_mod.get_air_text_flow - - def _fake(): - return self.flow - - air_mod.get_air_text_flow = _fake # type: ignore - try: - name = dispatch_inbound(msg, RouteDecision(intent="ordinary_text_other")) - finally: - air_mod.get_air_text_flow = orig # type: ignore - self.assertEqual(name, "air_group_collab") - ticket = self.ledger.get_ticket(work_order_no=wo) - self.assertEqual(ticket.quote.get("source_label"), "航线附件报价") - self.assertTrue(any(f"工单{wo}的1个附件已保存到后台工单" in t for t in self._texts())) - self.assertTrue(any("成交" in t or "未成交" in t or "协商" in t for t in self._texts())) - - def test_archive_text_ignored_until_at(self) -> None: - wo = self._create_air() - handle_air_group( - self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="ar2"), - flow=self.flow, - ledger=self.ledger, - ) - msg = self._msg(sender="sales1", text="成交", chat="wr_air_1", mid="ar3") - msg = InboundMessage( - sender_id=msg.sender_id, - message_id=msg.message_id, - content=msg.content, - chat_id=msg.chat_id, - chat_type="group", - raw={"source": "wshoto_archive"}, - ) - import agent.handlers.air_group_collab as air_mod - orig = air_mod.get_air_text_flow - - def _fake(): - return self.flow - - air_mod.get_air_text_flow = _fake # type: ignore - try: - name = dispatch_inbound(msg, RouteDecision(intent="ordinary_text_other")) - finally: - air_mod.get_air_text_flow = orig # type: ignore - self.assertEqual(name, "air_archive_text_ignore") - self.assertEqual(self.ledger.get_ticket(work_order_no=wo).status, "已报价") - - def test_archive_at_work_order_activates(self) -> None: - wo = self._create_air() - msg = InboundMessage( - sender_id="sales1", - message_id="ar-at-1", - content=f"@询价小助手 {wo}", - chat_id="wr_air_new", - chat_type="group", - raw={"source": "wshoto_archive"}, - ) - import agent.handlers.air_group_collab as air_mod - import agent.policy.air_text_flow as flow_mod - - orig = air_mod.get_air_text_flow - orig2 = flow_mod.get_air_text_flow - - def _fake(): - return self.flow - - air_mod.get_air_text_flow = _fake # type: ignore - flow_mod.get_air_text_flow = _fake # type: ignore - try: - name = dispatch_inbound(msg, RouteDecision(intent="ordinary_text_other")) - finally: - air_mod.get_air_text_flow = orig # type: ignore - flow_mod.get_air_text_flow = orig2 # type: ignore - self.assertEqual(name, "air_group_collab") - self.assertTrue(any(wo in t for t in self._texts())) - - def test_file_with_work_order_activates(self) -> None: - wo = self._create_air() - phase = handle_air_group( - self._file( - sender="air1", - filename="航线报价.xlsx", - chat="wr_air_1", - mid="af7", - text=wo, - ), - flow=self.flow, - ledger=self.ledger, - ) - self.assertEqual(phase, "airline_file_quote") - self.assertEqual(self.ledger.find_by_collab_chat(chat_id="wr_air_1").work_order_no, wo) - - def test_archive_same_sentence_different_msgid_sends_one_sheet(self) -> None: - wo = self._create_air() - phase = handle_air_group( - self._msg(sender="sales1", text=wo, chat="wr_air_dup2", mid="bot-mid-012"), - flow=self.flow, - ledger=self.ledger, - ) - self.assertEqual(phase, "activated") - self.assertEqual(sum(1 for x in self.groups.sent if x.get("kind") == "file"), 1) - phase = handle_air_group( - InboundMessage( - sender_id="sales1", - message_id="wshoto:archive-mid-999", - content=f"@询价小助手 {wo}", - chat_id="wr_air_dup2", - chat_type="group", - raw={"source": "wshoto_archive", "msgid": "archive-mid-999"}, - ), - flow=self.flow, - ledger=self.ledger, - ) - self.assertEqual(phase, "duplicate_inbound") - self.assertEqual(sum(1 for x in self.groups.sent if x.get("kind") == "file"), 1) - - def test_aibot_and_archive_same_msgid_sends_one_sheet(self) -> None: - wo = self._create_air() - import agent.handlers.air_group_collab as air_mod - import agent.policy.air_text_flow as flow_mod - - orig = air_mod.get_air_text_flow - orig2 = flow_mod.get_air_text_flow - - def _fake(): - return self.flow - - air_mod.get_air_text_flow = _fake # type: ignore - flow_mod.get_air_text_flow = _fake # type: ignore - try: - aibot = self._msg(sender="sales1", text=wo, chat="wr_air_dup", mid="same-mid-011") - name = dispatch_inbound(aibot, RouteDecision(intent="ordinary_text_other")) - self.assertEqual(name, "air_group_collab") - files_after_bot = sum(1 for x in self.groups.sent if x.get("kind") == "file") - self.assertEqual(files_after_bot, 1) - archive = InboundMessage( - sender_id="sales1", - message_id="wshoto:same-mid-011", - content=f"@询价小助手 {wo}", - chat_id="wr_air_dup", - chat_type="group", - raw={"source": "wshoto_archive", "msgid": "same-mid-011"}, - ) - name = dispatch_inbound(archive, RouteDecision(intent="ordinary_text_other")) - self.assertEqual(name, "air_group_collab") - self.assertEqual(sum(1 for x in self.groups.sent if x.get("kind") == "file"), 1) - finally: - air_mod.get_air_text_flow = orig # type: ignore - flow_mod.get_air_text_flow = orig2 # type: ignore - - def test_sea_ticket_rejected(self) -> None: - created = self.ledger.create_ticket( - sender_id="sales1", - business_line="SEA", - facts={"起运港": "上海", "目的港": "洛杉矶", "品名": "配件", "货量": "1x40HQ", "整柜或拼柜": "整柜", "箱型箱量": "1x40HQ", "报价日期": "2026-09-16"}, - ) - wo = created["work_order_no"] - phase = handle_air_group( - self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="sea1"), - flow=self.flow, - ledger=self.ledger, - ) - self.assertEqual(phase, "not_air") - - def test_lock_fail_tells_operator_and_tms_reason(self) -> None: - """锁舱失败群里要回操作人和 TMS 原因,不能被系统异常兜底盖掉。""" - wo = self._create_air(status="已成交") - self.ledger._cabin_lock_result = { - "ok": False, - "error": "lock_fail", - "failReason": "该航班日期不存在可锁定的配载记录", - } - phase = handle_air_group( - self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_fail", mid="lf1"), - flow=self.flow, - ledger=self.ledger, - injected_intent="air_lock", - ) - self.assertEqual(phase, "system_exception") - joined = "\n".join(self._texts()) - self.assertIn(f"工单{wo} 空运锁舱失败。", joined) - self.assertIn("操作人:HangXian", joined) - self.assertIn("锁舱失败的原因:该航班日期不存在可锁定的配载记录", joined) - self.assertIn("已通知IT运维排查,请稍等。", joined) - self.assertNotIn("TMS接口异常,IT运维排查中", joined) - self.assertNotIn("报价选项不存在", joined) - self.assertNotIn("与工单不匹配", joined) - - def test_lock_fail_does_not_block_airline_quote(self) -> None: - """成交前锁舱不会打 TMS,也不标系统异常,航线仍可继续报价。""" - wo = self._create_air() - handle_air_group( - self._msg(sender="air1", text=wo, chat="wr_air_lock_quote", mid="lq0"), - flow=self.flow, - ledger=self.ledger, - ) - phase = handle_air_group( - self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_quote", mid="lq1"), - flow=self.flow, - ledger=self.ledger, - injected_intent="air_lock", - ) - self.assertEqual(phase, "lock_need_deal") - self.groups.sent.clear() - phase = handle_air_group( - self._msg(sender="air1", text="空运费改为900USD", chat="wr_air_lock_quote", mid="lq2"), - flow=self.flow, - ledger=self.ledger, - injected_intent="product_quote", - ) - self.assertNotEqual(phase, "system_exception_paused") - joined = "\n".join(self._texts()) - self.assertNotIn("TMS接口异常", joined) - self.assertIn(phase, {"wait_tms_choice", "quoted"}) - - def test_lock_without_quote_still_shows_tms_reason(self) -> None: - """TMS 拒绝锁舱时原样回失败原因,不要改写成选项不匹配。""" - wo = self._create_air(status="已成交") - self.ledger._cabin_lock_result = { - "ok": False, - "error": "lock_fail", - "failReason": "该航班日期不存在可锁定的配载记录", - } - phase = handle_air_group( - self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_no_quote", mid="lnq1"), - flow=self.flow, - ledger=self.ledger, - injected_intent="air_lock", - ) - self.assertEqual(phase, "system_exception") - joined = "\n".join(self._texts()) - self.assertIn("锁舱失败的原因:该航班日期不存在可锁定的配载记录", joined) - self.assertNotIn("报价选项不存在", joined) - self.assertNotIn("与工单不匹配", joined) - - def test_paused_lock_retries_and_shows_tms_reason(self) -> None: - """上次锁舱失败标了系统异常后,再锁仍要打 TMS,不能只回兜底。""" - wo = self._create_air(status="已成交") - self.ledger.mark_system_exception( - work_order_no=wo, - system_exception="TMS", - system_exception_reason="空运锁舱接口调用失败", - step="tms_lock", - payload={}, - ) - hits = {"n": 0} - orig = self.ledger.lock_cabin - - def counted(**kwargs): - hits["n"] += 1 - return orig(**kwargs) - - self.ledger.lock_cabin = counted # type: ignore[method-assign] - self.ledger._cabin_lock_result = { - "ok": False, - "error": "lock_fail", - "failReason": "该航班日期不存在可锁定的配载记录", - } - phase = handle_air_group( - self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_paused", mid="lp1"), - flow=self.flow, - ledger=self.ledger, - injected_intent="air_lock", - ) - self.assertNotEqual(phase, "system_exception_paused") - self.assertGreaterEqual(hits["n"], 1) - joined = "\n".join(self._texts()) - self.assertIn("锁舱失败的原因:该航班日期不存在可锁定的配载记录", joined) - self.assertNotIn("TMS接口异常,IT运维排查中", joined) - - def test_paused_after_lock_still_records_lost_reason(self) -> None: - """锁舱失败标了暂停后,未成交和原因仍要入账,不能回 TMS 异常。""" - wo = self._create_air() - handle_air_group( - self._msg(sender="sales1", text=wo, chat="wr_air_paused_lost", mid="pl0"), - flow=self.flow, - ledger=self.ledger, - injected_intent="air_activate", - ) - self.ledger.mark_system_exception( - work_order_no=wo, - system_exception="TMS", - system_exception_reason="空运锁舱接口调用失败", - step="tms_lock", - payload={}, - ) - self.groups.sent.clear() - phase = handle_air_group( - self._msg(sender="sales1", text="未成交", chat="wr_air_paused_lost", mid="pl1"), - flow=self.flow, - ledger=self.ledger, - injected_intent="deal_lost", - ) - self.assertEqual(phase, "wait_lost_reason") - self.assertNotIn("TMS接口异常", "\n".join(self._texts())) - self.groups.sent.clear() - phase = handle_air_group( - self._msg(sender="sales1", text="客户不需要了", chat="wr_air_paused_lost", mid="pl2"), - flow=self.flow, - ledger=self.ledger, - injected_intent="other", - ) - self.assertEqual(phase, "done") - joined = "\n".join(self._texts()) - self.assertIn("已记下未成交原因:客户不需要了", joined) - self.assertNotIn("TMS接口异常", joined) - ticket = self.ledger.get_ticket(work_order_no=wo) - self.assertEqual(ticket.lost_reason, "客户不需要了") - - def test_release_fail_tells_operator_and_tms_reason(self) -> None: - """释放失败同理:操作人、原因、已通知IT,不要系统异常兜底。""" - wo = self._create_air(status="已成交") - handle_air_group( - self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_rl_fail", mid="rf0"), - flow=self.flow, - ledger=self.ledger, - injected_intent="air_lock", - ) - self.ledger._cabin_release_result = { - "ok": False, - "error": "release_fail", - "failReason": "舱位不存在", - } - phase = handle_air_group( - self._msg(sender="air1", text=f"{wo}释放舱位", chat="wr_air_rl_fail", mid="rf1"), - flow=self.flow, - ledger=self.ledger, - injected_intent="air_release", - ) - self.assertEqual(phase, "system_exception") - joined = "\n".join(self._texts()) - self.assertIn(f"工单{wo} 空运取消舱位失败。", joined) - self.assertIn("操作人:HangXian", joined) - self.assertIn("取消舱位失败的原因:舱位不存在", joined) - self.assertIn("已通知IT运维排查,请稍等。", joined) - self.assertNotIn("TMS接口异常,IT运维排查中", joined) - - -if __name__ == "__main__": - unittest.main() diff --git a/inquiry-agent/tests/test_air_route_options.py b/inquiry-agent/tests/test_air_route_options.py index c5472e7..fbbe448 100644 --- a/inquiry-agent/tests/test_air_route_options.py +++ b/inquiry-agent/tests/test_air_route_options.py @@ -357,6 +357,91 @@ class AirRouteOptionsTests(unittest.TestCase): self.assertNotIn("空运费", names) self.assertEqual(copy.quote_fee_lines(projected)[0][0], "运输费") + def test_oms_top_fee_items_merged_when_cabin_only_has_surcharges(self) -> None: + """OMS 表价:空运费在整单 feeItems,舱位只有增点/拆板时群摘要必须带上空运段。""" + quote = { + "total": "3334.0", + "currency": "CNY", + "remark": "空运报价来源:OMS空运表价;顶层价格为排序后的第一条直达航班方案", + "feeItems": [ + { + "feeCode": "AIR_FREIGHT", + "feeName": "空运费", + "currency": "CNY", + "unit": "KG", + "unitPrice": 10.0, + "quantity": 333.4, + "amount": 3334.0, + } + ], + "airOptions": [ + { + "originAirportCode": "NKG", + "destinationAirportCode": "KUL", + "optionNo": "AIR-OPT01", + "salePrice": 3334.0, + "currency": "CNY", + "chargeableWeightKg": 333.4, + "departureCharges": [ + {"chargeName": "增点费", "unit": "点", "currency": "CNY", "unitPrice": 100}, + {"chargeName": "拆板费", "unit": "板数", "currency": "USD", "unitPrice": 100}, + ], + } + ], + "valid_until": "2026-10-15", + } + facts = {"originCode": "NKG", "destinationCode": "KUL"} + projected = project_group_air_quote(quote, facts) + names = [str(x.get("name") or "") for x in (projected.get("fee_lines") or [])] + self.assertEqual(names, ["空运费", "增点费", "拆板费"]) + brief = copy.air_group_brief( + work_order_no="WO202610150047", + facts=facts, + quote=quote, + collab_facts={}, + has_price=True, + tms_quote=quote, + ) + self.assertIn("空运费", brief) + self.assertIn("增点费", brief) + self.assertIn("拆板费", brief) + + def test_oms_injects_air_freight_when_fee_items_missing(self) -> None: + """落库后若顶层 feeItems 丢失,仅增点/拆板时仍用舱位总价补空运费。""" + quote = { + "total": "4303.0", + "currency": "CNY", + "remark": "空运报价来源:OMS空运表价;顶层价格为排序后的第一条直达航班方案", + "airOptions": [ + { + "originAirportCode": "NKG", + "destinationAirportCode": "KUL", + "optionNo": "AIR-OPT01", + "salePrice": 4303.0, + "currency": "CNY", + "chargeableWeightKg": 333.4, + "departureCharges": [ + {"chargeName": "增点费", "unit": "点", "currency": "CNY", "unitPrice": 100}, + {"chargeName": "拆板费", "unit": "板数", "currency": "USD", "unitPrice": 100}, + ], + } + ], + "valid_until": "2026-10-31", + } + facts = {"originCode": "NKG", "destinationCode": "KUL"} + projected = project_group_air_quote(quote, facts) + names = [str(x.get("name") or "") for x in (projected.get("fee_lines") or [])] + self.assertEqual(names[0], "空运费") + self.assertIn("增点费", names) + private = copy.sea_quote_detail_text( + work_order_no="WO202610170071", + quote=quote, + business_line="AIR", + facts=facts, + ) + self.assertIn("空运费", private) + self.assertIn("价格方案如下", private) + def test_air_detail_text_hides_eta_keeps_valid_until(self) -> None: """空运没有时效;价格明细只留报价有效期。""" text = copy.sea_quote_detail_text( diff --git a/inquiry-agent/tests/test_air_text_inquiry.py b/inquiry-agent/tests/test_air_text_inquiry.py index 095d726..92fcb8a 100644 --- a/inquiry-agent/tests/test_air_text_inquiry.py +++ b/inquiry-agent/tests/test_air_text_inquiry.py @@ -151,6 +151,38 @@ class AirTextInquiryTests(unittest.TestCase): land = validate_required_fields(facts={"运输类型": "国内整车"}, business_line="LAND") self.assertTrue(land["facts"].get("报价日期")) + def test_paste_hint_must_not_become_quote_date(self) -> None: + """ + WO202610170068:补问提示误写入报价日期后出「线路」。 + + 提示文案必须当空日期,建单摘要不能原样展示,normalize 后不能留在 facts。 + """ + from agent.llm.extract_text import _harvest_labels, clip_inquiry_value + from agent.schema.field_validate import is_blank_quote_date, normalize_facts + + hint = copy.CLARIFY_PASTE_HINT + self.assertTrue(is_blank_quote_date(hint)) + self.assertEqual(clip_inquiry_value(hint), "") + cleaned = normalize_facts({"报价日期": hint, "目的港": "吉隆坡"}) + self.assertNotIn("报价日期", cleaned) + squeezed = f"报价日期(非必填):{hint}\n件数(必填):10" + harvested = _harvest_labels(squeezed, ("报价日期", "件数")) + self.assertNotIn("报价日期", harvested) + card = copy.inquiry_card( + work_order_no="WO202610170068", + facts={ + "目的港": "吉隆坡", + "品名": "普货", + "件数": "10", + "毛重": "21", + "体积": "1.1*1.2*1.3", + "包装方式": "散货", + "报价日期": hint, + }, + ) + self.assertIn("报价日期(非必填):", card) + self.assertNotIn("请复制粘贴", card) + def test_fly_is_air_and_followup_keeps_mode(self) -> None: phase = self._on_text( sender_id="u_fly", @@ -906,7 +938,7 @@ class AirTextInquiryTests(unittest.TestCase): self.assertNotIn("工单状态", sub) self.assertTrue(sub.endswith(copy.collab_hint("WO202609140001"))) self.assertIn( - "请手动将相关方拉到空运原有协同群,并在空运协同群中发送“ WO202609140001@询价小助手”,谢谢。", + "请手动将相关方拉到空运原有协同群,并在空运协同群中发送“ WO202609140001@智报通AIQ”,谢谢。", sub, ) self.assertNotIn("内部协同", json.dumps(card, ensure_ascii=False)) diff --git a/inquiry-agent/tests/test_bot_name.py b/inquiry-agent/tests/test_bot_name.py new file mode 100644 index 0000000..a073dc9 --- /dev/null +++ b/inquiry-agent/tests/test_bot_name.py @@ -0,0 +1,84 @@ +"""企微机器人显示名:后台配置 + 新旧都认。""" + +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.policy.bot_name import ( + DEFAULT_BOT_DISPLAY_NAME, + REPLY_CODE, + at_bot, + bot_display_name, + bot_mention_names, + strip_bot_mention, + text_mentions_bot, +) +from agent.policy.bot_reply import override_catalog, render +from agent.policy import inquiry_copy as copy + + +class BotDisplayNameTests(unittest.TestCase): + def tearDown(self) -> None: + override_catalog(None) + + def test_default_when_empty(self) -> None: + override_catalog({}) + self.assertEqual(bot_display_name(), DEFAULT_BOT_DISPLAY_NAME) + self.assertEqual(at_bot(), f"@{DEFAULT_BOT_DISPLAY_NAME}") + self.assertIn("询价小助手", bot_mention_names()) + self.assertIn(DEFAULT_BOT_DISPLAY_NAME, bot_mention_names()) + + def test_override_from_admin(self) -> None: + override_catalog({REPLY_CODE: "测试助手名"}) + self.assertEqual(bot_display_name(), "测试助手名") + self.assertTrue(text_mentions_bot("@测试助手名 WO1")) + self.assertTrue(text_mentions_bot("@询价小助手 WO1")) + self.assertEqual(strip_bot_mention("@测试助手名 2"), "2") + self.assertEqual(strip_bot_mention("@询价小助手 2"), "2") + + def test_collab_hint_uses_display_name(self) -> None: + override_catalog({REPLY_CODE: "智报通AIQ"}) + hint = copy.collab_hint("WO202610060030") + self.assertIn("WO202610060030@智报通AIQ", hint) + self.assertNotIn("询价小助手", hint) + + def test_template_bot_name_placeholder(self) -> None: + override_catalog( + { + REPLY_CODE: "智报通AIQ", + "inquiring.air_collab_hint": "请发送 {{ticket_no}}@{{bot_name}}", + } + ) + self.assertEqual( + render("inquiring.air_collab_hint", "缺省", ticket_no="WO1"), + "请发送 WO1@智报通AIQ", + ) + + def test_legacy_and_new_strip(self) -> None: + override_catalog({REPLY_CODE: "智报通AIQ"}) + rest_old = strip_bot_mention("工单编号WO202610070026@询价小助手") + rest_new = strip_bot_mention("@智报通AIQ WO202610070026") + self.assertNotIn("询价小助手", rest_old) + self.assertNotIn("智报通AIQ", rest_new) + self.assertIn("WO202610070026", rest_old) + self.assertIn("WO202610070026", rest_new) + + def test_strip_wecom_lock_instruction(self) -> None: + """锁舱 instructionText 不得把 @机器人 原样带给 TMS。""" + override_catalog({REPLY_CODE: "智价通AIQ"}) + cleaned = strip_bot_mention("@智价通AIQ AIR-OPT01, TH, TH641") + self.assertEqual(cleaned, "AIR-OPT01, TH, TH641") + self.assertNotIn("@", cleaned) + self.assertNotIn("智价通AIQ", cleaned) + + +if __name__ == "__main__": + unittest.main() diff --git a/inquiry-agent/tests/test_bot_reply.py b/inquiry-agent/tests/test_bot_reply.py index d55be45..75dbcdc 100644 --- a/inquiry-agent/tests/test_bot_reply.py +++ b/inquiry-agent/tests/test_bot_reply.py @@ -31,6 +31,21 @@ class BotReplyRenderTests(unittest.TestCase): self.assertEqual(copy.ask_transport(), copy.ASK_TRANSPORT) self.assertEqual(copy.deal_ack("未成交", "WO1"), "已记录为「未成交」。请补充未成交原因。") + def test_multi_won_ack_has_no_lock_note(self) -> None: + """多段已成交只要短确认,不要锁舱括号说明。""" + override_catalog( + { + "deal.recorded_won": ( + "工单 {{ticket_no}} 已记录为「已成交」。" + "(注:已成交/未成交为工单终态,除航线人员可锁舱外请悉知。)" + ) + } + ) + text = copy.deal_ack("已成交", "WO202610170050", business_line="MULTI") + self.assertEqual(text, "工单 WO202610170050 已记录为「已成交」。") + self.assertNotIn("注", text) + self.assertNotIn("锁舱", text) + def test_override_first_chat(self) -> None: override_catalog({"inquiring.first_chat": "你好,测试问候。"}) self.assertEqual(copy.first_chat(), "你好,测试问候。") diff --git a/inquiry-agent/tests/test_collab_watch_reconcile.py b/inquiry-agent/tests/test_collab_watch_reconcile.py new file mode 100644 index 0000000..17a2766 --- /dev/null +++ b/inquiry-agent/tests/test_collab_watch_reconcile.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- +"""协同群监听对账:补挂与清孤儿。""" + +from __future__ import annotations + +import unittest +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + + +class CollabWatchReconcileTests(unittest.TestCase): + def test_rewatch_open_missing_from_watch(self) -> None: + import agent.jobs.collab_watch_reconcile as mod + + book = MagicMock() + book.list_open_collab.return_value = [ + { + "work_order_no": "WO1", + "collab_chat_id": "wr_open", + "collab_ended": False, + "collab_member_ids": ["A", "B"], + } + ] + with ( + patch.object(mod, "_try_lock", return_value=True), + patch("agent.channel.wshoto.rooms.list_watched_rooms", return_value=[]), + patch("agent.channel.wshoto.rooms.watch_collab_room") as watch, + patch("agent.channel.wshoto.rooms.unwatch_collab_room") as unwatch, + patch("agent.channel.wshoto.rooms.ticket_no_for_room", return_value=""), + patch.object(mod, "_group_client", return_value=MagicMock()), + ): + stats = mod.reconcile_collab_watch( + ledger=book, + settings=SimpleNamespace(ledger_backend="http"), + dismiss=False, + ) + self.assertEqual(stats["rewatched"], 1) + watch.assert_called_with("wr_open", work_order_no="WO1") + unwatch.assert_not_called() + + def test_dismiss_ended_in_watch(self) -> None: + import agent.jobs.collab_watch_reconcile as mod + + book = MagicMock() + book.list_open_collab.return_value = [ + { + "work_order_no": "WO2", + "collab_chat_id": "wr_end", + "collab_ended": True, + "collab_member_ids": ["A", "owner"], + } + ] + client = MagicMock() + client._appchat_info.return_value = { + "owner": "owner", + "userlist": ["A", "owner"], + } + with ( + patch.object(mod, "_try_lock", return_value=True), + patch("agent.channel.wshoto.rooms.list_watched_rooms", return_value=["wr_end"]), + patch("agent.channel.wshoto.rooms.watch_collab_room"), + patch("agent.channel.wshoto.rooms.unwatch_collab_room") as unwatch, + patch("agent.channel.wshoto.rooms.ticket_no_for_room", return_value="WO2"), + patch.object(mod, "_group_client", return_value=client), + patch("agent.policy.inquiry_copy.group_dissolved", return_value="群已解散"), + ): + stats = mod.reconcile_collab_watch( + ledger=book, + settings=SimpleNamespace(ledger_backend="http"), + dismiss=True, + ) + self.assertEqual(stats["dismissed"], 1) + client.dismiss_group.assert_called() + unwatch.assert_called_with("wr_end") + + def test_list_empty_does_not_orphan_dismiss_live_redis_wo(self) -> None: + """listOpen 空 + byChat 失败时,Redis 仍有活单工单号 → 禁止清人。""" + import agent.jobs.collab_watch_reconcile as mod + + book = MagicMock() + book.list_open_collab.return_value = [] + book.find_by_collab_chat.return_value = None + book.get_ticket.return_value = { + "work_order_no": "WO202610030039", + "collab_ended": False, + "collab_chat_id": "wr_live", + "collab_member_ids": ["WuJiLin", "LinMianXun"], + } + client = MagicMock() + client._appchat_info.return_value = { + "owner": "WuJiLin", + "userlist": ["WuJiLin", "LinMianXun", "AI001"], + } + with ( + patch.object(mod, "_try_lock", return_value=True), + patch("agent.channel.wshoto.rooms.list_watched_rooms", return_value=["wr_live"]), + patch("agent.channel.wshoto.rooms.watch_collab_room"), + patch("agent.channel.wshoto.rooms.unwatch_collab_room") as unwatch, + patch( + "agent.channel.wshoto.rooms.ticket_no_for_room", + return_value="WO202610030039", + ), + patch.object(mod, "_group_client", return_value=client), + ): + stats = mod.reconcile_collab_watch( + ledger=book, + settings=SimpleNamespace(ledger_backend="http"), + dismiss=True, + ) + self.assertEqual(stats["dismissed"], 0) + client.dismiss_group.assert_not_called() + unwatch.assert_not_called() + self.assertGreaterEqual(stats["skipped"], 1) + + def test_list_none_blocks_orphan_without_redis_wo(self) -> None: + """listOpen 失败(None) 且无 Redis 工单映射时,也不做孤儿清人。""" + import agent.jobs.collab_watch_reconcile as mod + + book = MagicMock() + book.list_open_collab.return_value = None + book.find_by_collab_chat.return_value = None + client = MagicMock() + client._appchat_info.return_value = { + "owner": "A", + "userlist": ["A", "B"], + } + with ( + patch.object(mod, "_try_lock", return_value=True), + patch("agent.channel.wshoto.rooms.list_watched_rooms", return_value=["wr_x"]), + patch("agent.channel.wshoto.rooms.watch_collab_room"), + patch("agent.channel.wshoto.rooms.unwatch_collab_room") as unwatch, + patch("agent.channel.wshoto.rooms.ticket_no_for_room", return_value=""), + patch.object(mod, "_group_client", return_value=client), + ): + stats = mod.reconcile_collab_watch( + ledger=book, + settings=SimpleNamespace(ledger_backend="http"), + dismiss=True, + ) + self.assertEqual(stats["dismissed"], 0) + client.dismiss_group.assert_not_called() + unwatch.assert_not_called() + self.assertGreaterEqual(stats["skipped"], 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/inquiry-agent/tests/test_continue_ticket.py b/inquiry-agent/tests/test_continue_ticket.py index 07efa4a..0c3bb9a 100644 --- a/inquiry-agent/tests/test_continue_ticket.py +++ b/inquiry-agent/tests/test_continue_ticket.py @@ -265,6 +265,64 @@ class ContinueTicketTests(unittest.TestCase): self.assertTrue(any("已记录为「已成交」" in x for x in replies)) self.assertIn(wo, "\n".join(replies)) + def test_resume_lost_with_reason_replays_closed_ack(self) -> None: + """未成交且原因已记:继续工单只重播关单回执,不再要原因、不问运输方式。""" + from agent.policy.bot_reply import override_catalog + + ledger = MemoryLedger() + created = ledger.create_ticket( + sender_id="u-lost-done", business_line="AIR", facts=COMPLETE_AIR + ) + wo = created["work_order_no"] + ledger.upsert_quote( + work_order_no=wo, + quote={"total": "USD 1", "eta": "-", "valid_until": "-"}, + to_status="已报价", + ) + ledger.render_quote( + work_order_no=wo, + business_line="AIR", + facts=COMPLETE_AIR, + quote={"total": "USD 1"}, + ) + ledger.transition( + work_order_no=wo, to_status="未成交", lost_reason="客人比价了" + ) + override_catalog( + { + "lost.reason_ack": ( + "已将未成交原因“{{lost_reason}}”记录在后台," + "工单 {{ticket_no}}已关闭,智能体不再跟进该工单,请悉知。" + ) + } + ) + flow = AirTextInquiryFlow(ledger) + replies: list[str] = [] + + def reply(text: str, extra=None) -> None: + replies.append(text) + _ = extra + + try: + phase = flow.resume_historical( + sender_id="u-lost-done", + work_order_no=wo, + reply=reply, + text=f"继续工单{wo}", + ) + finally: + override_catalog(None) + self.assertEqual(phase, "done") + self.assertEqual( + replies, + [ + f"已将未成交原因“客人比价了”记录在后台," + f"工单 {wo}已关闭,智能体不再跟进该工单,请悉知。" + ], + ) + self.assertFalse(any("请补充未成交原因" in x for x in replies)) + self.assertFalse(any("运输方式" in x for x in replies)) + def test_resume_waiting_lost_reason_saves_sentence(self) -> None: """等未成交原因时继续工单,下一句要写入原因,不能再要一遍。""" ledger = MemoryLedger() diff --git a/inquiry-agent/tests/test_deal_followup.py b/inquiry-agent/tests/test_deal_followup.py index 3cb62f1..5c79149 100644 --- a/inquiry-agent/tests/test_deal_followup.py +++ b/inquiry-agent/tests/test_deal_followup.py @@ -154,6 +154,25 @@ class TestLeaderMatch(unittest.TestCase): facts=facts, ) ) + # 后台现网选项是「空运全线」,不能只认旧词「全线」 + self.assertTrue( + leader_matches( + role_code="air", + routes="空运全线", + is_leader=True, + business_line="AIR", + facts=facts, + ) + ) + self.assertTrue( + leader_matches( + role_code="land", + routes="陆运全线", + is_leader=True, + business_line="LAND", + facts={"线路类别": "东南亚"}, + ) + ) self.assertFalse( leader_matches( role_code="air", diff --git a/inquiry-agent/tests/test_land_field_validate.py b/inquiry-agent/tests/test_land_field_validate.py index 77a5c46..d6b11e3 100644 --- a/inquiry-agent/tests/test_land_field_validate.py +++ b/inquiry-agent/tests/test_land_field_validate.py @@ -12,7 +12,13 @@ ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) if ROOT not in sys.path: sys.path.insert(0, ROOT) -from agent.schema.field_validate import display_name, validate_required_fields +from agent.schema.field_validate import ( + display_name, + harvest_oral_land_vehicle, + promote_land_vehicle_from_container, + shape_facts_for_chosen_mode, + validate_required_fields, +) class LandFieldValidateTest(unittest.TestCase): @@ -83,6 +89,26 @@ class LandFieldValidateTest(unittest.TestCase): self.assertTrue(got["ok"]) self.assertEqual(got["facts"]["车型/数量"], "9米6/1") + def test_harvest_45hq_xiangche(self): + """口语 45HQ(13.5厢车)必须写入车型/数量。""" + text = ( + "陆运,苏州市到谅山,中越整车,通信机柜及线缆配件," + "45HQ(13.5厢车),9.11" + ) + got = harvest_oral_land_vehicle(text, {}) + self.assertIn("45HQ", got.get("车型/数量", "")) + self.assertIn("厢车", got.get("车型/数量", "")) + + def test_promote_container_to_land_vehicle(self): + """陆运误入箱型箱量时改写到车型/数量,收口后仍保留。""" + promoted = promote_land_vehicle_from_container( + {"箱型箱量": "45HQ(13.5厢车)", "品名": "机柜"} + ) + self.assertEqual(promoted.get("车型/数量"), "45HQ(13.5厢车)") + shaped = shape_facts_for_chosen_mode(promoted, "LAND") + self.assertEqual(shaped.get("车型/数量"), "45HQ(13.5厢车)") + self.assertFalse(shaped.get("箱型箱量")) + def test_quote_date_defaults(self): got = validate_required_fields( facts={ diff --git a/inquiry-agent/tests/test_land_options.py b/inquiry-agent/tests/test_land_options.py index 60502e0..b3d290c 100644 --- a/inquiry-agent/tests/test_land_options.py +++ b/inquiry-agent/tests/test_land_options.py @@ -44,6 +44,12 @@ class LandOptionsTest(unittest.TestCase): self.assertFalse(cur.get("线路类别")) self.assertEqual(cur.get("运输分类"), "急件") + def test_cn_vn_ftl_spoken_alias(self): + """中越整车 → 跨境整车 + 东南亚,不能落成国内拼车。""" + cur = harvest_land_options("中越整车") + self.assertEqual(cur.get("运输类型"), "跨境整车") + self.assertEqual(cur.get("线路类别"), "东南亚") + def test_hk_full_and_lcl_types(self): self.assertEqual( harvest_land_options("中港整车")["运输类型"], "中港整车" diff --git a/inquiry-agent/tests/test_multi_group.py b/inquiry-agent/tests/test_multi_group.py index bdae173..0d8ec26 100644 --- a/inquiry-agent/tests/test_multi_group.py +++ b/inquiry-agent/tests/test_multi_group.py @@ -130,6 +130,7 @@ class QuoteTests(unittest.TestCase): self.assertEqual(phase, "multi_deal_sent") self.assertEqual(self.sheets, ["SEA", "LAND"]) body = self.client.texts[-1] + self.assertTrue(body.startswith("@销售甲"), body) self.assertIn("段1:海运", body) self.assertIn("段2:陆运", body) self.assertNotIn("180", body) @@ -138,7 +139,8 @@ class QuoteTests(unittest.TestCase): joined = " ".join(row["value"] for row in rows) self.assertNotIn("180", joined) - def test_land_person_cannot_fill_sea_segment(self) -> None: + def test_fee_name_routes_to_matching_segment(self) -> None: + """正文写海运费就归海运段,不看岗位是不是陆运。""" phase = handle_multi_inbound( flow=self.flow, ticket=self.ticket, @@ -148,7 +150,66 @@ class QuoteTests(unittest.TestCase): sheet_fn=self.sheet, ) self.assertEqual(phase, "segment_quoted") - self.assertEqual(self.sheets, ["LAND"]) + self.assertEqual(self.sheets, ["SEA"]) + + def test_multi_file_archives_by_filename_mode(self) -> None: + """多段 Excel 按文件名归段并落附件,不再回「没有识别到报价」。""" + from agent.policy.multi_group_ops import apply_multi_file + + self.ledger.upsert_staff( + {"wecomId": "air1", "name": "航线甲", "roleCode": "air", "status": "active"} + ) + # 改成陆运+空运两段,文件名含空运。 + no = self.ticket.work_order_no + self.ledger.update_facts( + work_order_no=no, + facts={ + "segments_json": json.dumps( + [ + {"index": 1, "mode": "LAND", "facts": {"起运港": "北京"}}, + {"index": 2, "mode": "AIR", "facts": {"目的港": "北宁"}}, + ], + ensure_ascii=False, + ) + }, + ) + ticket = self.ledger.get_ticket(work_order_no=no) + phase = apply_multi_file( + flow=self.flow, + ticket=ticket, + chat_id="room-1", + sender_id="air1", + filename=f"{no}空运到港报价单.xlsx", + media={"media_id": "media-air-1"}, + ) + self.assertEqual(phase, "segment_quoted") + self.assertFalse(self.sheets) + self.assertTrue(any("报价附件已收到" in t for t in self.client.texts)) + self.assertFalse(any("没有识别到报价" in t for t in self.client.texts)) + + def test_segment_index_beats_wrong_role(self) -> None: + """岗位是空运,正文写段1陆运费 → 仍归陆运段,金额取 1000 不是段号 1。""" + from agent.llm.extract_text import extract_product_quote + from agent.policy.multi_group_ops import resolve_quote_segment + from agent.policy.multi_segments import Segment + + segs = [ + Segment(index=1, mode="LAND", facts={}), + Segment(index=2, mode="AIR", facts={}), + ] + target, reason = resolve_quote_segment("段1:陆运费 CNY1000", segs, "AIR") + self.assertIsNotNone(target) + assert target is not None + self.assertEqual(target.mode, "LAND") + self.assertEqual(target.index, 1) + self.assertTrue(reason.startswith("text_index")) + quote = extract_product_quote( + "@智价通AIQ 段1:陆运费 CNY1000", + business_line="LAND", + transport_mode="陆运", + ) + self.assertEqual(float(quote.get("total") or 0), 1000.0) + self.assertEqual(str((quote.get("fee_rows") or [{}])[0].get("name") or ""), "陆运费") def test_requote_refreshes_deal(self) -> None: handle_multi_inbound( diff --git a/inquiry-agent/tests/test_sea_options.py b/inquiry-agent/tests/test_sea_options.py index 150396e..e69de29 100644 --- a/inquiry-agent/tests/test_sea_options.py +++ b/inquiry-agent/tests/test_sea_options.py @@ -1,120 +0,0 @@ -""" -海运运输分类:只认 Port to Port、其他。 -""" - -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.schema.sea_options import ( - canonicalize_sea_class, - drop_invented_door_trade_terms, - harvest_sea_class, - sea_class_prompt, -) - - -class SeaOptionsTest(unittest.TestCase): - def test_port_to_port_aliases(self): - self.assertEqual(canonicalize_sea_class("Port to Port"), "Port to Port") - self.assertEqual(canonicalize_sea_class("port to port"), "Port to Port") - self.assertEqual(canonicalize_sea_class("P2P"), "Port to Port") - self.assertEqual(canonicalize_sea_class("港到港"), "Port to Port") - glued = "15件,Port\u00a0to\u00a0Port\n贸易条款:DOOR-DOOR" - self.assertEqual(canonicalize_sea_class(glued), "Port to Port") - self.assertEqual(harvest_sea_class(glued, {}).get("运输分类"), "Port to Port") - - def test_other_alias(self): - self.assertEqual(canonicalize_sea_class("其他"), "其他") - self.assertEqual(canonicalize_sea_class("其它"), "其他") - - def test_door_to_door_rejected(self): - self.assertEqual(canonicalize_sea_class("门到门"), "") - - def test_harvest_keeps_current_when_text_has_none(self): - got = harvest_sea_class("上海到洛杉矶整柜", {"运输分类": "Port to Port"}) - self.assertEqual(got["运输分类"], "Port to Port") - - def test_harvest_overrides_from_text(self): - got = harvest_sea_class("运输分类:其他", {"运输分类": "Port to Port"}) - self.assertEqual(got["运输分类"], "其他") - - def test_clarify_example_does_not_override_filled_other(self): - """举例行里的 Port to Port 不能盖掉冒号后的其它/其他。""" - pasted = "1.运输分类(必填):其它\n(如:Port to Port、其他)" - got = harvest_sea_class(pasted, {"运输分类": "Port to Port"}) - self.assertEqual(got["运输分类"], "其他") - other = harvest_sea_class("运输分类(必填):其他\n(如:Port to Port、其他)", {}) - self.assertEqual(other["运输分类"], "其他") - - def test_door_to_door_not_kept_as_trade_terms(self): - text = "海运、纽约港到巴生西港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件" - got = drop_invented_door_trade_terms(text, {"贸易条款": "门到门", "品名": "机器设备"}) - self.assertNotIn("贸易条款", got) - kept = drop_invented_door_trade_terms("贸易条款:门到门", {"贸易条款": "门到门"}) - self.assertEqual(kept["贸易条款"], "门到门") - - def test_short_reply_door_or_port_is_trade_term(self): - from agent.schema.sea_options import apply_spoken_sea_trade_reply - - door = apply_spoken_sea_trade_reply("门到门", {}) - self.assertEqual(door.get("贸易条款"), "门到门") - port = apply_spoken_sea_trade_reply("港到港", {"运输分类": "Port to Port"}) - self.assertEqual(port.get("贸易条款"), "港到港") - self.assertEqual(port.get("运输分类"), "Port to Port") - example = apply_spoken_sea_trade_reply("(如:门到门、港到港)", {}) - self.assertNotIn("贸易条款", example) - - def test_door_inside_long_sentence_is_trade_term(self): - """先报货再选海运:长句里的门到门是贸易条款,港到港仍是运输分类。""" - from agent.schema.sea_options import apply_spoken_sea_trade_reply - - text = "广州港到巴生港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件,Port to Port,门到门" - got = apply_spoken_sea_trade_reply(text, {"运输分类": "Port to Port"}) - self.assertEqual(got.get("贸易条款"), "门到门") - self.assertEqual(got.get("运输分类"), "Port to Port") - absent = apply_spoken_sea_trade_reply( - "广州港到巴生港,机器设备,40HQ*1,整柜,Port to Port", - {"贸易条款": "门到门", "运输分类": "Port to Port"}, - ) - self.assertNotIn("贸易条款", absent) - self.assertEqual(absent.get("运输分类"), "Port to Port") - port_term = apply_spoken_sea_trade_reply( - "广州港到巴生港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件,Port to Port,港到港", - {"运输分类": "Port to Port"}, - ) - self.assertEqual(port_term.get("贸易条款"), "港到港") - self.assertEqual(port_term.get("运输分类"), "Port to Port") - invented = apply_spoken_sea_trade_reply( - "广州港到巴生港,机器设备,40HQ*1,整柜,Port to Port", - {"贸易条款": "港到港"}, - ) - self.assertNotIn("贸易条款", invented) - - def test_wo012_trade_port_class_other(self): - """条款写 Port to Port、分类写其它:二者各归各栏,禁止互盖。""" - text = ( - "运输方式:海运\n起运港:纽约港\n目的港:巴生西港\n品名:机器设备\n" - "货物数量:15件\n整柜或拼柜:整柜\n箱型箱量:40HQ*1\n" - "贸易条款:Port to Port\n运输分类:其它\n报价日期:2026-09-29" - ) - got = harvest_sea_class(text, {"贸易条款": "门到门", "运输分类": "Port to Port"}) - self.assertEqual(got.get("运输分类"), "其他") - got = drop_invented_door_trade_terms(text, {"贸易条款": "门到门", "运输分类": "其他"}) - self.assertEqual(got.get("贸易条款"), "Port to Port") - self.assertEqual(got.get("运输分类"), "其他") - - def test_trade_line_port_does_not_set_class_when_class_labeled_other(self): - text = "贸易条款:Port to Port\n运输分类:其它" - self.assertEqual(harvest_sea_class(text, {}).get("运输分类"), "其他") - - - -if __name__ == "__main__": - unittest.main() diff --git a/inquiry-agent/tests/test_sea_text_inquiry.py b/inquiry-agent/tests/test_sea_text_inquiry.py index db7d560..7b4cc1c 100644 --- a/inquiry-agent/tests/test_sea_text_inquiry.py +++ b/inquiry-agent/tests/test_sea_text_inquiry.py @@ -1970,6 +1970,30 @@ class SeaGroupOpsTests(unittest.TestCase): ticket = self.ledger.get_ticket(work_order_no=wo) self.assertNotIn("客户名称", ticket.collab_facts or {}) + def test_handoff_group_still_allows_dissolve(self) -> None: + """转人工后发解散群仍要清人,不能静默丢掉。""" + wo, chat = self._open_group() + self.ledger.transition(work_order_no=wo, to_status="转人工") + sess = self.flow.session_of("sales1") + sess.status = "转人工" + sess.handoff_committed = True + self.flow._save(sess) + phase = handle_group_collab( + InboundMessage( + sender_id="sales1", + message_id="h-dis1", + content="解散群", + chat_id=chat, + chat_type="group", + ), + flow=self.flow, + ledger=self.ledger, + injected_intent="dissolve", + ) + self.assertEqual(phase, "group_dissolved") + self.assertTrue(any(x.get("kind") == "dismiss" for x in self.groups.sent)) + self.assertTrue(self.ledger.get_ticket(work_order_no=wo).collab_ended) + def test_dissolve_then_cannot_recreate(self) -> None: wo, chat = self._open_group() phase = handle_group_collab( diff --git a/inquiry-agent/tests/test_tms_land_query.py b/inquiry-agent/tests/test_tms_land_query.py index a5ce6e0..e69de29 100644 --- a/inquiry-agent/tests/test_tms_land_query.py +++ b/inquiry-agent/tests/test_tms_land_query.py @@ -1,107 +0,0 @@ -""" -陆运 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) - -from agent.schema.tms_land_query import assemble_land_query - - -class TmsLandQueryTest(unittest.TestCase): - def test_outbound_omits_class_and_hk_load(self): - facts = { - "运输类型": "中港零担/集拼", - "线路类别": "东南亚", - "运输分类": "急件", - "起运港": "广州", - "目的港": "香港", - "品名": "衣服", - "车型/数量": "9米6/1", - "通关口岸": "皇岗", - } - got = assemble_land_query(facts) - self.assertTrue(got["ok"]) - out = got["outbound"] - self.assertEqual(out["landTransportType"], "中港") - self.assertEqual(out["routeCategory"], "SOUTHEAST_ASIA") - self.assertNotIn("landRouteCategory", out) - self.assertEqual(out["vehicleType"], "9米6") - self.assertEqual(str(out["quantity"]), "1") - self.assertEqual(out["customsPort"], "皇岗") - self.assertNotIn("运输分类", out) - self.assertNotIn("landClass", out) - self.assertNotIn("hkLoadType", out) - self.assertNotIn("中港装载方式", out) - - def test_hk_full_truck_omits_vehicle(self): - facts = { - "运输类型": "中港整车", - "线路类别": "东南亚", - "起运港": "广州", - "目的港": "香港", - "品名": "衣服", - } - got = assemble_land_query(facts) - self.assertTrue(got["ok"]) - out = got["outbound"] - self.assertEqual(out["landTransportType"], "中港") - self.assertNotIn("vehicleType", out) - self.assertNotIn("quantity", out) - - def test_domestic_ftl_splits_combo(self): - facts = { - "运输类型": "国内运输整车", - "线路类别": "国内长途/零担", - "起运港": "广州", - "目的港": "深圳", - "品名": "衣服", - "车型/数量": "9米6*2", - } - out = assemble_land_query(facts)["outbound"] - self.assertEqual(out["vehicleType"], "9米6") - self.assertEqual(out["quantity"], "2") - - def test_domestic_has_no_customs(self): - facts = { - "运输类型": "国内运输拼车", - "线路类别": "国内长途/零担", - "起运港": "广州", - "目的港": "深圳", - "品名": "衣服", - "体积": "100CBM", - "毛重": "50kg", - } - out = assemble_land_query(facts)["outbound"] - self.assertNotIn("customsPort", out) - self.assertIn("volumeCbm", out) - self.assertEqual(out["landTransportType"], "DOMESTIC_LTL") - self.assertEqual(out["routeCategory"], "DOMESTIC_LONG_LTL") - - def test_wo011_pinche_city_dist_uses_tms_enums(self): - facts = { - "运输类型": "国内运输拼车", - "线路类别": "国内城配/拖车/打包", - "起运港": "泉州市", - "目的港": "南京市", - "品名": "通信机柜及线缆配件", - "体积": "10CBM", - "毛重": "10KG", - } - got = assemble_land_query(facts) - self.assertTrue(got["ok"]) - out = got["outbound"] - self.assertEqual(out["landTransportType"], "DOMESTIC_LTL") - self.assertEqual(out["routeCategory"], "DOMESTIC_CITY_DIST") - self.assertNotIn("landRouteCategory", out) - - -if __name__ == "__main__": - unittest.main() diff --git a/inquiry-agent/tests/test_wshoto_archive.py b/inquiry-agent/tests/test_wshoto_archive.py index a87b186..b583fff 100644 --- a/inquiry-agent/tests/test_wshoto_archive.py +++ b/inquiry-agent/tests/test_wshoto_archive.py @@ -23,7 +23,7 @@ os.environ.setdefault("LLM_ALLOW_NETWORK", "false") os.environ.setdefault("LEDGER_BACKEND", "memory") from agent.channel.queue import MemoryMessageStore -from agent.channel.wshoto.client import wshoto_ready +from agent.channel.wshoto.client import WshotoError, wshoto_ready from agent.channel.wshoto.parse import archive_record_to_inbound from agent.channel.wshoto.rooms import ( list_watched_rooms, @@ -340,6 +340,56 @@ class WshotoPollTests(unittest.TestCase): self.assertEqual(api.calls[0].get("userid"), "") self.assertEqual(api.calls[0].get("target_id"), "wr_room_1") + def test_hot_room_polled_before_cold_and_rate_limit_stops(self) -> None: + """热群优先;遇 10061 本轮停;游标只推进已扫冷群,不跳过未扫到的。""" + from agent.channel.wshoto import rooms as rooms_mod + from agent.channel.wshoto.rooms import ( + load_poll_cursor, + mark_room_hot, + plan_rooms_for_poll, + ) + + redis = _redis() + for i in range(6): + watch_collab_room(f"wr_cold_{i}", redis=redis) + watch_collab_room("wr_hot_now", redis=redis) + # watch 会标热;单测只留目标热群,模拟「大量冷群 + 一个刚说话的群」。 + with rooms_mod._hot_lock: + rooms_mod._hot_until.clear() + mark_room_hot("wr_hot_now") + planned = plan_rooms_for_poll( + list_watched_rooms(redis=redis), + max_cold=2, + redis=redis, + ) + self.assertEqual(planned[0], "wr_hot_now") + self.assertLessEqual(len(planned), 3) + # 规划阶段不得推进游标。 + self.assertEqual(load_poll_cursor(redis=redis), 0) + + class _RateApi: + def __init__(self) -> None: + self.calls: list[dict] = [] + + def list_group_messages(self, **kwargs): + self.calls.append(kwargs) + if len(self.calls) >= 2: + raise WshotoError(10061, "请求频率过高") + return [] + + store = MemoryMessageStore() + api = _RateApi() + cfg = _Cfg() + cfg.wshoto_rooms_per_poll = 2 + cfg.wshoto_room_gap_ms = 0 + cfg.wshoto_poll_budget_sec = 30 + out = poll_once(client=api, settings=cfg, redis=redis, store=store) + self.assertTrue(out["ok"]) + self.assertEqual(api.calls[0].get("target_id"), "wr_hot_now") + self.assertLessEqual(len(api.calls), 2) + # 热群不算冷游标;限频停在第 1 个冷群之前或之上时,冷游标推进应 < 计划冷群数。 + self.assertLessEqual(load_poll_cursor(redis=redis), 1) + if __name__ == "__main__": unittest.main() diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryAgentController.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryAgentController.java index a24e656..81c9dea 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryAgentController.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryAgentController.java @@ -180,6 +180,18 @@ public class InquiryAgentController { return Result.OK(out); } + @ApiOperation("列出绑着协同群的工单(Worker 对账监听,只读)") + @PostMapping("/ticket/listOpenCollab") + public Result> listOpenCollab(HttpServletRequest http) { + if (!authed(http)) { + return Result.error(401, "unauthorized"); + } + Map out = new java.util.LinkedHashMap<>(); + out.put("ok", true); + out.put("tickets", workOrderService.listOpenCollabGroups()); + return Result.OK(out); + } + @ApiOperation("开始事件异常等待(不改六态)") @PostMapping("/ticket/startEventWait") public Result> startEventWait(@RequestBody Map body, @@ -257,8 +269,10 @@ public class InquiryAgentController { Map facts = asMap(body == null ? null : body.get("facts")); @SuppressWarnings("unchecked") Map quote = asMap(body == null ? null : body.get("quote")); + // 默认 false:激活发模版只发群、不进后台附件;确认落库由智能体显式传 true。 + boolean persistAttachment = boolFlag(body, "persistAttachment", "persist_attachment", false); Map rendered = quoteTemplateService.renderForAgent( - workOrderNo, businessLine, facts, quote, sales); + workOrderNo, businessLine, facts, quote, sales, persistAttachment); if (rendered != null && Boolean.TRUE.equals(rendered.get("ok"))) { workOrderService.markQuoteFileIssued(workOrderNo); } @@ -477,6 +491,16 @@ public class InquiryAgentController { return Result.OK(workOrderService.attachFile(body)); } + @ApiOperation("报价附件字节入库(PDF/补传,不改六态)") + @PostMapping("/ticket/attachFileBytes") + public Result> attachFileBytes(@RequestBody Map body, + HttpServletRequest http) { + if (!authed(http)) { + return Result.error(401, "unauthorized"); + } + return Result.OK(workOrderService.attachFileBytes(body)); + } + @ApiOperation("产品报价写入新版本") @PostMapping("/ticket/upsertProductQuote") public Result> upsertProductQuote(@RequestBody Map body, @@ -547,6 +571,39 @@ public class InquiryAgentController { return ""; } + /** + * 读布尔开关;缺省或无法解析时用 defaultValue。 + * 为何:激活发模版与确认落库共用 /quote/render,用显式标志区分是否写附件表。 + */ + private static boolean boolFlag(Map body, String key1, String key2, boolean defaultValue) { + if (body == null) { + return defaultValue; + } + for (String k : new String[] {key1, key2}) { + if (k == null || !body.containsKey(k)) { + continue; + } + Object v = body.get(k); + if (v == null) { + return defaultValue; + } + if (v instanceof Boolean) { + return (Boolean) v; + } + String s = String.valueOf(v).trim(); + if (s.isEmpty() || "null".equalsIgnoreCase(s)) { + return defaultValue; + } + if ("1".equals(s) || "true".equalsIgnoreCase(s) || "yes".equalsIgnoreCase(s)) { + return true; + } + if ("0".equals(s) || "false".equalsIgnoreCase(s) || "no".equalsIgnoreCase(s)) { + return false; + } + } + return defaultValue; + } + @SuppressWarnings("unchecked") private static Map asMap(Object raw) { if (raw instanceof Map) { diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryQuoteTemplateController.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryQuoteTemplateController.java index 7a3cd97..17fcd28 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryQuoteTemplateController.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryQuoteTemplateController.java @@ -258,9 +258,11 @@ public class InquiryQuoteTemplateController { MediaType media = name.endsWith(".png") ? MediaType.IMAGE_PNG : MediaType.APPLICATION_PDF; + // 禁止浏览器长缓存:拼接算法升级后旧「左右拼」图会被继续展示 return ResponseEntity.ok() .contentType(media) - .header(HttpHeaders.CACHE_CONTROL, "private, max-age=3600") + .header(HttpHeaders.CACHE_CONTROL, "no-store, no-cache, must-revalidate") + .header(HttpHeaders.PRAGMA, "no-cache") .body(new FileSystemResource(path)); } diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryWorkOrderController.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryWorkOrderController.java index 0868418..689ee8d 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryWorkOrderController.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryWorkOrderController.java @@ -102,15 +102,54 @@ public class InquiryWorkOrderController { @ApiOperation("聚合详情") @RequiresPermissions("inquiry:workorder:list") @GetMapping("/view/detail") - public Result> viewDetail(@RequestParam String workOrderNo) { + public Result> viewDetail( + @RequestParam(name = "workOrderNo") String workOrderNo) { return Result.OK(workOrderService.detailView(workOrderNo)); } + /** + * 工单附件下载:浏览器不直连内网 MinIO,一律经主账鉴权后流式写出。 + */ + @AutoLog(value = "工单-附件下载") + @ApiOperation("下载工单附件") + @RequiresPermissions("inquiry:workorder:list") + @GetMapping("/view/attachment/download") + public void downloadAttachment( + @RequestParam(name = "id") String id, + HttpServletResponse response) { + try { + workOrderService.downloadAttachment(id, response); + } catch (IllegalArgumentException | IllegalStateException e) { + try { + response.reset(); + response.setCharacterEncoding("UTF-8"); + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(404); + response.getWriter().write("{\"success\":false,\"message\":\"" + + String.valueOf(e.getMessage()).replace("\"", "'") + "\"}"); + } catch (Exception ignored) { + // 写错误响应失败时忽略 + } + } catch (Exception e) { + log.error("工单附件下载失败 id={}", id, e); + try { + response.reset(); + response.setCharacterEncoding("UTF-8"); + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(500); + response.getWriter().write("{\"success\":false,\"message\":\"下载失败\"}"); + } catch (Exception ignored) { + // ignore + } + } + } + @ApiOperation("按工单号查主表") @RequiresPermissions("inquiry:workorder:list") @GetMapping("/queryById") - public Result queryById(@RequestParam(required = false) String id, - @RequestParam(required = false) String workOrderNo) { + public Result queryById( + @RequestParam(name = "id", required = false) String id, + @RequestParam(name = "workOrderNo", required = false) String workOrderNo) { if (workOrderNo != null && !workOrderNo.isEmpty()) { Map detail = workOrderService.detailView(workOrderNo); return Result.OK((InquiryTicket) detail.get("ticket")); 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 99d9bf4..4ba06f4 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 @@ -98,6 +98,14 @@ public class InquiryTicket implements Serializable { @ApiModelProperty("报价信息(展示)") private List quotes; + /** + * 列表「附件」摘要。不入库,enrich 时按 inq_attachment 填。 + * 详情仍走 detailView 的完整 attachments 列表。 + */ + @TableField(exist = false) + @ApiModelProperty("附件元数据(展示)") + private List attachments; + @ApiModelProperty("成本价展示") private String costPrice; 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 2c7f416..00cfef9 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 @@ -113,8 +113,11 @@ public interface IInquiryQuoteTemplateService extends IService * 只填 Excel 并回文件字节,不转 PDF(LibreOffice 在智能体 Worker 单槽)。 * 失败 message 固定为 QuoteTemplateMatcher.FAIL_MESSAGE。 + * + * @param persistAttachment true 时登记 inq_attachment(确认报价落库等); + * false 只回文件供企微发出(激活发模版),不进后台附件列 */ Map renderForAgent(String workOrderNo, String businessLine, Map facts, Map quote, - String salesWecomId); + String salesWecomId, boolean persistAttachment); } 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 1280096..6b70628 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 @@ -36,6 +36,17 @@ public interface IInquiryWorkOrderService extends IService { */ Map detailView(String workOrderNo); + /** + * 后台下载工单附件:按附件 id 鉴权后从对象存储/本地流式写出。 + *

+ * 副作用:只写 HTTP 响应流,不改六态。禁止把内网 MinIO URL 直接暴露给浏览器。 + * + * @param attachmentId inq_attachment.id + * @param response HTTP 响应 + */ + void downloadAttachment(String attachmentId, javax.servlet.http.HttpServletResponse response) + throws java.io.IOException; + /** * 六态流转(含关单到「已关闭」)。 * @@ -141,6 +152,12 @@ public interface IInquiryWorkOrderService extends IService { */ Map attachFile(Map body); + /** + * 智能体上传报价附件字节(Excel/PDF)并登记 inq_attachment。 + * 用于 PDF 转换完成后落库;不改六态。 + */ + Map attachFileBytes(Map body); + /** * 手工报价 / 销售调价写入新版本。销售调价另记一条状态流转备注。 */ @@ -162,6 +179,12 @@ public interface IInquiryWorkOrderService extends IService { */ java.util.List> claimDueNegotiate(); + /** + * 列出仍绑着协同群 chat_id 的工单,供 Worker 对账监听名单。 + * 只读;含已结束协同(collab_ended),由智能体决定补挂或清人。 + */ + java.util.List> listOpenCollabGroups(); + /** * 开始盯事件异常等待。种类相同不重置起点;种类不同重置并清已告警。不改六态。 */ diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryDashboardServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryDashboardServiceImpl.java index 7da8911..7017fa4 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryDashboardServiceImpl.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryDashboardServiceImpl.java @@ -19,6 +19,7 @@ import org.springframework.stereotype.Service; import java.math.BigDecimal; import java.math.RoundingMode; +import java.text.NumberFormat; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; @@ -110,13 +111,15 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService { List> metricCards = new ArrayList<>(); // 顺序对齐原型 5 列栅格:上排总量/报价/TMS/销售额/手工,下排待处理/成功率/有效率/响应 - metricCards.add(card("inquiry", "询价总量", "周期内询价工单总数", String.valueOf(total), false)); + // 件数/金额带千分位;百分比与时长不加(产品:数字千分号,百分比除外) + metricCards.add(card("inquiry", "询价总量", "周期内询价工单总数", formatCount(total), false)); metricCards.add(card("quoted", "报价工单", "已报价工单数(已报价 + 议价中 + 已成交 + 未成交)", - quoted + " 单", false)); + formatCount(quoted) + " 单", false)); metricCards.add(card("tms", "标准报价命中率", "命中 TMS 标准价的询价数 ÷ 询价总数 × 100%", pct(tmsHit, total), false)); metricCards.add(card("avgSale", "平均报价销售额", "AVG(已勾选报价的销售价)", avgSale, false)); metricCards.add(card("manual", "手工工单占比", "人工报价的询价数 ÷ 询价总数 × 100%", pct(manual, total), false)); - metricCards.add(card("pending", "待处理工单", "工单总数 − 已成交 − 未成交 − 已关闭", pending + " 单", false)); + metricCards.add(card("pending", "待处理工单", "工单总数 − 已成交 − 未成交 − 已关闭", + formatCount(pending) + " 单", false)); metricCards.add(card("dealRate", "报价成功率", "已成交数 ÷ 已报价数 × 100%", pct(deal, Math.max(quoted, 1)), false)); long feedbackNumer = deal + lost; metricCards.add(card("feedback", "报价有效率", "(已成交 + 未成交)÷ 工单数 × 100%", pct(feedbackNumer, total), false)); @@ -515,16 +518,36 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService { return formatAvgSale(sum.divide(BigDecimal.valueOf(n), 2, RoundingMode.HALF_UP)); } - /** 平均销售额展示:整数不留小数,否则最多两位。 */ + /** + * 件数千分位(zh-CN),如 12800 → 12,800。 + * 百分比、时长不要走这里。 + */ + static String formatCount(long n) { + NumberFormat nf = NumberFormat.getIntegerInstance(Locale.CHINA); + nf.setGroupingUsed(true); + return nf.format(n); + } + + /** + * 平均销售额展示:千分位;整数不留小数,否则两位。 + * 例:742187952.33 → 742,187,952.33。 + */ static String formatAvgSale(BigDecimal avg) { if (avg == null) { return "-"; } + NumberFormat nf = NumberFormat.getNumberInstance(Locale.CHINA); + nf.setGroupingUsed(true); BigDecimal stripped = avg.stripTrailingZeros(); if (stripped.scale() <= 0) { - return stripped.toPlainString(); + nf.setMaximumFractionDigits(0); + nf.setMinimumFractionDigits(0); + return nf.format(stripped); } - return avg.setScale(2, RoundingMode.HALF_UP).toPlainString(); + BigDecimal scaled = avg.setScale(2, RoundingMode.HALF_UP); + nf.setMinimumFractionDigits(2); + nf.setMaximumFractionDigits(2); + return nf.format(scaled); } private static String firstNonBlank(String a, String b) { 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 f90085b..9ba5264 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 @@ -12,16 +12,19 @@ import lombok.extern.slf4j.Slf4j; import org.apache.commons.codec.digest.DigestUtils; import org.apache.commons.lang3.StringUtils; import org.jeecg.common.util.CommonUtils; +import org.jeecg.modules.inquiry.entity.InquiryAttachment; import org.jeecg.modules.inquiry.entity.InquiryQuoteTemplate; import org.jeecg.modules.inquiry.entity.InquiryQuoteTemplateVersion; import org.jeecg.modules.inquiry.entity.InquiryStaffRole; import org.jeecg.modules.inquiry.entity.InquiryTicket; +import org.jeecg.modules.inquiry.mapper.InquiryAttachmentMapper; import org.jeecg.modules.inquiry.mapper.InquiryQuoteTemplateMapper; import org.jeecg.modules.inquiry.mapper.InquiryQuoteTemplateVersionMapper; import org.jeecg.modules.inquiry.mapper.InquiryStaffRoleMapper; import org.jeecg.modules.inquiry.mapper.InquiryTicketMapper; import org.jeecg.modules.inquiry.support.InquiryQuoteTemplateBizScope; import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport; +import org.jeecg.modules.inquiry.support.InquiryWorkOrderAttachmentStore; import org.jeecg.modules.inquiry.service.IInquiryQuoteTemplateService; import org.jeecg.modules.inquiry.template.QuoteTemplateAiParseClient; import org.jeecg.modules.inquiry.template.QuoteTemplateExcelSupport; @@ -80,6 +83,12 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl 0) { return cachedPng; } @@ -752,8 +761,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl - * 副作用:写生成文件到 upload/inquiry/quote-render;成功则模板 useCount+1。 + * 副作用:写生成文件到 upload/inquiry/quote-render;成功则模板 useCount+1; + * 仅 persistAttachment=true 时 insert inq_attachment(确认落库)。 + * 激活发群模版传 false,避免后台附件列堆满空白报价单模版。 * 线程:跟随 HTTP 请求线程,禁止在此调 LibreOffice。 */ @Override public Map renderForAgent(String workOrderNo, String businessLine, Map facts, Map quote, - String salesWecomId) { + String salesWecomId, boolean persistAttachment) { Map fail = new LinkedHashMap<>(); fail.put("ok", false); fail.put("error", "NO_TEMPLATE"); @@ -826,6 +837,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl picked = QuoteTemplateMatcher.pick(enabled, businessLine, facts, quote); if (picked == null) { log.warn("报价出单无模板 wo={} biz={}", workOrderNo, businessLine); + fail.put("failStage", "PICK_NULL"); return fail; } String objectKey = String.valueOf(picked.getOrDefault("objectKey", "")); @@ -833,6 +845,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl 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")); - // 销售看到的名字:工单号 + 后台模板名,不带后缀。磁盘路径仍是带时间戳的 xlsx。 - ok.put("fileName", QuoteSheetFileName.of(safeNo, String.valueOf(picked.getOrDefault("name", "")))); + ok.put("fileName", sheetBase); + ok.put("objectKey", renderedObjectKey); + ok.put("filePath", target.toAbsolutePath().toString()); ok.put("fileBase64", java.util.Base64.getEncoder().encodeToString(bytes)); ok.put("fileSize", bytes.length); + ok.put("attachmentSaved", attached); + ok.put("persistAttachmentRequested", persistAttachment); return ok; } catch (Exception e) { log.error("报价出单填表失败 wo={} template={}", workOrderNo, picked.get("templateId"), e); fail.put("error", "FILL_FAILED"); + fail.put("failStage", "FILL_FAILED"); + fail.put("templateId", picked.get("templateId")); return fail; } } + /** + * 确认报价等场景:把填好的 Excel 登记到 inq_attachment。 + *

+ * 副作用:insert inq_attachment。失败只记日志,不挡企微发文件。 + * 激活发群模版不要调这里——后台附件应只收航线/产品上传与确认落库文件。 + * + * @return true 已插入;false 跳过(无工单/无 Mapper/异常) + */ + private boolean persistRenderedAttachment(String workOrderNo, String fileName, + String objectKey, long fileSize) { + if (attachmentMapper == null || ticketMapper == null) { + return false; + } + if (StringUtils.isBlank(workOrderNo) || StringUtils.isBlank(objectKey)) { + return false; + } + try { + InquiryTicket ticket = ticketMapper.selectOne(new LambdaQueryWrapper() + .eq(InquiryTicket::getWorkOrderNo, workOrderNo) + .last("LIMIT 1")); + if (ticket == null) { + log.warn("出单附件登记跳过:工单不存在 wo={}", workOrderNo); + return false; + } + // 同一 objectKey 不重复插,避免 render 重试刷出多行。 + Long dup = attachmentMapper.selectCount(new LambdaQueryWrapper() + .eq(InquiryAttachment::getWorkOrderNo, workOrderNo) + .eq(InquiryAttachment::getObjectKey, objectKey)); + if (dup != null && dup > 0) { + return true; + } + InquiryAttachment row = new InquiryAttachment(); + row.setTicketId(ticket.getId()); + row.setWorkOrderNo(workOrderNo); + row.setFileName(fileName); + row.setObjectKey(objectKey); + row.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + row.setFileSize(fileSize); + row.setCreateTime(new Date()); + attachmentMapper.insert(row); + return true; + } catch (Exception e) { + log.warn("出单附件登记失败 wo={} err={}", workOrderNo, e.getMessage()); + return false; + } + } + /** * 把询价中文键与报价费用投影成合同 systemFields,映射点两边都能取到。 */ @@ -926,7 +1006,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl= 10 ? factQuote.substring(0, 10) : factQuote); + } else if (StringUtils.isNotBlank(created)) { out.put("quote_date", created.length() >= 10 ? created.substring(0, 10) : created); - // #region agent log - try { - java.nio.file.Path dbg = java.nio.file.Paths.get( - "C:/Users/ytd_extdev_0903/incoming/debug-717349.log"); - String line = "{\"sessionId\":\"717349\",\"runId\":\"post-fix\",\"hypothesisId\":\"T1\"," - + "\"location\":\"InquiryQuoteTemplateServiceImpl.buildFillFacts\"," - + "\"message\":\"template quote_date from create\"," - + "\"data\":{\"wo\":\"" + StringUtils.defaultString(workOrderNo) - + "\",\"quote_date\":\"" + out.getString("quote_date") - + "\",\"fact_quote\":\"" + StringUtils.defaultString(first(src, "报价日期", "quote_date")) - + "\"},\"timestamp\":" + System.currentTimeMillis() + "}\n"; - java.nio.file.Files.write(dbg, line.getBytes(java.nio.charset.StandardCharsets.UTF_8), - java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.APPEND); - } catch (Exception ignore) { - // 调试埋点失败不影响出单 - } - // #endregion } else { - out.put("quote_date", first(src, "报价日期", "quote_date", "询价日期", "走货日期", "询价时间", - "走货时间", "发货时间", "发货日期", "出货日期", "出货时间", "提货时间", "提货日期")); + out.put("quote_date", ""); } out.put("trade_terms", first(src, "贸易条款", "trade_terms")); out.put("运输分类", first(src, "运输分类")); 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 5c0bcb4..4f7f5ce 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 @@ -27,11 +27,13 @@ import org.jeecg.modules.inquiry.mapper.InquiryTicketStatusLogMapper; import org.jeecg.modules.inquiry.support.AgentSystemExceptionRetryClient; import org.jeecg.modules.inquiry.service.IInquiryWorkOrderService; import org.jeecg.modules.inquiry.support.InquiryDataScopeSupport; +import org.jeecg.modules.inquiry.support.InquiryMultiSegmentSupport; import org.jeecg.modules.inquiry.support.InquiryQuoteDisplaySupport; import org.jeecg.modules.inquiry.support.InquiryQuoteFileSupport; import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport; import org.jeecg.modules.inquiry.support.InquiryTicketFactsSupport; import org.jeecg.modules.inquiry.support.InquiryTicketPriceSupport; +import org.jeecg.modules.inquiry.support.InquiryWorkOrderAttachmentStore; import org.jeecg.modules.inquiry.support.InquiryWorkOrderExcelExport; import org.jeecg.modules.inquiry.tms.InquiryTmsQueryService; import org.springframework.beans.factory.annotation.Autowired; @@ -155,6 +157,50 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl 0) { + response.setContentLengthLong(row.getFileSize()); + } + try (java.io.InputStream stream = in; + java.io.OutputStream out = response.getOutputStream()) { + byte[] buf = new byte[8192]; + int n; + while ((n = stream.read(buf)) >= 0) { + out.write(buf, 0, n); + } + out.flush(); + } + } + @Override public Map detailView(String workOrderNo) { InquiryTicket ticket = findByWorkOrderNo(workOrderNo); @@ -203,6 +249,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl staffNameByWecom = loadStaffNames(tickets); Map> quotesByNo = loadQuotesByWorkOrder(tickets); + Map> attachByNo = loadAttachmentsByWorkOrder(tickets); for (InquiryTicket ticket : tickets) { if (ticket == null) { continue; @@ -224,6 +271,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl> loadAttachmentsByWorkOrder(List tickets) { + List nos = new ArrayList<>(); + for (InquiryTicket ticket : tickets) { + if (ticket != null && StringUtils.isNotBlank(ticket.getWorkOrderNo())) { + nos.add(ticket.getWorkOrderNo()); + } + } + if (nos.isEmpty()) { + return Collections.emptyMap(); + } + List rows = attachmentMapper.selectList(new LambdaQueryWrapper() + .in(InquiryAttachment::getWorkOrderNo, nos) + .orderByAsc(InquiryAttachment::getCreateTime)); + Map> out = new HashMap<>(); + if (rows == null) { + return out; + } + for (InquiryAttachment row : rows) { + if (row == null || StringUtils.isBlank(row.getWorkOrderNo())) { + continue; + } + out.computeIfAbsent(row.getWorkOrderNo(), k -> new ArrayList<>()).add(row); + } + return out; + } + /** * 按企微 userid 取员工管理姓名。建单和智能体读单都走这里,禁止把岗位词「销售」当人名。 * 查不到返回空,调用方再用 userid。副作用:只读员工表。 @@ -611,10 +690,20 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl merged = InquiryTicketFactsSupport.parseFacts(ticket.getFactsJson()); + if (merged == null) { + merged = facts; + } + InquiryMultiSegmentSupport.projectTicketColumns(ticket, merged); ticket.setUpdateTime(new Date()); updateById(ticket); out.put("ok", true); @@ -1273,21 +1371,39 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl endCollabGroup(String workOrderNo) { - Map body = new HashMap<>(); - body.put("workOrderNo", workOrderNo); - Map flag = new LinkedHashMap<>(); - flag.put("__ended", "1"); - body.put("facts", flag); - Map out = patchCollabFacts(body); - out.put("collab_ended", Boolean.TRUE); + Map out = new LinkedHashMap<>(); InquiryTicket ticket = findByWorkOrderNo(workOrderNo); - if (ticket != null) { - appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "end_collab_group", "agent", "协同结束"); + if (ticket == null) { + out.put("ok", false); + out.put("error", "ticket_not_found"); + out.put("collab_ended", Boolean.FALSE); + return out; } + Map merged = new LinkedHashMap<>(); + if (StringUtils.isNotBlank(ticket.getCollabFactsJson())) { + JSONObject old = JSON.parseObject(ticket.getCollabFactsJson()); + if (old != null) { + for (String key : old.keySet()) { + if (old.get(key) != null) { + merged.put(key, String.valueOf(old.get(key))); + } + } + } + } + merged.put("__ended", "1"); + ticket.setCollabFactsJson(JSON.toJSONString(merged)); + ticket.setUpdateTime(new Date()); + updateById(ticket); + appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "end_collab_group", "agent", "协同结束"); + out.put("ok", true); + out.put("facts", merged); + out.put("collab_ended", Boolean.TRUE); + out.put("work_order_no", workOrderNo); return out; } @@ -1319,6 +1435,74 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl + * 用于 PDF 转换完成后落库(Excel 出单在模板 render 内登记)。 + * 副作用:写对象存储 + insert inq_attachment;不改六态。 + * 线程:请求线程短事务;上传失败则整单回滚登记。 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Map attachFileBytes(Map body) { + String no = text(body, "workOrderNo", "work_order_no"); + InquiryTicket ticket = findByWorkOrderNo(no); + Map out = new LinkedHashMap<>(); + if (ticket == null) { + out.put("ok", false); + out.put("error", "ticket_not_found"); + return out; + } + String fileName = text(body, "fileName", "file_name"); + if (StringUtils.isBlank(fileName)) { + fileName = no + "_quote.bin"; + } + String contentType = text(body, "contentType", "content_type"); + String b64 = text(body, "fileBase64", "file_base64"); + if (StringUtils.isBlank(b64)) { + out.put("ok", false); + out.put("error", "empty_file"); + return out; + } + byte[] bytes; + try { + bytes = java.util.Base64.getDecoder().decode(b64); + } catch (Exception e) { + out.put("ok", false); + out.put("error", "bad_base64"); + return out; + } + if (bytes == null || bytes.length == 0) { + out.put("ok", false); + out.put("error", "empty_file"); + return out; + } + String objectKey; + try { + objectKey = InquiryWorkOrderAttachmentStore.uploadBytes( + fileName, bytes, contentType); + } catch (Exception e) { + out.put("ok", false); + out.put("error", "upload_failed"); + out.put("message", e.getMessage()); + return out; + } + InquiryAttachment row = new InquiryAttachment(); + row.setTicketId(ticket.getId()); + row.setWorkOrderNo(no); + row.setFileName(fileName); + row.setObjectKey(objectKey); + row.setContentType(contentType); + row.setFileSize((long) bytes.length); + row.setCreateTime(new Date()); + attachmentMapper.insert(row); + out.put("ok", true); + out.put("work_order_no", no); + out.put("file_name", fileName); + out.put("objectKey", objectKey); + return out; + } + /** * 手工报价 / 销售调价:新插一条 inq_quote,来源固定为手工报价,不覆盖 TMS 那一行。 * 销售调价必须再写一条状态流转备注(当时时间点)。询价中/协商中可迁已报价。 @@ -1692,20 +1876,63 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl + * 只读;上限 500,按更新时间倒序。含 collab_ended,已结束的由 Worker 清人摘监听。 + * 不加全局锁,不改六态。 + */ + @Override + public List> listOpenCollabGroups() { + List rows = list(new LambdaQueryWrapper() + .isNotNull(InquiryTicket::getCollabChatId) + .ne(InquiryTicket::getCollabChatId, "") + .orderByDesc(InquiryTicket::getUpdateTime) + .last("LIMIT 500")); + List> out = new ArrayList<>(); + if (rows == null || rows.isEmpty()) { + return out; + } + for (InquiryTicket ticket : rows) { + if (ticket == null) { + continue; } - // #endregion + String chat = StringUtils.trimToEmpty(ticket.getCollabChatId()); + if (StringUtils.isBlank(chat)) { + continue; + } + Map one = new LinkedHashMap<>(); + one.put("work_order_no", ticket.getWorkOrderNo()); + one.put("workOrderNo", ticket.getWorkOrderNo()); + one.put("status", StringUtils.defaultString(ticket.getStatus())); + one.put("business_line", StringUtils.defaultString(ticket.getBusinessLine())); + one.put("businessLine", StringUtils.defaultString(ticket.getBusinessLine())); + one.put("collab_chat_id", chat); + one.put("collabChatId", chat); + List members = parseMemberIds(ticket.getCollabMemberIds()); + one.put("collab_member_ids", members); + one.put("collabMemberIds", members); + one.put("memberIds", members); + Map collabFacts = new LinkedHashMap<>(); + if (StringUtils.isNotBlank(ticket.getCollabFactsJson())) { + JSONObject parsed = JSON.parseObject(ticket.getCollabFactsJson()); + if (parsed != null) { + for (String key : parsed.keySet()) { + if (parsed.get(key) != null) { + collabFacts.put(key, String.valueOf(parsed.get(key))); + } + } + } + } + boolean ended = "1".equals(collabFacts.get("__ended")) + || "true".equalsIgnoreCase(StringUtils.trimToEmpty(collabFacts.get("__ended"))); + one.put("collab_ended", ended); + one.put("collabEnded", ended); + out.add(one); } return out; } diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryDataScopeSupport.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryDataScopeSupport.java index c27868d..2317cf6 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryDataScopeSupport.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryDataScopeSupport.java @@ -106,6 +106,8 @@ public final class InquiryDataScopeSupport { } sql.append('\'').append(codes.get(i)).append('\''); } + // 多段联运:有任一业务线权限即可见(与 ticketInScope 对齐)。 + sql.append(",'MULTI'"); sql.append(") OR ((business_line IS NULL OR business_line = '') AND ("); for (int i = 0; i < marks.size(); i++) { if (i > 0) { @@ -113,6 +115,7 @@ public final class InquiryDataScopeSupport { } sql.append("biz_type_label LIKE '%").append(marks.get(i)).append("%'"); } + sql.append(" OR biz_type_label LIKE '%多段%' OR biz_type_label LIKE '%联运%'"); sql.append(")))"); qw.apply(sql.toString()); } @@ -141,8 +144,11 @@ public final class InquiryDataScopeSupport { public static List ticketBizOptionsForCurrentUser() { Set allowed = InquiryQuoteTemplateBizScope.currentAllowedOrNull(); List labels = new ArrayList(InquiryRoleBizScope.orderedLabels(allowed)); - if (allowed == null) { - labels.add("多段联运"); + // 不限制,或已有任一运输业务权限时,下拉可筛多段联运。 + if (allowed == null || !allowed.isEmpty()) { + if (!labels.contains("多段联运")) { + labels.add("多段联运"); + } } return labels; } diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryMultiSegmentSupport.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryMultiSegmentSupport.java new file mode 100644 index 0000000..c649b99 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryMultiSegmentSupport.java @@ -0,0 +1,325 @@ +package org.jeecg.modules.inquiry.support; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import org.apache.commons.lang3.StringUtils; +import org.jeecg.modules.inquiry.entity.InquiryTicket; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * 多段联运:把 facts.segments_json 投影到工单列表列,供后台检索与摘要展示。 + *

+ * 职责:解析段列表;写入起运/目的/品名/件数/重量/体积/需求标题;不改六态、不建子工单。 + * 副作用:只改传入的 ticket / facts 映射(调用方负责落库)。 + * 为何:建单时智能体只传 segments_json,若不投影,后台 origin/destination 全空,像「没存工单号」。 + */ +public final class InquiryMultiSegmentSupport { + + private static final Pattern PROMPT_NOISE = Pattern.compile( + "请确认|请复制粘贴|通过回复|告知我以下|补充以下信息"); + + private InquiryMultiSegmentSupport() { + } + + /** + * 若 facts 含 segments_json,把整票摘要列写到 ticket。 + * 整票口径:首段起运 → 末段目的;品名/货量优先取未污染的段值。 + * + * @return true 已识别为多段并投影;false 不是多段或解析失败 + */ + public static boolean projectTicketColumns(InquiryTicket ticket, Map facts) { + if (ticket == null || facts == null || facts.isEmpty()) { + return false; + } + List segments = parseSegments(facts.get("segments_json")); + if (segments.isEmpty()) { + segments = parseSegments(facts.get("segmentsJson")); + } + if (segments.isEmpty()) { + return false; + } + JSONObject first = segments.get(0); + JSONObject last = segments.get(segments.size() - 1); + Map firstFacts = segmentFacts(first); + Map lastFacts = segmentFacts(last); + + String origin = firstNonBlank( + firstFacts.get("起运港"), firstFacts.get("起运地"), firstFacts.get("始发站")); + String dest = firstNonBlank( + lastFacts.get("目的港"), lastFacts.get("目的地"), lastFacts.get("目的站")); + if (StringUtils.isNotBlank(origin)) { + ticket.setOrigin(origin); + } + if (StringUtils.isNotBlank(dest)) { + ticket.setDestination(dest); + } + + String goods = bestGoodsName(segments); + if (StringUtils.isNotBlank(goods)) { + ticket.setGoodsName(goods); + } + Integer pieces = firstInt(segments, "件数"); + if (pieces != null) { + ticket.setPieces(pieces); + } + BigDecimal weight = firstDecimal(segments, "毛重", "重量"); + if (weight != null) { + ticket.setWeightKg(weight); + } + BigDecimal volume = firstVolume(segments); + if (volume != null) { + ticket.setVolumeCbm(volume); + } + + if (StringUtils.isBlank(ticket.getBizTypeLabel()) + || "MULTI".equalsIgnoreCase(ticket.getBizTypeLabel())) { + ticket.setBizTypeLabel("多段联运"); + } + String title = "多段联运 · " + + (StringUtils.isNotBlank(origin) ? origin : "-") + + " → " + + (StringUtils.isNotBlank(dest) ? dest : "-"); + ticket.setDemandTitle(title); + ticket.setDemandDetail(segmentDetailLines(segments)); + return true; + } + + /** + * 后台展示用:从 factsJson 解出各段一行摘要。 + */ + public static List segmentSummaryLines(String factsJson) { + List lines = new ArrayList<>(); + if (StringUtils.isBlank(factsJson)) { + return lines; + } + try { + JSONObject root = JSON.parseObject(factsJson); + if (root == null) { + return lines; + } + List segments = parseSegments(root.getString("segments_json")); + if (segments.isEmpty()) { + segments = parseSegments(root.getString("segmentsJson")); + } + for (JSONObject seg : segments) { + Map f = segmentFacts(seg); + String mode = modeWord(seg.getString("mode")); + int index = seg.getIntValue("index"); + String origin = firstNonBlank(f.get("起运港"), f.get("起运地"), f.get("始发站")); + String dest = firstNonBlank(f.get("目的港"), f.get("目的地"), f.get("目的站")); + String goods = cleanText(f.get("品名")); + StringBuilder sb = new StringBuilder(); + sb.append("段").append(index > 0 ? index : lines.size() + 1).append(mode); + if (StringUtils.isNotBlank(origin) || StringUtils.isNotBlank(dest)) { + sb.append(" ").append(StringUtils.defaultIfBlank(origin, "-")) + .append(" → ").append(StringUtils.defaultIfBlank(dest, "-")); + } + if (StringUtils.isNotBlank(goods)) { + sb.append(" · ").append(goods); + } + lines.add(sb.toString()); + } + } catch (Exception ignore) { + // 坏 JSON 不当摘要 + } + return lines; + } + + private static List parseSegments(String raw) { + List out = new ArrayList<>(); + if (StringUtils.isBlank(raw)) { + return out; + } + try { + Object parsed = JSON.parse(raw.trim()); + if (parsed instanceof JSONArray) { + JSONArray arr = (JSONArray) parsed; + for (int i = 0; i < arr.size(); i++) { + Object one = arr.get(i); + if (one instanceof JSONObject) { + out.add((JSONObject) one); + } + } + } + } catch (Exception ignore) { + // 解析失败当无段 + } + return out; + } + + private static Map segmentFacts(JSONObject seg) { + Map out = new LinkedHashMap<>(); + if (seg == null) { + return out; + } + Object raw = seg.get("facts"); + if (!(raw instanceof JSONObject)) { + return out; + } + JSONObject facts = (JSONObject) raw; + for (String key : facts.keySet()) { + String val = cleanText(facts.getString(key)); + if (StringUtils.isNotBlank(val)) { + out.put(key, val); + } + } + return out; + } + + private static String segmentDetailLines(List segments) { + List lines = new ArrayList<>(); + for (JSONObject seg : segments) { + Map f = segmentFacts(seg); + String mode = modeWord(seg.getString("mode")); + int index = seg.getIntValue("index"); + String origin = firstNonBlank(f.get("起运港"), f.get("起运地"), f.get("始发站")); + String dest = firstNonBlank(f.get("目的港"), f.get("目的地"), f.get("目的站")); + lines.add("段" + (index > 0 ? index : lines.size() + 1) + mode + + " " + StringUtils.defaultIfBlank(origin, "-") + + " → " + StringUtils.defaultIfBlank(dest, "-")); + } + return String.join("\n", lines); + } + + private static String bestGoodsName(List segments) { + String fallback = ""; + for (JSONObject seg : segments) { + String goods = cleanText(segmentFacts(seg).get("品名")); + if (StringUtils.isBlank(goods)) { + continue; + } + if (!PROMPT_NOISE.matcher(goods).find()) { + return goods; + } + if (StringUtils.isBlank(fallback)) { + fallback = goods; + } + } + return fallback; + } + + private static Integer firstInt(List segments, String key) { + for (JSONObject seg : segments) { + String raw = segmentFacts(seg).get(key); + if (StringUtils.isBlank(raw)) { + continue; + } + String digits = raw.replaceAll("[^0-9]", ""); + if (StringUtils.isBlank(digits)) { + continue; + } + try { + return Integer.parseInt(digits); + } catch (NumberFormatException ignore) { + // 下一笔 + } + } + return null; + } + + private static BigDecimal firstDecimal(List segments, String... keys) { + for (JSONObject seg : segments) { + Map f = segmentFacts(seg); + for (String key : keys) { + String raw = f.get(key); + if (StringUtils.isBlank(raw)) { + continue; + } + String num = raw.replaceAll("[^0-9.]", ""); + if (StringUtils.isBlank(num)) { + continue; + } + try { + return new BigDecimal(num); + } catch (Exception ignore) { + // 下一笔 + } + } + } + return null; + } + + private static BigDecimal firstVolume(List segments) { + for (JSONObject seg : segments) { + String raw = segmentFacts(seg).get("体积"); + if (StringUtils.isBlank(raw)) { + continue; + } + // 尺寸 1.1*2.1*2.2 → 相乘;已是 CBM 数字则直接用。 + String[] parts = raw.toLowerCase(Locale.ROOT) + .replace("×", "*").replace("x", "*").split("\\*"); + if (parts.length >= 2) { + try { + BigDecimal product = BigDecimal.ONE; + for (String p : parts) { + String n = p.replaceAll("[^0-9.]", ""); + if (StringUtils.isBlank(n)) { + product = null; + break; + } + product = product.multiply(new BigDecimal(n)); + } + if (product != null) { + return product; + } + } catch (Exception ignore) { + // 退回数字解析 + } + } + String num = raw.replaceAll("[^0-9.]", ""); + if (StringUtils.isNotBlank(num)) { + try { + return new BigDecimal(num); + } catch (Exception ignore) { + // 下一笔 + } + } + } + return null; + } + + private static String modeWord(String mode) { + String m = StringUtils.trimToEmpty(mode).toUpperCase(Locale.ROOT); + if ("LAND".equals(m)) { + return "陆运"; + } + if ("AIR".equals(m)) { + return "空运"; + } + if ("SEA".equals(m)) { + return "海运"; + } + return StringUtils.defaultIfBlank(mode, ""); + } + + private static String cleanText(String value) { + String raw = StringUtils.trimToEmpty(value); + if (raw.isEmpty()) { + return ""; + } + if (PROMPT_NOISE.matcher(raw).find() && raw.length() > 24) { + return ""; + } + return raw; + } + + private static String firstNonBlank(String... values) { + if (values == null) { + return ""; + } + for (String v : values) { + if (StringUtils.isNotBlank(v)) { + return v.trim(); + } + } + return ""; + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryRoleBizScope.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryRoleBizScope.java index 59a4390..3978507 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryRoleBizScope.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryRoleBizScope.java @@ -251,12 +251,18 @@ public final class InquiryRoleBizScope { } /** - * {@code allowed == null} 表示不限制。多段、认不出的工单对有范围的账号不可见。 + * {@code allowed == null} 表示不限制。 + * 多段联运任一段都可能涉及空运/海运/陆运:有任一业务范围的账号可见,避免后台像「没存这张单」。 */ public static boolean ticketInScope(String businessLine, String bizTypeLabel, Set allowed) { if (allowed == null) { return true; } + String line = StringUtils.trimToEmpty(businessLine).toUpperCase(Locale.ROOT); + String label = StringUtils.trimToEmpty(bizTypeLabel); + if ("MULTI".equals(line) || label.contains("多段") || label.contains("联运")) { + return !allowed.isEmpty(); + } String biz = classifyTicketBiz(businessLine, bizTypeLabel); return StringUtils.isNotBlank(biz) && allowed.contains(biz); } diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketDisplaySupport.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketDisplaySupport.java index 6c46c29..e46dc6d 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketDisplaySupport.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketDisplaySupport.java @@ -42,6 +42,14 @@ public final class InquiryTicketDisplaySupport { } addLine(lines, join(heading, " · ")); + // 多段:把各段一行摘要补进需求详情,避免后台只见空路线。 + if ("MULTI".equals(businessLine) || containsChinese(lineLabel, "多段") || containsChinese(lineLabel, "联运")) { + List segs = InquiryMultiSegmentSupport.segmentSummaryLines(ticket.getFactsJson()); + for (String seg : segs) { + addLine(lines, seg); + } + } + List quantities = new ArrayList<>(); if ("AIR".equals(businessLine) || containsChinese(lineLabel, "空运")) { addLabel(quantities, "件数", ticket.getPieces()); diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryWorkOrderAttachmentStore.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryWorkOrderAttachmentStore.java new file mode 100644 index 0000000..7770cac --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryWorkOrderAttachmentStore.java @@ -0,0 +1,175 @@ +package org.jeecg.modules.inquiry.support; + +import org.apache.commons.lang3.StringUtils; +import org.jeecg.common.constant.CommonConstant; +import org.jeecg.common.util.CommonUtils; +import org.jeecg.common.util.MinioUtil; +import org.jeecg.common.util.oss.OssBootUtil; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * 工单报价附件字节落对象存储。 + *

+ * 职责:把 Excel/PDF 字节写成可解析的 objectKey(MinIO/OSS/本地)。 + * 不写 inq_attachment 行(由 WorkOrderService / 模板出单写入)。 + * 为何:出单与 PDF 转换两处都要同一套上传语义,禁止各写一套路径。 + */ +@Component +public class InquiryWorkOrderAttachmentStore { + + /** 与报价模板同桶分区,但子目录独立,避免和模板原件混在一起。 */ + public static final String BIZ_PATH = "inquiry/work-order-files"; + + private static String uploadPath = ""; + private static String uploadType = CommonConstant.UPLOAD_TYPE_LOCAL; + + @Value("${jeecg.path.upload:}") + public void setUploadPath(String path) { + uploadPath = StringUtils.defaultString(path); + } + + @Value("${jeecg.uploadType:local}") + public void setUploadType(String type) { + uploadType = StringUtils.defaultIfBlank(type, CommonConstant.UPLOAD_TYPE_LOCAL); + } + + /** + * 上传附件字节,返回 objectKey(本地相对路径或 MinIO/OSS URL)。 + * + * @param fileName 销售可见文件名(含后缀) + * @param bytes 文件内容 + * @param contentType MIME,可空 + * @return 非空 objectKey + */ + public static String uploadBytes(String fileName, byte[] bytes, String contentType) throws Exception { + if (bytes == null || bytes.length == 0) { + throw new IllegalArgumentException("empty_bytes"); + } + String safeName = CommonUtils.getFileName(StringUtils.defaultIfBlank(fileName, "quote.bin")); + String type = StringUtils.defaultIfBlank(uploadType, CommonConstant.UPLOAD_TYPE_LOCAL); + if (CommonConstant.UPLOAD_TYPE_LOCAL.equalsIgnoreCase(type)) { + Path dir = Paths.get(StringUtils.defaultIfBlank(uploadPath, "."), BIZ_PATH); + Files.createDirectories(dir); + Path target = dir.resolve(System.currentTimeMillis() + "-" + safeName); + Files.write(target, bytes); + return BIZ_PATH + "/" + target.getFileName().toString(); + } + // 库里只存相对键(inquiry/work-order-files/...),禁止存 http://127.0.0.1:9000/... + // 浏览器打不开内网 MinIO;后台下载走主账鉴权代理。 + String relative = BIZ_PATH + "/" + System.currentTimeMillis() + "-" + safeName; + try (InputStream in = new ByteArrayInputStream(bytes)) { + if (CommonConstant.UPLOAD_TYPE_MINIO.equalsIgnoreCase(type)) { + String url = MinioUtil.upload(in, relative); + if (StringUtils.isBlank(url)) { + throw new IllegalStateException("minio_upload_empty"); + } + return relative; + } + String url = OssBootUtil.upload(in, relative); + if (StringUtils.isBlank(url)) { + throw new IllegalStateException("oss_upload_empty"); + } + // OSS 外网 URL 可直接给浏览器;仍优先相对键,下载统一走主账。 + return relative; + } + } + + /** + * 按 inq_attachment.object_key 打开可读流(本地盘 / MinIO / 兼容历史全量 URL)。 + *

+ * 历史行可能是 http://127.0.0.1:9000/{bucket}/{relative},需剥成桶内对象名。 + * 调用方负责关闭流。找不到返回 null。 + */ + public static InputStream openStream(String objectKey) { + String key = StringUtils.trimToEmpty(objectKey); + if (StringUtils.isBlank(key)) { + return null; + } + String type = StringUtils.defaultIfBlank(uploadType, CommonConstant.UPLOAD_TYPE_LOCAL); + try { + if (CommonConstant.UPLOAD_TYPE_LOCAL.equalsIgnoreCase(type) + && !key.startsWith("http://") && !key.startsWith("https://")) { + Path file = Paths.get(StringUtils.defaultIfBlank(uploadPath, "."), key.replace("/", java.io.File.separator)); + if (!Files.isRegularFile(file)) { + return null; + } + return Files.newInputStream(file); + } + String objectName = toMinioObjectName(key); + if (StringUtils.isBlank(objectName)) { + return null; + } + return MinioUtil.getMinioFile(MinioUtil.getBucketName(), objectName); + } catch (Exception e) { + return null; + } + } + + /** + * 把相对键或历史 MinIO URL 收成桶内 objectName。 + */ + public static String toMinioObjectName(String objectKey) { + String key = StringUtils.trimToEmpty(objectKey).replace("\\", "/"); + if (StringUtils.isBlank(key)) { + return ""; + } + if (!key.startsWith("http://") && !key.startsWith("https://")) { + return key.replaceFirst("^/+", ""); + } + // http://host:9000/bucket/inquiry/work-order-files/xxx.xlsx + String bucket = StringUtils.defaultString(MinioUtil.getBucketName()); + int idx = key.indexOf("/" + bucket + "/"); + if (idx >= 0) { + return key.substring(idx + bucket.length() + 2); + } + // 兜底:取 URL path 去掉开头 / + try { + java.net.URI uri = java.net.URI.create(key); + String path = StringUtils.defaultString(uri.getPath()).replaceFirst("^/+", ""); + if (StringUtils.isNotBlank(bucket) && path.startsWith(bucket + "/")) { + return path.substring(bucket.length() + 1); + } + return path; + } catch (Exception e) { + return ""; + } + } + + /** + * 已落盘的填好表:优先原路径登记;MinIO/OSS 时再推一份到对象存储。 + *

+ * 失败不抛给业务:返回本地相对键,后台仍能按 uploadPath 找回。 + * + * @param localFile render 写出的本地 xlsx + * @param displayName 销售可见名 + * @param bytes 同文件字节 + */ + public static String resolveObjectKey(Path localFile, String displayName, byte[] bytes) { + String localKey = null; + if (localFile != null) { + String root = StringUtils.defaultIfBlank(uploadPath, "").replace("\\", "/"); + String abs = localFile.toAbsolutePath().toString().replace("\\", "/"); + if (StringUtils.isNotBlank(root) && abs.startsWith(root.replace("\\", "/"))) { + localKey = abs.substring(root.length()).replaceFirst("^/+", ""); + } else { + localKey = BIZ_PATH + "/render/" + localFile.getFileName(); + } + } + String type = StringUtils.defaultIfBlank(uploadType, CommonConstant.UPLOAD_TYPE_LOCAL); + if (CommonConstant.UPLOAD_TYPE_LOCAL.equalsIgnoreCase(type)) { + return StringUtils.defaultIfBlank(localKey, BIZ_PATH + "/" + displayName); + } + try { + return uploadBytes(displayName, bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + } catch (Exception e) { + return StringUtils.defaultIfBlank(localKey, BIZ_PATH + "/" + displayName); + } + } +} 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 f13cab3..a12c818 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 @@ -30,7 +30,6 @@ import java.util.*; @Slf4j public final class QuoteTemplateExcelSupport { - private static final Set YELLOW_RGB = new HashSet<>(Arrays.asList( "FFFFFF00", "FFFF00", "FFFFEB9C", "FFFFC000", "FFFFFF99", "FFFFCC00", "FFFFD966", "FFFFF2CC", "FFFFE699" @@ -213,6 +212,12 @@ public final class QuoteTemplateExcelSupport { private QuoteTemplateExcelSupport() { } + /** + * TMS 国内车型别名 → 报价单表头(如 6.8厢车 → 6.8/5T)。 + * 懒加载 contracts/tms-domestic-vehicle-mapping-v1.json;加载失败则空表,禁止瞎猜。 + */ + private static volatile Map TMS_DOMESTIC_ALIAS_TO_TEMPLATE; + /** * 加载 contracts 合同(优先 classpath,其次仓库相对路径由调用方传入)。 */ @@ -432,8 +437,9 @@ public final class QuoteTemplateExcelSupport { * @return sheet / colHeaders / rows(每行含 rowNo + cells) */ public static JSONObject buildSheetPreviewGrid(Path xlsxPath, JSONObject mapping) throws Exception { - final int maxRows = 40; - final int maxCols = 16; + // 宽表(海运/集拼)常超过 16 列;上限放宽避免预览右侧被截断 + final int maxRows = 60; + final int maxCols = 40; JSONObject out = new JSONObject(true); try (InputStream in = Files.newInputStream(xlsxPath); Workbook wb = WorkbookFactory.create(in)) { if (wb.getNumberOfSheets() <= 0) { @@ -2264,6 +2270,15 @@ public final class QuoteTemplateExcelSupport { int startRow = resolveFeeDataStartRow(sheet, region, anchorRef.row); // 映射缺单位/单价/数量列时,按表头补齐,否则明细有值也写不进 Excel columns = mergeFeeColumnsFromHeader(sheet, startRow, columns); + // 海运三段:表头「项目/Items」就是费目名列,不是空运「项目」骨架列。 + // 若误带 project=A=name,会把费目名盖成「起运港」,并冲掉海运费用区标题(WO202610180032)。 + String tmsSource = StringUtils.trimToEmpty(region.getString("tmsSource")); + boolean seaFeeBucket = "polCostItems".equals(tmsSource) + || "oceanCostItems".equals(tmsSource) + || "podCostItems".equals(tmsSource); + if (seaFeeBucket) { + stripSeaFakeProjectColumn(columns); + } // 数据起点整行已标黄:本段是模板固定占位(如陆运「项目二」),整段跳过 if (rowLooksYellowFixed(sheet, startRow)) { log.info("费用区起点为黄底固定区,跳过赋值 region={} startRow={}", region.getString("id"), startRow); @@ -2288,9 +2303,17 @@ public final class QuoteTemplateExcelSupport { applyDynamicContainerHeader(sheet, region, columns, facts, yellow); } // 空运模板有「空运段/起运港/目的地/其它」骨架时:费用落到对应段,禁止顺填盖掉空运段首行 - int projectColIdx = StringUtils.isBlank(columns.getString("project")) + // 海运三段禁止走空运项目骨架(否则费目名列被写成「起运港」) + int projectColIdx = seaFeeBucket || StringUtils.isBlank(columns.getString("project")) ? -1 : CellReference.convertColStringToIndex(columns.getString("project").trim()); + // 项目列与费目名同列时当作无项目列,避免盖字 + if (projectColIdx >= 0 && StringUtils.isNotBlank(columns.getString("name"))) { + int nameColIdx = CellReference.convertColStringToIndex(columns.getString("name").trim()); + if (projectColIdx == nameColIdx) { + projectColIdx = -1; + } + } boolean placeByProject = projectColIdx >= 0 && hasAirProjectSkeleton(sheet, startRow, stopRow, projectColIdx); List writeRows = placeByProject @@ -2309,17 +2332,19 @@ public final class QuoteTemplateExcelSupport { // 不整行跳过:备注「发生时收取」常标黄,整行跳过会把费用名留成模板样例(如 ZUH)。 // 黄格仍由 writeFeeCol 逐格跳过。 JSONObject rowObj = rows.getJSONObject(r); - String projectVal = normalizeAirProjectLabel( - firstNonBlank(rowObj.getString("project"), rowObj.getString("segment"))); - // 已有分段 project 原样用;无则按费目名兜底;附加费名禁止留在「空运段」 - if (StringUtils.isBlank(projectVal)) { - projectVal = isAirWeightFeeName(rowObj.getString("name")) ? "空运段" : "起运港"; - } else if ("空运段".equals(projectVal) && !isAirWeightFeeName(rowObj.getString("name"))) { - projectVal = "起运港"; + if (projectColIdx >= 0) { + String projectVal = normalizeAirProjectLabel( + firstNonBlank(rowObj.getString("project"), rowObj.getString("segment"))); + // 已有分段 project 原样用;无则按费目名兜底;附加费名禁止留在「空运段」 + if (StringUtils.isBlank(projectVal)) { + projectVal = isAirWeightFeeName(rowObj.getString("name")) ? "空运段" : "起运港"; + } else if ("空运段".equals(projectVal) && !isAirWeightFeeName(rowObj.getString("name"))) { + projectVal = "起运港"; + } + rowObj.put("project", projectVal); + // 项目列:已在正确分段时只保证骨架文案,禁止把「空运段」骨架改成「起运港」 + writeAirProjectCell(sheet, rowIdx, projectColIdx, projectVal, yellow, sheet.getSheetName()); } - rowObj.put("project", projectVal); - // 项目列:已在正确分段时只保证骨架文案,禁止把「空运段」骨架改成「起运港」 - writeAirProjectCell(sheet, rowIdx, projectColIdx, projectVal, yellow, sheet.getSheetName()); writeFeeCol(sheet, rowIdx, columns.getString("name"), rowObj.getString("name"), yellow, sheet.getSheetName()); writeFeeCol(sheet, rowIdx, columns.getString("unit"), rowObj.getString("unit"), yellow, sheet.getSheetName()); writeFeeCol(sheet, rowIdx, columns.getString("quantity"), @@ -3122,6 +3147,7 @@ public final class QuoteTemplateExcelSupport { writeFeeCol(sheet, ri, col, price, yellow, sheet.getSheetName()); } } + } else { } } } @@ -3367,7 +3393,8 @@ public final class QuoteTemplateExcelSupport { * 国内段 / 境外段各写对应 TMS 腿的完整起讫。 *

* 优先用 selected_land_route.legs(销售已选线路);否则从多条备选里挑一条多腿线路。 - * 单腿门到门(境内→境外)时:两段都写完整起讫(产品口径 A,WO202610140014),不发明口岸。 + * 仅陆运跨境整车:无中转(不足两腿)时只填境外段完整起讫+运价,国内段留空; + * 有中转(≥两腿)时国内段取首腿、境外段取末腿。不发明口岸。 * WO202610120001:备选混在 route_rows 时若要求整表同 segmentSequence,会退回切片导致口岸空。 */ static JSONArray routesForLandRouteSection(JSONArray routes, Sheet sheet, JSONObject region, @@ -3388,6 +3415,25 @@ public final class QuoteTemplateExcelSupport { return routes; } JSONArray preferred = preferredLandRouteLegs(facts, routes); + // 仅陆运跨境整车:无中转站(单腿)→ 国内段不写,境外段写完整门到门起讫与运价。 + // 识别:运输类型/模板名含「跨境整车」,或表上同时有国内段/境外段分区(该类模板专属)。 + boolean ftl = isLandCrossBorderFtl(facts, preferred) + || sheetHasDomesticAndOverseasSections(sheet); + boolean noTransit = preferred == null || preferred.size() < 2; + if (ftl && noTransit && ("domestic".equals(kind) || "overseas".equals(kind))) { + if ("domestic".equals(kind)) { + return new JSONArray(); + } + JSONObject seed = preferred != null && !preferred.isEmpty() + ? preferred.getJSONObject(0) : routes.getJSONObject(0); + JSONArray overseasOnly = new JSONArray(); + JSONObject one = sliceCrossBorderOd(seed, "overseas_full"); + if (one != null && (StringUtils.isNotBlank(one.getString("origin")) + || StringUtils.isNotBlank(one.getString("destination")))) { + overseasOnly.add(one); + } + return overseasOnly; + } JSONArray byLeg = pickLandRouteLegsForSection(preferred, kind); if (byLeg != null && !byLeg.isEmpty()) { return byLeg; @@ -3403,6 +3449,44 @@ public final class QuoteTemplateExcelSupport { return sliced; } + /** + * 是否陆运跨境整车(产品口径:无中转只填境外段仅作用于此类,不影响集拼/中港/国内整车)。 + */ + static boolean isLandCrossBorderFtl(JSONObject facts, JSONArray routes) { + String fromFacts = firstNonBlank( + facts == null ? null : facts.getString("运输类型"), + facts == null ? null : facts.getString("landTransportType"), + facts == null ? null : facts.getString("land_transport_type"), + facts == null ? null : facts.getString("transportType"), + facts == null ? null : facts.getString("templateName"), + facts == null ? null : facts.getString("template_name")); + if (looksLandCrossBorderFtlToken(fromFacts)) { + return true; + } + if (routes != null) { + for (int i = 0; i < routes.size(); i++) { + JSONObject row = routes.getJSONObject(i); + if (row == null) { + continue; + } + if (looksLandCrossBorderFtlToken(firstNonBlank( + row.getString("landTransportType"), row.getString("land_transport_type"), + row.getString("运输类型")))) { + return true; + } + } + } + return false; + } + + static boolean looksLandCrossBorderFtlToken(String token) { + if (StringUtils.isBlank(token)) { + return false; + } + String t = token.replaceAll("\\s+", ""); + return t.contains("跨境整车") || "CROSS_BORDER_FTL".equalsIgnoreCase(t); + } + /** * 出单优先腿:已选线路 legs > 同 segmentSequence 且腿数最多的备选 > 原数组。 */ @@ -3528,9 +3612,13 @@ public final class QuoteTemplateExcelSupport { String origin = firstNonBlank(row.getString("origin"), row.getString("originCode")); String dest = firstNonBlank(row.getString("destination"), row.getString("destinationCode")); JSONObject out = new JSONObject(true); - // 产品口径 A:单腿门到门(境内→境外)时国内段/境外段都写完整起讫,避免半边空白。 boolean doorToDoor = looksDomesticPlace(origin) && looksOverseasPlace(dest); - if (doorToDoor && ("domestic".equals(kind) || "overseas".equals(kind))) { + // overseas_full:跨境整车无中转只写境外段时,完整门到门起讫(不依赖地名识别)。 + if ("overseas_full".equals(kind)) { + out.put("origin", origin); + out.put("destination", dest); + } else if (doorToDoor && ("domestic".equals(kind) || "overseas".equals(kind))) { + // 非跨境整车或其它模板仍可走门到门双写;跨境整车无中转已在 routesForLandRouteSection 短路。 out.put("origin", origin); out.put("destination", dest); } else if ("domestic".equals(kind)) { @@ -3602,7 +3690,8 @@ public final class QuoteTemplateExcelSupport { String t = text.replaceAll("\\s+", ""); String[] marks = { "吉隆坡", "胡志明", "河内", "海防", "岘港", "谅山", "同登", "老街", "芒街", - "春疆", "曼谷", "清迈", "金边", "西哈努克", "新加坡", "雅加达", "万象", "仰光", + "春疆", "北宁", "BacNinh", "Bac Ninh", "曼谷", "清迈", "金边", "西哈努克", + "新加坡", "雅加达", "万象", "仰光", "马来", "越南", "泰国", "柬埔寨", "老挝", "缅甸", "印尼", "菲律宾", "Hồ", "ChiMinh", "Kuala", "Lumpur", "Hanoi", "Bangkok", "Saigon", "Phnom" }; @@ -3923,7 +4012,9 @@ public final class QuoteTemplateExcelSupport { String price = firstNonBlank(rowObj == null ? null : rowObj.getString("salePrice"), rowObj == null ? null : rowObj.getString("costPrice"), rowObj == null ? null : rowObj.getString("amount")); - if (StringUtils.isNotBlank(vehicleType) && vehicleTypeMatchesSection(vehicleType, key, sectionKind)) { + boolean matched = StringUtils.isNotBlank(vehicleType) + && vehicleTypeMatchesSection(vehicleType, key, sectionKind); + if (matched) { return price; } // 3) 仅一列且无车型信息时,允许写入该列(整车单列模板) @@ -3936,25 +4027,114 @@ public final class QuoteTemplateExcelSupport { /** * 车型列是否对得上当前段表头。 *

- * 跨境整车:询价/TMS 常写国内标注「3T」,国外段表头是「3.5T」; - * 境外段用国内→国外映射后再比,国内段反之。禁止把 3T 误当成所有 T 列。 + * 1) 字面/安全子串;2) TMS 国内别名表(6.8厢车→6.8/5T,合同 tms-domestic-vehicle-mapping-v1); + * 3) 跨境整车国内↔境外吨位换算(3T↔3.5T)。禁止未收录别名瞎猜。 */ static boolean vehicleTypeMatchesSection(String vehicleType, String headerKey, String sectionKind) { - if (vehicleTypeEquals(vehicleType, headerKey)) { + boolean direct = vehicleTypeEquals(vehicleType, headerKey); + String kind = StringUtils.trimToEmpty(sectionKind); + if (direct) { + return true; + } + // TMS 国内别名 → 报价单表头(国内整车 sectionKind 常为空,必须在跨境换算之前做) + String tmsMapped = mapTmsDomesticAliasToTemplate(vehicleType); + if (StringUtils.isNotBlank(tmsMapped) && vehicleTypeEquals(tmsMapped, headerKey)) { return true; } - String kind = StringUtils.trimToEmpty(sectionKind); if ("overseas".equals(kind)) { String mapped = mapDomesticVehicleToOverseas(vehicleType); - return StringUtils.isNotBlank(mapped) && vehicleTypeEquals(mapped, headerKey); + boolean overseasHit = StringUtils.isNotBlank(mapped) && vehicleTypeEquals(mapped, headerKey); + return overseasHit; } if ("domestic".equals(kind)) { String mapped = mapOverseasVehicleToDomestic(vehicleType); - return StringUtils.isNotBlank(mapped) && vehicleTypeEquals(mapped, headerKey); + boolean domesticHit = StringUtils.isNotBlank(mapped) && vehicleTypeEquals(mapped, headerKey); + return domesticHit; } return false; } + /** + * TMS/询价车型文案 → 国内整车报价单表头。 + *

+ * 例:6.8厢车 → 6.8/5T。未收录返回空。对照表见 contracts/tms-domestic-vehicle-mapping-v1.json。 + */ + static String mapTmsDomesticAliasToTemplate(String vehicleType) { + if (StringUtils.isBlank(vehicleType)) { + return ""; + } + Map map = tmsDomesticAliasToTemplate(); + String raw = vehicleType.trim(); + String hit = map.get(raw); + if (StringUtils.isNotBlank(hit)) { + return hit; + } + // 去空格后再查一次(TMS 偶发「6.8 厢车」) + String compact = raw.replace(" ", ""); + hit = map.get(compact); + if (StringUtils.isNotBlank(hit)) { + return hit; + } + // 键不区分全角斜杠/米字差异时:扫一遍规范化相等 + String norm = normalizeVehicleToken(compact); + for (Map.Entry e : map.entrySet()) { + if (norm.equals(normalizeVehicleToken(e.getKey()))) { + return e.getValue(); + } + } + return ""; + } + + private static Map tmsDomesticAliasToTemplate() { + Map local = TMS_DOMESTIC_ALIAS_TO_TEMPLATE; + if (local != null) { + return local; + } + synchronized (QuoteTemplateExcelSupport.class) { + if (TMS_DOMESTIC_ALIAS_TO_TEMPLATE == null) { + TMS_DOMESTIC_ALIAS_TO_TEMPLATE = loadTmsDomesticVehicleMapping(); + } + return TMS_DOMESTIC_ALIAS_TO_TEMPLATE; + } + } + + /** + * 读 classpath 合同;失败返回空 Map(出单不炸,只是别名对不上)。 + */ + private static Map loadTmsDomesticVehicleMapping() { + Map out = new LinkedHashMap<>(); + try { + ClassPathResource res = new ClassPathResource("contracts/tms-domestic-vehicle-mapping-v1.json"); + if (!res.exists()) { + log.warn("缺少 contracts/tms-domestic-vehicle-mapping-v1.json,TMS 车型别名无法映射到报价单表头"); + return out; + } + try (InputStream in = res.getInputStream()) { + byte[] buf = new byte[4096]; + java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); + int n; + while ((n = in.read(buf)) >= 0) { + bos.write(buf, 0, n); + } + JSONObject root = JSON.parseObject(new String(bos.toByteArray(), StandardCharsets.UTF_8)); + JSONObject alias = root == null ? null : root.getJSONObject("domesticAliasToTemplate"); + if (alias != null) { + for (String key : alias.keySet()) { + String val = alias.getString(key); + if (StringUtils.isNotBlank(key) && StringUtils.isNotBlank(val)) { + out.put(key.trim(), val.trim()); + } + } + } + } + log.info("已加载 TMS 国内车型映射 {} 条", out.size()); + } catch (Exception e) { + log.warn("加载 TMS 国内车型映射失败: {}", e.getMessage()); + } + return out; + } + + /** * 国内整车标注 → 跨境国外段表头。WO202610080006:3T 对 3.5T。 * 未收录的返回空,禁止瞎猜。 @@ -4399,7 +4579,7 @@ public final class QuoteTemplateExcelSupport { continue; } for (String n : needles) { - if (text.contains(n)) { + if (text.contains(n) || stopTextMatchesAlias(n, text)) { return r; } } @@ -4409,6 +4589,31 @@ public final class QuoteTemplateExcelSupport { return last + 1; } + /** + * 停行别名:映射写「海运费用」,模板常写「海运费/Ocean Freight」。 + * 只认分区标题(带 / 或 Ocean),避免费目名「海运舱单费」误停。 + */ + static boolean stopTextMatchesAlias(String needle, String cellText) { + if (StringUtils.isBlank(needle) || StringUtils.isBlank(cellText)) { + return false; + } + String n = needle.trim(); + String t = cellText.trim(); + if ("海运费用".equals(n) || "海运费".equals(n)) { + return t.startsWith("海运费/") || t.startsWith("海运费用/") + || t.toLowerCase(Locale.ROOT).startsWith("ocean freight") + || (t.startsWith("海运费") && t.contains("/")); + } + if ("始发港费用汇总".equals(n) || "始发港费用小计".equals(n)) { + return t.contains("始发港费用汇总") || t.contains("始发港费用小计") + || t.toLowerCase(Locale.ROOT).contains("pol local"); + } + if ("目的港费用".equals(n) || "目的港费用汇总".equals(n)) { + return t.startsWith("目的港费用") || t.toLowerCase(Locale.ROOT).contains("destination charges"); + } + return false; + } + /** * 需要的行数若会撞到 stop 行,则在 stop 前插入并复制样式行。 * @@ -6894,6 +7099,27 @@ public final class QuoteTemplateExcelSupport { if (isLandQuoteFactCode(matched)) { return matched; } + // TMS 别名(6.8厢车)先对到表头(6.8/5T),再取填表码 + String templateHeader = mapTmsDomesticAliasToTemplate(name); + if (StringUtils.isNotBlank(templateHeader)) { + if (FILL_BY_LABEL.containsKey(templateHeader)) { + String code = FILL_BY_LABEL.get(templateHeader); + if (isLandQuoteFactCode(code)) { + return code; + } + } + String withFee = templateHeader.endsWith("费用") ? templateHeader : templateHeader + "费用"; + if (FILL_BY_LABEL.containsKey(withFee)) { + String code = FILL_BY_LABEL.get(withFee); + if (isLandQuoteFactCode(code)) { + return code; + } + } + String viaLand = LandQuoteTemplateFields.codeForFeeName(templateHeader); + if (isLandQuoteFactCode(viaLand)) { + return viaLand; + } + } return ""; } @@ -7014,9 +7240,10 @@ public final class QuoteTemplateExcelSupport { if (slash > 0) { primary = text.substring(0, slash).trim(); } - // 空运表头「项目 | 费用名称」两列:项目单独记,勿盖掉费用名称 + // 空运:「项目 | 费用名称」两列;海运:「项目/Items」单列即费目名。 + // 先记下项目候选,有独立费用名称列才认 project,否则当 name(WO032)。 if ("项目".equals(primary) || "项目".equals(text) || text.startsWith("项目/")) { - cols.put("project", c); + cols.put("_projectCandidate", c); } else if (isFeeNameHeader(text)) { cols.put("name", c); } else if (isContainerTypeHeader(text)) { @@ -7041,12 +7268,40 @@ public final class QuoteTemplateExcelSupport { cols.put("unit_price", c); } } + Integer projectCand = cols.get("_projectCandidate"); + cols.remove("_projectCandidate"); + if (projectCand != null) { + Integer nameCol = cols.get("name"); + if (nameCol != null && !nameCol.equals(projectCand)) { + cols.put("project", projectCand); + } else { + cols.put("name", projectCand); + } + } if (!cols.containsKey("name")) { cols.put("name", 0); } return cols; } + /** + * 海运费用区:去掉误识别的 project 列(与 name 同列或仅有「项目/Items」)。 + * 副作用:修改 columns;线程:调用方线程。 + */ + static void stripSeaFakeProjectColumn(JSONObject columns) { + if (columns == null) { + return; + } + String project = StringUtils.trimToEmpty(columns.getString("project")); + if (project.isEmpty()) { + return; + } + String name = StringUtils.trimToEmpty(columns.getString("name")); + if (name.isEmpty() || project.equalsIgnoreCase(name)) { + columns.remove("project"); + } + } + /** * 只从费用表头行取列。禁止扫到「货物数量」把数量列认成 A。 */ diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateSnapshotRenderer.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateSnapshotRenderer.java index ef496bb..438038f 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateSnapshotRenderer.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateSnapshotRenderer.java @@ -16,13 +16,24 @@ import java.io.InputStream; import java.nio.channels.FileChannel; import java.nio.channels.FileLock; import java.nio.charset.StandardCharsets; -import java.nio.file.*; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; import java.util.ArrayList; import java.util.Comparator; +import java.util.Enumeration; import java.util.List; import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import java.util.zip.ZipOutputStream; /** * 管理端报价模板「原表截图」:本机 LibreOffice 把 xlsx 转 PDF,再用 PDFBox 等比例渲 PNG。 @@ -30,7 +41,8 @@ import java.util.stream.Stream; * 为何不用 LibreOffice 直接转 PNG:其 pdf→png 滤镜在部分版本会把页内容非等比拉伸, * 后台预览出现「字被拉高/压扁」。PDFBox 按页 MediaBox 均匀换算 DPI,保证 1:1 像素比例。 * 为何放在主账:后台预览不应依赖智能体进程;与出单 PDF 错开靠文件锁互斥。 - * 禁止 POI 先改打印再转:会丢掉 WPS 主题色与合并条带(跨境集拼蓝底表头拆成独立格)。 + * 禁止 POI 重写整簿再转:会丢掉 WPS 主题色与合并条带(跨境集拼蓝底表头拆成独立格)。 + * 宽表预览:仅 ZIP 改 worksheet 打印属性(横向 + 适应页宽),避免海运/集拼右侧被 A4 竖版裁切。 * 禁止:接到企微回调 / 智能体出单热路径;禁止本类起第二台 soffice 并行。 * 副作用:写临时目录与预览缓存;短阻塞当前 HTTP 线程(仅管理端预览)。 */ @@ -84,8 +96,9 @@ public class QuoteTemplateSnapshotRenderer { try { Path localXlsx = workDir.resolve("source.xlsx"); Files.copy(xlsxPath, localXlsx, StandardCopyOption.REPLACE_EXISTING); - // 禁止 POI 重写后再转:主题色/合并条带会被拆成独立格子(跨境集拼蓝底表头)。 - // 原表打印属性原样交给 soffice,保证后台截图与 WPS 打开一致。 + // 禁止 POI 重写整簿:主题色/合并条带会被拆格。 + // 仅 ZIP 补丁打印:横向 + 适应页宽 + 实占打印区;多页再在 PNG 阶段拼接。 + patchPrintFitLandscape(localXlsx); Path pdf = convert(soffice, localXlsx, workDir, "pdf:calc_pdf_Export", ".pdf"); if (pdf == null || !Files.isRegularFile(pdf)) { throw new IllegalStateException("LibreOffice 未生成 PDF"); @@ -108,17 +121,31 @@ public class QuoteTemplateSnapshotRenderer { } /** - * PDF 首页等比例渲 PNG:PDFBox 按页尺寸 × DPI 均匀换算,禁止非等比拉伸。 - * 渲完后裁掉近白页边,避免 A4 页底大块空白占预览高度。 + * PDF 等比例渲 PNG:PDFBox 按页尺寸 × DPI 均匀换算,禁止非等比拉伸。 + *

+ * 宽表可能被 LibreOffice 拆成多页;只渲第 1 页会丢后续条款/盖章。 + * 多页一律上下拼接(勿左右拼,否则条款会被甩到右侧像「分成两栏」)。 + * 每页先裁近白边再拼,减少页间空白。 */ private byte[] pdfToPngEqualRatio(Path pdf) throws IOException { try (PDDocument doc = PDDocument.load(pdf.toFile())) { - if (doc.getNumberOfPages() < 1) { + int pageCount = doc.getNumberOfPages(); + if (pageCount < 1) { return null; } PDFRenderer renderer = new PDFRenderer(doc); - BufferedImage image = renderer.renderImageWithDPI(0, PREVIEW_DPI, ImageType.RGB); + List pages = new ArrayList<>(); + for (int i = 0; i < pageCount; i++) { + // 每页先裁近白边,再上下拼,避免「第1页底空白 + 第2页顶内容」中间留巨大空隙 + BufferedImage pageImg = renderer.renderImageWithDPI(i, PREVIEW_DPI, ImageType.RGB); + pages.add(trimNearWhiteMargins(pageImg, 6)); + } + BufferedImage image = stitchPdfPages(pages); + int beforeW = image.getWidth(); + int beforeH = image.getHeight(); image = trimNearWhiteMargins(image, 8); + int afterW = image.getWidth(); + int afterH = image.getHeight(); ByteArrayOutputStream buf = new ByteArrayOutputStream(); if (!ImageIO.write(image, "png", buf)) { throw new IOException("ImageIO 无法写出 PNG"); @@ -127,6 +154,41 @@ public class QuoteTemplateSnapshotRenderer { } } + /** + * 把多页 PDF 渲成的图上下拼成一张。 + *

+ * LibreOffice Calc 导出多页几乎总是「行方向续页」(上→下),不是左右分页。 + * 误按横向页左右拼会把第 2 页条款/盖章甩到右侧,中间大块空白——空运也会中招。 + */ + static BufferedImage stitchPdfPages(List pages) { + if (pages == null || pages.isEmpty()) { + return null; + } + if (pages.size() == 1) { + return pages.get(0); + } + int maxW = 0; + int totalH = 0; + for (BufferedImage p : pages) { + maxW = Math.max(maxW, p.getWidth()); + totalH += p.getHeight(); + } + BufferedImage out = new BufferedImage(maxW, totalH, BufferedImage.TYPE_INT_RGB); + java.awt.Graphics2D g = out.createGraphics(); + try { + g.setColor(java.awt.Color.WHITE); + g.fillRect(0, 0, maxW, totalH); + int y = 0; + for (BufferedImage p : pages) { + g.drawImage(p, 0, y, null); + y += p.getHeight(); + } + } finally { + g.dispose(); + } + return out; + } + /** * 裁掉四周近白色边距,四周各留 {@code pad} 像素,避免预览弹窗底下留一大块空白。 *

@@ -221,6 +283,130 @@ public class QuoteTemplateSnapshotRenderer { return r >= 240 && g >= 240 && b >= 240; } + /** + * ZIP 级改打印:横向 + fitToWidth,不经 POI,保留主题色与合并条带。 + * 只动 xl/worksheets/sheet*.xml;失败则保留原文件(预览可能仍裁切)。 + */ + static void patchPrintFitLandscape(Path xlsx) { + if (xlsx == null || !Files.isRegularFile(xlsx)) { + return; + } + Path tmp = xlsx.resolveSibling(xlsx.getFileName().toString() + ".print-fit.tmp"); + try { + try (ZipFile zin = new ZipFile(xlsx.toFile()); + ZipOutputStream zout = new ZipOutputStream(Files.newOutputStream(tmp))) { + Enumeration entries = zin.entries(); + while (entries.hasMoreElements()) { + ZipEntry inEntry = entries.nextElement(); + byte[] data = readAll(zin.getInputStream(inEntry)); + String name = inEntry.getName(); + if (name.startsWith("xl/worksheets/sheet") && name.endsWith(".xml")) { + String xml = new String(data, StandardCharsets.UTF_8); + // 一律横向+适应页宽:海运双栏表列数不多但版心宽,按列数阈值会漏补丁再裁右侧。 + // 多页时靠「每页先裁边再上下拼」消化空运等续页,避免中间留白。 + String patched = patchSheetPrintXml(xml); + if (!patched.equals(xml)) { + data = patched.getBytes(StandardCharsets.UTF_8); + } + } + ZipEntry outEntry = new ZipEntry(name); + zout.putNextEntry(outEntry); + zout.write(data); + zout.closeEntry(); + } + } + try { + Files.move(tmp, xlsx, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException ex) { + Files.move(tmp, xlsx, StandardCopyOption.REPLACE_EXISTING); + } + } catch (Exception e) { + log.warn("预览打印补丁跳过 file={} err={}", xlsx.getFileName(), e.getMessage()); + try { + Files.deleteIfExists(tmp); + } catch (IOException ignored) { + // ignore + } + } + } + + /** 从单元格引用估算用到的最大列号(A=1);估不出时返回 0。 */ + static int estimateUsedCols(String xml) { + if (StringUtils.isBlank(xml)) { + return 0; + } + Matcher m = Pattern.compile("\\br=\"([A-Z]+)(\\d+)\"").matcher(xml); + int max = 0; + while (m.find()) { + String col = m.group(1); + int n = 0; + for (int i = 0; i < col.length(); i++) { + n = n * 26 + (col.charAt(i) - 'A' + 1); + } + if (n > max) { + max = n; + } + } + return max; + } + + /** + * worksheet XML:打开 fitToPage,横向,适应页宽、高度不限页数。 + * 去掉固定 scale,避免宽表仍按 100% 竖版裁右侧。 + */ + static String patchSheetPrintXml(String xml) { + if (StringUtils.isBlank(xml)) { + return xml; + } + String out = xml; + if (Pattern.compile("]*?/>", ""); + out = out.replaceAll("(?i)]*>\\s*", + ""); + } else if (Pattern.compile("]*/>", Pattern.CASE_INSENSITIVE).matcher(out).find()) { + out = out.replaceFirst("(?i)", + ""); + } else if (Pattern.compile("]*>)", + "$1"); + } else { + out = out.replaceFirst("(?i)(]*>)", + "$1"); + } + + Pattern pageSetup = Pattern.compile("(?i)]*?)(/?)>"); + Matcher m = pageSetup.matcher(out); + if (m.find()) { + StringBuffer sb = new StringBuffer(); + m.reset(); + while (m.find()) { + String attrs = m.group(1); + attrs = attrs.replaceAll("(?i)\\s+scale=\"[^\"]*\"", ""); + attrs = attrs.replaceAll("(?i)\\s+fitToWidth=\"[^\"]*\"", ""); + attrs = attrs.replaceAll("(?i)\\s+fitToHeight=\"[^\"]*\"", ""); + attrs = attrs.replaceAll("(?i)\\s+orientation=\"[^\"]*\"", ""); + String selfClose = m.group(2); + String repl; + // A3 横向给宽表更多页宽;仍保留 fitToWidth,避免竖版 A4 裁右侧 + attrs = attrs.replaceAll("(?i)\\s+paperSize=\"[^\"]*\"", ""); + if ("/".equals(selfClose)) { + repl = ""; + } else { + repl = ""; + } + m.appendReplacement(sb, Matcher.quoteReplacement(repl)); + } + m.appendTail(sb); + out = sb.toString(); + } else { + out = out.replaceFirst("(?i)", + ""); + } + return out; + } + /** * 调用 soffice 无界面转换;已在文件锁内,禁止再开并行实例。 */ diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/contracts/tms-domestic-vehicle-mapping-v1.json b/inquiry-api/jeecg-module-inquiry/src/main/resources/contracts/tms-domestic-vehicle-mapping-v1.json new file mode 100644 index 0000000..001da7c --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/contracts/tms-domestic-vehicle-mapping-v1.json @@ -0,0 +1,188 @@ +{ + "schemaVersion": "tms-domestic-vehicle-mapping-v1", + "description": "TMS车型/别名 → 国内整车报价单表头车型;来源 tms国内车型映射表 -ai.xlsx", + "templateToDomesticAliases": { + "4.2米/3T": [ + "3T危险品", + "4.2厢车", + "4.2高栏", + "4.2平板", + "4.2(3T)冷藏车", + "4.2冷危车", + "4.2尾板车", + "3T" + ], + "6.8/5T": [ + "5T冷车", + "5T危险品", + "5T", + "6.8高栏", + "6.8平板", + "6.8厢车", + "6.8(5T)冷藏车", + "6.8危冷车" + ], + "7.6米/8T": [ + "8T", + "7.6(8T)冷藏车", + "8T危险品", + "8T冷车", + "7.6厢车", + "7.6高栏", + "7.6危冷车", + "7.6飞翼", + "7.6危险品车", + "7.6尾板车" + ], + "9.6米/10T": [ + "9.6(10T)冷藏车", + "10T危险品", + "10T", + "10T冷车", + "9.6厢车", + "9.6平板", + "9.6高栏", + "9.6飞翼车", + "9.6气垫车", + "9.6恒温车", + "9.6危冷车", + "9.6尾板车" + ], + "12.5米/40尺": [ + "12.5平板", + "40HQ冷柜", + "40HQ", + "40HQ冷危柜", + "40HQ气垫车", + "12.5飞翼", + "40尺干柜" + ], + "13.5米/45尺": [ + "13.5飞翼", + "45HQ (13.5厢车)", + "45HQ气垫车", + "45HQ危险品", + "45尺高栏气垫车", + "13.5平板", + "13.5高栏", + "45尺干柜", + "45尺冷柜" + ], + "13.75米平板": [ + "13.75平板", + "13.75爬梯车" + ], + "15米/冷柜": [ + "15米冷柜", + "15米箱车", + "15米平板", + "15米低板车" + ], + "16.5米/53尺": [ + "53尺(16.5厢车)", + "16.5飞翼", + "16.5超重特种车", + "16.5平板", + "16.5高栏", + "16.5冷藏" + ], + "17.5米平板": [ + "17.5平板", + "58尺(17.5厢车)", + "17.5爬梯车", + "17.5抽拉板", + "17.5超低板" + ] + }, + "templateToOverseasAliases": { + "4.2米/3T": [ + "3.5T" + ] + }, + "domesticAliasToTemplate": { + "3T危险品": "4.2米/3T", + "4.2厢车": "4.2米/3T", + "4.2高栏": "4.2米/3T", + "4.2平板": "4.2米/3T", + "4.2(3T)冷藏车": "4.2米/3T", + "4.2冷危车": "4.2米/3T", + "4.2尾板车": "4.2米/3T", + "3T": "4.2米/3T", + "4.2米/3T": "4.2米/3T", + "5T冷车": "6.8/5T", + "5T危险品": "6.8/5T", + "5T": "6.8/5T", + "6.8高栏": "6.8/5T", + "6.8平板": "6.8/5T", + "6.8厢车": "6.8/5T", + "6.8(5T)冷藏车": "6.8/5T", + "6.8危冷车": "6.8/5T", + "6.8/5T": "6.8/5T", + "8T": "7.6米/8T", + "7.6(8T)冷藏车": "7.6米/8T", + "8T危险品": "7.6米/8T", + "8T冷车": "7.6米/8T", + "7.6厢车": "7.6米/8T", + "7.6高栏": "7.6米/8T", + "7.6危冷车": "7.6米/8T", + "7.6飞翼": "7.6米/8T", + "7.6危险品车": "7.6米/8T", + "7.6尾板车": "7.6米/8T", + "7.6米/8T": "7.6米/8T", + "9.6(10T)冷藏车": "9.6米/10T", + "10T危险品": "9.6米/10T", + "10T": "9.6米/10T", + "10T冷车": "9.6米/10T", + "9.6厢车": "9.6米/10T", + "9.6平板": "9.6米/10T", + "9.6高栏": "9.6米/10T", + "9.6飞翼车": "9.6米/10T", + "9.6气垫车": "9.6米/10T", + "9.6恒温车": "9.6米/10T", + "9.6危冷车": "9.6米/10T", + "9.6尾板车": "9.6米/10T", + "9.6米/10T": "9.6米/10T", + "12.5平板": "12.5米/40尺", + "40HQ冷柜": "12.5米/40尺", + "40HQ": "12.5米/40尺", + "40HQ冷危柜": "12.5米/40尺", + "40HQ气垫车": "12.5米/40尺", + "12.5飞翼": "12.5米/40尺", + "40尺干柜": "12.5米/40尺", + "12.5米/40尺": "12.5米/40尺", + "13.5飞翼": "13.5米/45尺", + "45HQ (13.5厢车)": "13.5米/45尺", + "45HQ气垫车": "13.5米/45尺", + "45HQ危险品": "13.5米/45尺", + "45尺高栏气垫车": "13.5米/45尺", + "13.5平板": "13.5米/45尺", + "13.5高栏": "13.5米/45尺", + "45尺干柜": "13.5米/45尺", + "45尺冷柜": "13.5米/45尺", + "13.5米/45尺": "13.5米/45尺", + "13.75平板": "13.75米平板", + "13.75爬梯车": "13.75米平板", + "13.75米平板": "13.75米平板", + "15米冷柜": "15米/冷柜", + "15米箱车": "15米/冷柜", + "15米平板": "15米/冷柜", + "15米低板车": "15米/冷柜", + "15米/冷柜": "15米/冷柜", + "53尺(16.5厢车)": "16.5米/53尺", + "16.5飞翼": "16.5米/53尺", + "16.5超重特种车": "16.5米/53尺", + "16.5平板": "16.5米/53尺", + "16.5高栏": "16.5米/53尺", + "16.5冷藏": "16.5米/53尺", + "16.5米/53尺": "16.5米/53尺", + "17.5平板": "17.5米平板", + "58尺(17.5厢车)": "17.5米平板", + "17.5爬梯车": "17.5米平板", + "17.5抽拉板": "17.5米平板", + "17.5超低板": "17.5米平板", + "17.5米平板": "17.5米平板" + }, + "overseasAliasToTemplate": { + "3.5T": "4.2米/3T" + } +} \ No newline at end of file diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_bot_reply_bot_display_name_v1.sql b/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_bot_reply_bot_display_name_v1.sql new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_bot_reply_bot_display_name_v1.sql @@ -0,0 +1 @@ + diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_sys_menu_bot_display_name_v1.sql b/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_sys_menu_bot_display_name_v1.sql new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_sys_menu_bot_display_name_v1.sql @@ -0,0 +1 @@ + diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_vmlDrawing1_wo011_other.vml b/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_vmlDrawing1_wo011_other.vml index ae9bd57..8b13789 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_vmlDrawing1_wo011_other.vml +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_vmlDrawing1_wo011_other.vml @@ -1,6 +1 @@ - - - - - -

 Port to Port, 1工作天回复

1,5,2,2,4,44,2,271Center

 其它, 3工作天回复

11,5,2,22,4,28,3,00Center
\ No newline at end of file + diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/service/impl/InquiryDashboardServiceImplTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/service/impl/InquiryDashboardServiceImplTest.java index 33172a6..c1b15c5 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/service/impl/InquiryDashboardServiceImplTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/service/impl/InquiryDashboardServiceImplTest.java @@ -42,8 +42,16 @@ public class InquiryDashboardServiceImplTest { @Test public void formatAvgSaleIsAmountNotPercent() { Assert.assertEquals("-", InquiryDashboardServiceImpl.formatAvgSale(null)); - Assert.assertEquals("12800", InquiryDashboardServiceImpl.formatAvgSale(new BigDecimal("12800.00"))); + Assert.assertEquals("12,800", InquiryDashboardServiceImpl.formatAvgSale(new BigDecimal("12800.00"))); Assert.assertEquals("12.50", InquiryDashboardServiceImpl.formatAvgSale(new BigDecimal("12.5"))); + Assert.assertEquals("742,187,952.33", + InquiryDashboardServiceImpl.formatAvgSale(new BigDecimal("742187952.33"))); + } + + @Test + public void formatCountUsesThousandSeparator() { + Assert.assertEquals("480", InquiryDashboardServiceImpl.formatCount(480)); + Assert.assertEquals("12,800", InquiryDashboardServiceImpl.formatCount(12800)); } @Test diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryMultiSegmentSupportTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryMultiSegmentSupportTest.java new file mode 100644 index 0000000..d1471ab --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryMultiSegmentSupportTest.java @@ -0,0 +1,44 @@ +package org.jeecg.modules.inquiry.support; + +import org.jeecg.modules.inquiry.entity.InquiryTicket; +import org.junit.jupiter.api.Test; + +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.assertTrue; + +/** + * 多段建单投影:segments_json → 列表列。 + */ +public class InquiryMultiSegmentSupportTest { + + @Test + public void projectTicketColumnsFromSegmentsJson() { + InquiryTicket ticket = new InquiryTicket(); + ticket.setBusinessLine("MULTI"); + ticket.setBizTypeLabel("MULTI"); + String segments = "[{\"index\":1,\"mode\":\"LAND\",\"facts\":{\"品名\":\"茶叶\",\"件数\":\"21件\"," + + "\"毛重\":\"11kg\",\"体积\":\"1.1*2.1*2.2\",\"起运港\":\"北京\",\"目的港\":\"上海\"}}," + + "{\"index\":2,\"mode\":\"AIR\",\"facts\":{\"品名\":\"茶叶\",\"件数\":\"21件\"," + + "\"毛重\":\"11kg\",\"体积\":\"1.1*2.1*2.2\",\"起运港\":\"上海\",\"目的港\":\"北宁\"}}]"; + Map facts = new HashMap<>(); + facts.put("segments_json", segments); + assertTrue(InquiryMultiSegmentSupport.projectTicketColumns(ticket, facts)); + assertEquals("北京", ticket.getOrigin()); + assertEquals("北宁", ticket.getDestination()); + assertEquals("茶叶", ticket.getGoodsName()); + assertEquals(Integer.valueOf(21), ticket.getPieces()); + assertEquals("多段联运", ticket.getBizTypeLabel()); + assertTrue(ticket.getDemandTitle().contains("北京")); + assertTrue(ticket.getDemandTitle().contains("北宁")); + + List lines = InquiryMultiSegmentSupport.segmentSummaryLines( + "{\"segments_json\":" + com.alibaba.fastjson.JSON.toJSONString(segments) + "}"); + assertEquals(2, lines.size()); + assertTrue(lines.get(0).contains("陆运")); + assertTrue(lines.get(1).contains("空运")); + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryRoleBizScopeTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryRoleBizScopeTest.java index fb84b5a..99cff08 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryRoleBizScopeTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryRoleBizScopeTest.java @@ -98,7 +98,8 @@ public class InquiryRoleBizScopeTest { assertTrue(InquiryRoleBizScope.ticketInScope("", "陆运", land)); assertFalse(InquiryRoleBizScope.ticketInScope("AIR", "空运", land)); assertFalse(InquiryRoleBizScope.ticketInScope("SEA", "海运", land)); - assertFalse(InquiryRoleBizScope.ticketInScope("MULTI", "多段联运", land)); + // 多段含陆运段:陆运角色可见,避免后台像没存这张单。 + assertTrue(InquiryRoleBizScope.ticketInScope("MULTI", "多段联运", land)); assertTrue(InquiryRoleBizScope.ticketInScope("AIR", "空运", null)); assertEquals(InquiryRoleBizScope.LAND, InquiryRoleBizScope.classifyTicketBiz("land", "")); assertEquals("", InquiryRoleBizScope.classifyTicketBiz("MULTI", "陆运")); 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 index 3c21f81..edccc8b 100644 --- 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 @@ -966,7 +966,7 @@ public class QuoteTemplateFillTest { assertEquals("板", one.getString("unit")); } } - assertTrue("人工增值费必须并进分桶结果", hasZz); + assertTrue(hasZz, "人工增值费必须并进分桶结果"); assertEquals("空运费", rows.getJSONObject(0).getString("name")); assertEquals("空运段", rows.getJSONObject(0).getString("project")); assertTrue(rows.getJSONObject(0).getString("amount").startsWith("183370")); @@ -2194,6 +2194,7 @@ public class QuoteTemplateFillTest { JSONObject facts = new JSONObject(true); facts.put("origin", "深圳"); facts.put("destination", "春疆货场"); + facts.put("运输类型", "跨境整车"); facts.put("currency", "CNY"); facts.put("route_rows", "[{\"origin\":\"广东省-深圳市\",\"destination\":\"谅山-春疆货场\"," @@ -2202,10 +2203,10 @@ public class QuoteTemplateFillTest { try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) { Sheet sheet = filled.getSheetAt(0); assertEquals("4.1*2.1*2.3M", read(sheet, 8, 3)); - assertEquals("广东省-深圳市", read(sheet, 9, 1)); - // 产品口径 A:单腿门到门两段都写完整起讫 - assertEquals("谅山-春疆货场", read(sheet, 9, 2)); - assertEquals("4000", normalizeMoney(read(sheet, 9, 3))); + // 跨境整车无中转:国内段留空,只填境外段;3T→境外 3.5T + assertEquals("", read(sheet, 9, 1)); + assertEquals("", read(sheet, 9, 2)); + assertEquals("", read(sheet, 9, 3)); assertEquals("", read(sheet, 9, 4)); assertEquals("2.8x1.5x1.6M", read(sheet, 27, 3)); assertEquals("广东省-深圳市", read(sheet, 28, 1)); @@ -2318,6 +2319,7 @@ public class QuoteTemplateFillTest { JSONObject facts = new JSONObject(true); facts.put("origin", "广西壮族自治区"); facts.put("destination", "胡志明"); + facts.put("运输类型", "跨境整车"); facts.put("currency", "CNY"); facts.put("route_rows", "[{\"origin\":\"广西壮族自治区\",\"destination\":\"胡志明\"," @@ -2330,12 +2332,13 @@ public class QuoteTemplateFillTest { // 3T/5T 尺寸副表头必须保留(后台模板有值,出单不能清掉) assertEquals("4.1*2.1*2.3M", read(sheet, 8, 3)); assertEquals("6.5*2.4*2.5M", read(sheet, 8, 4)); - assertEquals("广西壮族自治区", read(sheet, 9, 1)); - // 产品口径 A:国内段/境外段都写完整广西→胡志明 - assertEquals("胡志明", read(sheet, 9, 2)); - assertEquals("12500", normalizeMoney(read(sheet, 9, 3))); + // 跨境整车无中转:国内段留空 + assertEquals("", read(sheet, 9, 1)); + assertEquals("", read(sheet, 9, 2)); + assertEquals("", read(sheet, 9, 3)); assertEquals("广西壮族自治区", read(sheet, 28, 1)); assertEquals("胡志明", read(sheet, 28, 2)); + assertEquals("12500", normalizeMoney(read(sheet, 28, 3))); assertEquals("4.7x1.8x1.8M", read(sheet, 27, 3)); } finally { Files.deleteIfExists(src); @@ -2344,7 +2347,7 @@ public class QuoteTemplateFillTest { } /** - * 跨境整车单腿门到门:国内段与境外段都写完整起讫(产品口径 A)。 + * 跨境整车单腿无中转:只填境外段完整起讫,国内段留空。 */ @Test public void landCrossBorderFtlSplitsDomesticAndOverseasOd() throws Exception { @@ -2401,21 +2404,21 @@ public class QuoteTemplateFillTest { JSONObject facts = new JSONObject(true); facts.put("origin", "郑州"); facts.put("destination", "吉隆坡"); + facts.put("运输类型", "跨境整车"); facts.put("currency", "CNY"); facts.put("transit_days", "N+3"); QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts); try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) { Sheet sheet = filled.getSheetAt(0); assertEquals("起始地", read(sheet, 7, 1)); - assertEquals("郑州", read(sheet, 8, 1)); - assertEquals("吉隆坡", read(sheet, 8, 2)); + // 无中转:国内段留空 + assertEquals("", read(sheet, 8, 1)); + assertEquals("", read(sheet, 8, 2)); assertEquals("起始地", read(sheet, 26, 1)); assertEquals("郑州", read(sheet, 27, 1)); assertEquals("吉隆坡", read(sheet, 27, 2)); - assertEquals("N+3", read(sheet, 8, 15)); - assertEquals("CNY", read(sheet, 8, 16)); - assertEquals("", read(sheet, 8, 17)); - assertEquals("", read(sheet, 8, 18)); + assertEquals("", read(sheet, 8, 15)); + assertEquals("", read(sheet, 8, 16)); assertEquals("N+3", read(sheet, 27, 15)); assertEquals("CNY", read(sheet, 27, 16)); assertEquals("", read(sheet, 27, 17)); @@ -2620,6 +2623,7 @@ public class QuoteTemplateFillTest { JSONObject facts = new JSONObject(true); facts.put("origin", "深圳"); facts.put("destination", "春疆货场"); + facts.put("运输类型", "跨境整车"); facts.put("currency", "CNY"); facts.put("transit_days", "N+1"); facts.put("fuel_surcharge_rate", "8%"); @@ -2634,19 +2638,18 @@ public class QuoteTemplateFillTest { try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) { Sheet sheet = filled.getSheetAt(0); assertEquals("起始地", read(sheet, 6, 1)); - // 产品口径 A:单腿门到门两段都写完整起讫;运输费不能盖掉起点 - assertEquals("深圳", read(sheet, 8, 1)); - assertEquals("春疆货场", read(sheet, 8, 2)); - assertEquals("N+1", read(sheet, 8, 13)); - assertEquals("CNY", read(sheet, 8, 14)); - assertEquals("8%", read(sheet, 8, 15)); - assertEquals("0%", read(sheet, 8, 16)); - assertFalse("CNY".equalsIgnoreCase(read(sheet, 8, 15))); + // 跨境整车无中转:国内段留空;境外段写完整起讫;运输费不能盖掉起点 + assertEquals("", read(sheet, 8, 1)); + assertEquals("", read(sheet, 8, 2)); assertFalse("运输费".equals(read(sheet, 8, 1))); assertEquals("币种", read(sheet, 6, 14)); assertEquals("燃油附加费率", read(sheet, 5, 15)); assertEquals("深圳", read(sheet, 27, 1)); assertEquals("春疆货场", read(sheet, 27, 2)); + assertEquals("N+1", read(sheet, 27, 13)); + assertEquals("CNY", read(sheet, 27, 14)); + assertEquals("8%", read(sheet, 27, 15)); + assertEquals("0%", read(sheet, 27, 16)); } finally { Files.deleteIfExists(src); Files.deleteIfExists(out); @@ -2684,15 +2687,29 @@ public class QuoteTemplateFillTest { } /** - * 报价单「报价日期」格用工单创建日,不拿询价事实里的报价日期(TMS 用)。 + * 报价单「报价日期」格与群显示一致:优先询价事实,不被工单创建日盖掉。 */ @Test - public void buildFillFactsQuoteDatePrefersTicketCreateDate() { + public void buildFillFactsQuoteDatePrefersFactsOverTicketCreateDate() { Map facts = new HashMap<>(); facts.put("报价日期", "2026-10-18"); facts.put("ticket_create_date", "2026-10-15"); facts.put("起运港", "广西"); facts.put("目的港", "肇庆"); + JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl + .buildFillFacts("WO202610150031", facts, new HashMap<>(), "WuJiLin", "AIR"); + assertEquals("2026-10-18", out.getString("quote_date")); + } + + /** + * 没有事实报价日期时,报价单才回退工单创建日。 + */ + @Test + public void buildFillFactsQuoteDateFallsBackToTicketCreateDate() { + Map facts = new HashMap<>(); + facts.put("ticket_create_date", "2026-10-15"); + facts.put("起运港", "广西"); + facts.put("目的港", "肇庆"); JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl .buildFillFacts("WO202610150031", facts, new HashMap<>(), "WuJiLin", "AIR"); assertEquals("2026-10-15", out.getString("quote_date")); @@ -2914,6 +2931,115 @@ public class QuoteTemplateFillTest { } } + /** + * 回归 WO202610180032:始发港费用行很多时必须在「海运费用」前增行, + * 费目名原样写入(不去重),禁止把「项目/Items」当成空运项目列写成「起运港」。 + */ + @Test + public void seaManyPolFeesGrowWithoutAirProjectOverwrite() throws Exception { + Path src = Files.createTempFile("quote-sea-many-src", ".xlsx"); + Path out = Files.createTempFile("quote-sea-many-out", ".xlsx"); + try (Workbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("海运询价单"); + row(sheet, 16).createCell(0).setCellValue("始发港费用/Origin Charges"); + org.apache.poi.ss.usermodel.Row head = row(sheet, 17); + head.createCell(0).setCellValue("项目/Items"); + head.createCell(1).setCellValue("币别/Cur."); + head.createCell(7).setCellValue("金额/Ammount"); + // 模板仅预留 3 行,远少于 24 条费用,必触发增行 + row(sheet, 18).createCell(0).setCellValue("样例费"); + row(sheet, 19).createCell(0).setCellValue("样例费2"); + row(sheet, 20).createCell(0).setCellValue("样例费3"); + row(sheet, 21).createCell(0).setCellValue("海运费用/Ocean Freight"); + org.apache.poi.ss.usermodel.Row oceanHead = row(sheet, 22); + oceanHead.createCell(0).setCellValue("项目/Items"); + oceanHead.createCell(7).setCellValue("金额/Ammount"); + row(sheet, 23).createCell(0).setCellValue("海运费"); + row(sheet, 23).createCell(7).setCellValue("0"); + try (java.io.OutputStream os = Files.newOutputStream(src)) { + wb.write(os); + } + } + JSONObject mapping = new JSONObject(true); + mapping.put("fields", new JSONObject()); + JSONArray regions = new JSONArray(); + JSONObject pol = new JSONObject(true); + pol.put("id", "origin_charges"); + pol.put("title", "始发港费用/Origin Charges"); + pol.put("tmsSource", "polCostItems"); + pol.put("anchor", "海运询价单!A19"); + pol.put("stopAtText", JSONArray.parseArray("[\"海运费用\"]")); + pol.put("grow", "insert_before_stop"); + pol.put("styleCopyRows", 1); + JSONObject polCols = new JSONObject(true); + polCols.put("name", "A"); + polCols.put("currency", "B"); + polCols.put("amount", "H"); + pol.put("columns", polCols); + regions.add(pol); + JSONObject ocean = new JSONObject(true); + ocean.put("id", "ocean_charges"); + ocean.put("title", "海运费用/Ocean Freight"); + ocean.put("tmsSource", "oceanCostItems"); + ocean.put("anchor", "海运询价单!A24"); + ocean.put("stopAtText", JSONArray.parseArray("[\"目的港费用\",\"费用总计\"]")); + ocean.put("grow", "insert_before_stop"); + JSONObject oceanCols = new JSONObject(true); + oceanCols.put("name", "A"); + oceanCols.put("amount", "H"); + ocean.put("columns", oceanCols); + regions.add(ocean); + mapping.put("feeRegions", regions); + + StringBuilder feeJson = new StringBuilder("["); + for (int i = 1; i <= 24; i++) { + if (i > 1) { + feeJson.append(','); + } + // 模拟 TMS 多套同名费:每隔 4 行一套订舱/THC/VGM/封条 + String[] names = {"订舱费(DCF)", "码头操作费(THC)", "VGM申报费", "封条费(SEAI)"}; + String name = names[(i - 1) % 4]; + feeJson.append("{\"name\":\"").append(name).append("\",\"amount\":\"") + .append(100 + i).append("\",\"currency\":\"CNY\",\"quantity\":\"1\",\"unit\":\"柜\"}"); + } + feeJson.append(']'); + JSONObject facts = new JSONObject(true); + facts.put("fee_rows_polCostItems", QuoteTemplateExcelSupport.parseFeeRows(feeJson.toString())); + facts.put("fee_rows_oceanCostItems", QuoteTemplateExcelSupport.parseFeeRows( + "[{\"name\":\"海运费\",\"amount\":\"1230\",\"currency\":\"USD\",\"quantity\":\"1\",\"unit\":\"柜\"}]")); + QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts); + try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) { + Sheet sheet = filled.getSheetAt(0); + int oceanTitle = -1; + int originCount = 0; + int qiyungang = 0; + for (int r = 0; r <= sheet.getLastRowNum(); r++) { + String a = read(sheet, r, 0); + if (a.contains("海运费用") && a.contains("/")) { + oceanTitle = r; + } + if ("起运港".equals(a.trim())) { + qiyungang++; + } + if (a.contains("订舱费") || a.contains("码头操作费") || a.contains("VGM") || a.contains("封条费")) { + originCount++; + } + } + assertTrue(oceanTitle > 0, "ocean title missing"); + assertEquals(24, originCount, "pol fees should all be written without dedupe"); + assertEquals(0, qiyungang, "fee name col must not be overwritten with 起运港"); + // 海运费用标题应被推到始发港 24 行之后 + assertTrue(oceanTitle >= 18 + 24, "oceanTitle=" + oceanTitle); + assertTrue(read(sheet, oceanTitle + 2, 0).contains("海运费") + || read(sheet, oceanTitle + 1, 0).contains("海运费") + || read(sheet, oceanTitle + 3, 0).contains("海运费"), + "ocean freight row near title"); + } finally { + Files.deleteIfExists(src); + Files.deleteIfExists(out); + } + } + /** * 回归 WO202609220088:三段汇总写币别+金额;费用总计写在金额列并带币别,不得落在 B 列。 */ @@ -3011,6 +3137,77 @@ public class QuoteTemplateFillTest { } } + /** + * WO202610140036:TMS「6.8厢车」经 tms-domestic-vehicle-mapping-v1 对到表头「6.8/5T」并写入售价。 + * 不得误写入 4.2米/3T、7.6米/8T 列。 + */ + @Test + public void debugProbeLandFtlTmsVehicle68VanVsTemplateHeader() throws Exception { + Path src = Files.createTempFile("quote-land-ftl-68van-src", ".xlsx"); + Path out = Files.createTempFile("quote-land-ftl-68van-out", ".xlsx"); + try (Workbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("国内整车"); + row(sheet, 7).createCell(1).setCellValue("始发站"); + row(sheet, 7).createCell(2).setCellValue("目的地"); + row(sheet, 7).createCell(3).setCellValue("车型/费用"); + row(sheet, 7).createCell(13).setCellValue("时效"); + org.apache.poi.ss.usermodel.Row veh = row(sheet, 8); + veh.createCell(3).setCellValue("4.2米/3T"); + veh.createCell(4).setCellValue("6.8/5T"); + veh.createCell(5).setCellValue("7.6米/8T"); + row(sheet, 9); + try (java.io.OutputStream os = Files.newOutputStream(src)) { + wb.write(os); + } + } + JSONObject mapping = new JSONObject(true); + mapping.put("sheet", "国内整车"); + JSONArray regions = new JSONArray(); + JSONObject region = new JSONObject(true); + region.put("title", "项目一:整车报价"); + region.put("headerRow", 9); + region.put("dataStartRow", 10); + JSONObject cols = new JSONObject(true); + cols.put("origin", "B"); + cols.put("destination", "C"); + cols.put("transit_time", "N"); + JSONObject rates = new JSONObject(true); + rates.put("4.2米/3T", "D"); + rates.put("6.8/5T", "E"); + rates.put("7.6米/8T", "F"); + cols.put("vehicleRates", rates); + region.put("columns", cols); + regions.add(region); + mapping.put("routeRegions", regions); + JSONObject facts = new JSONObject(true); + facts.put("origin", "广东省-深圳市"); + facts.put("destination", "江苏省-南京市"); + facts.put("运输类型", "国内运输整车"); + facts.put("车型/数量", "6.8厢车"); + facts.put("currency", "CNY"); + facts.put("route_rows", + "[{\"origin\":\"广东省-深圳市\",\"destination\":\"江苏省-南京市\"," + + "\"vehicleType\":\"6.8厢车\",\"salePrice\":\"3200\",\"amount\":\"3200\"," + + "\"transit_time\":\"22H\",\"currency\":\"CNY\"}]"); + QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts); + assertEquals("6.8/5T", QuoteTemplateExcelSupport.mapTmsDomesticAliasToTemplate("6.8厢车")); + assertTrue(QuoteTemplateExcelSupport.vehicleTypeMatchesSection("6.8厢车", "6.8/5T", "")); + assertEquals("3200", QuoteTemplateExcelSupport.resolveRouteVehicleRate( + JSON.parseObject("{\"vehicleType\":\"6.8厢车\",\"salePrice\":\"3200\"}"), + facts, "6.8/5T", 3, "")); + try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) { + Sheet sheet = filled.getSheetAt(0); + assertEquals("广东省-深圳市", read(sheet, 9, 1)); + assertEquals("江苏省-南京市", read(sheet, 9, 2)); + assertEquals("", read(sheet, 9, 3)); + assertEquals("3200", normalizeMoney(read(sheet, 9, 4))); + assertEquals("", read(sheet, 9, 5)); + } finally { + Files.deleteIfExists(src); + Files.deleteIfExists(out); + } + } + /** * 国内整车:车型格「4.2米/3T」是浅蓝表头,出单后不能变成业务员那种深蓝。 */ 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 cd59bd3..80025d1 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 @@ -1,11 +1,12 @@ # 友通达新系统 · JeecgBoot 配置(空白低代码主账) # 激活:spring.profiles.active=inquiry -# 端口/库一律环境变量;禁止写现网 jeecg-boot 库、禁止占 8080。 +# 端口/库一律环境变量;禁止写现网 jeecg-boot 库。 +# 现网旧 Jeecg 已停,新主账默认占用 8080(可用 LEDGER_PORT 覆盖)。 # # 本文件不含密钥。口令从进程环境或 E:\wwwroot\.env 注入。 server: - port: ${LEDGER_PORT:8180} + port: ${LEDGER_PORT:8080} tomcat: max-swallow-size: -1 error: diff --git a/inquiry-backend/src/hooks/web/useTitle.ts b/inquiry-backend/src/hooks/web/useTitle.ts index 77aa0d4..16483e2 100644 --- a/inquiry-backend/src/hooks/web/useTitle.ts +++ b/inquiry-backend/src/hooks/web/useTitle.ts @@ -6,6 +6,10 @@ import { useRouter } from 'vue-router'; import { useLocaleStore } from '/@/store/modules/locale'; import { REDIRECT_NAME } from '/@/router/constant'; +import { + DEFAULT_ADMIN_SITE_TITLE, + adminSiteTitle, +} from '/@/views/inquiry/shared/adminSiteTitle'; /** * Listening to page changes and dynamically changing site titles @@ -19,7 +23,7 @@ export function useTitle() { const pageTitle = usePageTitle(); watch( - [() => currentRoute.value.path, () => localeStore.getLocale], + [() => currentRoute.value.path, () => localeStore.getLocale, adminSiteTitle], () => { const route = unref(currentRoute); @@ -28,8 +32,8 @@ export function useTitle() { } const tTitle = t(route?.meta?.title as string); - // 生产 _app.config.js 若漏打 VITE_GLOB_APP_TITLE,title 会是 undefined → 标签出现「报价模板 undefined」 - const appTitle = title || '友通达智能体运营后台'; + // 优先可配置后台名;否则 env;再否则产品默认名 + const appTitle = adminSiteTitle.value || title || DEFAULT_ADMIN_SITE_TITLE; pageTitle.value = tTitle ? `${tTitle} - ${appTitle}` : `${appTitle}`; }, { immediate: true } diff --git a/inquiry-backend/src/layouts/inquiry/index.vue b/inquiry-backend/src/layouts/inquiry/index.vue index ff9f929..42113db 100644 --- a/inquiry-backend/src/layouts/inquiry/index.vue +++ b/inquiry-backend/src/layouts/inquiry/index.vue @@ -12,6 +12,10 @@ import ProtoIcon from '/@/views/inquiry/components/ProtoIcon.vue'; import AdminModal from '/@/views/inquiry/components/AdminModal.vue'; import { canSeeInquiryPage } from '/@/views/inquiry/shared/inquiryPagePerms'; + import { + adminSiteTitle, + refreshAdminSiteTitle, + } from '/@/views/inquiry/shared/adminSiteTitle'; import '/@/views/inquiry/styles/admin-prototype.css'; import '/@/views/inquiry/styles/admin-fidelity.css'; import '/@/views/inquiry/styles/dashboard-prototype.css'; @@ -51,6 +55,7 @@ { title: '系统配置', items: [ + { key: '/inquiry-sys/admin-site-title', label: '显示名称', icon: 'botReply' }, { key: '/inquiry-sys/system-setting', label: '超时响应', icon: 'clock' }, { key: '/inquiry-sys/user', label: '账号管理', icon: 'account' }, { key: '/inquiry-sys/role', label: '角色管理', icon: 'role' }, @@ -96,8 +101,9 @@ .filter((g) => g.items.length > 0), ); - const pageTitle = computed(() => String(route.meta?.title || '友通达智能体运营后台')); + const pageTitle = computed(() => String(route.meta?.title || adminSiteTitle.value)); const crumb = computed(() => (route.meta?.crumb ? String(route.meta.crumb) : '')); + const brandTitle = adminSiteTitle; const username = computed(() => userStore.getUserInfo?.username || ''); const displayName = computed( @@ -185,6 +191,8 @@ onMounted(() => { document.addEventListener('click', onDocClick); + // 登录后拉一次可配置后台名;失败则保持默认「友通达智价通AIQ管理后台」 + void refreshAdminSiteTitle(); }); onUnmounted(() => { document.removeEventListener('click', onDocClick); @@ -195,6 +203,7 @@ themeToken, route, pageTitle, + brandTitle, crumb, username, displayName, @@ -227,7 +236,7 @@
-
友通达智能体运营后台
+
{{ brandTitle }}