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);
+ }
+ }
}