Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58b3c2f3e6 | ||
|
|
a7a8487eb3 |
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
H5 补问 / 附件上传通道。
|
H5 补问 / 附件上传 / 复制工单号通道。
|
||||||
|
|
||||||
本文件职责:挂载 /inquiry-form;用 clarification token 绑定身份(URL 禁止 userid)。
|
本文件职责:挂载 /inquiry-form;用 token 绑定身份或工单号(URL 禁止 userid)。
|
||||||
禁止:从 URL 读 userid;禁止在本包改六态或直连 TMS;禁止同步 invoke Graph。
|
禁止:从 URL 读 userid;禁止在本包改六态或直连 TMS;禁止同步 invoke Graph。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -10,10 +10,13 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any, Optional
|
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 pydantic import BaseModel, Field
|
||||||
|
|
||||||
from agent.channel.clarification import validate_token
|
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
|
from agent.handlers.card_action import handle_card_action
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -34,6 +37,36 @@ def h5_ping() -> dict[str, str]:
|
|||||||
return {"status": "ok", "channel": "h5"}
|
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}")
|
@router.get("/{token}")
|
||||||
def h5_open(token: str) -> dict[str, Any]:
|
def h5_open(token: str) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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)
|
logger.info("企微发送成功 touser=%s reply=%s", body.get("touser"), shown)
|
||||||
return WeComSendResult(ok=True, errcode=0, errmsg=errmsg, raw=data)
|
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:
|
def _get_token(self) -> str:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
@@ -190,6 +190,26 @@ class PostgresMessageStore:
|
|||||||
logger.exception("pg outbox 入队失败")
|
logger.exception("pg outbox 入队失败")
|
||||||
raise
|
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]:
|
def claim_outbound(self) -> Optional[OutboxItem]:
|
||||||
timeout = int(self._claim_timeout)
|
timeout = int(self._claim_timeout)
|
||||||
with connect_psycopg(self._dsn) as conn:
|
with connect_psycopg(self._dsn) as conn:
|
||||||
@@ -203,7 +223,7 @@ class PostgresMessageStore:
|
|||||||
AND claimed_at IS NOT NULL
|
AND claimed_at IS NOT NULL
|
||||||
AND claimed_at < NOW() - (%s || ' seconds')::interval
|
AND claimed_at < NOW() - (%s || ' seconds')::interval
|
||||||
)
|
)
|
||||||
ORDER BY created_at
|
ORDER BY created_at ASC, id ASC
|
||||||
FOR UPDATE SKIP LOCKED
|
FOR UPDATE SKIP LOCKED
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
)
|
)
|
||||||
@@ -294,6 +314,36 @@ class PostgresMessageStore:
|
|||||||
def wait_outbox(self, timeout: float = 1.0) -> None:
|
def wait_outbox(self, timeout: float = 1.0) -> None:
|
||||||
self._outbox_event.wait(timeout=timeout)
|
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]:
|
def stats(self) -> dict[str, Any]:
|
||||||
with connect_psycopg(self._dsn) as conn:
|
with connect_psycopg(self._dsn) as conn:
|
||||||
inbox = conn.execute(
|
inbox = conn.execute(
|
||||||
|
|||||||
@@ -163,6 +163,14 @@ class MemoryMessageStore:
|
|||||||
logger.info("outbox 入队 id=%s touser=%s", item.id, touser)
|
logger.info("outbox 入队 id=%s touser=%s", item.id, touser)
|
||||||
return True, item.id
|
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]:
|
def claim_outbound(self) -> Optional[OutboxItem]:
|
||||||
"""认领一条 pending/超时 outbox。"""
|
"""认领一条 pending/超时 outbox。"""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
@@ -207,6 +215,37 @@ class MemoryMessageStore:
|
|||||||
def wait_outbox(self, timeout: float = 1.0) -> None:
|
def wait_outbox(self, timeout: float = 1.0) -> None:
|
||||||
self._outbox_event.wait(timeout=timeout)
|
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]:
|
def stats(self) -> dict[str, Any]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -40,4 +40,6 @@ class MessageStore(Protocol):
|
|||||||
|
|
||||||
def wait_outbox(self, timeout: float = 1.0) -> None: ...
|
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]: ...
|
def stats(self) -> dict[str, Any]: ...
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -41,6 +41,10 @@ class Settings(BaseSettings):
|
|||||||
ytd_env: str = Field(default="test", alias="YTD_ENV")
|
ytd_env: str = Field(default="test", alias="YTD_ENV")
|
||||||
agent_http_port: int = Field(default=8910, alias="AGENT_HTTP_PORT")
|
agent_http_port: int = Field(default=8910, alias="AGENT_HTTP_PORT")
|
||||||
public_base_url: str = Field(default="", alias="PUBLIC_BASE_URL")
|
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_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_token: str = Field(default="", alias="BIZ_API_TOKEN")
|
||||||
biz_api_username: str = Field(default="", alias="BIZ_API_USERNAME")
|
biz_api_username: str = Field(default="", alias="BIZ_API_USERNAME")
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
意图:询价卡片按钮(跳过协同 / 采用 / 不采用 / 成交)。
|
意图:询价卡片按钮(复制工单号 / 跳过协同 / 采用 / 不采用 / 成交)。
|
||||||
|
|
||||||
本文件职责:校验当前会话允许的动作后交给流程执行。
|
本文件职责:校验当前会话允许的动作后交给流程执行。
|
||||||
禁止:不校验 wait/允许集合就改主账;禁止一个函数处理未声明的新按钮。
|
禁止:不校验 wait/允许集合就改主账;禁止一个函数处理未声明的新按钮。
|
||||||
@@ -44,7 +44,7 @@ def handle_card_action(
|
|||||||
|
|
||||||
msg_store = store or get_message_store()
|
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] 只给运维告警
|
# 销售询价正文不加 [test];[test] 只给运维告警
|
||||||
body = text
|
body = text
|
||||||
extra = dict(extra or {})
|
extra = dict(extra or {})
|
||||||
@@ -65,6 +65,8 @@ def handle_card_action(
|
|||||||
)
|
)
|
||||||
if not ok:
|
if not ok:
|
||||||
logger.warning("card 出站被去重 sender=%s wo=%s info=%s", sender_id, wo or "-", info)
|
logger.warning("card 出站被去重 sender=%s wo=%s info=%s", sender_id, wo or "-", info)
|
||||||
|
return ""
|
||||||
|
return info
|
||||||
|
|
||||||
return engine.on_button(
|
return engine.on_button(
|
||||||
sender_id=sender_id, action=action, reply=reply, card_meta=payload
|
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()
|
engine = flow or get_air_text_flow()
|
||||||
replies: list[str] = []
|
replies: list[str] = []
|
||||||
|
|
||||||
def reply(text: str, extra: dict | None = None) -> None:
|
def reply(text: str, extra: dict | None = None) -> str:
|
||||||
replies.append(text)
|
replies.append(text)
|
||||||
ok, info = msg_store.enqueue_outbound(
|
ok, info = msg_store.enqueue_outbound(
|
||||||
touser=message.sender_id,
|
touser=message.sender_id,
|
||||||
@@ -46,6 +46,7 @@ def handle_continue_thread(
|
|||||||
payload=dict(extra or {}),
|
payload=dict(extra or {}),
|
||||||
)
|
)
|
||||||
logger.info("continue_thread 出站 ok=%s info=%s", ok, info)
|
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 "")
|
wo = (thread_id or "").strip() or copy.extract_work_order_no(message.content or "")
|
||||||
phase = engine.resume_historical(
|
phase = engine.resume_historical(
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ def handle_text_inquiry(
|
|||||||
engine = flow or get_air_text_flow()
|
engine = flow or get_air_text_flow()
|
||||||
replies: list[str] = []
|
replies: list[str] = []
|
||||||
|
|
||||||
def reply(text: str, extra: dict | None = None) -> None:
|
def reply(text: str, extra: dict | None = None) -> str:
|
||||||
# 销售询价正文不加 [test];[test] 只给运维告警
|
# 销售询价正文不加 [test];[test] 只给运维告警
|
||||||
body = text
|
body = text
|
||||||
replies.append(body)
|
replies.append(body)
|
||||||
@@ -53,6 +53,7 @@ def handle_text_inquiry(
|
|||||||
payload=dict(extra or {}),
|
payload=dict(extra or {}),
|
||||||
)
|
)
|
||||||
logger.info("text_inquiry 出站 ok=%s info=%s phase_reply=%s", ok, info, body[:80])
|
logger.info("text_inquiry 出站 ok=%s info=%s phase_reply=%s", ok, info, body[:80])
|
||||||
|
return info if ok else ""
|
||||||
|
|
||||||
phase = engine.on_text(
|
phase = engine.on_text(
|
||||||
sender_id=message.sender_id,
|
sender_id=message.sender_id,
|
||||||
|
|||||||
@@ -70,7 +70,8 @@ class LibreOfficeConverter:
|
|||||||
str(exe),
|
str(exe),
|
||||||
"--headless",
|
"--headless",
|
||||||
"--convert-to",
|
"--convert-to",
|
||||||
"pdf",
|
# calc 导出才认 Excel 的 A4/居中/页宽缩放;裸 pdf 会裁顶、偏左
|
||||||
|
"pdf:calc_pdf_Export",
|
||||||
"--outdir",
|
"--outdir",
|
||||||
str(out_dir),
|
str(out_dir),
|
||||||
str(source),
|
str(source),
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ def enqueue_pdf_job(
|
|||||||
redis: Optional[RedisRuntime] = None,
|
redis: Optional[RedisRuntime] = None,
|
||||||
touser: str = "",
|
touser: str = "",
|
||||||
filename: str = "",
|
filename: str = "",
|
||||||
|
quote: Optional[dict[str, Any]] = None,
|
||||||
|
facts: Optional[dict[str, Any]] = None,
|
||||||
) -> PdfEnqueueResult:
|
) -> PdfEnqueueResult:
|
||||||
"""Excel→PDF 任务入队;真正转换只在 LibreOffice 槽。"""
|
"""Excel→PDF 任务入队;真正转换只在 LibreOffice 槽。"""
|
||||||
rt = redis or get_redis_runtime()
|
rt = redis or get_redis_runtime()
|
||||||
@@ -61,6 +63,8 @@ def enqueue_pdf_job(
|
|||||||
"quote_version": quote_version,
|
"quote_version": quote_version,
|
||||||
"touser": touser,
|
"touser": touser,
|
||||||
"filename": filename,
|
"filename": filename,
|
||||||
|
"quote": dict(quote or {}),
|
||||||
|
"facts": dict(facts or {}),
|
||||||
}
|
}
|
||||||
entry_id = q.enqueue(payload=payload, idempotency_key=idempotency_key)
|
entry_id = q.enqueue(payload=payload, idempotency_key=idempotency_key)
|
||||||
logger.info("pdf.enqueue id=%s inquiry=%s", entry_id, inquiry_no or "-")
|
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,
|
"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:
|
except Exception:
|
||||||
logger.exception("PDF 出站入队失败 inquiry=%s", wo)
|
logger.exception("PDF 出站入队失败 inquiry=%s", wo)
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import uuid
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Callable, Optional
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
from agent.config import get_settings
|
|
||||||
from agent.ledger.http_ledger import HttpLedger
|
from agent.ledger.http_ledger import HttpLedger
|
||||||
from agent.ledger.memory_ledger import MemoryLedger
|
from agent.ledger.memory_ledger import MemoryLedger
|
||||||
from agent.llm.extract_text import extract_inquiry_snapshot
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
ReplyFn = Callable[..., None]
|
# 可返回 outbox id;查价前要等询价确认真正发出。
|
||||||
|
ReplyFn = Callable[..., Optional[str]]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -249,18 +249,13 @@ class AirTextInquiryFlow:
|
|||||||
if sess.phase == "tms_miss":
|
if sess.phase == "tms_miss":
|
||||||
self._emit_tms_miss(reply, sess.work_order_no)
|
self._emit_tms_miss(reply, sess.work_order_no)
|
||||||
return "tms_miss"
|
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":
|
if sess.phase == "wait_deal":
|
||||||
summary = (
|
self._emit_deal(reply, sess)
|
||||||
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,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return "wait_deal"
|
return "wait_deal"
|
||||||
if sess.phase == "done":
|
if sess.phase == "done":
|
||||||
reply(copy.deal_ack(sess.status or "已关闭", sess.work_order_no))
|
reply(copy.deal_ack(sess.status or "已关闭", sess.work_order_no))
|
||||||
@@ -426,7 +421,9 @@ class AirTextInquiryFlow:
|
|||||||
reply("建单失败,请稍后把刚才那条再发一次。重量和体积已经记下。")
|
reply("建单失败,请稍后把刚才那条再发一次。重量和体积已经记下。")
|
||||||
return "ledger_fail"
|
return "ledger_fail"
|
||||||
first, history_wo = self._resolve_similar(created, facts=facts, current_no=no)
|
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)
|
tms = self._ledger.query_tms(work_order_no=no, facts=facts)
|
||||||
sess = FlowSession(
|
sess = FlowSession(
|
||||||
@@ -453,6 +450,7 @@ class AirTextInquiryFlow:
|
|||||||
self._emit_tms_miss(reply, no)
|
self._emit_tms_miss(reply, no)
|
||||||
sess.phase = "tms_miss"
|
sess.phase = "tms_miss"
|
||||||
sess.status = "询价中"
|
sess.status = "询价中"
|
||||||
|
sess.allowed = ()
|
||||||
self._save(sess)
|
self._save(sess)
|
||||||
return "tms_miss"
|
return "tms_miss"
|
||||||
|
|
||||||
@@ -487,11 +485,41 @@ class AirTextInquiryFlow:
|
|||||||
)
|
)
|
||||||
return "wait_collab"
|
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:
|
def _emit_tms_miss(self, reply: ReplyFn, work_order_no: str) -> None:
|
||||||
"""
|
"""
|
||||||
TMS 已查过且确认没价:文字兜底 + 暂无报价卡片。
|
TMS 已查过且确认没价:文字兜底 + 暂无报价卡片。
|
||||||
禁止在没打 query_tms 前回这个。
|
禁止在没打 query_tms 前回这个。
|
||||||
"""
|
"""
|
||||||
|
from agent.config import get_settings
|
||||||
|
|
||||||
reply(
|
reply(
|
||||||
copy.tms_miss(work_order_no=work_order_no),
|
copy.tms_miss(work_order_no=work_order_no),
|
||||||
copy.tms_miss_wecom_payload(
|
copy.tms_miss_wecom_payload(
|
||||||
@@ -499,6 +527,8 @@ class AirTextInquiryFlow:
|
|||||||
action_url=(get_settings().public_base_url or "").strip(),
|
action_url=(get_settings().public_base_url or "").strip(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
# 卡片发出后再单独发拉群句,不塞进卡、不当卡片失败兜底。
|
||||||
|
reply(copy.tms_miss_followup(work_order_no=work_order_no))
|
||||||
|
|
||||||
def _resolve_similar(
|
def _resolve_similar(
|
||||||
self,
|
self,
|
||||||
@@ -542,6 +572,15 @@ class AirTextInquiryFlow:
|
|||||||
"""处理已允许的按钮;错版本/未允许/已点过直接丢弃。"""
|
"""处理已允许的按钮;错版本/未允许/已点过直接丢弃。"""
|
||||||
sess = self.session_for_action(sender_id, card_meta)
|
sess = self.session_for_action(sender_id, card_meta)
|
||||||
card_wo = copy.work_order_from_card_meta(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:
|
if not sess or action not in sess.allowed:
|
||||||
logger.info(
|
logger.info(
|
||||||
"card 丢弃 sender=%s action=%s card_wo=%s sess_wo=%s phase=%s",
|
"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:
|
if action == copy.BTN_ADOPT_PDF:
|
||||||
return self._adopt_file(sess, reply, kind="PDF", card_meta=card_meta)
|
return self._adopt_file(sess, reply, kind="PDF", card_meta=card_meta)
|
||||||
if action == copy.BTN_DEAL:
|
if action == copy.BTN_DEAL:
|
||||||
return self._close(sess, reply, "已成交")
|
return self._close(sess, reply, "已成交", card_meta=card_meta)
|
||||||
if action == copy.BTN_LOST:
|
if action == copy.BTN_LOST:
|
||||||
return self._close(sess, reply, "未成交")
|
return self._close(sess, reply, "未成交", card_meta=card_meta)
|
||||||
if action == copy.BTN_NEGOTIATE:
|
if action == copy.BTN_NEGOTIATE:
|
||||||
return self._close(sess, reply, "协商中")
|
return self._close(sess, reply, "协商中", card_meta=card_meta)
|
||||||
return "card_unknown"
|
return "card_unknown"
|
||||||
|
|
||||||
def _skip_collab(
|
def _skip_collab(
|
||||||
@@ -617,14 +656,24 @@ class AirTextInquiryFlow:
|
|||||||
)
|
)
|
||||||
return "wait_adopt"
|
return "wait_adopt"
|
||||||
|
|
||||||
def _grey_clicked_card(self, reply: ReplyFn, card_meta: Optional[dict[str, Any]]) -> None:
|
def _grey_clicked_card(
|
||||||
"""点过方案卡后把原卡按钮改成已提交;无 ResponseCode 则不更新。"""
|
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()
|
code = str((card_meta or {}).get("response_code") or "").strip()
|
||||||
if not code:
|
if not code:
|
||||||
return
|
return
|
||||||
reply(
|
reply(
|
||||||
copy.BTN_SKIP_COLLAB_DONE,
|
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(
|
def _adopt_file(
|
||||||
@@ -670,35 +719,75 @@ class AirTextInquiryFlow:
|
|||||||
work_order_no=sess.work_order_no,
|
work_order_no=sess.work_order_no,
|
||||||
sender_id=sess.sender_id,
|
sender_id=sess.sender_id,
|
||||||
filename=filename if filename.lower().endswith(".pdf") else f"{sess.work_order_no}_quote.pdf",
|
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:
|
if not queued:
|
||||||
fallback = str(result.get("fileName") or f"{sess.work_order_no}_quote.xlsx")
|
fallback = str(result.get("fileName") or f"{sess.work_order_no}_quote.xlsx")
|
||||||
if not fallback.lower().endswith(".xlsx"):
|
if not fallback.lower().endswith(".xlsx"):
|
||||||
fallback = f"{sess.work_order_no}_quote.xlsx"
|
fallback = f"{sess.work_order_no}_quote.xlsx"
|
||||||
reply("PDF 转换排队失败,已按后台模板发送 Excel 报价单。")
|
reply("PDF 转换排队失败,已按后台模板发送 Excel 报价单。")
|
||||||
reply(
|
return self._emit_file_then_deal(
|
||||||
copy.file_ready(kind="Excel", work_order_no=sess.work_order_no, filename=fallback),
|
sess,
|
||||||
file_extra,
|
reply,
|
||||||
|
kind="Excel",
|
||||||
|
filename=fallback,
|
||||||
|
file_extra=file_extra,
|
||||||
)
|
)
|
||||||
# 已入队:Worker 出文件;此处不冒充 soffice 已成功
|
# 已入队:等 Worker 出文件后再出成交卡,这里不提前发卡
|
||||||
else:
|
sess.phase = "wait_file"
|
||||||
reply(
|
sess.allowed = ()
|
||||||
copy.file_ready(kind=kind, work_order_no=sess.work_order_no, filename=filename),
|
sess.wait_version += 1
|
||||||
file_extra,
|
self._save(sess)
|
||||||
)
|
return "wait_file"
|
||||||
summary = f"{sess.facts.get('起运港', '')} → {sess.facts.get('目的港', '')} {sess.facts.get('品名', '')}"
|
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.phase = "wait_deal"
|
||||||
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
|
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
|
||||||
sess.wait_version += 1
|
sess.wait_version += 1
|
||||||
self._save(sess)
|
self._save(sess)
|
||||||
|
self._emit_deal(reply, sess)
|
||||||
|
return "wait_deal"
|
||||||
|
|
||||||
|
def _emit_deal(self, reply: ReplyFn, sess: FlowSession) -> None:
|
||||||
|
"""
|
||||||
|
报价单发出后出成交跟进卡:文字兜底 + 企微模板卡。
|
||||||
|
字段以报价当时的 quote 为准,不编历史报价/工单状态/来源行。
|
||||||
|
"""
|
||||||
reply(
|
reply(
|
||||||
copy.deal_card(
|
copy.deal_card(
|
||||||
work_order_no=sess.work_order_no,
|
work_order_no=sess.work_order_no,
|
||||||
status=sess.status or "已报价",
|
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]:
|
def _quote_file_payload(self, result: dict[str, Any], *, filename: str) -> dict[str, Any]:
|
||||||
"""出站文件载荷:优先本机路径,否则主账回的 Base64。"""
|
"""出站文件载荷:优先本机路径,否则主账回的 Base64。"""
|
||||||
@@ -716,6 +805,8 @@ class AirTextInquiryFlow:
|
|||||||
work_order_no: str,
|
work_order_no: str,
|
||||||
sender_id: str,
|
sender_id: str,
|
||||||
filename: str,
|
filename: str,
|
||||||
|
quote: Optional[dict[str, Any]] = None,
|
||||||
|
facts: Optional[dict[str, str]] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
把主账已填好的 xlsx 交给 Worker LibreOffice 单槽转 PDF。
|
把主账已填好的 xlsx 交给 Worker LibreOffice 单槽转 PDF。
|
||||||
@@ -744,13 +835,23 @@ class AirTextInquiryFlow:
|
|||||||
idempotency_key=f"pdf:{work_order_no}:{uuid.uuid4().hex[:8]}",
|
idempotency_key=f"pdf:{work_order_no}:{uuid.uuid4().hex[:8]}",
|
||||||
touser=sender_id,
|
touser=sender_id,
|
||||||
filename=filename,
|
filename=filename,
|
||||||
|
quote=quote,
|
||||||
|
facts=facts,
|
||||||
)
|
)
|
||||||
return bool(queued.accepted)
|
return bool(queued.accepted)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("报价 PDF 入队失败 wo=%s", work_order_no)
|
logger.exception("报价 PDF 入队失败 wo=%s", work_order_no)
|
||||||
return False
|
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)
|
result = self._ledger.transition(work_order_no=sess.work_order_no, to_status=status)
|
||||||
if not result.get("ok"):
|
if not result.get("ok"):
|
||||||
reply("主账状态更新失败,成交结果未入账。")
|
reply("主账状态更新失败,成交结果未入账。")
|
||||||
@@ -762,6 +863,70 @@ class AirTextInquiryFlow:
|
|||||||
reply(copy.deal_ack(status, sess.work_order_no))
|
reply(copy.deal_ack(status, sess.work_order_no))
|
||||||
return "done"
|
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: Optional[AirTextInquiryFlow] = None
|
||||||
_FLOW_GUARD = threading.Lock()
|
_FLOW_GUARD = threading.Lock()
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ BTN_REJECT_KEY = "reject"
|
|||||||
BTN_DEAL = "成交"
|
BTN_DEAL = "成交"
|
||||||
BTN_LOST = "未成交"
|
BTN_LOST = "未成交"
|
||||||
BTN_NEGOTIATE = "协商中"
|
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)
|
_WO_NO = re.compile(r"WO\d{12}", re.IGNORECASE)
|
||||||
# 仅给无网 stub / 工单号回填用,正式续办意图由 DeepSeek A 判断,不要求销售说这些词。
|
# 仅给无网 stub / 工单号回填用,正式续办意图由 DeepSeek A 判断,不要求销售说这些词。
|
||||||
@@ -245,6 +251,18 @@ def canonical_button(raw: str) -> str:
|
|||||||
return BTN_ADOPT_PDF
|
return BTN_ADOPT_PDF
|
||||||
if text in {BTN_REJECT_KEY} or text.startswith(f"{BTN_REJECT_KEY}:"):
|
if text in {BTN_REJECT_KEY} or text.startswith(f"{BTN_REJECT_KEY}:"):
|
||||||
return BTN_REJECT
|
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:
|
if text == BTN_SKIP_COLLAB_DONE:
|
||||||
return ""
|
return ""
|
||||||
for btn in (BTN_ADOPT_EXCEL, BTN_ADOPT_PDF, BTN_REJECT, BTN_DEAL, BTN_LOST, BTN_NEGOTIATE):
|
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_EXCEL_KEY}:",
|
||||||
f"{BTN_ADOPT_PDF_KEY}:",
|
f"{BTN_ADOPT_PDF_KEY}:",
|
||||||
f"{BTN_REJECT_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):
|
if text.startswith(prefix):
|
||||||
rest = text[len(prefix) :].strip()
|
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:
|
def similar_inquiry_line(*, first_or_same: str, history_work_order_no: str = "") -> str:
|
||||||
"""
|
"""
|
||||||
相同询价内容检索:首次询价,或引用全库最新一条一模一样的历史工单号。
|
相同询价内容检索文案(主账仍会算,销售侧价格卡已隐藏,不再调用)。
|
||||||
"""
|
"""
|
||||||
no = (history_work_order_no or "").strip()
|
no = (history_work_order_no or "").strip()
|
||||||
if first_or_same == "same" and no:
|
if first_or_same == "same" and no:
|
||||||
@@ -455,11 +477,12 @@ def quote_detail_lines(
|
|||||||
status: str = "已报价",
|
status: str = "已报价",
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""
|
"""
|
||||||
价格明细正文:TMS 费用、合计、时效、有效期、相同检索。
|
价格明细正文:TMS 费用、合计、时效、有效期。
|
||||||
|
|
||||||
给文本兜底和企微引用条共用,禁止在这里编时效/有效期。价格卡不再展示工单状态。
|
给文本兜底和企微引用条共用,禁止在这里编时效/有效期。
|
||||||
|
价格卡不展示工单状态,也不展示相同询价内容检索。
|
||||||
"""
|
"""
|
||||||
_ = status
|
_ = status, first_or_same, history_work_order_no
|
||||||
src = dict(quote or {})
|
src = dict(quote or {})
|
||||||
header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip()
|
header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip()
|
||||||
total = money_text(str(src.get("total") or "-").strip() or "-", header_ccy)
|
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"总销售价合计:{total}")
|
||||||
lines.append(f"时效:{tms_eta_display(src)}")
|
lines.append(f"时效:{tms_eta_display(src)}")
|
||||||
lines.append(f"报价有效期:{tms_valid_until_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
|
return lines
|
||||||
|
|
||||||
|
|
||||||
@@ -518,22 +536,21 @@ def tms_hit_wecom_payload(
|
|||||||
|
|
||||||
价格明细
|
价格明细
|
||||||
┌ 引用条:空运费 / 其它费用 / 总销售价合计
|
┌ 引用条:空运费 / 其它费用 / 总销售价合计
|
||||||
时效、报价有效期、相同询价内容检索
|
时效、报价有效期
|
||||||
协同说明(普通文字,贴按钮上方)
|
协同说明(普通文字,贴按钮上方)
|
||||||
按钮
|
按钮
|
||||||
|
|
||||||
|
相同询价内容检索对销售隐藏;first_or_same 只兼容调用方,不展示。
|
||||||
企微 quote_area.title 只能很短,超长会被整段藏掉。报价必须放 quote_text。
|
企微 quote_area.title 只能很短,超长会被整段藏掉。报价必须放 quote_text。
|
||||||
不用 horizontal:它会排到副标题后面,把费用挤到按钮前。
|
不用 horizontal:它会排到副标题后面,把费用挤到按钮前。
|
||||||
"""
|
"""
|
||||||
|
_ = first_or_same, history_work_order_no
|
||||||
src = dict(quote or {})
|
src = dict(quote or {})
|
||||||
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
|
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
|
||||||
meta = "\n".join(
|
meta = "\n".join(
|
||||||
[
|
[
|
||||||
f"时效:{tms_eta_display(src)}",
|
f"时效:{tms_eta_display(src)}",
|
||||||
f"报价有效期:{tms_valid_until_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),
|
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;无码则不要入出站。
|
必须带企微回调 ResponseCode;无码则不要入出站。
|
||||||
|
成交卡三个按钮共用这一条,replace_name 默认「已提交」。
|
||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
"msgtype": "update_template_card",
|
"msgtype": "update_template_card",
|
||||||
"response_code": response_code,
|
"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:
|
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:
|
def tms_miss_hint() -> str:
|
||||||
"""卡片正文:无价说明 + 拉群提示,不单独再写标题下灰字。"""
|
"""卡片标题下灰字:与现网图一致。"""
|
||||||
return "TMS标准价库暂无匹配报价,请手动拉新人与智能体到原有群"
|
return "TMS 标准价库暂无匹配报价"
|
||||||
|
|
||||||
|
|
||||||
|
def tms_miss_body() -> str:
|
||||||
|
"""卡片中间说明:需求已建单。"""
|
||||||
|
return "需求已解析并创建工单。"
|
||||||
|
|
||||||
|
|
||||||
def tms_miss(*, work_order_no: str) -> str:
|
def tms_miss(*, work_order_no: str) -> str:
|
||||||
"""
|
"""
|
||||||
TMS 已经查过、确认没标准价时的文字兜底。
|
卡片发送失败时的短兜底。拉群句另发,不写在这里。
|
||||||
禁止在没打 TMS 前用本句冒充无价。
|
禁止在没打 TMS 前用本句冒充无价。
|
||||||
"""
|
"""
|
||||||
return (
|
no = (work_order_no or "").strip()
|
||||||
f"TMS暂无报价。{tms_miss_hint()},"
|
return f"询价工单{no}已建立,TMS标准价库暂无匹配报价。"
|
||||||
f"{tms_miss_quote_text(work_order_no=work_order_no)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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: dict[str, Any] = {
|
||||||
"card_type": "text_notice",
|
"card_type": "text_notice",
|
||||||
"source": {"desc": "TMS查价结果", "desc_color": 0},
|
"source": {"desc": "TMS查价结果", "desc_color": 0},
|
||||||
"main_title": {"title": f"{work_order_no}暂无报价"},
|
"main_title": {
|
||||||
"quote_area": {
|
"title": f"询价工单 {no}",
|
||||||
"type": 0,
|
"desc": tms_miss_hint(),
|
||||||
"quote_text": tms_miss_quote_text(work_order_no=work_order_no),
|
|
||||||
},
|
},
|
||||||
"sub_title_text": tms_miss_hint(),
|
"sub_title_text": tms_miss_body(),
|
||||||
"task_id": card_task_id("miss", work_order_no),
|
"horizontal_content_list": [
|
||||||
|
{"keyname": "工单状态", "value": "询价中"},
|
||||||
|
{"keyname": "查价结果", "value": "暂无匹配报价"},
|
||||||
|
],
|
||||||
|
"task_id": card_task_id("miss", no),
|
||||||
}
|
}
|
||||||
url = (action_url or "").strip()
|
url = (action_url or "").strip()
|
||||||
if url:
|
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})"
|
return f"已生成{kind}最终报价单:{filename}(工单 {work_order_no})"
|
||||||
|
|
||||||
|
|
||||||
def deal_card(*, work_order_no: str, status: str, summary: str) -> str:
|
DEAL_HINT = "咨询成交状态:请选择成交 / 未成交 / 协商中(未成交需补充原因)。"
|
||||||
return (
|
DEAL_SUBTITLE = "报价单已发送,请跟进成交状态"
|
||||||
"【咨询成交状态】\n"
|
|
||||||
f"工单编号:{work_order_no}\n"
|
|
||||||
f"工单状态:{status}\n"
|
def deal_horizontal_rows(
|
||||||
f"询价内容:{summary}\n"
|
*,
|
||||||
"请立即确认当前与客户沟通的成交状态:\n"
|
quote: dict[str, Any] | None,
|
||||||
f"【{BTN_DEAL}】【{BTN_LOST}】【{BTN_NEGOTIATE}】"
|
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:
|
def deal_ack(outcome: str, work_order_no: str) -> str:
|
||||||
|
|||||||
@@ -44,3 +44,7 @@ STREAM_WAKE = "stream:wake"
|
|||||||
IDEMPOTENCY = "idem"
|
IDEMPOTENCY = "idem"
|
||||||
RATE_LIMIT = "ratelimit"
|
RATE_LIMIT = "ratelimit"
|
||||||
TOKEN_WECOM = "token:wecom"
|
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
|
||||||
@@ -238,7 +238,8 @@ class AirTextInquiryTests(unittest.TestCase):
|
|||||||
self.assertIn("总销售价合计", joined)
|
self.assertIn("总销售价合计", joined)
|
||||||
self.assertIn("时效:", joined)
|
self.assertIn("时效:", joined)
|
||||||
self.assertIn("报价有效期:", joined)
|
self.assertIn("报价有效期:", joined)
|
||||||
self.assertIn("相同询价内容检索:首次询价", joined)
|
self.assertNotIn("相同询价内容检索", joined)
|
||||||
|
self.assertNotIn("引用历史工单号", 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.assertEqual(phase, "wait_deal")
|
||||||
self.assertIn("Excel", "\n".join(self.replies))
|
self.assertIn("Excel", "\n".join(self.replies))
|
||||||
self.assertIn("咨询成交状态", "\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")
|
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(
|
ticket = self.flow.ledger.get_ticket(
|
||||||
work_order_no=self.flow.session_of("u1").work_order_no
|
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.assertEqual(called[0]["facts"].get("起运港"), "阿拉伯")
|
||||||
self.assertFalse(called[0]["facts"].get("originCode"))
|
self.assertFalse(called[0]["facts"].get("originCode"))
|
||||||
self.assertEqual(phase, "tms_miss")
|
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))
|
self.assertNotIn("查价参数尚未齐全", "\n".join(self.replies))
|
||||||
miss = [x for x in self.extras if x.get("msgtype") == "template_card"]
|
miss = [x for x in self.extras if x.get("msgtype") == "template_card"]
|
||||||
self.assertTrue(miss)
|
self.assertTrue(miss)
|
||||||
self.assertEqual(miss[-1]["template_card"]["main_title"]["title"], "WO202609140088暂无报价")
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
miss[-1]["template_card"]["quote_area"]["quote_text"],
|
miss[-1]["template_card"]["main_title"]["title"],
|
||||||
"请务必在群聊发送工单编号WO202609140088",
|
"询价工单 WO202609140088",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_unknown_airport_no_card(self) -> None:
|
def test_unknown_airport_no_card(self) -> None:
|
||||||
@@ -389,7 +425,8 @@ class AirTextInquiryTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
# 目的地说了就算齐;对不上字典不补问,查价后按 TMS 分类
|
# 目的地说了就算齐;对不上字典不补问,查价后按 TMS 分类
|
||||||
self.assertEqual(phase, "tms_miss")
|
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:
|
def test_tms_miss_copy(self) -> None:
|
||||||
facts = dict(COMPLETE_AIR)
|
facts = dict(COMPLETE_AIR)
|
||||||
@@ -402,21 +439,90 @@ class AirTextInquiryTests(unittest.TestCase):
|
|||||||
injected_mode="AIR",
|
injected_mode="AIR",
|
||||||
)
|
)
|
||||||
self.assertEqual(phase, "tms_miss")
|
self.assertEqual(phase, "tms_miss")
|
||||||
self.assertIn("暂无报价", self.replies[-1])
|
wo = self.flow.session_of("u2").work_order_no
|
||||||
self.assertIn("工单编号", self.replies[-1])
|
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"]
|
miss = [x for x in self.extras if x.get("msgtype") == "template_card"]
|
||||||
self.assertTrue(miss)
|
self.assertTrue(miss)
|
||||||
card = miss[-1]["template_card"]
|
card = miss[-1]["template_card"]
|
||||||
self.assertEqual(card["card_type"], "text_notice")
|
self.assertEqual(card["card_type"], "text_notice")
|
||||||
self.assertEqual(card["source"]["desc"], "TMS查价结果")
|
self.assertEqual(card["source"]["desc"], "TMS查价结果")
|
||||||
self.assertEqual(card["main_title"]["title"], self.flow.session_of("u2").work_order_no + "暂无报价")
|
self.assertEqual(card["main_title"]["title"], f"询价工单 {wo}")
|
||||||
self.assertNotIn("desc", card["main_title"])
|
self.assertEqual(card["main_title"]["desc"], copy.tms_miss_hint())
|
||||||
self.assertEqual(card["sub_title_text"], copy.tms_miss_hint())
|
self.assertEqual(card["sub_title_text"], copy.tms_miss_body())
|
||||||
self.assertEqual(
|
rows = {x["keyname"]: x["value"] for x in card["horizontal_content_list"]}
|
||||||
card["quote_area"]["quote_text"],
|
self.assertEqual(rows["工单状态"], "询价中")
|
||||||
copy.tms_miss_quote_text(work_order_no=self.flow.session_of("u2").work_order_no),
|
self.assertEqual(rows["查价结果"], "暂无匹配报价")
|
||||||
)
|
self.assertNotIn("来源", rows)
|
||||||
self.assertNotIn("horizontal_content_list", card)
|
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:
|
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
|
no2 = self.flow.session_of("u4").work_order_no
|
||||||
self.assertIn("工单已创建,正在查询TMS报价", "\n".join(self.replies))
|
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(no1)
|
||||||
self.assertTrue(no2)
|
self.assertTrue(no2)
|
||||||
self.assertNotEqual(no1, no2)
|
self.assertNotEqual(no1, no2)
|
||||||
@@ -562,18 +670,31 @@ class AirTextInquiryTests(unittest.TestCase):
|
|||||||
card = payload["template_card"]
|
card = payload["template_card"]
|
||||||
self.assertEqual(card["card_type"], "text_notice")
|
self.assertEqual(card["card_type"], "text_notice")
|
||||||
self.assertEqual(card["source"]["desc"], "TMS查价结果")
|
self.assertEqual(card["source"]["desc"], "TMS查价结果")
|
||||||
self.assertEqual(card["main_title"]["title"], "WO202606160008暂无报价")
|
self.assertEqual(card["main_title"]["title"], "询价工单 WO202606160008")
|
||||||
self.assertNotIn("desc", card["main_title"])
|
self.assertEqual(card["main_title"]["desc"], "TMS 标准价库暂无匹配报价")
|
||||||
self.assertEqual(card["sub_title_text"], "TMS标准价库暂无匹配报价,请手动拉新人与智能体到原有群")
|
self.assertEqual(card["sub_title_text"], "需求已解析并创建工单。")
|
||||||
self.assertEqual(card["quote_area"]["quote_text"], "请务必在群聊发送工单编号WO202606160008")
|
rows = {x["keyname"]: x["value"] for x in card["horizontal_content_list"]}
|
||||||
self.assertNotIn("title", card["quote_area"])
|
self.assertEqual(rows, {
|
||||||
self.assertNotIn("horizontal_content_list", card)
|
"工单状态": "询价中",
|
||||||
|
"查价结果": "暂无匹配报价",
|
||||||
|
})
|
||||||
|
self.assertNotIn("来源", rows)
|
||||||
|
self.assertNotIn("button_list", card)
|
||||||
|
self.assertNotIn("quote_area", card)
|
||||||
self.assertNotIn("card_action", card)
|
self.assertNotIn("card_action", card)
|
||||||
with_url = copy.tms_miss_wecom_payload(
|
with_url = copy.tms_miss_wecom_payload(
|
||||||
work_order_no="WO202606160008",
|
work_order_no="WO202606160008",
|
||||||
action_url="https://example.test",
|
action_url="https://example.test",
|
||||||
)["template_card"]
|
)["template_card"]
|
||||||
self.assertEqual(with_url["card_action"]["url"], "https://example.test")
|
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:
|
def test_quote_card_payload_has_clickable_button(self) -> None:
|
||||||
payload = copy.tms_hit_wecom_payload(
|
payload = copy.tms_hit_wecom_payload(
|
||||||
@@ -623,7 +744,8 @@ class AirTextInquiryTests(unittest.TestCase):
|
|||||||
sub = card["sub_title_text"]
|
sub = card["sub_title_text"]
|
||||||
self.assertIn("时效:3-5 天", sub)
|
self.assertIn("时效:3-5 天", sub)
|
||||||
self.assertIn("报价有效期:2026-09-20", sub)
|
self.assertIn("报价有效期:2026-09-20", sub)
|
||||||
self.assertIn("相同询价内容检索:首次询价", sub)
|
self.assertNotIn("相同询价内容检索", sub)
|
||||||
|
self.assertNotIn("引用历史工单号", sub)
|
||||||
self.assertNotIn("工单状态", sub)
|
self.assertNotIn("工单状态", sub)
|
||||||
self.assertTrue(sub.endswith(copy.collab_hint("WO202609140001")))
|
self.assertTrue(sub.endswith(copy.collab_hint("WO202609140001")))
|
||||||
self.assertNotIn("内部协同", json.dumps(card, ensure_ascii=False))
|
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("adopt_excel:WO202609140001"), copy.BTN_ADOPT_EXCEL)
|
||||||
self.assertEqual(copy.canonical_button(copy.BTN_ADOPT_EXCEL_CARD), 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:
|
def test_same_demand_cites_latest_even_if_closed(self) -> None:
|
||||||
ledger = MemoryLedger()
|
ledger = MemoryLedger()
|
||||||
flow = AirTextInquiryFlow(ledger)
|
flow = AirTextInquiryFlow(ledger)
|
||||||
@@ -745,7 +928,9 @@ class AirTextInquiryTests(unittest.TestCase):
|
|||||||
wo2 = flow.session_of("hist-b").work_order_no
|
wo2 = flow.session_of("hist-b").work_order_no
|
||||||
self.assertTrue(wo2)
|
self.assertTrue(wo2)
|
||||||
self.assertNotEqual(wo2, wo1)
|
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] = []
|
replies_c: list[str] = []
|
||||||
|
|
||||||
def _keep_c(text: str, extra=None) -> None:
|
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
|
wo3 = flow.session_of("hist-c").work_order_no
|
||||||
self.assertNotEqual(wo3, wo2)
|
self.assertNotEqual(wo3, wo2)
|
||||||
self.assertIn(f"引用历史工单号{wo2}", "\n".join(replies_c))
|
self.assertEqual(flow.session_of("hist-c").history_work_order_no, wo2)
|
||||||
self.assertNotIn(f"引用历史工单号{wo1}", "\n".join(replies_c))
|
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:
|
def test_card_event_key_matches_skip_button(self) -> None:
|
||||||
from agent.channel.wecom.parse import parse_plain_xml
|
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("matchType"), "default")
|
||||||
self.assertEqual(ledger.last_render.get("templateName"), "默认空运")
|
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__":
|
def _render(**kwargs):
|
||||||
unittest.main()
|
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()
|
||||||
+32
-1
@@ -114,7 +114,25 @@ public class InquiryStaffRoleController extends JeecgController<InquiryStaffRole
|
|||||||
}
|
}
|
||||||
normalize(body);
|
normalize(body);
|
||||||
body.setUpdateTime(new Date());
|
body.setUpdateTime(new Date());
|
||||||
staffRoleService.updateById(body);
|
// 销售岗位需把 is_leader 写成 NULL;updateById 默认跳过 null,故用 UpdateWrapper
|
||||||
|
if ("sales".equals(body.getRoleCode())) {
|
||||||
|
staffRoleService.lambdaUpdate()
|
||||||
|
.eq(InquiryStaffRole::getId, body.getId())
|
||||||
|
.set(InquiryStaffRole::getName, body.getName())
|
||||||
|
.set(InquiryStaffRole::getWecomId, body.getWecomId())
|
||||||
|
.set(InquiryStaffRole::getMobile, body.getMobile())
|
||||||
|
.set(InquiryStaffRole::getDepartName, body.getDepartName())
|
||||||
|
.set(InquiryStaffRole::getRoleCode, body.getRoleCode())
|
||||||
|
.set(InquiryStaffRole::getRoleLabel, body.getRoleLabel())
|
||||||
|
.set(InquiryStaffRole::getRoutes, "")
|
||||||
|
.set(InquiryStaffRole::getIsLeader, null)
|
||||||
|
.set(InquiryStaffRole::getNotifyEventException, body.getNotifyEventException())
|
||||||
|
.set(InquiryStaffRole::getStatus, body.getStatus())
|
||||||
|
.set(InquiryStaffRole::getUpdateTime, body.getUpdateTime())
|
||||||
|
.update();
|
||||||
|
} else {
|
||||||
|
staffRoleService.updateById(body);
|
||||||
|
}
|
||||||
return Result.OK("更新成功");
|
return Result.OK("更新成功");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,5 +232,18 @@ public class InquiryStaffRoleController extends JeecgController<InquiryStaffRole
|
|||||||
if (body.getNotifyEventException() == null) {
|
if (body.getNotifyEventException() == null) {
|
||||||
body.setNotifyEventException(0);
|
body.setNotifyEventException(0);
|
||||||
}
|
}
|
||||||
|
// 销售无负责人/线路;产品与航线须维护(与旧项目 admin-staff 一致)
|
||||||
|
String role = body.getRoleCode();
|
||||||
|
if ("sales".equals(role)) {
|
||||||
|
body.setIsLeader(null);
|
||||||
|
body.setRoutes("");
|
||||||
|
} else if ("air".equals(role) || "sea".equals(role) || "land".equals(role)) {
|
||||||
|
if (body.getIsLeader() == null) {
|
||||||
|
throw new IllegalArgumentException("请选择是否负责人");
|
||||||
|
}
|
||||||
|
if (StringUtils.isBlank(body.getRoutes())) {
|
||||||
|
throw new IllegalArgumentException("请选择负责线路");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -50,9 +50,18 @@ public class InquiryStaffRole implements Serializable {
|
|||||||
@ApiModelProperty("角色展示名")
|
@ApiModelProperty("角色展示名")
|
||||||
private String roleLabel;
|
private String roleLabel;
|
||||||
|
|
||||||
@ApiModelProperty("负责线路")
|
@ApiModelProperty("负责线路(逗号分隔;销售可空)")
|
||||||
private String routes;
|
private String routes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否线路/产品负责人。
|
||||||
|
* <p>
|
||||||
|
* 仅航线·空运 / 产品·海运 / 产品·陆运有意义:1=是、0=否;销售岗位为 null。
|
||||||
|
* 成交/未成交等通知可按线路找负责人(后台维护,智能体后续读取)。
|
||||||
|
*/
|
||||||
|
@ApiModelProperty("是否负责人 1是0否,销售为空")
|
||||||
|
private Integer isLeader;
|
||||||
|
|
||||||
@ApiModelProperty("是否接收事件异常提醒 0/1")
|
@ApiModelProperty("是否接收事件异常提醒 0/1")
|
||||||
private Integer notifyEventException;
|
private Integer notifyEventException;
|
||||||
|
|
||||||
|
|||||||
+154
-8
@@ -191,6 +191,11 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
writeFeeRows(wb, mapping, facts, yellow);
|
writeFeeRows(wb, mapping, facts, yellow);
|
||||||
// 后台常未配 fee_rows:按「空运费」等名称写入金额列,SUM 才能加算
|
// 后台常未配 fee_rows:按「空运费」等名称写入金额列,SUM 才能加算
|
||||||
writeFeeAmountsByName(wb, facts, yellow);
|
writeFeeAmountsByName(wb, facts, yellow);
|
||||||
|
// 模板「预估总价」是 SUM。POI 写完数字不会重算,缓存仍是 0。
|
||||||
|
// 企微/WPS 预览常不重算,销售会看到汇总为 0。这里把公式缓存刷成当前值。
|
||||||
|
refreshFormulas(wb);
|
||||||
|
// 转 PDF 前把纸张改成 A4 居中,避免顶部裁字、整页偏左。
|
||||||
|
applyQuotePrintLayout(wb);
|
||||||
Files.createDirectories(targetXlsx.getParent());
|
Files.createDirectories(targetXlsx.getParent());
|
||||||
try (OutputStream out = Files.newOutputStream(targetXlsx)) {
|
try (OutputStream out = Files.newOutputStream(targetXlsx)) {
|
||||||
wb.write(out);
|
wb.write(out);
|
||||||
@@ -252,7 +257,62 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
if (cell == null) {
|
if (cell == null) {
|
||||||
cell = row.createCell(col);
|
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) {
|
private static void clearCell(Workbook wb, String cellRef) {
|
||||||
@@ -493,6 +553,23 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
return fuzzy;
|
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) {
|
private static String readCellText(Cell cell) {
|
||||||
if (cell == null) {
|
if (cell == null) {
|
||||||
return "";
|
return "";
|
||||||
@@ -585,6 +662,10 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
String text = value == null ? "" : value;
|
String text = value == null ? "" : value;
|
||||||
// 标签格「报价编号:」只换冒号后的值,避免整格被工单号顶掉看起来像残缺
|
// 标签格「报价编号:」只换冒号后的值,避免整格被工单号顶掉看起来像残缺
|
||||||
String existing = readCellText(cell);
|
String existing = readCellText(cell);
|
||||||
|
// 「币别」右侧常被误映到「单价」表头,不能把 CNY 盖掉单价二字
|
||||||
|
if (isFeeTableHeader(existing)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
int colon = Math.max(existing.indexOf(':'), existing.indexOf(':'));
|
int colon = Math.max(existing.indexOf(':'), existing.indexOf(':'));
|
||||||
if (colon >= 0 && colon == existing.length() - 1) {
|
if (colon >= 0 && colon == existing.length() - 1) {
|
||||||
text = existing + text;
|
text = existing + text;
|
||||||
@@ -599,13 +680,78 @@ public final class QuoteTemplateExcelSupport {
|
|||||||
copy.setDataFormat(wb.createDataFormat().getFormat("@"));
|
copy.setDataFormat(wb.createDataFormat().getFormat("@"));
|
||||||
cell.setCellStyle(copy);
|
cell.setCellStyle(copy);
|
||||||
}
|
}
|
||||||
// 工单号、日期略加宽,避免列太窄只露出半截
|
// 不再临时加宽列:会把打印缩放打乱,转 PDF 后整页偏左。
|
||||||
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;
|
* 报价单打印版心:A4、水平居中、按页宽缩放。
|
||||||
if (width > 0 && width < text.length() + 2) {
|
* <p>
|
||||||
sh.setColumnWidth(col, (int) Math.min(40, text.length() + 4) * 256);
|
* 空运模板常是 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+91
@@ -2,6 +2,8 @@ package org.jeecg.modules.inquiry.template;
|
|||||||
|
|
||||||
import com.alibaba.fastjson.JSONArray;
|
import com.alibaba.fastjson.JSONArray;
|
||||||
import com.alibaba.fastjson.JSONObject;
|
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.Sheet;
|
||||||
import org.apache.poi.ss.usermodel.Workbook;
|
import org.apache.poi.ss.usermodel.Workbook;
|
||||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||||
@@ -12,6 +14,8 @@ import java.nio.file.Path;
|
|||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
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.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
@@ -153,4 +157,91 @@ public class QuoteTemplateFillTest {
|
|||||||
Files.deleteIfExists(out);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* 机器人回复语分类元数据(版式用,非演示业务数据)。
|
* 机器人回复语分类元数据(版式用)。
|
||||||
* 条目内容一律来自主账 /inquiry/botReply。
|
* 标题/说明与原型 admin-bot-replies.html 一致;条目来自主账。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type ReplyItem = {
|
export type ReplyItem = {
|
||||||
@@ -53,25 +53,25 @@ export const REPLY_CATEGORY_META: Omit<ReplyCategory, "items">[] = [
|
|||||||
{
|
{
|
||||||
id: "closed",
|
id: "closed",
|
||||||
title: "已关闭",
|
title: "已关闭",
|
||||||
desc: "工单因超时、重复、误建等原因关闭。",
|
desc: "工单长期无有效推进,由系统自动关闭或人工手动关闭,不等同于客户明确拒绝。",
|
||||||
tagClass: "tag-rose",
|
tagClass: "tag-rose",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "event-ex",
|
id: "event-ex",
|
||||||
title: "事件异常",
|
title: "事件异常",
|
||||||
desc: "销售/产品/航线响应超时等业务事件异常提示。",
|
desc: "业务流程中参与方响应超时等业务侧异常,写入工单「事件异常」字段,需人工跟进。",
|
||||||
tagClass: "tag-amber",
|
tagClass: "tag-amber",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "sys-ex",
|
id: "sys-ex",
|
||||||
title: "系统异常",
|
title: "系统异常",
|
||||||
desc: "大模型、TMS、建群等系统失败话术。",
|
desc: "大模型、TMS、企微建群等系统侧调用失败,写入工单「系统异常」字段。",
|
||||||
tagClass: "tag-rose",
|
tagClass: "tag-rose",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "transfer",
|
id: "transfer",
|
||||||
title: "转人工",
|
title: "转人工",
|
||||||
desc: "关键词/无价/解析失败等场景转人工话术。",
|
desc: "AI 无法独立完成询价时,引导销售确认是否拉内部航线/产品进群协同。",
|
||||||
tagClass: "tag-violet",
|
tagClass: "tag-violet",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -21,16 +21,13 @@ const COST_PLOT_TOP = 36;
|
|||||||
const COST_PLOT_BOTTOM = 186;
|
const COST_PLOT_BOTTOM = 186;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const isoDay = now.toISOString().slice(0, 10);
|
/** 用本地日期,避免 toISOString 因时区偏到前一天 */
|
||||||
const dateDisplay = ref(`${isoDay}——${isoDay}`);
|
const pad2 = (n: number) => String(n).padStart(2, "0");
|
||||||
const dateFrom = ref(isoDay);
|
const isoDay = `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`;
|
||||||
|
/** 默认:当月 1 号 → 今天(不是当天到当天) */
|
||||||
|
const isoMonthStart = `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-01`;
|
||||||
|
const dateFrom = ref(isoMonthStart);
|
||||||
const dateTo = ref(isoDay);
|
const dateTo = ref(isoDay);
|
||||||
const dateFromMonth = ref(isoDay.slice(0, 7));
|
|
||||||
const dateToMonth = ref(isoDay.slice(0, 7));
|
|
||||||
const dateFromYear = ref(isoDay.slice(0, 4));
|
|
||||||
const dateToYear = ref(isoDay.slice(0, 4));
|
|
||||||
const pickerOpen = ref(false);
|
|
||||||
const pickerMode = ref<"day" | "month" | "year">("day");
|
|
||||||
const updatedTime = ref("00:00");
|
const updatedTime = ref("00:00");
|
||||||
const routeFilterOpen = ref(false);
|
const routeFilterOpen = ref(false);
|
||||||
const selectedRoutes = ref<string[]>([]);
|
const selectedRoutes = ref<string[]>([]);
|
||||||
@@ -60,18 +57,34 @@ const INQUIRY_PLOT_TOP = 36;
|
|||||||
const INQUIRY_PLOT_BOTTOM = 188;
|
const INQUIRY_PLOT_BOTTOM = 188;
|
||||||
|
|
||||||
function resolveRange(): string {
|
function resolveRange(): string {
|
||||||
if (pickerMode.value === "day") {
|
const from = new Date(dateFrom.value);
|
||||||
const from = new Date(dateFrom.value);
|
const to = new Date(dateTo.value);
|
||||||
const to = new Date(dateTo.value);
|
const days = Math.max(0, Math.round((to.getTime() - from.getTime()) / 86400000));
|
||||||
const days = Math.max(0, Math.round((to.getTime() - from.getTime()) / 86400000));
|
if (days <= 0) return "today";
|
||||||
if (days <= 0) return "today";
|
if (days <= 6) return "7d";
|
||||||
if (days <= 6) return "7d";
|
|
||||||
return "30d";
|
|
||||||
}
|
|
||||||
if (pickerMode.value === "month") return "30d";
|
|
||||||
return "30d";
|
return "30d";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 顶栏日期:与工单列表同一套 a-range-picker(双月历点选起止)。
|
||||||
|
* 不再并排放「按日/月/年」切换,避免顶栏挤换行显得乱。
|
||||||
|
*/
|
||||||
|
const dateRange = computed({
|
||||||
|
get(): [string, string] | undefined {
|
||||||
|
if (!dateFrom.value && !dateTo.value) return undefined;
|
||||||
|
return [dateFrom.value || "", dateTo.value || ""];
|
||||||
|
},
|
||||||
|
set(v: [string, string] | null | undefined) {
|
||||||
|
dateFrom.value = v?.[0] ? String(v[0]) : isoMonthStart;
|
||||||
|
dateTo.value = v?.[1] ? String(v[1]) : isoDay;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 选完起止日后立刻刷新看板 */
|
||||||
|
function onDateRangeChange() {
|
||||||
|
loadMetrics();
|
||||||
|
}
|
||||||
|
|
||||||
async function loadMetrics() {
|
async function loadMetrics() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -240,14 +253,6 @@ const costXs = computed(() => {
|
|||||||
return costDates.value.map((_, i) => (n === 1 ? 380 : 36 + (654 / (n - 1)) * i));
|
return costDates.value.map((_, i) => (n === 1 ? 380 : 36 + (654 / (n - 1)) * i));
|
||||||
});
|
});
|
||||||
|
|
||||||
function applyDateRange() {
|
|
||||||
if (pickerMode.value === "day") dateDisplay.value = `${dateFrom.value}——${dateTo.value}`;
|
|
||||||
else if (pickerMode.value === "month") dateDisplay.value = `${dateFromMonth.value}——${dateToMonth.value}`;
|
|
||||||
else dateDisplay.value = `${dateFromYear.value}——${dateToYear.value}`;
|
|
||||||
pickerOpen.value = false;
|
|
||||||
loadMetrics();
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshData() {
|
function refreshData() {
|
||||||
loadMetrics().then(() => message.success("数据已更新"));
|
loadMetrics().then(() => message.success("数据已更新"));
|
||||||
}
|
}
|
||||||
@@ -334,7 +339,6 @@ function exportRanking() {
|
|||||||
|
|
||||||
function onDocClick(e: MouseEvent) {
|
function onDocClick(e: MouseEvent) {
|
||||||
const t = e.target as HTMLElement;
|
const t = e.target as HTMLElement;
|
||||||
if (!t.closest(".month-range-picker")) pickerOpen.value = false;
|
|
||||||
if (!t.closest(".ref-route-filter")) routeFilterOpen.value = false;
|
if (!t.closest(".ref-route-filter")) routeFilterOpen.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,33 +357,21 @@ onUnmounted(() => document.removeEventListener("click", onDocClick));
|
|||||||
<!-- defer:等布局挂好 #page-topbar-actions 再传送,避免顶栏工具丢失 -->
|
<!-- defer:等布局挂好 #page-topbar-actions 再传送,避免顶栏工具丢失 -->
|
||||||
<Teleport defer to="#page-topbar-actions">
|
<Teleport defer to="#page-topbar-actions">
|
||||||
<div class="dash-topbar-tools">
|
<div class="dash-topbar-tools">
|
||||||
<div class="month-range-picker" @click.stop>
|
<div class="toolbar-date-range dash-toolbar-date" title="选择统计起止日期">
|
||||||
<div class="month-range-input" @click="pickerOpen = !pickerOpen">
|
<a-range-picker
|
||||||
<span>{{ dateDisplay }}</span>
|
v-model:value="dateRange"
|
||||||
<ProtoIcon name="calendar" :size="14" style="color: #94a3b8" />
|
class="toolbar-range-picker dash-range-picker"
|
||||||
</div>
|
value-format="YYYY-MM-DD"
|
||||||
<div v-show="pickerOpen" class="month-range-dropdown dash-picker-dropdown">
|
separator="至"
|
||||||
<div class="month-range-tabs">
|
:placeholder="['开始日期', '结束日期']"
|
||||||
<button type="button" class="month-range-tab" :class="{ 'is-active': pickerMode === 'day' }" @click="pickerMode = 'day'">按日</button>
|
:allow-clear="false"
|
||||||
<button type="button" class="month-range-tab" :class="{ 'is-active': pickerMode === 'month' }" @click="pickerMode = 'month'">按月</button>
|
:input-read-only="true"
|
||||||
<button type="button" class="month-range-tab" :class="{ 'is-active': pickerMode === 'year' }" @click="pickerMode = 'year'">按年</button>
|
@change="onDateRangeChange"
|
||||||
</div>
|
>
|
||||||
<div class="dash-picker-body">
|
<template #suffixIcon>
|
||||||
<template v-if="pickerMode === 'day'">
|
<ProtoIcon name="calendar" :size="14" />
|
||||||
<input v-model="dateFrom" type="date" class="dash-picker-input" />
|
</template>
|
||||||
<input v-model="dateTo" type="date" class="dash-picker-input" />
|
</a-range-picker>
|
||||||
</template>
|
|
||||||
<template v-else-if="pickerMode === 'month'">
|
|
||||||
<input v-model="dateFromMonth" type="month" class="dash-picker-input" />
|
|
||||||
<input v-model="dateToMonth" type="month" class="dash-picker-input" />
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<input v-model="dateFromYear" type="number" min="2020" max="2035" class="dash-picker-input" placeholder="起始年" />
|
|
||||||
<input v-model="dateToYear" type="number" min="2020" max="2035" class="dash-picker-input" placeholder="结束年" />
|
|
||||||
</template>
|
|
||||||
<button type="button" class="btn btn-primary" style="width: 100%; min-height: 32px; height: 32px" @click="applyDateRange">确定</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard-data-note" aria-live="polite">
|
<div class="dashboard-data-note" aria-live="polite">
|
||||||
<span class="dashboard-data-note__icon"><ProtoIcon name="clock" :size="13" /></span>
|
<span class="dashboard-data-note__icon"><ProtoIcon name="clock" :size="13" /></span>
|
||||||
@@ -634,38 +626,30 @@ onUnmounted(() => document.removeEventListener("click", onDocClick));
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.dash-topbar-tools {
|
.dash-topbar-tools {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-picker-dropdown {
|
.dash-toolbar-date {
|
||||||
position: absolute;
|
height: 34px;
|
||||||
right: 0;
|
flex-shrink: 0;
|
||||||
top: calc(100% + 8px);
|
|
||||||
width: 300px;
|
|
||||||
z-index: 80;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-picker-body {
|
.dash-toolbar-date :deep(.ant-picker) {
|
||||||
display: grid;
|
height: 34px !important;
|
||||||
gap: 8px;
|
min-height: 34px !important;
|
||||||
padding-top: 4px;
|
width: 260px !important;
|
||||||
}
|
|
||||||
|
|
||||||
.dash-picker-input {
|
|
||||||
width: 100%;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border: 1px solid #dfe5ee;
|
|
||||||
border-radius: 7px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-refresh-btn {
|
.dash-refresh-btn {
|
||||||
height: 34px;
|
height: 34px;
|
||||||
min-height: 34px;
|
min-height: 34px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-data-note {
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-chart-title {
|
.dash-chart-title {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* 机器人回复语:九分类对齐原型 admin-bot-replies.html(条目精简但仍覆盖各类)。
|
* 机器人回复语:完整照搬原型 admin-bot-replies.html(V1.0.24)。
|
||||||
|
* 共 9 类 37 条;与 deploy/sql/inquiry_bot_reply_proto_seed_v1.sql 同源。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type ReplyItem = {
|
export type ReplyItem = {
|
||||||
@@ -44,8 +45,26 @@ export const mockReplyCategories: ReplyCategory[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "r4",
|
id: "r4",
|
||||||
|
trigger: "AI 解析失败 → 要求补充参数",
|
||||||
|
text: "未能完全理解需求,请补充或确认:起运地?目的地?件数?毛重?体积?如有品名和时效要求,也请一并补充。",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r5",
|
||||||
trigger: "TMS 命中标准价 → 询问是否内部协同",
|
trigger: "TMS 命中标准价 → 询问是否内部协同",
|
||||||
text: "TMS 标准报价合计 {{system_quote}},预计时效 {{eta}}。是否需要咨询内部人员,或直接生成报价单?",
|
text: "TMS 标准报价合计 {{system_quote}},预计时效 {{eta}}。价格明细:空运价、操作费、文件费、报关费、提货费、目的地杂费、其他附加费。{{history_hint}}是否需要咨询内部{{internal_role_label}}人员,或直接生成报价单?",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r6",
|
||||||
|
trigger: "销售确认创建内部协同群",
|
||||||
|
text: "正在创建内部协同群「{{group_name}}」,并拉入销售 {{sales_name}}、{{internal_staff}} 及询价机器人。工单 {{ticket_no}} 保持「询价中」,等待内部反馈。",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r7",
|
||||||
|
trigger: "内部人员回复手工报价",
|
||||||
|
text: "收到 {{staff_name}}({{staff_role}})的手工报价,已结构化处理。报价 {{manual_quote}},附件 {{quote_attachment}} 已存入工单,正在整理报价方案…",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -57,21 +76,39 @@ export const mockReplyCategories: ReplyCategory[] = [
|
|||||||
tagClass: "tag-blue",
|
tagClass: "tag-blue",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: "r5",
|
id: "r8",
|
||||||
trigger: "报价方案整理完成 → 询问是否采用",
|
trigger: "报价方案整理完成 → 询问是否采用",
|
||||||
text: "工单 {{ticket_no}} 状态已更新为「已报价」。整理后的报价方案:{{route_summary}} · {{quote_amount}} · {{eta}}。请确认是否采用。",
|
text: "工单 {{ticket_no}} 状态已更新为「已报价」。整理后的报价方案:{{route_summary}} · {{quote_amount}} · {{eta}}。请确认是否采用:(采用,生成 Excel 报价单 / 采用,生成 PDF 报价单 / 不采用)",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "r6",
|
id: "r9",
|
||||||
|
trigger: "销售确认采用 → 生成最终报价单",
|
||||||
|
text: "已确认采用该方案,正在生成 {{quote_format}} 最终报价单…工单保持「已报价」,完成后可直接转发给客户。",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r10",
|
||||||
trigger: "最终报价单生成完成",
|
trigger: "最终报价单生成完成",
|
||||||
text: "最终报价单已生成:{{quote_file}}({{quote_amount}} · {{route_summary}})。工单 {{ticket_no}} 保持「已报价」,报价单已归档。",
|
text: "最终报价单已生成:{{quote_file}}({{quote_amount}} · {{route_summary}})。工单 {{ticket_no}} 保持「已报价」,报价单已归档。",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "r7",
|
id: "r11",
|
||||||
|
trigger: "销售发送比例调价指令",
|
||||||
|
text: "已识别调价指令:在当前报价基础上{{adjust_direction}}{{adjust_percent}}%。原报价 {{original_quote}} → 新报价 {{new_quote}}。正在刷新报价并重新生成报价单,工单保持「已报价」。",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r12",
|
||||||
|
trigger: "二次询价 · 关联历史工单",
|
||||||
|
text: "识别为二次询价,已关联历史工单 {{history_ticket_no}}。TMS 标准报价 {{system_quote}};上次报价 {{history_quote}},历史报价文档已引用至当前会话。",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r13",
|
||||||
trigger: "报价单出具满*小时 → 发起成交跟进",
|
trigger: "报价单出具满*小时 → 发起成交跟进",
|
||||||
text: "工单 {{ticket_no}} 已于 {{quote_time}} 出具报价。请确认当前与客户的成交状态:(成交 / 未成交 / 协商中)",
|
text: "工单 {{ticket_no}} 已于 {{quote_time}} 出具报价({{route_summary}} · {{quote_amount}})。请确认当前与客户的成交状态:(成交 / 未成交 / 协商中)",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -83,15 +120,15 @@ export const mockReplyCategories: ReplyCategory[] = [
|
|||||||
tagClass: "tag-green",
|
tagClass: "tag-green",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: "r8",
|
id: "r14",
|
||||||
trigger: "销售在成交跟进中选择「成交」",
|
trigger: "销售在成交跟进中选择「成交」",
|
||||||
text: "已记录为「已成交」。工单 {{ticket_no}} 状态已更新,成交确认时间 {{deal_time}} 已写入后台。",
|
text: "已记录为「已成交」。工单 {{ticket_no}} 状态已更新,成交确认时间 {{deal_time}} 已写入后台。已推送成交通知至 {{manager_name}}({{manager_role}})。",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "r9",
|
id: "r15",
|
||||||
trigger: "成交确认 → 企微推送负责人",
|
trigger: "成交确认 → 企微推送负责人",
|
||||||
text: "【成交通知】工单 {{ticket_no}} 已成交。销售:{{sales_name}} · 路线:{{route_summary}} · 报价:{{quote_amount}}。",
|
text: "【成交通知】工单 {{ticket_no}} 已成交。销售:{{sales_name}} · 业务:{{biz_type}} · 路线:{{route_summary}}报价:{{quote_amount}} · 确认时间:{{deal_time}}推送对象:{{manager_name}}({{manager_role}})。请知悉并安排后续履约跟进。",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -103,15 +140,27 @@ export const mockReplyCategories: ReplyCategory[] = [
|
|||||||
tagClass: "tag-amber",
|
tagClass: "tag-amber",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: "r10",
|
id: "r16",
|
||||||
trigger: "销售选择「不采用」当前报价方案",
|
trigger: "销售选择「不采用」当前报价方案",
|
||||||
text: "工单 {{ticket_no}} 状态已更新为「协商中」。请问希望如何调整?",
|
text: "工单 {{ticket_no}} 状态已更新为「协商中」。请问希望如何调整?可说明调价幅度、换舱/时效要求,或是否需要重新拉内部{{internal_role_label}}协同。",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "r11",
|
id: "r17",
|
||||||
trigger: "成交跟进中选择「协商中」",
|
trigger: "成交跟进中选择「协商中」",
|
||||||
text: "已记录为「协商中」。工单跟进周期已顺延,将于 {{next_followup_time}} 再次询问成交状态。",
|
text: "已记录为「协商中」。工单跟进周期已顺延 {{negotiate_extend_days}}个工作日,将于 {{next_followup_time}} 再次向您发起成交状态询问。",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r18",
|
||||||
|
trigger: "协商跟进周期到期 → 再次询问",
|
||||||
|
text: "工单 {{ticket_no}} 协商跟进周期已到({{route_summary}} · {{quote_amount}})。请再次确认当前成交状态:(成交 / 未成交 / 协商中)",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r19",
|
||||||
|
trigger: "调价或重新协同完成 → 出具新方案",
|
||||||
|
text: "协商调整已完成,新报价方案:{{route_summary}} · {{quote_amount}} · {{eta}}。工单状态更新为「已报价」,请确认是否采用。",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -123,15 +172,21 @@ export const mockReplyCategories: ReplyCategory[] = [
|
|||||||
tagClass: "tag-rose",
|
tagClass: "tag-rose",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: "r12",
|
id: "r20",
|
||||||
trigger: "销售选择「未成交」",
|
trigger: "成交跟进中选择「未成交」",
|
||||||
text: "已记录为「未成交」。请补充未成交原因,工单 {{ticket_no}} 将关闭智能体跟进。",
|
text: "请告知{{ticket_no}}「未成交」的原因(必填)。回复后系统将记录至工单 {{ticket_no}},并推送未成交通知至对应负责人。",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "r13",
|
id: "r21",
|
||||||
trigger: "未成交 → 通知负责人",
|
trigger: "销售回复未成交原因 → 关单并通知负责人",
|
||||||
text: "【未成交通知】工单 {{ticket_no}} 未成交。原因:{{lost_reason}}。",
|
text: "已记录为「未成交」。原因:{{reject_reason}}。登记时间 {{reject_time}},已推送未成交通知至 {{manager_name}}({{manager_role}})。工单 {{ticket_no}} 已归档。",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r22",
|
||||||
|
trigger: "未成交确认 → 企微推送负责人",
|
||||||
|
text: "【未成交通知】工单 {{ticket_no}} 未成交。销售:{{sales_name}} · 业务:{{biz_type}} · 路线:{{route_summary}}未成交原因:{{reject_reason}}登记时间:{{reject_time}} · 推送对象:{{manager_name}}({{manager_role}})。请知悉本单未成单情况。",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -139,13 +194,19 @@ export const mockReplyCategories: ReplyCategory[] = [
|
|||||||
{
|
{
|
||||||
id: "closed",
|
id: "closed",
|
||||||
title: "已关闭",
|
title: "已关闭",
|
||||||
desc: "工单因超时、重复、误建等原因关闭。",
|
desc: "工单长期无有效推进,由系统自动关闭或人工手动关闭,不等同于客户明确拒绝。",
|
||||||
tagClass: "tag-rose",
|
tagClass: "tag-rose",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: "r14",
|
id: "r23",
|
||||||
trigger: "销售主动关闭工单",
|
trigger: "达到配置超时时间 → 系统自动关闭",
|
||||||
text: "工单 {{ticket_no}} 已关闭。如需再次询价,请重新描述需求。",
|
text: "工单 {{ticket_no}} 因「{{close_reason}}」已达关闭条件,系统已自动关闭。关闭时间:{{close_time}}。本单不再发起成交跟进,亦不触发成交/未成交通知。",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r24",
|
||||||
|
trigger: "销售/客服手动关闭工单",
|
||||||
|
text: "工单 {{ticket_no}} 已由 {{operator_name}} 手动关闭。关闭原因:{{close_reason}}。如需重新询价,请发起新的询价对话。",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -153,19 +214,25 @@ export const mockReplyCategories: ReplyCategory[] = [
|
|||||||
{
|
{
|
||||||
id: "event-ex",
|
id: "event-ex",
|
||||||
title: "事件异常",
|
title: "事件异常",
|
||||||
desc: "销售/产品/航线响应超时等业务事件异常提示。",
|
desc: "业务流程中参与方响应超时等业务侧异常,写入工单「事件异常」字段,需人工跟进。",
|
||||||
tagClass: "tag-amber",
|
tagClass: "tag-amber",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: "r15",
|
id: "r25",
|
||||||
trigger: "销售响应超时",
|
trigger: "内部协同*小时无响应 → 群内提醒",
|
||||||
text: "工单 {{ticket_no}} 销售响应超时,已通知负责人跟进。",
|
text: "@{{staff_name}} @{{sales_name}} 工单 {{ticket_no}} 已超过 {{timeout_hours}} 小时未收到内部协同回复,请尽快确认。事件异常:{{event_exception_type}}。",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "r16",
|
id: "r26",
|
||||||
trigger: "产品 / 航线响应超时",
|
trigger: "内部协同超时达告警阈值 → 生成告警工单",
|
||||||
text: "内部协同人员响应超时,工单 {{ticket_no}} 已标记事件异常。",
|
text: "工单 {{ticket_no}} 内部协同已超过 {{alert_hours}} 小时未响应,已生成「{{event_exception_type}}」告警,请管理员跟进。未响应人员:{{staff_name}}({{staff_role}})。",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r27",
|
||||||
|
trigger: "成交跟进多次无响应 → 标记销售响应超时",
|
||||||
|
text: "工单 {{ticket_no}} 成交跟进已发起 {{followup_count}} 次,销售仍未回复。事件异常:销售响应超时。如继续无响应,系统将按配置自动关闭工单。",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -173,19 +240,31 @@ export const mockReplyCategories: ReplyCategory[] = [
|
|||||||
{
|
{
|
||||||
id: "sys-ex",
|
id: "sys-ex",
|
||||||
title: "系统异常",
|
title: "系统异常",
|
||||||
desc: "大模型、TMS、建群等系统失败话术。",
|
desc: "大模型、TMS、企微建群等系统侧调用失败,写入工单「系统异常」字段。",
|
||||||
tagClass: "tag-rose",
|
tagClass: "tag-rose",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: "r17",
|
id: "r28",
|
||||||
trigger: "大模型异常",
|
trigger: "大模型调用失败",
|
||||||
text: "需求识别暂时失败,请稍后重试或补充文字描述。工单 {{ticket_no}} 已记录系统异常。",
|
text: "AI 解析暂时不可用({{system_error_detail}})。工单 {{ticket_no}} 已标记系统异常:大模型异常。请稍后重试,或回复「人工报价」转人工处理。",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "r18",
|
id: "r29",
|
||||||
trigger: "TMS 异常",
|
trigger: "TMS 接口调用失败",
|
||||||
text: "TMS 标准价查询失败,已记录系统异常。可选择转内部人工报价。",
|
text: "TMS 标准价库查询失败({{system_error_detail}})。工单已标记系统异常:TMS异常。可稍后重试,或选择转人工由内部人员报价。",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r30",
|
||||||
|
trigger: "企微建群 / 添加成员失败",
|
||||||
|
text: "创建协同群失败,无法添加 {{staff_name}}({{staff_wechat_id}})。系统异常:建群异常 · {{system_error_detail}}。已在后台生成异常工单,请联系管理员检查人员配置。",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r31",
|
||||||
|
trigger: "系统异常处理完成",
|
||||||
|
text: "工单 {{ticket_no}} 系统异常「{{system_exception_type}}」已处理完毕,工单状态恢复为「{{restored_status}}」。处理记录:{{handle_note}}",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -193,19 +272,43 @@ export const mockReplyCategories: ReplyCategory[] = [
|
|||||||
{
|
{
|
||||||
id: "transfer",
|
id: "transfer",
|
||||||
title: "转人工",
|
title: "转人工",
|
||||||
desc: "关键词/无价/解析失败等场景转人工话术。",
|
desc: "AI 无法独立完成询价时,引导销售确认是否拉内部航线/产品进群协同。",
|
||||||
tagClass: "tag-violet",
|
tagClass: "tag-violet",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: "r19",
|
id: "r32",
|
||||||
trigger: "销售要求转人工",
|
trigger: "TMS 暂无报价",
|
||||||
text: "已为您转接人工处理,请稍候。工单 {{ticket_no}} 保持当前状态。",
|
text: "暂无 TMS 标准报价。触发转人工:{{transfer_reason}}。是否拉{{internal_role_label}}进群协同?(是 / 否)",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "r20",
|
id: "r33",
|
||||||
trigger: "无 TMS 标准价 → 引导转人工",
|
trigger: "意图识别失败",
|
||||||
text: "当前线路暂无标准价,建议转内部人工报价或转人工客服。",
|
text: "已尝试 {{intent_retry}} 次澄清,仍无法识别询价意图。触发转人工:意图识别失败。是否拉{{internal_role_label}}进群协同?(是 / 否)",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r34",
|
||||||
|
trigger: "参数提取不全",
|
||||||
|
text: "已连续 {{param_retry}} 次反问补充询价参数仍未完整。触发转人工:参数提取不全。是否拉{{internal_role_label}}进群协同?(是 / 否)",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r35",
|
||||||
|
trigger: "报价解析失败",
|
||||||
|
text: "触发转人工:{{transfer_reason}}(已连续失败 {{fail_count}} 次)。是否拉{{internal_role_label}}进群协同?(是 / 否)",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r36",
|
||||||
|
trigger: "空运锁舱/释放舱位失败",
|
||||||
|
text: "触发转人工:{{transfer_reason}}(已连续失败 {{fail_count}} 次)。请航线人员在 TMS 侧手工处理,或手动拉相关人员协助。请务必在群聊发送工单编号 {{ticket_no}}。",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r37",
|
||||||
|
trigger: "销售发送「人工报价」",
|
||||||
|
text: "已识别转人工指令。{{confirm_pull_group}}正在创建内部协同群,拉入销售 {{sales_name}}、{{internal_staff}} 及询价机器人…工单 {{ticket_no}} 进入「询价中」。",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
/**
|
/**
|
||||||
* 员工管理:对接主账 /inquiry/staffRole。
|
* 员工管理:对接主账 /inquiry/staffRole。
|
||||||
* 企微 userid 可手工维护,也可点「同步企业微信」由主账 upsert(姓名/手机/部门)。
|
* 企微 userid 可手工维护,也可点「同步企业微信」由主账 upsert(姓名/手机/部门)。
|
||||||
* 浏览器禁止直连企微;岗位/线路仍手工改。
|
* 浏览器禁止直连企微;岗位/负责人/线路仍手工改。
|
||||||
|
* 产品·航线:负责人(是/否)+ 负责线路多选,选项照搬旧项目 admin-staff。
|
||||||
*/
|
*/
|
||||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||||
import { message, Modal } from "ant-design-vue";
|
import { message, Modal } from "ant-design-vue";
|
||||||
@@ -33,6 +34,8 @@ type StaffRow = {
|
|||||||
role: StaffRole;
|
role: StaffRole;
|
||||||
roleLabel: string;
|
roleLabel: string;
|
||||||
routes: string;
|
routes: string;
|
||||||
|
/** 产品/航线是否负责人;销售为 null */
|
||||||
|
isLeader: boolean | null;
|
||||||
notifyEventException: boolean;
|
notifyEventException: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
status: "active" | "disabled";
|
status: "active" | "disabled";
|
||||||
@@ -48,6 +51,48 @@ const roleLabelMap: Record<StaffRole, string> = {
|
|||||||
land: "产品·陆运",
|
land: "产品·陆运",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 按岗位的负责线路选项(与原型 admin-staff.html ROUTES_BY_ROLE 一致) */
|
||||||
|
const ROUTES_BY_ROLE: Record<Exclude<StaffRole, "sales">, string[]> = {
|
||||||
|
air: [
|
||||||
|
"CGO-KUL",
|
||||||
|
"SZX-KUL",
|
||||||
|
"HKG-KUL",
|
||||||
|
"NKG-KUL",
|
||||||
|
"HKG-DEL",
|
||||||
|
"CGO-MAA",
|
||||||
|
"CGO-BLR",
|
||||||
|
"ZUH-CRK",
|
||||||
|
"WNZ-CGK",
|
||||||
|
"BKK",
|
||||||
|
"外采-空运",
|
||||||
|
"全线",
|
||||||
|
],
|
||||||
|
sea: ["海运全线"],
|
||||||
|
land: ["中港/中亚/中欧", "国内长途/零担", "国内城配/拖车/打包", "东南亚"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALL_ROUTE_FILTER_OPTIONS = [
|
||||||
|
...ROUTES_BY_ROLE.air,
|
||||||
|
...ROUTES_BY_ROLE.sea,
|
||||||
|
...ROUTES_BY_ROLE.land,
|
||||||
|
];
|
||||||
|
|
||||||
|
function isProductOrAir(role: StaffRole) {
|
||||||
|
return role === "air" || role === "sea" || role === "land";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRouteList(raw: string): string[] {
|
||||||
|
if (!raw || raw === "-") return [];
|
||||||
|
return raw
|
||||||
|
.split(/[/·,、]/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinRoutes(list: string[]) {
|
||||||
|
return list.map((s) => s.trim()).filter(Boolean).join(",");
|
||||||
|
}
|
||||||
|
|
||||||
const rows = ref<StaffRow[]>([]);
|
const rows = ref<StaffRow[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
@@ -76,14 +121,32 @@ const form = reactive({
|
|||||||
phone: "",
|
phone: "",
|
||||||
dept: "",
|
dept: "",
|
||||||
role: "sales" as StaffRole,
|
role: "sales" as StaffRole,
|
||||||
routes: "",
|
/** 空字符串=未选;true/false 字符串对齐旧项目下拉 */
|
||||||
|
isLeader: "" as "" | "true" | "false",
|
||||||
|
/** 负责线路多选;销售岗位不展示 */
|
||||||
|
routeList: [] as string[],
|
||||||
notifyEventException: false,
|
notifyEventException: false,
|
||||||
status: "active" as StaffRow["status"],
|
status: "active" as StaffRow["status"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 当前岗位可选线路;销售为空 */
|
||||||
|
const routeOptions = computed(() => {
|
||||||
|
if (!isProductOrAir(form.role)) return [] as string[];
|
||||||
|
return ROUTES_BY_ROLE[form.role];
|
||||||
|
});
|
||||||
|
|
||||||
|
const showLeaderAndRoutes = computed(() => isProductOrAir(form.role));
|
||||||
|
|
||||||
const filtered = computed(() => rows.value);
|
const filtered = computed(() => rows.value);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 部门下拉过滤:点开/聚焦时展示全量树;只有用户继续打字才按关键字筛。
|
||||||
|
* 为何:编辑时 form.dept 已是「武汉友通达」等完整名,若直接用它过滤会只剩 1 条。
|
||||||
|
*/
|
||||||
|
const deptFilterActive = ref(false);
|
||||||
|
|
||||||
const filteredDeptOptions = computed(() => {
|
const filteredDeptOptions = computed(() => {
|
||||||
|
if (!deptFilterActive.value) return deptOptions.value;
|
||||||
const q = (form.dept || "").trim().toLowerCase();
|
const q = (form.dept || "").trim().toLowerCase();
|
||||||
if (!q) return deptOptions.value;
|
if (!q) return deptOptions.value;
|
||||||
return deptOptions.value.filter((d) => d.name.toLowerCase().includes(q));
|
return deptOptions.value.filter((d) => d.name.toLowerCase().includes(q));
|
||||||
@@ -97,6 +160,9 @@ function colorFor(name: string) {
|
|||||||
|
|
||||||
function mapRow(r: any): StaffRow {
|
function mapRow(r: any): StaffRow {
|
||||||
const role = (r.roleCode || "sales") as StaffRole;
|
const role = (r.roleCode || "sales") as StaffRole;
|
||||||
|
let isLeader: boolean | null = null;
|
||||||
|
if (r.isLeader === 1 || r.isLeader === true) isLeader = true;
|
||||||
|
else if (r.isLeader === 0 || r.isLeader === false) isLeader = false;
|
||||||
return {
|
return {
|
||||||
id: String(r.id || ""),
|
id: String(r.id || ""),
|
||||||
name: r.name || "",
|
name: r.name || "",
|
||||||
@@ -106,6 +172,7 @@ function mapRow(r: any): StaffRow {
|
|||||||
role: ["sales", "air", "sea", "land"].includes(role) ? role : "sales",
|
role: ["sales", "air", "sea", "land"].includes(role) ? role : "sales",
|
||||||
roleLabel: r.roleLabel || roleLabelMap[role] || role,
|
roleLabel: r.roleLabel || roleLabelMap[role] || role,
|
||||||
routes: r.routes || "-",
|
routes: r.routes || "-",
|
||||||
|
isLeader,
|
||||||
notifyEventException: r.notifyEventException === 1 || r.notifyEventException === true,
|
notifyEventException: r.notifyEventException === 1 || r.notifyEventException === true,
|
||||||
createdAt: (r.createTime || "").toString().slice(0, 10) || "—",
|
createdAt: (r.createTime || "").toString().slice(0, 10) || "—",
|
||||||
status: r.status === "disabled" ? "disabled" : "active",
|
status: r.status === "disabled" ? "disabled" : "active",
|
||||||
@@ -264,6 +331,7 @@ let deptBlurTimer: ReturnType<typeof setTimeout> | null = null;
|
|||||||
|
|
||||||
function pickDept(d: DeptOption) {
|
function pickDept(d: DeptOption) {
|
||||||
form.dept = d.name;
|
form.dept = d.name;
|
||||||
|
deptFilterActive.value = false;
|
||||||
deptOpen.value = false;
|
deptOpen.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,20 +340,45 @@ function onDeptFocus() {
|
|||||||
clearTimeout(deptBlurTimer);
|
clearTimeout(deptBlurTimer);
|
||||||
deptBlurTimer = null;
|
deptBlurTimer = null;
|
||||||
}
|
}
|
||||||
|
// 聚焦先看全量,避免当前部门名把列表滤成一项
|
||||||
|
deptFilterActive.value = false;
|
||||||
deptOpen.value = true;
|
deptOpen.value = true;
|
||||||
ensureDeptOptions();
|
ensureDeptOptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onDeptInput() {
|
||||||
|
deptFilterActive.value = true;
|
||||||
|
deptOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
function onDeptBlur() {
|
function onDeptBlur() {
|
||||||
deptBlurTimer = setTimeout(() => {
|
deptBlurTimer = setTimeout(() => {
|
||||||
deptOpen.value = false;
|
deptOpen.value = false;
|
||||||
|
deptFilterActive.value = false;
|
||||||
}, 180);
|
}, 180);
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => modalOpen.value,
|
() => modalOpen.value,
|
||||||
(open) => {
|
(open) => {
|
||||||
if (!open) deptOpen.value = false;
|
if (!open) {
|
||||||
|
deptOpen.value = false;
|
||||||
|
deptFilterActive.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 换岗位时:销售清空负责人/线路;产品航线只保留本岗可选线路 */
|
||||||
|
watch(
|
||||||
|
() => form.role,
|
||||||
|
(role) => {
|
||||||
|
if (!isProductOrAir(role)) {
|
||||||
|
form.isLeader = "";
|
||||||
|
form.routeList = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const valid = new Set(ROUTES_BY_ROLE[role]);
|
||||||
|
form.routeList = form.routeList.filter((r) => valid.has(r));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -297,7 +390,8 @@ function openCreate() {
|
|||||||
phone: "",
|
phone: "",
|
||||||
dept: "",
|
dept: "",
|
||||||
role: "sales",
|
role: "sales",
|
||||||
routes: "",
|
isLeader: "",
|
||||||
|
routeList: [],
|
||||||
notifyEventException: false,
|
notifyEventException: false,
|
||||||
status: "active",
|
status: "active",
|
||||||
});
|
});
|
||||||
@@ -308,13 +402,16 @@ function openCreate() {
|
|||||||
|
|
||||||
function openEdit(row: StaffRow) {
|
function openEdit(row: StaffRow) {
|
||||||
editing.value = row;
|
editing.value = row;
|
||||||
|
const valid = isProductOrAir(row.role) ? new Set(ROUTES_BY_ROLE[row.role]) : null;
|
||||||
|
const routes = parseRouteList(row.routes).filter((r) => (valid ? valid.has(r) : true));
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
name: row.name,
|
name: row.name,
|
||||||
wecomId: row.wecomId,
|
wecomId: row.wecomId,
|
||||||
phone: row.phoneMasked === "未填写" ? "" : row.phoneMasked,
|
phone: row.phoneMasked === "未填写" ? "" : row.phoneMasked,
|
||||||
dept: row.dept === "未分配" ? "" : row.dept,
|
dept: row.dept === "未分配" ? "" : row.dept,
|
||||||
role: row.role,
|
role: row.role,
|
||||||
routes: row.routes === "-" ? "" : row.routes,
|
isLeader: row.isLeader === true ? "true" : row.isLeader === false ? "false" : "",
|
||||||
|
routeList: routes,
|
||||||
notifyEventException: Boolean(row.notifyEventException),
|
notifyEventException: Boolean(row.notifyEventException),
|
||||||
status: row.status,
|
status: row.status,
|
||||||
});
|
});
|
||||||
@@ -328,6 +425,16 @@ async function saveStaff() {
|
|||||||
message.warning("请填写姓名与企业微信 ID");
|
message.warning("请填写姓名与企业微信 ID");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isProductOrAir(form.role)) {
|
||||||
|
if (form.isLeader !== "true" && form.isLeader !== "false") {
|
||||||
|
message.warning("请选择是否负责人");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.routeList.length) {
|
||||||
|
message.warning("请选择负责线路");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
const body = {
|
const body = {
|
||||||
id: editing.value?.id,
|
id: editing.value?.id,
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
@@ -336,7 +443,8 @@ async function saveStaff() {
|
|||||||
departName: form.dept.trim() || undefined,
|
departName: form.dept.trim() || undefined,
|
||||||
roleCode: form.role,
|
roleCode: form.role,
|
||||||
roleLabel: roleLabelMap[form.role],
|
roleLabel: roleLabelMap[form.role],
|
||||||
routes: form.routes.trim() || undefined,
|
routes: isProductOrAir(form.role) ? joinRoutes(form.routeList) : undefined,
|
||||||
|
isLeader: isProductOrAir(form.role) ? (form.isLeader === "true" ? 1 : 0) : null,
|
||||||
notifyEventException: form.notifyEventException ? 1 : 0,
|
notifyEventException: form.notifyEventException ? 1 : 0,
|
||||||
status: form.status,
|
status: form.status,
|
||||||
};
|
};
|
||||||
@@ -383,11 +491,11 @@ async function syncWecom() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function routeTags(routes: string) {
|
function routeTags(routes: string) {
|
||||||
if (!routes || routes === "-") return [];
|
return parseRouteList(routes);
|
||||||
return routes
|
}
|
||||||
.split(/[/·,、]/)
|
|
||||||
.map((s) => s.trim())
|
function filterRouteOption(input: string, option: any) {
|
||||||
.filter(Boolean);
|
return String(option?.label ?? "").includes(input);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -438,15 +546,7 @@ function routeTags(routes: string) {
|
|||||||
</select>
|
</select>
|
||||||
<select v-model="filters.route">
|
<select v-model="filters.route">
|
||||||
<option value="">负责线路</option>
|
<option value="">负责线路</option>
|
||||||
<option value="CGO-KUL">CGO-KUL</option>
|
<option v-for="r in ALL_ROUTE_FILTER_OPTIONS" :key="r" :value="r">{{ r }}</option>
|
||||||
<option value="HKG-KUL">HKG-KUL</option>
|
|
||||||
<option value="全线">全线</option>
|
|
||||||
<option value="外采-空运">外采-空运</option>
|
|
||||||
<option value="海运全线">海运全线</option>
|
|
||||||
<option value="中港/中亚/中欧">中港/中亚/中欧</option>
|
|
||||||
<option value="国内长途/零担">国内长途/零担</option>
|
|
||||||
<option value="国内城配/拖车/打包">国内城配/拖车/打包</option>
|
|
||||||
<option value="东南亚">东南亚</option>
|
|
||||||
</select>
|
</select>
|
||||||
<select v-model="filters.status">
|
<select v-model="filters.status">
|
||||||
<option value="">状态</option>
|
<option value="">状态</option>
|
||||||
@@ -558,7 +658,7 @@ function routeTags(routes: string) {
|
|||||||
:disabled="deptLoading"
|
:disabled="deptLoading"
|
||||||
:placeholder="deptLoading ? '加载部门…' : '请选择或输入部门'"
|
:placeholder="deptLoading ? '加载部门…' : '请选择或输入部门'"
|
||||||
@focus="onDeptFocus"
|
@focus="onDeptFocus"
|
||||||
@input="deptOpen = true"
|
@input="onDeptInput"
|
||||||
@blur="onDeptBlur"
|
@blur="onDeptBlur"
|
||||||
/>
|
/>
|
||||||
<span class="dept-combo-arrow" aria-hidden="true">▾</span>
|
<span class="dept-combo-arrow" aria-hidden="true">▾</span>
|
||||||
@@ -585,9 +685,13 @@ function routeTags(routes: string) {
|
|||||||
<option value="land">产品·陆运</option>
|
<option value="land">产品·陆运</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div v-if="showLeaderAndRoutes" class="form-row">
|
||||||
<label class="lbl">负责线路</label>
|
<label class="lbl">负责人 <span class="req">*</span></label>
|
||||||
<input v-model="form.routes" class="field" type="text" placeholder="销售可留空" />
|
<select v-model="form.isLeader" class="field">
|
||||||
|
<option value="">请选择是否负责人</option>
|
||||||
|
<option value="true">是</option>
|
||||||
|
<option value="false">否</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label class="lbl">消息提醒</label>
|
<label class="lbl">消息提醒</label>
|
||||||
@@ -611,6 +715,19 @@ function routeTags(routes: string) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="showLeaderAndRoutes" class="form-row">
|
||||||
|
<label class="lbl">负责线路 <span class="req">*</span></label>
|
||||||
|
<a-select
|
||||||
|
v-model:value="form.routeList"
|
||||||
|
mode="multiple"
|
||||||
|
allow-clear
|
||||||
|
show-search
|
||||||
|
class="staff-route-select"
|
||||||
|
placeholder="请选择负责线路,可多选"
|
||||||
|
:options="routeOptions.map((r) => ({ label: r, value: r }))"
|
||||||
|
:filter-option="filterRouteOption"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</AdminModal>
|
</AdminModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -698,4 +815,16 @@ function routeTags(routes: string) {
|
|||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.staff-route-select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.staff-route-select :deep(.ant-select-selector) {
|
||||||
|
min-height: 34px;
|
||||||
|
border-radius: 7px !important;
|
||||||
|
border-color: #cbd5e1 !important;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -191,14 +191,28 @@ body {
|
|||||||
.page-topbar-actions-slot {
|
.page-topbar-actions-slot {
|
||||||
gap: 8px !important;
|
gap: 8px !important;
|
||||||
min-height: 34px !important;
|
min-height: 34px !important;
|
||||||
max-height: 40px !important;
|
/* 勿 max-height + overflow:hidden:会裁掉看板日期下拉,看起来像不能选 */
|
||||||
|
max-height: none !important;
|
||||||
position: relative !important;
|
position: relative !important;
|
||||||
z-index: 40 !important;
|
z-index: 40 !important;
|
||||||
pointer-events: auto !important;
|
pointer-events: auto !important;
|
||||||
overflow: hidden !important;
|
overflow: visible !important;
|
||||||
flex-wrap: nowrap !important;
|
flex-wrap: nowrap !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-topbar-actions-slot .dash-topbar-tools,
|
||||||
|
.page-topbar-actions-slot .month-range-picker,
|
||||||
|
.page-topbar-actions-slot .toolbar-date-range {
|
||||||
|
position: relative !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
z-index: 50 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-topbar-actions-slot .month-range-dropdown,
|
||||||
|
.page-topbar-actions-slot .dash-picker-dropdown {
|
||||||
|
z-index: 90 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.page-topbar-actions-slot .btn {
|
.page-topbar-actions-slot .btn {
|
||||||
pointer-events: auto !important;
|
pointer-events: auto !important;
|
||||||
cursor: pointer !important;
|
cursor: pointer !important;
|
||||||
|
|||||||
Reference in New Issue
Block a user