diff --git a/inquiry-agent/agent/channel/h5/__init__.py b/inquiry-agent/agent/channel/h5/__init__.py index 266773d..d62bf81 100644 --- a/inquiry-agent/agent/channel/h5/__init__.py +++ b/inquiry-agent/agent/channel/h5/__init__.py @@ -1,7 +1,7 @@ """ -H5 补问 / 附件上传通道。 +H5 补问 / 附件上传 / 复制工单号通道。 -本文件职责:挂载 /inquiry-form;用 clarification token 绑定身份(URL 禁止 userid)。 +本文件职责:挂载 /inquiry-form;用 token 绑定身份或工单号(URL 禁止 userid)。 禁止:从 URL 读 userid;禁止在本包改六态或直连 TMS;禁止同步 invoke Graph。 """ @@ -10,10 +10,13 @@ from __future__ import annotations import logging from typing import Any, Optional -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import HTMLResponse from pydantic import BaseModel, Field from agent.channel.clarification import validate_token +from agent.channel.h5.copy_page import expired_copy_page, render_copy_page +from agent.channel.h5.copy_store import lookup_copy_token from agent.handlers.card_action import handle_card_action logger = logging.getLogger(__name__) @@ -34,6 +37,36 @@ def h5_ping() -> dict[str, str]: return {"status": "ok", "channel": "h5"} +@router.get("/copy/{token}", response_class=HTMLResponse) +def h5_copy_ticket(token: str) -> HTMLResponse: + """ + 复制工单号页:token 换工单号,页面写入剪贴板。 + + 请求线程只读 token,不进 Graph、不发企微消息。 + """ + wo = lookup_copy_token(token) + if not wo: + return HTMLResponse(expired_copy_page(), status_code=410) + return HTMLResponse(render_copy_page(wo)) + + +@router.get("/jsapi-signature") +def h5_jsapi_signature( + url: str = Query(default=""), + kind: str = Query(default="corp"), +) -> dict[str, Any]: + """ + 给复制页 ww.register 签名。失败给空对象,页面改走浏览器剪贴板。 + """ + from agent.channel.wecom.jsapi import build_jsapi_signature + + try: + return build_jsapi_signature(url=url, kind=kind) + except Exception as exc: # noqa: BLE001 + logger.warning("jsapi 签名失败 kind=%s err=%s", kind, exc) + raise HTTPException(status_code=503, detail="jsapi_unavailable") from exc + + @router.get("/{token}") def h5_open(token: str) -> dict[str, Any]: """ diff --git a/inquiry-agent/agent/channel/h5/copy_page.py b/inquiry-agent/agent/channel/h5/copy_page.py new file mode 100644 index 0000000..513a8c7 --- /dev/null +++ b/inquiry-agent/agent/channel/h5/copy_page.py @@ -0,0 +1,150 @@ +""" +复制工单号页 HTML。 + +本文件职责:打开后立刻把工单号写入系统剪贴板(企微 JS-SDK + 浏览器兜底)。 +禁止:URL/页面带 userid;禁止在本页调主账或改六态。 +""" + +from __future__ import annotations + +import html + +from agent.config import get_settings + +_EXPIRED_HTML = """ + + + +链接已过期 + +

复制链接已过期。

+

请回到企微,重新询价后再点「复制工单号」。

