From 12219cacb1c79dbbecaa062eaa17f66c3242e73f Mon Sep 17 00:00:00 2001 From: jillion <439912596@qq.com> Date: Tue, 15 Sep 2026 18:12:45 +0800 Subject: [PATCH] =?UTF-8?q?=E8=90=BD=E5=9C=B0=E6=B5=B7=E8=BF=90=E5=8D=8F?= =?UTF-8?q?=E5=90=8C=E7=BE=A4=EF=BC=9A=E6=B5=8B=E6=9C=8D=E9=9D=A0=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E5=AD=98=E6=A1=A3=E5=90=AC=E7=BE=A4=E8=AF=9D=EF=BC=8C?= =?UTF-8?q?=E6=8A=A5=E4=BB=B7=E5=8D=95=E8=83=BD=E5=B8=A6=E4=B8=8A=E5=8D=8F?= =?UTF-8?q?=E5=90=8C=E5=AD=97=E6=AE=B5=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .env.example | 9 + inquiry-agent/agent/channel/outbox/sender.py | 164 +++- inquiry-agent/agent/channel/queue.py | 2 +- inquiry-agent/agent/channel/wecom/parse.py | 11 +- .../agent/channel/wshoto/__init__.py | 6 + inquiry-agent/agent/channel/wshoto/client.py | 197 +++++ inquiry-agent/agent/channel/wshoto/parse.py | 113 +++ inquiry-agent/agent/channel/wshoto/poll.py | 246 ++++++ inquiry-agent/agent/channel/wshoto/rooms.py | 177 ++++ inquiry-agent/agent/config/__init__.py | 17 + inquiry-agent/agent/handlers/group_collab.py | 165 +++- inquiry-agent/agent/jobs/__init__.py | 29 + inquiry-agent/agent/jobs/archive.py | 3 +- inquiry-agent/agent/jobs/archive_poll.py | 12 + inquiry-agent/agent/jobs/pdf_job.py | 26 +- inquiry-agent/agent/ledger/http_ledger.py | 58 ++ inquiry-agent/agent/ledger/memory_ledger.py | 55 +- inquiry-agent/agent/llm/extract_text.py | 72 +- inquiry-agent/agent/policy/air_text_flow.py | 44 +- inquiry-agent/agent/policy/inquiry_copy.py | 118 ++- inquiry-agent/agent/policy/quote_overlay.py | 125 +++ inquiry-agent/agent/policy/sea_group_ops.py | 702 ++++++++++++++++ inquiry-agent/agent/policy/sea_text_flow.py | 66 +- inquiry-agent/agent/redis_coord/keys.py | 5 + .../agent/redis_coord/token_cache.py | 24 +- inquiry-agent/agent/routing/dispatch.py | 18 +- inquiry-agent/agent/routing/group_intent.py | 91 +++ inquiry-agent/agent/schema/field_validate.py | 34 +- inquiry-agent/tests/test_air_text_inquiry.py | 70 +- inquiry-agent/tests/test_continue_ticket.py | 2 +- inquiry-agent/tests/test_sea_text_inquiry.py | 459 ++++++++++- inquiry-agent/tests/test_wshoto_archive.py | 325 ++++++++ .../controller/InquiryAgentController.java | 30 + .../service/IInquiryWorkOrderService.java | 15 + .../impl/InquiryQuoteTemplateServiceImpl.java | 22 +- .../impl/InquiryWorkOrderServiceImpl.java | 143 ++++ .../template/QuoteTemplateExcelSupport.java | 772 +++++++++++++++--- .../contracts/quote-template-mapping-v1.json | 6 +- .../template/QuoteTemplateFillTest.java | 259 ++++++ 39 files changed, 4436 insertions(+), 256 deletions(-) create mode 100644 inquiry-agent/agent/channel/wshoto/__init__.py create mode 100644 inquiry-agent/agent/channel/wshoto/client.py create mode 100644 inquiry-agent/agent/channel/wshoto/parse.py create mode 100644 inquiry-agent/agent/channel/wshoto/poll.py create mode 100644 inquiry-agent/agent/channel/wshoto/rooms.py create mode 100644 inquiry-agent/agent/jobs/archive_poll.py create mode 100644 inquiry-agent/agent/policy/quote_overlay.py create mode 100644 inquiry-agent/agent/policy/sea_group_ops.py create mode 100644 inquiry-agent/agent/routing/group_intent.py create mode 100644 inquiry-agent/tests/test_wshoto_archive.py diff --git a/.env.example b/.env.example index 94f937e..9746396 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,15 @@ WECOM_CALLBACK_REPLY_TEXT=询价智能体接通成功。已收到:{content} WECOM_OPS_AGENT_ID=1000014 # 会话存档席:后台员工管理里「询价机器人」的企微 userid,每个协同群必拉。可逗号分隔多个。 WECOM_ARCHIVE_SEAT_USER_IDS= +# 微盛会话存档(Worker 按存档席拉群消息)。游标只写 REDIS_KEY_PREFIX,禁止推现网官方 seq。 +WSHOTO_BASE_URL=https://open.wshoto.com +WSHOTO_APP_ID= +WSHOTO_APP_SECRET= +WSHOTO_TENANT_ID= +WSHOTO_POLL_ENABLED=true +WSHOTO_POLL_INTERVAL_SEC=2 +WSHOTO_BOOTSTRAP_LOOKBACK_SEC=10800 +WSHOTO_ATTACHMENT_MIN_INTERVAL_MS=1050 DEV_INJECT_ENABLED=true # 本机端口(切流前并存:勿占用现网 8080/8810/8813) diff --git a/inquiry-agent/agent/channel/outbox/sender.py b/inquiry-agent/agent/channel/outbox/sender.py index 132d801..d198da3 100644 --- a/inquiry-agent/agent/channel/outbox/sender.py +++ b/inquiry-agent/agent/channel/outbox/sender.py @@ -68,12 +68,20 @@ class WeComAppClient: """ extra = dict(payload or {}) if extra.get("msgtype") == "file": - return self.send_file( + file_result = self.send_file( touser=touser, filename=str(extra.get("filename") or "quote.xlsx"), filepath=str(extra.get("filepath") or ""), file_b64=str(extra.get("file_b64") or extra.get("fileBase64") or ""), ) + # 成交卡绑在同一条出站:文件 HTTP 成功后再发卡。 + # 企微文件气泡要等素材落地,卡片是即时的;同一毫秒连发,销售会先看到成交跟进。 + if file_result.ok: + gap = float(getattr(self, "_followup_gap_sec", 1.6) or 0) + if gap > 0: + time.sleep(gap) + self._send_followup_card(touser=touser, extra=extra) + return file_result if extra.get("msgtype") == "update_template_card": return self.update_template_card( touser=touser, @@ -142,6 +150,36 @@ class WeComAppClient: logger.info("企微模板卡已置灰 touser=%s", touser) return WeComSendResult(ok=True, errcode=0, errmsg=errmsg, raw=data) + def _send_followup_card(self, *, touser: str, extra: dict[str, Any]) -> None: + """ + 报价文件发出后再发跟进卡。失败不回滚文件、不触发整条重试。 + + 跟进卡单独入队会被另一条出站线程抢走,销售就会先看到成交跟进。 + """ + card = extra.get("followup_card") + if not isinstance(card, dict) or not card: + nested = extra.get("followup_payload") + if isinstance(nested, dict): + card = nested.get("template_card") + if not isinstance(card, dict) or not card: + return + result = self._post_message( + { + "touser": touser, + "msgtype": "template_card", + "agentid": self._agent_id, + "template_card": card, + "enable_id_trans": 0, + }, + preview="[followup_card]", + ) + if not result.ok: + logger.warning( + "报价文件已发出,跟进卡失败 err=%s:%s", + result.errcode, + result.errmsg, + ) + def send_file( self, *, @@ -283,12 +321,21 @@ class WeComAppClient: logger.info("企微群已补人 chat=%s", chat_id) return {"ok": True, "error": ""} - def send_group(self, *, chat_id: str, content: str) -> dict[str, Any]: + def send_group( + self, + *, + chat_id: str, + content: str, + mention_userids: list[str] | None = None, + ) -> dict[str, Any]: """ 向应用群发文本。必须走 appchat/send,不能当私聊 message/send。 + mention_userids 只给调用方记账,不传给企微。 + 正文开头已经写了 @姓名;再传企微点人名单,结尾会再点一次。 失败回 ok=False,由调用方决定是否重试;不抛给回调线程。 """ + _ = mention_userids if not chat_id or not content: return {"ok": False, "error": "chat_or_content"} if not self._corp_id or not self._secret: @@ -299,7 +346,7 @@ class WeComAppClient: logger.exception("群消息取 token 失败") return {"ok": False, "error": str(exc)} url = f"{self._api_base}/cgi-bin/appchat/send" - body = {"chatid": chat_id, "msgtype": "text", "text": {"content": content}} + body: dict[str, Any] = {"chatid": chat_id, "msgtype": "text", "text": {"content": content}} try: with httpx.Client(timeout=self._timeout) as client: resp = client.post(url, params={"access_token": token}, json=body) @@ -313,6 +360,117 @@ class WeComAppClient: return {"ok": False, "error": str(data.get("errmsg") or f"errcode_{errcode}")} return {"ok": True, "error": ""} + def send_group_file( + self, + *, + chat_id: str, + filename: str, + filepath: str = "", + file_b64: str = "", + ) -> dict[str, Any]: + """ + 向应用群发文件:先上传临时素材,再 appchat/send。 + + 与私聊 send_file 相同素材接口,但消息必须走群,不能 message/send。 + 失败回 ok=False,不抛给回调线程。 + """ + if not chat_id: + return {"ok": False, "error": "chat_id"} + raw = b"" + if filepath: + try: + from pathlib import Path + + raw = Path(filepath).read_bytes() + except OSError as exc: + return {"ok": False, "error": f"read_file:{exc}"} + elif file_b64: + import base64 + + try: + raw = base64.b64decode(file_b64) + except Exception as exc: # noqa: BLE001 + return {"ok": False, "error": f"b64:{exc}"} + if not raw: + return {"ok": False, "error": "file_empty"} + media_id, up = self._upload_media(filename=filename or "quote.xlsx", content=raw) + if not media_id: + return {"ok": False, "error": up.errmsg or "upload_fail"} + return self._post_appchat( + { + "chatid": chat_id, + "msgtype": "file", + "file": {"media_id": media_id}, + } + ) + + def send_group_card(self, *, chat_id: str, template_card: dict[str, Any]) -> dict[str, Any]: + """ + 向应用群发卡。企微 appchat 对 template_card 支持不稳定,失败由调用方已发过文本兜底。 + """ + if not chat_id or not template_card: + return {"ok": False, "error": "chat_or_card"} + return self._post_appchat( + { + "chatid": chat_id, + "msgtype": "template_card", + "template_card": template_card, + } + ) + + def dismiss_group(self, *, chat_id: str, userids: list[str]) -> dict[str, Any]: + """ + 解散应用群:官方无 dismiss,改为 appchat/update 移出成员。 + + 不抛给回调。移完人不改工单六态。 + """ + users = [str(x).strip() for x in (userids or []) if str(x).strip()] + if not chat_id or not users: + return {"ok": False, "error": "chat_or_users"} + try: + token = self._get_token() + except Exception as exc: # noqa: BLE001 + logger.exception("解散群取 token 失败") + return {"ok": False, "error": str(exc)} + url = f"{self._api_base}/cgi-bin/appchat/update" + body = {"chatid": chat_id, "del_user_list": users} + try: + with httpx.Client(timeout=self._timeout) as client: + resp = client.post(url, params={"access_token": token}, json=body) + data = resp.json() + except Exception as exc: # noqa: BLE001 + logger.exception("企微解散群网络失败") + return {"ok": False, "error": str(exc)} + errcode = int(data.get("errcode") or 0) + if errcode != 0: + logger.warning("企微解散群失败 errcode=%s", errcode) + return {"ok": False, "error": str(data.get("errmsg") or f"errcode_{errcode}")} + logger.info("企微群已移出成员 chat=%s", chat_id) + return {"ok": True, "error": ""} + + def _post_appchat(self, body: dict[str, Any]) -> dict[str, Any]: + """appchat/send 公共发送。失败不抛。""" + if not self._corp_id or not self._secret: + return {"ok": False, "error": "WECOM_CORP_ID/SECRET 未配置"} + try: + token = self._get_token() + except Exception as exc: # noqa: BLE001 + logger.exception("群发送取 token 失败") + return {"ok": False, "error": str(exc)} + url = f"{self._api_base}/cgi-bin/appchat/send" + try: + with httpx.Client(timeout=self._timeout) as client: + resp = client.post(url, params={"access_token": token}, json=body) + data = resp.json() + except Exception as exc: # noqa: BLE001 + logger.exception("企微 appchat/send 网络失败") + return {"ok": False, "error": str(exc)} + errcode = int(data.get("errcode") or 0) + if errcode != 0: + logger.warning("企微 appchat/send 失败 errcode=%s", errcode) + return {"ok": False, "error": str(data.get("errmsg") or f"errcode_{errcode}")} + return {"ok": True, "error": ""} + def send_text(self, *, touser: str, content: str) -> WeComSendResult: """ 向指定企微 userid 发送文本应用消息。 diff --git a/inquiry-agent/agent/channel/queue.py b/inquiry-agent/agent/channel/queue.py index a315c5d..19b3d2b 100644 --- a/inquiry-agent/agent/channel/queue.py +++ b/inquiry-agent/agent/channel/queue.py @@ -238,7 +238,7 @@ class MemoryMessageStore: return state == TaskState.DONE if state == TaskState.CLAIMED: saw_claim = True - # 单测没有出站线程:一直 PENDING 就不要空等 12 秒。测服有出站线程会立刻认领。 + # 没有出站线程时(单测)不要空等;已被认领则等到发完。 if not saw_claim and state == TaskState.PENDING and time.time() - started >= 0.4: return True if not state and time.time() - started >= 0.3: diff --git a/inquiry-agent/agent/channel/wecom/parse.py b/inquiry-agent/agent/channel/wecom/parse.py index bf32b14..4d850ef 100644 --- a/inquiry-agent/agent/channel/wecom/parse.py +++ b/inquiry-agent/agent/channel/wecom/parse.py @@ -53,6 +53,15 @@ def parse_plain_xml(xml_text: str, *, default_agent_id: str = "") -> InboundMess if chat_id: chat_type = "group" + media: dict[str, object] = {} + if msg_type.lower() in {"file", "image", "video"}: + media = { + "media_id": _text(root, "MediaId") or _text(root, "MediaID"), + "filename": _text(root, "FileName") or _text(root, "Title"), + "file_ext": _text(root, "FileExt"), + "content_type": "", + } + return InboundMessage( sender_id=sender_id, message_id=msg_id, @@ -62,7 +71,7 @@ def parse_plain_xml(xml_text: str, *, default_agent_id: str = "") -> InboundMess chat_type=chat_type, agent_id=agent_id, create_time=create_time, - media={}, + media=media, raw={ "event": event, "event_key": event_key, diff --git a/inquiry-agent/agent/channel/wshoto/__init__.py b/inquiry-agent/agent/channel/wshoto/__init__.py new file mode 100644 index 0000000..2af3f5e --- /dev/null +++ b/inquiry-agent/agent/channel/wshoto/__init__.py @@ -0,0 +1,6 @@ +""" +微盛会话存档通道:把管家查询结果归一成 InboundMessage。 + +本包只做 HTTP 查询与解析。轮询循环在 Worker HTTP 槽(jobs/archive_poll)。 +禁止:推现网官方会话存档 SDK 游标;禁止在本包建工单、调 LLM。 +""" diff --git a/inquiry-agent/agent/channel/wshoto/client.py b/inquiry-agent/agent/channel/wshoto/client.py new file mode 100644 index 0000000..91d43a6 --- /dev/null +++ b/inquiry-agent/agent/channel/wshoto/client.py @@ -0,0 +1,197 @@ +""" +微盛 OpenAPI 客户端:换票 + 拉员工群消息。 + +本文件职责:带超时的 HTTPS GET;token 缓存在本前缀 Redis。 +禁止:在回调线程调用;禁止把 access_token / secret 打进日志;禁止动现网官方 seq。 +""" + +from __future__ import annotations + +import logging +from typing import Any, Optional + +import httpx + +from agent.config import Settings, get_settings +from agent.redis_coord import get_redis_runtime +from agent.redis_coord.client import RedisClient +from agent.redis_coord.token_cache import get_wshoto_token, set_wshoto_token + +logger = logging.getLogger(__name__) + +_TOKEN_PATH = "/openapi/access_token" +_LIST_PATH = "/openapi/chat/message/list" +_HTTP_TIMEOUT = 8.0 + + +class WshotoError(RuntimeError): + """管家接口业务失败(code!=0)。message 不得含密钥。""" + + def __init__(self, code: int, msg: str) -> None: + self.code = int(code) + self.msg = str(msg or "") + super().__init__(f"wshoto code={self.code} msg={self.msg}") + + +def wshoto_ready(settings: Optional[Settings] = None) -> bool: + """有 AppId/Secret 且开关未关,才允许 Worker 去拉。""" + cfg = settings or get_settings() + if not bool(getattr(cfg, "wshoto_poll_enabled", True)): + return False + return bool((cfg.wshoto_app_id or "").strip() and (cfg.wshoto_app_secret or "").strip()) + + +def _is_token_error(code: int, msg: str) -> bool: + text = (msg or "").lower() + return code in {40001, 40014, 42001} or "token" in text or "过期" in (msg or "") + + +class WshotoOpenClient: + """ + 微盛查询客户端。 + + 调用时机:Worker HTTP 槽。失败抛 WshotoError 或返回空列表由调用方决定。 + """ + + def __init__( + self, + settings: Optional[Settings] = None, + *, + redis: Optional[RedisClient] = None, + ) -> None: + self.settings = settings or get_settings() + self._redis = redis + + def _redis_client(self) -> Optional[RedisClient]: + if self._redis is not None: + return self._redis + try: + return get_redis_runtime().client + except Exception: + logger.exception("微盛 token 缓存:Redis 不可用,本次直打换票") + return None + + def _base(self) -> str: + return (self.settings.wshoto_base_url or "https://open.wshoto.com").rstrip("/") + + def fetch_token(self, *, force: bool = False) -> str: + """ + 取 access_token:先读本前缀缓存,没有再换票。 + + 副作用:可能写 Redis token:wshoto。不会推进任何消息游标。 + """ + app_id = (self.settings.wshoto_app_id or "").strip() + secret = (self.settings.wshoto_app_secret or "").strip() + if not app_id or not secret: + raise WshotoError(0, "missing_app_credentials") + cache = self._redis_client() + if cache is not None and not force: + cached = get_wshoto_token(cache, app_id=app_id) + if cached: + return cached + url = f"{self._base()}{_TOKEN_PATH}" + try: + resp = httpx.get( + url, + params={"appid": app_id, "app_secret": secret}, + timeout=_HTTP_TIMEOUT, + ) + resp.raise_for_status() + body = resp.json() + except Exception as exc: # noqa: BLE001 + raise WshotoError(0, f"token_http:{type(exc).__name__}") from exc + code = int(body.get("code") if body.get("code") is not None else -1) + if code != 0: + raise WshotoError(code, str(body.get("msg") or "token_failed")) + data = body.get("data") if isinstance(body.get("data"), dict) else {} + token = str(data.get("access_token") or "").strip() + if not token: + raise WshotoError(code, "empty_access_token") + expires = int(data.get("expires_in") or 7200) + if cache is not None: + set_wshoto_token(cache, app_id=app_id, token=token, ttl_seconds=max(60, expires - 120)) + logger.info("微盛换票成功 expires_in=%s", expires) + return token + + def list_group_messages( + self, + *, + userid: str, + target_id: str = "", + last_seq: int = 0, + page_size: int = 50, + msg_time_start: int = 0, + msg_time_end: int = 0, + ) -> list[dict[str, Any]]: + """ + 拉指定员工的群聊消息(type=1)。 + + last_seq 只是本次查询过滤,不是官方 SDK 消费确认。 + 首次不要传 last_seq=0 翻全量,应配合时间窗。 + """ + token = self.fetch_token() + return self._list_once( + token=token, + userid=userid, + target_id=target_id, + last_seq=last_seq, + page_size=page_size, + msg_time_start=msg_time_start, + msg_time_end=msg_time_end, + retry_token=True, + ) + + def _list_once( + self, + *, + token: str, + userid: str, + target_id: str, + last_seq: int, + page_size: int, + msg_time_start: int, + msg_time_end: int, + retry_token: bool, + ) -> list[dict[str, Any]]: + params: dict[str, Any] = { + "access_token": token, + "type": 1, + "page_size": max(1, min(int(page_size or 50), 100)), + } + if userid: + params["userid"] = userid + if target_id: + params["target_id"] = target_id + if last_seq > 0: + params["last_seq"] = int(last_seq) + if msg_time_start > 0 and msg_time_end > 0: + params["msg_time_start"] = int(msg_time_start) + params["msg_time_end"] = int(msg_time_end) + url = f"{self._base()}{_LIST_PATH}" + try: + resp = httpx.get(url, params=params, timeout=_HTTP_TIMEOUT) + resp.raise_for_status() + body = resp.json() + except Exception as exc: # noqa: BLE001 + raise WshotoError(0, f"list_http:{type(exc).__name__}") from exc + code = int(body.get("code") if body.get("code") is not None else -1) + msg = str(body.get("msg") or "") + if code != 0: + if retry_token and _is_token_error(code, msg): + fresh = self.fetch_token(force=True) + return self._list_once( + token=fresh, + userid=userid, + target_id=target_id, + last_seq=last_seq, + page_size=page_size, + msg_time_start=msg_time_start, + msg_time_end=msg_time_end, + retry_token=False, + ) + raise WshotoError(code, msg or "list_failed") + data = body.get("data") if isinstance(body.get("data"), dict) else {} + records = data.get("records") if isinstance(data, dict) else None + if not isinstance(records, list): + return [] + return [dict(x) for x in records if isinstance(x, dict)] diff --git a/inquiry-agent/agent/channel/wshoto/parse.py b/inquiry-agent/agent/channel/wshoto/parse.py new file mode 100644 index 0000000..941959b --- /dev/null +++ b/inquiry-agent/agent/channel/wshoto/parse.py @@ -0,0 +1,113 @@ +""" +微盛群消息 → InboundMessage。 + +本文件职责:从管家 records 抽出 sender_id / chat_id / 正文 / 附件名。 +禁止:把存档席「询价机器人」当成发言人入业务;禁止再向企微换 ID。 +""" + +from __future__ import annotations + +import json +from typing import Any, Mapping, Optional, Sequence + +from agent.channel.wecom.models import InboundMessage + +# 存档席在群里的显示名;from 偶发不是 userid 时也要丢掉 +SEAT_DISPLAY_NAMES = frozenset({"询价机器人"}) +_SKIP_TYPES = frozenset({"revoke", "recall", "event", "unknown"}) +_FILE_TYPES = frozenset({"file", "image", "video", "emotion", "voice"}) + + +def _as_mapping(raw: Any) -> dict[str, Any]: + if isinstance(raw, Mapping): + return dict(raw) + if isinstance(raw, str) and raw.strip().startswith("{"): + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + return {} + return dict(parsed) if isinstance(parsed, Mapping) else {} + return {} + + +def _create_time(msgtime: Any) -> int: + """微盛 msgtime 常是毫秒;InboundMessage.create_time 用秒。""" + try: + n = int(msgtime or 0) + except (TypeError, ValueError): + return 0 + if n > 10**12: + return n // 1000 + return n + + +def _filename(msg_type: str, content: str, payload: Mapping[str, Any]) -> str: + for key in ("filename", "file_name", "FileName", "title", "name"): + val = str(payload.get(key) or "").strip() + if val: + return val + if msg_type in _FILE_TYPES: + return str(content or "").strip() + return "" + + +def archive_record_to_inbound( + record: Mapping[str, Any], + *, + seat_userids: Sequence[str] = (), +) -> Optional[InboundMessage]: + """ + 一条管家记录变成群入站。 + + 返回 None:不是群、没有发言人、发言人是存档席、或撤回类消息。 + message_id 带 wshoto: 前缀,避免和回调 MsgId 撞车。 + """ + room_id = str(record.get("room_id") or record.get("roomid") or "").strip() + sender_id = str(record.get("from") or record.get("from_id") or "").strip() + if not room_id or not sender_id: + return None + seats = {str(x).strip() for x in seat_userids if str(x).strip()} + if sender_id in seats or sender_id in SEAT_DISPLAY_NAMES: + return None + + msg_type = str(record.get("msgtype") or record.get("msg_type") or "text").strip().lower() + if msg_type in _SKIP_TYPES: + return None + + payload = _as_mapping(record.get("msg_content") or record.get("msgcontent")) + content = str(record.get("content") or payload.get("content") or "").strip() + msgid = str(record.get("msgid") or record.get("msg_id") or "").strip() + seq = record.get("seq") + if not msgid: + msgid = f"{room_id}:{seq or 0}:{sender_id}" + + media: dict[str, Any] = {} + inbound_type = "text" + if msg_type in _FILE_TYPES: + inbound_type = "file" if msg_type == "file" else msg_type + media = { + "filename": _filename(msg_type, content, payload), + "media_id": str(payload.get("sdkfileid") or payload.get("sdk_file_id") or ""), + "file_ext": str(payload.get("fileext") or payload.get("file_ext") or ""), + "content_type": "", + "filesize": payload.get("filesize") or payload.get("file_size") or 0, + } + if not content: + content = str(media.get("filename") or "") + + return InboundMessage( + sender_id=sender_id, + message_id=f"wshoto:{msgid}", + content=content, + msg_type=inbound_type, + chat_id=room_id, + chat_type="group", + create_time=_create_time(record.get("msgtime") or record.get("msg_time")), + media=media, + raw={ + "source": "wshoto_archive", + "seq": seq, + "msgtype": msg_type, + "direction": record.get("direction") or "", + }, + ) diff --git a/inquiry-agent/agent/channel/wshoto/poll.py b/inquiry-agent/agent/channel/wshoto/poll.py new file mode 100644 index 0000000..e74e717 --- /dev/null +++ b/inquiry-agent/agent/channel/wshoto/poll.py @@ -0,0 +1,246 @@ +""" +微盛群消息一轮:按「询价机器人」席位拉新话,写入 inbox。 + +本文件职责:换票查询、归一、入 inbox、推进测服 last_seq。 +Worker 独立线程轮询,不占 HTTP 槽、不堵回调。入队后由 inbox 线程分发。 +禁止:堵回调;禁止推现网官方 SDK 游标;禁止把存档席当发言人。 +""" + +from __future__ import annotations + +import logging +import time +from typing import Any, Callable, Optional, Sequence + +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 ( + list_watched_rooms, + load_last_seq, + mark_room_hot, + save_last_seq, +) +from agent.config import Settings, get_settings +from agent.redis_coord.client import RedisClient + +logger = logging.getLogger(__name__) + +# 新群还没有游标时,只回看最近几分钟,避免每轮翻三小时。 +_NEW_ROOM_LOOKBACK_SEC = 180 + + +def archive_seat_userids(settings: Optional[Settings] = None) -> list[str]: + """ + 会话存档账号 userid。 + + 测服群里的「询价机器人」就是这一席;默认读 WECOM_ARCHIVE_SEAT_USER_IDS(AI001)。 + """ + cfg = settings or get_settings() + raw = str(getattr(cfg, "wecom_archive_seat_user_ids", "") or "").strip() + ids: list[str] = [] + for part in raw.replace(";", ",").split(","): + uid = part.strip() + if uid and uid not in ids: + ids.append(uid) + return ids + + +def _record_seq(record: dict[str, Any]) -> int: + try: + return int(record.get("seq") or 0) + except (TypeError, ValueError): + return 0 + + +def ingest_records( + records: Sequence[dict[str, Any]], + *, + seat_userids: Sequence[str], + min_seq: int = 0, + store: Any = None, +) -> tuple[int, int]: + """ + 把一页管家记录写入 inbox。 + + 返回:(入队成功数, 跳过数)。无 sender / 存档席 / 重复 message_id 都算跳过。 + """ + book = store if store is not None else get_message_store() + enqueued = 0 + skipped = 0 + for rec in records: + if _record_seq(rec) <= min_seq: + skipped += 1 + continue + msg = archive_record_to_inbound(rec, seat_userids=seat_userids) + if msg is None: + skipped += 1 + continue + ok, reason = book.enqueue_inbound(msg) + if ok: + enqueued += 1 + mark_room_hot(msg.chat_id) + logger.info( + "存档入队 sender=%s chat=%s type=%s reason=%s", + msg.sender_id, + msg.chat_id, + msg.msg_type, + reason, + ) + else: + skipped += 1 + return enqueued, skipped + + +def _needs_target(exc: WshotoError) -> bool: + text = (exc.msg or "").lower() + return "target_id" in text or "targetid" in text or "房间" in (exc.msg or "") + + +def poll_once( + *, + client: Optional[WshotoOpenClient] = None, + settings: Optional[Settings] = None, + redis: Optional[RedisClient] = None, + store: Any = None, + now: Optional[Callable[[], float]] = None, +) -> dict[str, Any]: + """ + 拉一页并存入 inbox。 + + 已有 last_seq:按 seq 增量查。没有:只看最近若干小时,避免从 0 翻全公司历史。 + 已登记协同群按房间号拉(不要带存档席 userid,应用群否则给空页)。 + 还没有群时才按席位试拉。 + """ + cfg = settings or get_settings() + if not wshoto_ready(cfg): + return {"ok": False, "reason": "not_configured"} + seats = archive_seat_userids(cfg) + if not seats: + logger.warning("微盛轮询:未配置 WECOM_ARCHIVE_SEAT_USER_IDS,只能靠显示名丢掉询价机器人自己的话") + api = client or WshotoOpenClient(cfg, redis=redis) + lookback = max(60, int(getattr(cfg, "wshoto_bootstrap_lookback_sec", 10800) or 10800)) + page_size = int(getattr(cfg, "wshoto_page_size", 50) or 50) + clock = now or time.time + rooms = list_watched_rooms(redis=redis) + if rooms: + # 应用群必须带房间号、不要带存档席 userid,否则管家返回空页。 + records, mode, last = _poll_by_rooms( + api, + redis=redis, + page_size=page_size, + lookback=lookback, + clock=clock, + ) + else: + seat = seats[0] if seats else "" + last = load_last_seq(f"seat:{seat or 'default'}", redis=redis) + time_start = 0 + time_end = 0 + if last <= 0: + time_end = int(clock()) + time_start = time_end - lookback + try: + records = api.list_group_messages( + userid=seat, + last_seq=last, + page_size=page_size, + msg_time_start=time_start, + msg_time_end=time_end, + ) + mode = "seat" + except WshotoError as exc: + if not _needs_target(exc): + logger.warning("微盛按席拉取失败 code=%s msg=%s", exc.code, exc.msg) + return {"ok": False, "reason": "list_failed", "code": exc.code} + records, mode, last = _poll_by_rooms( + api, + redis=redis, + page_size=page_size, + lookback=lookback, + clock=clock, + ) + # 按房间拉时各群自己的 last_seq 已经过滤过,这里不要再用席游标误丢。 + ingest_min = 0 if mode == "rooms" else last + enqueued, skipped = ingest_records( + records, + seat_userids=seats, + min_seq=ingest_min, + store=store, + ) + max_seq = last + for rec in records: + seq = _record_seq(rec) + if seq > max_seq: + max_seq = seq + if max_seq > last and not rooms: + save_last_seq(f"seat:{(seats[0] if seats else 'default')}", max_seq, redis=redis) + logger.info( + "微盛轮询 mode=%s fetched=%s enqueued=%s skipped=%s last_seq=%s→%s", + mode, + len(records), + enqueued, + skipped, + last, + max_seq, + ) + return { + "ok": True, + "mode": mode, + "fetched": len(records), + "enqueued": enqueued, + "skipped": skipped, + "last_seq": max_seq, + } + + +def _poll_by_rooms( + api: WshotoOpenClient, + *, + redis: Optional[RedisClient], + page_size: int, + lookback: int, + clock: Callable[[], float], +) -> tuple[list[dict[str, Any]], str, int]: + """只拉已登记协同群。应用群不要带存档席 userid,否则管家给空页。""" + rooms = list_watched_rooms(redis=redis) + if not 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), + ) + merged: list[dict[str, Any]] = [] + floor = 0 + for room in rooms: + scope = f"room:{room}" + last = load_last_seq(scope, redis=redis) + time_start = 0 + time_end = 0 + if last <= 0: + time_end = int(clock()) + time_start = time_end - min(lookback, _NEW_ROOM_LOOKBACK_SEC) + try: + rows = api.list_group_messages( + userid="", + target_id=room, + last_seq=last, + page_size=page_size, + msg_time_start=time_start, + msg_time_end=time_end, + ) + except WshotoError as exc: + logger.warning("微盛房间拉取失败 chat=%s code=%s msg=%s", room, exc.code, exc.msg) + continue + max_seq = last + for rec in rows: + seq = _record_seq(rec) + if seq > max_seq: + max_seq = seq + if max_seq > last: + save_last_seq(scope, max_seq, redis=redis) + if last > floor: + floor = last + merged.extend(rows) + return merged, "rooms", floor diff --git a/inquiry-agent/agent/channel/wshoto/rooms.py b/inquiry-agent/agent/channel/wshoto/rooms.py new file mode 100644 index 0000000..02f3265 --- /dev/null +++ b/inquiry-agent/agent/channel/wshoto/rooms.py @@ -0,0 +1,177 @@ +""" +测服自己的微盛监听名单与 last_seq。 + +本文件职责:把协同群 chat_id、按席/按房间的查询游标写到 REDIS_KEY_PREFIX 下。 +禁止:写现网 ytd:prod: 前缀;禁止把游标当六态。 +线程:可被建群线程与 Worker 轮询同时调用,只做单 key 读写。 +""" + +from __future__ import annotations + +import logging +import threading +import time +from typing import Optional + +from agent.redis_coord import get_redis_runtime +from agent.redis_coord.client import RedisClient +from agent.redis_coord.keys import WSHOTO_LAST_SEQ, WSHOTO_ROOM_TICKET, WSHOTO_ROOMS + +logger = logging.getLogger(__name__) + +# 建群后或刚听到群话:立刻唤醒轮询,并在热窗内更勤地扫。 +_poll_wake = threading.Event() +_hot_until: dict[str, float] = {} +_hot_lock = threading.Lock() +HOT_WINDOW_SEC = 90.0 + + +def wake_wshoto_poll() -> None: + """打断轮询等待。建群线程可调;真正拉消息仍在 Worker 轮询线程。""" + _poll_wake.set() + + +def wait_wshoto_poll(timeout: float) -> bool: + """等下一轮或被唤醒。返回是否被踢醒。""" + fired = _poll_wake.wait(timeout=max(0.1, float(timeout))) + if fired: + _poll_wake.clear() + return fired + + +def mark_room_hot(chat_id: str, *, ttl_sec: float = HOT_WINDOW_SEC) -> None: + """这群接下来几十秒更勤扫:刚建群或刚有人说话。""" + raw = (chat_id or "").strip() + if not raw: + return + until = time.time() + max(5.0, float(ttl_sec)) + with _hot_lock: + _hot_until[raw] = until + wake_wshoto_poll() + + +def has_hot_room() -> bool: + """是否还有热窗内的协同群。""" + 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()) + + +def _client(redis: Optional[RedisClient] = None) -> Optional[RedisClient]: + if redis is not None: + return redis + try: + return get_redis_runtime().client + except Exception: + logger.exception("微盛监听名单:Redis 不可用") + return None + + +def watch_collab_room( + chat_id: str, + *, + work_order_no: str = "", + redis: Optional[RedisClient] = None, +) -> None: + """ + 建群成功后登记 chat_id,供按房间补拉。 + + 可同时记下房间→工单号,主账按群查找暂时不可用时也能对上单。 + 副作用:改 Redis。失败只打日志,不挡销售侧建群。 + """ + raw = (chat_id or "").strip() + if not raw: + return + client = _client(redis) + if client is None: + return + key = client.key(WSHOTO_ROOMS) + current = client.raw.get(key) or "" + if isinstance(current, bytes): + current = current.decode("utf-8") + rooms = {x.strip() for x in str(current).split(",") if x.strip()} + rooms.add(raw) + client.raw.set(key, ",".join(sorted(rooms))) + no = (work_order_no or "").strip() + if no: + client.raw.set(client.key(WSHOTO_ROOM_TICKET, raw), no) + logger.info("存档监听已登记群数=%s", len(rooms)) + mark_room_hot(raw) + + +def ticket_no_for_room(chat_id: str, *, redis: Optional[RedisClient] = None) -> str: + """用协同群 chat_id 找回工单号(测服 Redis 短索引,不是六态)。""" + raw = (chat_id or "").strip() + if not raw: + return "" + client = _client(redis) + if client is None: + return "" + val = client.raw.get(client.key(WSHOTO_ROOM_TICKET, raw)) + if val is None: + return "" + if isinstance(val, bytes): + val = val.decode("utf-8") + return str(val).strip() + + +def unwatch_collab_room(chat_id: str, *, redis: Optional[RedisClient] = None) -> None: + """协同结束后去掉监听,避免解散群还被当成活群补拉。""" + raw = (chat_id or "").strip() + if not raw: + return + client = _client(redis) + if client is None: + return + key = client.key(WSHOTO_ROOMS) + current = client.raw.get(key) or "" + if isinstance(current, bytes): + current = current.decode("utf-8") + rooms = {x.strip() for x in str(current).split(",") if x.strip() and x.strip() != raw} + client.raw.set(key, ",".join(sorted(rooms))) + client.raw.delete(client.key(WSHOTO_ROOM_TICKET, raw)) + + +def list_watched_rooms(*, redis: Optional[RedisClient] = None) -> list[str]: + """当前要按房间补拉的协同群。""" + client = _client(redis) + if client is None: + return [] + current = client.raw.get(client.key(WSHOTO_ROOMS)) or "" + if isinstance(current, bytes): + current = current.decode("utf-8") + return [x.strip() for x in str(current).split(",") if x.strip()] + + +def load_last_seq(scope: str, *, redis: Optional[RedisClient] = None) -> int: + """ + 读取本前缀下的 last_seq。 + + scope:seat:AI001 或 room:wrxxx。没有记录返回 0(调用方改走时间窗,避免从 0 翻全量历史)。 + """ + client = _client(redis) + if client is None: + return 0 + val = client.raw.get(client.key(WSHOTO_LAST_SEQ, scope)) + if val is None: + return 0 + try: + return int(val) + except (TypeError, ValueError): + return 0 + + +def save_last_seq(scope: str, seq: int, *, redis: Optional[RedisClient] = None) -> None: + """推进测服自己的查询游标。seq 必须单调不减。""" + if seq <= 0: + return + client = _client(redis) + if client is None: + return + prev = load_last_seq(scope, redis=client) + if seq < prev: + return + client.raw.set(client.key(WSHOTO_LAST_SEQ, scope), str(int(seq))) diff --git a/inquiry-agent/agent/config/__init__.py b/inquiry-agent/agent/config/__init__.py index c6567a8..34773e4 100644 --- a/inquiry-agent/agent/config/__init__.py +++ b/inquiry-agent/agent/config/__init__.py @@ -64,6 +64,23 @@ class Settings(BaseSettings): default="", alias="WECOM_ARCHIVE_SEAT_USER_IDS", ) + # 微盛企微管家:Worker 轮询群消息。游标只写本进程 Redis 前缀,禁止去推现网官方 seq。 + wshoto_base_url: str = Field(default="https://open.wshoto.com", alias="WSHOTO_BASE_URL") + wshoto_app_id: str = Field(default="", alias="WSHOTO_APP_ID") + wshoto_app_secret: str = Field(default="", alias="WSHOTO_APP_SECRET") + wshoto_tenant_id: str = Field(default="", alias="WSHOTO_TENANT_ID") + 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") + wshoto_bootstrap_lookback_sec: int = Field( + default=10800, + alias="WSHOTO_BOOTSTRAP_LOOKBACK_SEC", + ) + wshoto_page_size: int = Field(default=50, alias="WSHOTO_PAGE_SIZE") + wshoto_attachment_min_interval_ms: int = Field( + default=1050, + alias="WSHOTO_ATTACHMENT_MIN_INTERVAL_MS", + ) wecom_callback_token: str = Field(default="", alias="WECOM_CALLBACK_TOKEN") wecom_encoding_aes_key: str = Field(default="", alias="WECOM_ENCODING_AES_KEY") wecom_callback_reply_text: str = Field( diff --git a/inquiry-agent/agent/handlers/group_collab.py b/inquiry-agent/agent/handlers/group_collab.py index 687965c..6c6e534 100644 --- a/inquiry-agent/agent/handlers/group_collab.py +++ b/inquiry-agent/agent/handlers/group_collab.py @@ -1,18 +1,27 @@ """ -意图:海运协同群里补协同字段。 +意图:海运协同群入站(文字意图 / 附件)。 -本文件职责:群聊文字 → 只抽协同六字段 → 静默写主账。 -禁止:回「已记下」;把闲聊/报价金额写成报价版本;改工单六态。 +本文件职责:对上工单后交给 sea_group_ops;一类动作一条分支。 +禁止:改询价七项;终态装没看见;把销售附件当报价。 """ from __future__ import annotations import logging +from typing import Any, Optional from agent.channel.wecom.models import InboundMessage -from agent.llm.extract_text import extract_collab_fields +from agent.policy.sea_group_ops import ( + apply_collab_update, + apply_product_quote, + dissolve_group, + group_client_of, + handle_group_file, + send_group_text, + ticket_no, + ticket_view, +) from agent.policy.sea_text_flow import get_sea_text_flow -from agent.schema.field_validate import SEA_COLLAB_FIELDS logger = logging.getLogger(__name__) @@ -22,12 +31,36 @@ def handle_group_collab( *, flow=None, ledger=None, + injected_intent: str = "", + injected_quote: Optional[dict[str, Any]] = None, ) -> str: """ - 处理一条群聊文字。 + 处理一条群聊入站。兼容旧测试名。 必须能对上已绑定工单,且发言人是被拉进来的销售或产品。 - 识别到协同字段才写库;否则忽略。不回复。 + """ + return handle_group_inbound( + message, + flow=flow, + ledger=ledger, + injected_intent=injected_intent, + injected_quote=injected_quote, + ) + + +def handle_group_inbound( + message: InboundMessage, + *, + flow=None, + ledger=None, + injected_intent: str = "", + injected_quote: Optional[dict[str, Any]] = None, +) -> str: + """ + 群里一条文字或附件。 + + 文字走 DeepSeek A(单测可注入);附件只看扩展名。 + 协同已结束或终态要回话,不能当没看见。 """ if (message.chat_type or "") != "group": return "group_ignore" @@ -41,40 +74,105 @@ def handle_group_collab( ticket = _find_ticket(book, chat_id) if ticket is None: + logger.info("group_inbound 对不上工单 chat=%s", chat_id) return "group_ignore" if not _sender_allowed(ticket, message.sender_id): - logger.info("group_collab 忽略非成员 sender=%s chat=%s", message.sender_id, chat_id) + logger.info("group_inbound 忽略非成员 sender=%s chat=%s", message.sender_id, chat_id) return "group_ignore" - facts = extract_collab_fields(message.content or "") - if not facts: - return "group_ignore" + sess = _session_of_ticket(engine, ticket) + view = ticket_view(ticket) + if view.get("collab_ended") or (sess is not None and getattr(sess, "collab_ended", False)): + from agent.policy import inquiry_copy as copy - if isinstance(ticket, dict): - wo = str(ticket.get("work_order_no") or "") - else: - wo = str(getattr(ticket, "work_order_no", "") or "") - if not wo: - return "group_ignore" + send_group_text( + group_client_of(engine), + chat_id, + copy.group_dissolved(work_order_no=ticket_no(ticket)), + ) + return "group_already_ended" - engine.apply_collab_facts( - work_order_no=wo, - facts=facts, - sender_id=message.sender_id, + kind = (message.msg_type or "text").lower() + if kind in {"file", "image", "video"}: + media = dict(message.media or {}) + filename = str( + media.get("filename") or media.get("FileName") or (message.content or "") + ).strip() + return handle_group_file( + flow=engine, + ticket=ticket, + sender_id=message.sender_id, + filename=filename, + media=media, + sess=sess, + ) + + from agent.routing.group_intent import ( + INTENT_DISSOLVE, + INTENT_FIELDS, + INTENT_QUOTE, + classify_group_text, ) - logger.info( - "group_collab 写入 wo=%s keys=%s sender=%s", - wo, - sorted(k for k in facts if k in SEA_COLLAB_FIELDS), - message.sender_id, - ) - return "collab_field_saved" + + intent = classify_group_text(message.content or "", injected=injected_intent) + if intent == INTENT_FIELDS: + return apply_collab_update( + flow=engine, + ticket=ticket, + text=message.content or "", + sender_id=message.sender_id, + sess=sess, + ) + if intent == INTENT_QUOTE: + return apply_product_quote( + flow=engine, + ticket=ticket, + text=message.content or "", + sender_id=message.sender_id, + sess=sess, + injected_quote=injected_quote, + ) + if intent == INTENT_DISSOLVE: + return dissolve_group(flow=engine, ticket=ticket, sess=sess) + logger.info("group_inbound 其它意图 chat=%s intent=%s", chat_id, intent) + return "group_ignore" def _find_ticket(book: object, chat_id: str): finder = getattr(book, "find_by_collab_chat", None) if callable(finder): - return finder(chat_id=chat_id) + hit = finder(chat_id=chat_id) + if hit is not None: + return hit + try: + from agent.channel.wshoto.rooms import ticket_no_for_room + + no = ticket_no_for_room(chat_id) + except Exception: + logger.exception("存档房间反查工单失败 chat=%s", chat_id) + no = "" + if not no: + return None + getter = getattr(book, "get_ticket", None) + if callable(getter): + hit = getter(work_order_no=no) + if hit is not None: + return hit + getter = getattr(book, "get_for_agent", None) + if callable(getter): + return getter(work_order_no=no, sender_id="") + return None + + +def _session_of_ticket(engine: object, ticket: object): + """用工单号找回销售侧书签,方便写 collab_facts / deal_version。""" + no = ticket_no(ticket) + getter = getattr(engine, "session_by_work_order", None) + if callable(getter) and no: + return getter(no) + by_ticket = getattr(engine, "_by_ticket", None) + if isinstance(by_ticket, dict) and no: + return by_ticket.get(no) return None @@ -82,12 +180,9 @@ def _sender_allowed(ticket: object, sender_id: str) -> bool: uid = (sender_id or "").strip() if not uid: return False - if isinstance(ticket, dict): - sales = str(ticket.get("sales_wecom_id") or ticket.get("salesWecomId") or "") - members = ticket.get("collab_member_ids") or ticket.get("memberIds") or [] - else: - sales = str(getattr(ticket, "sales_wecom_id", "") or "") - members = list(getattr(ticket, "collab_member_ids", []) or []) + view = ticket_view(ticket) + sales = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "") + members = view.get("collab_member_ids") or view.get("memberIds") or [] if uid == sales: return True return uid in {str(x) for x in members} diff --git a/inquiry-agent/agent/jobs/__init__.py b/inquiry-agent/agent/jobs/__init__.py index 829ae1b..8b9b0b2 100644 --- a/inquiry-agent/agent/jobs/__init__.py +++ b/inquiry-agent/agent/jobs/__init__.py @@ -110,6 +110,7 @@ class WorkerRuntime: threading.Thread(target=self._llm_loop, name="llm-loop", daemon=True), threading.Thread(target=self._recognition_loop, name="recog-loop", daemon=True), threading.Thread(target=self._archive_loop, name="archive-loop", daemon=True), + threading.Thread(target=self._wshoto_poll_loop, name="wshoto-poll", daemon=True), threading.Thread(target=self._pdf_loop, name="pdf-loop", daemon=True), ] for t in self._threads: @@ -241,6 +242,34 @@ class WorkerRuntime: self.http_jobs.run(_job, label="archive") time.sleep(0.2) + def _wshoto_poll_loop(self) -> None: + """ + 独立线程轮询微盛群消息,写入 inbox。 + + 不占回调、不占 HTTP 槽、不与 soffice 串队。建群或刚有人说话会立刻再扫。 + 未配钥匙时只空等,避免打无密钥请求。 + """ + from agent.channel.wshoto.client import wshoto_ready + from agent.channel.wshoto.poll import poll_once + from agent.channel.wshoto.rooms import has_hot_room, wait_wshoto_poll + + interval = float(getattr(self.settings, "wshoto_poll_interval_sec", 2.0) or 2.0) + interval = min(30.0, max(1.0, interval)) + logged_skip = False + while not self._stop.is_set(): + if not wshoto_ready(self.settings): + if not logged_skip: + logger.info("微盛轮询未开启:缺少 WSHOTO_APP_ID/SECRET 或开关已关") + logged_skip = True + self._stop.wait(15.0) + continue + try: + poll_once(settings=self.settings, redis=self.redis.client if self.redis else None) + except Exception: + logger.exception("微盛轮询失败") + wait = 1.0 if has_hot_room() else interval + wait_wshoto_poll(wait) + def _pdf_loop(self) -> None: """仅 LibreOffice 单槽消费 PDF Stream。""" if not self.redis: diff --git a/inquiry-agent/agent/jobs/archive.py b/inquiry-agent/agent/jobs/archive.py index cc22750..2b9830b 100644 --- a/inquiry-agent/agent/jobs/archive.py +++ b/inquiry-agent/agent/jobs/archive.py @@ -1,7 +1,8 @@ """ 微盛存档任务壳:独立 Stream,与 LibreOffice 槽隔离。 -本文件职责:enqueue / process_stub;正式下载与登记后续接对象存储 + 主账附件元数据。 +本文件职责:enqueue / process_stub;附件落对象存储后续再接。 +群消息轮询不在本 Stream,见 jobs/archive_poll(Worker HTTP 槽拉微盛)。 禁止:与 soffice 共用阻塞线;禁止把存档成功冒充业务成交成功。 """ diff --git a/inquiry-agent/agent/jobs/archive_poll.py b/inquiry-agent/agent/jobs/archive_poll.py new file mode 100644 index 0000000..da39c2b --- /dev/null +++ b/inquiry-agent/agent/jobs/archive_poll.py @@ -0,0 +1,12 @@ +""" +微盛轮询入口(Worker 槽调用)。 + +实现在 channel/wshoto/poll.py,避免 jobs 包导入把通道单测拖进 Graph。 +""" + +from __future__ import annotations + +from agent.channel.wshoto.client import wshoto_ready +from agent.channel.wshoto.poll import archive_seat_userids, ingest_records, poll_once + +__all__ = ["archive_seat_userids", "ingest_records", "poll_once", "wshoto_ready"] diff --git a/inquiry-agent/agent/jobs/pdf_job.py b/inquiry-agent/agent/jobs/pdf_job.py index 393b9d4..2734188 100644 --- a/inquiry-agent/agent/jobs/pdf_job.py +++ b/inquiry-agent/agent/jobs/pdf_job.py @@ -136,28 +136,30 @@ def _deliver_quote_file(data: dict[str, Any], result: LibreOfficeResult) -> None from agent.policy.inquiry_copy import file_ready store = get_message_store() + quote = data.get("quote") if isinstance(data.get("quote"), dict) else {} + facts = data.get("facts") if isinstance(data.get("facts"), dict) else {} + from agent.policy import inquiry_copy as copy + store.enqueue_outbound( touser=touser, - content=file_ready(kind="PDF", work_order_no=wo, filename=filename), + content="\n".join( + [ + file_ready(kind="PDF", work_order_no=wo, filename=filename), + copy.deal_card(work_order_no=wo, quote=quote, facts=facts), + ] + ), dedupe_key=f"pdf-file:{wo}:{filename}", payload={ "msgtype": "file", "filepath": result.pdf_path, "filename": filename, + "followup_card": copy.deal_wecom_payload( + work_order_no=wo, quote=quote, facts=facts + ).get("template_card") + or {}, }, ) - # 文件先入队,成交卡后入队;出站单线程保证销售先看到文件 - quote = data.get("quote") if isinstance(data.get("quote"), dict) else {} - facts = data.get("facts") if isinstance(data.get("facts"), dict) else {} if wo: - from agent.policy import inquiry_copy as copy - - store.enqueue_outbound( - touser=touser, - content=copy.deal_card(work_order_no=wo, quote=quote, facts=facts), - dedupe_key=f"deal-card:{wo}:{filename}", - payload=copy.deal_wecom_payload(work_order_no=wo, quote=quote, facts=facts), - ) try: from agent.policy.air_text_flow import get_air_text_flow diff --git a/inquiry-agent/agent/ledger/http_ledger.py b/inquiry-agent/agent/ledger/http_ledger.py index aaa09da..5566859 100644 --- a/inquiry-agent/agent/ledger/http_ledger.py +++ b/inquiry-agent/agent/ledger/http_ledger.py @@ -102,6 +102,15 @@ class HttpLedger: return {"ok": False, "found": False, "owned": False, "work_order_no": work_order_no} return out + def get_ticket(self, *, work_order_no: str) -> dict[str, Any] | None: + """用工单号读快照。给群入站在按房间反查时用。找不到返回 None。""" + out = self.get_for_agent(work_order_no=work_order_no, sender_id="") + if not out or (not out.get("found") and not out.get("work_order_no") and not out.get("ok")): + return None + if out.get("found") is False and out.get("ok") is False: + return None + return out + def query_tms( self, *, @@ -224,6 +233,7 @@ class HttpLedger: chat_id: str, member_ids: list[str], product_names: list[str] | None = None, + product_ids: list[str] | None = None, ) -> dict[str, Any]: out = self._post( "/inquiry/agent/ticket/bindCollabGroup", @@ -232,6 +242,7 @@ class HttpLedger: "chatId": chat_id, "memberIds": member_ids, "productNames": product_names or [], + "productIds": product_ids or [], }, idem=f"bind-group:{work_order_no}", ) @@ -261,3 +272,50 @@ class HttpLedger: if not out.get("found") and not out.get("work_order_no"): return None return out + + def end_collab_group(self, *, work_order_no: str) -> dict[str, Any]: + """协同结束,这单不能再拉群。不改六态。""" + return self._post( + "/inquiry/agent/ticket/endCollabGroup", + {"workOrderNo": work_order_no}, + idem=f"end-collab:{work_order_no}", + ) + + def attach_file( + self, + *, + work_order_no: str, + file_name: str, + object_key: str = "", + content_type: str = "", + sender_id: str = "", + ) -> dict[str, Any]: + """群附件元数据写入主账。""" + return self._post( + "/inquiry/agent/ticket/attachFile", + { + "workOrderNo": work_order_no, + "fileName": file_name, + "objectKey": object_key or file_name, + "contentType": content_type, + "senderId": sender_id, + }, + ) + + def upsert_product_quote( + self, + *, + work_order_no: str, + quote: dict[str, Any], + to_status: str = "已报价", + ) -> dict[str, Any]: + """产品报价写入主账并迁已报价。""" + return self._post( + "/inquiry/agent/ticket/upsertProductQuote", + { + "workOrderNo": work_order_no, + "quote": quote, + "toStatus": to_status, + }, + idem=f"product-quote:{work_order_no}:{sorted((quote or {}).keys())}", + ) diff --git a/inquiry-agent/agent/ledger/memory_ledger.py b/inquiry-agent/agent/ledger/memory_ledger.py index c893ec9..bc177f3 100644 --- a/inquiry-agent/agent/ledger/memory_ledger.py +++ b/inquiry-agent/agent/ledger/memory_ledger.py @@ -36,6 +36,10 @@ class MemoryTicket: collab_chat_id: str = "" collab_facts: dict[str, str] = field(default_factory=dict) collab_member_ids: list[str] = field(default_factory=list) + product_ids: list[str] = field(default_factory=list) + product_names: list[str] = field(default_factory=list) + collab_ended: bool = False + attachments: list[dict[str, Any]] = field(default_factory=list) quote_file_ready: bool = False @@ -367,17 +371,64 @@ class MemoryLedger: chat_id: str, member_ids: list[str], product_names: list[str] | None = None, + product_ids: list[str] | None = None, ) -> dict[str, Any]: - """记下协同群,不改六态。""" - _ = product_names + """记下协同群和产品姓名,不改六态。重启后群摘要还要靠这些名字 @ 到人。""" with self._lock: ticket = self._tickets.get(work_order_no) if ticket is None: return {"ok": False, "error": "ticket_not_found"} ticket.collab_chat_id = (chat_id or "").strip() ticket.collab_member_ids = [str(x) for x in member_ids] + ticket.product_ids = [str(x) for x in (product_ids or [])] + ticket.product_names = [str(x).strip() for x in (product_names or []) if str(x).strip()] return {"ok": True, "chat_id": ticket.collab_chat_id} + def end_collab_group(self, *, work_order_no: str) -> dict[str, Any]: + """协同结束:不改六态,这单不能再拉群。""" + with self._lock: + ticket = self._tickets.get(work_order_no) + if ticket is None: + return {"ok": False, "error": "ticket_not_found"} + ticket.collab_ended = True + ticket.collab_facts["__ended"] = "1" + return {"ok": True, "collab_ended": True} + + def attach_file( + self, + *, + work_order_no: str, + file_name: str, + object_key: str = "", + content_type: str = "", + sender_id: str = "", + ) -> dict[str, Any]: + """把群附件记到工单。不改六态。""" + with self._lock: + ticket = self._tickets.get(work_order_no) + if ticket is None: + return {"ok": False, "error": "ticket_not_found"} + row = { + "file_name": file_name, + "object_key": object_key or file_name, + "content_type": content_type, + "sender_id": sender_id, + } + ticket.attachments.append(row) + return {"ok": True, "file_name": file_name} + + def upsert_product_quote( + self, + *, + work_order_no: str, + quote: dict[str, Any], + to_status: str = "已报价", + ) -> dict[str, Any]: + """产品报价盖 TMS 价。""" + return self.upsert_quote( + work_order_no=work_order_no, quote=quote, to_status=to_status + ) + def patch_collab_facts( self, *, diff --git a/inquiry-agent/agent/llm/extract_text.py b/inquiry-agent/agent/llm/extract_text.py index 431b6ec..d6a874e 100644 --- a/inquiry-agent/agent/llm/extract_text.py +++ b/inquiry-agent/agent/llm/extract_text.py @@ -23,12 +23,12 @@ _MODE_WORDS = ( _LABEL_LINE = re.compile( r"^(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|" r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型数量|" - r"贸易条款|货好时间|HS编码|HS|是否含油|是否含电|是否含磁)\s*[::]\s*(.+)$" + r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁)\s*[::]\s*(.+)$" ) _LABEL_INLINE = re.compile( r"(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|" r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型数量|" - r"贸易条款|货好时间|HS编码|HS|是否含油|是否含电|是否含磁)\s*[::]\s*(\S+)" + r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁)\s*[::]\s*(\S+)" ) @@ -118,18 +118,76 @@ def extract_inquiry_snapshot( } -def extract_collab_fields(text: str) -> dict[str, str]: +def extract_collab_fields( + text: str, + *, + allow_b: bool = False, + injected: Optional[dict[str, str]] = None, +) -> dict[str, str]: """ - 群里协同字段:只收带标签的非空值。 + 群里协同字段:标签「键:值」+ 口语含油/含电/含磁、货好时间。 - 闲聊、报价金额、附件本轮不进这里。HS 折成 HS编码。 + 闲聊、报价金额不进这里。商品海关编码 / HS 折成 HS编码。 + allow_b 保留给调用方;协同六项不走询价七项的 B 合同。 """ - from agent.schema.field_validate import SEA_COLLAB_FIELDS, normalize_facts + from agent.schema.field_validate import ( + SEA_COLLAB_FIELDS, + harvest_oral_collab, + normalize_facts, + ) + del allow_b + if injected: + return { + k: str(v).strip() + for k, v in normalize_facts(dict(injected)).items() + if k in SEA_COLLAB_FIELDS and str(v or "").strip() + } labeled = normalize_facts(parse_labeled_facts(text)) + oral = harvest_oral_collab(text) + merged = dict(oral) + merged.update({k: v for k, v in labeled.items() if v}) out: dict[str, str] = {} for key in SEA_COLLAB_FIELDS: - val = (labeled.get(key) or "").strip() + val = (merged.get(key) or "").strip() if val: out[key] = val return out + + +_RE_MONEY = re.compile( + r"(?:USD|CNY|RMB|美金|美元|人民币)?\s*(\d+(?:\.\d+)?)\s*(?:USD|CNY|RMB|美金|美元|人民币|元)?" + r"|(?:USD|CNY|RMB|美金|美元)\s*(\d+(?:\.\d+)?)", + re.I, +) + + +def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = None) -> dict[str, Any]: + """ + 产品文字报价:抽出合计/币别。注入优先。 + + 认不出价格返回空 dict,调用方回「没有识别到报价」。 + """ + if isinstance(injected, dict) and (injected.get("total") or injected.get("fee_rows")): + out = dict(injected) + out.setdefault("source_label", "产品报价") + return out + raw = (text or "").strip() + if not raw: + return {} + hit = _RE_MONEY.search(raw) + if not hit: + return {} + amount = (hit.group(1) or hit.group(2) or "").strip() + if not amount: + return {} + ccy = "USD" + if any(x in raw.upper() for x in ("CNY", "RMB", "人民币", "元")): + ccy = "CNY" + return { + "total": amount, + "currency": ccy, + "source_label": "产品报价", + "raw_text": raw, + "fee_rows": [{"name": "海运费", "amount": amount, "currency": ccy}], + } diff --git a/inquiry-agent/agent/policy/air_text_flow.py b/inquiry-agent/agent/policy/air_text_flow.py index 2c55dc5..181ec05 100644 --- a/inquiry-agent/agent/policy/air_text_flow.py +++ b/inquiry-agent/agent/policy/air_text_flow.py @@ -50,6 +50,8 @@ class FlowSession: invited_userids: tuple[str, ...] = () invited_sales_name: str = "" invited_product_names: tuple[str, ...] = () + deal_version: int = 0 + collab_ended: bool = False class AirTextInquiryFlow: @@ -74,6 +76,14 @@ class AirTextInquiryFlow: with self._lock: return self._by_sender.get(sender_id) + def session_by_work_order(self, work_order_no: str) -> Optional[FlowSession]: + """用工单号找回书签,不校验 sender。群成交卡校验用。""" + no = (work_order_no or "").strip() + if not no: + return None + with self._lock: + return self._by_ticket.get(no) + def session_by_ticket(self, sender_id: str, work_order_no: str) -> Optional[FlowSession]: """ 只查进程内该书签,不读主账。 @@ -298,7 +308,8 @@ class AirTextInquiryFlow: reply("报价单正在生成,请稍候。") return "wait_file" if sess.phase == "wait_deal": - self._emit_deal(reply, sess) + # 成交卡已经绑在报价文件那条出站上。这里再发一张独立卡, + # 会在文件还在上传时被另一条出站线程抢走,销售又先看到成交跟进。 return "wait_deal" if sess.phase == "done": reply(copy.deal_ack(sess.status or "已关闭", sess.work_order_no)) @@ -814,15 +825,33 @@ class AirTextInquiryFlow: file_extra: dict[str, Any], ) -> str: """ - 先发报价文件,等这条出站真正发出,再出成交跟进卡。 + 报价文件与成交跟进走同一条出站:先传文件,成功后再发卡。 - 避免卡片比文件先到销售手机。单测 reply 不回 id 则不等待。 + 两张消息分两条出站时,另一条消费线程会在上传期间把成交卡发出去。 + 单测 reply 不回 id 则不等待。 """ - out_id = reply( - copy.file_ready(kind=kind, work_order_no=sess.work_order_no, filename=filename), - file_extra, + extra = dict(file_extra or {}) + extra["followup_card"] = copy.deal_wecom_payload( + work_order_no=sess.work_order_no, + quote=sess.quote, + facts=sess.facts, + ).get("template_card") or {} + reply( + "\n".join( + [ + copy.file_ready( + kind=kind, work_order_no=sess.work_order_no, filename=filename + ), + copy.deal_card( + work_order_no=sess.work_order_no, + status=sess.status or "已报价", + quote=sess.quote, + facts=sess.facts, + ), + ] + ), + extra, ) - self._wait_outbound_sent(str(out_id or ""), work_order_no=sess.work_order_no, what="报价文件") from agent.redis_coord.quote_file import mark_quote_file_ready mark_quote_file_ready(sess.work_order_no) @@ -830,7 +859,6 @@ class AirTextInquiryFlow: sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE) sess.wait_version += 1 self._save(sess) - self._emit_deal(reply, sess) return "wait_deal" def _emit_deal(self, reply: ReplyFn, sess: FlowSession) -> None: diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py index 95d49e3..f5ad5fb 100644 --- a/inquiry-agent/agent/policy/inquiry_copy.py +++ b/inquiry-agent/agent/policy/inquiry_copy.py @@ -13,7 +13,12 @@ import time import uuid from typing import Any, Iterable -from agent.schema.field_validate import AIR_REQUIRED_FIELDS, SEA_COLLAB_FIELDS, SEA_REQUIRED_FIELDS +from agent.schema.field_validate import ( + AIR_REQUIRED_FIELDS, + SEA_COLLAB_DISPLAY, + SEA_COLLAB_FIELDS, + SEA_REQUIRED_FIELDS, +) # 空运补问:与合同 8 项一致。报价日期默认当天,不进待补充。 _AIR_EXAMPLES = { @@ -379,6 +384,20 @@ def work_order_from_card_meta( return "" +def deal_card_version(card_meta: dict[str, Any] | None = None, *, text: str = "") -> int: + """从成交卡 EventKey 取出 vN;没有则 0(旧私聊卡)。""" + src = card_meta or {} + for raw in ( + str(src.get("event_key") or ""), + str(src.get("task_id") or ""), + text or "", + ): + hit = re.search(r":v(\d+)\s*$", raw) + if hit: + return int(hit.group(1)) + return 0 + + def _work_order_in_token(raw: str) -> str: """从 TaskId / EventKey 里抽出 WO 工单号;没有则空。""" text = (raw or "").strip() @@ -505,19 +524,18 @@ def sea_quote_detail_text( history_work_order_no: str = "", status: str = "已报价", ) -> str: - """海运完整价格明细文本。不受卡片引用条字数限制。""" + """ + 海运完整价格明细文本:来源、工单号、费用行、合计。 + + 时效、报价有效期只在卡片上出现,这里不写,避免和卡重复。 + 不受卡片引用条字数限制。first_or_same 只兼容调用方。 + """ + _ = first_or_same, history_work_order_no, status src = dict(quote or {}) source = str(src.get("source_label") or "TMS标准报价").replace(" ", "") - lines = [source, f"{work_order_no}价格明细"] - lines.extend( - quote_detail_lines( - src, - first_or_same=first_or_same, - history_work_order_no=history_work_order_no, - status=status, - ) + return "\n".join( + [source, f"{work_order_no}价格明细", quote_area_fee_text(src)] ) - return "\n".join(lines) def collab_hint(work_order_no: str) -> str: @@ -957,6 +975,11 @@ def sea_group_no_staff() -> str: return "没有可拉的产品人员。请先在后台「员工管理」维护本线路的海运产品岗后再试。" +def group_sheet_send_fail() -> str: + """模板已填好但没发进群。""" + return "报价单已生成,发到群里失败,请稍后重试。" + + def sea_group_create_fail(*, reason: str = "") -> str: """建群失败,按钮保持可点。""" extra = (reason or "").strip() @@ -978,7 +1001,8 @@ def sea_group_brief( """ 群里自动摘要:@销售 @产品 + 工单号 + 询价字段 + 协同字段 + TMS 报价。 - 协同没填写 -。无价写明暂无匹配报价。 + 三段之间空一行,方便群里扫。协同没填写 -。 + HS编码对外写成商品海关编码。无价只写「TMS 暂无匹配报价」。 """ no = (work_order_no or "").strip() sales = (sales_name or "").strip() or "销售" @@ -988,16 +1012,19 @@ def sea_group_brief( lines.extend( inquiry_field_lines(work_order_no=no, facts=facts, transport_mode="海运")[1:] ) + lines.append("") lines.append("协同补充字段:") collab = dict(collab_facts or {}) for key in SEA_COLLAB_FIELDS: - val = (collab.get(key) or "").strip() or "-" - lines.append(f"{key}:{val}") + label = SEA_COLLAB_DISPLAY.get(key, key) + val = (collab.get(key) or collab.get(label) or "").strip() or "-" + lines.append(f"{label}:{val}") + lines.append("") lines.append("TMS报价:") if has_price and quote: lines.extend(quote_detail_lines(quote)) else: - lines.append("TMS 暂无匹配报价 / 未查到合适报价") + lines.append("TMS 暂无匹配报价") return "\n".join(lines) @@ -1244,6 +1271,7 @@ def deal_wecom_payload( work_order_no: str, quote: dict[str, Any] | None = None, facts: dict[str, str] | None = None, + card_version: int = 0, ) -> dict[str, Any]: """ 成交跟进卡:对齐现网 button_interaction。 @@ -1254,6 +1282,8 @@ def deal_wecom_payload( src = dict(quote or {}) source = str(src.get("source_label") or "TMS标准报价").replace(" ", "") no = (work_order_no or "").strip() + ver = int(card_version or 0) + suffix = f":v{ver}" if ver > 0 else "" return { "msgtype": "template_card", "template_card": { @@ -1270,23 +1300,75 @@ def deal_wecom_payload( { "text": BTN_DEAL, "style": 1, - "key": f"{BTN_DEAL_KEY}:{no}", + "key": f"{BTN_DEAL_KEY}:{no}{suffix}", }, { "text": BTN_LOST, "style": 1, - "key": f"{BTN_LOST_KEY}:{no}", + "key": f"{BTN_LOST_KEY}:{no}{suffix}", }, { "text": BTN_NEGOTIATE, "style": 1, - "key": f"{BTN_NEGOTIATE_KEY}:{no}", + "key": f"{BTN_NEGOTIATE_KEY}:{no}{suffix}", }, ], }, } +def ticket_locked(*, work_order_no: str, status: str) -> str: + """已成交/未成交/已关闭后,群里改字段或再报价。""" + no = (work_order_no or "").strip() or "该工单" + st = (status or "").strip() or "已关闭" + return f"工单{no}{st},不能修改工单信息" + + +def ticket_locked_file(*, work_order_no: str, status: str) -> str: + """终态工单再发 Excel/PDF。""" + no = (work_order_no or "").strip() or "该工单" + st = (status or "").strip() or "已关闭" + return f"工单{no}{st},不能再上传附件" + + +def group_only_excel_pdf() -> str: + return "群里只收 Excel 和 PDF,其它文件不会存入工单" + + +def group_quote_not_recognized() -> str: + return "没有识别到报价,请产品再发一次价格或 Excel/PDF" + + +def group_fields_not_recognized() -> str: + return "听到了。请直接说贸易条款、货好时间、商品海关编码、是否含油/含电/含磁。" + + +def group_deal_stale(*, work_order_no: str) -> str: + """点了上一版成交卡。""" + no = (work_order_no or "").strip() + return f"这张成交确认已过期。工单{no}已有更新的报价,请点击群里最新一条成交跟进。" + + +def group_deal_sales_only() -> str: + return "只有负责该工单的销售可以确认成交状态。" + + +def group_dissolved(*, work_order_no: str) -> str: + no = (work_order_no or "").strip() + return f"群已解散。工单{no}协同已结束。" + + +def group_cannot_recreate(*, work_order_no: str) -> str: + """解散后再点拉群。""" + no = (work_order_no or "").strip() or "该工单" + return f"工单{no}协同已结束,不能再拉群。" + + +def group_deal_mention(*, sales_name: str, work_order_no: str) -> str: + name = (sales_name or "").strip() or "销售" + return f"@{name}\n{deal_card(work_order_no=work_order_no)}" + + def deal_ack(outcome: str, work_order_no: str) -> str: if outcome == "已成交": return f"已记录为「已成交」。工单 {work_order_no} 已关闭智能体跟进。" diff --git a/inquiry-agent/agent/policy/quote_overlay.py b/inquiry-agent/agent/policy/quote_overlay.py new file mode 100644 index 0000000..6b0eefc --- /dev/null +++ b/inquiry-agent/agent/policy/quote_overlay.py @@ -0,0 +1,125 @@ +""" +按海运询价单标签补写协同字段。 + +主账填表只认英文码,货好时间 / 海关编码 / 含油电磁常空着。 +本文件只改 Excel 字节,不打主账、不占回调线程。 +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any, Mapping + +logger = logging.getLogger(__name__) + +# 长的「含油,电,磁」必须排在「是否含油」前面,避免只写到油。 +_COLLAB_LABEL_MARKERS: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("是否含油,电,磁", ("oil_elec_mag", "是否含油,电,磁")), + ("OIL,ELEC,MAG", ("oil_elec_mag", "是否含油,电,磁")), + ("贸易条款", ("贸易条款", "trade_terms")), + ("货好时间", ("货好时间", "cargo_ready_date")), + ("商品海关编码", ("商品海关编码", "HS编码", "海关编码", "hs_code")), + ("HS Code", ("商品海关编码", "HS编码", "海关编码", "hs_code")), + ("是否含油", ("是否含油", "has_oil")), + ("是否含电", ("是否含电", "has_battery")), + ("是否含磁", ("是否含磁", "has_magnet")), +) + + +def _fact_for_markers(facts: Mapping[str, Any], keys: tuple[str, ...]) -> str: + for key in keys: + val = str((facts or {}).get(key) or "").strip() + if val and val != "-": + return val + return "" + + +def overlay_labeled_facts( + *, + facts: Mapping[str, Any], + filepath: str = "", + file_b64: str = "", +) -> dict[str, str]: + """ + 按模板标签再写协同格。失败则原样交回。 + + 副作用:可能写临时 xlsx。 + """ + import base64 + import tempfile + + raw = b"" + if filepath: + try: + raw = Path(filepath).read_bytes() + except OSError as exc: + logger.warning("overlay 读文件失败 err=%s", exc) + if not raw and file_b64 and len(file_b64) > 64: + try: + raw = base64.b64decode(file_b64) + except Exception as exc: # noqa: BLE001 + logger.warning("overlay 解码失败 err=%s", exc) + if not raw: + return {"filepath": filepath, "file_b64": file_b64} + try: + from openpyxl import load_workbook + except ImportError: + return {"filepath": filepath, "file_b64": file_b64} + + tmp = Path(tempfile.mkdtemp(prefix="quote-overlay-")) / "filled.xlsx" + tmp.write_bytes(raw) + written = 0 + try: + wb = load_workbook(tmp) + ws = wb.worksheets[0] + pending: list[tuple[int, int, str]] = [] + used_rows: set[int] = set() + for row in ws.iter_rows(min_row=1, max_row=ws.max_row or 1, max_col=min(ws.max_column or 1, 16)): + for cell in row: + text = str(cell.value or "").strip() + if not text or cell.row in used_rows: + continue + keys = _match_collab_label(text) + if not keys: + continue + val = _fact_for_markers(facts, keys) + if not val: + continue + dest = _label_value_cell(ws, cell) + pending.append((dest.row, dest.column, val)) + used_rows.add(cell.row) + break + for r, c, val in pending: + ws.cell(row=r, column=c, value=val) + written += 1 + wb.save(tmp) + wb.close() + except Exception: + logger.exception("overlay 协同字段失败") + return {"filepath": filepath, "file_b64": file_b64} + + out_b64 = base64.b64encode(tmp.read_bytes()).decode("ascii") + logger.info("overlay 协同字段 written=%s", written) + return {"filepath": str(tmp), "file_b64": out_b64} + + +def _match_collab_label(text: str) -> tuple[str, ...] | None: + raw = (text or "").replace(":", ":").strip() + for marker, keys in _COLLAB_LABEL_MARKERS: + if marker in raw: + return keys + return None + + +def _label_value_cell(ws, label_cell): + """标签右侧格子;合并标签则从合并区右边开始。""" + row_idx = label_cell.row + start_col = label_cell.column + 1 + merged = getattr(ws, "merged_cells", None) + if merged is not None: + for rng in merged.ranges: + if rng.min_row <= row_idx <= rng.max_row and rng.min_col <= label_cell.column <= rng.max_col: + start_col = rng.max_col + 1 + break + return ws.cell(row=row_idx, column=start_col) diff --git a/inquiry-agent/agent/policy/sea_group_ops.py b/inquiry-agent/agent/policy/sea_group_ops.py new file mode 100644 index 0000000..35d1e41 --- /dev/null +++ b/inquiry-agent/agent/policy/sea_group_ops.py @@ -0,0 +1,702 @@ +""" +海运协同群业务:填表进群、补字段重发、产品报价、附件、解散、成交跟进。 + +本文件职责:群里副作用(主账+企微群)。私聊按钮仍走 sea_text_flow.on_button。 +禁止:改询价七项;终态默默忽略;把销售附件当报价。 +""" + +from __future__ import annotations + +import logging +from typing import Any, Optional + +from agent.llm.extract_text import extract_collab_fields, extract_product_quote +from agent.policy.quote_overlay import overlay_labeled_facts +from agent.policy import inquiry_copy as copy +from agent.schema.field_validate import SEA_COLLAB_DISPLAY, SEA_COLLAB_FIELDS + +# 主账填表按英文码取格子;中文键必须同时带上,否则货好时间/条款写不进表。 +_COLLAB_FILL_ALIASES = { + "贸易条款": ("贸易条款", "trade_terms"), + "货好时间": ("货好时间", "cargo_ready_date"), + "HS编码": ("HS编码", "商品海关编码", "海关编码", "hs_code"), + "是否含油": ("是否含油", "has_oil"), + "是否含电": ("是否含电", "has_battery"), + "是否含磁": ("是否含磁", "has_magnet"), +} + +logger = logging.getLogger(__name__) + +TERMINAL = {"已成交", "未成交", "已关闭"} +OPEN_EDIT = {"询价中", "已报价", "协商中"} +EXCEL_PDF = {".xlsx", ".xls", ".xlsm", ".pdf"} + + +def ticket_view(ticket: object) -> dict[str, Any]: + """主账工单或内存票统一成 dict。""" + if ticket is None: + return {} + if isinstance(ticket, dict): + view = dict(ticket) + view.setdefault("work_order_no", view.get("workOrderNo") or "") + view.setdefault("sales_wecom_id", view.get("salesWecomId") or view.get("sales_wecom_id") or "") + view.setdefault("salesWecomId", view.get("sales_wecom_id") or view.get("salesWecomId") or "") + view.setdefault( + "collab_member_ids", + view.get("memberIds") or view.get("collabMemberIds") or view.get("collab_member_ids") or [], + ) + view.setdefault( + "product_ids", + view.get("productIds") or view.get("product_ids") or [], + ) + view.setdefault( + "product_names", + view.get("productNames") or view.get("product_names") or [], + ) + view.setdefault("sales_name", view.get("salesName") or view.get("sales_name") or "") + facts = dict(view.get("collab_facts") or view.get("collabFacts") or {}) + view.setdefault("collab_facts", facts) + flag = str(facts.get("__ended") or view.get("collab_ended") or view.get("collabEnded") or "").strip().lower() + if flag in {"1", "true", "yes"}: + view["collab_ended"] = True + return view + facts = dict(getattr(ticket, "collab_facts", {}) or {}) + ended = bool(getattr(ticket, "collab_ended", False)) or str( + facts.get("__ended") or "" + ).strip().lower() in {"1", "true", "yes"} + return { + "work_order_no": getattr(ticket, "work_order_no", "") or "", + "status": getattr(ticket, "status", "") or "", + "sales_wecom_id": getattr(ticket, "sales_wecom_id", "") or "", + "salesWecomId": getattr(ticket, "sales_wecom_id", "") or "", + "business_line": getattr(ticket, "business_line", "") or "SEA", + "facts": dict(getattr(ticket, "facts", {}) or {}), + "quote": dict(getattr(ticket, "quote", {}) or {}), + "collab_facts": dict(getattr(ticket, "collab_facts", {}) or {}), + "collab_chat_id": getattr(ticket, "collab_chat_id", "") or "", + "collab_member_ids": list(getattr(ticket, "collab_member_ids", []) or []), + "collab_ended": ended, + "product_ids": list(getattr(ticket, "product_ids", []) or []), + "product_names": list(getattr(ticket, "product_names", []) or []), + "sales_name": str(getattr(ticket, "sales_name", "") or ""), + } + + +def ticket_status(ticket: object) -> str: + return str(ticket_view(ticket).get("status") or "").strip() + + +def ticket_no(ticket: object) -> str: + return str(ticket_view(ticket).get("work_order_no") or "").strip() + + +def is_terminal(ticket: object) -> bool: + return ticket_status(ticket) in TERMINAL + + +def is_sales(ticket: object, sender_id: str) -> bool: + uid = (sender_id or "").strip() + view = ticket_view(ticket) + sales = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip() + return bool(uid) and uid == sales + + +def is_product(ticket: object, sender_id: str) -> bool: + """产品岗:成员里且不是销售。""" + uid = (sender_id or "").strip() + if not uid or is_sales(ticket, uid): + return False + view = ticket_view(ticket) + members = {str(x) for x in (view.get("collab_member_ids") or view.get("memberIds") or [])} + products = {str(x) for x in (view.get("product_ids") or [])} + if products: + return uid in products + return uid in members + + +def filename_is_excel_pdf(name: str) -> bool: + raw = (name or "").strip().lower() + return any(raw.endswith(ext) for ext in EXCEL_PDF) + + +def group_client_of(flow: Any) -> Any: + """正式路径没有注入客户端时,走流程自己的默认企微客户端。""" + getter = getattr(flow, "group_client", None) + if callable(getter): + return getter() + return getattr(flow, "_group_client", None) + + +def _archive_seat_ids(flow: Any) -> set[str]: + """会话存档账号(测服 AI001 / 询价机器人)不能出现在群 @ 里。""" + seats: set[str] = {"AI001", "询价机器人"} + getter = getattr(flow, "_archive_seat_userids", None) + if callable(getter): + try: + seats.update(str(x).strip() for x in (getter() or []) if str(x).strip()) + except Exception: + logger.warning("读会话存档账号失败,按默认 AI001 排除") + return {x for x in seats if x} + + +def _staff_display_name(flow: Any, wecom_id: str) -> str: + """按企微 userid 取后台员工姓名;查不到就退回 userid,仍是具体人。""" + uid = (wecom_id or "").strip() + if not uid: + return "" + book = getattr(flow, "ledger", None) or getattr(flow, "_ledger", None) + finder = getattr(book, "get_staff_by_wecom_id", None) if book is not None else None + if callable(finder): + try: + row = finder(wecom_id=uid) or {} + name = str(row.get("name") or "").strip() + if name: + return name + except Exception: + logger.warning("查群成员姓名失败 uid=%s", uid) + return uid + + +def resolve_group_mentions( + flow: Any, + ticket: object, + sess: Any = None, +) -> tuple[str, list[str], list[str]]: + """ + 群摘要必须 @ 到具体销售和每位产品,禁止只写岗位词「销售」。 + + 进程重启后内存会话没了,主账 byCollabChat 也可能不带姓名: + 用销售 userid + 群成员(去掉存档账号)反查员工管理。 + 返回:(销售展示名, 产品展示名列表, 要点到的 userid)。 + """ + view = ticket_view(ticket) + sales_id = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip() + if sess is not None: + sales_id = sales_id or str(getattr(sess, "sales_wecom_id", "") or getattr(sess, "sender_id", "") or "").strip() + members = [ + str(x).strip() + for x in (view.get("collab_member_ids") or view.get("memberIds") or []) + if str(x).strip() + ] + if sess is not None: + for uid in getattr(sess, "invited_userids", ()) or (): + raw = str(uid).strip() + if raw and raw not in members: + members.append(raw) + seats = _archive_seat_ids(flow) + product_ids = [ + str(x).strip() + for x in (view.get("product_ids") or view.get("productIds") or []) + if str(x).strip() and str(x).strip() not in seats + ] + if not product_ids: + product_ids = [m for m in members if m and m != sales_id and m not in seats] + + sales_name = "" + if sess is not None: + sales_name = str(getattr(sess, "invited_sales_name", "") or "").strip() + sales_name = sales_name or str(view.get("sales_name") or view.get("salesName") or "").strip() + if not sales_name or sales_name == "销售": + looked = _staff_display_name(flow, sales_id) if sales_id else "" + if looked: + sales_name = looked + if not sales_name: + sales_name = sales_id or "销售" + + products: list[str] = [] + if sess is not None: + products = [ + str(n).strip() + for n in (getattr(sess, "invited_product_names", None) or []) + if str(n or "").strip() + ] + if not products: + products = [ + str(n).strip() + for n in (view.get("product_names") or view.get("productNames") or []) + if str(n or "").strip() + ] + if not products: + products = [name for name in (_staff_display_name(flow, pid) for pid in product_ids) if name] + + mention_ids: list[str] = [] + if sales_id and sales_id not in seats: + mention_ids.append(sales_id) + for pid in product_ids: + if pid and pid not in mention_ids and pid not in seats: + mention_ids.append(pid) + return sales_name, products, mention_ids + + +def send_group_text( + client: Any, + chat_id: str, + content: str, + mention_userids: Optional[list[str]] = None, +) -> None: + """群文本可带企微 userid,方便客户端真正点到人。旧客户端无此参数就只发正文。""" + if not client or not chat_id or not content: + return + sender = getattr(client, "send_group", None) + if not callable(sender): + return + mentions = [str(x).strip() for x in (mention_userids or []) if str(x).strip()] + try: + sender(chat_id=chat_id, content=content, mention_userids=mentions) + except TypeError: + sender(chat_id=chat_id, content=content) + + +def hydrate_ticket(flow: Any, ticket: object) -> object: + """ + 按群找回的工单若缺询价七项,用工单号+销售再读主账补全。 + + 测服 byCollabChat 可能只回协同字段;续办 get 空 sender 也不回 facts。 + 禁止用空 facts 把已确认港口冲成「-」。 + """ + view = ticket_view(ticket) + facts = dict(view.get("facts") or {}) + if any(str(facts.get(k) or "").strip() for k in ("起运港", "目的港", "品名", "货量")): + return ticket + no = ticket_no(ticket) + sales = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip() + book = getattr(flow, "ledger", None) + if book is None or not no: + return ticket + full: Any = None + getter = getattr(book, "get_for_agent", None) + if callable(getter) and sales: + full = getter(work_order_no=no, sender_id=sales) + if not isinstance(full, dict) or not dict(full.get("facts") or {}): + getter = getattr(book, "get_ticket", None) + if callable(getter): + full = getter(work_order_no=no) + if full is None: + return ticket + merged = dict(view) + full_view = ticket_view(full) + if full_view.get("facts"): + merged["facts"] = dict(full_view.get("facts") or {}) + if full_view.get("quote") and not merged.get("quote"): + merged["quote"] = dict(full_view.get("quote") or {}) + if full_view.get("status"): + merged.setdefault("status", full_view.get("status")) + collab = dict(full_view.get("collab_facts") or {}) + collab.update(dict(view.get("collab_facts") or {})) + merged["collab_facts"] = collab + if view.get("collab_chat_id"): + merged["collab_chat_id"] = view.get("collab_chat_id") + return merged + + +def send_brief_and_sheet( + *, + flow: Any, + ticket: object, + sess: Any = None, + quote_override: Optional[dict[str, Any]] = None, +) -> str: + """ + 群里再发一版摘要 + 填好的海运模板。旧消息不撤。 + + 无价也发表,询价和协同能填的先填。 + """ + ticket = hydrate_ticket(flow, ticket) + view = ticket_view(ticket) + no = ticket_no(ticket) + chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "").strip() + client = group_client_of(flow) + facts = dict(view.get("facts") or {}) + if sess is not None and getattr(sess, "facts", None): + facts.update(dict(sess.facts or {})) + 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 {})) + quote = dict(quote_override or (sess.quote if sess else None) or view.get("quote") or {}) + sales_name, products, mention_ids = resolve_group_mentions(flow, ticket, sess) + has_price = bool(quote.get("total") or quote.get("fee_rows")) + brief = copy.sea_group_brief( + work_order_no=no, + facts=facts, + quote=quote, + collab_facts=collab, + sales_name=sales_name, + product_names=products, + has_price=has_price, + ) + send_group_text(client, chat_id, brief, mention_userids=mention_ids) + send_filled_sheet(flow=flow, ticket=ticket, sess=sess, quote_override=quote_override) + return "brief_and_sheet" + + +def send_filled_sheet( + *, + flow: Any, + ticket: object, + sess: Any = None, + quote_override: Optional[dict[str, Any]] = None, +) -> None: + """只发填好的模板文件,不再发文字摘要。""" + ticket = hydrate_ticket(flow, ticket) + view = ticket_view(ticket) if ticket is not None else {} + no = (sess.work_order_no if sess else "") or ticket_no(ticket) + chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "").strip() + facts = dict(view.get("facts") or {}) + if sess is not None and getattr(sess, "facts", None): + facts.update(dict(sess.facts or {})) + 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 {})) + quote = dict(quote_override or (sess.quote if sess else None) or view.get("quote") or {}) + _render_and_send_sheet( + flow=flow, + chat_id=chat_id, + work_order_no=no, + facts=_facts_for_fill(facts, collab), + quote=quote, + sales_id=str( + (sess.sender_id if sess else "") + or view.get("sales_wecom_id") + or view.get("salesWecomId") + or "" + ), + ) + + +def _collab_value(collab: dict[str, str], key: str) -> str: + """协同六项:内部键、对外展示名都能取到。""" + src = dict(collab or {}) + val = str(src.get(key) or "").strip() + if val: + return val + label = SEA_COLLAB_DISPLAY.get(key, "") + if label: + val = str(src.get(label) or "").strip() + return val + + +def _oil_elec_mag_text(collab: dict[str, str]) -> str: + """ + 海运模板是一格「是否含油,电,磁」,三句话拼成一句再写入。 + 没说的写成 -,不编造。 + """ + oil = _collab_value(collab, "是否含油") + elec = _collab_value(collab, "是否含电") + mag = _collab_value(collab, "是否含磁") + if not (oil or elec or mag): + return "" + return f"油:{oil or '-'};电:{elec or '-'};磁:{mag or '-'}" + + +def _facts_for_fill(facts: dict[str, str], collab: dict[str, str]) -> dict[str, str]: + """ + 询价七项 + 协同六项。中文键和填表英文码一起带上。 + 主账 writeFactsByLabel 只认 cargo_ready_date / trade_terms,只传「货好时间」会空着。 + """ + merged = dict(facts or {}) + src = dict(collab or {}) + for key in SEA_COLLAB_FIELDS: + val = _collab_value(src, key) + if not val: + continue + for alias in _COLLAB_FILL_ALIASES.get(key, (key,)): + merged[alias] = val + oem = _oil_elec_mag_text(src) + if oem: + merged["oil_elec_mag"] = oem + merged["是否含油,电,磁"] = oem + return merged + + +def _render_and_send_sheet( + *, + flow: Any, + chat_id: str, + work_order_no: str, + facts: dict[str, Any], + quote: dict[str, Any], + sales_id: str, +) -> None: + renderer = getattr(flow.ledger, "render_quote", None) + client = group_client_of(flow) + if not callable(renderer) or not chat_id: + return + result = renderer( + work_order_no=work_order_no, + business_line="SEA", + facts=facts, + quote=quote, + format="xlsx", + sales_wecom_id=sales_id, + ) or {} + if not result.get("ok"): + logger.warning("群填表失败 wo=%s err=%s", work_order_no, result.get("error")) + return + filename = str(result.get("fileName") or f"{work_order_no}_quote.xlsx") + sender = getattr(client, "send_group_file", None) + if not callable(sender): + logger.warning("群发文件无客户端 wo=%s chat=%s", work_order_no, chat_id) + return + painted = overlay_labeled_facts( + filepath=str(result.get("filePath") or ""), + file_b64=str(result.get("fileBase64") or ""), + facts=facts, + ) + sent = sender( + chat_id=chat_id, + filename=filename, + filepath=str(painted.get("filepath") or ""), + file_b64=str(painted.get("file_b64") or ""), + ) or {} + if sent.get("ok") is False: + logger.warning("群发文件失败 wo=%s err=%s", work_order_no, sent.get("error")) + send_group_text(client, chat_id, copy.group_sheet_send_fail()) + return + logger.info("群已发模板 wo=%s file=%s", work_order_no, filename) + + +def apply_collab_update( + *, + flow: Any, + ticket: object, + text: str, + sender_id: str, + sess: Any = None, +) -> str: + """补协同六项。终态要说明原因。""" + no = ticket_no(ticket) + chat_id = str((sess.collab_chat_id if sess else "") or ticket_view(ticket).get("collab_chat_id") or "") + client = group_client_of(flow) + if is_terminal(ticket): + send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket))) + return "ticket_locked" + fields = extract_collab_fields(text, allow_b=True) + if not fields: + send_group_text(client, chat_id, copy.group_fields_not_recognized()) + return "collab_not_recognized" + flow.apply_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id) + if sess is not None: + merged = dict(sess.collab_facts) + merged.update(fields) + sess.collab_facts = merged + flow._save(sess) + ticket2 = _reload(flow, chat_id, no) or ticket + view = ticket_view(ticket2) + collab = dict(view.get("collab_facts") or {}) + collab.update(fields) + view["collab_facts"] = collab + if chat_id and not view.get("collab_chat_id"): + view["collab_chat_id"] = chat_id + send_brief_and_sheet(flow=flow, ticket=view, sess=sess) + return "collab_field_saved" + + +def apply_product_quote( + *, + flow: Any, + ticket: object, + text: str, + sender_id: str, + sess: Any = None, + injected_quote: Optional[dict[str, Any]] = None, +) -> str: + """ + 产品文字报价:盖 TMS 价、填表发出、群里成交跟进。 + + 一句话里的协同字段一并写入。非产品或终态要回话。 + """ + no = ticket_no(ticket) + 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) + if is_terminal(ticket): + send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=ticket_status(ticket))) + return "ticket_locked" + if not is_product(ticket, sender_id): + return "group_ignore" + fields = extract_collab_fields(text) + if fields: + flow.apply_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id) + if sess is not None: + merged = dict(sess.collab_facts) + merged.update(fields) + sess.collab_facts = merged + quote = extract_product_quote(text, injected=injected_quote) + if not quote: + send_group_text(client, chat_id, copy.group_quote_not_recognized()) + return "quote_not_recognized" + _save_product_quote(flow, no, quote, sess) + ticket2 = _reload(flow, chat_id, no) or ticket + send_brief_and_sheet(flow=flow, ticket=ticket2, sess=sess, quote_override=quote) + emit_group_deal(flow=flow, ticket=ticket2, sess=sess, quote=quote) + return "product_quote_sent" + + +def handle_group_file( + *, + flow: Any, + ticket: object, + sender_id: str, + filename: str, + media: dict[str, Any], + sess: Any = None, +) -> str: + """ + 群附件:只收 Excel/PDF。终态不存要提醒。 + 产品 Excel/PDF 当报价并出成交跟进;别人的只存档。 + """ + no = ticket_no(ticket) + 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) + if is_terminal(ticket): + send_group_text(client, chat_id, copy.ticket_locked_file(work_order_no=no, status=ticket_status(ticket))) + return "ticket_locked_file" + if not filename_is_excel_pdf(filename): + send_group_text(client, chat_id, copy.group_only_excel_pdf()) + return "file_type_rejected" + _archive_file(flow, no, filename, media, sender_id) + if is_product(ticket, sender_id): + quote = { + "source_label": "产品附件报价", + "file_name": filename, + "total": str((view.get("quote") or {}).get("total") or "-"), + } + _save_product_quote(flow, no, quote, sess) + emit_group_deal(flow=flow, ticket=ticket, sess=sess, quote=quote) + return "product_file_quote" + return "file_archived" + + +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 "") + client = group_client_of(flow) + members = list(view.get("collab_member_ids") or view.get("memberIds") or []) + if sess is not None: + members = list(sess.invited_userids or members) + dismiss = getattr(client, "dismiss_group", None) + if callable(dismiss) and chat_id: + dismiss(chat_id=chat_id, userids=members) + if chat_id: + try: + from agent.channel.wshoto.rooms import unwatch_collab_room + + unwatch_collab_room(chat_id) + except Exception: + logger.exception("取消存档监听失败 chat=%s", chat_id) + ender = getattr(flow.ledger, "end_collab_group", None) + if callable(ender): + ender(work_order_no=no) + 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" + + +def emit_group_deal( + *, + flow: Any, + ticket: object, + sess: Any = None, + quote: Optional[dict[str, Any]] = None, +) -> None: + """群里只发一张成交跟进。版本 +1,旧卡点了要提示。""" + view = ticket_view(ticket) + no = ticket_no(ticket) + chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "") + client = group_client_of(flow) + q = dict(quote or (sess.quote if sess else None) or view.get("quote") or {}) + facts = dict((sess.facts if sess else None) or view.get("facts") or {}) + ver = 1 + if sess is not None: + sess.deal_version = int(getattr(sess, "deal_version", 0) or 0) + 1 + ver = sess.deal_version + sess.phase = "wait_deal" + sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE) + sess.status = "已报价" + sess.quote = q + sess.wait_version += 1 + flow._save(sess) + sales_name, _products, mention_ids = resolve_group_mentions(flow, ticket, sess) + text = f"@{sales_name}\n" + copy.deal_card(work_order_no=no, quote=q, facts=facts) + # 成交跟进只点销售:产品不能点这张卡。 + sales_mention = mention_ids[:1] if mention_ids else [] + send_group_text(client, chat_id, text, mention_userids=sales_mention) + carder = getattr(client, "send_group_card", None) + payload = copy.deal_wecom_payload(work_order_no=no, quote=q, facts=facts, card_version=ver) + if callable(carder): + carder(chat_id=chat_id, template_card=payload.get("template_card") or {}) + + +def explain_deal_click( + *, + flow: Any, + sender_id: str, + action: str, + card_meta: Optional[dict[str, Any]], + sess: Any, +) -> str: + """ + 成交卡点错人 / 旧版 / 终态:群里说清楚,不当没反应。 + + 返回空表示可以继续走成交。 + """ + if action not in {copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE}: + return "" + if sess is None: + return "" + chat_id = (sess.collab_chat_id or "").strip() + client = group_client_of(flow) + no = sess.work_order_no + if sess.status in TERMINAL: + send_group_text(client, chat_id, copy.ticket_locked(work_order_no=no, status=sess.status)) + return "ticket_locked" + if sender_id != sess.sender_id: + send_group_text(client, chat_id, copy.group_deal_sales_only()) + return "not_sales" + clicked = copy.deal_card_version(card_meta) + current = int(getattr(sess, "deal_version", 0) or 0) + if current and clicked and clicked != current: + send_group_text(client, chat_id, copy.group_deal_stale(work_order_no=no)) + return "stale_deal" + return "" + + +def _save_product_quote(flow: Any, no: str, quote: dict[str, Any], sess: Any) -> None: + writer = getattr(flow.ledger, "upsert_product_quote", None) or getattr( + flow.ledger, "upsert_quote", None + ) + if callable(writer): + writer(work_order_no=no, quote=quote, to_status="已报价") + if sess is not None: + sess.quote = dict(quote) + sess.status = "已报价" + flow._save(sess) + + +def _archive_file(flow: Any, no: str, filename: str, media: dict[str, Any], sender_id: str) -> None: + saver = getattr(flow.ledger, "attach_file", None) + if callable(saver): + saver( + work_order_no=no, + file_name=filename, + object_key=str(media.get("media_id") or media.get("MediaId") or filename), + content_type=str(media.get("content_type") or ""), + sender_id=sender_id, + ) + + +def _reload(flow: Any, chat_id: str, no: str) -> object: + finder = getattr(flow.ledger, "find_by_collab_chat", None) + if callable(finder) and chat_id: + hit = finder(chat_id=chat_id) + if hit is not None: + return hit + getter = getattr(flow.ledger, "get_ticket", None) + if callable(getter) and no: + return getter(work_order_no=no) + return None diff --git a/inquiry-agent/agent/policy/sea_text_flow.py b/inquiry-agent/agent/policy/sea_text_flow.py index 79fbc41..4094ff9 100644 --- a/inquiry-agent/agent/policy/sea_text_flow.py +++ b/inquiry-agent/agent/policy/sea_text_flow.py @@ -44,6 +44,16 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): super().__init__(ledger=ledger) self._group_client = group_client + def group_client(self) -> Any: + """ + 企微应用群客户端。测服正式不注入,现场再取默认 WeCom 客户端。 + + 单测可注入 FakeGroupClient。取到后挂在流程上,后续补字段/发文件共用。 + """ + if self._group_client is None: + self._group_client = _default_group_client() + return self._group_client + def on_text( self, *, @@ -374,6 +384,23 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): sess = self.session_for_action(sender_id, card_meta) if sess and action == copy.BTN_PULL_COLLAB and action in sess.allowed: return self._pull_collab_group(sess, reply, card_meta=card_meta) + if action in {copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE}: + from agent.policy.sea_group_ops import explain_deal_click + + ticket_sess = sess + if ticket_sess is None: + wo = copy.work_order_from_card_meta(card_meta) + ticket_sess = self.session_by_work_order(wo) + if ticket_sess and (ticket_sess.collab_chat_id or "").strip(): + reason = explain_deal_click( + flow=self, + sender_id=sender_id, + action=action, + card_meta=card_meta, + sess=ticket_sess, + ) + if reason: + return reason return super().on_button( sender_id=sender_id, action=action, reply=reply, card_meta=card_meta ) @@ -390,10 +417,22 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): 没人可拉 / 建群失败:说明原因,按钮保持可点。已有群:不新建,置灰。 """ no = (sess.work_order_no or "").strip() + if sess.collab_ended: + reply(copy.group_cannot_recreate(work_order_no=no)) + return "collab_ended" + getter = getattr(self._ledger, "get_ticket", None) + ticket = getter(work_order_no=no) if callable(getter) else None + if ticket is not None: + facts = dict(getattr(ticket, "collab_facts", {}) or {}) + if bool(getattr(ticket, "collab_ended", False)) or str( + facts.get("__ended") or "" + ).strip() in {"1", "true"}: + sess.collab_ended = True + self._save(sess) + reply(copy.group_cannot_recreate(work_order_no=no)) + return "collab_ended" existing = (sess.collab_chat_id or "").strip() - if not existing: - getter = getattr(self._ledger, "get_ticket", None) - ticket = getter(work_order_no=no) if callable(getter) else None + if not existing and ticket is not None: existing = str(getattr(ticket, "collab_chat_id", "") or "") if existing: sess.collab_chat_id = existing @@ -402,6 +441,12 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): self._save(sess) self._grey_clicked_card(reply, card_meta) self._ensure_archive_seats_in_group(existing) + try: + from agent.channel.wshoto.rooms import watch_collab_room + + watch_collab_room(existing, work_order_no=no) + except Exception: + logger.exception("登记已有存档监听群失败 chat=%s", existing) reply(copy.sea_group_exists(work_order_no=no)) return "collab_group" @@ -429,13 +474,19 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): if uid not in userids: userids.append(uid) - client = self._group_client or _default_group_client() + client = self.group_client() created = client.create_group(name=no, userids=userids) or {} if not created.get("ok") or not created.get("chat_id"): reply(copy.sea_group_create_fail(reason=str(created.get("error") or ""))) return sess.phase or "wait_collab" chat_id = str(created.get("chat_id") or "").strip() + try: + from agent.channel.wshoto.rooms import watch_collab_room + + watch_collab_room(chat_id, work_order_no=no) + except Exception: + logger.exception("登记存档监听群失败 chat=%s", chat_id) sales_name = self._sales_display_name(sess.sender_id) binder = getattr(self._ledger, "bind_collab_group", None) if callable(binder): @@ -444,6 +495,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): chat_id=chat_id, member_ids=userids, product_names=product_names, + product_ids=product_ids, ) brief = copy.sea_group_brief( @@ -456,8 +508,10 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): has_price=bool(sess.quote and sess.status == "已报价"), ) client.send_group(chat_id=chat_id, content=brief) - sess.collab_chat_id = chat_id + from agent.policy.sea_group_ops import send_filled_sheet + + send_filled_sheet(flow=self, ticket=None, sess=sess) sess.invited_userids = tuple(userids) sess.invited_sales_name = sales_name sess.invited_product_names = tuple(product_names) @@ -525,7 +579,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow): seats = self._archive_seat_userids() if not chat_id or not seats: return - client = self._group_client or _default_group_client() + client = self.group_client() adder = getattr(client, "add_group_members", None) if not callable(adder): return diff --git a/inquiry-agent/agent/redis_coord/keys.py b/inquiry-agent/agent/redis_coord/keys.py index 1c03439..56609d3 100644 --- a/inquiry-agent/agent/redis_coord/keys.py +++ b/inquiry-agent/agent/redis_coord/keys.py @@ -44,7 +44,12 @@ STREAM_WAKE = "stream:wake" IDEMPOTENCY = "idem" RATE_LIMIT = "ratelimit" TOKEN_WECOM = "token:wecom" +TOKEN_WSHOTO = "token:wshoto" TOKEN_COPY_TICKET = "token:copy_ticket" TOKEN_JSAPI = "token:jsapi" +# 微盛查询游标(本前缀下的 last_seq,不是现网官方 SDK 消费游标) +WSHOTO_LAST_SEQ = "wshoto:last_seq" +WSHOTO_ROOMS = "wshoto:rooms" +WSHOTO_ROOM_TICKET = "wshoto:room_ticket" # Worker 出完报价文件后写给 HTTP:短协调,不当六态 QUOTE_FILE_READY = "quote_file_ready" diff --git a/inquiry-agent/agent/redis_coord/token_cache.py b/inquiry-agent/agent/redis_coord/token_cache.py index f71af1d..4e13dc2 100644 --- a/inquiry-agent/agent/redis_coord/token_cache.py +++ b/inquiry-agent/agent/redis_coord/token_cache.py @@ -11,7 +11,7 @@ import logging from typing import Optional from agent.redis_coord.client import RedisClient -from agent.redis_coord.keys import TOKEN_WECOM +from agent.redis_coord.keys import TOKEN_WECOM, TOKEN_WSHOTO logger = logging.getLogger(__name__) @@ -40,3 +40,25 @@ def set_wecom_token( key = client.key(TOKEN_WECOM, agent_id) client.raw.set(key, token, ex=ttl_seconds) logger.info("企微 token 已缓存 agent_id=%s ttl=%s", agent_id, ttl_seconds) + + +def get_wshoto_token(client: RedisClient, *, app_id: str) -> Optional[str]: + """读取微盛 access_token 缓存;未命中返回 None。不把 token 当业务真相。""" + key = client.key(TOKEN_WSHOTO, app_id or "default") + val = client.raw.get(key) + if val is None: + return None + return val if isinstance(val, str) else val.decode("utf-8") + + +def set_wshoto_token( + client: RedisClient, + *, + app_id: str, + token: str, + ttl_seconds: int = 7000, +) -> None: + """写入微盛 token;TTL 略短于接口 expires_in,避免用过期票去拉群。""" + key = client.key(TOKEN_WSHOTO, app_id or "default") + client.raw.set(key, token, ex=max(60, int(ttl_seconds))) + logger.info("微盛 token 已缓存 ttl=%s", ttl_seconds) diff --git a/inquiry-agent/agent/routing/dispatch.py b/inquiry-agent/agent/routing/dispatch.py index 809a9d2..1600fd7 100644 --- a/inquiry-agent/agent/routing/dispatch.py +++ b/inquiry-agent/agent/routing/dispatch.py @@ -68,12 +68,17 @@ def _wrap_card(message: InboundMessage) -> None: from agent.policy import inquiry_copy as copy action = copy.canonical_button(message.content or "") or (message.content or "").strip() + payload = dict(message.raw or {}) + payload.setdefault("event_key", message.content or "") + if message.chat_id: + payload["chat_id"] = message.chat_id + payload["chat_type"] = message.chat_type card_action.handle_card_action( sender_id=message.sender_id, thread_id="", wait_version=0, action=action or "unknown", - payload=dict(message.raw or {}), + payload=payload, ) @@ -136,6 +141,17 @@ def dispatch_inbound(message: InboundMessage, decision: RouteDecision) -> str: 副作用:仅 handler 内产生;本函数不做业务。 """ if (message.chat_type or "") == "group": + from agent.policy import inquiry_copy as copy + + event = str((message.raw or {}).get("event") or "").lower() + action = copy.canonical_button(message.content or "") + if event in {"template_card_event", "click", "templatecardevent"} or action in { + copy.BTN_DEAL, + copy.BTN_LOST, + copy.BTN_NEGOTIATE, + }: + _wrap_card(message) + return "card_action" phase = handle_group_collab(message) logger.info("dispatch group_collab phase=%s chat=%s", phase, message.chat_id) return "group_collab" diff --git a/inquiry-agent/agent/routing/group_intent.py b/inquiry-agent/agent/routing/group_intent.py new file mode 100644 index 0000000..b7d56b4 --- /dev/null +++ b/inquiry-agent/agent/routing/group_intent.py @@ -0,0 +1,91 @@ +""" +海运协同群文字意图:DeepSeek A 判断补字段 / 产品报价 / 解散 / 其它。 + +本文件职责:只分类,不写主账、不发企微。 +禁止:把「解散群」写成唯一口令;私聊按钮不走这里。 +""" + +from __future__ import annotations + +import logging +from typing import Optional + +from agent.llm.extract_text import extract_collab_fields + +logger = logging.getLogger(__name__) + +INTENT_FIELDS = "collab_fields" +INTENT_QUOTE = "product_quote" +INTENT_DISSOLVE = "dissolve" +INTENT_OTHER = "other" + +_VALID = {INTENT_FIELDS, INTENT_QUOTE, INTENT_DISSOLVE, INTENT_OTHER} + + +def classify_group_text( + text: str, + *, + injected: str = "", + allow_network: Optional[bool] = None, +) -> str: + """ + 群里一句文字的意图。单测可注入;正式走模型。 + + 模型不可用时:只认带标签的协同字段(键:值是合同,不是口令),其余当其它。 + """ + hint = (injected or "").strip() + if hint in _VALID: + return hint + raw = (text or "").strip() + if not raw: + return INTENT_OTHER + try: + from agent.config import get_settings + + settings = get_settings() + use_net = ( + allow_network + if allow_network is not None + else bool(getattr(settings, "llm_allow_network", False)) + ) + if use_net and getattr(settings, "llm_data_usage_confirmed", False): + got = _classify_with_model(raw, settings) + if got in _VALID: + return got + except Exception: + logger.exception("群意图模型失败,回退标签字段") + if extract_collab_fields(raw): + return INTENT_FIELDS + from agent.llm.extract_text import extract_product_quote + + if extract_product_quote(raw): + return INTENT_QUOTE + return INTENT_OTHER + + +def _classify_with_model(text: str, settings) -> str: + """DeepSeek 只回一个意图词。失败空串。""" + from agent.llm.http_client import LlmHttpClient + from agent.llm.modes import LlmMode + + client = LlmHttpClient.from_settings(settings) + system = ( + "你是海运协同群意图分类。只回复一个英文词,不要解释。" + "collab_fields=在补贸易条款/货好时间/商品海关编码/含油含电含磁;" + "product_quote=产品在报价格或费用;" + "dissolve=要把这个协同群解散;" + "other=闲聊、问候、对询价港口的随口更正(不当改单)。" + "一句话里既补字段又报价,回 product_quote。" + ) + data = client.chat( + mode=LlmMode.ORDERED_ACTIONS, + messages=[ + {"role": "system", "content": system}, + {"role": "user", "content": text[:2000]}, + ], + ) + content = str(getattr(data, "content", "") or "").strip().lower() + for token in (INTENT_QUOTE, INTENT_DISSOLVE, INTENT_FIELDS, INTENT_OTHER): + if token in content: + return token + return "" diff --git a/inquiry-agent/agent/schema/field_validate.py b/inquiry-agent/agent/schema/field_validate.py index abd9536..77b0374 100644 --- a/inquiry-agent/agent/schema/field_validate.py +++ b/inquiry-agent/agent/schema/field_validate.py @@ -44,8 +44,12 @@ SEA_REQUIRED_FIELDS = ( ) SEA_DISPLAY = {key: label for key, label in SEA_REQUIRED_FIELDS} -# 协同补充字段:拉群后可静默写入,不挡首次查价。 +# 协同补充字段:拉群后可静默写入,不挡首次查价。内部键仍是 HS编码。 SEA_COLLAB_FIELDS = ("贸易条款", "货好时间", "HS编码", "是否含油", "是否含电", "是否含磁") +# 群摘要对外展示名。内部继续用 HS编码,避免改合同键。 +SEA_COLLAB_DISPLAY = { + "HS编码": "商品海关编码", +} # 口语里已经带上 TMS 文档规定的单位时,补进空字段。 # 只认「数字+单位」,不猜港口,不把长宽高换算成立方。 @@ -80,6 +84,8 @@ _ALIASES = { "车型/数量": "车型数量", "HS": "HS编码", "HS CODE": "HS编码", + "商品海关编码": "HS编码", + "海关编码": "HS编码", } @@ -178,6 +184,32 @@ def harvest_oral_measures(text: str, facts: dict[str, str] | None = None) -> dic return harvest_named_air_route(raw, merged) +def harvest_oral_collab(text: str) -> dict[str, str]: + """ + 群里口语协同三项:含油/含电/含磁、货好时间。 + + 只认原话里已经说死的词(不含电、随时可提),不猜贸易条款和海关编码。 + """ + raw = text or "" + out: dict[str, str] = {} + yes_no = ( + ("是否含电", ("不含电", "不带电", "无电"), ("含电", "带电")), + ("是否含油", ("不含油", "不带油", "无油"), ("含油", "带油")), + ("是否含磁", ("不含磁", "不带磁", "无磁"), ("含磁", "带磁")), + ) + for key, no_words, yes_words in yes_no: + if any(w in raw for w in no_words): + out[key] = "否" + elif any(w in raw for w in yes_words): + out[key] = "是" + ready = ("随时可提", "货好随时", "随时货好", "货已好", "已经货好") + for word in ready: + if word in raw: + out["货好时间"] = word + break + return out + + def project_sea_cargo_qty(facts: dict[str, str] | None) -> dict[str, str]: """ 海运货物数量投影:原话「15件」常被抽成空运「件数」。 diff --git a/inquiry-agent/tests/test_air_text_inquiry.py b/inquiry-agent/tests/test_air_text_inquiry.py index 5977e0b..e283163 100644 --- a/inquiry-agent/tests/test_air_text_inquiry.py +++ b/inquiry-agent/tests/test_air_text_inquiry.py @@ -296,29 +296,22 @@ class AirTextInquiryTests(unittest.TestCase): self.assertEqual(phase, "wait_deal") self.assertIn("Excel", "\n".join(self.replies)) self.assertIn("咨询成交状态", "\n".join(self.replies)) - deal_cards = [ - x["template_card"] - for x in self.extras - if x.get("msgtype") == "template_card" - and str((x.get("template_card") or {}).get("task_id") or "").startswith("deal-") - ] - self.assertTrue(deal_cards) - deal_rows = {r["keyname"]: r["value"] for r in deal_cards[-1]["horizontal_content_list"]} + file_extras = [x for x in self.extras if x.get("msgtype") == "file"] + self.assertTrue(file_extras) + follow = file_extras[-1].get("followup_card") or {} + self.assertTrue(str(follow.get("task_id") or "").startswith("deal-")) + deal_rows = {r["keyname"]: r["value"] for r in follow.get("horizontal_content_list") or []} self.assertIn("总销售价合计", deal_rows) self.assertNotIn("历史报价", deal_rows) self.assertNotIn("工单状态", deal_rows) self.assertNotIn("来源", deal_rows) - - file_idx = next( - i for i, x in enumerate(self.extras) if x.get("msgtype") == "file" - ) - deal_idx = next( - i - for i, x in enumerate(self.extras) + separate_deal = [ + x + for x in self.extras if x.get("msgtype") == "template_card" and str((x.get("template_card") or {}).get("task_id") or "").startswith("deal-") - ) - self.assertLess(file_idx, deal_idx) + ] + self.assertEqual(separate_deal, []) phase = self.flow.on_button( sender_id="u1", action=copy.BTN_DEAL, @@ -1268,7 +1261,7 @@ class AirTextInquiryTests(unittest.TestCase): source="test", ) self.assertEqual(replay, "wait_deal") - self.assertIn("咨询成交状态", "\n".join(again)) + self.assertNotIn("咨询成交状态", "\n".join(again)) self.assertEqual(http.session_of("u-pdf-x").phase, "wait_deal") def test_deal_click_works_while_http_still_wait_file(self) -> None: @@ -1315,3 +1308,44 @@ class AirTextInquiryTests(unittest.TestCase): self.assertEqual(phase, "done") self.assertEqual(flow.session_of("u-pdf-d").phase, "done") self.assertTrue(any(x.get("msgtype") == "update_template_card" for x in extras)) + + +class FileThenDealOrderTests(unittest.TestCase): + def test_send_outbound_sends_file_before_followup_card(self) -> None: + """同一条出站必须先传文件再发成交卡。""" + from agent.channel.outbox.sender import WeComAppClient, WeComSendResult + + order: list[str] = [] + + class _Client(WeComAppClient): + def send_file(self, **kwargs): # type: ignore[override] + order.append("file") + return WeComSendResult(ok=True) + + def _post_message(self, body, preview=""): # type: ignore[override] + order.append(str(preview or body.get("msgtype"))) + return WeComSendResult(ok=True) + + client = _Client(corp_id="c", secret="s", agent_id=1) + client._followup_gap_sec = 0 + client.send_outbound( + touser="u1", + content="file+deal", + payload={ + "msgtype": "file", + "filename": "WO1.xlsx", + "file_b64": "QQ==", + "followup_card": {"task_id": "deal-WO1"}, + }, + ) + self.assertEqual(order, ["file", "[followup_card]"]) + + def test_wait_outbound_stays_until_claimed_item_finishes(self) -> None: + """已被认领的出站必须等到发完,不能中途当成功。""" + store = MemoryMessageStore() + ok, oid = store.enqueue_outbound(touser="u1", content="报价文件") + self.assertTrue(ok) + claimed = store.claim_outbound() + self.assertIsNotNone(claimed) + done = store.wait_outbound_done(oid, timeout_sec=0.45) + self.assertFalse(done) diff --git a/inquiry-agent/tests/test_continue_ticket.py b/inquiry-agent/tests/test_continue_ticket.py index c37cdd2..5512452 100644 --- a/inquiry-agent/tests/test_continue_ticket.py +++ b/inquiry-agent/tests/test_continue_ticket.py @@ -215,7 +215,7 @@ class ContinueTicketTests(unittest.TestCase): sender_id="u6", work_order_no=wo, reply=reply ) self.assertEqual(phase, "wait_deal") - self.assertTrue(any("成交" in x or copy.BTN_DEAL in x for x in replies)) + self.assertFalse(any("成交" in x or copy.BTN_DEAL in x for x in replies)) def test_resume_missing_wo_asks(self) -> None: flow = AirTextInquiryFlow(MemoryLedger()) diff --git a/inquiry-agent/tests/test_sea_text_inquiry.py b/inquiry-agent/tests/test_sea_text_inquiry.py index 3b33fcd..0e90d89 100644 --- a/inquiry-agent/tests/test_sea_text_inquiry.py +++ b/inquiry-agent/tests/test_sea_text_inquiry.py @@ -62,8 +62,29 @@ class FakeGroupClient: self.created.append({"name": "", "userids": list(userids), "chat_id": chat_id, "add": True}) return {"ok": True} - def send_group(self, *, chat_id: str, content: str) -> dict: - self.sent.append({"chat_id": chat_id, "content": content}) + def send_group(self, *, chat_id: str, content: str, mention_userids: list | None = None) -> dict: + self.sent.append( + { + "chat_id": chat_id, + "content": content, + "kind": "text", + "mention_userids": list(mention_userids or []), + } + ) + return {"ok": True} + + def send_group_file(self, *, chat_id: str, filename: str, filepath: str = "", file_b64: str = "") -> dict: + self.sent.append( + {"chat_id": chat_id, "filename": filename, "kind": "file", "file_b64": file_b64} + ) + return {"ok": True} + + def send_group_card(self, *, chat_id: str, template_card: dict) -> dict: + self.sent.append({"chat_id": chat_id, "kind": "card", "template_card": template_card}) + return {"ok": True} + + def dismiss_group(self, *, chat_id: str, userids: list) -> dict: + self.sent.append({"chat_id": chat_id, "kind": "dismiss", "userids": list(userids)}) return {"ok": True} @@ -213,21 +234,40 @@ class SeaTmsCardTests(unittest.TestCase): ], } text = copy.sea_quote_detail_text( - work_order_no="WO202609150029", quote=long_quote + work_order_no="WO202609150030", quote=long_quote ) - self.assertIn("海运费:USD 100.00", text) - self.assertIn("报关费:CNY 250.00", text) - self.assertIn("总销售价合计:CNY 2287.65", text) + self.assertEqual( + text, + "\n".join( + [ + "TMS标准报价", + "WO202609150030价格明细", + "海运费:USD 100.00", + "燃油附加费:USD 15.00", + "操作费:CNY 680.00", + "文件费:CNY 450.00", + "封条费(SEAI):CNY 50.00", + "设备费:CNY 50.00", + "报关费:CNY 250.00", + "总销售价合计:CNY 2287.65", + ] + ), + ) + self.assertNotIn("时效", text) + self.assertNotIn("报价有效期", text) card_text = copy.quote_area_safe_text(long_quote) self.assertIn("总销售价合计:CNY 2287.65", card_text) self.assertNotIn("海运费:USD 100.00\n燃油附加费", card_text) self.assertLessEqual(len(card_text), 80) payload = copy.sea_tms_hit_wecom_payload( - work_order_no="WO202609150029", quote=long_quote + work_order_no="WO202609150030", quote=long_quote ) self.assertEqual( payload["template_card"]["quote_area"]["quote_text"], card_text ) + sub = payload["template_card"]["sub_title_text"] + self.assertIn("时效:1天", sub) + self.assertIn("报价有效期:2026-09-30", sub) def test_sea_hit_card_has_price_and_two_buttons(self) -> None: phase = self.flow.on_text( @@ -393,9 +433,12 @@ class SeaSkipAndPullTests(unittest.TestCase): self.assertNotIn("AI001", self.groups.created[0]["userids"]) self.assertTrue(self.groups.sent) group_text = self.groups.sent[0]["content"] + self.assertTrue(any(x.get("kind") == "file" for x in self.groups.sent)) self.assertIn(wo, group_text) self.assertIn("起运港", group_text) self.assertIn("贸易条款", group_text) + self.assertIn("商品海关编码", group_text) + self.assertNotIn("HS编码", group_text) self.assertIn("TMS", group_text) sess = self.flow.session_of("s1") self.assertNotIn(copy.BTN_PULL_COLLAB, sess.allowed) @@ -405,6 +448,40 @@ class SeaSkipAndPullTests(unittest.TestCase): ) self.assertEqual(again, "card_discard") + def test_pull_sends_sheet_when_client_not_injected(self) -> None: + """测服正式路径不注入 group_client,拉群仍要发出填好的模板。""" + from unittest.mock import patch + + live = FakeGroupClient() + self.ledger.set_sea_staff( + [ + { + "name": "张产品", + "wecomId": "prod1", + "roleCode": "sea", + "routes": "海运·全部线路", + "status": "active", + } + ] + ) + with patch("agent.policy.sea_text_flow._default_group_client", return_value=live): + flow = SeaTextInquiryFlow(self.ledger, group_client=None) + flow.on_text( + sender_id="s9", + text="海运有价", + reply=self._reply, + injected_facts=COMPLETE_SEA, + injected_mode="SEA", + ) + phase = flow.on_button( + sender_id="s9", + action=copy.BTN_PULL_COLLAB, + reply=self._reply, + ) + self.assertEqual(phase, "collab_group") + self.assertTrue(any(x.get("kind") == "text" for x in live.sent)) + self.assertTrue(any(x.get("kind") == "file" for x in live.sent)) + def test_pull_group_includes_archive_bot(self) -> None: self.ledger.upsert_staff( { @@ -509,12 +586,27 @@ class SeaSkipAndPullTests(unittest.TestCase): reply=self._reply, ) self.assertTrue(self.groups.sent) - self.assertIn("暂无匹配报价", self.groups.sent[0]["content"]) + group_text = self.groups.sent[0]["content"] + self.assertIn("TMS 暂无匹配报价", group_text) + self.assertNotIn("未查到合适报价", group_text) + self.assertIn("\n\n协同补充字段:\n", group_text) + self.assertIn("\n\nTMS报价:\n", group_text) class SeaGroupCollabFieldTests(unittest.TestCase): def setUp(self) -> None: self.ledger = MemoryLedger() + self.ledger.set_quote_templates( + [ + { + "templateId": "mem-default-sea", + "name": "海运默认", + "bizType": "海运", + "keywords": [], + "isDefault": True, + } + ] + ) self.groups = FakeGroupClient() self.flow = SeaTextInquiryFlow(self.ledger, group_client=self.groups) self.replies: list[str] = [] @@ -602,8 +694,10 @@ class SeaGroupCollabFieldTests(unittest.TestCase): flow=self.flow, ledger=self.ledger, ) - self.assertEqual(phase, "group_ignore") - self.assertFalse(ticket.quote_version > 1 and "1200" in str(ticket.quote)) + self.assertEqual(phase, "product_quote_sent") + self.assertEqual(str(ticket.quote.get("total") or ""), "1200") + deal_texts = [x["content"] for x in self.groups.sent if x.get("kind") == "text" and "成交" in (x.get("content") or "")] + self.assertTrue(deal_texts) class SeaContinueTests(unittest.TestCase): @@ -761,6 +855,17 @@ class SeaContinueTests(unittest.TestCase): card_meta={"event_key": f"adopt_excel:{wo}", "response_code": "rc-xl"}, ) self.assertEqual(self.flow.session_of("c3").phase, "wait_deal") + file_extras = [x for x in self.extras if x.get("msgtype") == "file"] + self.assertTrue(file_extras) + follow = file_extras[-1].get("followup_card") or {} + self.assertTrue(str(follow.get("task_id") or "").startswith("deal-")) + separate_deal = [ + x + for x in self.extras + if x.get("msgtype") == "template_card" + and str((x.get("template_card") or {}).get("task_id") or "").startswith("deal-") + ] + self.assertEqual(separate_deal, []) empty = SeaTextInquiryFlow(self.ledger) store = MemoryMessageStore() msg = InboundMessage( @@ -777,11 +882,37 @@ class SeaContinueTests(unittest.TestCase): phase = handle_continue_thread(msg, thread_id=wo, store=store) self.assertEqual(phase, "wait_deal") joined = "\n".join(item.content for item in store._outbox.values()) - self.assertTrue(any(x in joined for x in (copy.BTN_DEAL, "成交"))) + self.assertFalse(any(x in joined for x in (copy.BTN_DEAL, "成交"))) self.assertNotIn(copy.BTN_SKIP_COLLAB, joined) class SeaGroupCopyTests(unittest.TestCase): + def test_group_brief_uses_customs_code_and_section_breaks(self) -> None: + text = copy.sea_group_brief( + work_order_no="WO202609150023", + facts={ + "起运港": "纽约港", + "目的港": "巴生西港", + "品名": "机器设备", + "货量": "15", + "整柜或拼柜": "整柜", + "箱型箱量": "40HQ*1", + "报价日期": "2026-09-15", + }, + quote=None, + collab_facts={}, + sales_name="吴季林", + product_names=["罗小华"], + has_price=False, + ) + self.assertIn("@吴季林 @罗小华", text) + self.assertIn("询价字段:\n运输方式:海运", text) + self.assertIn("\n\n协同补充字段:\n", text) + self.assertIn("商品海关编码:-", text) + self.assertNotIn("HS编码", text) + self.assertIn("\n\nTMS报价:\nTMS 暂无匹配报价", text) + self.assertNotIn("未查到合适报价", text) + def test_scheme_card_uses_sea_fields(self) -> None: facts = { "起运港": "上海", @@ -831,6 +962,312 @@ class SeaGroupCopyTests(unittest.TestCase): "已邀请:销售吴季林、产品罗小华。请在群聊中继续确认报价。", ) + def test_group_only_excel_pdf_copy(self) -> None: + self.assertEqual( + copy.group_only_excel_pdf(), + "群里只收 Excel 和 PDF,其它文件不会存入工单", + ) + + +class SeaGroupOpsTests(unittest.TestCase): + """群协同:附件类型、产品报价、解散后再拉、终态提醒。""" + + def setUp(self) -> None: + self.ledger = MemoryLedger() + self.ledger.set_quote_templates( + [ + { + "templateId": "mem-default-sea", + "name": "海运默认", + "bizType": "海运", + "keywords": [], + "isDefault": True, + } + ] + ) + self.ledger.set_sea_staff( + [ + { + "name": "赵产品", + "wecomId": "prod4", + "roleCode": "sea", + "routes": "海运·全部线路", + "status": "active", + } + ] + ) + self.ledger.upsert_staff({"name": "吴吉林", "wecomId": "sales1"}) + self.groups = FakeGroupClient() + self.flow = SeaTextInquiryFlow(self.ledger, group_client=self.groups) + self.replies: list[str] = [] + + def _reply(self, text: str, extra=None) -> None: + self.replies.append(text) + + def _open_group(self) -> tuple[str, str]: + self.flow.on_text( + sender_id="sales1", + text="海运有价", + reply=self._reply, + injected_facts=COMPLETE_SEA, + injected_mode="SEA", + ) + self.flow.on_button( + sender_id="sales1", + action=copy.BTN_PULL_COLLAB, + reply=self._reply, + ) + sess = self.flow.session_of("sales1") + return sess.work_order_no, sess.collab_chat_id + + def test_group_rejects_non_excel_pdf(self) -> None: + wo, chat = self._open_group() + phase = handle_group_collab( + InboundMessage( + sender_id="sales1", + message_id="img1", + content="", + msg_type="image", + chat_id=chat, + chat_type="group", + media={"filename": "现场.jpg", "media_id": "mid-1"}, + ), + flow=self.flow, + ledger=self.ledger, + ) + self.assertEqual(phase, "file_type_rejected") + texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"] + self.assertIn("群里只收 Excel 和 PDF,其它文件不会存入工单", texts) + ticket = self.ledger.get_ticket(work_order_no=wo) + self.assertFalse(ticket.attachments) + + def test_sales_excel_is_archived_not_quote(self) -> None: + wo, chat = self._open_group() + phase = handle_group_collab( + InboundMessage( + sender_id="sales1", + message_id="xls1", + content="", + msg_type="file", + chat_id=chat, + chat_type="group", + media={"filename": "对账单.xlsx", "media_id": "mid-2"}, + ), + 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"), "产品附件报价") + + def test_product_excel_quotes_and_deal(self) -> None: + wo, chat = self._open_group() + phase = handle_group_collab( + InboundMessage( + sender_id="prod4", + message_id="xls2", + content="", + msg_type="file", + chat_id=chat, + chat_type="group", + media={"filename": "产品报价.pdf", "media_id": "mid-3"}, + ), + flow=self.flow, + ledger=self.ledger, + ) + self.assertEqual(phase, "product_file_quote") + ticket = self.ledger.get_ticket(work_order_no=wo) + self.assertEqual(ticket.attachments[0]["file_name"], "产品报价.pdf") + self.assertEqual(ticket.status, "已报价") + self.assertTrue(any(x.get("kind") == "card" for x in self.groups.sent)) + + def test_terminal_file_speaks(self) -> None: + wo, chat = self._open_group() + self.ledger.transition(work_order_no=wo, to_status="已成交") + sess = self.flow.session_of("sales1") + sess.status = "已成交" + phase = handle_group_collab( + InboundMessage( + sender_id="prod4", + message_id="xls3", + content="", + msg_type="file", + chat_id=chat, + chat_type="group", + media={"filename": "晚到.xlsx", "media_id": "mid-4"}, + ), + flow=self.flow, + ledger=self.ledger, + ) + self.assertEqual(phase, "ticket_locked_file") + texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"] + self.assertTrue(any("不能再上传附件" in t for t in texts)) + self.assertFalse(self.ledger.get_ticket(work_order_no=wo).attachments) + + def test_dissolve_then_cannot_recreate(self) -> None: + wo, chat = self._open_group() + phase = handle_group_collab( + InboundMessage( + sender_id="sales1", + message_id="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) + sess = self.flow.session_of("sales1") + sess.allowed = (copy.BTN_PULL_COLLAB,) + again = self.flow.on_button( + sender_id="sales1", + action=copy.BTN_PULL_COLLAB, + reply=self._reply, + ) + self.assertEqual(again, "collab_ended") + self.assertIn("不能再拉群", "\n".join(self.replies)) + + def test_group_oral_keeps_inquiry_facts_when_snapshot_skinny(self) -> None: + wo, chat = self._open_group() + real_find = self.ledger.find_by_collab_chat + + def skinny(*, chat_id: str): + ticket = real_find(chat_id=chat_id) + if ticket is None: + return None + return { + "found": True, + "work_order_no": ticket.work_order_no, + "sales_wecom_id": ticket.sales_wecom_id, + "collab_chat_id": ticket.collab_chat_id, + "collab_member_ids": list(ticket.collab_member_ids), + "collab_facts": dict(ticket.collab_facts), + } + + self.ledger.find_by_collab_chat = skinny # type: ignore[method-assign] + self.flow._by_ticket.clear() + self.flow._by_sender.clear() + phase = handle_group_collab( + InboundMessage( + sender_id="sales1", + message_id="oral-keep-facts-1", + content="不含电、不含油、随时可提", + chat_id=chat, + chat_type="group", + ), + flow=self.flow, + ledger=self.ledger, + injected_intent="collab_fields", + ) + self.assertEqual(phase, "collab_field_saved") + texts = "\n".join(x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text") + self.assertIn("上海", texts) + self.assertIn("洛杉矶", texts) + self.assertIn("随时可提", texts) + self.assertNotRegex(texts, r"起运港:\s*-") + self.assertIn("@吴吉林 @赵产品", texts) + self.assertNotRegex(texts, r"^@销售(\s|$)") + briefs = [ + x + for x in self.groups.sent + if x.get("kind") == "text" and "询价字段" in (x.get("content") or "") + ] + self.assertTrue(briefs) + self.assertIn("sales1", briefs[-1].get("mention_userids") or []) + self.assertIn("prod4", briefs[-1].get("mention_userids") or []) + self.assertNotIn("AI001", briefs[-1].get("mention_userids") or []) + + def test_group_oral_oil_power_ready_is_saved(self) -> None: + wo, chat = self._open_group() + n = len([x for x in self.groups.sent if x.get("kind") == "text"]) + phase = handle_group_collab( + InboundMessage( + sender_id="sales1", + message_id="oral-collab-1", + content="不含电、不含油、随时可提", + chat_id=chat, + chat_type="group", + ), + flow=self.flow, + ledger=self.ledger, + injected_intent="collab_fields", + ) + self.assertEqual(phase, "collab_field_saved") + ticket = self.ledger.get_ticket(work_order_no=wo) + self.assertEqual(ticket.collab_facts.get("是否含电"), "否") + self.assertEqual(ticket.collab_facts.get("是否含油"), "否") + self.assertEqual(ticket.collab_facts.get("货好时间"), "随时可提") + texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"] + self.assertGreater(len(texts), n) + + def test_collab_facts_are_projected_for_quote_sheet(self) -> None: + from agent.policy.quote_overlay import overlay_labeled_facts + from agent.policy.sea_group_ops import _facts_for_fill + from openpyxl import Workbook, load_workbook + + filled = _facts_for_fill( + {"起运港": "DONGGUAN", "目的港": "海防"}, + {"货好时间": "随时可提", "是否含油": "否", "是否含电": "否", "HS编码": "8479"}, + ) + self.assertEqual(filled["cargo_ready_date"], "随时可提") + self.assertEqual(filled["hs_code"], "8479") + self.assertIn("油:否", filled["oil_elec_mag"]) + self.assertIn("电:否", filled["oil_elec_mag"]) + + import tempfile + from pathlib import Path + + wb = Workbook() + ws = wb.active + ws["A1"] = "*货好时间/Cargo Ready Date:" + ws["A2"] = "*商品海关编码/HS Code:" + ws["A3"] = "*是否含油,电,磁/OIL,ELEC,MAG:" + path = str(Path(tempfile.mkdtemp(prefix="collab-sheet-")) / "src.xlsx") + wb.save(path) + out = overlay_labeled_facts(filepath=path, facts=filled) + painted = load_workbook(out["filepath"]) + sheet = painted.active + self.assertEqual(sheet["B1"].value, "随时可提") + self.assertEqual(sheet["B2"].value, "8479") + self.assertIn("油:否", str(sheet["B3"].value or "")) + + def test_stale_deal_card_speaks(self) -> None: + from agent.policy.sea_group_ops import explain_deal_click + + self._open_group() + sess = self.flow.session_of("sales1") + sess.deal_version = 2 + sess.collab_chat_id = sess.collab_chat_id or "wr_sea_1" + reason = explain_deal_click( + flow=self.flow, + sender_id="sales1", + action=copy.BTN_DEAL, + card_meta={"event_key": f"deal:{sess.work_order_no}:v1"}, + sess=sess, + ) + self.assertEqual(reason, "stale_deal") + texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"] + self.assertTrue(any("已过期" in t for t in texts)) + + def test_parse_group_file_fills_media(self) -> None: + from agent.channel.wecom.parse import parse_plain_xml + + xml = ( + "prod41" + "filemid-x" + "报价.xlsxwr_sea_1" + ) + msg = parse_plain_xml(xml) + self.assertEqual(msg.chat_type, "group") + self.assertEqual(msg.chat_id, "wr_sea_1") + self.assertEqual(msg.media.get("filename"), "报价.xlsx") + self.assertEqual(msg.media.get("media_id"), "mid-x") + if __name__ == "__main__": unittest.main() diff --git a/inquiry-agent/tests/test_wshoto_archive.py b/inquiry-agent/tests/test_wshoto_archive.py new file mode 100644 index 0000000..c5ed4c6 --- /dev/null +++ b/inquiry-agent/tests/test_wshoto_archive.py @@ -0,0 +1,325 @@ +""" +微盛会话存档:解析、跳过存档席、测服游标、入 inbox。 + +不连微盛外网、不连现网 Redis。 +""" + +from __future__ import annotations + +import os +import sys +import unittest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +os.environ.setdefault("YTD_ENV", "test") +os.environ.setdefault("REDIS_BACKEND", "memory") +os.environ.setdefault("CHECKPOINT_BACKEND", "memory") +os.environ.setdefault("MESSAGE_STORE_BACKEND", "memory") +os.environ.setdefault("LLM_DATA_USAGE_CONFIRMED", "false") +os.environ.setdefault("LLM_ALLOW_NETWORK", "false") +os.environ.setdefault("LEDGER_BACKEND", "memory") + +from agent.channel.queue import MemoryMessageStore +from agent.channel.wshoto.client import wshoto_ready +from agent.channel.wshoto.parse import archive_record_to_inbound +from agent.channel.wshoto.rooms import ( + list_watched_rooms, + load_last_seq, + save_last_seq, + unwatch_collab_room, + watch_collab_room, +) +from agent.channel.wshoto.poll import ingest_records, poll_once +from agent.redis_coord.client import MemoryRedis, RedisClient + + +def _redis() -> RedisClient: + return RedisClient(raw=MemoryRedis(), key_prefix="inquiry_robot:", backend="memory") + + +class _Cfg: + wshoto_poll_enabled = True + wshoto_app_id = "app" + wshoto_app_secret = "secret" + wecom_archive_seat_user_ids = "AI001" + wshoto_bootstrap_lookback_sec = 3600 + wshoto_page_size = 50 + + +class _FakeApi: + def __init__(self, records: list[dict], *, error: WshotoError | None = None) -> None: + self.records = records + self.error = error + self.calls: list[dict] = [] + + def list_group_messages(self, **kwargs): + self.calls.append(kwargs) + if self.error is not None: + raise self.error + last = int(kwargs.get("last_seq") or 0) + return [r for r in self.records if int(r.get("seq") or 0) > last] + + +class WshotoParseTests(unittest.TestCase): + def test_member_text_becomes_group_inbound(self) -> None: + msg = archive_record_to_inbound( + { + "msgid": "m1", + "seq": 10, + "from": "WuJiLin", + "room_id": "wr_room_1", + "msgtype": "text", + "content": "运费 1200", + "msgtime": 1_700_000_000_000, + }, + seat_userids=["AI001"], + ) + self.assertIsNotNone(msg) + assert msg is not None + self.assertEqual(msg.sender_id, "WuJiLin") + self.assertEqual(msg.chat_id, "wr_room_1") + self.assertEqual(msg.chat_type, "group") + self.assertEqual(msg.content, "运费 1200") + self.assertEqual(msg.message_id, "wshoto:m1") + self.assertEqual(msg.create_time, 1_700_000_000) + + def test_skip_archive_seat_and_display_name(self) -> None: + self.assertIsNone( + archive_record_to_inbound( + { + "msgid": "m2", + "from": "AI001", + "room_id": "wr_room_1", + "msgtype": "text", + "content": "群简介", + }, + seat_userids=["AI001"], + ) + ) + self.assertIsNone( + archive_record_to_inbound( + { + "msgid": "m3", + "from": "询价机器人", + "room_id": "wr_room_1", + "msgtype": "text", + "content": "系统话术", + }, + seat_userids=["AI001"], + ) + ) + + def test_file_keeps_filename(self) -> None: + msg = archive_record_to_inbound( + { + "msgid": "m4", + "from": "LuoXiaoHua", + "room_id": "wr_room_1", + "msgtype": "file", + "content": "", + "msg_content": {"filename": "报价.xlsx", "sdkfileid": "sdk-1"}, + }, + seat_userids=["AI001"], + ) + self.assertIsNotNone(msg) + assert msg is not None + self.assertEqual(msg.msg_type, "file") + self.assertEqual(msg.media.get("filename"), "报价.xlsx") + self.assertEqual(msg.media.get("media_id"), "sdk-1") + + def test_skip_revoke_and_missing_room(self) -> None: + self.assertIsNone( + archive_record_to_inbound( + {"msgid": "m5", "from": "WuJiLin", "room_id": "", "msgtype": "text", "content": "x"}, + seat_userids=["AI001"], + ) + ) + self.assertIsNone( + archive_record_to_inbound( + { + "msgid": "m6", + "from": "WuJiLin", + "room_id": "wr_room_1", + "msgtype": "revoke", + "content": "x", + }, + seat_userids=["AI001"], + ) + ) + + +class WshotoPollTests(unittest.TestCase): + def test_ready_needs_keys(self) -> None: + cfg = _Cfg() + self.assertTrue(wshoto_ready(cfg)) + cfg.wshoto_app_secret = "" + self.assertFalse(wshoto_ready(cfg)) + + def test_watch_rooms_and_cursor(self) -> None: + redis = _redis() + watch_collab_room("wr_a", redis=redis) + watch_collab_room("wr_b", redis=redis) + self.assertEqual(list_watched_rooms(redis=redis), ["wr_a", "wr_b"]) + unwatch_collab_room("wr_a", redis=redis) + self.assertEqual(list_watched_rooms(redis=redis), ["wr_b"]) + save_last_seq("seat:AI001", 88, redis=redis) + self.assertEqual(load_last_seq("seat:AI001", redis=redis), 88) + watch_collab_room("wr_c", work_order_no="WO1", redis=redis) + from agent.channel.wshoto.rooms import ticket_no_for_room + + self.assertEqual(ticket_no_for_room("wr_c", redis=redis), "WO1") + + def test_watch_room_enters_hot_window(self) -> None: + from agent.channel.wshoto.rooms import has_hot_room, wait_wshoto_poll + + redis = _redis() + watch_collab_room("wr_hot", redis=redis) + self.assertTrue(has_hot_room()) + self.assertTrue(wait_wshoto_poll(0.2)) + + def test_new_room_lookback_is_capped(self) -> None: + store = MemoryMessageStore() + redis = _redis() + watch_collab_room("wr_room_1", redis=redis) + api = _FakeApi( + [ + { + "msgid": "fresh", + "seq": 7, + "from": "WuJiLin", + "room_id": "wr_room_1", + "msgtype": "text", + "content": "随时可提", + } + ] + ) + out = poll_once( + client=api, + settings=_Cfg(), + redis=redis, + store=store, + now=lambda: 1_800_000_000, + ) + self.assertTrue(out["ok"]) + call = api.calls[0] + self.assertEqual(call.get("msg_time_end"), 1_800_000_000) + self.assertEqual(call.get("msg_time_start"), 1_800_000_000 - 180) + + def test_poll_enqueues_member_skips_seat_and_advances_seq(self) -> None: + store = MemoryMessageStore() + redis = _redis() + api = _FakeApi( + [ + { + "msgid": "old", + "seq": 10, + "from": "WuJiLin", + "room_id": "wr_room_1", + "msgtype": "text", + "content": "旧消息", + }, + { + "msgid": "seat", + "seq": 11, + "from": "AI001", + "room_id": "wr_room_1", + "msgtype": "text", + "content": "机器人自己说的", + }, + { + "msgid": "new", + "seq": 12, + "from": "WuJiLin", + "room_id": "wr_room_1", + "msgtype": "text", + "content": "运费 1500", + }, + ] + ) + save_last_seq("seat:AI001", 10, redis=redis) + out = poll_once(client=api, settings=_Cfg(), redis=redis, store=store) + self.assertTrue(out["ok"]) + self.assertEqual(out["enqueued"], 1) + self.assertEqual(out["last_seq"], 12) + self.assertEqual(load_last_seq("seat:AI001", redis=redis), 12) + item = store.claim_inbound() + self.assertIsNotNone(item) + assert item is not None + self.assertEqual(item.message.content, "运费 1500") + self.assertEqual(item.message.chat_type, "group") + + def test_first_poll_uses_time_window_not_seq_zero(self) -> None: + store = MemoryMessageStore() + redis = _redis() + api = _FakeApi( + [ + { + "msgid": "recent", + "seq": 5, + "from": "WuJiLin", + "room_id": "wr_room_1", + "msgtype": "text", + "content": "刚说的", + } + ] + ) + out = poll_once( + client=api, + settings=_Cfg(), + redis=redis, + store=store, + now=lambda: 1_800_000_000, + ) + self.assertTrue(out["ok"]) + self.assertEqual(len(api.calls), 1) + call = api.calls[0] + self.assertEqual(call.get("last_seq"), 0) + self.assertGreater(call.get("msg_time_start") or 0, 0) + self.assertEqual(call.get("msg_time_end"), 1_800_000_000) + self.assertEqual(out["enqueued"], 1) + + def test_ingest_dedupes_message_id(self) -> None: + store = MemoryMessageStore() + rec = { + "msgid": "same", + "seq": 3, + "from": "WuJiLin", + "room_id": "wr_room_1", + "msgtype": "text", + "content": "重复", + } + a, _ = ingest_records([rec], seat_userids=["AI001"], store=store) + b, skipped = ingest_records([rec], seat_userids=["AI001"], store=store) + self.assertEqual(a, 1) + self.assertEqual(b, 0) + self.assertEqual(skipped, 1) + + def test_watched_rooms_preferred_without_seat_userid(self) -> None: + store = MemoryMessageStore() + redis = _redis() + watch_collab_room("wr_room_1", redis=redis) + api = _FakeApi( + [ + { + "msgid": "room-msg", + "seq": 21, + "from": "LuoXiaoHua", + "room_id": "wr_room_1", + "msgtype": "text", + "content": "20GP 1", + } + ] + ) + out = poll_once(client=api, settings=_Cfg(), redis=redis, store=store) + self.assertTrue(out["ok"]) + self.assertEqual(out["mode"], "rooms") + self.assertEqual(out["enqueued"], 1) + self.assertEqual(api.calls[0].get("userid"), "") + self.assertEqual(api.calls[0].get("target_id"), "wr_room_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 5319785..fcadab4 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 @@ -207,6 +207,36 @@ public class InquiryAgentController { return Result.OK(workOrderService.findByCollabChat(firstStr(body, "chatId", "chat_id"))); } + @ApiOperation("协同结束(不改六态,这单不能再拉群)") + @PostMapping("/ticket/endCollabGroup") + public Result> endCollabGroup(@RequestBody Map body, + HttpServletRequest http) { + if (!authed(http)) { + return Result.error(401, "unauthorized"); + } + return Result.OK(workOrderService.endCollabGroup(firstStr(body, "workOrderNo", "work_order_no"))); + } + + @ApiOperation("群附件元数据入库(不改六态)") + @PostMapping("/ticket/attachFile") + public Result> attachFile(@RequestBody Map body, + HttpServletRequest http) { + if (!authed(http)) { + return Result.error(401, "unauthorized"); + } + return Result.OK(workOrderService.attachFile(body)); + } + + @ApiOperation("产品报价写入新版本") + @PostMapping("/ticket/upsertProductQuote") + public Result> upsertProductQuote(@RequestBody Map body, + HttpServletRequest http) { + if (!authed(http)) { + return Result.error(401, "unauthorized"); + } + return Result.OK(workOrderService.upsertProductQuote(body)); + } + private boolean authed(HttpServletRequest http) { String got = http.getHeader("X-Agent-Token"); if (StringUtils.isBlank(got)) { diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryWorkOrderService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryWorkOrderService.java index 63e3bbc..cf58e4f 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 @@ -89,4 +89,19 @@ public interface IInquiryWorkOrderService extends IService { * 用协同群 chat_id 找回工单。只读。 */ Map findByCollabChat(String chatId); + + /** + * 协同结束:不改六态,这单不能再拉群。 + */ + Map endCollabGroup(String workOrderNo); + + /** + * 群附件元数据入库。不改六态。 + */ + Map attachFile(Map body); + + /** + * 产品报价写入新版本并视情况迁「已报价」。 + */ + Map upsertProductQuote(Map body); } 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 2b679a7..23e814c 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 @@ -538,10 +538,30 @@ public class InquiryQuoteTemplateServiceImpl 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); + InquiryTicket ticket = findByWorkOrderNo(workOrderNo); + if (ticket != null) { + appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "end_collab_group", "agent", "协同结束"); + } + return out; + } + + /** + * 群附件只存元数据。终态拦截在智能体侧,这里不再改六态。 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public Map attachFile(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; + } + InquiryAttachment row = new InquiryAttachment(); + row.setTicketId(ticket.getId()); + row.setWorkOrderNo(no); + row.setFileName(text(body, "fileName", "file_name")); + row.setObjectKey(text(body, "objectKey", "object_key")); + row.setContentType(text(body, "contentType", "content_type")); + row.setCreateTime(new Date()); + attachmentMapper.insert(row); + out.put("ok", true); + out.put("work_order_no", no); + out.put("file_name", row.getFileName()); + return out; + } + + /** + * 产品报价盖 TMS 价:新插一条 inq_quote,主表售价跟着改。 + * 询价中/协商中可迁已报价;已报价只加版本。终态由智能体先拦。 + */ + @Override + @Transactional(rollbackFor = Exception.class) + @SuppressWarnings("unchecked") + public Map upsertProductQuote(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; + } + Object raw = body == null ? null : body.get("quote"); + Map quote = raw instanceof Map ? (Map) raw : new LinkedHashMap<>(); + Long n = quoteMapper.selectCount(new LambdaQueryWrapper() + .eq(InquiryQuote::getWorkOrderNo, no)); + int ver = n == null ? 1 : n.intValue() + 1; + InquiryQuote row = new InquiryQuote(); + row.setTicketId(ticket.getId()); + row.setWorkOrderNo(no); + row.setQuoteVersion(ver); + row.setSource("PRODUCT"); + row.setSourceLabel(String.valueOf(quote.getOrDefault("source_label", "产品报价"))); + row.setPriceText(String.valueOf(quote.getOrDefault("total", ""))); + row.setHighlighted(1); + row.setPayloadJson(JSON.toJSONString(quote)); + row.setCreateTime(new Date()); + quoteMapper.insert(row); + Object total = quote.get("total"); + Object ccy = quote.get("currency"); + if (total != null && StringUtils.isNotBlank(String.valueOf(total))) { + String sale = (ccy == null ? "" : String.valueOf(ccy) + " ") + total; + ticket.setSalePrice(sale.trim()); + ticket.setUpdateTime(new Date()); + updateById(ticket); + } + String to = text(body, "toStatus", "to_status"); + if (StringUtils.isBlank(to)) { + to = TicketStatuses.QUOTED; + } + if (TicketStatuses.canTransit(ticket.getStatus(), to)) { + TicketTransitionRequest tr = new TicketTransitionRequest(); + tr.setWorkOrderNo(no); + tr.setToStatus(to); + transition(tr, "agent"); + } + out.put("ok", true); + out.put("quote_version", ver); + out.put("status", ticket.getStatus()); + return out; + } + + private static Map parseFactsJson(String raw) { + Map facts = new LinkedHashMap<>(); + if (StringUtils.isBlank(raw)) { + return facts; + } + JSONObject parsed = JSON.parseObject(raw); + if (parsed == null) { + return facts; + } + for (String key : parsed.keySet()) { + if (parsed.get(key) != null) { + facts.put(key, String.valueOf(parsed.get(key))); + } + } + return facts; + } + + private Map latestQuotePayload(String workOrderNo) { + Map quote = new LinkedHashMap<>(); + List quotes = quoteMapper.selectList(new LambdaQueryWrapper() + .eq(InquiryQuote::getWorkOrderNo, workOrderNo) + .orderByDesc(InquiryQuote::getCreateTime) + .last("LIMIT 1")); + if (quotes != null && !quotes.isEmpty() && StringUtils.isNotBlank(quotes.get(0).getPayloadJson())) { + JSONObject parsed = JSON.parseObject(quotes.get(0).getPayloadJson()); + if (parsed != null) { + quote.putAll(parsed); + } + } + return quote; + } + private static String memberIdsJson(Map body) { if (body == null) { return "[]"; 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 34b43e0..5910996 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 @@ -32,6 +32,45 @@ public final class QuoteTemplateExcelSupport { "FFFFCC00", "FFFFD966", "FFFFF2CC", "FFFFE699" )); + /** + * 按标签找格子时用的词典。与合同 labelDictionary 对齐,并补上海运询价单上的「费用总计 / RFQ No」。 + * 只用于填表兜底,不发明价格。 + */ + private static final Map FILL_BY_LABEL = new LinkedHashMap<>(); + + static { + FILL_BY_LABEL.put("询价日期", "quote_date"); + FILL_BY_LABEL.put("报价日期", "quote_date"); + FILL_BY_LABEL.put("品名", "commodity"); + FILL_BY_LABEL.put("货物数量", "quantity"); + FILL_BY_LABEL.put("整柜或拼柜", "fcl_or_lcl"); + FILL_BY_LABEL.put("箱型箱量", "container_type_qty"); + FILL_BY_LABEL.put("起运港", "pol"); + FILL_BY_LABEL.put("目的港", "pod"); + FILL_BY_LABEL.put("报价有效期", "valid_until"); + FILL_BY_LABEL.put("有效期至", "valid_until"); + FILL_BY_LABEL.put("费用总计", "total_amount"); + FILL_BY_LABEL.put("报价合计", "total_amount"); + FILL_BY_LABEL.put("RFQ No", "quote_no"); + FILL_BY_LABEL.put("报价单编号", "quote_no"); + FILL_BY_LABEL.put("报价编号", "quote_no"); + FILL_BY_LABEL.put("贸易条款", "trade_terms"); + FILL_BY_LABEL.put("货好时间", "cargo_ready_date"); + FILL_BY_LABEL.put("商品海关编码", "hs_code"); + FILL_BY_LABEL.put("HS编码", "hs_code"); + FILL_BY_LABEL.put("海关编码", "hs_code"); + FILL_BY_LABEL.put("是否含油,电,磁", "oil_elec_mag"); + FILL_BY_LABEL.put("件数", "pieces"); + FILL_BY_LABEL.put("重量", "weight_kg"); + FILL_BY_LABEL.put("体积", "volume_cbm"); + FILL_BY_LABEL.put("始发地", "origin"); + FILL_BY_LABEL.put("目的地", "destination"); + FILL_BY_LABEL.put("币别", "currency"); + FILL_BY_LABEL.put("业务员", "sales_name"); + FILL_BY_LABEL.put("客户名称", "customer_name"); + FILL_BY_LABEL.put("联系人", "contact_name"); + } + private QuoteTemplateExcelSupport() { } @@ -188,6 +227,8 @@ public final class QuoteTemplateExcelSupport { writeCell(wb, cellRef, String.valueOf(val)); } } + // 海运询价单映射常只对上 4 个点,且起运港映进了标签合并格。按标签再写一遍正确格子。 + writeFactsByLabel(wb, facts, yellow); writeFeeRows(wb, mapping, facts, yellow); // 后台常未配 fee_rows:按「空运费」等名称写入金额列,SUM 才能加算 writeFeeAmountsByName(wb, facts, yellow); @@ -235,7 +276,7 @@ public final class QuoteTemplateExcelSupport { writeFeeCol(sheet, r, columns.getString("currency"), rowObj.getString("currency"), yellow, sheet.getSheetName()); } if (columns.containsKey("remark")) { - writeFeeCol(sheet, r, columns.getString("remark"), rowObj.getString("remark"), yellow, sheet.getSheetName()); + writeFeeCol(sheet, r, columns.getString("remark"), formatFeeRemark(rowObj.getString("remark")), yellow, sheet.getSheetName()); } } } @@ -258,6 +299,8 @@ public final class QuoteTemplateExcelSupport { cell = row.createCell(col); } writeNumericOrText(cell, value); + // 映射表写出的备注同样要换行,否则只看得见半截 + applyRemarkWrap(sheet, row, cell, value); } /** @@ -302,9 +345,21 @@ public final class QuoteTemplateExcelSupport { if (cell == null || cell.getCellType() != CellType.FORMULA) { continue; } + String formula = cell.getCellFormula(); try { - ev.evaluateFormulaCell(cell); + CellType result = ev.evaluateFormulaCell(cell); + // 空的 D*G 会算出错误,海运未用费目行不要留下一个「=」 + if (result == CellType.ERROR && StringUtils.isNotBlank(formula)) { + cell.setCellFormula(formula); + } } catch (Exception e) { + try { + if (StringUtils.isNotBlank(formula)) { + cell.setCellFormula(formula); + } + } catch (Exception ignored) { + // 还原失败不挡出单 + } log.warn("报价公式未重算 {}!{}: {}", sheet.getSheetName(), new CellReference(cell).formatAsString(), @@ -321,7 +376,11 @@ public final class QuoteTemplateExcelSupport { /** * 按费用名称把 TMS 费用行写进模板:单位 / 币别 / 单价 / 数量 / 金额 / 备注。 - * 映射没有 fee_rows 时也能对上「空运费」等已有行。没有的字段不编、不覆盖模板原字。 + * 映射没有 fee_rows 时也能对上「空运费」等已有行。 + *

