Compare commits

..
22 changed files with 1630 additions and 126 deletions
+36 -3
View File
@@ -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]:
"""
+150
View File
@@ -0,0 +1,150 @@
"""
复制工单号页 HTML。
本文件职责:打开后立刻把工单号写入系统剪贴板(企微 JS-SDK + 浏览器兜底)。
禁止:URL/页面带 userid;禁止在本页调主账或改六态。
"""
from __future__ import annotations
import html
from agent.config import get_settings
_EXPIRED_HTML = """<!doctype html>
<html lang="zh-CN"><head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>链接已过期</title>
</head><body style="font-family:sans-serif;padding:32px;text-align:center;color:#333">
<p>复制链接已过期。</p>
<p>请回到企微,重新询价后再点「复制工单号」。</p>
</body></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"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>复制工单号</title>
<style>
body {{ margin:0; font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;
background:#f5f6f7; color:#1f2329; }}
.wrap {{ max-width:420px; margin:48px auto; padding:0 20px; text-align:center; }}
.wo {{ font-size:22px; font-weight:600; letter-spacing:.04em; margin:16px 0 8px; word-break:break-all; }}
.st {{ min-height:24px; color:#646a73; font-size:14px; }}
.ok {{ color:#00a870; }}
.bad {{ color:#d83931; }}
button {{ margin-top:20px; width:100%; height:44px; border:0; border-radius:8px;
background:#1677ff; color:#fff; font-size:16px; }}
</style>
</head>
<body>
<div class="wrap">
<div>工单号</div>
<div class="wo" id="wo" data-wo="{wo}">{wo}</div>
<div class="st" id="st">正在复制…</div>
<button type="button" id="btn">复制工单号</button>
</div>
<script src="https://wwcdn.weixin.qq.com/node/wework/open/js/wecom-jssdk-2.3.2.js"></script>
<script>
(function () {{
var text = document.getElementById("wo").getAttribute("data-wo") || "";
var st = document.getElementById("st");
var corpId = "{corp}";
var agentId = Number("{agent}") || 0;
function show(ok, msg) {{
st.className = "st " + (ok ? "ok" : "bad");
st.textContent = msg;
}}
function browserCopy(value) {{
if (navigator.clipboard && navigator.clipboard.writeText) {{
return navigator.clipboard.writeText(value);
}}
return new Promise(function (resolve, reject) {{
var box = document.createElement("textarea");
box.value = value;
box.setAttribute("readonly", "readonly");
box.style.position = "fixed";
box.style.left = "-9999px";
document.body.appendChild(box);
box.select();
try {{
if (!document.execCommand("copy")) throw new Error("exec");
resolve();
}} catch (err) {{ reject(err); }}
document.body.removeChild(box);
}});
}}
function wecomCopy(value) {{
if (window.ww && ww.setClipboardData) {{
return ww.setClipboardData({{ data: value }});
}}
return Promise.reject(new Error("no-ww"));
}}
function sign(kind) {{
return function (url) {{
return fetch("/inquiry-form/jsapi-signature?kind=" + encodeURIComponent(kind)
+ "&url=" + encodeURIComponent(url))
.then(function (r) {{
if (!r.ok) throw new Error("sign");
return r.json();
}});
}};
}}
function tryCopy() {{
return wecomCopy(text).catch(function () {{ return browserCopy(text); }})
.then(function () {{ show(true, "已复制到剪贴板"); }})
.catch(function () {{ show(false, "复制失败,请再点一次按钮"); }});
}}
function boot() {{
var ready = Promise.resolve();
if (window.ww && ww.register && corpId) {{
ready = ww.register({{
corpId: corpId,
agentId: agentId,
jsApiList: ["setClipboardData"],
getConfigSignature: sign("corp"),
getAgentConfigSignature: sign("agent")
}}).catch(function () {{ return null; }});
}}
ready.then(tryCopy).catch(tryCopy);
}}
document.getElementById("btn").addEventListener("click", function () {{
show(true, "正在复制…");
tryCopy();
}});
if (document.readyState === "loading") {{
document.addEventListener("DOMContentLoaded", boot);
}} else {{
boot();
}}
}})();
</script>
</body>
</html>
"""
@@ -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
@@ -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()
+51 -1
View File
@@ -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(
+39
View File
@@ -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 {
@@ -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]: ...
+110
View File
@@ -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}&timestamp={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
+4
View File
@@ -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")
+4 -2
View File
@@ -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
@@ -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(
+2 -1
View File
@@ -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,
+2 -1
View File
@@ -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),
+22
View File
@@ -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)
+199 -34
View File
@@ -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()
+177 -45
View File
@@ -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:
+4
View File
@@ -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"
@@ -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
+349 -30
View File
@@ -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))
@@ -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()
@@ -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、水平居中、按页宽缩放。
* <p>
* 空运模板常是 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);
}
}
}
}
@@ -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);
}
}
}