+ +""" + + +def expired_copy_page() -> str: + """token 无效时给销售看的短页,不暴露内部原因。""" + return _EXPIRED_HTML + + +def render_copy_page(work_order_no: str) -> str: + """ + 渲染复制页。工单号只进页面正文,由 JS 写入剪贴板。 + + 先走企微 ww.setClipboardData(应用内才能真复制),失败再走浏览器剪贴板。 + """ + wo = html.escape((work_order_no or "").strip(), quote=True) + settings = get_settings() + corp = html.escape((settings.wecom_corp_id or "").strip(), quote=True) + agent = html.escape(str(settings.wecom_agent_id or "1000010").strip(), quote=True) + return f""" + + + + +复制工单号 + + + +
+
工单号
+
{wo}
+
正在复制…
+ +
+ + + + +""" diff --git a/inquiry-agent/agent/channel/h5/copy_store.py b/inquiry-agent/agent/channel/h5/copy_store.py new file mode 100644 index 0000000..aee2801 --- /dev/null +++ b/inquiry-agent/agent/channel/h5/copy_store.py @@ -0,0 +1,126 @@ +""" +复制工单号 H5 的短命 token。 + +本文件职责:签发/校验 token → 工单号;URL 只带 token,禁止带 userid/工单号。 +优先 Redis(HTTP 与 Worker 不同进程必须共享);单测无 Redis 时用内存。 +禁止:当六态;禁止把工单号拼进公网路径。 +""" + +from __future__ import annotations + +import logging +import secrets +import threading +import time +from typing import Optional + +from agent.config import get_settings +from agent.redis_coord.keys import TOKEN_COPY_TICKET + +logger = logging.getLogger(__name__) + +# 销售可能隔几天才点卡;7 天内同一 token 可重复打开复制。 +COPY_TTL_SECONDS = 7 * 24 * 60 * 60 +_FORM_FALLBACK = "https://ai.ytd-scm.com/inquiry-form" + +_MEM: dict[str, tuple[str, float]] = {} +_MEM_LOCK = threading.Lock() + + +def issue_copy_token(work_order_no: str, *, ttl_seconds: int = COPY_TTL_SECONDS) -> str: + """ + 签发复制页 token。 + + 副作用:写 Redis 或内存。工单号为空则返回空串,调用方不要出跳转按钮。 + """ + wo = (work_order_no or "").strip() + if not wo: + return "" + token = secrets.token_urlsafe(24) + if _redis_set(token, wo, ttl_seconds): + return token + expires = time.time() + ttl_seconds + with _MEM_LOCK: + _MEM[token] = (wo, expires) + logger.info("copy token 走内存 wo=%s(HTTP/Worker 分进程时页面会找不到)", wo) + return token + + +def lookup_copy_token(token: str) -> Optional[str]: + """校验 token 未过期;返回工单号或 None。不消费,可重复打开。""" + raw = (token or "").strip() + if not raw: + return None + wo = _redis_get(raw) + if wo: + return wo + now = time.time() + with _MEM_LOCK: + hit = _MEM.get(raw) + if not hit: + return None + wo, exp = hit + if exp < now: + _MEM.pop(raw, None) + return None + return wo + + +def build_copy_ticket_url(work_order_no: str) -> str: + """ + 拼复制页绝对地址:{inquiry-form}/copy/{token}。 + + 域名来自环境变量,禁止写死测试/正式切换。缺省回合同域名,保证企微能打开。 + """ + token = issue_copy_token(work_order_no) + if not token: + return "" + return f"{_form_base()}/copy/{token}" + + +def _form_base() -> str: + settings = get_settings() + form = (settings.inquiry_form_public_base_url or "").strip().rstrip("/") + if form: + return form + pub = (settings.public_base_url or "").strip().rstrip("/") + if pub: + return f"{pub}/inquiry-form" + return _FORM_FALLBACK + + +def _redis_set(token: str, work_order_no: str, ttl_seconds: int) -> bool: + client = _try_redis() + if client is None: + return False + try: + client.raw.set(client.key(TOKEN_COPY_TICKET, token), work_order_no, ex=ttl_seconds) + return True + except Exception as exc: # noqa: BLE001 + logger.warning("copy token Redis 写入失败:%s", exc) + return False + + +def _redis_get(token: str) -> Optional[str]: + client = _try_redis() + if client is None: + return None + try: + val = client.raw.get(client.key(TOKEN_COPY_TICKET, token)) + except Exception as exc: # noqa: BLE001 + logger.warning("copy token Redis 读取失败:%s", exc) + return None + if val is None: + return None + text = val if isinstance(val, str) else val.decode("utf-8") + return text.strip() or None + + +def _try_redis(): + """有 Redis 才用;单测 memory 后端也可用,保证同进程能读到。""" + try: + from agent.redis_coord import get_redis_runtime + + return get_redis_runtime(get_settings()).client + except Exception: # noqa: BLE001 + return None diff --git a/inquiry-agent/agent/channel/outbox/sender.py b/inquiry-agent/agent/channel/outbox/sender.py index 3ebc673..9f2cdbb 100644 --- a/inquiry-agent/agent/channel/outbox/sender.py +++ b/inquiry-agent/agent/channel/outbox/sender.py @@ -270,6 +270,14 @@ class WeComAppClient: logger.info("企微发送成功 touser=%s reply=%s", body.get("touser"), shown) return WeComSendResult(ok=True, errcode=0, errmsg=errmsg, raw=data) + def get_access_token(self) -> str: + """ + 取出站/JS-SDK 用的 access_token。 + + 只给同进程 H5 签名用;禁止写进页面或日志。 + """ + return self._get_token() + def _get_token(self) -> str: with self._lock: now = time.time() diff --git a/inquiry-agent/agent/channel/pg_store.py b/inquiry-agent/agent/channel/pg_store.py index 83e84a6..b2be23e 100644 --- a/inquiry-agent/agent/channel/pg_store.py +++ b/inquiry-agent/agent/channel/pg_store.py @@ -190,6 +190,26 @@ class PostgresMessageStore: logger.exception("pg outbox 入队失败") raise + def has_outbound_dedupe(self, prefix: str) -> bool: + """去重键是否已出过站(前缀匹配)。跨进程看成交卡/文件是否已发。""" + head = (prefix or "").strip() + if not head: + return False + try: + with connect_psycopg(self._dsn) as conn: + row = conn.execute( + """ + SELECT 1 FROM agent_outbox + WHERE dedupe_key LIKE %s + LIMIT 1 + """, + (head + "%",), + ).fetchone() + return bool(row) + except Exception: + logger.exception("查 outbox 去重前缀失败 prefix=%s", head) + return False + def claim_outbound(self) -> Optional[OutboxItem]: timeout = int(self._claim_timeout) with connect_psycopg(self._dsn) as conn: @@ -203,7 +223,7 @@ class PostgresMessageStore: AND claimed_at IS NOT NULL AND claimed_at < NOW() - (%s || ' seconds')::interval ) - ORDER BY created_at + ORDER BY created_at ASC, id ASC FOR UPDATE SKIP LOCKED LIMIT 1 ) @@ -294,6 +314,36 @@ class PostgresMessageStore: def wait_outbox(self, timeout: float = 1.0) -> None: self._outbox_event.wait(timeout=timeout) + def wait_outbound_done(self, item_id: str, timeout_sec: float = 12.0) -> bool: + """ + 等到指定出站已发送完成或失败。 + + 查价前先等询价确认发出,避免暂无报价卡抢先。只等这一条。 + """ + raw = (item_id or "").strip() + if not raw: + return False + started = time.time() + deadline = started + max(0.1, float(timeout_sec)) + saw_claim = False + while time.time() < deadline: + with connect_psycopg(self._dsn) as conn: + row = conn.execute( + "SELECT state FROM agent_outbox WHERE id = %s", + (raw,), + ).fetchone() + state = str(row[0] or "") if row else "" + if state in {"done", "failed"}: + return state == "done" + if state == "claimed": + saw_claim = True + if not saw_claim and state == "pending" and time.time() - started >= 0.4: + return True + if not state and time.time() - started >= 0.3: + return True + self._outbox_event.wait(0.05) + return False + def stats(self) -> dict[str, Any]: with connect_psycopg(self._dsn) as conn: inbox = conn.execute( diff --git a/inquiry-agent/agent/channel/queue.py b/inquiry-agent/agent/channel/queue.py index 085d512..a315c5d 100644 --- a/inquiry-agent/agent/channel/queue.py +++ b/inquiry-agent/agent/channel/queue.py @@ -163,6 +163,14 @@ class MemoryMessageStore: logger.info("outbox 入队 id=%s touser=%s", item.id, touser) return True, item.id + def has_outbound_dedupe(self, prefix: str) -> bool: + """去重键是否已出过站(前缀匹配)。给 wait_file 判断文件/成交卡是否已发。""" + head = (prefix or "").strip() + if not head: + return False + with self._lock: + return any(str(k).startswith(head) for k in self._seen_out) + def claim_outbound(self) -> Optional[OutboxItem]: """认领一条 pending/超时 outbox。""" now = time.time() @@ -207,6 +215,37 @@ class MemoryMessageStore: def wait_outbox(self, timeout: float = 1.0) -> None: self._outbox_event.wait(timeout=timeout) + def wait_outbound_done(self, item_id: str, timeout_sec: float = 12.0) -> bool: + """ + 等到指定出站已发送完成或失败。 + + 查价前必须先等询价确认发出,避免暂无报价卡抢先到销售手机。 + 只等这一条,不锁其它工单。超时返回 False。 + """ + raw = (item_id or "").strip() + if not raw: + return False + started = time.time() + deadline = started + max(0.1, float(timeout_sec)) + saw_claim = False + while time.time() < deadline: + state = "" + with self._lock: + item = self._outbox.get(raw) + if item: + state = item.state + if state in {TaskState.DONE, TaskState.FAILED}: + 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: + return True + self._outbox_event.wait(0.05) + return False + def stats(self) -> dict[str, Any]: with self._lock: return { diff --git a/inquiry-agent/agent/channel/store_protocol.py b/inquiry-agent/agent/channel/store_protocol.py index db92f8d..bca168f 100644 --- a/inquiry-agent/agent/channel/store_protocol.py +++ b/inquiry-agent/agent/channel/store_protocol.py @@ -40,4 +40,6 @@ class MessageStore(Protocol): def wait_outbox(self, timeout: float = 1.0) -> None: ... + def wait_outbound_done(self, item_id: str, timeout_sec: float = 12.0) -> bool: ... + def stats(self) -> dict[str, Any]: ... diff --git a/inquiry-agent/agent/channel/wecom/jsapi.py b/inquiry-agent/agent/channel/wecom/jsapi.py new file mode 100644 index 0000000..1b7e69f --- /dev/null +++ b/inquiry-agent/agent/channel/wecom/jsapi.py @@ -0,0 +1,110 @@ +""" +企微 JS-SDK 签名(给复制页写剪贴板)。 + +本文件职责:取 jsapi_ticket、按当前页 URL 签名。请求线程只打缓存/短 HTTP。 +禁止:把 access_token / ticket 写进页面或日志;禁止在这里改工单。 +""" + +from __future__ import annotations + +import hashlib +import logging +import secrets +import time +from typing import Any, Optional + +import httpx + +from agent.channel.outbox.sender import WeComAppClient +from agent.config import get_settings +from agent.redis_coord.keys import TOKEN_JSAPI + +logger = logging.getLogger(__name__) + +_TICKET_TTL = 3600 + + +def build_jsapi_signature(*, url: str, kind: str = "corp") -> dict[str, Any]: + """ + 给前端 ww.register 用的签名。 + + kind=corp → get_jsapi_ticket;kind=agent → agent_config ticket。 + URL 必须与浏览器地址栏一致(不含 hash)。 + """ + page = (url or "").strip() + if not page: + raise ValueError("url_empty") + ticket = _jsapi_ticket(kind="agent" if kind == "agent" else "corp") + if not ticket: + raise RuntimeError("jsapi_ticket_missing") + nonce = secrets.token_hex(8) + timestamp = str(int(time.time())) + raw = f"jsapi_ticket={ticket}&noncestr={nonce}×tamp={timestamp}&url={page}" + signature = hashlib.sha1(raw.encode("utf-8")).hexdigest() + return { + "timestamp": int(timestamp), + "nonceStr": nonce, + "signature": signature, + } + + +def _jsapi_ticket(*, kind: str) -> str: + cached = _cache_get(kind) + if cached: + return cached + settings = get_settings() + if not (settings.wecom_corp_id and settings.wecom_secret): + return "" + client = WeComAppClient( + corp_id=settings.wecom_corp_id, + secret=settings.wecom_secret, + agent_id=int(settings.wecom_agent_id or "0") or 0, + ) + token = client.get_access_token() + if kind == "agent": + api = "https://qyapi.weixin.qq.com/cgi-bin/ticket/get" + params = {"access_token": token, "type": "agent_config"} + else: + api = "https://qyapi.weixin.qq.com/cgi-bin/get_jsapi_ticket" + params = {"access_token": token} + with httpx.Client(timeout=8.0) as http: + data = http.get(api, params=params).json() + errcode = int(data.get("errcode") or 0) + ticket = str(data.get("ticket") or "") + if errcode != 0 or not ticket: + logger.warning("jsapi ticket 失败 kind=%s errcode=%s", kind, errcode) + return "" + _cache_set(kind, ticket) + return ticket + + +def _cache_get(kind: str) -> Optional[str]: + redis = _try_redis() + if redis is None: + return None + try: + val = redis.raw.get(redis.key(TOKEN_JSAPI, kind)) + except Exception: # noqa: BLE001 + return None + if val is None: + return None + return val if isinstance(val, str) else val.decode("utf-8") + + +def _cache_set(kind: str, ticket: str) -> None: + redis = _try_redis() + if redis is None: + return + try: + redis.raw.set(redis.key(TOKEN_JSAPI, kind), ticket, ex=_TICKET_TTL) + except Exception as exc: # noqa: BLE001 + logger.warning("jsapi ticket 缓存失败:%s", exc) + + +def _try_redis(): + try: + from agent.redis_coord import get_redis_runtime + + return get_redis_runtime(get_settings()).client + except Exception: # noqa: BLE001 + return None diff --git a/inquiry-agent/agent/config/__init__.py b/inquiry-agent/agent/config/__init__.py index 4ec7c99..f53face 100644 --- a/inquiry-agent/agent/config/__init__.py +++ b/inquiry-agent/agent/config/__init__.py @@ -41,6 +41,10 @@ class Settings(BaseSettings): ytd_env: str = Field(default="test", alias="YTD_ENV") agent_http_port: int = Field(default=8910, alias="AGENT_HTTP_PORT") public_base_url: str = Field(default="", alias="PUBLIC_BASE_URL") + inquiry_form_public_base_url: str = Field( + default="", + alias="INQUIRY_FORM_PUBLIC_BASE_URL", + ) biz_api_base_url: str = Field(default="http://127.0.0.1:8180/jeecgboot", alias="BIZ_API_BASE_URL") biz_api_token: str = Field(default="", alias="BIZ_API_TOKEN") biz_api_username: str = Field(default="", alias="BIZ_API_USERNAME") diff --git a/inquiry-agent/agent/handlers/card_action.py b/inquiry-agent/agent/handlers/card_action.py index b3af757..fcee1d4 100644 --- a/inquiry-agent/agent/handlers/card_action.py +++ b/inquiry-agent/agent/handlers/card_action.py @@ -1,5 +1,5 @@ """ -意图:询价卡片按钮(跳过协同 / 采用 / 不采用 / 成交)。 +意图:询价卡片按钮(复制工单号 / 跳过协同 / 采用 / 不采用 / 成交)。 本文件职责:校验当前会话允许的动作后交给流程执行。 禁止:不校验 wait/允许集合就改主账;禁止一个函数处理未声明的新按钮。 @@ -44,7 +44,7 @@ def handle_card_action( msg_store = store or get_message_store() - def reply(text: str, extra: dict | None = None) -> None: + def reply(text: str, extra: dict | None = None) -> str: # 销售询价正文不加 [test];[test] 只给运维告警 body = text extra = dict(extra or {}) @@ -65,6 +65,8 @@ def handle_card_action( ) if not ok: logger.warning("card 出站被去重 sender=%s wo=%s info=%s", sender_id, wo or "-", info) + return "" + return info return engine.on_button( sender_id=sender_id, action=action, reply=reply, card_meta=payload diff --git a/inquiry-agent/agent/handlers/continue_thread.py b/inquiry-agent/agent/handlers/continue_thread.py index 1a2a02b..1279cf6 100644 --- a/inquiry-agent/agent/handlers/continue_thread.py +++ b/inquiry-agent/agent/handlers/continue_thread.py @@ -37,7 +37,7 @@ def handle_continue_thread( engine = flow or get_air_text_flow() replies: list[str] = [] - def reply(text: str, extra: dict | None = None) -> None: + def reply(text: str, extra: dict | None = None) -> str: replies.append(text) ok, info = msg_store.enqueue_outbound( touser=message.sender_id, @@ -46,6 +46,7 @@ def handle_continue_thread( payload=dict(extra or {}), ) logger.info("continue_thread 出站 ok=%s info=%s", ok, info) + return info if ok else "" wo = (thread_id or "").strip() or copy.extract_work_order_no(message.content or "") phase = engine.resume_historical( diff --git a/inquiry-agent/agent/handlers/text_inquiry.py b/inquiry-agent/agent/handlers/text_inquiry.py index 004215e..f9d6569 100644 --- a/inquiry-agent/agent/handlers/text_inquiry.py +++ b/inquiry-agent/agent/handlers/text_inquiry.py @@ -42,7 +42,7 @@ def handle_text_inquiry( engine = flow or get_air_text_flow() replies: list[str] = [] - def reply(text: str, extra: dict | None = None) -> None: + def reply(text: str, extra: dict | None = None) -> str: # 销售询价正文不加 [test];[test] 只给运维告警 body = text replies.append(body) @@ -53,6 +53,7 @@ def handle_text_inquiry( payload=dict(extra or {}), ) logger.info("text_inquiry 出站 ok=%s info=%s phase_reply=%s", ok, info, body[:80]) + return info if ok else "" phase = engine.on_text( sender_id=message.sender_id, diff --git a/inquiry-agent/agent/jobs/libreoffice.py b/inquiry-agent/agent/jobs/libreoffice.py index 073a120..9b38f9f 100644 --- a/inquiry-agent/agent/jobs/libreoffice.py +++ b/inquiry-agent/agent/jobs/libreoffice.py @@ -70,7 +70,8 @@ class LibreOfficeConverter: str(exe), "--headless", "--convert-to", - "pdf", + # calc 导出才认 Excel 的 A4/居中/页宽缩放;裸 pdf 会裁顶、偏左 + "pdf:calc_pdf_Export", "--outdir", str(out_dir), str(source), diff --git a/inquiry-agent/agent/jobs/pdf_job.py b/inquiry-agent/agent/jobs/pdf_job.py index c3f8a9f..393b9d4 100644 --- a/inquiry-agent/agent/jobs/pdf_job.py +++ b/inquiry-agent/agent/jobs/pdf_job.py @@ -41,6 +41,8 @@ def enqueue_pdf_job( redis: Optional[RedisRuntime] = None, touser: str = "", filename: str = "", + quote: Optional[dict[str, Any]] = None, + facts: Optional[dict[str, Any]] = None, ) -> PdfEnqueueResult: """Excel→PDF 任务入队;真正转换只在 LibreOffice 槽。""" rt = redis or get_redis_runtime() @@ -61,6 +63,8 @@ def enqueue_pdf_job( "quote_version": quote_version, "touser": touser, "filename": filename, + "quote": dict(quote or {}), + "facts": dict(facts or {}), } entry_id = q.enqueue(payload=payload, idempotency_key=idempotency_key) logger.info("pdf.enqueue id=%s inquiry=%s", entry_id, inquiry_no or "-") @@ -142,5 +146,23 @@ def _deliver_quote_file(data: dict[str, Any], result: LibreOfficeResult) -> None "filename": filename, }, ) + # 文件先入队,成交卡后入队;出站单线程保证销售先看到文件 + 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 + + get_air_text_flow().mark_wait_deal(touser, wo) + except Exception: + logger.exception("PDF 出文件后未能切到成交跟进 inquiry=%s", wo) except Exception: logger.exception("PDF 出站入队失败 inquiry=%s", wo) diff --git a/inquiry-agent/agent/policy/air_text_flow.py b/inquiry-agent/agent/policy/air_text_flow.py index c4a9b96..ed36c68 100644 --- a/inquiry-agent/agent/policy/air_text_flow.py +++ b/inquiry-agent/agent/policy/air_text_flow.py @@ -15,7 +15,6 @@ import uuid from dataclasses import dataclass, field from typing import Any, Callable, Optional -from agent.config import get_settings from agent.ledger.http_ledger import HttpLedger from agent.ledger.memory_ledger import MemoryLedger from agent.llm.extract_text import extract_inquiry_snapshot @@ -25,7 +24,8 @@ from agent.schema.tms_air_query import assemble_air_query logger = logging.getLogger(__name__) -ReplyFn = Callable[..., None] +# 可返回 outbox id;查价前要等询价确认真正发出。 +ReplyFn = Callable[..., Optional[str]] @dataclass @@ -249,18 +249,13 @@ class AirTextInquiryFlow: if sess.phase == "tms_miss": self._emit_tms_miss(reply, sess.work_order_no) return "tms_miss" + if sess.phase == "wait_file": + self._refresh_wait_file(sess) + if sess.phase == "wait_file": + reply("报价单正在生成,请稍候。") + return "wait_file" if sess.phase == "wait_deal": - summary = ( - f"{sess.facts.get('起运港', '')} → {sess.facts.get('目的港', '')} " - f"{sess.facts.get('品名', '')}" - ) - reply( - copy.deal_card( - work_order_no=sess.work_order_no, - status=sess.status or "已报价", - summary=summary, - ) - ) + self._emit_deal(reply, sess) return "wait_deal" if sess.phase == "done": reply(copy.deal_ack(sess.status or "已关闭", sess.work_order_no)) @@ -426,7 +421,9 @@ class AirTextInquiryFlow: reply("建单失败,请稍后把刚才那条再发一次。重量和体积已经记下。") return "ledger_fail" first, history_wo = self._resolve_similar(created, facts=facts, current_no=no) - reply(copy.inquiry_card(work_order_no=no, facts=facts, first_or_same=first)) + out_id = reply(copy.inquiry_card(work_order_no=no, facts=facts, first_or_same=first)) or "" + # 询价确认必须先到销售手机,再打 TMS。否则查得快时暂无报价卡会抢先发出。 + self._wait_inquiry_sent(str(out_id), work_order_no=no) tms = self._ledger.query_tms(work_order_no=no, facts=facts) sess = FlowSession( @@ -453,6 +450,7 @@ class AirTextInquiryFlow: self._emit_tms_miss(reply, no) sess.phase = "tms_miss" sess.status = "询价中" + sess.allowed = () self._save(sess) return "tms_miss" @@ -487,11 +485,41 @@ class AirTextInquiryFlow: ) return "wait_collab" + def _wait_inquiry_sent(self, out_id: str, *, work_order_no: str) -> None: + """等询价确认出站完成后再查价。""" + self._wait_outbound_sent(out_id, work_order_no=work_order_no, what="询价确认") + + def _wait_outbound_sent(self, out_id: str, *, work_order_no: str, what: str) -> None: + """ + 等指定出站真正发出。只等这一条,不锁其它工单。 + + 单测 reply 不回 id 则跳过。超时仍继续,避免卡死 inbox。 + """ + raw = (out_id or "").strip() + if not raw: + return + from agent.channel.queue import get_message_store + + store = get_message_store() + waiter = getattr(store, "wait_outbound_done", None) + if not callable(waiter): + return + ok = waiter(raw, timeout_sec=30.0) + if not ok: + logger.warning( + "%s出站未在时限内完成 wo=%s out=%s,仍继续", + what, + work_order_no, + raw, + ) + def _emit_tms_miss(self, reply: ReplyFn, work_order_no: str) -> None: """ TMS 已查过且确认没价:文字兜底 + 暂无报价卡片。 禁止在没打 query_tms 前回这个。 """ + from agent.config import get_settings + reply( copy.tms_miss(work_order_no=work_order_no), copy.tms_miss_wecom_payload( @@ -499,6 +527,8 @@ class AirTextInquiryFlow: action_url=(get_settings().public_base_url or "").strip(), ), ) + # 卡片发出后再单独发拉群句,不塞进卡、不当卡片失败兜底。 + reply(copy.tms_miss_followup(work_order_no=work_order_no)) def _resolve_similar( self, @@ -542,6 +572,15 @@ class AirTextInquiryFlow: """处理已允许的按钮;错版本/未允许/已点过直接丢弃。""" sess = self.session_for_action(sender_id, card_meta) card_wo = copy.work_order_from_card_meta(card_meta) + if sess and sess.phase == "wait_file": + self._refresh_wait_file(sess) + # 成交卡已经发到销售手机,HTTP 进程可能还停在 wait_file,不能丢弃 + if ( + sess + and action in {copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE} + and sess.phase == "wait_file" + ): + self._promote_wait_deal(sess) if not sess or action not in sess.allowed: logger.info( "card 丢弃 sender=%s action=%s card_wo=%s sess_wo=%s phase=%s", @@ -577,11 +616,11 @@ class AirTextInquiryFlow: if action == copy.BTN_ADOPT_PDF: return self._adopt_file(sess, reply, kind="PDF", card_meta=card_meta) if action == copy.BTN_DEAL: - return self._close(sess, reply, "已成交") + return self._close(sess, reply, "已成交", card_meta=card_meta) if action == copy.BTN_LOST: - return self._close(sess, reply, "未成交") + return self._close(sess, reply, "未成交", card_meta=card_meta) if action == copy.BTN_NEGOTIATE: - return self._close(sess, reply, "协商中") + return self._close(sess, reply, "协商中", card_meta=card_meta) return "card_unknown" def _skip_collab( @@ -617,14 +656,24 @@ class AirTextInquiryFlow: ) return "wait_adopt" - def _grey_clicked_card(self, reply: ReplyFn, card_meta: Optional[dict[str, Any]]) -> None: - """点过方案卡后把原卡按钮改成已提交;无 ResponseCode 则不更新。""" + def _grey_clicked_card( + self, + reply: ReplyFn, + card_meta: Optional[dict[str, Any]], + *, + replace_name: str = "", + ) -> None: + """ + 点过模板卡后整卡置灰,三个按钮都不可再点。 + + 企微 update_template_card 一次更新后整卡按钮失效;无 ResponseCode 则不更新。 + """ code = str((card_meta or {}).get("response_code") or "").strip() if not code: return reply( copy.BTN_SKIP_COLLAB_DONE, - copy.skip_collab_card_update(response_code=code), + copy.skip_collab_card_update(response_code=code, replace_name=replace_name), ) def _adopt_file( @@ -670,35 +719,75 @@ class AirTextInquiryFlow: work_order_no=sess.work_order_no, sender_id=sess.sender_id, filename=filename if filename.lower().endswith(".pdf") else f"{sess.work_order_no}_quote.pdf", + quote=dict(sess.quote or {}), + facts=dict(sess.facts or {}), ) if not queued: fallback = str(result.get("fileName") or f"{sess.work_order_no}_quote.xlsx") if not fallback.lower().endswith(".xlsx"): fallback = f"{sess.work_order_no}_quote.xlsx" reply("PDF 转换排队失败,已按后台模板发送 Excel 报价单。") - reply( - copy.file_ready(kind="Excel", work_order_no=sess.work_order_no, filename=fallback), - file_extra, + return self._emit_file_then_deal( + sess, + reply, + kind="Excel", + filename=fallback, + file_extra=file_extra, ) - # 已入队:Worker 出文件;此处不冒充 soffice 已成功 - else: - reply( - copy.file_ready(kind=kind, work_order_no=sess.work_order_no, filename=filename), - file_extra, - ) - summary = f"{sess.facts.get('起运港', '')} → {sess.facts.get('目的港', '')} {sess.facts.get('品名', '')}" + # 已入队:等 Worker 出文件后再出成交卡,这里不提前发卡 + sess.phase = "wait_file" + sess.allowed = () + sess.wait_version += 1 + self._save(sess) + return "wait_file" + return self._emit_file_then_deal( + sess, reply, kind=kind, filename=filename, file_extra=file_extra + ) + + def _emit_file_then_deal( + self, + sess: FlowSession, + reply: ReplyFn, + *, + kind: str, + filename: str, + file_extra: dict[str, Any], + ) -> str: + """ + 先发报价文件,等这条出站真正发出,再出成交跟进卡。 + + 避免卡片比文件先到销售手机。单测 reply 不回 id 则不等待。 + """ + out_id = reply( + copy.file_ready(kind=kind, work_order_no=sess.work_order_no, filename=filename), + file_extra, + ) + self._wait_outbound_sent(str(out_id or ""), work_order_no=sess.work_order_no, what="报价文件") sess.phase = "wait_deal" 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: + """ + 报价单发出后出成交跟进卡:文字兜底 + 企微模板卡。 + 字段以报价当时的 quote 为准,不编历史报价/工单状态/来源行。 + """ reply( copy.deal_card( work_order_no=sess.work_order_no, status=sess.status or "已报价", - summary=summary, - ) + quote=sess.quote, + facts=sess.facts, + ), + copy.deal_wecom_payload( + work_order_no=sess.work_order_no, + quote=sess.quote, + facts=sess.facts, + ), ) - return "wait_deal" def _quote_file_payload(self, result: dict[str, Any], *, filename: str) -> dict[str, Any]: """出站文件载荷:优先本机路径,否则主账回的 Base64。""" @@ -716,6 +805,8 @@ class AirTextInquiryFlow: work_order_no: str, sender_id: str, filename: str, + quote: Optional[dict[str, Any]] = None, + facts: Optional[dict[str, str]] = None, ) -> bool: """ 把主账已填好的 xlsx 交给 Worker LibreOffice 单槽转 PDF。 @@ -744,13 +835,23 @@ class AirTextInquiryFlow: idempotency_key=f"pdf:{work_order_no}:{uuid.uuid4().hex[:8]}", touser=sender_id, filename=filename, + quote=quote, + facts=facts, ) return bool(queued.accepted) except Exception: logger.exception("报价 PDF 入队失败 wo=%s", work_order_no) return False - def _close(self, sess: FlowSession, reply: ReplyFn, status: str) -> str: + def _close( + self, + sess: FlowSession, + reply: ReplyFn, + status: str, + card_meta: Optional[dict[str, Any]] = None, + ) -> str: + """点成交/未成交/协商中:先整卡置灰,再入主账。""" + self._grey_clicked_card(reply, card_meta, replace_name=status) result = self._ledger.transition(work_order_no=sess.work_order_no, to_status=status) if not result.get("ok"): reply("主账状态更新失败,成交结果未入账。") @@ -762,6 +863,70 @@ class AirTextInquiryFlow: reply(copy.deal_ack(status, sess.work_order_no)) return "done" + def mark_wait_deal(self, sender_id: str, work_order_no: str) -> None: + """ + Worker 出完报价文件后:先写 Redis,再切本进程书签。 + + HTTP 与 Worker 不是同一份内存。只改本进程会让销售一直看到「请稍候」。 + 找不到本进程书签也要写 Redis,让收消息那边能切到成交跟进。 + """ + no = (work_order_no or "").strip() + if not no: + return + from agent.redis_coord.quote_file import mark_quote_file_ready + + mark_quote_file_ready(no) + if not sender_id: + return + with self._lock: + sess = self._by_sender.get(sender_id) + if sess is None or sess.work_order_no != no: + sess = self._by_ticket.get(no) + if sess is None: + return + self._promote_wait_deal(sess) + + def _promote_wait_deal(self, sess: FlowSession) -> None: + """书签切到成交跟进,允许点成交/未成交/协商中。""" + sess.phase = "wait_deal" + sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE) + sess.wait_version += 1 + self._save(sess) + + def _refresh_wait_file(self, sess: FlowSession) -> None: + """ + wait_file 时看文件是否已经发出。 + + Worker 写 Redis;本进程重启后也可看出站成交卡/文件去重键。 + """ + if sess.phase != "wait_file": + return + no = (sess.work_order_no or "").strip() + if not no: + return + if self._quote_file_already_out(no): + self._promote_wait_deal(sess) + + def _quote_file_already_out(self, work_order_no: str) -> bool: + """文件或成交卡是否已经入过出站。""" + from agent.redis_coord.quote_file import is_quote_file_ready + + if is_quote_file_ready(work_order_no): + return True + try: + from agent.channel.queue import get_message_store + + store = get_message_store() + probe = getattr(store, "has_outbound_dedupe", None) + if not callable(probe): + return False + return bool( + probe(f"deal-card:{work_order_no}:") or probe(f"pdf-file:{work_order_no}:") + ) + except Exception: + logger.exception("判断报价文件是否已出站失败 wo=%s", work_order_no) + return False + _FLOW: Optional[AirTextInquiryFlow] = None _FLOW_GUARD = threading.Lock() diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py index 21f5929..2fc0371 100644 --- a/inquiry-agent/agent/policy/inquiry_copy.py +++ b/inquiry-agent/agent/policy/inquiry_copy.py @@ -47,6 +47,12 @@ BTN_REJECT_KEY = "reject" BTN_DEAL = "成交" BTN_LOST = "未成交" BTN_NEGOTIATE = "协商中" +BTN_DEAL_KEY = "deal" +BTN_LOST_KEY = "lost" +BTN_NEGOTIATE_KEY = "negotiate" +# 旧卡 EventKey 兼容;新暂无报价卡按现网图:无按钮。 +BTN_COPY_TICKET = "复制工单号" +BTN_COPY_TICKET_KEY = "copy_ticket" _WO_NO = re.compile(r"WO\d{12}", re.IGNORECASE) # 仅给无网 stub / 工单号回填用,正式续办意图由 DeepSeek A 判断,不要求销售说这些词。 @@ -245,6 +251,18 @@ def canonical_button(raw: str) -> str: return BTN_ADOPT_PDF if text in {BTN_REJECT_KEY} or text.startswith(f"{BTN_REJECT_KEY}:"): return BTN_REJECT + if text in {BTN_COPY_TICKET, BTN_COPY_TICKET_KEY} or text.startswith( + f"{BTN_COPY_TICKET_KEY}:" + ): + return BTN_COPY_TICKET + if text in {BTN_DEAL, BTN_DEAL_KEY} or text.startswith(f"{BTN_DEAL_KEY}:"): + return BTN_DEAL + if text in {BTN_LOST, BTN_LOST_KEY} or text.startswith(f"{BTN_LOST_KEY}:"): + return BTN_LOST + if text in {BTN_NEGOTIATE, BTN_NEGOTIATE_KEY} or text.startswith( + f"{BTN_NEGOTIATE_KEY}:" + ): + return BTN_NEGOTIATE if text == BTN_SKIP_COLLAB_DONE: return "" for btn in (BTN_ADOPT_EXCEL, BTN_ADOPT_PDF, BTN_REJECT, BTN_DEAL, BTN_LOST, BTN_NEGOTIATE): @@ -313,6 +331,10 @@ def _work_order_in_token(raw: str) -> str: f"{BTN_ADOPT_EXCEL_KEY}:", f"{BTN_ADOPT_PDF_KEY}:", f"{BTN_REJECT_KEY}:", + f"{BTN_COPY_TICKET_KEY}:", + f"{BTN_DEAL_KEY}:", + f"{BTN_LOST_KEY}:", + f"{BTN_NEGOTIATE_KEY}:", ): if text.startswith(prefix): rest = text[len(prefix) :].strip() @@ -439,7 +461,7 @@ def tms_valid_until_display(quote: dict[str, Any] | None) -> str: def similar_inquiry_line(*, first_or_same: str, history_work_order_no: str = "") -> str: """ - 相同询价内容检索:首次询价,或引用全库最新一条一模一样的历史工单号。 + 相同询价内容检索文案(主账仍会算,销售侧价格卡已隐藏,不再调用)。 """ no = (history_work_order_no or "").strip() if first_or_same == "same" and no: @@ -455,11 +477,12 @@ def quote_detail_lines( status: str = "已报价", ) -> list[str]: """ - 价格明细正文:TMS 费用、合计、时效、有效期、相同检索。 + 价格明细正文:TMS 费用、合计、时效、有效期。 - 给文本兜底和企微引用条共用,禁止在这里编时效/有效期。价格卡不再展示工单状态。 + 给文本兜底和企微引用条共用,禁止在这里编时效/有效期。 + 价格卡不展示工单状态,也不展示相同询价内容检索。 """ - _ = status + _ = status, first_or_same, history_work_order_no src = dict(quote or {}) header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip() total = money_text(str(src.get("total") or "-").strip() or "-", header_ccy) @@ -467,11 +490,6 @@ def quote_detail_lines( lines.append(f"总销售价合计:{total}") lines.append(f"时效:{tms_eta_display(src)}") lines.append(f"报价有效期:{tms_valid_until_display(src)}") - lines.append( - similar_inquiry_line( - first_or_same=first_or_same, history_work_order_no=history_work_order_no - ) - ) return lines @@ -518,22 +536,21 @@ def tms_hit_wecom_payload( 价格明细 ┌ 引用条:空运费 / 其它费用 / 总销售价合计 - 时效、报价有效期、相同询价内容检索 + 时效、报价有效期 协同说明(普通文字,贴按钮上方) 按钮 + 相同询价内容检索对销售隐藏;first_or_same 只兼容调用方,不展示。 企微 quote_area.title 只能很短,超长会被整段藏掉。报价必须放 quote_text。 不用 horizontal:它会排到副标题后面,把费用挤到按钮前。 """ + _ = first_or_same, history_work_order_no src = dict(quote or {}) source = str(src.get("source_label") or "TMS标准报价").replace(" ", "") meta = "\n".join( [ f"时效:{tms_eta_display(src)}", f"报价有效期:{tms_valid_until_display(src)}", - similar_inquiry_line( - first_or_same=first_or_same, history_work_order_no=history_work_order_no - ), "", collab_hint(work_order_no), ] @@ -562,58 +579,86 @@ def tms_hit_wecom_payload( } -def skip_collab_card_update(*, response_code: str) -> dict[str, Any]: +def skip_collab_card_update(*, response_code: str, replace_name: str = "") -> dict[str, Any]: """ - 点过「生成报价单」后更新原卡:按钮改成已提交并保持置灰。 + 点过模板卡后更新原卡:被点按钮改成已提交,整卡置灰不可再点。 必须带企微回调 ResponseCode;无码则不要入出站。 + 成交卡三个按钮共用这一条,replace_name 默认「已提交」。 """ return { "msgtype": "update_template_card", "response_code": response_code, - "button": {"replace_name": BTN_SKIP_COLLAB_DONE}, + "button": {"replace_name": (replace_name or BTN_SKIP_COLLAB_DONE).strip() or BTN_SKIP_COLLAB_DONE}, } def tms_miss_quote_text(*, work_order_no: str) -> str: - """引用条正文:只放「请务必在群聊发送工单编号」。""" - return f"请务必在群聊发送工单编号{work_order_no}" + """文字兜底里的拉群句,与现网图一致。""" + return f"请手动拉相关人员和智能体到原有群,并在群聊发送工单编号{work_order_no}" def tms_miss_hint() -> str: - """卡片正文:无价说明 + 拉群提示,不单独再写标题下灰字。""" - return "TMS标准价库暂无匹配报价,请手动拉新人与智能体到原有群" + """卡片标题下灰字:与现网图一致。""" + return "TMS 标准价库暂无匹配报价" + + +def tms_miss_body() -> str: + """卡片中间说明:需求已建单。""" + return "需求已解析并创建工单。" def tms_miss(*, work_order_no: str) -> str: """ - TMS 已经查过、确认没标准价时的文字兜底。 + 卡片发送失败时的短兜底。拉群句另发,不写在这里。 禁止在没打 TMS 前用本句冒充无价。 """ - return ( - f"TMS暂无报价。{tms_miss_hint()}," - f"{tms_miss_quote_text(work_order_no=work_order_no)}" - ) + no = (work_order_no or "").strip() + return f"询价工单{no}已建立,TMS标准价库暂无匹配报价。" -def tms_miss_wecom_payload(*, work_order_no: str, action_url: str = "") -> dict[str, Any]: +def tms_miss_followup(*, work_order_no: str) -> str: + """出卡后再发的独立一句:拉人进群并带上工单号。""" + return tms_miss_quote_text(work_order_no=work_order_no) + + +def copy_ticket_text(*, work_order_no: str) -> str: """ - TMS 已查过、确认没价:一张 text_notice 卡。 + 点「复制工单号」后回给销售的正文:只含工单号。 - 顺序:来源 → 标题(仅工单号+暂无报价)→ 引用条(工单编号)→ 拉群说明。 - 禁止:标题下灰字、工单状态、查价结果/来源行、按钮。 - 企微 text_notice 必须带可信域名 card_action,否则整卡发失败只剩文字。 + 企微模板卡按钮不能写系统剪贴板。整条消息只有工单号,销售长按「复制」不会夹杂其它字。 """ + return (work_order_no or "").strip() + + +def tms_miss_wecom_payload( + *, + work_order_no: str, + action_url: str = "", + copy_url: str = "", +) -> dict[str, Any]: + """ + TMS 已查过、确认没价:一张文本通知卡(对齐现网图)。 + + 询价工单号 / 灰字无价说明 / 建单说明 / 工单状态与查价结果。 + 不要「来源」行、不要按钮。企微 text_notice 必须带可信域名 card_action。 + copy_url 忽略:不再跳复制页。 + """ + _ = copy_url + no = (work_order_no or "").strip() card: dict[str, Any] = { "card_type": "text_notice", "source": {"desc": "TMS查价结果", "desc_color": 0}, - "main_title": {"title": f"{work_order_no}暂无报价"}, - "quote_area": { - "type": 0, - "quote_text": tms_miss_quote_text(work_order_no=work_order_no), + "main_title": { + "title": f"询价工单 {no}", + "desc": tms_miss_hint(), }, - "sub_title_text": tms_miss_hint(), - "task_id": card_task_id("miss", work_order_no), + "sub_title_text": tms_miss_body(), + "horizontal_content_list": [ + {"keyname": "工单状态", "value": "询价中"}, + {"keyname": "查价结果", "value": "暂无匹配报价"}, + ], + "task_id": card_task_id("miss", no), } url = (action_url or "").strip() if url: @@ -778,15 +823,102 @@ def file_ready(*, kind: str, work_order_no: str, filename: str) -> str: return f"已生成{kind}最终报价单:{filename}(工单 {work_order_no})" -def deal_card(*, work_order_no: str, status: str, summary: str) -> str: - return ( - "【咨询成交状态】\n" - f"工单编号:{work_order_no}\n" - f"工单状态:{status}\n" - f"询价内容:{summary}\n" - "请立即确认当前与客户沟通的成交状态:\n" - f"【{BTN_DEAL}】【{BTN_LOST}】【{BTN_NEGOTIATE}】" - ) +DEAL_HINT = "咨询成交状态:请选择成交 / 未成交 / 协商中(未成交需补充原因)。" +DEAL_SUBTITLE = "报价单已发送,请跟进成交状态" + + +def deal_horizontal_rows( + *, + quote: dict[str, Any] | None, + facts: dict[str, str] | None = None, +) -> list[dict[str, str]]: + """ + 成交跟进卡字段:总销售价合计、时效、报价有效期。 + + 不要历史报价 / 工单状态 / 来源。来源只留卡片顶部红字。 + """ + src = dict(quote or {}) + header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip() + total = money_text(str(src.get("total") or "-").strip() or "-", header_ccy) + return [ + {"keyname": "总销售价合计", "value": _clip(total, 26)}, + {"keyname": "时效", "value": _clip(tms_eta_display(src), 26)}, + {"keyname": "报价有效期", "value": _clip(tms_valid_until_display(src), 26)}, + ] + + +def deal_card( + *, + work_order_no: str, + status: str = "", + summary: str = "", + quote: dict[str, Any] | None = None, + facts: dict[str, str] | None = None, +) -> str: + """ + 成交跟进文字兜底(模板卡发失败时仍给销售看)。 + + 与卡片同一套字段;status / summary 只兼容旧调用,不再展示。 + """ + _ = status, summary + no = (work_order_no or "").strip() + rows = deal_horizontal_rows(quote=quote, facts=facts) + lines = [ + f"询价工单 {no}", + DEAL_SUBTITLE, + DEAL_HINT, + ] + lines.extend(f"{row['keyname']}:{row['value']}" for row in rows) + lines.append(f"【{BTN_DEAL}】【{BTN_LOST}】【{BTN_NEGOTIATE}】") + return "\n".join(lines) + + +def deal_wecom_payload( + *, + work_order_no: str, + quote: dict[str, Any] | None = None, + facts: dict[str, str] | None = None, +) -> dict[str, Any]: + """ + 成交跟进卡:对齐现网 button_interaction。 + + 顶部来源红字;标题询价工单号;副标题跟进成交;三个按钮。 + 字段只要总销售价、时效、有效期。 + """ + src = dict(quote or {}) + source = str(src.get("source_label") or "TMS标准报价").replace(" ", "") + no = (work_order_no or "").strip() + return { + "msgtype": "template_card", + "template_card": { + "card_type": "button_interaction", + "source": {"desc": source, "desc_color": 0}, + "main_title": { + "title": f"询价工单 {no}", + "desc": DEAL_SUBTITLE, + }, + "sub_title_text": DEAL_HINT, + "horizontal_content_list": deal_horizontal_rows(quote=src, facts=facts), + "task_id": card_task_id("deal", no), + "button_list": [ + { + "text": BTN_DEAL, + "style": 1, + "key": f"{BTN_DEAL_KEY}:{no}", + }, + { + "text": BTN_LOST, + "style": 1, + "key": f"{BTN_LOST_KEY}:{no}", + }, + { + "text": BTN_NEGOTIATE, + "style": 1, + "key": f"{BTN_NEGOTIATE_KEY}:{no}", + }, + ], + }, + } def deal_ack(outcome: str, work_order_no: str) -> str: diff --git a/inquiry-agent/agent/redis_coord/keys.py b/inquiry-agent/agent/redis_coord/keys.py index 081961f..1c03439 100644 --- a/inquiry-agent/agent/redis_coord/keys.py +++ b/inquiry-agent/agent/redis_coord/keys.py @@ -44,3 +44,7 @@ STREAM_WAKE = "stream:wake" IDEMPOTENCY = "idem" RATE_LIMIT = "ratelimit" TOKEN_WECOM = "token:wecom" +TOKEN_COPY_TICKET = "token:copy_ticket" +TOKEN_JSAPI = "token:jsapi" +# Worker 出完报价文件后写给 HTTP:短协调,不当六态 +QUOTE_FILE_READY = "quote_file_ready" diff --git a/inquiry-agent/agent/redis_coord/quote_file.py b/inquiry-agent/agent/redis_coord/quote_file.py new file mode 100644 index 0000000..98ff453 --- /dev/null +++ b/inquiry-agent/agent/redis_coord/quote_file.py @@ -0,0 +1,43 @@ +""" +报价文件完成短协调。 + +Worker LibreOffice 出完文件后写键;HTTP 进程读键,把 wait_file 切到成交跟进。 +不当六态、不当 checkpoint。TTL 一周,过期当未完成。 +""" + +from __future__ import annotations + +import logging + +from agent.redis_coord.keys import QUOTE_FILE_READY +from agent.redis_coord.runtime import get_redis_runtime + +logger = logging.getLogger(__name__) + +_TTL_SEC = 7 * 24 * 3600 + + +def mark_quote_file_ready(work_order_no: str) -> None: + """Worker 出完报价文件后调用;HTTP 进程据此结束「请稍候」。""" + no = (work_order_no or "").strip() + if not no: + return + try: + client = get_redis_runtime().client + client.raw.set(client.key(QUOTE_FILE_READY, no), "1", ex=_TTL_SEC) + logger.info("报价文件已就绪 wo=%s", no) + except Exception: + logger.exception("写报价文件就绪键失败 wo=%s", no) + + +def is_quote_file_ready(work_order_no: str) -> bool: + """HTTP 读:文件是否已经发出。读失败当未完成,继续请稍候。""" + no = (work_order_no or "").strip() + if not no: + return False + try: + client = get_redis_runtime().client + return bool(client.raw.get(client.key(QUOTE_FILE_READY, no))) + except Exception: + logger.exception("读报价文件就绪键失败 wo=%s", no) + return False diff --git a/inquiry-agent/tests/test_air_text_inquiry.py b/inquiry-agent/tests/test_air_text_inquiry.py index b8803f2..aa35180 100644 --- a/inquiry-agent/tests/test_air_text_inquiry.py +++ b/inquiry-agent/tests/test_air_text_inquiry.py @@ -238,7 +238,8 @@ class AirTextInquiryTests(unittest.TestCase): self.assertIn("总销售价合计", joined) self.assertIn("时效:", joined) self.assertIn("报价有效期:", joined) - self.assertIn("相同询价内容检索:首次询价", joined) + self.assertNotIn("相同询价内容检索", joined) + self.assertNotIn("引用历史工单号", joined) self.assertNotIn("工单状态:已报价", joined) self.assertNotIn("【价格明细】", joined) @@ -292,9 +293,44 @@ 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"]} + self.assertIn("总销售价合计", deal_rows) + self.assertNotIn("历史报价", deal_rows) + self.assertNotIn("工单状态", deal_rows) + self.assertNotIn("来源", deal_rows) - phase = self.flow.on_button(sender_id="u1", action=copy.BTN_DEAL, reply=self._reply) + 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) + 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) + phase = self.flow.on_button( + sender_id="u1", + action=copy.BTN_DEAL, + reply=self._reply, + card_meta={"response_code": "rc-deal-1"}, + ) self.assertEqual(phase, "done") + deal_updates = [ + x + for x in self.extras + if x.get("msgtype") == "update_template_card" + and x.get("response_code") == "rc-deal-1" + ] + self.assertTrue(deal_updates) + self.assertEqual(deal_updates[-1].get("button", {}).get("replace_name"), "已成交") ticket = self.flow.ledger.get_ticket( work_order_no=self.flow.session_of("u1").work_order_no ) @@ -367,14 +403,14 @@ class AirTextInquiryTests(unittest.TestCase): self.assertEqual(called[0]["facts"].get("起运港"), "阿拉伯") self.assertFalse(called[0]["facts"].get("originCode")) self.assertEqual(phase, "tms_miss") - self.assertIn("暂无报价", self.replies[-1]) + self.assertTrue(any("暂无匹配报价" in x for x in self.replies)) + self.assertIn("工单编号WO202609140088", self.replies[-1]) self.assertNotIn("查价参数尚未齐全", "\n".join(self.replies)) miss = [x for x in self.extras if x.get("msgtype") == "template_card"] self.assertTrue(miss) - self.assertEqual(miss[-1]["template_card"]["main_title"]["title"], "WO202609140088暂无报价") self.assertEqual( - miss[-1]["template_card"]["quote_area"]["quote_text"], - "请务必在群聊发送工单编号WO202609140088", + miss[-1]["template_card"]["main_title"]["title"], + "询价工单 WO202609140088", ) def test_unknown_airport_no_card(self) -> None: @@ -389,7 +425,8 @@ class AirTextInquiryTests(unittest.TestCase): ) # 目的地说了就算齐;对不上字典不补问,查价后按 TMS 分类 self.assertEqual(phase, "tms_miss") - self.assertIn("暂无报价", self.replies[-1]) + self.assertTrue(any("暂无匹配报价" in x for x in self.replies)) + self.assertIn("工单编号", self.replies[-1]) def test_tms_miss_copy(self) -> None: facts = dict(COMPLETE_AIR) @@ -402,21 +439,90 @@ class AirTextInquiryTests(unittest.TestCase): injected_mode="AIR", ) self.assertEqual(phase, "tms_miss") - self.assertIn("暂无报价", self.replies[-1]) - self.assertIn("工单编号", self.replies[-1]) + wo = self.flow.session_of("u2").work_order_no + self.assertEqual(self.replies[-1], copy.tms_miss_followup(work_order_no=wo)) + self.assertTrue(any("暂无匹配报价" in x for x in self.replies)) miss = [x for x in self.extras if x.get("msgtype") == "template_card"] self.assertTrue(miss) card = miss[-1]["template_card"] self.assertEqual(card["card_type"], "text_notice") self.assertEqual(card["source"]["desc"], "TMS查价结果") - self.assertEqual(card["main_title"]["title"], self.flow.session_of("u2").work_order_no + "暂无报价") - self.assertNotIn("desc", card["main_title"]) - self.assertEqual(card["sub_title_text"], copy.tms_miss_hint()) - self.assertEqual( - card["quote_area"]["quote_text"], - copy.tms_miss_quote_text(work_order_no=self.flow.session_of("u2").work_order_no), - ) - self.assertNotIn("horizontal_content_list", card) + self.assertEqual(card["main_title"]["title"], f"询价工单 {wo}") + self.assertEqual(card["main_title"]["desc"], copy.tms_miss_hint()) + self.assertEqual(card["sub_title_text"], copy.tms_miss_body()) + rows = {x["keyname"]: x["value"] for x in card["horizontal_content_list"]} + self.assertEqual(rows["工单状态"], "询价中") + self.assertEqual(rows["查价结果"], "暂无匹配报价") + self.assertNotIn("来源", rows) + self.assertNotIn("button_list", card) + self.assertEqual(self.flow.session_of("u2").allowed, ()) + + def test_inquiry_must_send_before_tms_query(self) -> None: + """询价确认出站完成之前不能打 TMS,避免暂无报价卡抢先。""" + import threading + import time + from unittest.mock import patch + + from agent.channel.queue import TaskState + + store = MemoryMessageStore() + query_saw: list[str] = [] + + class _Led: + def create_ticket(self, **kwargs): + return { + "ok": True, + "work_order_no": "WO202609150111", + "first_or_same": "first", + } + + def query_tms(self, **kwargs): + inq = [ + i + for i in store._outbox.values() + if "正在查询TMS报价" in (i.content or "") + ] + query_saw.append(inq[0].state.value if inq else "missing") + return { + "ok": True, + "has_price": False, + "classification": "TMS_NO_QUOTE_1002", + } + + def reply(text: str, extra=None) -> str: + _ok, oid = store.enqueue_outbound( + touser="u_ord", + content=text, + payload=dict(extra or {}), + ) + return oid + + stop = threading.Event() + + def pump() -> None: + while not stop.is_set(): + item = store.claim_outbound() + if item: + store.complete_outbound(item.id) + else: + time.sleep(0.01) + + worker = threading.Thread(target=pump, daemon=True) + worker.start() + try: + with patch("agent.channel.queue.get_message_store", return_value=store): + flow = AirTextInquiryFlow(_Led()) + phase = flow.on_text( + sender_id="u_ord", + text="空运", + reply=reply, + injected_facts=COMPLETE_AIR, + injected_mode="AIR", + ) + finally: + stop.set() + self.assertEqual(phase, "tms_miss") + self.assertEqual(query_saw, [TaskState.DONE.value]) def test_incomplete_skip_is_not_no_quote(self) -> None: """主账未出站不能对销售说暂无报价。""" @@ -469,7 +575,9 @@ class AirTextInquiryTests(unittest.TestCase): ) no2 = self.flow.session_of("u4").work_order_no self.assertIn("工单已创建,正在查询TMS报价", "\n".join(self.replies)) - self.assertIn(f"引用历史工单号{no1}", "\n".join(self.replies)) + self.assertNotIn("相同询价内容检索", "\n".join(self.replies)) + self.assertNotIn("引用历史工单号", "\n".join(self.replies)) + self.assertEqual(self.flow.session_of("u4").history_work_order_no, no1) self.assertTrue(no1) self.assertTrue(no2) self.assertNotEqual(no1, no2) @@ -562,18 +670,31 @@ class AirTextInquiryTests(unittest.TestCase): card = payload["template_card"] self.assertEqual(card["card_type"], "text_notice") self.assertEqual(card["source"]["desc"], "TMS查价结果") - self.assertEqual(card["main_title"]["title"], "WO202606160008暂无报价") - self.assertNotIn("desc", card["main_title"]) - self.assertEqual(card["sub_title_text"], "TMS标准价库暂无匹配报价,请手动拉新人与智能体到原有群") - self.assertEqual(card["quote_area"]["quote_text"], "请务必在群聊发送工单编号WO202606160008") - self.assertNotIn("title", card["quote_area"]) - self.assertNotIn("horizontal_content_list", card) + self.assertEqual(card["main_title"]["title"], "询价工单 WO202606160008") + self.assertEqual(card["main_title"]["desc"], "TMS 标准价库暂无匹配报价") + self.assertEqual(card["sub_title_text"], "需求已解析并创建工单。") + rows = {x["keyname"]: x["value"] for x in card["horizontal_content_list"]} + self.assertEqual(rows, { + "工单状态": "询价中", + "查价结果": "暂无匹配报价", + }) + self.assertNotIn("来源", rows) + self.assertNotIn("button_list", card) + self.assertNotIn("quote_area", card) self.assertNotIn("card_action", card) with_url = copy.tms_miss_wecom_payload( work_order_no="WO202606160008", action_url="https://example.test", )["template_card"] self.assertEqual(with_url["card_action"]["url"], "https://example.test") + self.assertEqual( + copy.tms_miss(work_order_no="WO202606160008"), + "询价工单WO202606160008已建立,TMS标准价库暂无匹配报价。", + ) + self.assertEqual( + copy.tms_miss_followup(work_order_no="WO202606160008"), + "请手动拉相关人员和智能体到原有群,并在群聊发送工单编号WO202606160008", + ) def test_quote_card_payload_has_clickable_button(self) -> None: payload = copy.tms_hit_wecom_payload( @@ -623,7 +744,8 @@ class AirTextInquiryTests(unittest.TestCase): sub = card["sub_title_text"] self.assertIn("时效:3-5 天", sub) self.assertIn("报价有效期:2026-09-20", sub) - self.assertIn("相同询价内容检索:首次询价", sub) + self.assertNotIn("相同询价内容检索", sub) + self.assertNotIn("引用历史工单号", sub) self.assertNotIn("工单状态", sub) self.assertTrue(sub.endswith(copy.collab_hint("WO202609140001"))) self.assertNotIn("内部协同", json.dumps(card, ensure_ascii=False)) @@ -710,6 +832,67 @@ class AirTextInquiryTests(unittest.TestCase): self.assertEqual(copy.canonical_button("adopt_excel:WO202609140001"), copy.BTN_ADOPT_EXCEL) self.assertEqual(copy.canonical_button(copy.BTN_ADOPT_EXCEL_CARD), copy.BTN_ADOPT_EXCEL) + def test_deal_card_matches_followup_layout(self) -> None: + quote = { + "total": "722.5", + "currency": "CNY", + "eta": "2天", + "eta_date": "2026-08-23", + "valid_until": "2026-08-24", + "source_label": "TMS 标准报价", + } + text = copy.deal_card(work_order_no="WO202608210017", quote=quote) + self.assertIn("询价工单 WO202608210017", text) + self.assertIn("报价单已发送,请跟进成交状态", text) + self.assertIn("咨询成交状态:请选择成交 / 未成交 / 协商中(未成交需补充原因)。", text) + self.assertIn("总销售价合计:CNY 722.5", text) + self.assertIn("时效:2天", text) + self.assertNotIn("时效(要求/TMS)", text) + self.assertNotIn("- / -", text) + self.assertIn("报价有效期:2026-08-24", text) + self.assertNotIn("历史报价", text) + self.assertNotIn("工单状态", text) + self.assertNotIn("来源:", text) + self.assertNotIn("总成本价合计", text) + payload = copy.deal_wecom_payload(work_order_no="WO202608210017", quote=quote) + card = payload["template_card"] + self.assertEqual(payload["msgtype"], "template_card") + self.assertEqual(card["card_type"], "button_interaction") + self.assertEqual(card["source"]["desc"], "TMS标准报价") + self.assertEqual(card["main_title"]["title"], "询价工单 WO202608210017") + self.assertEqual(card["main_title"]["desc"], "报价单已发送,请跟进成交状态") + self.assertEqual(card["sub_title_text"], copy.DEAL_HINT) + rows = {x["keyname"]: x["value"] for x in card["horizontal_content_list"]} + self.assertEqual( + rows, + { + "总销售价合计": "CNY 722.5", + "时效": "2天", + "报价有效期": "2026-08-24", + }, + ) + self.assertNotIn("历史报价", rows) + self.assertNotIn("工单状态", rows) + self.assertNotIn("来源", rows) + self.assertEqual( + [x["text"] for x in card["button_list"]], + [copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE], + ) + self.assertEqual(card["button_list"][0]["key"], "deal:WO202608210017") + self.assertTrue(str(card["task_id"]).startswith("deal-WO202608210017-")) + self.assertEqual( + copy.work_order_from_card_meta({"event_key": "lost:WO202608210017"}), + "WO202608210017", + ) + self.assertEqual(copy.canonical_button("deal:WO202608210017"), copy.BTN_DEAL) + self.assertEqual(copy.canonical_button("negotiate:WO202608210017"), copy.BTN_NEGOTIATE) + empty_rows = { + x["keyname"]: x["value"] + for x in copy.deal_horizontal_rows(quote={"total": "-", "valid_until": "-"}) + } + self.assertEqual(empty_rows["时效"], "-") + self.assertNotEqual(empty_rows["时效"], "- / -") + def test_same_demand_cites_latest_even_if_closed(self) -> None: ledger = MemoryLedger() flow = AirTextInquiryFlow(ledger) @@ -745,7 +928,9 @@ class AirTextInquiryTests(unittest.TestCase): wo2 = flow.session_of("hist-b").work_order_no self.assertTrue(wo2) self.assertNotEqual(wo2, wo1) - self.assertIn(f"引用历史工单号{wo1}", "\n".join(replies_b)) + self.assertEqual(flow.session_of("hist-b").history_work_order_no, wo1) + self.assertNotIn("相同询价内容检索", "\n".join(replies_b)) + self.assertNotIn("引用历史工单号", "\n".join(replies_b)) replies_c: list[str] = [] def _keep_c(text: str, extra=None) -> None: @@ -761,8 +946,10 @@ class AirTextInquiryTests(unittest.TestCase): ) wo3 = flow.session_of("hist-c").work_order_no self.assertNotEqual(wo3, wo2) - self.assertIn(f"引用历史工单号{wo2}", "\n".join(replies_c)) - self.assertNotIn(f"引用历史工单号{wo1}", "\n".join(replies_c)) + self.assertEqual(flow.session_of("hist-c").history_work_order_no, wo2) + self.assertNotEqual(flow.session_of("hist-c").history_work_order_no, wo1) + self.assertNotIn("相同询价内容检索", "\n".join(replies_c)) + self.assertNotIn("引用历史工单号", "\n".join(replies_c)) def test_card_event_key_matches_skip_button(self) -> None: from agent.channel.wecom.parse import parse_plain_xml @@ -990,6 +1177,138 @@ class AirTextInquiryTests(unittest.TestCase): self.assertEqual(ledger.last_render.get("matchType"), "default") self.assertEqual(ledger.last_render.get("templateName"), "默认空运") + def test_pdf_queue_does_not_emit_deal_before_file(self) -> None: + """PDF 还在 LibreOffice 队列时,不能先出成交跟进卡。""" + ledger = MemoryLedger() + ledger.set_quote_templates( + [{"templateId": "d1", "name": "默认空运", "bizType": "空运", "isDefault": True}] + ) + orig = ledger.render_quote -if __name__ == "__main__": - unittest.main() + def _render(**kwargs): + out = orig(**kwargs) + out["needsPdf"] = True + return out + + ledger.render_quote = _render # type: ignore[method-assign] + flow = AirTextInquiryFlow(ledger) + replies: list[str] = [] + flow.on_text( + sender_id="u-pdf-q", + text="空运", + reply=lambda t, extra=None: replies.append(t), + injected_facts=COMPLETE_AIR, + injected_mode="AIR", + ) + flow.on_button( + sender_id="u-pdf-q", + action=copy.BTN_SKIP_COLLAB, + reply=lambda t, extra=None: replies.append(t), + ) + flow._enqueue_quote_pdf = lambda *a, **k: True # type: ignore[method-assign] + phase = flow.on_button( + sender_id="u-pdf-q", + action=copy.BTN_ADOPT_PDF, + reply=lambda t, extra=None: replies.append(t), + ) + self.assertEqual(phase, "wait_file") + self.assertNotIn("咨询成交状态", "\n".join(replies)) + self.assertEqual(flow.session_of("u-pdf-q").phase, "wait_file") + again: list[str] = [] + replay = flow._replay_wait(flow.session_of("u-pdf-q"), lambda t, extra=None: again.append(t), source="test") + self.assertEqual(replay, "wait_file") + self.assertIn("请稍候", "\n".join(again)) + self.assertNotIn("咨询成交状态", "\n".join(again)) + + def test_worker_file_ready_unblocks_http_wait_file(self) -> None: + """Worker 出完文件后 HTTP 进程必须能切到成交跟进,不能一直请稍候。""" + ledger = MemoryLedger() + ledger.set_quote_templates( + [{"templateId": "d1", "name": "默认空运", "bizType": "空运", "isDefault": True}] + ) + orig = ledger.render_quote + + def _render(**kwargs): + out = orig(**kwargs) + out["needsPdf"] = True + return out + + ledger.render_quote = _render # type: ignore[method-assign] + http = AirTextInquiryFlow(ledger) + replies: list[str] = [] + http.on_text( + sender_id="u-pdf-x", + text="空运", + reply=lambda t, extra=None: replies.append(t), + injected_facts=COMPLETE_AIR, + injected_mode="AIR", + ) + http.on_button( + sender_id="u-pdf-x", + action=copy.BTN_SKIP_COLLAB, + reply=lambda t, extra=None: replies.append(t), + ) + http._enqueue_quote_pdf = lambda *a, **k: True # type: ignore[method-assign] + http.on_button( + sender_id="u-pdf-x", + action=copy.BTN_ADOPT_PDF, + reply=lambda t, extra=None: replies.append(t), + ) + self.assertEqual(http.session_of("u-pdf-x").phase, "wait_file") + wo = http.session_of("u-pdf-x").work_order_no + worker = AirTextInquiryFlow(ledger) + worker.mark_wait_deal("u-pdf-x", wo) + again: list[str] = [] + replay = http._replay_wait( + http.session_of("u-pdf-x"), + lambda t, extra=None: again.append(t), + source="test", + ) + self.assertEqual(replay, "wait_deal") + self.assertIn("咨询成交状态", "\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: + """成交卡已发出时,点成交不能因为书签还停在 wait_file 就没反应。""" + ledger = MemoryLedger() + ledger.set_quote_templates( + [{"templateId": "d1", "name": "默认空运", "bizType": "空运", "isDefault": True}] + ) + orig = ledger.render_quote + + def _render(**kwargs): + out = orig(**kwargs) + out["needsPdf"] = True + return out + + ledger.render_quote = _render # type: ignore[method-assign] + flow = AirTextInquiryFlow(ledger) + flow.on_text( + sender_id="u-pdf-d", + text="空运", + reply=lambda t, extra=None: None, + injected_facts=COMPLETE_AIR, + injected_mode="AIR", + ) + flow.on_button( + sender_id="u-pdf-d", + action=copy.BTN_SKIP_COLLAB, + reply=lambda t, extra=None: None, + ) + flow._enqueue_quote_pdf = lambda *a, **k: True # type: ignore[method-assign] + flow.on_button( + sender_id="u-pdf-d", + action=copy.BTN_ADOPT_PDF, + reply=lambda t, extra=None: None, + ) + self.assertEqual(flow.session_of("u-pdf-d").phase, "wait_file") + extras: list[dict] = [] + phase = flow.on_button( + sender_id="u-pdf-d", + action=copy.BTN_DEAL, + reply=lambda t, extra=None: extras.append(dict(extra or {})), + card_meta={"response_code": "rc-stuck-deal"}, + ) + 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)) diff --git a/inquiry-agent/tests/test_copy_ticket_h5.py b/inquiry-agent/tests/test_copy_ticket_h5.py new file mode 100644 index 0000000..6303acc --- /dev/null +++ b/inquiry-agent/tests/test_copy_ticket_h5.py @@ -0,0 +1,55 @@ +""" +复制工单号页:token 不带工单号,打开页能看到工单号,过期给 410。 +""" + +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("LEDGER_BACKEND", "memory") + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from agent.channel.h5 import router as h5_router +from agent.channel.h5.copy_page import render_copy_page +from agent.channel.h5.copy_store import build_copy_ticket_url, lookup_copy_token + + +class CopyTicketH5Tests(unittest.TestCase): + def test_token_roundtrip_and_page(self) -> None: + url = build_copy_ticket_url("WO202609150001") + self.assertIn("/inquiry-form/copy/", url) + token = url.rstrip("/").rsplit("/", 1)[-1] + self.assertEqual(lookup_copy_token(token), "WO202609150001") + self.assertNotIn("WO202609150001", token) + html = render_copy_page("WO202609150001") + self.assertIn("WO202609150001", html) + self.assertIn("setClipboardData", html) + + def test_http_copy_page(self) -> None: + url = build_copy_ticket_url("WO202609150002") + token = url.rstrip("/").rsplit("/", 1)[-1] + app = FastAPI() + app.include_router(h5_router) + client = TestClient(app) + hit = client.get(f"/inquiry-form/copy/{token}") + self.assertEqual(hit.status_code, 200) + self.assertIn("WO202609150002", hit.text) + miss = client.get("/inquiry-form/copy/not-a-real-token") + self.assertEqual(miss.status_code, 410) + self.assertIn("过期", miss.text) + + +if __name__ == "__main__": + unittest.main() 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 3b883df..34b43e0 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 @@ -191,6 +191,11 @@ public final class QuoteTemplateExcelSupport { writeFeeRows(wb, mapping, facts, yellow); // 后台常未配 fee_rows:按「空运费」等名称写入金额列,SUM 才能加算 writeFeeAmountsByName(wb, facts, yellow); + // 模板「预估总价」是 SUM。POI 写完数字不会重算,缓存仍是 0。 + // 企微/WPS 预览常不重算,销售会看到汇总为 0。这里把公式缓存刷成当前值。 + refreshFormulas(wb); + // 转 PDF 前把纸张改成 A4 居中,避免顶部裁字、整页偏左。 + applyQuotePrintLayout(wb); Files.createDirectories(targetXlsx.getParent()); try (OutputStream out = Files.newOutputStream(targetXlsx)) { wb.write(out); @@ -252,7 +257,62 @@ public final class QuoteTemplateExcelSupport { if (cell == null) { cell = row.createCell(col); } - cell.setCellValue(value == null ? "" : value); + writeNumericOrText(cell, value); + } + + /** + * 金额/数量/单价写成数字,SUM 才能加算;名称等仍写文本。 + */ + private static void writeNumericOrText(Cell cell, String value) { + if (value == null) { + cell.setCellValue(""); + return; + } + String norm = stripMoneyPrefix(value); + if (StringUtils.isBlank(norm) || "-".equals(norm)) { + cell.setCellValue(value); + return; + } + try { + cell.setCellValue(Double.parseDouble(norm)); + } catch (NumberFormatException e) { + cell.setCellValue(value); + } + } + + /** + * 回填后重算公式,把 SUM 等缓存从 0 刷成真实合计。 + * 失败不抛:出单仍要发出,只记日志。 + */ + static void refreshFormulas(Workbook wb) { + if (wb == null) { + return; + } + FormulaEvaluator ev = wb.getCreationHelper().createFormulaEvaluator(); + for (int s = 0; s < wb.getNumberOfSheets(); s++) { + Sheet sheet = wb.getSheetAt(s); + if (sheet == null) { + continue; + } + for (Row row : sheet) { + if (row == null) { + continue; + } + for (Cell cell : row) { + if (cell == null || cell.getCellType() != CellType.FORMULA) { + continue; + } + try { + ev.evaluateFormulaCell(cell); + } catch (Exception e) { + log.warn("报价公式未重算 {}!{}: {}", + sheet.getSheetName(), + new CellReference(cell).formatAsString(), + e.getMessage()); + } + } + } + } } private static void clearCell(Workbook wb, String cellRef) { @@ -493,6 +553,23 @@ public final class QuoteTemplateExcelSupport { return fuzzy; } + /** + * 费用明细表头原字,回填系统字段时不得覆盖。 + */ + 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); + } + private static String readCellText(Cell cell) { if (cell == null) { return ""; @@ -585,6 +662,10 @@ public final class QuoteTemplateExcelSupport { String text = value == null ? "" : value; // 标签格「报价编号:」只换冒号后的值,避免整格被工单号顶掉看起来像残缺 String existing = readCellText(cell); + // 「币别」右侧常被误映到「单价」表头,不能把 CNY 盖掉单价二字 + if (isFeeTableHeader(existing)) { + return; + } int colon = Math.max(existing.indexOf(':'), existing.indexOf(':')); if (colon >= 0 && colon == existing.length() - 1) { text = existing + text; @@ -599,13 +680,78 @@ public final class QuoteTemplateExcelSupport { copy.setDataFormat(wb.createDataFormat().getFormat("@")); cell.setCellStyle(copy); } - // 工单号、日期略加宽,避免列太窄只露出半截 - if (dest[1] >= 0 && dest[1] < 20 && text.length() >= 8) { - org.apache.poi.ss.usermodel.Sheet sh = sheet; - int col = dest[1]; - double width = sh.getColumnWidth(col) / 256.0; - if (width > 0 && width < text.length() + 2) { - sh.setColumnWidth(col, (int) Math.min(40, text.length() + 4) * 256); + // 不再临时加宽列:会把打印缩放打乱,转 PDF 后整页偏左。 + } + + /** + * 报价单打印版心:A4、水平居中、按页宽缩放。 + *

+ * 空运模板常是 62% 缩放且不居中。LibreOffice 按这个出 PDF, + * 公司名会被裁掉上半截,正文整页靠左。出单时统一改过来。 + * 只改打印属性,不改单元格里的业务数字。 + */ + static void applyQuotePrintLayout(Workbook wb) { + if (wb == null) { + return; + } + for (int s = 0; s < wb.getNumberOfSheets(); s++) { + Sheet sheet = wb.getSheetAt(s); + if (sheet == null) { + continue; + } + sheet.setMargin(Sheet.LeftMargin, 0.5); + sheet.setMargin(Sheet.RightMargin, 0.5); + // 上边距适中:太小企微会裁公司名,太大纸顶空一截 + sheet.setMargin(Sheet.TopMargin, 0.75); + sheet.setMargin(Sheet.BottomMargin, 0.6); + sheet.setMargin(Sheet.HeaderMargin, 0.1); + sheet.setMargin(Sheet.FooterMargin, 0.2); + sheet.setHorizontallyCenter(true); + sheet.setVerticallyCenter(false); + sheet.setAutobreaks(true); + sheet.setFitToPage(true); + PrintSetup ps = sheet.getPrintSetup(); + ps.setPaperSize(PrintSetup.A4_PAPERSIZE); + ps.setLandscape(false); + ps.setFitWidth((short) 1); + ps.setFitHeight((short) 0); + ensureHeaderRowHeight(sheet); + } + } + + /** + * 抬头合并格(公司中英文)行高太矮时加高。 + * 现网空运模板 A1:H2 合计只有 42 磅,两行字会裁顶。 + */ + static void ensureHeaderRowHeight(Sheet sheet) { + if (sheet == null) { + return; + } + for (int i = 0; i < sheet.getNumMergedRegions(); i++) { + org.apache.poi.ss.util.CellRangeAddress range = sheet.getMergedRegion(i); + if (range.getFirstRow() != 0 || range.getLastRow() < 1) { + continue; + } + Row first = sheet.getRow(range.getFirstRow()); + Cell cell = first == null ? null : first.getCell(range.getFirstColumn()); + String text = readCellText(cell); + if (!text.contains("公司") && text.toLowerCase().indexOf("company") < 0) { + continue; + } + // 模板末尾常带空行,字会被顶到合并格上沿,转 PDF 再被裁一刀 + if (cell != null && StringUtils.isNotBlank(text)) { + cell.setCellValue(text.replaceAll("[\\r\\n]+$", "")); + } + int rows = range.getLastRow() - range.getFirstRow() + 1; + float each = Math.max(32f, 64f / rows); + for (int r = range.getFirstRow(); r <= range.getLastRow(); r++) { + Row row = sheet.getRow(r); + if (row == null) { + row = sheet.createRow(r); + } + if (row.getHeightInPoints() < each) { + row.setHeightInPoints(each); + } } } } 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 00f7303..3f1e300 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 @@ -2,6 +2,8 @@ package org.jeecg.modules.inquiry.template; import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; +import org.apache.poi.ss.usermodel.PrintSetup; +import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Sheet; import org.apache.poi.ss.usermodel.Workbook; import org.apache.poi.xssf.usermodel.XSSFWorkbook; @@ -12,6 +14,8 @@ import java.nio.file.Path; import java.util.HashMap; import java.util.Map; +import org.apache.poi.ss.usermodel.CellType; + import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -153,4 +157,91 @@ public class QuoteTemplateFillTest { Files.deleteIfExists(out); } } + + /** + * 空运模板「预估总价」是 SUM。回填后必须把公式缓存刷成合计, + * 否则企微/WPS 预览仍显示模板里的 0。 + */ + @Test + public void refreshSumCacheAfterWritingFeeAmount() throws Exception { + Path src = Files.createTempFile("quote-sum-src", ".xlsx"); + Path out = Files.createTempFile("quote-sum-out", ".xlsx"); + try (Workbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("Sheet2"); + org.apache.poi.ss.usermodel.Row head = sheet.createRow(15); + head.createCell(1).setCellValue("费用名称"); + head.createCell(2).setCellValue("单位"); + head.createCell(3).setCellValue("币别"); + head.createCell(4).setCellValue("单价"); + head.createCell(5).setCellValue("数量"); + head.createCell(6).setCellValue("金额"); + head.createCell(7).setCellValue("备注"); + sheet.createRow(16).createCell(1).setCellValue("空运费"); + org.apache.poi.ss.usermodel.Row total = sheet.createRow(22); + total.createCell(0).setCellValue("预估总价"); + total.createCell(6).setCellFormula("SUM(G17:G22)"); + try (java.io.OutputStream os = Files.newOutputStream(src)) { + wb.write(os); + } + } + JSONObject mapping = new JSONObject(); + JSONObject fields = new JSONObject(); + // 测服空运模板常把「币别」右侧误映到单价表头,回填时不能盖掉「单价」 + fields.put("currency", "Sheet2!E16"); + mapping.put("fields", fields); + JSONObject facts = new JSONObject(true); + facts.put("currency", "CNY"); + facts.put("total_amount", "14669.60"); + facts.put("fee_rows", QuoteTemplateExcelSupport.parseFeeRows( + "[{\"name\":\"空运费\",\"amount\":\"14669.60\",\"currency\":\"CNY\"," + + "\"unit\":\"KG\",\"quantity\":\"1333.6\",\"unit_price\":\"11.0\"}]")); + QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts); + try (Workbook formulas = new XSSFWorkbook(Files.newInputStream(out))) { + org.apache.poi.ss.usermodel.Cell sum = formulas.getSheetAt(0).getRow(22).getCell(6); + assertEquals(CellType.FORMULA, sum.getCellType()); + assertEquals("SUM(G17:G22)", sum.getCellFormula()); + // 公式格的数字缓存 = 企微预览看到的值;没刷缓存会是 0 + assertEquals(14669.60, sum.getNumericCellValue(), 0.01); + assertEquals("单价", formulas.getSheetAt(0).getRow(15).getCell(4).getStringCellValue()); + } finally { + Files.deleteIfExists(src); + Files.deleteIfExists(out); + } + } + + /** + * 出单后必须 A4 水平居中,抬头行高够放下中英文公司名。 + * 否则转 PDF 会顶部裁字、整页偏左。 + */ + @Test + public void applyPrintLayoutCentersA4AndRaisesHeader() throws Exception { + Path src = Files.createTempFile("quote-layout-src", ".xlsx"); + Path out = Files.createTempFile("quote-layout-out", ".xlsx"); + try (Workbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("Sheet2"); + sheet.setHorizontallyCenter(false); + sheet.getPrintSetup().setScale((short) 62); + sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(0, 1, 0, 7)); + Row r0 = sheet.createRow(0); + r0.setHeightInPoints(21f); + r0.createCell(0).setCellValue("深圳市友通达国际货运代理有限公司\nShenzhen Youtongda International Freight Forwarding Co.LTD"); + sheet.createRow(1).setHeightInPoints(21f); + try (java.io.OutputStream os = Files.newOutputStream(src)) { + wb.write(os); + } + } + QuoteTemplateExcelSupport.fillWorkbook(src, out, new JSONObject(), new JSONObject()); + try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) { + Sheet sheet = filled.getSheetAt(0); + assertTrue(sheet.getHorizontallyCenter()); + assertEquals(PrintSetup.A4_PAPERSIZE, sheet.getPrintSetup().getPaperSize()); + assertEquals(1, sheet.getPrintSetup().getFitWidth()); + assertEquals(0.75, sheet.getMargin(Sheet.TopMargin), 0.01); + assertTrue(sheet.getRow(0).getHeightInPoints() >= 32f); + assertTrue(sheet.getRow(1).getHeightInPoints() >= 32f); + } finally { + Files.deleteIfExists(src); + Files.deleteIfExists(out); + } + } }