Compare commits

...
Author SHA1 Message Date
linmianxunandCursor 58b3c2f3e6 人员岗位补负责人/线路维护,并同步后台人员、机器人回复与看板页改动。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 11:12:13 +08:00
jillion886andCursor a7a8487eb3 让成交卡等报价文件真正发出后再出现,并收紧 PDF 顶部留白,避免抬头被裁或空一截。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 10:36:55 +08:00
jillion886 cb3bf5238b 合并远程主仓:纳入工单日期筛选、服务端分页与分析看板,保留询价出单。 2026-09-15 09:02:06 +08:00
linmianxunandCursor 11ec728cf4 后台列表统一分页组件,工单/人员/账号改为服务端分页。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 22:19:58 +08:00
linmianxunandCursor 9d4889c482 分析看板补齐成本趋势与排名:主账聚合近7日线路均价,后台按真实数据绘图。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 21:49:48 +08:00
linmianxunandCursor 07c988e61f 工单创建时间改用 Ant RangePicker,一次弹层选起止并与筛选同步。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 21:27:26 +08:00
jillion886 1847504807 Merge remote-tracking branch 'origin/main' into feat/tms-quote-staff-sync 2026-09-14 21:24:55 +08:00
linmianxunandCursor b1eba3facc 工单筛选日期控件对齐原型:可见文案、点选即弹日历。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 21:24:20 +08:00
linmianxunandCursor 38a25a4b40 人员所属部门下拉对齐旧项目:主账提供 /departments 短名全量,后台可搜可选。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 21:13:52 +08:00
38 changed files with 3156 additions and 496 deletions
+36 -3
View File
@@ -1,7 +1,7 @@
"""
H5 补问 / 附件上传通道。
H5 补问 / 附件上传 / 复制工单号通道。
本文件职责:挂载 /inquiry-form;用 clarification token 绑定身份(URL 禁止 userid)。
本文件职责:挂载 /inquiry-form;用 token 绑定身份或工单号(URL 禁止 userid)。
禁止:从 URL 读 userid;禁止在本包改六态或直连 TMS;禁止同步 invoke Graph。
"""
@@ -10,10 +10,13 @@ from __future__ import annotations
import logging
from typing import Any, Optional
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import HTMLResponse
from pydantic import BaseModel, Field
from agent.channel.clarification import validate_token
from agent.channel.h5.copy_page import expired_copy_page, render_copy_page
from agent.channel.h5.copy_store import lookup_copy_token
from agent.handlers.card_action import handle_card_action
logger = logging.getLogger(__name__)
@@ -34,6 +37,36 @@ def h5_ping() -> dict[str, str]:
return {"status": "ok", "channel": "h5"}
@router.get("/copy/{token}", response_class=HTMLResponse)
def h5_copy_ticket(token: str) -> HTMLResponse:
"""
复制工单号页:token 换工单号,页面写入剪贴板。
请求线程只读 token,不进 Graph、不发企微消息。
"""
wo = lookup_copy_token(token)
if not wo:
return HTMLResponse(expired_copy_page(), status_code=410)
return HTMLResponse(render_copy_page(wo))
@router.get("/jsapi-signature")
def h5_jsapi_signature(
url: str = Query(default=""),
kind: str = Query(default="corp"),
) -> dict[str, Any]:
"""
给复制页 ww.register 签名。失败给空对象,页面改走浏览器剪贴板。
"""
from agent.channel.wecom.jsapi import build_jsapi_signature
try:
return build_jsapi_signature(url=url, kind=kind)
except Exception as exc: # noqa: BLE001
logger.warning("jsapi 签名失败 kind=%s err=%s", kind, exc)
raise HTTPException(status_code=503, detail="jsapi_unavailable") from exc
@router.get("/{token}")
def h5_open(token: str) -> dict[str, Any]:
"""
+150
View File
@@ -0,0 +1,150 @@
"""
复制工单号页 HTML。
本文件职责:打开后立刻把工单号写入系统剪贴板(企微 JS-SDK + 浏览器兜底)。
禁止:URL/页面带 userid;禁止在本页调主账或改六态。
"""
from __future__ import annotations
import html
from agent.config import get_settings
_EXPIRED_HTML = """<!doctype html>
<html lang="zh-CN"><head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>链接已过期</title>
</head><body style="font-family:sans-serif;padding:32px;text-align:center;color:#333">
<p>复制链接已过期。</p>
<p>请回到企微,重新询价后再点「复制工单号」。</p>
</body></html>
"""
def expired_copy_page() -> str:
"""token 无效时给销售看的短页,不暴露内部原因。"""
return _EXPIRED_HTML
def render_copy_page(work_order_no: str) -> str:
"""
渲染复制页。工单号只进页面正文,由 JS 写入剪贴板。
先走企微 ww.setClipboardData(应用内才能真复制),失败再走浏览器剪贴板。
"""
wo = html.escape((work_order_no or "").strip(), quote=True)
settings = get_settings()
corp = html.escape((settings.wecom_corp_id or "").strip(), quote=True)
agent = html.escape(str(settings.wecom_agent_id or "1000010").strip(), quote=True)
return f"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>复制工单号</title>
<style>
body {{ margin:0; font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;
background:#f5f6f7; color:#1f2329; }}
.wrap {{ max-width:420px; margin:48px auto; padding:0 20px; text-align:center; }}
.wo {{ font-size:22px; font-weight:600; letter-spacing:.04em; margin:16px 0 8px; word-break:break-all; }}
.st {{ min-height:24px; color:#646a73; font-size:14px; }}
.ok {{ color:#00a870; }}
.bad {{ color:#d83931; }}
button {{ margin-top:20px; width:100%; height:44px; border:0; border-radius:8px;
background:#1677ff; color:#fff; font-size:16px; }}
</style>
</head>
<body>
<div class="wrap">
<div>工单号</div>
<div class="wo" id="wo" data-wo="{wo}">{wo}</div>
<div class="st" id="st">正在复制…</div>
<button type="button" id="btn">复制工单号</button>
</div>
<script src="https://wwcdn.weixin.qq.com/node/wework/open/js/wecom-jssdk-2.3.2.js"></script>
<script>
(function () {{
var text = document.getElementById("wo").getAttribute("data-wo") || "";
var st = document.getElementById("st");
var corpId = "{corp}";
var agentId = Number("{agent}") || 0;
function show(ok, msg) {{
st.className = "st " + (ok ? "ok" : "bad");
st.textContent = msg;
}}
function browserCopy(value) {{
if (navigator.clipboard && navigator.clipboard.writeText) {{
return navigator.clipboard.writeText(value);
}}
return new Promise(function (resolve, reject) {{
var box = document.createElement("textarea");
box.value = value;
box.setAttribute("readonly", "readonly");
box.style.position = "fixed";
box.style.left = "-9999px";
document.body.appendChild(box);
box.select();
try {{
if (!document.execCommand("copy")) throw new Error("exec");
resolve();
}} catch (err) {{ reject(err); }}
document.body.removeChild(box);
}});
}}
function wecomCopy(value) {{
if (window.ww && ww.setClipboardData) {{
return ww.setClipboardData({{ data: value }});
}}
return Promise.reject(new Error("no-ww"));
}}
function sign(kind) {{
return function (url) {{
return fetch("/inquiry-form/jsapi-signature?kind=" + encodeURIComponent(kind)
+ "&url=" + encodeURIComponent(url))
.then(function (r) {{
if (!r.ok) throw new Error("sign");
return r.json();
}});
}};
}}
function tryCopy() {{
return wecomCopy(text).catch(function () {{ return browserCopy(text); }})
.then(function () {{ show(true, "已复制到剪贴板"); }})
.catch(function () {{ show(false, "复制失败,请再点一次按钮"); }});
}}
function boot() {{
var ready = Promise.resolve();
if (window.ww && ww.register && corpId) {{
ready = ww.register({{
corpId: corpId,
agentId: agentId,
jsApiList: ["setClipboardData"],
getConfigSignature: sign("corp"),
getAgentConfigSignature: sign("agent")
}}).catch(function () {{ return null; }});
}}
ready.then(tryCopy).catch(tryCopy);
}}
document.getElementById("btn").addEventListener("click", function () {{
show(true, "正在复制…");
tryCopy();
}});
if (document.readyState === "loading") {{
document.addEventListener("DOMContentLoaded", boot);
}} else {{
boot();
}}
}})();
</script>
</body>
</html>
"""
@@ -0,0 +1,126 @@
"""
复制工单号 H5 的短命 token。
本文件职责:签发/校验 token → 工单号;URL 只带 token,禁止带 userid/工单号。
优先 Redis(HTTP 与 Worker 不同进程必须共享);单测无 Redis 时用内存。
禁止:当六态;禁止把工单号拼进公网路径。
"""
from __future__ import annotations
import logging
import secrets
import threading
import time
from typing import Optional
from agent.config import get_settings
from agent.redis_coord.keys import TOKEN_COPY_TICKET
logger = logging.getLogger(__name__)
# 销售可能隔几天才点卡;7 天内同一 token 可重复打开复制。
COPY_TTL_SECONDS = 7 * 24 * 60 * 60
_FORM_FALLBACK = "https://ai.ytd-scm.com/inquiry-form"
_MEM: dict[str, tuple[str, float]] = {}
_MEM_LOCK = threading.Lock()
def issue_copy_token(work_order_no: str, *, ttl_seconds: int = COPY_TTL_SECONDS) -> str:
"""
签发复制页 token。
副作用:写 Redis 或内存。工单号为空则返回空串,调用方不要出跳转按钮。
"""
wo = (work_order_no or "").strip()
if not wo:
return ""
token = secrets.token_urlsafe(24)
if _redis_set(token, wo, ttl_seconds):
return token
expires = time.time() + ttl_seconds
with _MEM_LOCK:
_MEM[token] = (wo, expires)
logger.info("copy token 走内存 wo=%s(HTTP/Worker 分进程时页面会找不到)", wo)
return token
def lookup_copy_token(token: str) -> Optional[str]:
"""校验 token 未过期;返回工单号或 None。不消费,可重复打开。"""
raw = (token or "").strip()
if not raw:
return None
wo = _redis_get(raw)
if wo:
return wo
now = time.time()
with _MEM_LOCK:
hit = _MEM.get(raw)
if not hit:
return None
wo, exp = hit
if exp < now:
_MEM.pop(raw, None)
return None
return wo
def build_copy_ticket_url(work_order_no: str) -> str:
"""
拼复制页绝对地址:{inquiry-form}/copy/{token}。
域名来自环境变量,禁止写死测试/正式切换。缺省回合同域名,保证企微能打开。
"""
token = issue_copy_token(work_order_no)
if not token:
return ""
return f"{_form_base()}/copy/{token}"
def _form_base() -> str:
settings = get_settings()
form = (settings.inquiry_form_public_base_url or "").strip().rstrip("/")
if form:
return form
pub = (settings.public_base_url or "").strip().rstrip("/")
if pub:
return f"{pub}/inquiry-form"
return _FORM_FALLBACK
def _redis_set(token: str, work_order_no: str, ttl_seconds: int) -> bool:
client = _try_redis()
if client is None:
return False
try:
client.raw.set(client.key(TOKEN_COPY_TICKET, token), work_order_no, ex=ttl_seconds)
return True
except Exception as exc: # noqa: BLE001
logger.warning("copy token Redis 写入失败:%s", exc)
return False
def _redis_get(token: str) -> Optional[str]:
client = _try_redis()
if client is None:
return None
try:
val = client.raw.get(client.key(TOKEN_COPY_TICKET, token))
except Exception as exc: # noqa: BLE001
logger.warning("copy token Redis 读取失败:%s", exc)
return None
if val is None:
return None
text = val if isinstance(val, str) else val.decode("utf-8")
return text.strip() or None
def _try_redis():
"""有 Redis 才用;单测 memory 后端也可用,保证同进程能读到。"""
try:
from agent.redis_coord import get_redis_runtime
return get_redis_runtime(get_settings()).client
except Exception: # noqa: BLE001
return None
@@ -270,6 +270,14 @@ class WeComAppClient:
logger.info("企微发送成功 touser=%s reply=%s", body.get("touser"), shown)
return WeComSendResult(ok=True, errcode=0, errmsg=errmsg, raw=data)
def get_access_token(self) -> str:
"""
取出站/JS-SDK 用的 access_token。
只给同进程 H5 签名用;禁止写进页面或日志。
"""
return self._get_token()
def _get_token(self) -> str:
with self._lock:
now = time.time()
+51 -1
View File
@@ -190,6 +190,26 @@ class PostgresMessageStore:
logger.exception("pg outbox 入队失败")
raise
def has_outbound_dedupe(self, prefix: str) -> bool:
"""去重键是否已出过站(前缀匹配)。跨进程看成交卡/文件是否已发。"""
head = (prefix or "").strip()
if not head:
return False
try:
with connect_psycopg(self._dsn) as conn:
row = conn.execute(
"""
SELECT 1 FROM agent_outbox
WHERE dedupe_key LIKE %s
LIMIT 1
""",
(head + "%",),
).fetchone()
return bool(row)
except Exception:
logger.exception("查 outbox 去重前缀失败 prefix=%s", head)
return False
def claim_outbound(self) -> Optional[OutboxItem]:
timeout = int(self._claim_timeout)
with connect_psycopg(self._dsn) as conn:
@@ -203,7 +223,7 @@ class PostgresMessageStore:
AND claimed_at IS NOT NULL
AND claimed_at < NOW() - (%s || ' seconds')::interval
)
ORDER BY created_at
ORDER BY created_at ASC, id ASC
FOR UPDATE SKIP LOCKED
LIMIT 1
)
@@ -294,6 +314,36 @@ class PostgresMessageStore:
def wait_outbox(self, timeout: float = 1.0) -> None:
self._outbox_event.wait(timeout=timeout)
def wait_outbound_done(self, item_id: str, timeout_sec: float = 12.0) -> bool:
"""
等到指定出站已发送完成或失败。
查价前先等询价确认发出,避免暂无报价卡抢先。只等这一条。
"""
raw = (item_id or "").strip()
if not raw:
return False
started = time.time()
deadline = started + max(0.1, float(timeout_sec))
saw_claim = False
while time.time() < deadline:
with connect_psycopg(self._dsn) as conn:
row = conn.execute(
"SELECT state FROM agent_outbox WHERE id = %s",
(raw,),
).fetchone()
state = str(row[0] or "") if row else ""
if state in {"done", "failed"}:
return state == "done"
if state == "claimed":
saw_claim = True
if not saw_claim and state == "pending" and time.time() - started >= 0.4:
return True
if not state and time.time() - started >= 0.3:
return True
self._outbox_event.wait(0.05)
return False
def stats(self) -> dict[str, Any]:
with connect_psycopg(self._dsn) as conn:
inbox = conn.execute(
+39
View File
@@ -163,6 +163,14 @@ class MemoryMessageStore:
logger.info("outbox 入队 id=%s touser=%s", item.id, touser)
return True, item.id
def has_outbound_dedupe(self, prefix: str) -> bool:
"""去重键是否已出过站(前缀匹配)。给 wait_file 判断文件/成交卡是否已发。"""
head = (prefix or "").strip()
if not head:
return False
with self._lock:
return any(str(k).startswith(head) for k in self._seen_out)
def claim_outbound(self) -> Optional[OutboxItem]:
"""认领一条 pending/超时 outbox。"""
now = time.time()
@@ -207,6 +215,37 @@ class MemoryMessageStore:
def wait_outbox(self, timeout: float = 1.0) -> None:
self._outbox_event.wait(timeout=timeout)
def wait_outbound_done(self, item_id: str, timeout_sec: float = 12.0) -> bool:
"""
等到指定出站已发送完成或失败。
查价前必须先等询价确认发出,避免暂无报价卡抢先到销售手机。
只等这一条,不锁其它工单。超时返回 False。
"""
raw = (item_id or "").strip()
if not raw:
return False
started = time.time()
deadline = started + max(0.1, float(timeout_sec))
saw_claim = False
while time.time() < deadline:
state = ""
with self._lock:
item = self._outbox.get(raw)
if item:
state = item.state
if state in {TaskState.DONE, TaskState.FAILED}:
return state == TaskState.DONE
if state == TaskState.CLAIMED:
saw_claim = True
# 单测没有出站线程:一直 PENDING 就不要空等 12 秒。测服有出站线程会立刻认领。
if not saw_claim and state == TaskState.PENDING and time.time() - started >= 0.4:
return True
if not state and time.time() - started >= 0.3:
return True
self._outbox_event.wait(0.05)
return False
def stats(self) -> dict[str, Any]:
with self._lock:
return {
@@ -40,4 +40,6 @@ class MessageStore(Protocol):
def wait_outbox(self, timeout: float = 1.0) -> None: ...
def wait_outbound_done(self, item_id: str, timeout_sec: float = 12.0) -> bool: ...
def stats(self) -> dict[str, Any]: ...
+110
View File
@@ -0,0 +1,110 @@
"""
企微 JS-SDK 签名(给复制页写剪贴板)。
本文件职责:取 jsapi_ticket、按当前页 URL 签名。请求线程只打缓存/短 HTTP。
禁止:把 access_token / ticket 写进页面或日志;禁止在这里改工单。
"""
from __future__ import annotations
import hashlib
import logging
import secrets
import time
from typing import Any, Optional
import httpx
from agent.channel.outbox.sender import WeComAppClient
from agent.config import get_settings
from agent.redis_coord.keys import TOKEN_JSAPI
logger = logging.getLogger(__name__)
_TICKET_TTL = 3600
def build_jsapi_signature(*, url: str, kind: str = "corp") -> dict[str, Any]:
"""
给前端 ww.register 用的签名。
kind=corp → get_jsapi_ticket;kind=agent → agent_config ticket。
URL 必须与浏览器地址栏一致(不含 hash)。
"""
page = (url or "").strip()
if not page:
raise ValueError("url_empty")
ticket = _jsapi_ticket(kind="agent" if kind == "agent" else "corp")
if not ticket:
raise RuntimeError("jsapi_ticket_missing")
nonce = secrets.token_hex(8)
timestamp = str(int(time.time()))
raw = f"jsapi_ticket={ticket}&noncestr={nonce}&timestamp={timestamp}&url={page}"
signature = hashlib.sha1(raw.encode("utf-8")).hexdigest()
return {
"timestamp": int(timestamp),
"nonceStr": nonce,
"signature": signature,
}
def _jsapi_ticket(*, kind: str) -> str:
cached = _cache_get(kind)
if cached:
return cached
settings = get_settings()
if not (settings.wecom_corp_id and settings.wecom_secret):
return ""
client = WeComAppClient(
corp_id=settings.wecom_corp_id,
secret=settings.wecom_secret,
agent_id=int(settings.wecom_agent_id or "0") or 0,
)
token = client.get_access_token()
if kind == "agent":
api = "https://qyapi.weixin.qq.com/cgi-bin/ticket/get"
params = {"access_token": token, "type": "agent_config"}
else:
api = "https://qyapi.weixin.qq.com/cgi-bin/get_jsapi_ticket"
params = {"access_token": token}
with httpx.Client(timeout=8.0) as http:
data = http.get(api, params=params).json()
errcode = int(data.get("errcode") or 0)
ticket = str(data.get("ticket") or "")
if errcode != 0 or not ticket:
logger.warning("jsapi ticket 失败 kind=%s errcode=%s", kind, errcode)
return ""
_cache_set(kind, ticket)
return ticket
def _cache_get(kind: str) -> Optional[str]:
redis = _try_redis()
if redis is None:
return None
try:
val = redis.raw.get(redis.key(TOKEN_JSAPI, kind))
except Exception: # noqa: BLE001
return None
if val is None:
return None
return val if isinstance(val, str) else val.decode("utf-8")
def _cache_set(kind: str, ticket: str) -> None:
redis = _try_redis()
if redis is None:
return
try:
redis.raw.set(redis.key(TOKEN_JSAPI, kind), ticket, ex=_TICKET_TTL)
except Exception as exc: # noqa: BLE001
logger.warning("jsapi ticket 缓存失败:%s", exc)
def _try_redis():
try:
from agent.redis_coord import get_redis_runtime
return get_redis_runtime(get_settings()).client
except Exception: # noqa: BLE001
return None
+4
View File
@@ -41,6 +41,10 @@ class Settings(BaseSettings):
ytd_env: str = Field(default="test", alias="YTD_ENV")
agent_http_port: int = Field(default=8910, alias="AGENT_HTTP_PORT")
public_base_url: str = Field(default="", alias="PUBLIC_BASE_URL")
inquiry_form_public_base_url: str = Field(
default="",
alias="INQUIRY_FORM_PUBLIC_BASE_URL",
)
biz_api_base_url: str = Field(default="http://127.0.0.1:8180/jeecgboot", alias="BIZ_API_BASE_URL")
biz_api_token: str = Field(default="", alias="BIZ_API_TOKEN")
biz_api_username: str = Field(default="", alias="BIZ_API_USERNAME")
+4 -2
View File
@@ -1,5 +1,5 @@
"""
意图:询价卡片按钮(跳过协同 / 采用 / 不采用 / 成交)。
意图:询价卡片按钮(复制工单号 / 跳过协同 / 采用 / 不采用 / 成交)。
本文件职责:校验当前会话允许的动作后交给流程执行。
禁止:不校验 wait/允许集合就改主账;禁止一个函数处理未声明的新按钮。
@@ -44,7 +44,7 @@ def handle_card_action(
msg_store = store or get_message_store()
def reply(text: str, extra: dict | None = None) -> None:
def reply(text: str, extra: dict | None = None) -> str:
# 销售询价正文不加 [test];[test] 只给运维告警
body = text
extra = dict(extra or {})
@@ -65,6 +65,8 @@ def handle_card_action(
)
if not ok:
logger.warning("card 出站被去重 sender=%s wo=%s info=%s", sender_id, wo or "-", info)
return ""
return info
return engine.on_button(
sender_id=sender_id, action=action, reply=reply, card_meta=payload
@@ -37,7 +37,7 @@ def handle_continue_thread(
engine = flow or get_air_text_flow()
replies: list[str] = []
def reply(text: str, extra: dict | None = None) -> None:
def reply(text: str, extra: dict | None = None) -> str:
replies.append(text)
ok, info = msg_store.enqueue_outbound(
touser=message.sender_id,
@@ -46,6 +46,7 @@ def handle_continue_thread(
payload=dict(extra or {}),
)
logger.info("continue_thread 出站 ok=%s info=%s", ok, info)
return info if ok else ""
wo = (thread_id or "").strip() or copy.extract_work_order_no(message.content or "")
phase = engine.resume_historical(
+2 -1
View File
@@ -42,7 +42,7 @@ def handle_text_inquiry(
engine = flow or get_air_text_flow()
replies: list[str] = []
def reply(text: str, extra: dict | None = None) -> None:
def reply(text: str, extra: dict | None = None) -> str:
# 销售询价正文不加 [test];[test] 只给运维告警
body = text
replies.append(body)
@@ -53,6 +53,7 @@ def handle_text_inquiry(
payload=dict(extra or {}),
)
logger.info("text_inquiry 出站 ok=%s info=%s phase_reply=%s", ok, info, body[:80])
return info if ok else ""
phase = engine.on_text(
sender_id=message.sender_id,
+2 -1
View File
@@ -70,7 +70,8 @@ class LibreOfficeConverter:
str(exe),
"--headless",
"--convert-to",
"pdf",
# calc 导出才认 Excel 的 A4/居中/页宽缩放;裸 pdf 会裁顶、偏左
"pdf:calc_pdf_Export",
"--outdir",
str(out_dir),
str(source),
+22
View File
@@ -41,6 +41,8 @@ def enqueue_pdf_job(
redis: Optional[RedisRuntime] = None,
touser: str = "",
filename: str = "",
quote: Optional[dict[str, Any]] = None,
facts: Optional[dict[str, Any]] = None,
) -> PdfEnqueueResult:
"""Excel→PDF 任务入队;真正转换只在 LibreOffice 槽。"""
rt = redis or get_redis_runtime()
@@ -61,6 +63,8 @@ def enqueue_pdf_job(
"quote_version": quote_version,
"touser": touser,
"filename": filename,
"quote": dict(quote or {}),
"facts": dict(facts or {}),
}
entry_id = q.enqueue(payload=payload, idempotency_key=idempotency_key)
logger.info("pdf.enqueue id=%s inquiry=%s", entry_id, inquiry_no or "-")
@@ -142,5 +146,23 @@ def _deliver_quote_file(data: dict[str, Any], result: LibreOfficeResult) -> None
"filename": filename,
},
)
# 文件先入队,成交卡后入队;出站单线程保证销售先看到文件
quote = data.get("quote") if isinstance(data.get("quote"), dict) else {}
facts = data.get("facts") if isinstance(data.get("facts"), dict) else {}
if wo:
from agent.policy import inquiry_copy as copy
store.enqueue_outbound(
touser=touser,
content=copy.deal_card(work_order_no=wo, quote=quote, facts=facts),
dedupe_key=f"deal-card:{wo}:{filename}",
payload=copy.deal_wecom_payload(work_order_no=wo, quote=quote, facts=facts),
)
try:
from agent.policy.air_text_flow import get_air_text_flow
get_air_text_flow().mark_wait_deal(touser, wo)
except Exception:
logger.exception("PDF 出文件后未能切到成交跟进 inquiry=%s", wo)
except Exception:
logger.exception("PDF 出站入队失败 inquiry=%s", wo)
+199 -34
View File
@@ -15,7 +15,6 @@ import uuid
from dataclasses import dataclass, field
from typing import Any, Callable, Optional
from agent.config import get_settings
from agent.ledger.http_ledger import HttpLedger
from agent.ledger.memory_ledger import MemoryLedger
from agent.llm.extract_text import extract_inquiry_snapshot
@@ -25,7 +24,8 @@ from agent.schema.tms_air_query import assemble_air_query
logger = logging.getLogger(__name__)
ReplyFn = Callable[..., None]
# 可返回 outbox id;查价前要等询价确认真正发出。
ReplyFn = Callable[..., Optional[str]]
@dataclass
@@ -249,18 +249,13 @@ class AirTextInquiryFlow:
if sess.phase == "tms_miss":
self._emit_tms_miss(reply, sess.work_order_no)
return "tms_miss"
if sess.phase == "wait_file":
self._refresh_wait_file(sess)
if sess.phase == "wait_file":
reply("报价单正在生成,请稍候。")
return "wait_file"
if sess.phase == "wait_deal":
summary = (
f"{sess.facts.get('起运港', '')} → {sess.facts.get('目的港', '')} "
f"{sess.facts.get('品名', '')}"
)
reply(
copy.deal_card(
work_order_no=sess.work_order_no,
status=sess.status or "已报价",
summary=summary,
)
)
self._emit_deal(reply, sess)
return "wait_deal"
if sess.phase == "done":
reply(copy.deal_ack(sess.status or "已关闭", sess.work_order_no))
@@ -426,7 +421,9 @@ class AirTextInquiryFlow:
reply("建单失败,请稍后把刚才那条再发一次。重量和体积已经记下。")
return "ledger_fail"
first, history_wo = self._resolve_similar(created, facts=facts, current_no=no)
reply(copy.inquiry_card(work_order_no=no, facts=facts, first_or_same=first))
out_id = reply(copy.inquiry_card(work_order_no=no, facts=facts, first_or_same=first)) or ""
# 询价确认必须先到销售手机,再打 TMS。否则查得快时暂无报价卡会抢先发出。
self._wait_inquiry_sent(str(out_id), work_order_no=no)
tms = self._ledger.query_tms(work_order_no=no, facts=facts)
sess = FlowSession(
@@ -453,6 +450,7 @@ class AirTextInquiryFlow:
self._emit_tms_miss(reply, no)
sess.phase = "tms_miss"
sess.status = "询价中"
sess.allowed = ()
self._save(sess)
return "tms_miss"
@@ -487,11 +485,41 @@ class AirTextInquiryFlow:
)
return "wait_collab"
def _wait_inquiry_sent(self, out_id: str, *, work_order_no: str) -> None:
"""等询价确认出站完成后再查价。"""
self._wait_outbound_sent(out_id, work_order_no=work_order_no, what="询价确认")
def _wait_outbound_sent(self, out_id: str, *, work_order_no: str, what: str) -> None:
"""
等指定出站真正发出。只等这一条,不锁其它工单。
单测 reply 不回 id 则跳过。超时仍继续,避免卡死 inbox。
"""
raw = (out_id or "").strip()
if not raw:
return
from agent.channel.queue import get_message_store
store = get_message_store()
waiter = getattr(store, "wait_outbound_done", None)
if not callable(waiter):
return
ok = waiter(raw, timeout_sec=30.0)
if not ok:
logger.warning(
"%s出站未在时限内完成 wo=%s out=%s,仍继续",
what,
work_order_no,
raw,
)
def _emit_tms_miss(self, reply: ReplyFn, work_order_no: str) -> None:
"""
TMS 已查过且确认没价:文字兜底 + 暂无报价卡片。
禁止在没打 query_tms 前回这个。
"""
from agent.config import get_settings
reply(
copy.tms_miss(work_order_no=work_order_no),
copy.tms_miss_wecom_payload(
@@ -499,6 +527,8 @@ class AirTextInquiryFlow:
action_url=(get_settings().public_base_url or "").strip(),
),
)
# 卡片发出后再单独发拉群句,不塞进卡、不当卡片失败兜底。
reply(copy.tms_miss_followup(work_order_no=work_order_no))
def _resolve_similar(
self,
@@ -542,6 +572,15 @@ class AirTextInquiryFlow:
"""处理已允许的按钮;错版本/未允许/已点过直接丢弃。"""
sess = self.session_for_action(sender_id, card_meta)
card_wo = copy.work_order_from_card_meta(card_meta)
if sess and sess.phase == "wait_file":
self._refresh_wait_file(sess)
# 成交卡已经发到销售手机,HTTP 进程可能还停在 wait_file,不能丢弃
if (
sess
and action in {copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE}
and sess.phase == "wait_file"
):
self._promote_wait_deal(sess)
if not sess or action not in sess.allowed:
logger.info(
"card 丢弃 sender=%s action=%s card_wo=%s sess_wo=%s phase=%s",
@@ -577,11 +616,11 @@ class AirTextInquiryFlow:
if action == copy.BTN_ADOPT_PDF:
return self._adopt_file(sess, reply, kind="PDF", card_meta=card_meta)
if action == copy.BTN_DEAL:
return self._close(sess, reply, "已成交")
return self._close(sess, reply, "已成交", card_meta=card_meta)
if action == copy.BTN_LOST:
return self._close(sess, reply, "未成交")
return self._close(sess, reply, "未成交", card_meta=card_meta)
if action == copy.BTN_NEGOTIATE:
return self._close(sess, reply, "协商中")
return self._close(sess, reply, "协商中", card_meta=card_meta)
return "card_unknown"
def _skip_collab(
@@ -617,14 +656,24 @@ class AirTextInquiryFlow:
)
return "wait_adopt"
def _grey_clicked_card(self, reply: ReplyFn, card_meta: Optional[dict[str, Any]]) -> None:
"""点过方案卡后把原卡按钮改成已提交;无 ResponseCode 则不更新。"""
def _grey_clicked_card(
self,
reply: ReplyFn,
card_meta: Optional[dict[str, Any]],
*,
replace_name: str = "",
) -> None:
"""
点过模板卡后整卡置灰,三个按钮都不可再点。
企微 update_template_card 一次更新后整卡按钮失效;无 ResponseCode 则不更新。
"""
code = str((card_meta or {}).get("response_code") or "").strip()
if not code:
return
reply(
copy.BTN_SKIP_COLLAB_DONE,
copy.skip_collab_card_update(response_code=code),
copy.skip_collab_card_update(response_code=code, replace_name=replace_name),
)
def _adopt_file(
@@ -670,35 +719,75 @@ class AirTextInquiryFlow:
work_order_no=sess.work_order_no,
sender_id=sess.sender_id,
filename=filename if filename.lower().endswith(".pdf") else f"{sess.work_order_no}_quote.pdf",
quote=dict(sess.quote or {}),
facts=dict(sess.facts or {}),
)
if not queued:
fallback = str(result.get("fileName") or f"{sess.work_order_no}_quote.xlsx")
if not fallback.lower().endswith(".xlsx"):
fallback = f"{sess.work_order_no}_quote.xlsx"
reply("PDF 转换排队失败,已按后台模板发送 Excel 报价单。")
reply(
copy.file_ready(kind="Excel", work_order_no=sess.work_order_no, filename=fallback),
file_extra,
return self._emit_file_then_deal(
sess,
reply,
kind="Excel",
filename=fallback,
file_extra=file_extra,
)
# 已入队:Worker 出文件;此处不冒充 soffice 已成功
else:
reply(
copy.file_ready(kind=kind, work_order_no=sess.work_order_no, filename=filename),
file_extra,
)
summary = f"{sess.facts.get('起运港', '')} → {sess.facts.get('目的港', '')} {sess.facts.get('品名', '')}"
# 已入队:等 Worker 出文件后再出成交卡,这里不提前发卡
sess.phase = "wait_file"
sess.allowed = ()
sess.wait_version += 1
self._save(sess)
return "wait_file"
return self._emit_file_then_deal(
sess, reply, kind=kind, filename=filename, file_extra=file_extra
)
def _emit_file_then_deal(
self,
sess: FlowSession,
reply: ReplyFn,
*,
kind: str,
filename: str,
file_extra: dict[str, Any],
) -> str:
"""
先发报价文件,等这条出站真正发出,再出成交跟进卡。
避免卡片比文件先到销售手机。单测 reply 不回 id 则不等待。
"""
out_id = reply(
copy.file_ready(kind=kind, work_order_no=sess.work_order_no, filename=filename),
file_extra,
)
self._wait_outbound_sent(str(out_id or ""), work_order_no=sess.work_order_no, what="报价文件")
sess.phase = "wait_deal"
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
sess.wait_version += 1
self._save(sess)
self._emit_deal(reply, sess)
return "wait_deal"
def _emit_deal(self, reply: ReplyFn, sess: FlowSession) -> None:
"""
报价单发出后出成交跟进卡:文字兜底 + 企微模板卡。
字段以报价当时的 quote 为准,不编历史报价/工单状态/来源行。
"""
reply(
copy.deal_card(
work_order_no=sess.work_order_no,
status=sess.status or "已报价",
summary=summary,
)
quote=sess.quote,
facts=sess.facts,
),
copy.deal_wecom_payload(
work_order_no=sess.work_order_no,
quote=sess.quote,
facts=sess.facts,
),
)
return "wait_deal"
def _quote_file_payload(self, result: dict[str, Any], *, filename: str) -> dict[str, Any]:
"""出站文件载荷:优先本机路径,否则主账回的 Base64。"""
@@ -716,6 +805,8 @@ class AirTextInquiryFlow:
work_order_no: str,
sender_id: str,
filename: str,
quote: Optional[dict[str, Any]] = None,
facts: Optional[dict[str, str]] = None,
) -> bool:
"""
把主账已填好的 xlsx 交给 Worker LibreOffice 单槽转 PDF。
@@ -744,13 +835,23 @@ class AirTextInquiryFlow:
idempotency_key=f"pdf:{work_order_no}:{uuid.uuid4().hex[:8]}",
touser=sender_id,
filename=filename,
quote=quote,
facts=facts,
)
return bool(queued.accepted)
except Exception:
logger.exception("报价 PDF 入队失败 wo=%s", work_order_no)
return False
def _close(self, sess: FlowSession, reply: ReplyFn, status: str) -> str:
def _close(
self,
sess: FlowSession,
reply: ReplyFn,
status: str,
card_meta: Optional[dict[str, Any]] = None,
) -> str:
"""点成交/未成交/协商中:先整卡置灰,再入主账。"""
self._grey_clicked_card(reply, card_meta, replace_name=status)
result = self._ledger.transition(work_order_no=sess.work_order_no, to_status=status)
if not result.get("ok"):
reply("主账状态更新失败,成交结果未入账。")
@@ -762,6 +863,70 @@ class AirTextInquiryFlow:
reply(copy.deal_ack(status, sess.work_order_no))
return "done"
def mark_wait_deal(self, sender_id: str, work_order_no: str) -> None:
"""
Worker 出完报价文件后:先写 Redis,再切本进程书签。
HTTP 与 Worker 不是同一份内存。只改本进程会让销售一直看到「请稍候」。
找不到本进程书签也要写 Redis,让收消息那边能切到成交跟进。
"""
no = (work_order_no or "").strip()
if not no:
return
from agent.redis_coord.quote_file import mark_quote_file_ready
mark_quote_file_ready(no)
if not sender_id:
return
with self._lock:
sess = self._by_sender.get(sender_id)
if sess is None or sess.work_order_no != no:
sess = self._by_ticket.get(no)
if sess is None:
return
self._promote_wait_deal(sess)
def _promote_wait_deal(self, sess: FlowSession) -> None:
"""书签切到成交跟进,允许点成交/未成交/协商中。"""
sess.phase = "wait_deal"
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
sess.wait_version += 1
self._save(sess)
def _refresh_wait_file(self, sess: FlowSession) -> None:
"""
wait_file 时看文件是否已经发出。
Worker 写 Redis;本进程重启后也可看出站成交卡/文件去重键。
"""
if sess.phase != "wait_file":
return
no = (sess.work_order_no or "").strip()
if not no:
return
if self._quote_file_already_out(no):
self._promote_wait_deal(sess)
def _quote_file_already_out(self, work_order_no: str) -> bool:
"""文件或成交卡是否已经入过出站。"""
from agent.redis_coord.quote_file import is_quote_file_ready
if is_quote_file_ready(work_order_no):
return True
try:
from agent.channel.queue import get_message_store
store = get_message_store()
probe = getattr(store, "has_outbound_dedupe", None)
if not callable(probe):
return False
return bool(
probe(f"deal-card:{work_order_no}:") or probe(f"pdf-file:{work_order_no}:")
)
except Exception:
logger.exception("判断报价文件是否已出站失败 wo=%s", work_order_no)
return False
_FLOW: Optional[AirTextInquiryFlow] = None
_FLOW_GUARD = threading.Lock()
+177 -45
View File
@@ -47,6 +47,12 @@ BTN_REJECT_KEY = "reject"
BTN_DEAL = "成交"
BTN_LOST = "未成交"
BTN_NEGOTIATE = "协商中"
BTN_DEAL_KEY = "deal"
BTN_LOST_KEY = "lost"
BTN_NEGOTIATE_KEY = "negotiate"
# 旧卡 EventKey 兼容;新暂无报价卡按现网图:无按钮。
BTN_COPY_TICKET = "复制工单号"
BTN_COPY_TICKET_KEY = "copy_ticket"
_WO_NO = re.compile(r"WO\d{12}", re.IGNORECASE)
# 仅给无网 stub / 工单号回填用,正式续办意图由 DeepSeek A 判断,不要求销售说这些词。
@@ -245,6 +251,18 @@ def canonical_button(raw: str) -> str:
return BTN_ADOPT_PDF
if text in {BTN_REJECT_KEY} or text.startswith(f"{BTN_REJECT_KEY}:"):
return BTN_REJECT
if text in {BTN_COPY_TICKET, BTN_COPY_TICKET_KEY} or text.startswith(
f"{BTN_COPY_TICKET_KEY}:"
):
return BTN_COPY_TICKET
if text in {BTN_DEAL, BTN_DEAL_KEY} or text.startswith(f"{BTN_DEAL_KEY}:"):
return BTN_DEAL
if text in {BTN_LOST, BTN_LOST_KEY} or text.startswith(f"{BTN_LOST_KEY}:"):
return BTN_LOST
if text in {BTN_NEGOTIATE, BTN_NEGOTIATE_KEY} or text.startswith(
f"{BTN_NEGOTIATE_KEY}:"
):
return BTN_NEGOTIATE
if text == BTN_SKIP_COLLAB_DONE:
return ""
for btn in (BTN_ADOPT_EXCEL, BTN_ADOPT_PDF, BTN_REJECT, BTN_DEAL, BTN_LOST, BTN_NEGOTIATE):
@@ -313,6 +331,10 @@ def _work_order_in_token(raw: str) -> str:
f"{BTN_ADOPT_EXCEL_KEY}:",
f"{BTN_ADOPT_PDF_KEY}:",
f"{BTN_REJECT_KEY}:",
f"{BTN_COPY_TICKET_KEY}:",
f"{BTN_DEAL_KEY}:",
f"{BTN_LOST_KEY}:",
f"{BTN_NEGOTIATE_KEY}:",
):
if text.startswith(prefix):
rest = text[len(prefix) :].strip()
@@ -439,7 +461,7 @@ def tms_valid_until_display(quote: dict[str, Any] | None) -> str:
def similar_inquiry_line(*, first_or_same: str, history_work_order_no: str = "") -> str:
"""
相同询价内容检索:首次询价,或引用全库最新一条一模一样的历史工单号。
相同询价内容检索文案(主账仍会算,销售侧价格卡已隐藏,不再调用)。
"""
no = (history_work_order_no or "").strip()
if first_or_same == "same" and no:
@@ -455,11 +477,12 @@ def quote_detail_lines(
status: str = "已报价",
) -> list[str]:
"""
价格明细正文:TMS 费用、合计、时效、有效期、相同检索。
价格明细正文:TMS 费用、合计、时效、有效期。
给文本兜底和企微引用条共用,禁止在这里编时效/有效期。价格卡不再展示工单状态。
给文本兜底和企微引用条共用,禁止在这里编时效/有效期。
价格卡不展示工单状态,也不展示相同询价内容检索。
"""
_ = status
_ = status, first_or_same, history_work_order_no
src = dict(quote or {})
header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip()
total = money_text(str(src.get("total") or "-").strip() or "-", header_ccy)
@@ -467,11 +490,6 @@ def quote_detail_lines(
lines.append(f"总销售价合计:{total}")
lines.append(f"时效:{tms_eta_display(src)}")
lines.append(f"报价有效期:{tms_valid_until_display(src)}")
lines.append(
similar_inquiry_line(
first_or_same=first_or_same, history_work_order_no=history_work_order_no
)
)
return lines
@@ -518,22 +536,21 @@ def tms_hit_wecom_payload(
价格明细
┌ 引用条:空运费 / 其它费用 / 总销售价合计
时效、报价有效期、相同询价内容检索
时效、报价有效期
协同说明(普通文字,贴按钮上方)
按钮
相同询价内容检索对销售隐藏;first_or_same 只兼容调用方,不展示。
企微 quote_area.title 只能很短,超长会被整段藏掉。报价必须放 quote_text。
不用 horizontal:它会排到副标题后面,把费用挤到按钮前。
"""
_ = first_or_same, history_work_order_no
src = dict(quote or {})
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
meta = "\n".join(
[
f"时效:{tms_eta_display(src)}",
f"报价有效期:{tms_valid_until_display(src)}",
similar_inquiry_line(
first_or_same=first_or_same, history_work_order_no=history_work_order_no
),
"",
collab_hint(work_order_no),
]
@@ -562,58 +579,86 @@ def tms_hit_wecom_payload(
}
def skip_collab_card_update(*, response_code: str) -> dict[str, Any]:
def skip_collab_card_update(*, response_code: str, replace_name: str = "") -> dict[str, Any]:
"""
点过「生成报价单」后更新原卡:按钮改成已提交并保持置灰。
点过模板卡后更新原卡:被点按钮改成已提交,整卡置灰不可再点。
必须带企微回调 ResponseCode;无码则不要入出站。
成交卡三个按钮共用这一条,replace_name 默认「已提交」。
"""
return {
"msgtype": "update_template_card",
"response_code": response_code,
"button": {"replace_name": BTN_SKIP_COLLAB_DONE},
"button": {"replace_name": (replace_name or BTN_SKIP_COLLAB_DONE).strip() or BTN_SKIP_COLLAB_DONE},
}
def tms_miss_quote_text(*, work_order_no: str) -> str:
"""引用条正文:只放「请务必在群聊发送工单编号」。"""
return f"请务必在群聊发送工单编号{work_order_no}"
"""文字兜底里的拉群句,与现网图一致。"""
return f"请手动拉相关人员和智能体到原有群,并在群聊发送工单编号{work_order_no}"
def tms_miss_hint() -> str:
"""卡片正文:无价说明 + 拉群提示,不单独再写标题下灰字。"""
return "TMS标准价库暂无匹配报价,请手动拉新人与智能体到原有群"
"""卡片标题下灰字:与现网图一致。"""
return "TMS 标准价库暂无匹配报价"
def tms_miss_body() -> str:
"""卡片中间说明:需求已建单。"""
return "需求已解析并创建工单。"
def tms_miss(*, work_order_no: str) -> str:
"""
TMS 已经查过、确认没标准价时的文字兜底。
卡片发送失败时的短兜底。拉群句另发,不写在这里。
禁止在没打 TMS 前用本句冒充无价。
"""
return (
f"TMS暂无报价。{tms_miss_hint()},"
f"{tms_miss_quote_text(work_order_no=work_order_no)}"
)
no = (work_order_no or "").strip()
return f"询价工单{no}已建立,TMS标准价库暂无匹配报价。"
def tms_miss_wecom_payload(*, work_order_no: str, action_url: str = "") -> dict[str, Any]:
def tms_miss_followup(*, work_order_no: str) -> str:
"""出卡后再发的独立一句:拉人进群并带上工单号。"""
return tms_miss_quote_text(work_order_no=work_order_no)
def copy_ticket_text(*, work_order_no: str) -> str:
"""
TMS 已查过、确认没价:一张 text_notice 卡。
点「复制工单号」后回给销售的正文:只含工单号。
顺序:来源 → 标题(仅工单号+暂无报价)→ 引用条(工单编号)→ 拉群说明。
禁止:标题下灰字、工单状态、查价结果/来源行、按钮。
企微 text_notice 必须带可信域名 card_action,否则整卡发失败只剩文字。
企微模板卡按钮不能写系统剪贴板。整条消息只有工单号,销售长按「复制」不会夹杂其它字。
"""
return (work_order_no or "").strip()
def tms_miss_wecom_payload(
*,
work_order_no: str,
action_url: str = "",
copy_url: str = "",
) -> dict[str, Any]:
"""
TMS 已查过、确认没价:一张文本通知卡(对齐现网图)。
询价工单号 / 灰字无价说明 / 建单说明 / 工单状态与查价结果。
不要「来源」行、不要按钮。企微 text_notice 必须带可信域名 card_action。
copy_url 忽略:不再跳复制页。
"""
_ = copy_url
no = (work_order_no or "").strip()
card: dict[str, Any] = {
"card_type": "text_notice",
"source": {"desc": "TMS查价结果", "desc_color": 0},
"main_title": {"title": f"{work_order_no}暂无报价"},
"quote_area": {
"type": 0,
"quote_text": tms_miss_quote_text(work_order_no=work_order_no),
"main_title": {
"title": f"询价工单 {no}",
"desc": tms_miss_hint(),
},
"sub_title_text": tms_miss_hint(),
"task_id": card_task_id("miss", work_order_no),
"sub_title_text": tms_miss_body(),
"horizontal_content_list": [
{"keyname": "工单状态", "value": "询价中"},
{"keyname": "查价结果", "value": "暂无匹配报价"},
],
"task_id": card_task_id("miss", no),
}
url = (action_url or "").strip()
if url:
@@ -778,15 +823,102 @@ def file_ready(*, kind: str, work_order_no: str, filename: str) -> str:
return f"已生成{kind}最终报价单:{filename}(工单 {work_order_no})"
def deal_card(*, work_order_no: str, status: str, summary: str) -> str:
return (
"【咨询成交状态】\n"
f"工单编号:{work_order_no}\n"
f"工单状态:{status}\n"
f"询价内容:{summary}\n"
"请立即确认当前与客户沟通的成交状态:\n"
f"【{BTN_DEAL}】【{BTN_LOST}】【{BTN_NEGOTIATE}】"
)
DEAL_HINT = "咨询成交状态:请选择成交 / 未成交 / 协商中(未成交需补充原因)。"
DEAL_SUBTITLE = "报价单已发送,请跟进成交状态"
def deal_horizontal_rows(
*,
quote: dict[str, Any] | None,
facts: dict[str, str] | None = None,
) -> list[dict[str, str]]:
"""
成交跟进卡字段:总销售价合计、时效、报价有效期。
不要历史报价 / 工单状态 / 来源。来源只留卡片顶部红字。
"""
src = dict(quote or {})
header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip()
total = money_text(str(src.get("total") or "-").strip() or "-", header_ccy)
return [
{"keyname": "总销售价合计", "value": _clip(total, 26)},
{"keyname": "时效", "value": _clip(tms_eta_display(src), 26)},
{"keyname": "报价有效期", "value": _clip(tms_valid_until_display(src), 26)},
]
def deal_card(
*,
work_order_no: str,
status: str = "",
summary: str = "",
quote: dict[str, Any] | None = None,
facts: dict[str, str] | None = None,
) -> str:
"""
成交跟进文字兜底(模板卡发失败时仍给销售看)。
与卡片同一套字段;status / summary 只兼容旧调用,不再展示。
"""
_ = status, summary
no = (work_order_no or "").strip()
rows = deal_horizontal_rows(quote=quote, facts=facts)
lines = [
f"询价工单 {no}",
DEAL_SUBTITLE,
DEAL_HINT,
]
lines.extend(f"{row['keyname']}:{row['value']}" for row in rows)
lines.append(f"【{BTN_DEAL}】【{BTN_LOST}】【{BTN_NEGOTIATE}】")
return "\n".join(lines)
def deal_wecom_payload(
*,
work_order_no: str,
quote: dict[str, Any] | None = None,
facts: dict[str, str] | None = None,
) -> dict[str, Any]:
"""
成交跟进卡:对齐现网 button_interaction。
顶部来源红字;标题询价工单号;副标题跟进成交;三个按钮。
字段只要总销售价、时效、有效期。
"""
src = dict(quote or {})
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
no = (work_order_no or "").strip()
return {
"msgtype": "template_card",
"template_card": {
"card_type": "button_interaction",
"source": {"desc": source, "desc_color": 0},
"main_title": {
"title": f"询价工单 {no}",
"desc": DEAL_SUBTITLE,
},
"sub_title_text": DEAL_HINT,
"horizontal_content_list": deal_horizontal_rows(quote=src, facts=facts),
"task_id": card_task_id("deal", no),
"button_list": [
{
"text": BTN_DEAL,
"style": 1,
"key": f"{BTN_DEAL_KEY}:{no}",
},
{
"text": BTN_LOST,
"style": 1,
"key": f"{BTN_LOST_KEY}:{no}",
},
{
"text": BTN_NEGOTIATE,
"style": 1,
"key": f"{BTN_NEGOTIATE_KEY}:{no}",
},
],
},
}
def deal_ack(outcome: str, work_order_no: str) -> str:
+4
View File
@@ -44,3 +44,7 @@ STREAM_WAKE = "stream:wake"
IDEMPOTENCY = "idem"
RATE_LIMIT = "ratelimit"
TOKEN_WECOM = "token:wecom"
TOKEN_COPY_TICKET = "token:copy_ticket"
TOKEN_JSAPI = "token:jsapi"
# Worker 出完报价文件后写给 HTTP:短协调,不当六态
QUOTE_FILE_READY = "quote_file_ready"
@@ -0,0 +1,43 @@
"""
报价文件完成短协调。
Worker LibreOffice 出完文件后写键;HTTP 进程读键,把 wait_file 切到成交跟进。
不当六态、不当 checkpoint。TTL 一周,过期当未完成。
"""
from __future__ import annotations
import logging
from agent.redis_coord.keys import QUOTE_FILE_READY
from agent.redis_coord.runtime import get_redis_runtime
logger = logging.getLogger(__name__)
_TTL_SEC = 7 * 24 * 3600
def mark_quote_file_ready(work_order_no: str) -> None:
"""Worker 出完报价文件后调用;HTTP 进程据此结束「请稍候」。"""
no = (work_order_no or "").strip()
if not no:
return
try:
client = get_redis_runtime().client
client.raw.set(client.key(QUOTE_FILE_READY, no), "1", ex=_TTL_SEC)
logger.info("报价文件已就绪 wo=%s", no)
except Exception:
logger.exception("写报价文件就绪键失败 wo=%s", no)
def is_quote_file_ready(work_order_no: str) -> bool:
"""HTTP 读:文件是否已经发出。读失败当未完成,继续请稍候。"""
no = (work_order_no or "").strip()
if not no:
return False
try:
client = get_redis_runtime().client
return bool(client.raw.get(client.key(QUOTE_FILE_READY, no)))
except Exception:
logger.exception("读报价文件就绪键失败 wo=%s", no)
return False
+349 -30
View File
@@ -238,7 +238,8 @@ class AirTextInquiryTests(unittest.TestCase):
self.assertIn("总销售价合计", joined)
self.assertIn("时效:", joined)
self.assertIn("报价有效期:", joined)
self.assertIn("相同询价内容检索:首次询价", joined)
self.assertNotIn("相同询价内容检索", joined)
self.assertNotIn("引用历史工单号", joined)
self.assertNotIn("工单状态:已报价", joined)
self.assertNotIn("【价格明细】", joined)
@@ -292,9 +293,44 @@ class AirTextInquiryTests(unittest.TestCase):
self.assertEqual(phase, "wait_deal")
self.assertIn("Excel", "\n".join(self.replies))
self.assertIn("咨询成交状态", "\n".join(self.replies))
deal_cards = [
x["template_card"]
for x in self.extras
if x.get("msgtype") == "template_card"
and str((x.get("template_card") or {}).get("task_id") or "").startswith("deal-")
]
self.assertTrue(deal_cards)
deal_rows = {r["keyname"]: r["value"] for r in deal_cards[-1]["horizontal_content_list"]}
self.assertIn("总销售价合计", deal_rows)
self.assertNotIn("历史报价", deal_rows)
self.assertNotIn("工单状态", deal_rows)
self.assertNotIn("来源", deal_rows)
phase = self.flow.on_button(sender_id="u1", action=copy.BTN_DEAL, reply=self._reply)
file_idx = next(
i for i, x in enumerate(self.extras) if x.get("msgtype") == "file"
)
deal_idx = next(
i
for i, x in enumerate(self.extras)
if x.get("msgtype") == "template_card"
and str((x.get("template_card") or {}).get("task_id") or "").startswith("deal-")
)
self.assertLess(file_idx, deal_idx)
phase = self.flow.on_button(
sender_id="u1",
action=copy.BTN_DEAL,
reply=self._reply,
card_meta={"response_code": "rc-deal-1"},
)
self.assertEqual(phase, "done")
deal_updates = [
x
for x in self.extras
if x.get("msgtype") == "update_template_card"
and x.get("response_code") == "rc-deal-1"
]
self.assertTrue(deal_updates)
self.assertEqual(deal_updates[-1].get("button", {}).get("replace_name"), "已成交")
ticket = self.flow.ledger.get_ticket(
work_order_no=self.flow.session_of("u1").work_order_no
)
@@ -367,14 +403,14 @@ class AirTextInquiryTests(unittest.TestCase):
self.assertEqual(called[0]["facts"].get("起运港"), "阿拉伯")
self.assertFalse(called[0]["facts"].get("originCode"))
self.assertEqual(phase, "tms_miss")
self.assertIn("暂无报价", self.replies[-1])
self.assertTrue(any("暂无匹配报价" in x for x in self.replies))
self.assertIn("工单编号WO202609140088", self.replies[-1])
self.assertNotIn("查价参数尚未齐全", "\n".join(self.replies))
miss = [x for x in self.extras if x.get("msgtype") == "template_card"]
self.assertTrue(miss)
self.assertEqual(miss[-1]["template_card"]["main_title"]["title"], "WO202609140088暂无报价")
self.assertEqual(
miss[-1]["template_card"]["quote_area"]["quote_text"],
"请务必在群聊发送工单编号WO202609140088",
miss[-1]["template_card"]["main_title"]["title"],
"询价工单 WO202609140088",
)
def test_unknown_airport_no_card(self) -> None:
@@ -389,7 +425,8 @@ class AirTextInquiryTests(unittest.TestCase):
)
# 目的地说了就算齐;对不上字典不补问,查价后按 TMS 分类
self.assertEqual(phase, "tms_miss")
self.assertIn("暂无报价", self.replies[-1])
self.assertTrue(any("暂无匹配报价" in x for x in self.replies))
self.assertIn("工单编号", self.replies[-1])
def test_tms_miss_copy(self) -> None:
facts = dict(COMPLETE_AIR)
@@ -402,21 +439,90 @@ class AirTextInquiryTests(unittest.TestCase):
injected_mode="AIR",
)
self.assertEqual(phase, "tms_miss")
self.assertIn("暂无报价", self.replies[-1])
self.assertIn("工单编号", self.replies[-1])
wo = self.flow.session_of("u2").work_order_no
self.assertEqual(self.replies[-1], copy.tms_miss_followup(work_order_no=wo))
self.assertTrue(any("暂无匹配报价" in x for x in self.replies))
miss = [x for x in self.extras if x.get("msgtype") == "template_card"]
self.assertTrue(miss)
card = miss[-1]["template_card"]
self.assertEqual(card["card_type"], "text_notice")
self.assertEqual(card["source"]["desc"], "TMS查价结果")
self.assertEqual(card["main_title"]["title"], self.flow.session_of("u2").work_order_no + "暂无报价")
self.assertNotIn("desc", card["main_title"])
self.assertEqual(card["sub_title_text"], copy.tms_miss_hint())
self.assertEqual(
card["quote_area"]["quote_text"],
copy.tms_miss_quote_text(work_order_no=self.flow.session_of("u2").work_order_no),
)
self.assertNotIn("horizontal_content_list", card)
self.assertEqual(card["main_title"]["title"], f"询价工单 {wo}")
self.assertEqual(card["main_title"]["desc"], copy.tms_miss_hint())
self.assertEqual(card["sub_title_text"], copy.tms_miss_body())
rows = {x["keyname"]: x["value"] for x in card["horizontal_content_list"]}
self.assertEqual(rows["工单状态"], "询价中")
self.assertEqual(rows["查价结果"], "暂无匹配报价")
self.assertNotIn("来源", rows)
self.assertNotIn("button_list", card)
self.assertEqual(self.flow.session_of("u2").allowed, ())
def test_inquiry_must_send_before_tms_query(self) -> None:
"""询价确认出站完成之前不能打 TMS,避免暂无报价卡抢先。"""
import threading
import time
from unittest.mock import patch
from agent.channel.queue import TaskState
store = MemoryMessageStore()
query_saw: list[str] = []
class _Led:
def create_ticket(self, **kwargs):
return {
"ok": True,
"work_order_no": "WO202609150111",
"first_or_same": "first",
}
def query_tms(self, **kwargs):
inq = [
i
for i in store._outbox.values()
if "正在查询TMS报价" in (i.content or "")
]
query_saw.append(inq[0].state.value if inq else "missing")
return {
"ok": True,
"has_price": False,
"classification": "TMS_NO_QUOTE_1002",
}
def reply(text: str, extra=None) -> str:
_ok, oid = store.enqueue_outbound(
touser="u_ord",
content=text,
payload=dict(extra or {}),
)
return oid
stop = threading.Event()
def pump() -> None:
while not stop.is_set():
item = store.claim_outbound()
if item:
store.complete_outbound(item.id)
else:
time.sleep(0.01)
worker = threading.Thread(target=pump, daemon=True)
worker.start()
try:
with patch("agent.channel.queue.get_message_store", return_value=store):
flow = AirTextInquiryFlow(_Led())
phase = flow.on_text(
sender_id="u_ord",
text="空运",
reply=reply,
injected_facts=COMPLETE_AIR,
injected_mode="AIR",
)
finally:
stop.set()
self.assertEqual(phase, "tms_miss")
self.assertEqual(query_saw, [TaskState.DONE.value])
def test_incomplete_skip_is_not_no_quote(self) -> None:
"""主账未出站不能对销售说暂无报价。"""
@@ -469,7 +575,9 @@ class AirTextInquiryTests(unittest.TestCase):
)
no2 = self.flow.session_of("u4").work_order_no
self.assertIn("工单已创建,正在查询TMS报价", "\n".join(self.replies))
self.assertIn(f"引用历史工单号{no1}", "\n".join(self.replies))
self.assertNotIn("相同询价内容检索", "\n".join(self.replies))
self.assertNotIn("引用历史工单号", "\n".join(self.replies))
self.assertEqual(self.flow.session_of("u4").history_work_order_no, no1)
self.assertTrue(no1)
self.assertTrue(no2)
self.assertNotEqual(no1, no2)
@@ -562,18 +670,31 @@ class AirTextInquiryTests(unittest.TestCase):
card = payload["template_card"]
self.assertEqual(card["card_type"], "text_notice")
self.assertEqual(card["source"]["desc"], "TMS查价结果")
self.assertEqual(card["main_title"]["title"], "WO202606160008暂无报价")
self.assertNotIn("desc", card["main_title"])
self.assertEqual(card["sub_title_text"], "TMS标准价库暂无匹配报价,请手动拉新人与智能体到原有群")
self.assertEqual(card["quote_area"]["quote_text"], "请务必在群聊发送工单编号WO202606160008")
self.assertNotIn("title", card["quote_area"])
self.assertNotIn("horizontal_content_list", card)
self.assertEqual(card["main_title"]["title"], "询价工单 WO202606160008")
self.assertEqual(card["main_title"]["desc"], "TMS 标准价库暂无匹配报价")
self.assertEqual(card["sub_title_text"], "需求已解析并创建工单。")
rows = {x["keyname"]: x["value"] for x in card["horizontal_content_list"]}
self.assertEqual(rows, {
"工单状态": "询价中",
"查价结果": "暂无匹配报价",
})
self.assertNotIn("来源", rows)
self.assertNotIn("button_list", card)
self.assertNotIn("quote_area", card)
self.assertNotIn("card_action", card)
with_url = copy.tms_miss_wecom_payload(
work_order_no="WO202606160008",
action_url="https://example.test",
)["template_card"]
self.assertEqual(with_url["card_action"]["url"], "https://example.test")
self.assertEqual(
copy.tms_miss(work_order_no="WO202606160008"),
"询价工单WO202606160008已建立,TMS标准价库暂无匹配报价。",
)
self.assertEqual(
copy.tms_miss_followup(work_order_no="WO202606160008"),
"请手动拉相关人员和智能体到原有群,并在群聊发送工单编号WO202606160008",
)
def test_quote_card_payload_has_clickable_button(self) -> None:
payload = copy.tms_hit_wecom_payload(
@@ -623,7 +744,8 @@ class AirTextInquiryTests(unittest.TestCase):
sub = card["sub_title_text"]
self.assertIn("时效:3-5 天", sub)
self.assertIn("报价有效期:2026-09-20", sub)
self.assertIn("相同询价内容检索:首次询价", sub)
self.assertNotIn("相同询价内容检索", sub)
self.assertNotIn("引用历史工单号", sub)
self.assertNotIn("工单状态", sub)
self.assertTrue(sub.endswith(copy.collab_hint("WO202609140001")))
self.assertNotIn("内部协同", json.dumps(card, ensure_ascii=False))
@@ -710,6 +832,67 @@ class AirTextInquiryTests(unittest.TestCase):
self.assertEqual(copy.canonical_button("adopt_excel:WO202609140001"), copy.BTN_ADOPT_EXCEL)
self.assertEqual(copy.canonical_button(copy.BTN_ADOPT_EXCEL_CARD), copy.BTN_ADOPT_EXCEL)
def test_deal_card_matches_followup_layout(self) -> None:
quote = {
"total": "722.5",
"currency": "CNY",
"eta": "2天",
"eta_date": "2026-08-23",
"valid_until": "2026-08-24",
"source_label": "TMS 标准报价",
}
text = copy.deal_card(work_order_no="WO202608210017", quote=quote)
self.assertIn("询价工单 WO202608210017", text)
self.assertIn("报价单已发送,请跟进成交状态", text)
self.assertIn("咨询成交状态:请选择成交 / 未成交 / 协商中(未成交需补充原因)。", text)
self.assertIn("总销售价合计:CNY 722.5", text)
self.assertIn("时效:2天", text)
self.assertNotIn("时效(要求/TMS)", text)
self.assertNotIn("- / -", text)
self.assertIn("报价有效期:2026-08-24", text)
self.assertNotIn("历史报价", text)
self.assertNotIn("工单状态", text)
self.assertNotIn("来源:", text)
self.assertNotIn("总成本价合计", text)
payload = copy.deal_wecom_payload(work_order_no="WO202608210017", quote=quote)
card = payload["template_card"]
self.assertEqual(payload["msgtype"], "template_card")
self.assertEqual(card["card_type"], "button_interaction")
self.assertEqual(card["source"]["desc"], "TMS标准报价")
self.assertEqual(card["main_title"]["title"], "询价工单 WO202608210017")
self.assertEqual(card["main_title"]["desc"], "报价单已发送,请跟进成交状态")
self.assertEqual(card["sub_title_text"], copy.DEAL_HINT)
rows = {x["keyname"]: x["value"] for x in card["horizontal_content_list"]}
self.assertEqual(
rows,
{
"总销售价合计": "CNY 722.5",
"时效": "2天",
"报价有效期": "2026-08-24",
},
)
self.assertNotIn("历史报价", rows)
self.assertNotIn("工单状态", rows)
self.assertNotIn("来源", rows)
self.assertEqual(
[x["text"] for x in card["button_list"]],
[copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE],
)
self.assertEqual(card["button_list"][0]["key"], "deal:WO202608210017")
self.assertTrue(str(card["task_id"]).startswith("deal-WO202608210017-"))
self.assertEqual(
copy.work_order_from_card_meta({"event_key": "lost:WO202608210017"}),
"WO202608210017",
)
self.assertEqual(copy.canonical_button("deal:WO202608210017"), copy.BTN_DEAL)
self.assertEqual(copy.canonical_button("negotiate:WO202608210017"), copy.BTN_NEGOTIATE)
empty_rows = {
x["keyname"]: x["value"]
for x in copy.deal_horizontal_rows(quote={"total": "-", "valid_until": "-"})
}
self.assertEqual(empty_rows["时效"], "-")
self.assertNotEqual(empty_rows["时效"], "- / -")
def test_same_demand_cites_latest_even_if_closed(self) -> None:
ledger = MemoryLedger()
flow = AirTextInquiryFlow(ledger)
@@ -745,7 +928,9 @@ class AirTextInquiryTests(unittest.TestCase):
wo2 = flow.session_of("hist-b").work_order_no
self.assertTrue(wo2)
self.assertNotEqual(wo2, wo1)
self.assertIn(f"引用历史工单号{wo1}", "\n".join(replies_b))
self.assertEqual(flow.session_of("hist-b").history_work_order_no, wo1)
self.assertNotIn("相同询价内容检索", "\n".join(replies_b))
self.assertNotIn("引用历史工单号", "\n".join(replies_b))
replies_c: list[str] = []
def _keep_c(text: str, extra=None) -> None:
@@ -761,8 +946,10 @@ class AirTextInquiryTests(unittest.TestCase):
)
wo3 = flow.session_of("hist-c").work_order_no
self.assertNotEqual(wo3, wo2)
self.assertIn(f"引用历史工单号{wo2}", "\n".join(replies_c))
self.assertNotIn(f"引用历史工单号{wo1}", "\n".join(replies_c))
self.assertEqual(flow.session_of("hist-c").history_work_order_no, wo2)
self.assertNotEqual(flow.session_of("hist-c").history_work_order_no, wo1)
self.assertNotIn("相同询价内容检索", "\n".join(replies_c))
self.assertNotIn("引用历史工单号", "\n".join(replies_c))
def test_card_event_key_matches_skip_button(self) -> None:
from agent.channel.wecom.parse import parse_plain_xml
@@ -990,6 +1177,138 @@ class AirTextInquiryTests(unittest.TestCase):
self.assertEqual(ledger.last_render.get("matchType"), "default")
self.assertEqual(ledger.last_render.get("templateName"), "默认空运")
def test_pdf_queue_does_not_emit_deal_before_file(self) -> None:
"""PDF 还在 LibreOffice 队列时,不能先出成交跟进卡。"""
ledger = MemoryLedger()
ledger.set_quote_templates(
[{"templateId": "d1", "name": "默认空运", "bizType": "空运", "isDefault": True}]
)
orig = ledger.render_quote
if __name__ == "__main__":
unittest.main()
def _render(**kwargs):
out = orig(**kwargs)
out["needsPdf"] = True
return out
ledger.render_quote = _render # type: ignore[method-assign]
flow = AirTextInquiryFlow(ledger)
replies: list[str] = []
flow.on_text(
sender_id="u-pdf-q",
text="空运",
reply=lambda t, extra=None: replies.append(t),
injected_facts=COMPLETE_AIR,
injected_mode="AIR",
)
flow.on_button(
sender_id="u-pdf-q",
action=copy.BTN_SKIP_COLLAB,
reply=lambda t, extra=None: replies.append(t),
)
flow._enqueue_quote_pdf = lambda *a, **k: True # type: ignore[method-assign]
phase = flow.on_button(
sender_id="u-pdf-q",
action=copy.BTN_ADOPT_PDF,
reply=lambda t, extra=None: replies.append(t),
)
self.assertEqual(phase, "wait_file")
self.assertNotIn("咨询成交状态", "\n".join(replies))
self.assertEqual(flow.session_of("u-pdf-q").phase, "wait_file")
again: list[str] = []
replay = flow._replay_wait(flow.session_of("u-pdf-q"), lambda t, extra=None: again.append(t), source="test")
self.assertEqual(replay, "wait_file")
self.assertIn("请稍候", "\n".join(again))
self.assertNotIn("咨询成交状态", "\n".join(again))
def test_worker_file_ready_unblocks_http_wait_file(self) -> None:
"""Worker 出完文件后 HTTP 进程必须能切到成交跟进,不能一直请稍候。"""
ledger = MemoryLedger()
ledger.set_quote_templates(
[{"templateId": "d1", "name": "默认空运", "bizType": "空运", "isDefault": True}]
)
orig = ledger.render_quote
def _render(**kwargs):
out = orig(**kwargs)
out["needsPdf"] = True
return out
ledger.render_quote = _render # type: ignore[method-assign]
http = AirTextInquiryFlow(ledger)
replies: list[str] = []
http.on_text(
sender_id="u-pdf-x",
text="空运",
reply=lambda t, extra=None: replies.append(t),
injected_facts=COMPLETE_AIR,
injected_mode="AIR",
)
http.on_button(
sender_id="u-pdf-x",
action=copy.BTN_SKIP_COLLAB,
reply=lambda t, extra=None: replies.append(t),
)
http._enqueue_quote_pdf = lambda *a, **k: True # type: ignore[method-assign]
http.on_button(
sender_id="u-pdf-x",
action=copy.BTN_ADOPT_PDF,
reply=lambda t, extra=None: replies.append(t),
)
self.assertEqual(http.session_of("u-pdf-x").phase, "wait_file")
wo = http.session_of("u-pdf-x").work_order_no
worker = AirTextInquiryFlow(ledger)
worker.mark_wait_deal("u-pdf-x", wo)
again: list[str] = []
replay = http._replay_wait(
http.session_of("u-pdf-x"),
lambda t, extra=None: again.append(t),
source="test",
)
self.assertEqual(replay, "wait_deal")
self.assertIn("咨询成交状态", "\n".join(again))
self.assertEqual(http.session_of("u-pdf-x").phase, "wait_deal")
def test_deal_click_works_while_http_still_wait_file(self) -> None:
"""成交卡已发出时,点成交不能因为书签还停在 wait_file 就没反应。"""
ledger = MemoryLedger()
ledger.set_quote_templates(
[{"templateId": "d1", "name": "默认空运", "bizType": "空运", "isDefault": True}]
)
orig = ledger.render_quote
def _render(**kwargs):
out = orig(**kwargs)
out["needsPdf"] = True
return out
ledger.render_quote = _render # type: ignore[method-assign]
flow = AirTextInquiryFlow(ledger)
flow.on_text(
sender_id="u-pdf-d",
text="空运",
reply=lambda t, extra=None: None,
injected_facts=COMPLETE_AIR,
injected_mode="AIR",
)
flow.on_button(
sender_id="u-pdf-d",
action=copy.BTN_SKIP_COLLAB,
reply=lambda t, extra=None: None,
)
flow._enqueue_quote_pdf = lambda *a, **k: True # type: ignore[method-assign]
flow.on_button(
sender_id="u-pdf-d",
action=copy.BTN_ADOPT_PDF,
reply=lambda t, extra=None: None,
)
self.assertEqual(flow.session_of("u-pdf-d").phase, "wait_file")
extras: list[dict] = []
phase = flow.on_button(
sender_id="u-pdf-d",
action=copy.BTN_DEAL,
reply=lambda t, extra=None: extras.append(dict(extra or {})),
card_meta={"response_code": "rc-stuck-deal"},
)
self.assertEqual(phase, "done")
self.assertEqual(flow.session_of("u-pdf-d").phase, "done")
self.assertTrue(any(x.get("msgtype") == "update_template_card" for x in extras))
@@ -0,0 +1,55 @@
"""
复制工单号页:token 不带工单号,打开页能看到工单号,过期给 410。
"""
from __future__ import annotations
import os
import sys
import unittest
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
os.environ.setdefault("YTD_ENV", "test")
os.environ.setdefault("REDIS_BACKEND", "memory")
os.environ.setdefault("CHECKPOINT_BACKEND", "memory")
os.environ.setdefault("MESSAGE_STORE_BACKEND", "memory")
os.environ.setdefault("LEDGER_BACKEND", "memory")
from fastapi import FastAPI
from fastapi.testclient import TestClient
from agent.channel.h5 import router as h5_router
from agent.channel.h5.copy_page import render_copy_page
from agent.channel.h5.copy_store import build_copy_ticket_url, lookup_copy_token
class CopyTicketH5Tests(unittest.TestCase):
def test_token_roundtrip_and_page(self) -> None:
url = build_copy_ticket_url("WO202609150001")
self.assertIn("/inquiry-form/copy/", url)
token = url.rstrip("/").rsplit("/", 1)[-1]
self.assertEqual(lookup_copy_token(token), "WO202609150001")
self.assertNotIn("WO202609150001", token)
html = render_copy_page("WO202609150001")
self.assertIn("WO202609150001", html)
self.assertIn("setClipboardData", html)
def test_http_copy_page(self) -> None:
url = build_copy_ticket_url("WO202609150002")
token = url.rstrip("/").rsplit("/", 1)[-1]
app = FastAPI()
app.include_router(h5_router)
client = TestClient(app)
hit = client.get(f"/inquiry-form/copy/{token}")
self.assertEqual(hit.status_code, 200)
self.assertIn("WO202609150002", hit.text)
miss = client.get("/inquiry-form/copy/not-a-real-token")
self.assertEqual(miss.status_code, 410)
self.assertIn("过期", miss.text)
if __name__ == "__main__":
unittest.main()
@@ -22,8 +22,10 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
@@ -112,7 +114,25 @@ public class InquiryStaffRoleController extends JeecgController<InquiryStaffRole
}
normalize(body);
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("更新成功");
}
@@ -147,9 +167,40 @@ public class InquiryStaffRoleController extends JeecgController<InquiryStaffRole
}
/**
* 企微部门下拉选项。浏览器禁止直连企微,只打本接口。
* 员工「所属部门」下拉(对齐旧项目 admin-rel26)。
* <p>
* 旧前端:{@code GET /inquiry/staffRole/departments} → {@code string[]},
* 再 {@code options: names.map(n => ({label:n,value:n}))},showSearch 可滚动全量。
* 本接口同源:企微 {@code department/list} 短名全量;根公司(id=1)不进下拉。
* 浏览器禁止直连企微。
*/
@ApiOperation("企微部门列表")
@ApiOperation("员工部门名列表(对齐旧项目 /departments)")
@RequiresPermissions("inquiry:staff:list")
@GetMapping("/departments")
public Result<List<String>> departments() {
List<WeComDepartment> all = weComAddressBookClient.listDepartments();
List<String> names = new ArrayList<>();
LinkedHashSet<String> seen = new LinkedHashSet<>();
for (WeComDepartment d : all) {
if (d == null || StringUtils.isBlank(d.getName())) {
continue;
}
// 企微根部门(id=1 / parentid=0)是公司全称,员工下拉不展示
if (d.getId() == 1 || d.getParentId() <= 0) {
continue;
}
String name = d.getName().trim();
if (seen.add(name)) {
names.add(name);
}
}
return Result.OK(names);
}
/**
* 企微部门结构列表(含 id/path,供排查)。员工下拉请用 {@link #departments()}。
*/
@ApiOperation("企微部门结构列表")
@RequiresPermissions("inquiry:staff:list")
@GetMapping("/wecomDepartments")
public Result<List<WeComDepartment>> wecomDepartments() {
@@ -181,5 +232,18 @@ public class InquiryStaffRoleController extends JeecgController<InquiryStaffRole
if (body.getNotifyEventException() == null) {
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("请选择负责线路");
}
}
}
}
@@ -50,9 +50,18 @@ public class InquiryStaffRole implements Serializable {
@ApiModelProperty("角色展示名")
private String roleLabel;
@ApiModelProperty("负责线路")
@ApiModelProperty("负责线路(逗号分隔;销售可空)")
private String routes;
/**
* 是否线路/产品负责人。
* <p>
* 仅航线·空运 / 产品·海运 / 产品·陆运有意义:1=是、0=否;销售岗位为 null。
* 成交/未成交等通知可按线路找负责人(后台维护,智能体后续读取)。
*/
@ApiModelProperty("是否负责人 1是0否,销售为空")
private Integer isLeader;
@ApiModelProperty("是否接收事件异常提醒 0/1")
private Integer notifyEventException;
@@ -8,7 +8,8 @@ import java.util.Map;
public interface IInquiryDashboardService {
/**
* 返回看板指标:metrics 列表 + trend + rankings。
* 返回看板指标:metrics + trend + rankings + costDates/costRoutes。
* rankings 与工单列表同套发起人解析;costRoutes 为近 7 日线路均价(主账工单投影)。
*
* @param range today|7d|30d,默认 today
*/
@@ -1,12 +1,14 @@
package org.jeecg.modules.inquiry.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.apache.commons.lang3.StringUtils;
import org.jeecg.modules.inquiry.domain.TicketStatuses;
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
import org.jeecg.modules.inquiry.entity.InquiryTicket;
import org.jeecg.modules.inquiry.mapper.InquiryStaffRoleMapper;
import org.jeecg.modules.inquiry.mapper.InquiryTicketMapper;
import org.jeecg.modules.inquiry.service.IInquiryDashboardService;
import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -18,24 +20,41 @@ import java.time.LocalTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 看板实时聚合实现(无快照依赖)。
* <p>
* 计算口径对齐后台原型:询价总数、待处理、TMS 命中率、异常完成率、手工报价占比、
* 计算口径:询价总数、待处理、TMS 命中率、异常完成率、手工报价占比、
* 已成交、成交率、反馈率;内部协同时长本期无协同表时返回「-」。
* 人员排名与工单列表同一套发起人解析(企微 id → 员工姓名),禁止只认库内空 sales_name。
* 成本趋势:近 7 日工单按线路聚合均价(成本价优先,缺省用销售价),非浏览器直连 TMS。
* 线程:只读查询,无写锁。
*/
@Service
public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
private static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ofPattern("MM-dd");
private static final int COST_ROUTE_LIMIT = 16;
private static final String[] COST_COLORS = {
"#2563eb", "#3b82f6", "#0891b2", "#0e7490", "#6366f1", "#8b5cf6",
"#a855f7", "#c026d3", "#db2777", "#e11d48", "#dc2626", "#ea580c",
"#d97706", "#65a30d", "#0d9488", "#059669", "#0284c7"
};
/** 单日金额累加(成本趋势用)。 */
private static final class DaySum {
private BigDecimal sum = BigDecimal.ZERO;
private int count;
}
@Autowired
private InquiryTicketMapper ticketMapper;
@@ -91,59 +110,306 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
metricCards.add(card("accepted", "已成交工单", "状态=已成交的工单数量", String.valueOf(deal), false));
metricCards.add(card("dealRate", "报价成交率", "已成交数 ÷ 已报价相关数 × 100%", pct(deal, Math.max(quoted, 1)), false));
long feedbackNumer = deal + lost;
// 反馈率口径:已成交+未成交(相对周期工单)
metricCards.add(card("feedback", "报价反馈率", "(已成交 + 未成交)÷ 工单数 × 100%", pct(feedbackNumer, total), false));
metricCards.add(card("avgResp", "内部协同平均响应时长", "本期无协同明细表,暂无数据", "-", true));
// 近 7 日趋势(不受 range 限制,便于折线)
List<Map<String, Object>> trend = new ArrayList<>();
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("MM-dd");
// 近 7 日趋势 + 成本:一次拉周内工单,避免 7 次 count
Date weekFrom = Date.from(today.minusDays(6).atStartOfDay(ZONE).toInstant());
List<InquiryTicket> weekTickets = ticketMapper.selectList(new LambdaQueryWrapper<InquiryTicket>()
.ge(InquiryTicket::getCreateTime, weekFrom)
.le(InquiryTicket::getCreateTime, to));
List<String> trendDates = new ArrayList<>(7);
List<Map<String, Object>> trend = new ArrayList<>(7);
Map<LocalDate, Integer> dayIndex = new HashMap<>();
for (int i = 6; i >= 0; i--) {
LocalDate d = today.minusDays(i);
Date dFrom = Date.from(d.atStartOfDay(ZONE).toInstant());
Date dTo = Date.from(LocalDateTime.of(d, LocalTime.MAX).atZone(ZONE).toInstant());
long cnt = ticketMapper.selectCount(new LambdaQueryWrapper<InquiryTicket>()
.ge(InquiryTicket::getCreateTime, dFrom)
.le(InquiryTicket::getCreateTime, dTo));
dayIndex.put(d, trendDates.size());
String label = d.format(DAY_FMT);
trendDates.add(label);
Map<String, Object> p = new HashMap<>();
p.put("date", d.format(fmt));
p.put("value", cnt);
p.put("date", label);
p.put("value", 0L);
trend.add(p);
}
for (InquiryTicket t : weekTickets) {
LocalDate d = toLocalDate(t.getCreateTime());
Integer idx = d == null ? null : dayIndex.get(d);
if (idx == null) {
continue;
}
Map<String, Object> p = trend.get(idx);
p.put("value", ((Number) p.get("value")).longValue() + 1L);
}
// 人员排名:按销售名聚合询价量
Map<String, Long> bySales = tickets.stream()
.filter(t -> t.getSalesName() != null && !t.getSalesName().isEmpty())
.collect(Collectors.groupingBy(InquiryTicket::getSalesName, Collectors.counting()));
Map<String, InquiryStaffRole> staffByName = staffRoleMapper.selectList(null).stream()
.collect(Collectors.toMap(InquiryStaffRole::getName, s -> s, (a, b) -> a));
List<Map<String, Object>> rankings = bySales.entrySet().stream()
.sorted(Map.Entry.<String, Long>comparingByValue().reversed())
.limit(20)
.map(e -> {
Map<String, Object> row = new LinkedHashMap<>();
row.put("name", e.getKey());
InquiryStaffRole staff = staffByName.get(e.getKey());
if (staff != null) {
row.put("role", mapRankRole(staff.getRoleCode()));
row.put("dept", staff.getDepartName());
} else {
row.put("role", "sales");
row.put("dept", "-");
}
row.put("volumeNum", e.getValue());
return row;
})
.collect(Collectors.toList());
List<InquiryStaffRole> staffRows = staffRoleMapper.selectList(null);
Map<String, InquiryStaffRole> staffByWecom = new HashMap<>();
Map<String, InquiryStaffRole> staffByName = new HashMap<>();
for (InquiryStaffRole s : staffRows) {
if (s == null) {
continue;
}
if (StringUtils.isNotBlank(s.getWecomId())) {
staffByWecom.put(s.getWecomId().trim(), s);
}
if (StringUtils.isNotBlank(s.getName())) {
staffByName.putIfAbsent(s.getName().trim(), s);
}
}
List<Map<String, Object>> rankings = buildRankings(tickets, staffByWecom, staffByName);
List<Map<String, Object>> costRoutes = buildCostRoutes(weekTickets, dayIndex, trendDates);
Map<String, Object> result = new LinkedHashMap<>();
result.put("range", range == null ? "today" : range);
result.put("metrics", metricCards);
result.put("trend", trend);
result.put("rankings", rankings);
result.put("costDates", trendDates);
result.put("costRoutes", costRoutes);
return result;
}
/**
* 人员排名:与工单列表 enrichDisplayFields 同口径。
* 聚合键优先 salesWecomId(同人合并),展示名走 resolveSalesDisplayName。
*/
private static List<Map<String, Object>> buildRankings(List<InquiryTicket> tickets,
Map<String, InquiryStaffRole> staffByWecom,
Map<String, InquiryStaffRole> staffByName) {
class Acc {
String name;
InquiryStaffRole staff;
long volume;
}
Map<String, Acc> byKey = new LinkedHashMap<>();
for (InquiryTicket t : tickets) {
if (t == null) {
continue;
}
String wecom = StringUtils.trimToEmpty(t.getSalesWecomId());
InquiryStaffRole staff = StringUtils.isNotBlank(wecom) ? staffByWecom.get(wecom) : null;
String staffName = staff != null ? staff.getName() : null;
String display = InquiryTicketDisplaySupport.resolveSalesDisplayName(
t.getSalesName(), t.getSalesWecomId(), staffName);
if (StringUtils.isBlank(display)) {
continue;
}
// 仅企微 userid、又无员工绑定时不当人名进榜(与列表「应显示中文名」一致)
if (staff == null && InquiryTicketDisplaySupport.looksLikeWecomUserId(display)) {
continue;
}
if (staff == null) {
staff = staffByName.get(display.trim());
}
String key = StringUtils.isNotBlank(wecom) ? "w:" + wecom : "n:" + display.trim();
Acc acc = byKey.get(key);
if (acc == null) {
acc = new Acc();
acc.name = display.trim();
acc.staff = staff;
acc.volume = 0L;
byKey.put(key, acc);
}
acc.volume++;
if (acc.staff == null && staff != null) {
acc.staff = staff;
}
// 有员工中文名时覆盖展示名,避免残留 userid
if (acc.staff != null && StringUtils.isNotBlank(acc.staff.getName())) {
acc.name = acc.staff.getName().trim();
}
}
return byKey.values().stream()
.sorted(Comparator.comparingLong((Acc a) -> a.volume).reversed()
.thenComparing(a -> a.name, Comparator.nullsLast(String::compareTo)))
.limit(20)
.map(a -> {
Map<String, Object> row = new LinkedHashMap<>();
row.put("name", a.name);
if (a.staff != null) {
row.put("role", mapRankRole(a.staff.getRoleCode()));
row.put("dept", StringUtils.defaultIfBlank(a.staff.getDepartName(), "-"));
} else {
row.put("role", "sales");
row.put("dept", "-");
}
row.put("volumeNum", a.volume);
return row;
})
.collect(Collectors.toList());
}
/**
* 成本趋势:近 7 日按线路日均价。
* 金额:cost_price 可解析则用;否则回退 sale_price(避免全空折线)。
* 线路:起运地-目的地;缺省业务线标签。
*/
private static List<Map<String, Object>> buildCostRoutes(List<InquiryTicket> weekTickets,
Map<LocalDate, Integer> dayIndex,
List<String> trendDates) {
int days = trendDates.size();
class RouteAcc {
String label;
DaySum[] daysArr = new DaySum[days];
int samples;
RouteAcc(String label) {
this.label = label;
for (int i = 0; i < days; i++) {
daysArr[i] = new DaySum();
}
}
}
Map<String, RouteAcc> byRoute = new LinkedHashMap<>();
DaySum[] allLine = new DaySum[days];
for (int i = 0; i < days; i++) {
allLine[i] = new DaySum();
}
int allSamples = 0;
for (InquiryTicket t : weekTickets) {
if (t == null) {
continue;
}
BigDecimal amount = parseMoney(t.getCostPrice());
if (amount == null) {
amount = parseMoney(t.getSalePrice());
}
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
continue;
}
LocalDate d = toLocalDate(t.getCreateTime());
Integer idx = d == null ? null : dayIndex.get(d);
if (idx == null) {
continue;
}
String label = routeLabel(t);
String id = routeId(label);
RouteAcc acc = byRoute.get(id);
if (acc == null) {
acc = new RouteAcc(label);
byRoute.put(id, acc);
}
DaySum ds = acc.daysArr[idx];
ds.sum = ds.sum.add(amount);
ds.count++;
acc.samples++;
allLine[idx].sum = allLine[idx].sum.add(amount);
allLine[idx].count++;
allSamples++;
}
List<Map.Entry<String, RouteAcc>> top = byRoute.entrySet().stream()
.sorted(Comparator.comparingInt((Map.Entry<String, RouteAcc> e) -> e.getValue().samples).reversed())
.limit(COST_ROUTE_LIMIT)
.collect(Collectors.toList());
List<Map<String, Object>> routes = new ArrayList<>();
if (allSamples > 0) {
routes.add(toCostRouteRow("quanxian", "全线", COST_COLORS[0], allLine, days));
}
int colorIdx = 1;
for (Map.Entry<String, RouteAcc> e : top) {
if ("quanxian".equals(e.getKey()) || "全线".equals(e.getValue().label)) {
continue;
}
String color = COST_COLORS[colorIdx % COST_COLORS.length];
colorIdx++;
routes.add(toCostRouteRow(e.getKey(), e.getValue().label, color, e.getValue().daysArr, days));
}
return routes;
}
private static Map<String, Object> toCostRouteRow(String id, String label, String color, DaySum[] daysArr, int days) {
List<Object> values = new ArrayList<>(days);
for (int i = 0; i < days; i++) {
DaySum ds = daysArr[i];
if (ds == null || ds.count <= 0) {
values.add(null);
} else {
values.add(ds.sum.divide(BigDecimal.valueOf(ds.count), 2, RoundingMode.HALF_UP));
}
}
Map<String, Object> row = new LinkedHashMap<>();
row.put("id", id);
row.put("label", label);
row.put("color", color);
row.put("values", values);
return row;
}
/** 金额串:「CNY 1,234.5」「1234.5」→ BigDecimal;「-」/空 → null。 */
static BigDecimal parseMoney(String raw) {
if (raw == null) {
return null;
}
String s = raw.trim();
if (s.isEmpty() || "-".equals(s) || "—".equals(s)) {
return null;
}
s = s.replace(",", "");
StringBuilder num = new StringBuilder();
boolean dot = false;
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c >= '0' && c <= '9') {
num.append(c);
} else if (c == '.' && !dot) {
num.append(c);
dot = true;
} else if (c == '-' && num.length() == 0) {
num.append(c);
}
}
if (num.length() == 0 || "-".contentEquals(num) || ".".contentEquals(num)) {
return null;
}
try {
return new BigDecimal(num.toString());
} catch (NumberFormatException ex) {
return null;
}
}
static String routeLabel(InquiryTicket t) {
String o = StringUtils.trimToEmpty(t.getOrigin());
String d = StringUtils.trimToEmpty(t.getDestination());
if (StringUtils.isNotBlank(o) && StringUtils.isNotBlank(d)) {
return o + "-" + d;
}
if (StringUtils.isNotBlank(t.getBizTypeLabel())) {
return t.getBizTypeLabel().trim() + "全线";
}
String line = StringUtils.trimToEmpty(t.getBusinessLine()).toUpperCase(Locale.ROOT);
if ("AIR".equals(line)) {
return "空运全线";
}
if ("SEA".equals(line)) {
return "海运全线";
}
if ("LAND".equals(line)) {
return "陆运全线";
}
return "未分线路";
}
static String routeId(String label) {
String raw = StringUtils.trimToEmpty(label).toLowerCase(Locale.ROOT);
String id = raw.replaceAll("[^a-z0-9\\u4e00-\\u9fff]+", "-");
if (id.isEmpty()) {
return "route";
}
return id.length() > 48 ? id.substring(0, 48) : id;
}
private static LocalDate toLocalDate(Date time) {
if (time == null) {
return null;
}
return time.toInstant().atZone(ZONE).toLocalDate();
}
private static boolean hasExc(String v) {
return v != null && !v.trim().isEmpty() && !"-".equals(v.trim());
}
@@ -166,9 +432,13 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
return m;
}
/** 员工 roleCode → 看板筛选项 sales/route/product。 */
private static String mapRankRole(String roleCode) {
if ("air".equals(roleCode) || "sea".equals(roleCode) || "land".equals(roleCode)) {
return "air".equals(roleCode) ? "route" : "product";
if ("air".equals(roleCode)) {
return "route";
}
if ("sea".equals(roleCode) || "land".equals(roleCode)) {
return "product";
}
return "sales";
}
@@ -191,6 +191,11 @@ public final class QuoteTemplateExcelSupport {
writeFeeRows(wb, mapping, facts, yellow);
// 后台常未配 fee_rows:按「空运费」等名称写入金额列,SUM 才能加算
writeFeeAmountsByName(wb, facts, yellow);
// 模板「预估总价」是 SUM。POI 写完数字不会重算,缓存仍是 0。
// 企微/WPS 预览常不重算,销售会看到汇总为 0。这里把公式缓存刷成当前值。
refreshFormulas(wb);
// 转 PDF 前把纸张改成 A4 居中,避免顶部裁字、整页偏左。
applyQuotePrintLayout(wb);
Files.createDirectories(targetXlsx.getParent());
try (OutputStream out = Files.newOutputStream(targetXlsx)) {
wb.write(out);
@@ -252,7 +257,62 @@ public final class QuoteTemplateExcelSupport {
if (cell == null) {
cell = row.createCell(col);
}
cell.setCellValue(value == null ? "" : value);
writeNumericOrText(cell, value);
}
/**
* 金额/数量/单价写成数字,SUM 才能加算;名称等仍写文本。
*/
private static void writeNumericOrText(Cell cell, String value) {
if (value == null) {
cell.setCellValue("");
return;
}
String norm = stripMoneyPrefix(value);
if (StringUtils.isBlank(norm) || "-".equals(norm)) {
cell.setCellValue(value);
return;
}
try {
cell.setCellValue(Double.parseDouble(norm));
} catch (NumberFormatException e) {
cell.setCellValue(value);
}
}
/**
* 回填后重算公式,把 SUM 等缓存从 0 刷成真实合计。
* 失败不抛:出单仍要发出,只记日志。
*/
static void refreshFormulas(Workbook wb) {
if (wb == null) {
return;
}
FormulaEvaluator ev = wb.getCreationHelper().createFormulaEvaluator();
for (int s = 0; s < wb.getNumberOfSheets(); s++) {
Sheet sheet = wb.getSheetAt(s);
if (sheet == null) {
continue;
}
for (Row row : sheet) {
if (row == null) {
continue;
}
for (Cell cell : row) {
if (cell == null || cell.getCellType() != CellType.FORMULA) {
continue;
}
try {
ev.evaluateFormulaCell(cell);
} catch (Exception e) {
log.warn("报价公式未重算 {}!{}: {}",
sheet.getSheetName(),
new CellReference(cell).formatAsString(),
e.getMessage());
}
}
}
}
}
private static void clearCell(Workbook wb, String cellRef) {
@@ -493,6 +553,23 @@ public final class QuoteTemplateExcelSupport {
return fuzzy;
}
/**
* 费用明细表头原字,回填系统字段时不得覆盖。
*/
static boolean isFeeTableHeader(String text) {
if (StringUtils.isBlank(text)) {
return false;
}
String t = text.trim();
return "项目".equals(t) || "费用名称".equals(t) || "费用名".equals(t)
|| "单位".equals(t) || "單位".equals(t)
|| "币别".equals(t) || "币種".equals(t) || "币种".equals(t)
|| "单价".equals(t) || "單價".equals(t)
|| "数量".equals(t) || "數量".equals(t)
|| "金额".equals(t) || "金額".equals(t)
|| "备注".equals(t) || "備註".equals(t);
}
private static String readCellText(Cell cell) {
if (cell == null) {
return "";
@@ -585,6 +662,10 @@ public final class QuoteTemplateExcelSupport {
String text = value == null ? "" : value;
// 标签格「报价编号:」只换冒号后的值,避免整格被工单号顶掉看起来像残缺
String existing = readCellText(cell);
// 「币别」右侧常被误映到「单价」表头,不能把 CNY 盖掉单价二字
if (isFeeTableHeader(existing)) {
return;
}
int colon = Math.max(existing.indexOf(':'), existing.indexOf(':'));
if (colon >= 0 && colon == existing.length() - 1) {
text = existing + text;
@@ -599,13 +680,78 @@ public final class QuoteTemplateExcelSupport {
copy.setDataFormat(wb.createDataFormat().getFormat("@"));
cell.setCellStyle(copy);
}
// 工单号、日期略加宽,避免列太窄只露出半截
if (dest[1] >= 0 && dest[1] < 20 && text.length() >= 8) {
org.apache.poi.ss.usermodel.Sheet sh = sheet;
int col = dest[1];
double width = sh.getColumnWidth(col) / 256.0;
if (width > 0 && width < text.length() + 2) {
sh.setColumnWidth(col, (int) Math.min(40, text.length() + 4) * 256);
// 不再临时加宽列:会把打印缩放打乱,转 PDF 后整页偏左。
}
/**
* 报价单打印版心:A4、水平居中、按页宽缩放。
* <p>
* 空运模板常是 62% 缩放且不居中。LibreOffice 按这个出 PDF,
* 公司名会被裁掉上半截,正文整页靠左。出单时统一改过来。
* 只改打印属性,不改单元格里的业务数字。
*/
static void applyQuotePrintLayout(Workbook wb) {
if (wb == null) {
return;
}
for (int s = 0; s < wb.getNumberOfSheets(); s++) {
Sheet sheet = wb.getSheetAt(s);
if (sheet == null) {
continue;
}
sheet.setMargin(Sheet.LeftMargin, 0.5);
sheet.setMargin(Sheet.RightMargin, 0.5);
// 上边距适中:太小企微会裁公司名,太大纸顶空一截
sheet.setMargin(Sheet.TopMargin, 0.75);
sheet.setMargin(Sheet.BottomMargin, 0.6);
sheet.setMargin(Sheet.HeaderMargin, 0.1);
sheet.setMargin(Sheet.FooterMargin, 0.2);
sheet.setHorizontallyCenter(true);
sheet.setVerticallyCenter(false);
sheet.setAutobreaks(true);
sheet.setFitToPage(true);
PrintSetup ps = sheet.getPrintSetup();
ps.setPaperSize(PrintSetup.A4_PAPERSIZE);
ps.setLandscape(false);
ps.setFitWidth((short) 1);
ps.setFitHeight((short) 0);
ensureHeaderRowHeight(sheet);
}
}
/**
* 抬头合并格(公司中英文)行高太矮时加高。
* 现网空运模板 A1:H2 合计只有 42 磅,两行字会裁顶。
*/
static void ensureHeaderRowHeight(Sheet sheet) {
if (sheet == null) {
return;
}
for (int i = 0; i < sheet.getNumMergedRegions(); i++) {
org.apache.poi.ss.util.CellRangeAddress range = sheet.getMergedRegion(i);
if (range.getFirstRow() != 0 || range.getLastRow() < 1) {
continue;
}
Row first = sheet.getRow(range.getFirstRow());
Cell cell = first == null ? null : first.getCell(range.getFirstColumn());
String text = readCellText(cell);
if (!text.contains("公司") && text.toLowerCase().indexOf("company") < 0) {
continue;
}
// 模板末尾常带空行,字会被顶到合并格上沿,转 PDF 再被裁一刀
if (cell != null && StringUtils.isNotBlank(text)) {
cell.setCellValue(text.replaceAll("[\\r\\n]+$", ""));
}
int rows = range.getLastRow() - range.getFirstRow() + 1;
float each = Math.max(32f, 64f / rows);
for (int r = range.getFirstRow(); r <= range.getLastRow(); r++) {
Row row = sheet.getRow(r);
if (row == null) {
row = sheet.createRow(r);
}
if (row.getHeightInPoints() < each) {
row.setHeightInPoints(each);
}
}
}
}
@@ -127,8 +127,9 @@ public class WeComAddressBookClient {
}
/**
* 拉取企微部门树(扁平列表,带 path 便于下拉展示)。
* 拉取企微 {@code department/list} 全量扁平列表(短名 + path)。
* <p>
* 员工下拉与旧项目同源:整份通讯录,不截层级。顺序保持企微返回序。
* 副作用:出站 HTTP;失败抛 IllegalStateException。
*/
public List<WeComDepartment> listDepartments() {
@@ -163,8 +164,8 @@ public class WeComAddressBookClient {
String path = buildDepartmentPath(id, byId);
result.add(new WeComDepartment(id, name, parentId, path));
}
// 按路径排序,下拉更易扫
result.sort((a, b) -> StringUtils.defaultString(a.getPath()).compareTo(StringUtils.defaultString(b.getPath())));
// 保持企微 department/list 原序(LinkedHashMap 插入序 = 接口返回序),
// 不要按 path 重排,也不要截层级;员工下拉与旧项目一样用这份全量短名。
return result;
}
@@ -0,0 +1,33 @@
package org.jeecg.modules.inquiry.service.impl;
import org.jeecg.modules.inquiry.entity.InquiryTicket;
import org.junit.Assert;
import org.junit.Test;
import java.math.BigDecimal;
/**
* 看板聚合辅助:金额解析与线路标签(不启 Spring)。
*/
public class InquiryDashboardServiceImplTest {
@Test
public void parseMoneyAcceptsCurrencyPrefix() {
Assert.assertEquals(new BigDecimal("14659.60"), InquiryDashboardServiceImpl.parseMoney("CNY 14659.60"));
Assert.assertEquals(new BigDecimal("1234.5"), InquiryDashboardServiceImpl.parseMoney("1,234.5"));
Assert.assertNull(InquiryDashboardServiceImpl.parseMoney("-"));
Assert.assertNull(InquiryDashboardServiceImpl.parseMoney(""));
}
@Test
public void routeLabelPrefersOdPair() {
InquiryTicket t = new InquiryTicket();
t.setOrigin("阿特伯");
t.setDestination("广州");
Assert.assertEquals("阿特伯-广州", InquiryDashboardServiceImpl.routeLabel(t));
InquiryTicket air = new InquiryTicket();
air.setBusinessLine("AIR");
Assert.assertEquals("空运全线", InquiryDashboardServiceImpl.routeLabel(air));
}
}
@@ -2,6 +2,8 @@ package org.jeecg.modules.inquiry.template;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import org.apache.poi.ss.usermodel.PrintSetup;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
@@ -12,6 +14,8 @@ import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import org.apache.poi.ss.usermodel.CellType;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -153,4 +157,91 @@ public class QuoteTemplateFillTest {
Files.deleteIfExists(out);
}
}
/**
* 空运模板「预估总价」是 SUM。回填后必须把公式缓存刷成合计,
* 否则企微/WPS 预览仍显示模板里的 0。
*/
@Test
public void refreshSumCacheAfterWritingFeeAmount() throws Exception {
Path src = Files.createTempFile("quote-sum-src", ".xlsx");
Path out = Files.createTempFile("quote-sum-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("Sheet2");
org.apache.poi.ss.usermodel.Row head = sheet.createRow(15);
head.createCell(1).setCellValue("费用名称");
head.createCell(2).setCellValue("单位");
head.createCell(3).setCellValue("币别");
head.createCell(4).setCellValue("单价");
head.createCell(5).setCellValue("数量");
head.createCell(6).setCellValue("金额");
head.createCell(7).setCellValue("备注");
sheet.createRow(16).createCell(1).setCellValue("空运费");
org.apache.poi.ss.usermodel.Row total = sheet.createRow(22);
total.createCell(0).setCellValue("预估总价");
total.createCell(6).setCellFormula("SUM(G17:G22)");
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject mapping = new JSONObject();
JSONObject fields = new JSONObject();
// 测服空运模板常把「币别」右侧误映到单价表头,回填时不能盖掉「单价」
fields.put("currency", "Sheet2!E16");
mapping.put("fields", fields);
JSONObject facts = new JSONObject(true);
facts.put("currency", "CNY");
facts.put("total_amount", "14669.60");
facts.put("fee_rows", QuoteTemplateExcelSupport.parseFeeRows(
"[{\"name\":\"空运费\",\"amount\":\"14669.60\",\"currency\":\"CNY\","
+ "\"unit\":\"KG\",\"quantity\":\"1333.6\",\"unit_price\":\"11.0\"}]"));
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
try (Workbook formulas = new XSSFWorkbook(Files.newInputStream(out))) {
org.apache.poi.ss.usermodel.Cell sum = formulas.getSheetAt(0).getRow(22).getCell(6);
assertEquals(CellType.FORMULA, sum.getCellType());
assertEquals("SUM(G17:G22)", sum.getCellFormula());
// 公式格的数字缓存 = 企微预览看到的值;没刷缓存会是 0
assertEquals(14669.60, sum.getNumericCellValue(), 0.01);
assertEquals("单价", formulas.getSheetAt(0).getRow(15).getCell(4).getStringCellValue());
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
/**
* 出单后必须 A4 水平居中,抬头行高够放下中英文公司名。
* 否则转 PDF 会顶部裁字、整页偏左。
*/
@Test
public void applyPrintLayoutCentersA4AndRaisesHeader() throws Exception {
Path src = Files.createTempFile("quote-layout-src", ".xlsx");
Path out = Files.createTempFile("quote-layout-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("Sheet2");
sheet.setHorizontallyCenter(false);
sheet.getPrintSetup().setScale((short) 62);
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(0, 1, 0, 7));
Row r0 = sheet.createRow(0);
r0.setHeightInPoints(21f);
r0.createCell(0).setCellValue("深圳市友通达国际货运代理有限公司\nShenzhen Youtongda International Freight Forwarding Co.LTD");
sheet.createRow(1).setHeightInPoints(21f);
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
QuoteTemplateExcelSupport.fillWorkbook(src, out, new JSONObject(), new JSONObject());
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled.getSheetAt(0);
assertTrue(sheet.getHorizontallyCenter());
assertEquals(PrintSetup.A4_PAPERSIZE, sheet.getPrintSetup().getPaperSize());
assertEquals(1, sheet.getPrintSetup().getFitWidth());
assertEquals(0.75, sheet.getMargin(Sheet.TopMargin), 0.01);
assertTrue(sheet.getRow(0).getHeightInPoints() >= 32f);
assertTrue(sheet.getRow(1).getHeightInPoints() >= 32f);
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
}
@@ -18,9 +18,13 @@ import {
} from "/@/views/inquiry/account/account.api";
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
import AdminPagination from "/@/views/inquiry/components/AdminPagination.vue";
const rows = ref<AccountRow[]>([]);
const loading = ref(false);
const total = ref(0);
const pageNo = ref(1);
const pageSize = ref(10);
const roleOptions = ref<{ value: string; label: string; tag: string }[]>([]);
const filters = reactive({ keyword: "", status: "" as "" | "active" | "disabled" });
const modalOpen = ref(false);
@@ -35,15 +39,6 @@ const form = reactive({
password: "",
});
const filtered = computed(() =>
rows.value.filter((r) => {
const kw = filters.keyword.trim().toLowerCase();
if (kw && !`${r.username}${r.name}${r.wecomUserId}${r.phoneMasked}`.toLowerCase().includes(kw)) return false;
if (filters.status && r.status !== filters.status) return false;
return true;
}),
);
const modalTitle = computed(() => (editing.value ? "编辑账号" : "新建账号"));
const modalSub = computed(() =>
editing.value ? `正在编辑:${editing.value.username}` : "创建账号并绑定角色",
@@ -67,8 +62,28 @@ async function loadList() {
loading.value = true;
try {
if (!roleOptions.value.length) await loadRoles();
const res: any = await listUsers({ pageNo: 1, pageSize: 200 });
const records = res?.records || res?.result?.records || [];
const kw = filters.keyword.trim();
// Jeecg QueryGenerator:字段值包 * 走 LIKE。多字段 AND 过严,按关键字形态选一个字段。
const params: Record<string, any> = {
pageNo: pageNo.value,
pageSize: pageSize.value,
};
if (kw) {
if (/[\u4e00-\u9fff]/.test(kw)) {
params.realname = `*${kw}*`;
} else if (/^\d{5,}$/.test(kw)) {
params.phone = `*${kw}*`;
} else {
params.username = `*${kw}*`;
}
}
if (filters.status === "active") params.status = 1;
if (filters.status === "disabled") params.status = 2;
const res: any = await listUsers(params);
const page = res?.records ? res : res?.result || {};
const records = page.records || [];
total.value = Number(page.total || records.length || 0);
const mapped: AccountRow[] = [];
for (const u of records as any[]) {
let roleId = "";
@@ -89,6 +104,11 @@ async function loadList() {
mapped.push(mapUserToAccount(u, roleId, roleLabel, roleTag));
}
rows.value = mapped;
const maxPage = Math.max(1, Math.ceil(total.value / pageSize.value) || 1);
if (pageNo.value > maxPage) {
pageNo.value = maxPage;
await loadList();
}
} catch (e: any) {
message.error(e?.message || "加载账号失败");
} finally {
@@ -96,6 +116,20 @@ async function loadList() {
}
}
let accountSearchTimer: ReturnType<typeof setTimeout> | null = null;
watch(
() => [filters.keyword, filters.status],
() => {
pageNo.value = 1;
if (accountSearchTimer) clearTimeout(accountSearchTimer);
accountSearchTimer = setTimeout(() => loadList(), 250);
},
);
function onPageChange() {
loadList();
}
onMounted(() => loadList());
function openCreate() {
@@ -225,13 +259,6 @@ function removeAccount(row: AccountRow) {
},
});
}
watch(
() => filters.keyword,
() => {
/* 本地过滤即可,账号量通常很小 */
},
);
</script>
<template>
@@ -250,7 +277,7 @@ watch(
<div class="toolbar">
<div class="search">
<ProtoIcon class="search-icon" name="search" :size="16" />
<input v-model="filters.keyword" placeholder="搜索账号、姓名、企微ID、手机号..." />
<input v-model="filters.keyword" placeholder="搜索账号 / 姓名 / 手机号..." />
</div>
<select v-model="filters.status">
<option value="">状态</option>
@@ -276,10 +303,10 @@ watch(
<tr v-if="loading">
<td colspan="7" style="text-align: center; color: #94a3b8">加载中…</td>
</tr>
<tr v-else-if="!filtered.length">
<tr v-else-if="!rows.length">
<td colspan="7" style="text-align: center; color: #94a3b8">暂无账号</td>
</tr>
<tr v-for="row in filtered" :key="row.id">
<tr v-for="row in rows" :key="row.id">
<td class="account-username">{{ row.username }}</td>
<td>{{ row.name }}</td>
<td>
@@ -312,14 +339,14 @@ watch(
</tbody>
</table>
</div>
<div class="pagination">
<span>
共 <b style="color: #0f172a">{{ filtered.length }}</b> 个账号
</span>
<div class="pages">
<button class="is-active" type="button">1</button>
</div>
</div>
<AdminPagination
v-model:page-no="pageNo"
v-model:page-size="pageSize"
:total="total"
unit="个账号"
:disabled="loading"
@change="onPageChange"
/>
</div>
<AdminModal
@@ -1,6 +1,6 @@
/**
* 机器人回复语分类元数据(版式用,非演示业务数据)。
* 条目内容一律来自主账 /inquiry/botReply。
* 机器人回复语分类元数据(版式用)。
* 标题/说明与原型 admin-bot-replies.html 一致;条目来自主账。
*/
export type ReplyItem = {
@@ -53,25 +53,25 @@ export const REPLY_CATEGORY_META: Omit<ReplyCategory, "items">[] = [
{
id: "closed",
title: "已关闭",
desc: "工单因超时、重复、误建等原因关闭。",
desc: "工单长期无有效推进,由系统自动关闭或人工手动关闭,不等同于客户明确拒绝。",
tagClass: "tag-rose",
},
{
id: "event-ex",
title: "事件异常",
desc: "销售/产品/航线响应超时等业务事件异常提示。",
desc: "业务流程中参与方响应超时等业务侧异常,写入工单「事件异常」字段,需人工跟进。",
tagClass: "tag-amber",
},
{
id: "sys-ex",
title: "系统异常",
desc: "大模型、TMS、建群等系统失败话术。",
desc: "大模型、TMS、企微建群等系统侧调用失败,写入工单「系统异常」字段。",
tagClass: "tag-rose",
},
{
id: "transfer",
title: "转人工",
desc: "关键词/无价/解析失败等场景转人工话术。",
desc: "AI 无法独立完成询价时,引导销售确认是否拉内部航线/产品进群协同。",
tagClass: "tag-violet",
},
];
@@ -0,0 +1,172 @@
<script setup lang="ts">
/**
* 询价后台列表分页条:总数 + 每页条数 + 页码。
* 样式对齐 admin-fidelity.css 的 .pagination;翻页只发事件,不请求接口。
*/
import { computed } from "vue";
const props = withDefaults(
defineProps<{
total: number;
pageNo: number;
pageSize: number;
/** 文案:共 N {unit} */
unit?: string;
pageSizeOptions?: number[];
disabled?: boolean;
}>(),
{
unit: "条",
pageSizeOptions: () => [10, 20, 50, 100],
disabled: false,
},
);
const emit = defineEmits<{
(e: "update:pageNo", v: number): void;
(e: "update:pageSize", v: number): void;
(e: "change"): void;
}>();
const totalPages = computed(() => Math.max(1, Math.ceil(Math.max(0, props.total) / Math.max(1, props.pageSize))));
/**
* 页码按钮:两端保留,中间省略。
* 例:1 … 4 5 6 … 20
*/
const pageItems = computed(() => {
const total = totalPages.value;
const cur = Math.min(Math.max(1, props.pageNo), total);
if (total <= 7) {
return Array.from({ length: total }, (_, i) => i + 1) as Array<number | "ellipsis">;
}
const set = new Set<number>();
set.add(1);
set.add(total);
for (let p = cur - 1; p <= cur + 1; p++) {
if (p >= 1 && p <= total) set.add(p);
}
if (cur <= 3) {
set.add(2);
set.add(3);
set.add(4);
}
if (cur >= total - 2) {
set.add(total - 1);
set.add(total - 2);
set.add(total - 3);
}
const sorted = [...set].sort((a, b) => a - b);
const out: Array<number | "ellipsis"> = [];
let prev = 0;
for (const p of sorted) {
if (prev && p - prev > 1) out.push("ellipsis");
out.push(p);
prev = p;
}
return out;
});
function go(page: number) {
if (props.disabled) return;
const next = Math.min(Math.max(1, page), totalPages.value);
if (next === props.pageNo) return;
emit("update:pageNo", next);
emit("change");
}
function onSizeChange(e: Event) {
if (props.disabled) return;
const v = Number((e.target as HTMLSelectElement).value);
if (!v || v === props.pageSize) return;
emit("update:pageSize", v);
emit("update:pageNo", 1);
emit("change");
}
</script>
<template>
<div class="pagination admin-pagination">
<span class="admin-pagination__total">
共 <b>{{ total }}</b> {{ unit }}
<span v-if="total > 0" class="admin-pagination__range">
(第 {{ pageNo }} / {{ totalPages }} 页)
</span>
</span>
<div class="admin-pagination__right">
<label class="admin-pagination__size">
每页
<select :value="pageSize" :disabled="disabled" @change="onSizeChange">
<option v-for="n in pageSizeOptions" :key="n" :value="n">{{ n }}</option>
</select>
</label>
<div class="pages">
<button type="button" :disabled="disabled || pageNo <= 1" @click="go(pageNo - 1)">上一页</button>
<template v-for="(item, idx) in pageItems" :key="idx + '-' + item">
<span v-if="item === 'ellipsis'" class="admin-pagination__ellipsis">…</span>
<button
v-else
type="button"
:class="{ 'is-active': item === pageNo }"
:disabled="disabled"
@click="go(item)"
>
{{ item }}
</button>
</template>
<button type="button" :disabled="disabled || pageNo >= totalPages" @click="go(pageNo + 1)">下一页</button>
</div>
</div>
</div>
</template>
<style scoped>
.admin-pagination__right {
display: inline-flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.admin-pagination__size {
display: inline-flex;
align-items: center;
gap: 6px;
color: #475467;
font-size: 11px;
font-weight: 600;
}
.admin-pagination__size select {
height: 28px;
border: 1px solid var(--line, #e4e7ec);
border-radius: 6px;
padding: 0 8px;
background: #fff;
font-size: 11px;
font-weight: 700;
color: #344054;
cursor: pointer;
}
.admin-pagination__range {
color: #98a2b3;
font-weight: 600;
margin-left: 4px;
}
.admin-pagination__ellipsis {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
color: #98a2b3;
font-weight: 700;
}
.pages button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
</style>
@@ -1,7 +1,7 @@
<script setup lang="ts">
/**
* 分析看板:对接主账 /inquiry/dashboard/metrics。
* 指标 / 趋势 / 排名来自聚合接口;成本趋势本期主账未提供,展示空态不造假数。
* 指标 / 询价趋势 / 人员排名 / 成本趋势均来自主账聚合;禁止用 Mock 假线冒充生产。
*/
import { computed, onMounted, onUnmounted, ref } from "vue";
import { message } from "ant-design-vue";
@@ -13,20 +13,21 @@ const todayMetrics = ref<MetricCard[]>([]);
const inquiryTrend = ref<InquiryPoint[]>([]);
const rankingPeople = ref<RankingPerson[]>([]);
const rankingDepts = ref<string[]>([]);
/** 成本趋势主账暂无接口,保持空数组避免 Mock 假线。 */
/** 成本趋势:主账 costRoutes(近 7 日线路均价)。 */
const costRoutes = ref<CostRoute[]>([]);
const costDateLabels = ref<string[]>([]);
const COST_PLOT_TOP = 36;
const COST_PLOT_BOTTOM = 186;
const now = new Date();
const isoDay = now.toISOString().slice(0, 10);
const dateDisplay = ref(`${isoDay}——${isoDay}`);
const dateFrom = ref(isoDay);
/** 用本地日期,避免 toISOString 因时区偏到前一天 */
const pad2 = (n: number) => String(n).padStart(2, "0");
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 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 routeFilterOpen = ref(false);
const selectedRoutes = ref<string[]>([]);
@@ -56,18 +57,34 @@ const INQUIRY_PLOT_TOP = 36;
const INQUIRY_PLOT_BOTTOM = 188;
function resolveRange(): string {
if (pickerMode.value === "day") {
const from = new Date(dateFrom.value);
const to = new Date(dateTo.value);
const days = Math.max(0, Math.round((to.getTime() - from.getTime()) / 86400000));
if (days <= 0) return "today";
if (days <= 6) return "7d";
return "30d";
}
if (pickerMode.value === "month") return "30d";
const from = new Date(dateFrom.value);
const to = new Date(dateTo.value);
const days = Math.max(0, Math.round((to.getTime() - from.getTime()) / 86400000));
if (days <= 0) return "today";
if (days <= 6) return "7d";
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() {
loading.value = true;
try {
@@ -87,6 +104,15 @@ async function loadMetrics() {
rankingDepts.value = Array.from(
new Set(rankingPeople.value.map((p) => p.dept).filter((d) => d && d !== "-")),
);
const dates: string[] = Array.isArray(data.costDates) && data.costDates.length
? data.costDates.map((d: any) => String(d))
: inquiryTrend.value.map((p) => p.date);
costDateLabels.value = dates;
const rawRoutes = (data.costRoutes || []) as any[];
costRoutes.value = buildCostRouteViews(rawRoutes, dates);
selectedRoutes.value = costRoutes.value.map((r) => r.id);
const t = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
updatedTime.value = `${pad(t.getHours())}:${pad(t.getMinutes())}`;
@@ -97,6 +123,76 @@ async function loadMetrics() {
}
}
/**
* 把主账 costRoutes.values 转成 SVG path + 数据点。
* 只有一天有金额时 path 只有 M 看不见线,必须画 points。
*/
function buildCostRouteViews(rawRoutes: any[], dates: string[]): CostRoute[] {
const n = Math.max(dates.length, 1);
const xs = dates.map((_, i) => (n === 1 ? 380 : 36 + (654 / (n - 1)) * i));
const parsed = rawRoutes.map((r) => {
const values = Array.isArray(r.values)
? r.values.map((v: any) => (v == null || v === "" ? null : Number(v)))
: dates.map(() => null);
while (values.length < dates.length) values.push(null);
return {
id: String(r.id || r.label || "route"),
label: String(r.label || r.id || "线路"),
color: String(r.color || "#2563eb"),
values: values.slice(0, dates.length) as Array<number | null>,
};
});
const nums = parsed.flatMap((r) => r.values.filter((v): v is number => v != null && !Number.isNaN(v) && v > 0));
const max = Math.max(...nums, 1);
// 单点时 min==max 会压成一条横线贴顶/贴底,留 10% 边距更易看见
let min = nums.length ? Math.min(...nums) : 0;
if (nums.length && max - min < max * 0.05) {
min = Math.max(0, min - max * 0.1);
}
const range = Math.max(max - min, 1);
const plotH = COST_PLOT_BOTTOM - COST_PLOT_TOP;
const yFor = (v: number) => COST_PLOT_BOTTOM - ((v - min) / range) * plotH;
return parsed.map((r) => {
const points: Array<{ x: number; y: number; index: number }> = [];
r.values.forEach((v, i) => {
if (v == null || Number.isNaN(v)) return;
points.push({ x: xs[i], y: yFor(v), index: i });
});
return {
...r,
path: pathFromValues(r.values, xs, yFor),
points,
};
});
}
/** 有值连线,无值断点(不造假插值)。 */
function pathFromValues(
values: Array<number | null>,
xs: number[],
yFor: (v: number) => number,
): string {
let d = "";
let started = false;
for (let i = 0; i < values.length; i++) {
const v = values[i];
if (v == null || Number.isNaN(v)) {
started = false;
continue;
}
const x = xs[i];
const y = yFor(v);
d += `${started ? "L" : "M"}${x.toFixed(1)} ${y.toFixed(1)} `;
started = true;
}
return d.trim();
}
function formatCost(v: number | null | undefined) {
if (v == null || Number.isNaN(v)) return "-";
return v.toLocaleString("zh-CN", { maximumFractionDigits: 2 });
}
const inquirySvg = computed(() => {
const points = inquiryTrend.value.length
? inquiryTrend.value
@@ -128,20 +224,35 @@ const rankingRows = computed(() => {
return rows.sort((a, b) => b.volumeNum - a.volumeNum || a.name.localeCompare(b.name, "zh-CN"));
});
const costDates = computed(() => inquiryTrend.value.map((p) => p.date));
const costDates = computed(() =>
costDateLabels.value.length ? costDateLabels.value : inquiryTrend.value.map((p) => p.date),
);
const costSampleDays = computed(() => {
const set = new Set<string>();
for (const r of costRoutes.value) {
r.values.forEach((v, i) => {
if (v != null && !Number.isNaN(v) && costDates.value[i]) set.add(costDates.value[i]);
});
}
return [...set];
});
const costFootnote = computed(() => {
if (!costRoutes.value.length) {
return "近 7 日暂无带金额的工单,有报价后将显示线路成本趋势";
}
const days = costSampleDays.value;
if (!days.length) {
return "已选线路在近 7 日无金额样本";
}
return `成本来自主账工单(成本价优先,缺省销售价);有金额日期:${days.join("、")},其余日悬停为 —`;
});
const costXs = computed(() => {
const n = Math.max(costDates.value.length, 1);
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() {
loadMetrics().then(() => message.success("数据已更新"));
}
@@ -180,17 +291,15 @@ function hideInquiryTip() {
chartTip.value.visible = false;
}
function showCostTip(date: string, x: number, wrapEl: HTMLElement | null) {
function showCostTip(date: string, x: number, dateIndex: number, wrapEl: HTMLElement | null) {
if (!wrapEl) return;
const box = wrapEl.getBoundingClientRect();
const routes = filteredRoutes.value;
const lines =
routes.length === 0
? ["未选择线路"]
: [
...routes.slice(0, 3).map((r) => r.label),
...(routes.length > 3 ? [`等 ${routes.length} 条`] : []),
];
: routes.slice(0, 6).map((r) => `${r.label}: ${formatCost(r.values[dateIndex])}`);
if (routes.length > 6) lines.push(`等 ${routes.length} 条`);
costTip.value = {
visible: true,
left: `${(x / 760) * box.width}px`,
@@ -230,7 +339,6 @@ function exportRanking() {
function onDocClick(e: MouseEvent) {
const t = e.target as HTMLElement;
if (!t.closest(".month-range-picker")) pickerOpen.value = false;
if (!t.closest(".ref-route-filter")) routeFilterOpen.value = false;
}
@@ -249,33 +357,21 @@ onUnmounted(() => document.removeEventListener("click", onDocClick));
<!-- defer:等布局挂好 #page-topbar-actions 再传送,避免顶栏工具丢失 -->
<Teleport defer to="#page-topbar-actions">
<div class="dash-topbar-tools">
<div class="month-range-picker" @click.stop>
<div class="month-range-input" @click="pickerOpen = !pickerOpen">
<span>{{ dateDisplay }}</span>
<ProtoIcon name="calendar" :size="14" style="color: #94a3b8" />
</div>
<div v-show="pickerOpen" class="month-range-dropdown dash-picker-dropdown">
<div class="month-range-tabs">
<button type="button" class="month-range-tab" :class="{ 'is-active': pickerMode === 'day' }" @click="pickerMode = 'day'">按日</button>
<button type="button" class="month-range-tab" :class="{ 'is-active': pickerMode === 'month' }" @click="pickerMode = 'month'">按月</button>
<button type="button" class="month-range-tab" :class="{ 'is-active': pickerMode === 'year' }" @click="pickerMode = 'year'">按年</button>
</div>
<div class="dash-picker-body">
<template v-if="pickerMode === 'day'">
<input v-model="dateFrom" type="date" class="dash-picker-input" />
<input v-model="dateTo" type="date" class="dash-picker-input" />
</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 class="toolbar-date-range dash-toolbar-date" title="选择统计起止日期">
<a-range-picker
v-model:value="dateRange"
class="toolbar-range-picker dash-range-picker"
value-format="YYYY-MM-DD"
separator="至"
:placeholder="['开始日期', '结束日期']"
:allow-clear="false"
:input-read-only="true"
@change="onDateRangeChange"
>
<template #suffixIcon>
<ProtoIcon name="calendar" :size="14" />
</template>
</a-range-picker>
</div>
<div class="dashboard-data-note" aria-live="polite">
<span class="dashboard-data-note__icon"><ProtoIcon name="clock" :size="13" /></span>
@@ -434,11 +530,24 @@ onUnmounted(() => document.removeEventListener("click", onDocClick));
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- 有金额的日子画点:近 7 日往往只有一两天有价,单点折线不可见 -->
<g v-for="r in filteredRoutes" :key="'pts-' + r.id">
<circle
v-for="p in r.points"
:key="r.id + '-' + p.index"
:cx="p.x"
:cy="p.y"
r="4"
:fill="r.color"
stroke="#ffffff"
stroke-width="2"
/>
</g>
<g
v-for="(x, i) in costXs"
:key="'cx-' + costDates[i]"
class="cost-point"
@mouseenter="showCostTip(costDates[i], x, costWrap)"
@mouseenter="showCostTip(costDates[i], x, i, costWrap)"
@mouseleave="hideCostTip"
>
<rect :x="x - 40" y="28" width="80" height="165" fill="transparent" style="cursor: pointer" />
@@ -458,7 +567,7 @@ onUnmounted(() => document.removeEventListener("click", onDocClick));
</div>
</div>
<div class="dash-cost-footnote">
{{ costRoutes.length ? "成本数据来源于 TMS" : "成本趋势主账暂未开放,待接口就绪后展示" }}
{{ costFootnote }}
</div>
</div>
</div>
@@ -517,38 +626,30 @@ onUnmounted(() => document.removeEventListener("click", onDocClick));
<style scoped>
.dash-topbar-tools {
display: flex;
flex-wrap: wrap;
flex-wrap: nowrap;
align-items: center;
gap: 8px;
}
.dash-picker-dropdown {
position: absolute;
right: 0;
top: calc(100% + 8px);
width: 300px;
z-index: 80;
.dash-toolbar-date {
height: 34px;
flex-shrink: 0;
}
.dash-picker-body {
display: grid;
gap: 8px;
padding-top: 4px;
}
.dash-picker-input {
width: 100%;
height: 32px;
padding: 0 10px;
border: 1px solid #dfe5ee;
border-radius: 7px;
font-size: 11px;
font-weight: 700;
.dash-toolbar-date :deep(.ant-picker) {
height: 34px !important;
min-height: 34px !important;
width: 260px !important;
}
.dash-refresh-btn {
height: 34px;
min-height: 34px;
flex-shrink: 0;
}
.dashboard-data-note {
flex-shrink: 0;
}
.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 = {
@@ -44,8 +45,26 @@ export const mockReplyCategories: ReplyCategory[] = [
},
{
id: "r4",
trigger: "AI 解析失败 → 要求补充参数",
text: "未能完全理解需求,请补充或确认:起运地?目的地?件数?毛重?体积?如有品名和时效要求,也请一并补充。",
enabled: true,
},
{
id: "r5",
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,
},
],
@@ -57,21 +76,39 @@ export const mockReplyCategories: ReplyCategory[] = [
tagClass: "tag-blue",
items: [
{
id: "r5",
id: "r8",
trigger: "报价方案整理完成 → 询问是否采用",
text: "工单 {{ticket_no}} 状态已更新为「已报价」。整理后的报价方案:{{route_summary}} · {{quote_amount}} · {{eta}}。请确认是否采用。",
text: "工单 {{ticket_no}} 状态已更新为「已报价」。整理后的报价方案:{{route_summary}} · {{quote_amount}} · {{eta}}。请确认是否采用:(采用,生成 Excel 报价单 / 采用,生成 PDF 报价单 / 不采用)",
enabled: true,
},
{
id: "r6",
id: "r9",
trigger: "销售确认采用 → 生成最终报价单",
text: "已确认采用该方案,正在生成 {{quote_format}} 最终报价单…工单保持「已报价」,完成后可直接转发给客户。",
enabled: true,
},
{
id: "r10",
trigger: "最终报价单生成完成",
text: "最终报价单已生成:{{quote_file}}({{quote_amount}} · {{route_summary}})。工单 {{ticket_no}} 保持「已报价」,报价单已归档。",
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: "报价单出具满*小时 → 发起成交跟进",
text: "工单 {{ticket_no}} 已于 {{quote_time}} 出具报价。请确认当前与客户的成交状态:(成交 / 未成交 / 协商中)",
text: "工单 {{ticket_no}} 已于 {{quote_time}} 出具报价({{route_summary}} · {{quote_amount}})。请确认当前与客户的成交状态:(成交 / 未成交 / 协商中)",
enabled: true,
},
],
@@ -83,15 +120,15 @@ export const mockReplyCategories: ReplyCategory[] = [
tagClass: "tag-green",
items: [
{
id: "r8",
id: "r14",
trigger: "销售在成交跟进中选择「成交」",
text: "已记录为「已成交」。工单 {{ticket_no}} 状态已更新,成交确认时间 {{deal_time}} 已写入后台。",
text: "已记录为「已成交」。工单 {{ticket_no}} 状态已更新,成交确认时间 {{deal_time}} 已写入后台。已推送成交通知至 {{manager_name}}({{manager_role}})。",
enabled: true,
},
{
id: "r9",
id: "r15",
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,
},
],
@@ -103,15 +140,27 @@ export const mockReplyCategories: ReplyCategory[] = [
tagClass: "tag-amber",
items: [
{
id: "r10",
id: "r16",
trigger: "销售选择「不采用」当前报价方案",
text: "工单 {{ticket_no}} 状态已更新为「协商中」。请问希望如何调整?",
text: "工单 {{ticket_no}} 状态已更新为「协商中」。请问希望如何调整?可说明调价幅度、换舱/时效要求,或是否需要重新拉内部{{internal_role_label}}协同。",
enabled: true,
},
{
id: "r11",
id: "r17",
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,
},
],
@@ -123,15 +172,21 @@ export const mockReplyCategories: ReplyCategory[] = [
tagClass: "tag-rose",
items: [
{
id: "r12",
trigger: "销售选择「未成交」",
text: "已记录为「未成交」。请补充未成交原因,工单 {{ticket_no}} 将关闭智能体跟进。",
id: "r20",
trigger: "成交跟进中选择「未成交」",
text: "请告知{{ticket_no}}「未成交」的原因(必填)。回复后系统将记录至工单 {{ticket_no}},并推送未成交通知至对应负责人。",
enabled: true,
},
{
id: "r13",
trigger: "未成交 → 通知负责人",
text: "【未成交通知】工单 {{ticket_no}} 未成交。原因:{{lost_reason}}。",
id: "r21",
trigger: "销售回复未成交原因 → 关单并通知负责人",
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,
},
],
@@ -139,13 +194,19 @@ export const mockReplyCategories: ReplyCategory[] = [
{
id: "closed",
title: "已关闭",
desc: "工单因超时、重复、误建等原因关闭。",
desc: "工单长期无有效推进,由系统自动关闭或人工手动关闭,不等同于客户明确拒绝。",
tagClass: "tag-rose",
items: [
{
id: "r14",
trigger: "销售主动关闭工单",
text: "工单 {{ticket_no}} 已关闭。如需再次询价,请重新描述需求。",
id: "r23",
trigger: "达到配置超时时间 → 系统自动关闭",
text: "工单 {{ticket_no}} 因「{{close_reason}}」已达关闭条件,系统已自动关闭。关闭时间:{{close_time}}。本单不再发起成交跟进,亦不触发成交/未成交通知。",
enabled: true,
},
{
id: "r24",
trigger: "销售/客服手动关闭工单",
text: "工单 {{ticket_no}} 已由 {{operator_name}} 手动关闭。关闭原因:{{close_reason}}。如需重新询价,请发起新的询价对话。",
enabled: true,
},
],
@@ -153,19 +214,25 @@ export const mockReplyCategories: ReplyCategory[] = [
{
id: "event-ex",
title: "事件异常",
desc: "销售/产品/航线响应超时等业务事件异常提示。",
desc: "业务流程中参与方响应超时等业务侧异常,写入工单「事件异常」字段,需人工跟进。",
tagClass: "tag-amber",
items: [
{
id: "r15",
trigger: "销售响应超时",
text: "工单 {{ticket_no}} 销售响应超时,已通知负责人跟进。",
id: "r25",
trigger: "内部协同*小时无响应 → 群内提醒",
text: "@{{staff_name}} @{{sales_name}} 工单 {{ticket_no}} 已超过 {{timeout_hours}} 小时未收到内部协同回复,请尽快确认。事件异常:{{event_exception_type}}。",
enabled: true,
},
{
id: "r16",
trigger: "产品 / 航线响应超时",
text: "内部协同人员响应超时,工单 {{ticket_no}} 已标记事件异常。",
id: "r26",
trigger: "内部协同超时达告警阈值 → 生成告警工单",
text: "工单 {{ticket_no}} 内部协同已超过 {{alert_hours}} 小时未响应,已生成「{{event_exception_type}}」告警,请管理员跟进。未响应人员:{{staff_name}}({{staff_role}})。",
enabled: true,
},
{
id: "r27",
trigger: "成交跟进多次无响应 → 标记销售响应超时",
text: "工单 {{ticket_no}} 成交跟进已发起 {{followup_count}} 次,销售仍未回复。事件异常:销售响应超时。如继续无响应,系统将按配置自动关闭工单。",
enabled: true,
},
],
@@ -173,19 +240,31 @@ export const mockReplyCategories: ReplyCategory[] = [
{
id: "sys-ex",
title: "系统异常",
desc: "大模型、TMS、建群等系统失败话术。",
desc: "大模型、TMS、企微建群等系统侧调用失败,写入工单「系统异常」字段。",
tagClass: "tag-rose",
items: [
{
id: "r17",
trigger: "大模型异常",
text: "需求识别暂时失败,请稍后重试或补充文字描述。工单 {{ticket_no}} 已记录系统异常。",
id: "r28",
trigger: "大模型调用失败",
text: "AI 解析暂时不可用({{system_error_detail}})。工单 {{ticket_no}} 已标记系统异常:大模型异常。请稍后重试,或回复「人工报价」转人工处理。",
enabled: true,
},
{
id: "r18",
trigger: "TMS 异常",
text: "TMS 标准价查询失败,已记录系统异常。可选择转内部人工报价。",
id: "r29",
trigger: "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,
},
],
@@ -193,19 +272,43 @@ export const mockReplyCategories: ReplyCategory[] = [
{
id: "transfer",
title: "转人工",
desc: "关键词/无价/解析失败等场景转人工话术。",
desc: "AI 无法独立完成询价时,引导销售确认是否拉内部航线/产品进群协同。",
tagClass: "tag-violet",
items: [
{
id: "r19",
trigger: "销售要求转人工",
text: "已为您转接人工处理,请稍候。工单 {{ticket_no}} 保持当前状态。",
id: "r32",
trigger: "TMS 暂无报价",
text: "暂无 TMS 标准报价。触发转人工:{{transfer_reason}}。是否拉{{internal_role_label}}进群协同?(是 / 否)",
enabled: true,
},
{
id: "r20",
trigger: "无 TMS 标准价 → 引导转人工",
text: "当前线路暂无标准价,建议转内部人工报价或转人工客服。",
id: "r33",
trigger: "意图识别失败",
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,
},
],
@@ -23,7 +23,12 @@ export type CostRoute = {
id: string;
label: string;
color: string;
/** 与 costDates 对齐;某日无样本为 null */
values: Array<number | null>;
/** 前端根据 values 生成的 SVG path */
path: string;
/** 有金额的日子画点(仅一天有数时折线不可见,靠点可见) */
points: Array<{ x: number; y: number; index: number }>;
};
/** 今日视角九指标(与原型默认展示一致) */
@@ -78,23 +83,14 @@ export const rankingDepts = [
"陆运产品部",
];
/** 成本趋势折线:路径坐标对齐原型 SVG */
/** 成本趋势折线:路径坐标对齐原型 SVG(仅作样式参考;正式数据来自主账) */
export const costRoutes: CostRoute[] = [
{ id: "cgo-kul", label: "CGO-KUL", color: "#2563eb", path: "M36 56 L145 51 L254 61 L363 46 L472 50 L581 41 L690 36" },
{ id: "szx-kul", label: "SZX-KUL", color: "#3b82f6", path: "M36 64 L145 59 L254 69 L363 54 L472 59 L581 49 L690 44" },
{ id: "hkg-kul", label: "HKG-KUL", color: "#0891b2", path: "M36 71 L145 66 L254 75 L363 61 L472 66 L581 56 L690 51" },
{ id: "nkg-kul", label: "NKG-KUL", color: "#0e7490", path: "M36 79 L145 74 L254 81 L363 69 L472 74 L581 64 L690 59" },
{ id: "hkg-del", label: "HKG-DEL", color: "#6366f1", path: "M36 86 L145 81 L254 89 L363 76 L472 81 L581 71 L690 66" },
{ id: "cgo-maa", label: "CGO-MAA", color: "#8b5cf6", path: "M36 94 L145 89 L254 96 L363 84 L472 89 L581 79 L690 74" },
{ id: "cgo-blr", label: "CGO-BLR", color: "#a855f7", path: "M36 101 L145 96 L254 104 L363 91 L472 96 L581 86 L690 81" },
{ id: "zuh-crk", label: "ZUH-CRK", color: "#c026d3", path: "M36 109 L145 104 L254 111 L363 99 L472 104 L581 94 L690 89" },
{ id: "wnz-cgk", label: "WNZ-CGK", color: "#db2777", path: "M36 116 L145 111 L254 119 L363 106 L472 111 L581 101 L690 96" },
{ id: "bkk", label: "BKK", color: "#e11d48", path: "M36 124 L145 119 L254 126 L363 114 L472 119 L581 109 L690 104" },
{ id: "waicai", label: "外采-空运", color: "#dc2626", path: "M36 131 L145 126 L254 134 L363 121 L472 126 L581 116 L690 111" },
{ id: "quanxian", label: "全线", color: "#ea580c", path: "M36 139 L145 134 L254 141 L363 129 L472 134 L581 124 L690 119" },
{ id: "haiyun", label: "海运全线", color: "#d97706", path: "M36 150 L145 146 L254 154 L363 144 L472 141 L581 136 L690 131" },
{ id: "zhonggang", label: "中港/中亚/中欧", color: "#65a30d", path: "M36 159 L145 156 L254 163 L363 154 L472 151 L581 146 L690 141" },
{ id: "changtu", label: "国内长途/零担", color: "#0d9488", path: "M36 169 L145 166 L254 173 L363 164 L472 161 L581 159 L690 154" },
{ id: "chengpei", label: "国内城配/拖车/打包", color: "#059669", path: "M36 179 L145 176 L254 181 L363 174 L472 171 L581 169 L690 164" },
{ id: "dongnan", label: "东南亚", color: "#0284c7", path: "M36 186 L145 184 L254 189 L363 181 L472 179 L581 176 L690 171" },
{
id: "cgo-kul",
label: "CGO-KUL",
color: "#2563eb",
values: [120, 118, 125, 110, 115, 105, 100],
path: "M36 56 L145 51 L254 61 L363 46 L472 50 L581 41 L690 36",
points: [],
},
];
@@ -2,13 +2,15 @@
/**
* 员工管理:对接主账 /inquiry/staffRole。
* 企微 userid 可手工维护,也可点「同步企业微信」由主账 upsert(姓名/手机/部门)。
* 浏览器禁止直连企微;岗位/线路仍手工改。
* 浏览器禁止直连企微;岗位/负责人/线路仍手工改。
* 产品·航线:负责人(是/否)+ 负责线路多选,选项照搬旧项目 admin-staff。
*/
import { computed, onMounted, reactive, ref, watch } from "vue";
import { message, Modal } from "ant-design-vue";
import {
disableStaff,
getStaffCounts,
getStaffDepartments,
getStaffList,
getWecomDepartments,
saveStaff as saveStaffApi,
@@ -16,10 +18,12 @@ import {
} from "/@/views/inquiry/staffRole/staffRole.api";
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
import AdminPagination from "/@/views/inquiry/components/AdminPagination.vue";
type StaffRole = "sales" | "air" | "sea" | "land";
type WecomDeptOption = { id: number; name: string; path: string; parentId: number; depth: number };
/** 部门下拉:value 存短名,label 带层级缩进 */
type DeptOption = { id: number; name: string; label: string; depth: number };
type StaffRow = {
id: string;
@@ -30,6 +34,8 @@ type StaffRow = {
role: StaffRole;
roleLabel: string;
routes: string;
/** 产品/航线是否负责人;销售为 null */
isLeader: boolean | null;
notifyEventException: boolean;
createdAt: string;
status: "active" | "disabled";
@@ -45,8 +51,53 @@ const roleLabelMap: Record<StaffRole, string> = {
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 loading = ref(false);
const total = ref(0);
const pageNo = ref(1);
const pageSize = ref(10);
const counts = ref({ all: 0, sales: 0, air: 0, sea: 0, land: 0 });
const tab = ref<"all" | StaffRole>("all");
const filters = reactive({
@@ -59,22 +110,48 @@ const filters = reactive({
const modalOpen = ref(false);
const editing = ref<StaffRow | null>(null);
const syncing = ref(false);
const wecomDepts = ref<WecomDeptOption[]>([]);
const wecomDeptsLoading = ref(false);
const wecomDeptsLoaded = ref(false);
/** 部门树扁平列表:一级不缩进,子级前加空格 */
const deptOptions = ref<DeptOption[]>([]);
const deptLoading = ref(false);
const deptLoaded = ref(false);
const deptOpen = ref(false);
const form = reactive({
name: "",
wecomId: "",
phone: "",
dept: "",
role: "sales" as StaffRole,
routes: "",
/** 空字符串=未选;true/false 字符串对齐旧项目下拉 */
isLeader: "" as "" | "true" | "false",
/** 负责线路多选;销售岗位不展示 */
routeList: [] as string[],
notifyEventException: false,
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);
/**
* 部门下拉过滤:点开/聚焦时展示全量树;只有用户继续打字才按关键字筛。
* 为何:编辑时 form.dept 已是「武汉友通达」等完整名,若直接用它过滤会只剩 1 条。
*/
const deptFilterActive = ref(false);
const filteredDeptOptions = computed(() => {
if (!deptFilterActive.value) return deptOptions.value;
const q = (form.dept || "").trim().toLowerCase();
if (!q) return deptOptions.value;
return deptOptions.value.filter((d) => d.name.toLowerCase().includes(q));
});
function colorFor(name: string) {
let h = 0;
for (let i = 0; i < name.length; i++) h = (h + name.charCodeAt(i) * (i + 1)) % AVATAR_COLORS.length;
@@ -83,6 +160,9 @@ function colorFor(name: string) {
function mapRow(r: any): StaffRow {
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 {
id: String(r.id || ""),
name: r.name || "",
@@ -92,6 +172,7 @@ function mapRow(r: any): StaffRow {
role: ["sales", "air", "sea", "land"].includes(role) ? role : "sales",
roleLabel: r.roleLabel || roleLabelMap[role] || role,
routes: r.routes || "-",
isLeader,
notifyEventException: r.notifyEventException === 1 || r.notifyEventException === true,
createdAt: (r.createTime || "").toString().slice(0, 10) || "—",
status: r.status === "disabled" ? "disabled" : "active",
@@ -124,11 +205,19 @@ async function loadList() {
roleCode,
routes: filters.route || undefined,
status: filters.status || undefined,
pageNo: 1,
pageSize: 200,
pageNo: pageNo.value,
pageSize: pageSize.value,
});
const records = res?.records || res?.result?.records || [];
const page = res?.records ? res : res?.result || {};
const records = page.records || [];
total.value = Number(page.total || records.length || 0);
rows.value = (records as any[]).map(mapRow);
const maxPage = Math.max(1, Math.ceil(total.value / pageSize.value) || 1);
if (pageNo.value > maxPage) {
pageNo.value = maxPage;
await loadList();
return;
}
await loadCounts();
} catch (e: any) {
message.error(e?.message || "加载员工失败");
@@ -141,71 +230,91 @@ let searchTimer: ReturnType<typeof setTimeout> | null = null;
watch(
() => [tab.value, filters.keyword, filters.role, filters.route, filters.status],
() => {
pageNo.value = 1;
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => loadList(), 200);
},
);
function onPageChange() {
loadList();
}
onMounted(() => loadList());
/** 企微根部门公司全称,下拉不展示 */
const ROOT_DEPT_NAME = "深圳市友通达供应链管理有限公司";
/**
* 打开弹窗时拉企微部门;失败不挡手工保存,仅提示。
* 下拉对齐旧项目业务层:只展示「分公司」「旧组织」下的直接子部门
*(事业二部/元昇/武汉友通达/通多邦…),不要公司根下的一级中心。
* 按 parentId 排成树再前序展开。
* 展示:部门1 / 「 」子部门1 / 「 」子部门2 / 部门2(根公司不出现)。
* 保存仍用短名 name。
*/
async function ensureWecomDepartments() {
if (wecomDeptsLoaded.value || wecomDeptsLoading.value) {
function buildDeptTreeOptions(raw: any[]): DeptOption[] {
const nodes = (raw as any[])
.map((d) => ({
id: Number(d?.id),
name: String(d?.name || "").trim(),
parentId: Number(d?.parentId ?? d?.parentid ?? 0),
}))
.filter((d) => d.name && Number.isFinite(d.id) && d.id > 0)
.filter((d) => d.id !== 1 && d.parentId > 0 && d.name !== ROOT_DEPT_NAME);
const idSet = new Set(nodes.map((n) => n.id));
const children = new Map<number, typeof nodes>();
for (const n of nodes) {
const pid = idSet.has(n.parentId) ? n.parentId : 0;
const list = children.get(pid) || [];
list.push(n);
children.set(pid, list);
}
const out: DeptOption[] = [];
const walk = (pid: number, depth: number) => {
for (const n of children.get(pid) || []) {
out.push({
id: n.id,
name: n.name,
label: "\u00A0".repeat(depth) + n.name,
depth,
});
walk(n.id, depth + 1);
}
};
walk(0, 0);
return out;
}
/**
* 打开弹窗拉企微部门树;失败不挡手工保存。
* 优先 wecomDepartments(有 parentId 才能缩进);否则退回 departments 扁平短名。
*/
async function ensureDeptOptions() {
if (deptLoaded.value || deptLoading.value) {
ensureCurrentDeptOption();
return;
}
wecomDeptsLoading.value = true;
deptLoading.value = true;
try {
const res: any = await getWecomDepartments();
const list = Array.isArray(res) ? res : res?.result || [];
const all: WecomDeptOption[] = (list as any[])
.map((d) => {
const path = String(d.path || d.name || "");
const name = String(d.name || path);
const parentId = Number(d.parentId ?? d.parentid ?? 0);
const depth = path ? path.split(" / ").length - 1 : 0;
return {
id: Number(d.id),
name,
path,
parentId,
depth: depth < 0 ? 0 : depth,
};
})
.filter((d) => d.name && d.id);
// 旧项目下拉对应层:分公司 + 旧组织 的子节点
const businessParentNames = new Set(["分公司", "旧组织"]);
const businessParentIds = new Set(
all.filter((d) => businessParentNames.has(d.name)).map((d) => d.id),
);
let picked = all.filter((d) => businessParentIds.has(d.parentId));
// 兜底:若企微改名导致找不到父节点,仍回退到根下一级,避免空下拉
if (!picked.length) {
const rootIds = new Set(
all.filter((d) => d.parentId === 0 || d.depth === 0).map((d) => d.id),
);
if (!rootIds.size) {
const minDepth = Math.min(...all.map((d) => d.depth));
all.filter((d) => d.depth === minDepth).forEach((d) => rootIds.add(d.id));
}
picked = all.filter((d) => rootIds.has(d.parentId));
try {
const res: any = await getWecomDepartments();
const list = Array.isArray(res) ? res : res?.result || [];
deptOptions.value = buildDeptTreeOptions(list);
} catch {
const res: any = await getStaffDepartments();
const list = Array.isArray(res) ? res : res?.result || [];
deptOptions.value = (list as any[])
.map((x, i) => {
const name = String(x || "").trim();
return { id: -(i + 1), name, label: name, depth: 0 };
})
.filter((d) => d.name && d.name !== ROOT_DEPT_NAME);
}
wecomDepts.value = picked
.slice()
.sort((a, b) => a.name.localeCompare(b.name, "zh-CN"))
.map((d) => ({ ...d, depth: 0 }));
wecomDeptsLoaded.value = true;
deptLoaded.value = true;
} catch (e: any) {
message.warning(e?.message || "企微部门列表加载失败,可稍后重试");
message.warning(e?.message || "部门列表加载失败,可稍后重试");
} finally {
wecomDeptsLoading.value = false;
deptLoading.value = false;
ensureCurrentDeptOption();
}
}
@@ -213,17 +322,66 @@ async function ensureWecomDepartments() {
function ensureCurrentDeptOption() {
const cur = (form.dept || "").trim();
if (!cur) return;
const hit = wecomDepts.value.some((d) => d.name === cur || d.path === cur);
if (!hit) {
wecomDepts.value = [{ id: -1, name: cur, path: cur, parentId: 0, depth: 0 }, ...wecomDepts.value];
} else {
const byPath = wecomDepts.value.find((d) => d.path === cur);
if (byPath && form.dept !== byPath.name) {
form.dept = byPath.name;
}
if (!deptOptions.value.some((d) => d.name === cur)) {
deptOptions.value = [{ id: -1, name: cur, label: cur, depth: 0 }, ...deptOptions.value];
}
}
let deptBlurTimer: ReturnType<typeof setTimeout> | null = null;
function pickDept(d: DeptOption) {
form.dept = d.name;
deptFilterActive.value = false;
deptOpen.value = false;
}
function onDeptFocus() {
if (deptBlurTimer) {
clearTimeout(deptBlurTimer);
deptBlurTimer = null;
}
// 聚焦先看全量,避免当前部门名把列表滤成一项
deptFilterActive.value = false;
deptOpen.value = true;
ensureDeptOptions();
}
function onDeptInput() {
deptFilterActive.value = true;
deptOpen.value = true;
}
function onDeptBlur() {
deptBlurTimer = setTimeout(() => {
deptOpen.value = false;
deptFilterActive.value = false;
}, 180);
}
watch(
() => modalOpen.value,
(open) => {
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));
},
);
function openCreate() {
editing.value = null;
Object.assign(form, {
@@ -232,31 +390,34 @@ function openCreate() {
phone: "",
dept: "",
role: "sales",
routes: "",
isLeader: "",
routeList: [],
notifyEventException: false,
status: "active",
});
modalOpen.value = true;
// 每次打开重新拉一级部门,避免会话里还留着旧的「整棵树」缓存
wecomDeptsLoaded.value = false;
ensureWecomDepartments();
deptLoaded.value = false;
ensureDeptOptions();
}
function openEdit(row: StaffRow) {
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, {
name: row.name,
wecomId: row.wecomId,
phone: row.phoneMasked === "未填写" ? "" : row.phoneMasked,
dept: row.dept === "未分配" ? "" : row.dept,
role: row.role,
routes: row.routes === "-" ? "" : row.routes,
isLeader: row.isLeader === true ? "true" : row.isLeader === false ? "false" : "",
routeList: routes,
notifyEventException: Boolean(row.notifyEventException),
status: row.status,
});
modalOpen.value = true;
wecomDeptsLoaded.value = false;
ensureWecomDepartments();
deptLoaded.value = false;
ensureDeptOptions();
}
async function saveStaff() {
@@ -264,6 +425,16 @@ async function saveStaff() {
message.warning("请填写姓名与企业微信 ID");
return;
}
if (isProductOrAir(form.role)) {
if (form.isLeader !== "true" && form.isLeader !== "false") {
message.warning("请选择是否负责人");
return;
}
if (!form.routeList.length) {
message.warning("请选择负责线路");
return;
}
}
const body = {
id: editing.value?.id,
name: form.name.trim(),
@@ -272,7 +443,8 @@ async function saveStaff() {
departName: form.dept.trim() || undefined,
roleCode: 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,
status: form.status,
};
@@ -319,11 +491,11 @@ async function syncWecom() {
}
function routeTags(routes: string) {
if (!routes || routes === "-") return [];
return routes
.split(/[/·,、]/)
.map((s) => s.trim())
.filter(Boolean);
return parseRouteList(routes);
}
function filterRouteOption(input: string, option: any) {
return String(option?.label ?? "").includes(input);
}
</script>
@@ -374,15 +546,7 @@ function routeTags(routes: string) {
</select>
<select v-model="filters.route">
<option value="">负责线路</option>
<option value="CGO-KUL">CGO-KUL</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>
<option v-for="r in ALL_ROUTE_FILTER_OPTIONS" :key="r" :value="r">{{ r }}</option>
</select>
<select v-model="filters.status">
<option value="">状态</option>
@@ -454,12 +618,14 @@ function routeTags(routes: string) {
</tbody>
</table>
</div>
<div class="pagination">
<span>共 <b style="color: #0f172a">{{ filtered.length }}</b> 名员工</span>
<div class="pages">
<button class="is-active" type="button">1</button>
</div>
</div>
<AdminPagination
v-model:page-no="pageNo"
v-model:page-size="pageSize"
:total="total"
unit="名员工"
:disabled="loading"
@change="onPageChange"
/>
</div>
<AdminModal
@@ -483,10 +649,32 @@ function routeTags(routes: string) {
</div>
<div class="form-row">
<label class="lbl">所属部门</label>
<select v-model="form.dept" class="field" :disabled="wecomDeptsLoading">
<option value="">{{ wecomDeptsLoading ? "加载企微部门…" : "请选择部门" }}</option>
<option v-for="d in wecomDepts" :key="d.id + '-' + d.name" :value="d.name">{{ d.name }}</option>
</select>
<div class="dept-combo">
<input
v-model="form.dept"
class="field"
type="text"
autocomplete="off"
:disabled="deptLoading"
:placeholder="deptLoading ? '加载部门…' : '请选择或输入部门'"
@focus="onDeptFocus"
@input="onDeptInput"
@blur="onDeptBlur"
/>
<span class="dept-combo-arrow" aria-hidden="true">▾</span>
<ul v-show="deptOpen" class="dept-combo-list">
<li v-if="!filteredDeptOptions.length" class="is-empty">无匹配部门</li>
<li
v-for="d in filteredDeptOptions"
:key="d.id"
:class="{ 'is-active': d.name === form.dept }"
:style="{ paddingLeft: 10 + d.depth * 12 + 'px' }"
@mousedown.prevent="pickDept(d)"
>
{{ d.name }}
</li>
</ul>
</div>
</div>
<div class="form-row">
<label class="lbl">岗位 <span class="req">*</span></label>
@@ -497,9 +685,13 @@ function routeTags(routes: string) {
<option value="land">产品·陆运</option>
</select>
</div>
<div class="form-row">
<label class="lbl">负责线路</label>
<input v-model="form.routes" class="field" type="text" placeholder="销售可留空" />
<div v-if="showLeaderAndRoutes" class="form-row">
<label class="lbl">负责人 <span class="req">*</span></label>
<select v-model="form.isLeader" class="field">
<option value="">请选择是否负责人</option>
<option value="true">是</option>
<option value="false">否</option>
</select>
</div>
<div class="form-row">
<label class="lbl">消息提醒</label>
@@ -523,6 +715,19 @@ function routeTags(routes: string) {
</button>
</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>
</template>
@@ -553,4 +758,73 @@ function routeTags(routes: string) {
accent-color: #155eef;
cursor: pointer;
}
.dept-combo {
position: relative;
width: 100%;
}
.dept-combo .field {
padding-right: 28px;
}
.dept-combo-arrow {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
color: #64748b;
font-size: 10px;
line-height: 1;
}
.dept-combo-list {
position: absolute;
left: 0;
right: 0;
top: calc(100% + 4px);
z-index: 30;
max-height: 220px;
overflow: auto;
margin: 0;
padding: 4px 0;
list-style: none;
background: #fff;
border: 1px solid #cbd5e1;
border-radius: 7px;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
}
.dept-combo-list li {
padding: 6px 10px;
font-size: 11px;
font-weight: 600;
color: #172033;
cursor: pointer;
white-space: nowrap;
}
.dept-combo-list li:hover,
.dept-combo-list li.is-active {
background: #eff6ff;
color: #155eef;
}
.dept-combo-list li.is-empty {
color: #94a3b8;
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>
@@ -1,6 +1,7 @@
/**
* 员工岗位 API:对接主账 /inquiry/staffRole。
* 与 Jeecg /sys/user 分离——本页管企微岗位绑定;同步走主账 /syncWecom,浏览器不直连企微。
* 部门下拉对齐旧项目:GET /inquiry/staffRole/departments → string[]。
*/
import { defHttp } from '/@/utils/http/axios';
@@ -12,6 +13,8 @@ enum Api {
disable = '/inquiry/staffRole/disable',
queryById = '/inquiry/staffRole/queryById',
syncWecom = '/inquiry/staffRole/syncWecom',
/** 旧项目同名接口:部门短名全量 */
departments = '/inquiry/staffRole/departments',
wecomDepartments = '/inquiry/staffRole/wecomDepartments',
}
@@ -23,5 +26,10 @@ export const disableStaff = (id: string) => defHttp.post({ url: Api.disable, par
export const getStaffById = (id: string) => defHttp.get({ url: Api.queryById, params: { id } });
/** 主账拉企微通讯录 upsert;返回 { fetched, created, updated, skipped, message } */
export const syncWecomStaff = () => defHttp.post({ url: Api.syncWecom });
/** 企微部门下拉(主账转发,禁止浏览器直连企微) */
/**
* 部门下拉(对齐旧项目):返回 string[] 短名全量,可搜索可滚动。
* 失败时勿挡手工填部门。
*/
export const getStaffDepartments = () => defHttp.get({ url: Api.departments });
/** @deprecated 结构列表;员工下拉请用 getStaffDepartments */
export const getWecomDepartments = () => defHttp.get({ url: Api.wecomDepartments });
@@ -191,14 +191,28 @@ body {
.page-topbar-actions-slot {
gap: 8px !important;
min-height: 34px !important;
max-height: 40px !important;
/* 勿 max-height + overflow:hidden:会裁掉看板日期下拉,看起来像不能选 */
max-height: none !important;
position: relative !important;
z-index: 40 !important;
pointer-events: auto !important;
overflow: hidden !important;
overflow: visible !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 {
pointer-events: auto !important;
cursor: pointer !important;
@@ -284,41 +298,62 @@ body {
box-shadow: 0 0 0 3px rgba(21, 94, 239, 0.12) !important;
}
/* 日期范围:Ant RangePicker,一次弹层点选起止;外观对齐工具栏 select */
.toolbar-date-range {
display: inline-flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
height: 38px;
box-sizing: border-box;
padding: 0 8px;
border: 1px solid var(--line);
border-radius: 8px;
background: #ffffff;
flex-shrink: 0;
max-width: 220px;
}
.toolbar-date-range input[type="date"] {
width: 92px;
min-width: 0;
max-width: 92px;
height: 28px;
border: none;
background: transparent;
padding: 0;
font-size: 10px;
font-weight: 600;
color: #344054;
outline: none;
cursor: pointer;
.toolbar-date-range .toolbar-range-picker.ant-picker,
.toolbar-date-range .ant-picker {
height: 38px !important;
padding: 0 12px !important;
border: 1px solid var(--line) !important;
border-radius: 8px !important;
background: #ffffff !important;
box-shadow: none !important;
transition: border-color 0.15s ease, box-shadow 0.15s ease !important;
}
.toolbar-date-sep {
font-size: 10px;
font-weight: 700;
color: #98a2b3;
flex-shrink: 0;
line-height: 1;
.toolbar-date-range .ant-picker:hover {
border-color: #98a2b3 !important;
}
.toolbar-date-range .ant-picker-focused,
.toolbar-date-range .ant-picker:focus,
.toolbar-date-range .ant-picker.ant-picker-focused {
border-color: #155eef !important;
box-shadow: 0 0 0 3px rgba(21, 94, 239, 0.12) !important;
}
.toolbar-date-range .ant-picker-input > input {
font-size: 11px !important;
font-weight: 700 !important;
color: #344054 !important;
cursor: pointer !important;
}
.toolbar-date-range .ant-picker-input > input::placeholder {
color: #98a2b3 !important;
font-weight: 600 !important;
}
.toolbar-date-range .ant-picker-separator {
color: #98a2b3 !important;
font-size: 11px !important;
font-weight: 600 !important;
padding: 0 4px !important;
}
.toolbar-date-range .ant-picker-suffix {
color: #98a2b3 !important;
margin-left: 4px !important;
}
.toolbar-date-range .ant-picker-clear {
background: #ffffff !important;
}
.toolbar .toolbar-actions .btn {
@@ -770,6 +805,7 @@ body {
background: #ffffff !important;
font-size: 11px !important;
color: #475467 !important;
flex-wrap: wrap !important;
}
.pagination button {
@@ -778,6 +814,17 @@ body {
border-radius: 6px !important;
font-size: 10.5px !important;
font-weight: 700 !important;
border: 1px solid #e4e7ec !important;
background: #ffffff !important;
color: #344054 !important;
cursor: pointer !important;
padding: 0 8px !important;
}
.pagination .pages button.is-active {
border-color: #155eef !important;
background: #eff4ff !important;
color: #155eef !important;
}
.pagination .pages {
@@ -786,6 +833,11 @@ body {
gap: 4px;
}
.pagination b {
color: #0f172a;
font-weight: 800;
}
/* ---- section / 权限树 ---- */
.section-card {
padding: 18px 20px !important;
@@ -5,7 +5,7 @@
* 详情/关单弹窗必须用 AdminModal:本工程 ant-design-vue 3 的 a-modal 只认 visible,
* v-model:open 不会弹出(账号/角色页已统一走 AdminModal)。
*/
import { onMounted, reactive, ref, watch } from "vue";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { message, Modal } from "ant-design-vue";
import {
getWorkOrderDetail,
@@ -15,6 +15,7 @@ import {
} from "/@/views/inquiry/workOrder/workOrder.api";
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
import AdminPagination from "/@/views/inquiry/components/AdminPagination.vue";
type TicketQuote = { source: string; price: string; highlight?: boolean };
type TicketAttach = { name: string; uploadedAt: string; uploader: string };
@@ -52,6 +53,8 @@ type TicketRow = {
const rows = ref<TicketRow[]>([]);
const loading = ref(false);
const total = ref(0);
const pageNo = ref(1);
const pageSize = ref(10);
const filters = reactive({
keyword: "",
status: "",
@@ -67,6 +70,21 @@ const closeOpen = ref(false);
const current = ref<TicketRow | null>(null);
const closeReason = ref("");
/**
* 创建时间范围:与 filters.dateFrom/dateTo 双向同步。
* RangePicker 一次弹层点选起止两天;valueFormat 固定 YYYY-MM-DD 给列表 API。
*/
const dateRange = computed({
get(): [string, string] | undefined {
if (!filters.dateFrom && !filters.dateTo) return undefined;
return [filters.dateFrom || "", filters.dateTo || ""];
},
set(v: [string, string] | null | undefined) {
filters.dateFrom = v?.[0] ? String(v[0]) : "";
filters.dateTo = v?.[1] ? String(v[1]) : "";
},
});
function dash(v: any) {
const s = (v ?? "").toString().trim();
return s ? s : "-";
@@ -276,13 +294,19 @@ async function loadList() {
bizType: filters.bizType || undefined,
dateFrom: filters.dateFrom || undefined,
dateTo: filters.dateTo || undefined,
pageNo: 1,
pageSize: 200,
pageNo: pageNo.value,
pageSize: pageSize.value,
});
const page = res?.records ? res : res?.result || {};
const records = page.records || [];
total.value = Number(page.total || records.length || 0);
rows.value = (records as any[]).map((t) => mapTicket(t));
// 删减后当前页可能越界,回退到最后一页
const maxPage = Math.max(1, Math.ceil(total.value / pageSize.value) || 1);
if (pageNo.value > maxPage) {
pageNo.value = maxPage;
await loadList();
}
} catch (e: any) {
message.error(e?.message || "加载工单失败");
} finally {
@@ -291,15 +315,21 @@ async function loadList() {
}
let timer: ReturnType<typeof setTimeout> | null = null;
/** 筛选变更:回到第 1 页再拉列表 */
watch(
() => ({ ...filters }),
() => {
pageNo.value = 1;
if (timer) clearTimeout(timer);
timer = setTimeout(() => loadList(), 250);
},
{ deep: true },
);
function onPageChange() {
loadList();
}
onMounted(() => loadList());
async function openDetail(row: TicketRow) {
@@ -414,9 +444,19 @@ function refresh() {
<option value="多段联运">多段联运</option>
</select>
<div class="toolbar-date-range" title="按创建时间筛选">
<input v-model="filters.dateFrom" type="date" aria-label="创建时间起" />
<span class="toolbar-date-sep">–</span>
<input v-model="filters.dateTo" type="date" aria-label="创建时间止" />
<a-range-picker
v-model:value="dateRange"
class="toolbar-range-picker"
value-format="YYYY-MM-DD"
separator="至"
:placeholder="['开始日期', '结束日期']"
:allow-clear="true"
:input-read-only="true"
>
<template #suffixIcon>
<ProtoIcon name="calendar" :size="14" />
</template>
</a-range-picker>
</div>
</div>
@@ -555,9 +595,14 @@ function refresh() {
</tbody>
</table>
</div>
<div class="pagination">
<span>共 <b style="color: #0f172a">{{ total }}</b> 条工单</span>
</div>
<AdminPagination
v-model:page-no="pageNo"
v-model:page-size="pageSize"
:total="total"
unit="条工单"
:disabled="loading"
@change="onPageChange"
/>
</div>
</div>