+ * 海运询价单有三段费用表,且「海运费用」先出现在整行标题里。 + * 必须跳过标题行,列映射取费目上方最近的表头,对不上的费目追加到空行。 + * 禁止把数量写进「货物数量」标签列,否则会把费目名盖成 1。 */ public static void writeFeeAmountsByName(Workbook wb, JSONObject facts, Set yellow) { JSONArray rows = parseFeeRows(facts == null ? null : facts.get("fee_rows")); @@ -329,16 +388,11 @@ public final class QuoteTemplateExcelSupport { return; } String headerCurrency = facts == null ? "" : StringUtils.trimToEmpty(facts.getString("currency")); + String boxType = firstFact(facts, "container_type_qty", "箱型箱量"); for (int s = 0; s < wb.getNumberOfSheets(); s++) { Sheet sheet = wb.getSheetAt(s); - Map cols = findFeeTableColumns(sheet); - if (cols.isEmpty()) { - cols = fallbackFeeColumns(sheet); - } - Integer amountCol = cols.get("amount"); - if (amountCol == null) { - amountCol = 6; - } + Set usedRows = new HashSet<>(); + List unmatched = new ArrayList<>(); for (int i = 0; i < rows.size(); i++) { JSONObject rowObj = rows.getJSONObject(i); if (rowObj == null) { @@ -349,68 +403,134 @@ public final class QuoteTemplateExcelSupport { if (StringUtils.isBlank(name) || StringUtils.isBlank(amount) || "-".equals(amount)) { continue; } - Cell nameCell = findCellByFeeName(sheet, name); + Cell nameCell = findCellByFeeName(sheet, name, usedRows); if (nameCell == null) { + unmatched.add(rowObj); continue; } int r = nameCell.getRowIndex(); - writeAmountCell(sheet, r, amountCol, amount, yellow); - String lineCurrency = StringUtils.trimToEmpty(rowObj.getString("currency")); - if (StringUtils.isBlank(lineCurrency)) { - lineCurrency = headerCurrency; + usedRows.add(r); + Map cols = findFeeTableColumnsAbove(sheet, r); + writeFeeDetailRow(sheet, r, cols, rowObj, headerCurrency, boxType, yellow, false); + } + for (JSONObject rowObj : unmatched) { + int empty = findEmptyFeeDataRow(sheet, usedRows); + if (empty < 0) { + log.warn("报价单没有空行可追加费用 {}", rowObj.getString("name")); + continue; } - writeMappedText(sheet, r, cols.get("currency"), lineCurrency, yellow); - writeMappedText(sheet, r, cols.get("unit"), rowObj.getString("unit"), yellow); - writeMappedNumber(sheet, r, cols.get("quantity"), rowObj.getString("quantity"), yellow); - writeMappedNumber(sheet, r, cols.get("unit_price"), rowObj.getString("unit_price"), yellow); - writeMappedText(sheet, r, cols.get("remark"), rowObj.getString("remark"), yellow); + usedRows.add(empty); + Map cols = findFeeTableColumnsAbove(sheet, empty); + writeFeeDetailRow(sheet, empty, cols, rowObj, headerCurrency, boxType, yellow, true); } } } /** - * 只认「费用名称 + 金额」那一行表头,避免把货物信息里的件/KG 当成费用列。 + * 写一行费用。writeName=false 时不碰已有费目名,避免把「文件费/Doc.」盖成数量 1。 + */ + private static void writeFeeDetailRow(Sheet sheet, int r, Map cols, JSONObject rowObj, + String headerCurrency, String boxType, Set yellow, boolean writeName) { + if (cols == null || cols.isEmpty()) { + cols = fallbackFeeColumns(sheet); + } + Integer amountCol = cols.get("amount"); + if (amountCol == null) { + amountCol = 7; + } + Integer nameCol = cols.get("name"); + Integer qtyCol = cols.get("quantity"); + // 数量列若和费目名同列,多半是误把「货物数量」当成了费用数量 + if (qtyCol != null && nameCol != null && qtyCol.equals(nameCol)) { + qtyCol = null; + } + if (writeName) { + writeMappedText(sheet, r, nameCol == null ? 0 : nameCol, rowObj.getString("name"), yellow); + } + String amount = StringUtils.trimToEmpty(rowObj.getString("amount")); + writeAmountCell(sheet, r, amountCol, amount, yellow); + String lineCurrency = StringUtils.trimToEmpty(rowObj.getString("currency")); + if (StringUtils.isBlank(lineCurrency)) { + lineCurrency = headerCurrency; + } + writeMappedText(sheet, r, cols.get("currency"), lineCurrency, yellow); + writeMappedText(sheet, r, cols.get("unit"), rowObj.getString("unit"), yellow); + writeMappedNumber(sheet, r, qtyCol, rowObj.getString("quantity"), yellow); + writeMappedNumber(sheet, r, cols.get("unit_price"), rowObj.getString("unit_price"), yellow); + writeMappedText(sheet, r, cols.get("remark"), formatFeeRemark(rowObj.getString("remark")), yellow); + Integer boxCol = pickContainerRateColumn(cols, boxType); + if (boxCol != null && !boxCol.equals(amountCol)) { + String rate = firstNonBlank(rowObj.getString("unit_price"), amount); + writeMappedNumber(sheet, r, boxCol, rate, yellow); + } + } + + /** + * 只认「费用名称/项目 + 金额」那一行表头,避免把货物信息里的件/KG 当成费用列。 + * 海运询价单表头是「项目/Items」不是「费用名称」。 * 币别和数量中间的空列或 CNY 列,按现网空运模板当成单价。 */ static Map findFeeTableColumns(Sheet sheet) { - Map cols = new LinkedHashMap<>(); if (sheet == null) { - return cols; + return new LinkedHashMap<>(); } - int headerRow = -1; - int lastCol = 0; - int maxRow = Math.min(sheet.getLastRowNum(), 40); + int maxRow = Math.min(sheet.getLastRowNum(), 80); for (int r = 0; r <= maxRow; r++) { - Row row = sheet.getRow(r); - if (row == null) { - continue; - } - boolean hasAmount = false; - boolean hasFeeName = false; - short last = row.getLastCellNum(); - for (int c = 0; c < last; c++) { - String text = readCellText(row.getCell(c)); - if (text.contains("金额") || text.contains("金額") || "amount".equalsIgnoreCase(text)) { - hasAmount = true; - } - if (text.contains("费用名称") || text.contains("费用名")) { - hasFeeName = true; - } - } - if (hasAmount && hasFeeName) { - headerRow = r; - lastCol = last; - break; + Map cols = parseFeeHeaderRow(sheet, r); + if (!cols.isEmpty()) { + return cols; } } - if (headerRow < 0) { + return new LinkedHashMap<>(); + } + + /** + * 从费目行往上找最近的费用表头。海运有始发/海运/目的三段,列不能都用第一张表。 + */ + static Map findFeeTableColumnsAbove(Sheet sheet, int dataRow) { + if (sheet == null) { + return new LinkedHashMap<>(); + } + for (int r = Math.max(dataRow - 1, 0); r >= 0; r--) { + Map cols = parseFeeHeaderRow(sheet, r); + if (!cols.isEmpty()) { + return cols; + } + } + Map first = findFeeTableColumns(sheet); + return first.isEmpty() ? fallbackFeeColumns(sheet) : first; + } + + /** + * 解析一行是否为费用表头,并记下各列。 + */ + static Map parseFeeHeaderRow(Sheet sheet, int r) { + Map cols = new LinkedHashMap<>(); + Row row = sheet.getRow(r); + if (row == null) { return cols; } - Row row = sheet.getRow(headerRow); - for (int c = 0; c < lastCol; c++) { + boolean hasAmount = false; + boolean hasFeeName = false; + short last = row.getLastCellNum(); + for (int c = 0; c < last; c++) { + String text = readCellText(row.getCell(c)); + if (isAmountHeader(text)) { + hasAmount = true; + } + if (isFeeNameHeader(text)) { + hasFeeName = true; + } + } + if (!hasAmount || !hasFeeName) { + return cols; + } + for (int c = 0; c < last; c++) { String text = StringUtils.trimToEmpty(readCellText(row.getCell(c))); - if (text.contains("费用名称") || text.contains("费用名")) { + if (isFeeNameHeader(text)) { cols.put("name", c); + } else if (isContainerTypeHeader(text)) { + cols.put(normalizeContainerType(text), c); } else if (text.contains("单位") || text.contains("單位")) { cols.put("unit", c); } else if (text.contains("币别") || text.contains("币種") || text.contains("币种")) { @@ -419,7 +539,7 @@ public final class QuoteTemplateExcelSupport { cols.put("unit_price", c); } else if (text.contains("数量") || text.contains("數量")) { cols.put("quantity", c); - } else if (text.contains("金额") || text.contains("金額") || "amount".equalsIgnoreCase(text)) { + } else if (isAmountHeader(text)) { cols.put("amount", c); } else if (text.contains("备注") || text.contains("備註")) { cols.put("remark", c); @@ -431,29 +551,25 @@ public final class QuoteTemplateExcelSupport { cols.put("unit_price", c); } } + if (!cols.containsKey("name")) { + cols.put("name", 0); + } return cols; } + /** + * 只从费用表头行取列。禁止扫到「货物数量」把数量列认成 A。 + */ private static Map fallbackFeeColumns(Sheet sheet) { + Map found = findFeeTableColumns(sheet); + if (!found.isEmpty()) { + return found; + } Map cols = new LinkedHashMap<>(); - putCol(cols, "amount", findHeaderColumn(sheet, Arrays.asList("金额", "金額", "amount"))); - putCol(cols, "currency", findHeaderColumn(sheet, Arrays.asList("币别", "币種", "币种"))); - putCol(cols, "unit", findHeaderColumn(sheet, Arrays.asList("单位", "單位"))); - putCol(cols, "quantity", findHeaderColumn(sheet, Arrays.asList("数量", "數量"))); - putCol(cols, "unit_price", findHeaderColumn(sheet, Arrays.asList("单价", "單價"))); - putCol(cols, "remark", findHeaderColumn(sheet, Arrays.asList("备注", "備註"))); - if (!cols.containsKey("amount")) { - cols.put("amount", 6); - } + cols.put("amount", 6); return cols; } - private static void putCol(Map cols, String key, int col) { - if (col >= 0) { - cols.put(key, col); - } - } - private static void writeMappedText(Sheet sheet, int rowIdx, Integer col, String value, Set yellow) { if (col == null || StringUtils.isBlank(value) || "-".equals(value.trim())) { return; @@ -502,35 +618,17 @@ public final class QuoteTemplateExcelSupport { return new JSONArray(); } - private static int findHeaderColumn(Sheet sheet, List titles) { - int maxRow = Math.min(sheet.getLastRowNum(), 40); - for (int r = 0; r <= maxRow; r++) { - Row row = sheet.getRow(r); - if (row == null) { - continue; - } - short last = row.getLastCellNum(); - for (int c = 0; c < last; c++) { - Cell cell = row.getCell(c); - String text = readCellText(cell); - if (StringUtils.isBlank(text)) { - continue; - } - for (String t : titles) { - if (text.contains(t)) { - return c; - } - } - } - } - return -1; - } - - private static Cell findCellByFeeName(Sheet sheet, String name) { + /** + * 按费目名找数据行。跳过整行标题(「海运费用」先出现在 A29 合并标题里)。 + * 只认「海运费↔海运费用」这种加「用/费用」的宽松匹配,不对「操作费」误中「码头操作费」。 + */ + private static Cell findCellByFeeName(Sheet sheet, String name, Set usedRows) { String want = name.trim(); - Cell fuzzy = null; int maxRow = Math.min(sheet.getLastRowNum(), 80); for (int r = 0; r <= maxRow; r++) { + if (usedRows != null && usedRows.contains(r)) { + continue; + } Row row = sheet.getRow(r); if (row == null) { continue; @@ -539,35 +637,39 @@ public final class QuoteTemplateExcelSupport { for (int c = 0; c < last; c++) { Cell cell = row.getCell(c); String text = readCellText(cell); - if (StringUtils.isBlank(text)) { + if (StringUtils.isBlank(text) || isFeeTableHeader(text) || isSectionTitle(sheet, cell)) { continue; } - if (want.equals(text.trim())) { + if (feeNameMatches(text, want)) { return cell; } - if (fuzzy == null && text.contains(want) && want.length() >= 2) { - fuzzy = cell; - } } } - return fuzzy; + return null; } /** * 费用明细表头原字,回填系统字段时不得覆盖。 + * 海运表头带英文「项目/Items」「金额/Ammount」,以及 20GP 箱型列。 */ static boolean isFeeTableHeader(String text) { if (StringUtils.isBlank(text)) { return false; } - String t = text.trim(); - return "项目".equals(t) || "费用名称".equals(t) || "费用名".equals(t) - || "单位".equals(t) || "單位".equals(t) - || "币别".equals(t) || "币種".equals(t) || "币种".equals(t) - || "单价".equals(t) || "單價".equals(t) - || "数量".equals(t) || "數量".equals(t) - || "金额".equals(t) || "金額".equals(t) - || "备注".equals(t) || "備註".equals(t); + if (isContainerTypeHeader(text)) { + return true; + } + String primary = feePrimaryName(text); + return "项目".equals(primary) || "费用名称".equals(primary) || "费用名".equals(primary) + || "单位".equals(primary) || "單位".equals(primary) + || "币别".equals(primary) || "币種".equals(primary) || "币种".equals(primary) + || "单价".equals(primary) || "單價".equals(primary) + || "数量".equals(primary) || "數量".equals(primary) + || "金额".equals(primary) || "金額".equals(primary) + || "备注".equals(primary) || "備註".equals(primary) + || "Items".equalsIgnoreCase(primary) || "Cur.".equalsIgnoreCase(primary) + || "Unit".equalsIgnoreCase(primary) || "Qty.".equalsIgnoreCase(primary) + || "Ammount".equalsIgnoreCase(primary) || "Amount".equalsIgnoreCase(primary); } private static String readCellText(Cell cell) { @@ -589,8 +691,17 @@ public final class QuoteTemplateExcelSupport { row = sheet.createRow(dest[0]); } Cell cell = row.getCell(dest[1]); + CellStyle style = cell == null ? null : cell.getCellStyle(); + // 海运模板金额格是 =D31*G31。只改缓存的话,后面 refreshFormulas 会按空的 40HQ 列算回 0。 + if (cell != null && cell.getCellType() == CellType.FORMULA) { + row.removeCell(cell); + cell = null; + } if (cell == null) { cell = row.createCell(dest[1]); + if (style != null) { + cell.setCellStyle(style); + } } String norm = stripMoneyPrefix(amount); try { @@ -628,7 +739,93 @@ public final class QuoteTemplateExcelSupport { if (cell == null) { cell = row.createCell(dest[1]); } - cell.setCellValue(value == null ? "" : value); + String text = value == null ? "" : value; + cell.setCellValue(text); + applyRemarkWrap(sheet, row, cell, text); + } + + /** + * 海运备注常是「计价单位:柜;加点系数:0.0000000」。 + * 模板备注列很窄(约 13 个英文字宽),整句挤一行只能看见半截; + * 按分号拆成两行,销售一眼能看完两段。 + */ + static String formatFeeRemark(String raw) { + if (StringUtils.isBlank(raw)) { + return raw; + } + String text = raw.trim(); + if (!text.contains("计价") && !text.contains("加点") && !text.contains("计费")) { + return text; + } + return text.replace(';', '\n').replace(';', '\n').replaceAll("\n+", "\n").trim(); + } + + /** + * 备注打开换行、顶对齐,并按最长一行把列加宽、按行数加高。 + * 只加宽备注这类长句所在列,不改其它列,避免整页打印缩放被带跑。 + */ + static void applyRemarkWrap(Sheet sheet, Row row, Cell cell, String text) { + if (sheet == null || row == null || cell == null || StringUtils.isBlank(text) || text.length() < 10) { + return; + } + String shown = formatFeeRemark(text); + if (!shown.equals(text)) { + cell.setCellValue(shown); + text = shown; + } + CellStyle copy = sheet.getWorkbook().createCellStyle(); + if (cell.getCellStyle() != null) { + copy.cloneStyleFrom(cell.getCellStyle()); + } + copy.setWrapText(true); + copy.setShrinkToFit(false); + copy.setVerticalAlignment(VerticalAlignment.TOP); + cell.setCellStyle(copy); + boolean feeRemark = text.contains("\n") || text.contains("计价") || text.contains("加点") || text.contains("计费"); + if (feeRemark) { + widenRemarkColumn(sheet, cell.getColumnIndex(), text); + } + int lines = countWrappedLines(sheet, cell.getColumnIndex(), text); + float need = Math.max(row.getHeightInPoints(), Math.min(16f * lines + 6f, 80f)); + if (row.getHeightInPoints() < need) { + row.setHeightInPoints(need); + } + } + + /** + * 按最长一行估算列宽。中文按 2 个英文字宽,上限 26,避免把整表撑破。 + */ + static void widenRemarkColumn(Sheet sheet, int col, String text) { + if (sheet == null || col < 0 || StringUtils.isBlank(text)) { + return; + } + int longest = 0; + for (String part : text.split("\\r?\\n")) { + longest = Math.max(longest, displayWidth(part)); + } + int want = Math.min(Math.max(longest + 2, 18), 26); + int current = sheet.getColumnWidth(col) / 256; + if (current < want) { + sheet.setColumnWidth(col, want * 256); + } + } + + private static int countWrappedLines(Sheet sheet, int col, String text) { + int charsPerLine = Math.max(sheet.getColumnWidth(col) / 256, 8); + int lines = 0; + for (String part : text.split("\\r?\\n", -1)) { + int width = Math.max(displayWidth(part), 1); + lines += Math.max(1, (width + charsPerLine - 1) / charsPerLine); + } + return Math.max(lines, 1); + } + + private static int displayWidth(String text) { + int n = 0; + for (int i = 0; i < text.length(); i++) { + n += text.charAt(i) > 127 ? 2 : 1; + } + return n; } private static int[] resolveMergeOrigin(Sheet sheet, int row, int col) { @@ -662,13 +859,17 @@ public final class QuoteTemplateExcelSupport { String text = value == null ? "" : value; // 标签格「报价编号:」只换冒号后的值,避免整格被工单号顶掉看起来像残缺 String existing = readCellText(cell); - // 「币别」右侧常被误映到「单价」表头,不能把 CNY 盖掉单价二字 - if (isFeeTableHeader(existing)) { + // 映射点常落在合并标签格 / 20GP 表头上,不能把原字盖掉 + if (isFeeTableHeader(existing) || isLabelLike(existing) || isSectionTitle(sheet, cell)) { return; } int colon = Math.max(existing.indexOf(':'), existing.indexOf(':')); - if (colon >= 0 && colon == existing.length() - 1) { - text = existing + text; + if (colon >= 0) { + String prefix = existing.substring(0, colon + 1); + if (colon == existing.length() - 1 || prefix.toUpperCase(Locale.ROOT).contains("RFQ") + || prefix.contains("编号")) { + text = prefix + text; + } } cell.setCellValue(text); CellStyle style = cell.getCellStyle(); @@ -756,39 +957,137 @@ public final class QuoteTemplateExcelSupport { } } + /** + * 标签右侧的可填格。海运询价单标签常是 E4:G4 合并,值在 H4:J4; + * 不能再取合并区内的 F4,否则会把「起运港」四字盖掉。 + * 表头「币别」右侧是 20GP,对不上就空着,不要映到箱型列。 + */ private static String resolveFillTarget(Sheet sheet, Cell labelCell, String sheetName, String text, DataFormatter fmt) { - // 同一格「标签:值」 int idx = Math.max(text.indexOf(':'), text.indexOf(':')); if (idx >= 0 && idx < text.length() - 1 && StringUtils.isNotBlank(text.substring(idx + 1).trim())) { - // 值已在标签格内:映射到本格(生成时会整格重写为纯值,运营需知悉) + // RFQ No:样例 / 报价编号:WO 这类:值在本格冒号后 return sheetName + "!" + new CellReference(labelCell).formatAsString(); } - // 右侧相邻非黄空格或任意右侧格 + int rowIdx = labelCell.getRowIndex(); + int startCol = labelCell.getColumnIndex() + 1; + for (int i = 0; i < sheet.getNumMergedRegions(); i++) { + org.apache.poi.ss.util.CellRangeAddress range = sheet.getMergedRegion(i); + if (range.isInRange(rowIdx, labelCell.getColumnIndex())) { + startCol = range.getLastColumn() + 1; + break; + } + } Row row = labelCell.getRow(); - int nextCol = labelCell.getColumnIndex() + 1; - Cell right = row.getCell(nextCol); - if (right == null) { - right = row.createCell(nextCol); + if (row == null) { + return null; + } + String firstEmpty = null; + String wideEmpty = null; + int last = Math.max(row.getLastCellNum(), startCol + 10); + for (int c = startCol; c <= last; c++) { + int[] dest = resolveMergeOrigin(sheet, rowIdx, c); + Cell cand = row.getCell(dest[1]); + String exist = readCellText(cand); + if (isFeeTableHeader(exist) || isLabelLike(exist) || isSectionTitle(sheet, cand)) { + continue; + } + String ref = sheetName + "!" + new CellReference(dest[0], dest[1]).formatAsString(); + if (StringUtils.isNotBlank(exist)) { + // 映射已经写过本字段,不要再跨到右侧「航程时间」那一格 + if (firstEmpty == null && wideEmpty == null) { + return ref; + } + break; + } + if (firstEmpty == null) { + firstEmpty = ref; + } + if (wideEmpty == null && mergeWidth(sheet, dest[0], dest[1]) >= 2) { + wideEmpty = ref; + } + } + return wideEmpty != null ? wideEmpty : firstEmpty; + } + + /** + * 映射点不够时,按标签把询价七项 / 合计写到标签右侧空格。 + * 币别若落在费用表头行则跳过,避免 CNY 盖掉 20GP。 + */ + static void writeFactsByLabel(Workbook wb, JSONObject facts, Set yellow) { + if (wb == null || facts == null) { + return; + } + DataFormatter fmt = new DataFormatter(); + List pending = new ArrayList<>(); + for (int s = 0; s < wb.getNumberOfSheets(); s++) { + Sheet sheet = wb.getSheetAt(s); + if (sheet == null) { + continue; + } + String sheetName = sheet.getSheetName(); + int lastRow = sheet.getLastRowNum(); + for (int r = 0; r <= lastRow; r++) { + Row row = sheet.getRow(r); + if (row == null) { + continue; + } + boolean feeHeader = !parseFeeHeaderRow(sheet, r).isEmpty(); + short lastCol = row.getLastCellNum(); + for (int c = 0; c < lastCol; c++) { + Cell cell = row.getCell(c); + if (cell == null) { + continue; + } + String text = StringUtils.trimToEmpty(fmt.formatCellValue(cell)); + if (text.isEmpty() || isSectionTitle(sheet, cell) || isFeeTableHeader(text)) { + continue; + } + String code = matchLabel(text.replace(":", ":").trim(), FILL_BY_LABEL); + if (StringUtils.isBlank(code)) { + continue; + } + if ("currency".equals(code) && feeHeader) { + continue; + } + String val = firstFact(facts, code, text.replace(":", "").replace(":", "").trim()); + if (StringUtils.isBlank(val) || "-".equals(val)) { + continue; + } + String fillRef = resolveFillTarget(sheet, cell, sheetName, text, fmt); + if (StringUtils.isBlank(fillRef) || (yellow != null && yellow.contains(fillRef))) { + continue; + } + pending.add(new String[]{fillRef, val}); + } + } + } + // 先扫完再写:POI 遍历行时 createCell 会 ConcurrentModificationException + for (String[] one : pending) { + writeCell(wb, one[0], one[1]); } - return sheetName + "!" + new CellReference(right).formatAsString(); } private static String matchLabel(String normalized, Map dict) { - if (dict == null || dict.isEmpty()) { + if (dict == null || dict.isEmpty() || StringUtils.isBlank(normalized)) { return null; } - String key = normalized; + String key = normalized.replace(":", ":").trim(); if (dict.containsKey(key)) { return dict.get(key); } - // 去冒号再试 - String noColon = key.replace(":", "").trim(); + // 「*货物数量/Quantity…」只取斜杠前中文,避免星号和英文把词典对不上 + String primary = feePrimaryName(key); + if (dict.containsKey(primary)) { + return dict.get(primary); + } + String noColon = primary.replace(":", "").trim(); for (Map.Entry e : dict.entrySet()) { String dk = e.getKey().replace(":", ":").replace(":", "").trim(); + if (dk.length() < 2 || noColon.length() < 2) { + continue; + } if (dk.equalsIgnoreCase(noColon) || noColon.startsWith(dk) || dk.startsWith(noColon)) { - if (dk.length() >= 2 && noColon.length() >= 2) { - return e.getValue(); - } + return e.getValue(); } } return null; @@ -837,6 +1136,225 @@ public final class QuoteTemplateExcelSupport { return map; } + /** + * 询价单左侧/右侧说明文字。写进去会把「起运港/POL…」盖成港口名,销售看起来像空表。 + */ + static boolean isLabelLike(String text) { + if (StringUtils.isBlank(text) || text.length() < 4) { + return false; + } + if (isFeeTableHeader(text) || isContainerTypeHeader(text)) { + return false; + } + String[] markers = { + "起运港", "目的港", "询价日期", "品名", "货物数量", "整柜或拼柜", "箱型箱量", + "贸易条款", "货好时间", "商品海关编码", "是否含油", "提货地址", "送货地址", "报价有效期", "费用总计", + "始发港", "目的地", "Inquiry Date", "Commodity", "Place of Receipt" + }; + for (String m : markers) { + if (text.contains(m) && (text.contains("/") || text.contains(":") || text.contains("(") || text.length() >= 6)) { + return true; + } + } + return false; + } + + /** + * 整行分区标题:始发港费用 / 海运费用 / 汇总。模糊匹配「海运费」时必须跳过。 + */ + static boolean isSectionTitle(Sheet sheet, Cell cell) { + if (cell == null) { + return false; + } + String text = readCellText(cell); + if (StringUtils.isBlank(text)) { + return false; + } + if (text.contains("汇总") || text.contains("免用箱期") || text.contains("Origin Charges") + || text.contains("Destination Charges") || text.contains("Local Charges")) { + return true; + } + int row = cell.getRowIndex(); + int col = cell.getColumnIndex(); + for (int i = 0; i < sheet.getNumMergedRegions(); i++) { + org.apache.poi.ss.util.CellRangeAddress range = sheet.getMergedRegion(i); + if (range.isInRange(row, col) && range.getLastColumn() - range.getFirstColumn() >= 6 + && (text.contains("费用") || text.toLowerCase(Locale.ROOT).contains("freight") + || text.toLowerCase(Locale.ROOT).contains("charges"))) { + return true; + } + } + return false; + } + + static boolean isAmountHeader(String text) { + if (StringUtils.isBlank(text)) { + return false; + } + String primary = feePrimaryName(text); + return "金额".equals(primary) || "金額".equals(primary) + || "amount".equalsIgnoreCase(primary) || "ammount".equalsIgnoreCase(primary) + || text.contains("金额") || text.contains("金額"); + } + + static boolean isFeeNameHeader(String text) { + if (StringUtils.isBlank(text)) { + return false; + } + String primary = feePrimaryName(text); + return "费用名称".equals(primary) || "费用名".equals(primary) || "项目".equals(primary) + || text.contains("费用名称") || text.startsWith("项目/") || "项目/Items".equals(text.trim()); + } + + static boolean isContainerTypeHeader(String text) { + if (StringUtils.isBlank(text)) { + return false; + } + String t = text.trim().toUpperCase(Locale.ROOT).replace(" ", ""); + return t.equals("20GP") || t.equals("40GP") || t.equals("40HQ") || t.equals("40HC") + || t.equals("45HQ") || t.equals("40NOR") || t.equals("20HQ"); + } + + static String normalizeContainerType(String text) { + return text == null ? "" : text.trim().toUpperCase(Locale.ROOT).replace(" ", ""); + } + + static String feePrimaryName(String text) { + if (text == null) { + return ""; + } + String t = text.trim(); + int slash = t.indexOf('/'); + if (slash < 0) { + slash = t.indexOf('/'); + } + if (slash > 0) { + t = t.substring(0, slash).trim(); + } + if (t.startsWith("*")) { + t = t.substring(1).trim(); + } + return t; + } + + /** + * 海运费=海运费用;文件费=文件费/Doc.。不对「操作费」误中「码头操作费」。 + */ + static boolean feeNameMatches(String cellText, String want) { + if (StringUtils.isBlank(cellText) || StringUtils.isBlank(want)) { + return false; + } + String full = cellText.trim(); + if (full.equals(want)) { + return true; + } + String primary = feePrimaryName(full); + if (primary.equals(want)) { + return true; + } + return primary.equals(want + "用") || primary.equals(want + "费用") + || want.equals(primary + "用") || want.equals(primary + "费用"); + } + + static Integer pickContainerRateColumn(Map cols, String boxType) { + if (cols == null || StringUtils.isBlank(boxType)) { + return null; + } + String box = boxType.toUpperCase(Locale.ROOT).replace(" ", ""); + String[] keys = {"45HQ", "40HQ", "40HC", "40GP", "20GP", "20HQ"}; + for (String key : keys) { + if (box.contains(key) && cols.containsKey(key)) { + return cols.get(key); + } + } + return null; + } + + /** + * 费用表里费目名为空的数据行,用来追加 TMS 有、模板没预印的费目。 + */ + static int findEmptyFeeDataRow(Sheet sheet, Set usedRows) { + if (sheet == null) { + return -1; + } + int maxRow = Math.min(sheet.getLastRowNum() + 8, 80); + List headers = new ArrayList<>(); + for (int r = 0; r <= maxRow; r++) { + if (!parseFeeHeaderRow(sheet, r).isEmpty()) { + headers.add(r); + } + } + for (Integer header : headers) { + Map cols = parseFeeHeaderRow(sheet, header); + int nameCol = cols.getOrDefault("name", 0); + for (int r = header + 1; r <= maxRow; r++) { + if (!parseFeeHeaderRow(sheet, r).isEmpty()) { + break; + } + Row row = sheet.getRow(r); + Cell first = row == null ? null : row.getCell(nameCol); + if (isSectionTitle(sheet, first)) { + break; + } + if (usedRows != null && usedRows.contains(r)) { + continue; + } + String name = readCellText(first); + if (StringUtils.isBlank(name)) { + return r; + } + } + } + return -1; + } + + static int mergeWidth(Sheet sheet, int row, int col) { + for (int i = 0; i < sheet.getNumMergedRegions(); i++) { + org.apache.poi.ss.util.CellRangeAddress range = sheet.getMergedRegion(i); + if (range.isInRange(row, col)) { + return range.getLastColumn() - range.getFirstColumn() + 1; + } + } + return 1; + } + + static String firstFact(JSONObject facts, String... keys) { + if (facts == null) { + return ""; + } + for (String k : keys) { + Object v = facts.get(k); + if (v != null && StringUtils.isNotBlank(String.valueOf(v))) { + return String.valueOf(v).trim(); + } + } + // 只传了「货好时间」时,按标签词典反查 cargo_ready_date + if (keys != null && keys.length > 0) { + String code = keys[0]; + for (Map.Entry e : FILL_BY_LABEL.entrySet()) { + if (code.equals(e.getValue())) { + Object v = facts.get(e.getKey()); + if (v != null && StringUtils.isNotBlank(String.valueOf(v))) { + return String.valueOf(v).trim(); + } + } + } + } + return ""; + } + + static String firstNonBlank(String... vals) { + if (vals == null) { + return ""; + } + for (String v : vals) { + if (StringUtils.isNotBlank(v) && !"-".equals(v.trim())) { + return v.trim(); + } + } + return ""; + } + /** sheet!A1 解析。 */ private static final class ParsedRef { final String sheet; diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/contracts/quote-template-mapping-v1.json b/inquiry-api/jeecg-module-inquiry/src/main/resources/contracts/quote-template-mapping-v1.json index afa8d5b..8498312 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/resources/contracts/quote-template-mapping-v1.json +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/contracts/quote-template-mapping-v1.json @@ -95,7 +95,11 @@ "重量": "weight_kg", "重 量": "weight_kg", "体积": "volume_cbm", - "体 积": "volume_cbm" + "体 积": "volume_cbm", + "费用总计": "total_amount", + "报价合计": "total_amount", + "RFQ No": "quote_no", + "询价单号": "quote_no" }, "samplePreviewFacts": { "customer_name": "ABC Trading Co.", 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 3f1e300..d1af42a 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 @@ -122,6 +122,46 @@ public class QuoteTemplateFillTest { assertEquals(1333.6, row.getCell(5).getNumericCellValue(), 0.01); assertEquals(14669.60, row.getCell(6).getNumericCellValue(), 0.01); assertEquals("计费重进位+1000KG", row.getCell(7).getStringCellValue()); + assertTrue(row.getCell(7).getCellStyle().getWrapText()); + assertTrue(row.getHeightInPoints() >= 28f); + } finally { + Files.deleteIfExists(src); + Files.deleteIfExists(out); + } + } + + @Test + public void seaFeeRemarkSplitsAndWidensColumn() throws Exception { + Path src = Files.createTempFile("quote-remark-src", ".xlsx"); + Path out = Files.createTempFile("quote-remark-out", ".xlsx"); + try (Workbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("海运询价单"); + sheet.setColumnWidth(8, 13 * 256); + org.apache.poi.ss.usermodel.Row head = sheet.createRow(17); + head.createCell(0).setCellValue("项目"); + head.createCell(7).setCellValue("金额"); + head.createCell(8).setCellValue("备注"); + sheet.createRow(18).createCell(0).setCellValue("燃油附加费"); + try (java.io.OutputStream os = Files.newOutputStream(src)) { + wb.write(os); + } + } + JSONObject mapping = new JSONObject(); + mapping.put("fields", new JSONObject()); + JSONObject facts = new JSONObject(true); + facts.put("fee_rows", QuoteTemplateExcelSupport.parseFeeRows( + "[{\"name\":\"燃油附加费\",\"amount\":\"15\",\"remark\":\"计价单位:柜;加点系数:0.0000000\"}]")); + QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts); + try (Workbook wb = new XSSFWorkbook(Files.newInputStream(out))) { + Sheet sheet = wb.getSheetAt(0); + org.apache.poi.ss.usermodel.Row row = sheet.getRow(18); + String remark = row.getCell(8).getStringCellValue(); + assertTrue(remark.contains("计价单位:柜")); + assertTrue(remark.contains("加点系数:0.0000000")); + assertTrue(remark.contains("\n")); + assertTrue(row.getCell(8).getCellStyle().getWrapText()); + assertTrue(sheet.getColumnWidth(8) >= 18 * 256); + assertTrue(row.getHeightInPoints() >= 30f); } finally { Files.deleteIfExists(src); Files.deleteIfExists(out); @@ -244,4 +284,223 @@ public class QuoteTemplateFillTest { Files.deleteIfExists(out); } } + + @Test + public void buildFillFactsProjectsSeaSevenFields() { + Map facts = new HashMap<>(); + facts.put("起运港", "DONGGUAN"); + facts.put("目的港", "海防"); + facts.put("品名", "机器设备"); + facts.put("货量", "15件"); + facts.put("整柜或拼柜", "整柜"); + facts.put("箱型箱量", "20GP*1"); + facts.put("报价日期", "2026-09-15"); + Map quote = new HashMap<>(); + quote.put("total", "2287.65"); + quote.put("currency", "CNY"); + quote.put("fee_lines", + "[{\"name\":\"海运费\",\"amount\":\"100\",\"currency\":\"USD\",\"quantity\":\"1\",\"unit\":\"柜\"}," + + "{\"name\":\"文件费\",\"amount\":\"450\",\"currency\":\"CNY\",\"quantity\":\"1\",\"unit\":\"票\"}]"); + JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl + .buildFillFacts("WO202609150032", facts, quote, "WuJiLin"); + assertEquals("DONGGUAN", out.getString("pol")); + assertEquals("海防", out.getString("pod")); + assertEquals("机器设备", out.getString("commodity")); + assertEquals("15件", out.getString("quantity")); + assertEquals("整柜", out.getString("fcl_or_lcl")); + assertEquals("20GP*1", out.getString("container_type_qty")); + assertEquals("2287.65", out.getString("total_amount")); + } + + @Test + public void buildFillFactsProjectsCollabFields() { + Map facts = new HashMap<>(); + facts.put("起运港", "DONGGUAN"); + facts.put("货好时间", "随时可提"); + facts.put("是否含油", "否"); + facts.put("是否含电", "否"); + facts.put("HS编码", "8479"); + JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl + .buildFillFacts("WO202609150041", facts, new HashMap<>(), "WuJiLin"); + assertEquals("随时可提", out.getString("cargo_ready_date")); + assertEquals("8479", out.getString("hs_code")); + assertTrue(out.getString("oil_elec_mag").contains("油:否")); + assertTrue(out.getString("oil_elec_mag").contains("电:否")); + assertEquals(2, out.getJSONArray("fee_rows").size()); + } + + /** + * 海运询价单:起运港标签是 E4:G4 合并,值在 H4:J4。 + * 旧映射把 pol 指到 F4,会把标签盖成港口名;费用数量还会误中「货物数量」列把费目名改成 1。 + * 出单必须:标签还在、港口/货量写在值格、TMS 金额进金额列。 + */ + @Test + public void seaInquiryFormKeepsLabelsAndWritesTmsFees() throws Exception { + Path src = Files.createTempFile("quote-sea-src", ".xlsx"); + Path out = Files.createTempFile("quote-sea-out", ".xlsx"); + try (Workbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("海运询价单"); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(3, 3, 1, 3)); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(3, 3, 4, 6)); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(3, 3, 7, 9)); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(4, 4, 1, 3)); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(4, 4, 4, 6)); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(4, 4, 7, 9)); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(5, 5, 1, 3)); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(7, 7, 1, 3)); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(28, 28, 0, 9)); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(43, 43, 2, 9)); + row(sheet, 3).createCell(0).setCellValue("*询价日期/Inquiry Date:"); + row(sheet, 3).createCell(4).setCellValue("起运港/POL(有指定起运港口的必填)"); + row(sheet, 4).createCell(0).setCellValue("*品名/Commodity:"); + row(sheet, 4).createCell(4).setCellValue("目的港/POD(有指定目的港口的必填)"); + row(sheet, 5).createCell(0).setCellValue("*货物数量/Quantity (或提供装箱单/Packing List)"); + row(sheet, 6).createCell(0).setCellValue("*整柜或拼柜/FCL or LCL:"); + row(sheet, 7).createCell(0).setCellValue("*箱型箱量/Type & Qty of Cont.:"); + row(sheet, 17).createCell(0).setCellValue("项目/Items"); + row(sheet, 17).createCell(1).setCellValue("币别/Cur."); + row(sheet, 17).createCell(2).setCellValue("20GP"); + row(sheet, 17).createCell(7).setCellValue("金额/Ammount"); + row(sheet, 28).createCell(0).setCellValue("海运费用/Ocean Freight"); + org.apache.poi.ss.usermodel.Row oceanHead = row(sheet, 29); + oceanHead.createCell(0).setCellValue("项目/Items"); + oceanHead.createCell(1).setCellValue("币别/Cur."); + oceanHead.createCell(2).setCellValue("20GP"); + oceanHead.createCell(7).setCellValue("金额/Ammount"); + row(sheet, 30).createCell(0).setCellValue("海运费用/Ocean Freight"); + row(sheet, 30).createCell(7).setCellFormula("D31*G31"); + org.apache.poi.ss.usermodel.Row destHead = row(sheet, 34); + destHead.createCell(0).setCellValue("项目/Items"); + destHead.createCell(1).setCellValue("币别/Cur."); + destHead.createCell(7).setCellValue("金额/Ammount"); + row(sheet, 35).createCell(0).setCellValue("文件费/Doc."); + row(sheet, 43).createCell(0).setCellValue("费用总计/Total Costs"); + try (java.io.OutputStream os = Files.newOutputStream(src)) { + wb.write(os); + } + } + JSONObject mapping = new JSONObject(); + JSONObject fields = new JSONObject(); + fields.put("pol", "海运询价单!F4"); + fields.put("pod", "海运询价单!F5"); + fields.put("currency", "海运询价单!C18"); + mapping.put("fields", fields); + JSONObject facts = new JSONObject(true); + facts.put("pol", "DONGGUAN"); + facts.put("pod", "海防"); + facts.put("commodity", "机器设备"); + facts.put("quantity", "15件"); + facts.put("fcl_or_lcl", "整柜"); + facts.put("container_type_qty", "20GP*1"); + facts.put("currency", "CNY"); + facts.put("total_amount", "2287.65"); + facts.put("fee_rows", QuoteTemplateExcelSupport.parseFeeRows( + "[{\"name\":\"海运费\",\"amount\":\"100\",\"currency\":\"USD\",\"quantity\":\"1\",\"unit\":\"柜\"}," + + "{\"name\":\"文件费\",\"amount\":\"450\",\"currency\":\"CNY\",\"quantity\":\"1\",\"unit\":\"票\"}," + + "{\"name\":\"燃油附加费\",\"amount\":\"15\",\"currency\":\"USD\",\"quantity\":\"1\",\"unit\":\"柜\"}]")); + QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts); + try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) { + Sheet sheet = filled.getSheetAt(0); + assertTrue(read(sheet, 3, 4).contains("起运港")); + assertEquals("DONGGUAN", read(sheet, 3, 7)); + assertTrue(read(sheet, 4, 4).contains("目的港")); + assertEquals("海防", read(sheet, 4, 7)); + assertEquals("机器设备", read(sheet, 4, 1)); + assertEquals("15件", read(sheet, 5, 1)); + assertEquals("整柜", read(sheet, 6, 1)); + assertEquals("20GP*1", read(sheet, 7, 1)); + assertEquals("20GP", read(sheet, 17, 2)); + assertTrue(read(sheet, 28, 0).contains("海运费用")); + assertTrue(read(sheet, 30, 0).contains("海运费用")); + assertEquals(CellType.NUMERIC, sheet.getRow(30).getCell(7).getCellType()); + assertEquals(100.0, sheet.getRow(30).getCell(7).getNumericCellValue(), 0.01); + assertEquals("USD", read(sheet, 30, 1)); + assertTrue(read(sheet, 35, 0).contains("文件费")); + assertEquals(450.0, sheet.getRow(35).getCell(7).getNumericCellValue(), 0.01); + assertEquals("燃油附加费", read(sheet, 18, 0)); + assertEquals(15.0, sheet.getRow(18).getCell(7).getNumericCellValue(), 0.01); + assertEquals(2287.65, sheet.getRow(43).getCell(2).getNumericCellValue(), 0.01); + } finally { + Files.deleteIfExists(src); + Files.deleteIfExists(out); + } + } + + @Test + public void realSeaTemplateWritesPortsAndFees() throws Exception { + Path src = java.nio.file.Paths.get("d:/Desktop/友通达询价智能体重制版/deploy/sql/sea_tpl.xlsx"); + if (!Files.isRegularFile(src)) { + return; + } + Path out = Files.createTempFile("quote-sea-real", ".xlsx"); + JSONObject mapping = new JSONObject(); + JSONObject fields = new JSONObject(); + fields.put("pol", "海运询价单!F4"); + fields.put("pod", "海运询价单!F5"); + fields.put("currency", "海运询价单!C18"); + fields.put("valid_until", "海运询价单!B16"); + mapping.put("fields", fields); + JSONObject facts = new JSONObject(true); + facts.put("pol", "DONGGUAN"); + facts.put("pod", "海防"); + facts.put("commodity", "机器设备"); + facts.put("quantity", "15件"); + facts.put("fcl_or_lcl", "整柜"); + facts.put("container_type_qty", "20GP*1"); + facts.put("quote_date", "2026-09-15"); + facts.put("valid_until", "2026-09-30"); + facts.put("currency", "CNY"); + facts.put("total_amount", "2287.65"); + facts.put("fee_rows", QuoteTemplateExcelSupport.parseFeeRows( + "[{\"name\":\"海运费\",\"amount\":\"100.00\",\"currency\":\"USD\",\"quantity\":\"1\",\"unit\":\"柜\"}," + + "{\"name\":\"燃油附加费\",\"amount\":\"15.00\",\"currency\":\"USD\",\"quantity\":\"1\",\"unit\":\"柜\"}," + + "{\"name\":\"操作费\",\"amount\":\"680.00\",\"currency\":\"CNY\",\"quantity\":\"1\",\"unit\":\"票\"}," + + "{\"name\":\"文件费\",\"amount\":\"450.00\",\"currency\":\"CNY\",\"quantity\":\"1\",\"unit\":\"票\"}," + + "{\"name\":\"封条费\",\"amount\":\"50.00\",\"currency\":\"CNY\",\"quantity\":\"1\",\"unit\":\"柜\"}," + + "{\"name\":\"设备费\",\"amount\":\"50.00\",\"currency\":\"CNY\",\"quantity\":\"1\",\"unit\":\"柜\"}," + + "{\"name\":\"报关费\",\"amount\":\"200.00\",\"currency\":\"CNY\",\"quantity\":\"1\",\"unit\":\"票\"}]")); + QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts); + try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) { + Sheet sheet = filled.getSheet("海运询价单"); + assertTrue(read(sheet, 3, 4).contains("起运港")); + assertEquals("DONGGUAN", read(sheet, 3, 7)); + assertTrue(read(sheet, 4, 4).contains("目的港")); + assertEquals("海防", read(sheet, 4, 7)); + assertEquals("机器设备", read(sheet, 4, 1)); + assertEquals("15件", read(sheet, 5, 1)); + assertEquals("整柜", read(sheet, 6, 1)); + assertEquals("20GP*1", read(sheet, 7, 1)); + assertEquals("20GP", read(sheet, 17, 2)); + assertTrue(read(sheet, 28, 0).contains("海运费用")); + assertTrue(read(sheet, 30, 0).contains("海运费用")); + assertEquals(CellType.NUMERIC, sheet.getRow(30).getCell(7).getCellType()); + assertEquals(100.0, sheet.getRow(30).getCell(7).getNumericCellValue(), 0.01); + assertTrue(read(sheet, 35, 0).contains("文件费")); + assertEquals(CellType.NUMERIC, sheet.getRow(35).getCell(7).getCellType()); + assertEquals("", read(sheet, 15, 7)); + assertEquals(450.0, sheet.getRow(35).getCell(7).getNumericCellValue(), 0.01); + assertEquals("操作费", read(sheet, 18, 0)); + assertEquals(680.0, sheet.getRow(18).getCell(7).getNumericCellValue(), 0.01); + assertEquals(2287.65, sheet.getRow(43).getCell(2).getNumericCellValue(), 0.01); + } finally { + Files.deleteIfExists(out); + } + } + + private static org.apache.poi.ss.usermodel.Row row(Sheet sheet, int r) { + org.apache.poi.ss.usermodel.Row exist = sheet.getRow(r); + return exist == null ? sheet.createRow(r) : exist; + } + + private static String read(Sheet sheet, int r, int c) { + org.apache.poi.ss.usermodel.Row row = sheet.getRow(r); + if (row == null || row.getCell(c) == null) { + return ""; + } + org.apache.poi.ss.usermodel.Cell cell = row.getCell(c); + if (cell.getCellType() == CellType.NUMERIC) { + return String.valueOf(cell.getNumericCellValue()); + } + return cell.getStringCellValue(); + } }