Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7a8487eb3 | ||
|
|
cb3bf5238b | ||
|
|
11ec728cf4 | ||
|
|
9d4889c482 | ||
|
|
07c988e61f | ||
|
|
1847504807 | ||
|
|
b1eba3facc | ||
|
|
38a25a4b40 | ||
|
|
060f865a1e | ||
|
|
624ec1b7c4 |
@@ -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]:
|
||||
"""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]: ...
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
企微 JS-SDK 签名(给复制页写剪贴板)。
|
||||
|
||||
本文件职责:取 jsapi_ticket、按当前页 URL 签名。请求线程只打缓存/短 HTTP。
|
||||
禁止:把 access_token / ticket 写进页面或日志;禁止在这里改工单。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from agent.channel.outbox.sender import WeComAppClient
|
||||
from agent.config import get_settings
|
||||
from agent.redis_coord.keys import TOKEN_JSAPI
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TICKET_TTL = 3600
|
||||
|
||||
|
||||
def build_jsapi_signature(*, url: str, kind: str = "corp") -> dict[str, Any]:
|
||||
"""
|
||||
给前端 ww.register 用的签名。
|
||||
|
||||
kind=corp → get_jsapi_ticket;kind=agent → agent_config ticket。
|
||||
URL 必须与浏览器地址栏一致(不含 hash)。
|
||||
"""
|
||||
page = (url or "").strip()
|
||||
if not page:
|
||||
raise ValueError("url_empty")
|
||||
ticket = _jsapi_ticket(kind="agent" if kind == "agent" else "corp")
|
||||
if not ticket:
|
||||
raise RuntimeError("jsapi_ticket_missing")
|
||||
nonce = secrets.token_hex(8)
|
||||
timestamp = str(int(time.time()))
|
||||
raw = f"jsapi_ticket={ticket}&noncestr={nonce}×tamp={timestamp}&url={page}"
|
||||
signature = hashlib.sha1(raw.encode("utf-8")).hexdigest()
|
||||
return {
|
||||
"timestamp": int(timestamp),
|
||||
"nonceStr": nonce,
|
||||
"signature": signature,
|
||||
}
|
||||
|
||||
|
||||
def _jsapi_ticket(*, kind: str) -> str:
|
||||
cached = _cache_get(kind)
|
||||
if cached:
|
||||
return cached
|
||||
settings = get_settings()
|
||||
if not (settings.wecom_corp_id and settings.wecom_secret):
|
||||
return ""
|
||||
client = WeComAppClient(
|
||||
corp_id=settings.wecom_corp_id,
|
||||
secret=settings.wecom_secret,
|
||||
agent_id=int(settings.wecom_agent_id or "0") or 0,
|
||||
)
|
||||
token = client.get_access_token()
|
||||
if kind == "agent":
|
||||
api = "https://qyapi.weixin.qq.com/cgi-bin/ticket/get"
|
||||
params = {"access_token": token, "type": "agent_config"}
|
||||
else:
|
||||
api = "https://qyapi.weixin.qq.com/cgi-bin/get_jsapi_ticket"
|
||||
params = {"access_token": token}
|
||||
with httpx.Client(timeout=8.0) as http:
|
||||
data = http.get(api, params=params).json()
|
||||
errcode = int(data.get("errcode") or 0)
|
||||
ticket = str(data.get("ticket") or "")
|
||||
if errcode != 0 or not ticket:
|
||||
logger.warning("jsapi ticket 失败 kind=%s errcode=%s", kind, errcode)
|
||||
return ""
|
||||
_cache_set(kind, ticket)
|
||||
return ticket
|
||||
|
||||
|
||||
def _cache_get(kind: str) -> Optional[str]:
|
||||
redis = _try_redis()
|
||||
if redis is None:
|
||||
return None
|
||||
try:
|
||||
val = redis.raw.get(redis.key(TOKEN_JSAPI, kind))
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
if val is None:
|
||||
return None
|
||||
return val if isinstance(val, str) else val.decode("utf-8")
|
||||
|
||||
|
||||
def _cache_set(kind: str, ticket: str) -> None:
|
||||
redis = _try_redis()
|
||||
if redis is None:
|
||||
return
|
||||
try:
|
||||
redis.raw.set(redis.key(TOKEN_JSAPI, kind), ticket, ex=_TICKET_TTL)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("jsapi ticket 缓存失败:%s", exc)
|
||||
|
||||
|
||||
def _try_redis():
|
||||
try:
|
||||
from agent.redis_coord import get_redis_runtime
|
||||
|
||||
return get_redis_runtime(get_settings()).client
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
@@ -41,6 +41,10 @@ class Settings(BaseSettings):
|
||||
ytd_env: str = Field(default="test", alias="YTD_ENV")
|
||||
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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
+80
-1
@@ -11,21 +11,30 @@ import org.apache.shiro.authz.annotation.RequiresPermissions;
|
||||
import org.jeecg.common.api.vo.Result;
|
||||
import org.jeecg.common.aspect.annotation.AutoLog;
|
||||
import org.jeecg.common.system.base.controller.JeecgController;
|
||||
import org.apache.shiro.SecurityUtils;
|
||||
import org.jeecg.common.system.vo.LoginUser;
|
||||
import org.jeecg.modules.inquiry.dto.StaffWecomSyncResult;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
|
||||
import org.jeecg.modules.inquiry.service.IInquiryStaffRoleService;
|
||||
import org.jeecg.modules.inquiry.wecom.WeComAddressBookClient;
|
||||
import org.jeecg.modules.inquiry.wecom.WeComDepartment;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 员工岗位管理 API。
|
||||
* <p>
|
||||
* 路径前缀 /inquiry/staffRole;权限码 inquiry:staff:*。
|
||||
* 不做企微换号,wecom_id 由运营手工绑定。
|
||||
* wecom_id 可手工维护,也可经 /syncWecom 从企微通讯录 upsert(姓名/手机/部门)。
|
||||
* 浏览器禁止直连企微;同步只服务展示,不挡询价主路径。
|
||||
*/
|
||||
@Slf4j
|
||||
@Api(tags = "询价-员工岗位")
|
||||
@@ -36,6 +45,9 @@ public class InquiryStaffRoleController extends JeecgController<InquiryStaffRole
|
||||
@Autowired
|
||||
private IInquiryStaffRoleService staffRoleService;
|
||||
|
||||
@Autowired
|
||||
private WeComAddressBookClient weComAddressBookClient;
|
||||
|
||||
private static final Map<String, String> ROLE_LABEL = new HashMap<>();
|
||||
|
||||
static {
|
||||
@@ -122,6 +134,73 @@ public class InquiryStaffRoleController extends JeecgController<InquiryStaffRole
|
||||
return Result.OK(staffRoleService.getById(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 企微通讯录全量 upsert。
|
||||
* 权限:编辑人员即可同步;不自动删/停用企微侧消失的人。
|
||||
*/
|
||||
@AutoLog(value = "员工岗位-同步企微通讯录")
|
||||
@ApiOperation("同步企微通讯录")
|
||||
@RequiresPermissions("inquiry:staff:edit")
|
||||
@PostMapping("/syncWecom")
|
||||
public Result<StaffWecomSyncResult> syncWecom() {
|
||||
String operator = currentUsername();
|
||||
StaffWecomSyncResult result = staffRoleService.syncFromWeCom(operator);
|
||||
return Result.OK(result.getMessage(), result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 员工「所属部门」下拉(对齐旧项目 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("员工部门名列表(对齐旧项目 /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() {
|
||||
return Result.OK(weComAddressBookClient.listDepartments());
|
||||
}
|
||||
|
||||
private String currentUsername() {
|
||||
try {
|
||||
Object principal = SecurityUtils.getSubject().getPrincipal();
|
||||
if (principal instanceof LoginUser) {
|
||||
return ((LoginUser) principal).getUsername();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 无登录主体时用系统标记
|
||||
}
|
||||
return "system";
|
||||
}
|
||||
|
||||
private void normalize(InquiryStaffRole body) {
|
||||
if (StringUtils.isBlank(body.getName()) || StringUtils.isBlank(body.getWecomId())) {
|
||||
throw new IllegalArgumentException("姓名与企微 userid 不能为空");
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package org.jeecg.modules.inquiry.dto;
|
||||
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 企微通讯录同步结果(给后台 toast / 日志)。
|
||||
*/
|
||||
@Data
|
||||
@ApiModel("员工企微同步结果")
|
||||
public class StaffWecomSyncResult {
|
||||
|
||||
@ApiModelProperty("企微侧已激活成员数")
|
||||
private int fetched;
|
||||
|
||||
@ApiModelProperty("新建条数")
|
||||
private int created;
|
||||
|
||||
@ApiModelProperty("更新条数")
|
||||
private int updated;
|
||||
|
||||
@ApiModelProperty("跳过条数(无 userid 等)")
|
||||
private int skipped;
|
||||
|
||||
@ApiModelProperty("摘要文案")
|
||||
private String message;
|
||||
}
|
||||
+2
-1
@@ -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
|
||||
*/
|
||||
|
||||
+11
@@ -1,6 +1,7 @@
|
||||
package org.jeecg.modules.inquiry.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import org.jeecg.modules.inquiry.dto.StaffWecomSyncResult;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
|
||||
|
||||
import java.util.Map;
|
||||
@@ -19,4 +20,14 @@ public interface IInquiryStaffRoleService extends IService<InquiryStaffRole> {
|
||||
* 停用员工(status=disabled),不物理删除。
|
||||
*/
|
||||
void disable(String id);
|
||||
|
||||
/**
|
||||
* 从企微通讯录全量 upsert:按 wecom_id 更新姓名/手机/部门,缺失则新建(默认销售岗)。
|
||||
* <p>
|
||||
* 不删、不停用企微侧消失的人;不覆盖已有 roleCode / routes / 提醒开关。
|
||||
*
|
||||
* @param operator 操作人(写入 createBy/updateBy)
|
||||
* @return 同步计数
|
||||
*/
|
||||
StaffWecomSyncResult syncFromWeCom(String operator);
|
||||
}
|
||||
|
||||
+308
-38
@@ -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";
|
||||
}
|
||||
|
||||
+80
-2
@@ -2,9 +2,15 @@ package org.jeecg.modules.inquiry.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.jeecg.modules.inquiry.dto.StaffWecomSyncResult;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryStaffRoleMapper;
|
||||
import org.jeecg.modules.inquiry.service.IInquiryStaffRoleService;
|
||||
import org.jeecg.modules.inquiry.wecom.WeComAddressBookClient;
|
||||
import org.jeecg.modules.inquiry.wecom.WeComMember;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -14,15 +20,25 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 员工岗位 CRUD 实现。
|
||||
* 员工岗位 CRUD + 企微通讯录 upsert。
|
||||
* <p>
|
||||
* 线程:跟随 HTTP 请求线程;无全局锁。wecom_id 唯一约束由库保证。
|
||||
* 线程:跟随 HTTP 请求线程;同步方法串行化,避免并发双写同一 wecom_id。
|
||||
* 无全局工单大锁;wecom_id 唯一约束由库保证。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class InquiryStaffRoleServiceImpl extends ServiceImpl<InquiryStaffRoleMapper, InquiryStaffRole>
|
||||
implements IInquiryStaffRoleService {
|
||||
|
||||
private static final String[] ROLE_CODES = {"sales", "air", "sea", "land"};
|
||||
private static final String DEFAULT_ROLE = "sales";
|
||||
private static final String DEFAULT_ROLE_LABEL = "销售";
|
||||
|
||||
@Autowired
|
||||
private WeComAddressBookClient weComAddressBookClient;
|
||||
|
||||
/** 同步互斥:同一进程内禁止两路并发全量 upsert。 */
|
||||
private final Object syncLock = new Object();
|
||||
|
||||
@Override
|
||||
public Map<String, Long> countByRole() {
|
||||
@@ -54,4 +70,66 @@ public class InquiryStaffRoleServiceImpl extends ServiceImpl<InquiryStaffRoleMap
|
||||
public InquiryStaffRole findByWecomId(String wecomId) {
|
||||
return getOne(new QueryWrapper<InquiryStaffRole>().eq("wecom_id", wecomId).last("LIMIT 1"), false);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public StaffWecomSyncResult syncFromWeCom(String operator) {
|
||||
synchronized (syncLock) {
|
||||
List<WeComMember> members = weComAddressBookClient.listActiveMembers();
|
||||
int created = 0;
|
||||
int updated = 0;
|
||||
int skipped = 0;
|
||||
Date now = new Date();
|
||||
String op = StringUtils.defaultIfBlank(operator, "wecom-sync");
|
||||
|
||||
for (WeComMember m : members) {
|
||||
if (m == null || StringUtils.isBlank(m.getUserid())) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
InquiryStaffRole existing = findByWecomId(m.getUserid());
|
||||
if (existing == null) {
|
||||
InquiryStaffRole row = new InquiryStaffRole();
|
||||
row.setWecomId(m.getUserid());
|
||||
row.setName(StringUtils.defaultIfBlank(m.getName(), m.getUserid()));
|
||||
row.setMobile(StringUtils.trimToNull(m.getMobile()));
|
||||
row.setDepartName(StringUtils.trimToNull(m.getDepartmentName()));
|
||||
row.setRoleCode(DEFAULT_ROLE);
|
||||
row.setRoleLabel(DEFAULT_ROLE_LABEL);
|
||||
row.setNotifyEventException(0);
|
||||
row.setStatus("active");
|
||||
row.setCreateBy(op);
|
||||
row.setCreateTime(now);
|
||||
row.setUpdateBy(op);
|
||||
row.setUpdateTime(now);
|
||||
save(row);
|
||||
created++;
|
||||
} else {
|
||||
// 只刷新展示字段;岗位/线路/提醒开关保留运营手工配置
|
||||
existing.setName(StringUtils.defaultIfBlank(m.getName(), existing.getName()));
|
||||
if (StringUtils.isNotBlank(m.getMobile())) {
|
||||
existing.setMobile(m.getMobile());
|
||||
}
|
||||
if (StringUtils.isNotBlank(m.getDepartmentName())) {
|
||||
existing.setDepartName(m.getDepartmentName());
|
||||
}
|
||||
existing.setUpdateBy(op);
|
||||
existing.setUpdateTime(now);
|
||||
updateById(existing);
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
|
||||
StaffWecomSyncResult result = new StaffWecomSyncResult();
|
||||
result.setFetched(members.size());
|
||||
result.setCreated(created);
|
||||
result.setUpdated(updated);
|
||||
result.setSkipped(skipped);
|
||||
result.setMessage(String.format(
|
||||
"同步完成:企微成员 %d,新建 %d,更新 %d,跳过 %d",
|
||||
members.size(), created, updated, skipped));
|
||||
log.info("企微通讯录同步 operator={} {}", op, result.getMessage());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+59
-1
@@ -12,14 +12,17 @@ import org.jeecg.modules.inquiry.dto.TicketTransitionRequest;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryAttachment;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryExceptionCase;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryQuote;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryTicket;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryTicketStatusLog;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryAttachmentMapper;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryExceptionCaseMapper;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryQuoteMapper;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryStaffRoleMapper;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryTicketMapper;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryTicketStatusLogMapper;
|
||||
import org.jeecg.modules.inquiry.service.IInquiryWorkOrderService;
|
||||
import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport;
|
||||
import org.jeecg.modules.inquiry.tms.InquiryTmsQueryService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -29,16 +32,21 @@ import com.alibaba.fastjson.JSONObject;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 询价工单主账实现。
|
||||
* <p>
|
||||
* 副作用:改 inq_ticket.status、写 inq_ticket_status_log、改 inq_exception_case。
|
||||
* 列表/详情会就地补展示字段(salesName、demandTitle/Detail),只影响响应,不写回库。
|
||||
* 线程:单请求事务;只锁当前工单行(DB 行级),不串行其它工单。
|
||||
* 为何:Graph/前端成功不能冒充主账六态成功,所有 transition 必须过本服务。
|
||||
*/
|
||||
@@ -56,6 +64,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
private InquiryExceptionCaseMapper exceptionCaseMapper;
|
||||
@Autowired
|
||||
private InquiryTmsQueryService tmsQueryService;
|
||||
@Autowired
|
||||
private InquiryStaffRoleMapper staffRoleMapper;
|
||||
|
||||
@Override
|
||||
public IPage<InquiryTicket> pageView(Page<InquiryTicket> page, Map<String, String> filters) {
|
||||
@@ -64,6 +74,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
if (StringUtils.isNotBlank(keyword)) {
|
||||
qw.and(w -> w.like(InquiryTicket::getWorkOrderNo, keyword)
|
||||
.or().like(InquiryTicket::getSalesName, keyword)
|
||||
.or().like(InquiryTicket::getSalesWecomId, keyword)
|
||||
.or().like(InquiryTicket::getDemandTitle, keyword)
|
||||
.or().like(InquiryTicket::getOrigin, keyword)
|
||||
.or().like(InquiryTicket::getDestination, keyword));
|
||||
@@ -100,7 +111,9 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
qw.le(InquiryTicket::getCreateTime, dateTo + " 23:59:59");
|
||||
}
|
||||
qw.orderByDesc(InquiryTicket::getCreateTime);
|
||||
return page(page, qw);
|
||||
IPage<InquiryTicket> result = page(page, qw);
|
||||
enrichDisplayFields(result.getRecords());
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -109,6 +122,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
if (ticket == null) {
|
||||
throw new IllegalArgumentException("工单不存在: " + workOrderNo);
|
||||
}
|
||||
enrichDisplayFields(Collections.singletonList(ticket));
|
||||
List<InquiryQuote> quotes = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
|
||||
.eq(InquiryQuote::getWorkOrderNo, workOrderNo)
|
||||
.orderByAsc(InquiryQuote::getCreateTime));
|
||||
@@ -131,6 +145,50 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表/详情响应补齐:发起人姓名(员工表)+ 询价需求摘要(结构化字段)。
|
||||
* 副作用:只改内存中的 ticket 展示字段,不 updateById。
|
||||
*/
|
||||
private void enrichDisplayFields(List<InquiryTicket> tickets) {
|
||||
if (tickets == null || tickets.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<String, String> staffNameByWecom = loadStaffNames(tickets);
|
||||
for (InquiryTicket ticket : tickets) {
|
||||
if (ticket == null) {
|
||||
continue;
|
||||
}
|
||||
String staffName = staffNameByWecom.get(StringUtils.trimToEmpty(ticket.getSalesWecomId()));
|
||||
ticket.setSalesName(InquiryTicketDisplaySupport.resolveSalesDisplayName(
|
||||
ticket.getSalesName(), ticket.getSalesWecomId(), staffName));
|
||||
InquiryTicketDisplaySupport.applyRequirementSummary(ticket);
|
||||
}
|
||||
}
|
||||
|
||||
/** 批量按企微 userid 取员工姓名,避免列表 N+1。 */
|
||||
private Map<String, String> loadStaffNames(List<InquiryTicket> tickets) {
|
||||
Set<String> wecomIds = new HashSet<>();
|
||||
for (InquiryTicket ticket : tickets) {
|
||||
if (ticket != null && StringUtils.isNotBlank(ticket.getSalesWecomId())) {
|
||||
wecomIds.add(ticket.getSalesWecomId().trim());
|
||||
}
|
||||
}
|
||||
if (wecomIds.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
List<InquiryStaffRole> staffRows = staffRoleMapper.selectList(new LambdaQueryWrapper<InquiryStaffRole>()
|
||||
.in(InquiryStaffRole::getWecomId, new ArrayList<>(wecomIds))
|
||||
.eq(InquiryStaffRole::getStatus, "active"));
|
||||
Map<String, String> result = new HashMap<>();
|
||||
for (InquiryStaffRole row : staffRows) {
|
||||
if (row == null || StringUtils.isBlank(row.getWecomId()) || StringUtils.isBlank(row.getName())) {
|
||||
continue;
|
||||
}
|
||||
result.put(row.getWecomId().trim(), row.getName().trim());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public InquiryTicket transition(TicketTransitionRequest req, String operator) {
|
||||
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
package org.jeecg.modules.inquiry.support;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryTicket;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* 工单列表/详情的展示口径,对齐现网 InquiryAggregateService.requirementSummaryLines。
|
||||
* <p>
|
||||
* 职责:把结构化字段拼成「发起人可读名 + 询价需求多行摘要」,禁止把 FP:AIR|… 原文甩到后台。
|
||||
* 副作用:无写库;仅生成展示用字符串。
|
||||
* 为何:inq_ticket.sales_name / demand_* 可能为空或被写入管道原文,后台必须以员工表与字段快照为准。
|
||||
*/
|
||||
public final class InquiryTicketDisplaySupport {
|
||||
|
||||
private InquiryTicketDisplaySupport() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 询价需求多行摘要:首行标题(业务线 · 路线),后续为货量/品名。
|
||||
* 与旧后台 requirementSummaryLines 同口径,便于对照验收。
|
||||
*/
|
||||
public static List<String> requirementSummaryLines(InquiryTicket ticket) {
|
||||
List<String> lines = new ArrayList<>();
|
||||
if (ticket == null) {
|
||||
return lines;
|
||||
}
|
||||
String businessLine = upper(ticket.getBusinessLine());
|
||||
|
||||
List<String> heading = new ArrayList<>();
|
||||
String lineLabel = firstText(ticket.getBizTypeLabel(), businessLineLabel(ticket.getBusinessLine()));
|
||||
if (StringUtils.isNotBlank(lineLabel)) {
|
||||
heading.add(lineLabel);
|
||||
}
|
||||
String route = route(ticket.getOrigin(), ticket.getDestination());
|
||||
if (StringUtils.isNotBlank(route)) {
|
||||
heading.add(route);
|
||||
}
|
||||
addLine(lines, join(heading, " · "));
|
||||
|
||||
List<String> quantities = new ArrayList<>();
|
||||
if ("AIR".equals(businessLine) || containsChinese(lineLabel, "空运")) {
|
||||
addLabel(quantities, "件数", ticket.getPieces());
|
||||
addUnit(quantities, "毛重", ticket.getWeightKg(), "kg");
|
||||
addUnit(quantities, "体积", ticket.getVolumeCbm(), "CBM");
|
||||
} else if ("SEA".equals(businessLine) || containsChinese(lineLabel, "海运")) {
|
||||
addLabel(quantities, "件数", ticket.getPieces());
|
||||
addUnit(quantities, "重量", ticket.getWeightKg(), "kg");
|
||||
addUnit(quantities, "体积", ticket.getVolumeCbm(), "CBM");
|
||||
} else if ("LAND".equals(businessLine) || containsChinese(lineLabel, "陆运")) {
|
||||
addUnit(quantities, "体积", ticket.getVolumeCbm(), "CBM");
|
||||
addUnit(quantities, "重量", ticket.getWeightKg(), "kg");
|
||||
addLabel(quantities, "件数", ticket.getPieces());
|
||||
} else {
|
||||
addLabel(quantities, "件数", ticket.getPieces());
|
||||
addUnit(quantities, "重量", ticket.getWeightKg(), "kg");
|
||||
addUnit(quantities, "体积", ticket.getVolumeCbm(), "CBM");
|
||||
}
|
||||
addLine(lines, join(quantities, " · "));
|
||||
|
||||
List<String> cargo = new ArrayList<>();
|
||||
boolean land = "LAND".equals(businessLine) || containsChinese(lineLabel, "陆运");
|
||||
addLabel(cargo, land ? "货物品名" : "品名", ticket.getGoodsName());
|
||||
if ("AIR".equals(businessLine) || containsChinese(lineLabel, "空运")) {
|
||||
addLabel(cargo, "客户", ticket.getCustomerName());
|
||||
}
|
||||
addLine(lines, join(cargo, " · "));
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把摘要写回 ticket 的 demandTitle/demandDetail,仅用于 API 响应,不落库。
|
||||
*/
|
||||
public static void applyRequirementSummary(InquiryTicket ticket) {
|
||||
if (ticket == null) {
|
||||
return;
|
||||
}
|
||||
List<String> lines = requirementSummaryLines(ticket);
|
||||
if (lines.isEmpty()) {
|
||||
// 结构化字段全空时,仍禁止把 FP: 管道原文当需求展示
|
||||
if (looksLikeRawFp(ticket.getDemandTitle())) {
|
||||
ticket.setDemandTitle(null);
|
||||
}
|
||||
if (looksLikeRawFp(ticket.getDemandDetail())) {
|
||||
ticket.setDemandDetail(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
ticket.setDemandTitle(lines.get(0));
|
||||
if (lines.size() == 1) {
|
||||
ticket.setDemandDetail("");
|
||||
} else {
|
||||
ticket.setDemandDetail(join(lines.subList(1, lines.size()), "\n"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起人展示名:优先已有中文名,其次员工表姓名;企微 userid 不能冒充人名。
|
||||
*/
|
||||
public static String resolveSalesDisplayName(String salesName, String salesWecomId, String staffName) {
|
||||
if (StringUtils.isNotBlank(salesName) && !looksLikeWecomUserId(salesName)) {
|
||||
return salesName.trim();
|
||||
}
|
||||
if (StringUtils.isNotBlank(staffName)) {
|
||||
return staffName.trim();
|
||||
}
|
||||
if (StringUtils.isNotBlank(salesName)) {
|
||||
return salesName.trim();
|
||||
}
|
||||
return StringUtils.trimToEmpty(salesWecomId);
|
||||
}
|
||||
|
||||
public static boolean looksLikeRawFp(String value) {
|
||||
String raw = StringUtils.trimToEmpty(value);
|
||||
return raw.startsWith("FP:") || raw.contains("|") && raw.contains(":");
|
||||
}
|
||||
|
||||
/** 纯拼音/企微 userid(无中文)不当作「发起人名称」。 */
|
||||
public static boolean looksLikeWecomUserId(String value) {
|
||||
String raw = StringUtils.trimToEmpty(value);
|
||||
if (raw.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
if (raw.chars().anyMatch(ch -> Character.UnicodeScript.of(ch) == Character.UnicodeScript.HAN)) {
|
||||
return false;
|
||||
}
|
||||
return raw.matches("^[A-Za-z][A-Za-z0-9._-]{1,64}$");
|
||||
}
|
||||
|
||||
private static String businessLineLabel(String value) {
|
||||
if ("AIR".equalsIgnoreCase(value)) {
|
||||
return "空运";
|
||||
}
|
||||
if ("SEA".equalsIgnoreCase(value)) {
|
||||
return "海运";
|
||||
}
|
||||
if ("LAND".equalsIgnoreCase(value)) {
|
||||
return "陆运";
|
||||
}
|
||||
if ("MULTI".equalsIgnoreCase(value)) {
|
||||
return "多段联运";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String route(String origin, String destination) {
|
||||
if (StringUtils.isBlank(origin) && StringUtils.isBlank(destination)) {
|
||||
return null;
|
||||
}
|
||||
return (StringUtils.isNotBlank(origin) ? origin.trim() : "-")
|
||||
+ " → "
|
||||
+ (StringUtils.isNotBlank(destination) ? destination.trim() : "-");
|
||||
}
|
||||
|
||||
private static void addLine(List<String> lines, String value) {
|
||||
if (StringUtils.isNotBlank(value)) {
|
||||
lines.add(value);
|
||||
}
|
||||
}
|
||||
|
||||
private static void addLabel(List<String> values, String label, Object value) {
|
||||
if (value == null || !StringUtils.isNotBlank(String.valueOf(value))) {
|
||||
return;
|
||||
}
|
||||
values.add(label + " " + plain(value));
|
||||
}
|
||||
|
||||
private static void addUnit(List<String> values, String label, Object value, String unit) {
|
||||
if (value == null || !StringUtils.isNotBlank(String.valueOf(value))) {
|
||||
return;
|
||||
}
|
||||
values.add(label + " " + plain(value) + unit);
|
||||
}
|
||||
|
||||
private static String plain(Object value) {
|
||||
if (value instanceof BigDecimal) {
|
||||
return ((BigDecimal) value).stripTrailingZeros().toPlainString();
|
||||
}
|
||||
return String.valueOf(value).trim();
|
||||
}
|
||||
|
||||
private static String join(List<String> values, String sep) {
|
||||
if (values == null || values.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String v : values) {
|
||||
if (!StringUtils.isNotBlank(v)) {
|
||||
continue;
|
||||
}
|
||||
if (sb.length() > 0) {
|
||||
sb.append(sep);
|
||||
}
|
||||
sb.append(v.trim());
|
||||
}
|
||||
return sb.length() == 0 ? null : sb.toString();
|
||||
}
|
||||
|
||||
private static String firstText(String... values) {
|
||||
if (values == null) {
|
||||
return null;
|
||||
}
|
||||
for (String v : values) {
|
||||
if (StringUtils.isNotBlank(v)) {
|
||||
return v.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String upper(String value) {
|
||||
return value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static boolean containsChinese(String value, String token) {
|
||||
return StringUtils.isNotBlank(value) && value.contains(token);
|
||||
}
|
||||
}
|
||||
+154
-8
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+288
@@ -0,0 +1,288 @@
|
||||
package org.jeecg.modules.inquiry.wecom;
|
||||
|
||||
import com.alibaba.fastjson.JSONArray;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.http.converter.StringHttpMessageConverter;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
import javax.annotation.PostConstruct;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Proxy;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 企微通讯录只读客户端(主账侧)。
|
||||
* <p>
|
||||
* 职责:gettoken + department/list + user/list,给员工页同步姓名/手机/部门。
|
||||
* 密钥:环境变量 WECOM_CORP_ID / WECOM_SECRET(与询价应用 1000010 同一套,见 prompt/03)。
|
||||
* 本机开发可设 WECOM_SOCKS_HOST/PORT,经测试机 SOCKS 出口,避免本机公网 IP 不在企微白名单(errcode=60020)。
|
||||
* 禁止:浏览器直连;禁止在同步失败时挡询价主路径;禁止把 Secret 写入日志。
|
||||
* 线程:HTTP 请求线程调用;token 缓存带锁;connect/read 超时,禁止无限阻塞。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class WeComAddressBookClient {
|
||||
|
||||
private static final String DEFAULT_API_BASE = "https://qyapi.weixin.qq.com";
|
||||
/** 根部门 id;fetch_child=1 拉全树成员。 */
|
||||
private static final int ROOT_DEPARTMENT_ID = 1;
|
||||
|
||||
@Value("${WECOM_CORP_ID:}")
|
||||
private String corpId;
|
||||
|
||||
@Value("${WECOM_SECRET:}")
|
||||
private String secret;
|
||||
|
||||
@Value("${WECOM_API_BASE:" + DEFAULT_API_BASE + "}")
|
||||
private String apiBase;
|
||||
|
||||
/** 可选:本机开发经 SOCKS 出站(如 127.0.0.1,配合 ssh -D)。 */
|
||||
@Value("${WECOM_SOCKS_HOST:}")
|
||||
private String socksHost;
|
||||
|
||||
@Value("${WECOM_SOCKS_PORT:1080}")
|
||||
private int socksPort;
|
||||
|
||||
private RestTemplate restTemplate;
|
||||
private final Object tokenLock = new Object();
|
||||
private String cachedToken;
|
||||
private long tokenExpireAtMs;
|
||||
|
||||
@PostConstruct
|
||||
public void initHttpClient() {
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(5_000);
|
||||
factory.setReadTimeout(15_000);
|
||||
if (StringUtils.isNotBlank(socksHost)) {
|
||||
factory.setProxy(new Proxy(Proxy.Type.SOCKS, new InetSocketAddress(socksHost.trim(), socksPort)));
|
||||
log.info("企微 HTTP 经 SOCKS {}:{} 出站(绕过本机 IP 白名单限制)", socksHost, socksPort);
|
||||
}
|
||||
RestTemplate rt = new RestTemplate(factory);
|
||||
rt.getMessageConverters().set(1, new StringHttpMessageConverter(StandardCharsets.UTF_8));
|
||||
this.restTemplate = rt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置是否齐全(不访问外网)。
|
||||
*/
|
||||
public boolean isConfigured() {
|
||||
return StringUtils.isNotBlank(corpId) && StringUtils.isNotBlank(secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取应用可见范围内的已激活成员(去重 userid)。
|
||||
* <p>
|
||||
* 副作用:出站 HTTP 调企微;失败抛 IllegalStateException,由上层转 Result。
|
||||
*/
|
||||
public List<WeComMember> listActiveMembers() {
|
||||
if (!isConfigured()) {
|
||||
throw new IllegalStateException("未配置 WECOM_CORP_ID / WECOM_SECRET,无法同步通讯录");
|
||||
}
|
||||
String token = getAccessToken();
|
||||
Map<Integer, String> deptNames = loadDepartmentNames(token);
|
||||
String url = UriComponentsBuilder
|
||||
.fromHttpUrl(trimBase(apiBase) + "/cgi-bin/user/list")
|
||||
.queryParam("access_token", token)
|
||||
.queryParam("department_id", ROOT_DEPARTMENT_ID)
|
||||
.queryParam("fetch_child", 1)
|
||||
.toUriString();
|
||||
JSONObject body = getJson(url);
|
||||
assertOk(body, "user/list");
|
||||
JSONArray userlist = body.getJSONArray("userlist");
|
||||
Map<String, WeComMember> byUserid = new LinkedHashMap<>();
|
||||
if (userlist == null) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
for (int i = 0; i < userlist.size(); i++) {
|
||||
JSONObject u = userlist.getJSONObject(i);
|
||||
if (u == null) {
|
||||
continue;
|
||||
}
|
||||
String userid = StringUtils.trimToEmpty(u.getString("userid"));
|
||||
if (userid.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
int status = u.getIntValue("status");
|
||||
// 1=已激活;其它状态不同步进业务表,避免半离职账号入主账
|
||||
if (status != 1) {
|
||||
continue;
|
||||
}
|
||||
String name = StringUtils.defaultIfBlank(u.getString("name"), userid);
|
||||
String mobile = StringUtils.trimToEmpty(u.getString("mobile"));
|
||||
String deptName = resolveDepartmentName(u.getJSONArray("department"), deptNames);
|
||||
byUserid.put(userid, new WeComMember(userid, name, mobile, deptName, status));
|
||||
}
|
||||
return new ArrayList<>(byUserid.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取企微 {@code department/list} 全量扁平列表(短名 + path)。
|
||||
* <p>
|
||||
* 员工下拉与旧项目同源:整份通讯录,不截层级。顺序保持企微返回序。
|
||||
* 副作用:出站 HTTP;失败抛 IllegalStateException。
|
||||
*/
|
||||
public List<WeComDepartment> listDepartments() {
|
||||
if (!isConfigured()) {
|
||||
throw new IllegalStateException("未配置 WECOM_CORP_ID / WECOM_SECRET,无法拉取部门");
|
||||
}
|
||||
String token = getAccessToken();
|
||||
String url = UriComponentsBuilder
|
||||
.fromHttpUrl(trimBase(apiBase) + "/cgi-bin/department/list")
|
||||
.queryParam("access_token", token)
|
||||
.toUriString();
|
||||
JSONObject body = getJson(url);
|
||||
assertOk(body, "department/list");
|
||||
JSONArray list = body.getJSONArray("department");
|
||||
if (list == null || list.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
Map<Integer, JSONObject> byId = new LinkedHashMap<>();
|
||||
for (int i = 0; i < list.size(); i++) {
|
||||
JSONObject d = list.getJSONObject(i);
|
||||
if (d != null) {
|
||||
byId.put(d.getIntValue("id"), d);
|
||||
}
|
||||
}
|
||||
|
||||
List<WeComDepartment> result = new ArrayList<>();
|
||||
for (JSONObject d : byId.values()) {
|
||||
int id = d.getIntValue("id");
|
||||
String name = StringUtils.defaultIfBlank(d.getString("name"), String.valueOf(id));
|
||||
int parentId = d.getIntValue("parentid");
|
||||
String path = buildDepartmentPath(id, byId);
|
||||
result.add(new WeComDepartment(id, name, parentId, path));
|
||||
}
|
||||
// 保持企微 department/list 原序(LinkedHashMap 插入序 = 接口返回序),
|
||||
// 不要按 path 重排,也不要截层级;员工下拉与旧项目一样用这份全量短名。
|
||||
return result;
|
||||
}
|
||||
|
||||
private String buildDepartmentPath(int id, Map<Integer, JSONObject> byId) {
|
||||
List<String> parts = new ArrayList<>();
|
||||
int cursor = id;
|
||||
// 防环:最多 32 层
|
||||
for (int depth = 0; depth < 32; depth++) {
|
||||
JSONObject d = byId.get(cursor);
|
||||
if (d == null) {
|
||||
break;
|
||||
}
|
||||
parts.add(0, StringUtils.defaultIfBlank(d.getString("name"), String.valueOf(cursor)));
|
||||
int parentId = d.getIntValue("parentid");
|
||||
if (parentId <= 0 || parentId == cursor || !byId.containsKey(parentId)) {
|
||||
break;
|
||||
}
|
||||
cursor = parentId;
|
||||
}
|
||||
return String.join(" / ", parts);
|
||||
}
|
||||
|
||||
private Map<Integer, String> loadDepartmentNames(String token) {
|
||||
String url = UriComponentsBuilder
|
||||
.fromHttpUrl(trimBase(apiBase) + "/cgi-bin/department/list")
|
||||
.queryParam("access_token", token)
|
||||
.toUriString();
|
||||
JSONObject body = getJson(url);
|
||||
assertOk(body, "department/list");
|
||||
Map<Integer, String> map = new HashMap<>();
|
||||
JSONArray list = body.getJSONArray("department");
|
||||
if (list == null) {
|
||||
return map;
|
||||
}
|
||||
for (int i = 0; i < list.size(); i++) {
|
||||
JSONObject d = list.getJSONObject(i);
|
||||
if (d == null) {
|
||||
continue;
|
||||
}
|
||||
map.put(d.getIntValue("id"), StringUtils.defaultString(d.getString("name")));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private String resolveDepartmentName(JSONArray departmentIds, Map<Integer, String> deptNames) {
|
||||
if (departmentIds == null || departmentIds.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
List<String> names = new ArrayList<>();
|
||||
for (int i = 0; i < departmentIds.size(); i++) {
|
||||
int id = departmentIds.getIntValue(i);
|
||||
String n = deptNames.get(id);
|
||||
if (StringUtils.isNotBlank(n)) {
|
||||
names.add(n);
|
||||
}
|
||||
}
|
||||
return String.join("/", names);
|
||||
}
|
||||
|
||||
private String getAccessToken() {
|
||||
synchronized (tokenLock) {
|
||||
long now = System.currentTimeMillis();
|
||||
if (StringUtils.isNotBlank(cachedToken) && now < tokenExpireAtMs) {
|
||||
return cachedToken;
|
||||
}
|
||||
String url = UriComponentsBuilder
|
||||
.fromHttpUrl(trimBase(apiBase) + "/cgi-bin/gettoken")
|
||||
.queryParam("corpid", corpId)
|
||||
.queryParam("corpsecret", secret)
|
||||
.toUriString();
|
||||
JSONObject body = getJson(url);
|
||||
assertOk(body, "gettoken");
|
||||
String token = body.getString("access_token");
|
||||
if (StringUtils.isBlank(token)) {
|
||||
throw new IllegalStateException("企微 gettoken 未返回 access_token");
|
||||
}
|
||||
int expiresIn = body.getIntValue("expires_in");
|
||||
if (expiresIn <= 0) {
|
||||
expiresIn = 7200;
|
||||
}
|
||||
// 提前 5 分钟失效,避免边界过期
|
||||
cachedToken = token;
|
||||
tokenExpireAtMs = now + Math.max(60, expiresIn - 300) * 1000L;
|
||||
log.info("企微 access_token 已刷新,expiresIn={}s", expiresIn);
|
||||
return cachedToken;
|
||||
}
|
||||
}
|
||||
|
||||
private JSONObject getJson(String url) {
|
||||
try {
|
||||
String raw = restTemplate.getForObject(url, String.class);
|
||||
if (StringUtils.isBlank(raw)) {
|
||||
throw new IllegalStateException("企微接口返回空响应");
|
||||
}
|
||||
return JSONObject.parseObject(raw);
|
||||
} catch (IllegalStateException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("调用企微失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void assertOk(JSONObject body, String api) {
|
||||
if (body == null) {
|
||||
throw new IllegalStateException("企微 " + api + " 响应为空");
|
||||
}
|
||||
int errcode = body.getIntValue("errcode");
|
||||
if (errcode != 0) {
|
||||
String errmsg = body.getString("errmsg");
|
||||
throw new IllegalStateException("企微 " + api + " 失败 errcode=" + errcode + " errmsg=" + errmsg);
|
||||
}
|
||||
}
|
||||
|
||||
private static String trimBase(String base) {
|
||||
if (StringUtils.isBlank(base)) {
|
||||
return DEFAULT_API_BASE;
|
||||
}
|
||||
return base.endsWith("/") ? base.substring(0, base.length() - 1) : base;
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package org.jeecg.modules.inquiry.wecom;
|
||||
|
||||
/**
|
||||
* 企微部门投影,供员工表单下拉。
|
||||
*/
|
||||
public class WeComDepartment {
|
||||
|
||||
private final int id;
|
||||
private final String name;
|
||||
private final int parentId;
|
||||
/** 自根到本节点的展示路径,如「友通达 / 销售部」。 */
|
||||
private final String path;
|
||||
|
||||
public WeComDepartment(int id, String name, int parentId, String path) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.parentId = parentId;
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
public int getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public int getParentId() {
|
||||
return parentId;
|
||||
}
|
||||
|
||||
public String getPath() {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package org.jeecg.modules.inquiry.wecom;
|
||||
|
||||
/**
|
||||
* 企微通讯录成员投影(仅姓名展示所需字段)。
|
||||
* <p>
|
||||
* 来源:qyapi user/list;不做岗位推断,岗位仍由运营在后台维护。
|
||||
*/
|
||||
public class WeComMember {
|
||||
|
||||
private final String userid;
|
||||
private final String name;
|
||||
private final String mobile;
|
||||
private final String departmentName;
|
||||
private final int status;
|
||||
|
||||
public WeComMember(String userid, String name, String mobile, String departmentName, int status) {
|
||||
this.userid = userid;
|
||||
this.name = name;
|
||||
this.mobile = mobile;
|
||||
this.departmentName = departmentName;
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public String getUserid() {
|
||||
return userid;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public String getMobile() {
|
||||
return mobile;
|
||||
}
|
||||
|
||||
public String getDepartmentName() {
|
||||
return departmentName;
|
||||
}
|
||||
|
||||
public int getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
/** 企微 status=1 表示已激活,可入员工表。 */
|
||||
public boolean isActive() {
|
||||
return status == 1;
|
||||
}
|
||||
}
|
||||
+33
@@ -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));
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package org.jeecg.modules.inquiry.support;
|
||||
|
||||
import org.jeecg.modules.inquiry.entity.InquiryTicket;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 工单列表展示口径:发起人名称 + 询价需求摘要,对齐旧后台。
|
||||
*/
|
||||
public class InquiryTicketDisplaySupportTest {
|
||||
|
||||
@Test
|
||||
public void airRequirementSummaryMatchesLegacyShape() {
|
||||
InquiryTicket ticket = new InquiryTicket();
|
||||
ticket.setBusinessLine("AIR");
|
||||
ticket.setBizTypeLabel("空运");
|
||||
ticket.setOrigin("ZUH");
|
||||
ticket.setDestination("CRK");
|
||||
ticket.setPieces(10);
|
||||
ticket.setWeightKg(new BigDecimal("1"));
|
||||
ticket.setVolumeCbm(new BigDecimal("8"));
|
||||
ticket.setGoodsName("茶叶");
|
||||
ticket.setDemandDetail("FP:AIR|ZUH|CRK|茶叶|10件|1KGS|8CBM|2026-09-11");
|
||||
|
||||
List<String> lines = InquiryTicketDisplaySupport.requirementSummaryLines(ticket);
|
||||
assertEquals("空运 · ZUH → CRK", lines.get(0));
|
||||
assertEquals("件数 10 · 毛重 1kg · 体积 8CBM", lines.get(1));
|
||||
assertEquals("品名 茶叶", lines.get(2));
|
||||
|
||||
InquiryTicketDisplaySupport.applyRequirementSummary(ticket);
|
||||
assertEquals("空运 · ZUH → CRK", ticket.getDemandTitle());
|
||||
assertEquals("件数 10 · 毛重 1kg · 体积 8CBM\n品名 茶叶", ticket.getDemandDetail());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void landUsesGoodsNameLabel() {
|
||||
InquiryTicket ticket = new InquiryTicket();
|
||||
ticket.setBusinessLine("LAND");
|
||||
ticket.setBizTypeLabel("陆运");
|
||||
ticket.setOrigin("上海");
|
||||
ticket.setDestination("北宁Bac Ninh");
|
||||
ticket.setGoodsName("设备");
|
||||
|
||||
List<String> lines = InquiryTicketDisplaySupport.requirementSummaryLines(ticket);
|
||||
assertEquals("陆运 · 上海 → 北宁Bac Ninh", lines.get(0));
|
||||
assertEquals("货物品名 设备", lines.get(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void salesNamePrefersStaffChineseName() {
|
||||
assertEquals("吴吉林",
|
||||
InquiryTicketDisplaySupport.resolveSalesDisplayName(null, "WuJiLin", "吴吉林"));
|
||||
assertEquals("陈柏成",
|
||||
InquiryTicketDisplaySupport.resolveSalesDisplayName("WuJiLin", "WuJiLin", "陈柏成"));
|
||||
assertFalse(InquiryTicketDisplaySupport.looksLikeWecomUserId("陈柏成"));
|
||||
assertTrue(InquiryTicketDisplaySupport.looksLikeWecomUserId("WuJiLin"));
|
||||
}
|
||||
}
|
||||
+91
@@ -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
|
||||
|
||||
@@ -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,8 +13,12 @@ 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);
|
||||
@@ -87,6 +91,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 +110,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,7 +211,30 @@ 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));
|
||||
@@ -180,17 +286,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`,
|
||||
@@ -434,11 +538,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 +575,7 @@ onUnmounted(() => document.removeEventListener("click", onDocClick));
|
||||
</div>
|
||||
</div>
|
||||
<div class="dash-cost-footnote">
|
||||
{{ costRoutes.length ? "成本数据来源于 TMS" : "成本趋势主账暂未开放,待接口就绪后展示" }}
|
||||
{{ costFootnote }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 员工管理:对接主账 /inquiry/staffRole。
|
||||
* 企微 userid 由运营手工绑定,不向企微换号;停用走 disable 接口。
|
||||
* 企微 userid 可手工维护,也可点「同步企业微信」由主账 upsert(姓名/手机/部门)。
|
||||
* 浏览器禁止直连企微;岗位/线路仍手工改。
|
||||
*/
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { message, Modal } from "ant-design-vue";
|
||||
import {
|
||||
disableStaff,
|
||||
getStaffCounts,
|
||||
getStaffDepartments,
|
||||
getStaffList,
|
||||
getWecomDepartments,
|
||||
saveStaff as saveStaffApi,
|
||||
syncWecomStaff,
|
||||
} 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";
|
||||
|
||||
/** 部门下拉:value 存短名,label 带层级缩进 */
|
||||
type DeptOption = { id: number; name: string; label: string; depth: number };
|
||||
|
||||
type StaffRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -42,6 +50,9 @@ const roleLabelMap: Record<StaffRole, string> = {
|
||||
|
||||
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({
|
||||
@@ -53,6 +64,12 @@ const filters = reactive({
|
||||
|
||||
const modalOpen = ref(false);
|
||||
const editing = ref<StaffRow | null>(null);
|
||||
const syncing = ref(false);
|
||||
/** 部门树扁平列表:一级不缩进,子级前加空格 */
|
||||
const deptOptions = ref<DeptOption[]>([]);
|
||||
const deptLoading = ref(false);
|
||||
const deptLoaded = ref(false);
|
||||
const deptOpen = ref(false);
|
||||
const form = reactive({
|
||||
name: "",
|
||||
wecomId: "",
|
||||
@@ -66,6 +83,12 @@ const form = reactive({
|
||||
|
||||
const filtered = computed(() => rows.value);
|
||||
|
||||
const filteredDeptOptions = computed(() => {
|
||||
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;
|
||||
@@ -115,11 +138,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 || "加载员工失败");
|
||||
@@ -132,13 +163,132 @@ 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。
|
||||
*/
|
||||
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;
|
||||
}
|
||||
deptLoading.value = true;
|
||||
try {
|
||||
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);
|
||||
}
|
||||
deptLoaded.value = true;
|
||||
} catch (e: any) {
|
||||
message.warning(e?.message || "部门列表加载失败,可稍后重试");
|
||||
} finally {
|
||||
deptLoading.value = false;
|
||||
ensureCurrentDeptOption();
|
||||
}
|
||||
}
|
||||
|
||||
function ensureCurrentDeptOption() {
|
||||
const cur = (form.dept || "").trim();
|
||||
if (!cur) return;
|
||||
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;
|
||||
deptOpen.value = false;
|
||||
}
|
||||
|
||||
function onDeptFocus() {
|
||||
if (deptBlurTimer) {
|
||||
clearTimeout(deptBlurTimer);
|
||||
deptBlurTimer = null;
|
||||
}
|
||||
deptOpen.value = true;
|
||||
ensureDeptOptions();
|
||||
}
|
||||
|
||||
function onDeptBlur() {
|
||||
deptBlurTimer = setTimeout(() => {
|
||||
deptOpen.value = false;
|
||||
}, 180);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => modalOpen.value,
|
||||
(open) => {
|
||||
if (!open) deptOpen.value = false;
|
||||
},
|
||||
);
|
||||
|
||||
function openCreate() {
|
||||
editing.value = null;
|
||||
Object.assign(form, {
|
||||
@@ -152,6 +302,8 @@ function openCreate() {
|
||||
status: "active",
|
||||
});
|
||||
modalOpen.value = true;
|
||||
deptLoaded.value = false;
|
||||
ensureDeptOptions();
|
||||
}
|
||||
|
||||
function openEdit(row: StaffRow) {
|
||||
@@ -167,6 +319,8 @@ function openEdit(row: StaffRow) {
|
||||
status: row.status,
|
||||
});
|
||||
modalOpen.value = true;
|
||||
deptLoaded.value = false;
|
||||
ensureDeptOptions();
|
||||
}
|
||||
|
||||
async function saveStaff() {
|
||||
@@ -213,8 +367,19 @@ function removeStaff(row: StaffRow) {
|
||||
});
|
||||
}
|
||||
|
||||
function syncWecom() {
|
||||
message.info("企微通讯录同步尚未开放,请手工维护企微 ID");
|
||||
async function syncWecom() {
|
||||
if (syncing.value) return;
|
||||
syncing.value = true;
|
||||
try {
|
||||
const res: any = await syncWecomStaff();
|
||||
const msg = res?.message || (typeof res === "string" ? res : "同步完成");
|
||||
message.success(msg);
|
||||
await loadList();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || "同步失败");
|
||||
} finally {
|
||||
syncing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function routeTags(routes: string) {
|
||||
@@ -229,11 +394,11 @@ function routeTags(routes: string) {
|
||||
<template>
|
||||
<Teleport defer to="#page-topbar-actions">
|
||||
<div class="staff-topbar-actions">
|
||||
<button type="button" class="btn btn-secondary" @click="syncWecom">
|
||||
<ProtoIcon name="refresh" :size="16" />同步企业微信
|
||||
<button type="button" class="btn btn-secondary" :disabled="syncing" @click="syncWecom">
|
||||
<ProtoIcon name="refresh" :size="16" />{{ syncing ? "同步中…" : "同步企业微信" }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" @click="openCreate">
|
||||
<ProtoIcon name="userPlus" :size="16" />新建员工
|
||||
<ProtoIcon name="userPlus" :size="16" />新增员工
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
@@ -353,39 +518,66 @@ 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
|
||||
v-model:open="modalOpen"
|
||||
:title="editing ? '编辑员工' : '新建员工'"
|
||||
:title="editing ? '编辑员工' : '新增员工'"
|
||||
size="md"
|
||||
ok-text="保存"
|
||||
ok-text="确认"
|
||||
@ok="saveStaff"
|
||||
>
|
||||
<div class="form-row">
|
||||
<label class="lbl">姓名 <span class="req">*</span></label>
|
||||
<input v-model="form.name" class="field" type="text" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="lbl">企微 ID <span class="req">*</span></label>
|
||||
<input v-model="form.wecomId" class="field field-mono" type="text" placeholder="如 zhangmin@yt" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="lbl">手机号</label>
|
||||
<input v-model="form.phone" class="field" type="tel" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="lbl">部门</label>
|
||||
<input v-model="form.dept" class="field" type="text" />
|
||||
<label class="lbl">企业微信 ID <span class="req">*</span></label>
|
||||
<input v-model="form.wecomId" class="field field-mono" type="text" placeholder="如 zhangmin@yt" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="lbl">岗位</label>
|
||||
<label class="lbl">所属部门</label>
|
||||
<div class="dept-combo">
|
||||
<input
|
||||
v-model="form.dept"
|
||||
class="field"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:disabled="deptLoading"
|
||||
:placeholder="deptLoading ? '加载部门…' : '请选择或输入部门'"
|
||||
@focus="onDeptFocus"
|
||||
@input="deptOpen = true"
|
||||
@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>
|
||||
<select v-model="form.role" class="field">
|
||||
<option value="sales">销售</option>
|
||||
<option value="air">航线·空运</option>
|
||||
@@ -449,4 +641,61 @@ 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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* 员工岗位 API:对接主账 /inquiry/staffRole。
|
||||
* 与 Jeecg /sys/user 分离——本页管企微岗位绑定。
|
||||
* 与 Jeecg /sys/user 分离——本页管企微岗位绑定;同步走主账 /syncWecom,浏览器不直连企微。
|
||||
* 部门下拉对齐旧项目:GET /inquiry/staffRole/departments → string[]。
|
||||
*/
|
||||
import { defHttp } from '/@/utils/http/axios';
|
||||
|
||||
@@ -11,6 +12,10 @@ enum Api {
|
||||
edit = '/inquiry/staffRole/edit',
|
||||
disable = '/inquiry/staffRole/disable',
|
||||
queryById = '/inquiry/staffRole/queryById',
|
||||
syncWecom = '/inquiry/staffRole/syncWecom',
|
||||
/** 旧项目同名接口:部门短名全量 */
|
||||
departments = '/inquiry/staffRole/departments',
|
||||
wecomDepartments = '/inquiry/staffRole/wecomDepartments',
|
||||
}
|
||||
|
||||
export const getStaffList = (params) => defHttp.get({ url: Api.list, params });
|
||||
@@ -19,3 +24,12 @@ export const saveStaff = (data, isUpdate: boolean) =>
|
||||
isUpdate ? defHttp.put({ url: Api.edit, data }) : defHttp.post({ url: Api.add, data });
|
||||
export const disableStaff = (id: string) => defHttp.post({ url: Api.disable, params: { id } });
|
||||
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 });
|
||||
|
||||
@@ -284,41 +284,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 +791,7 @@ body {
|
||||
background: #ffffff !important;
|
||||
font-size: 11px !important;
|
||||
color: #475467 !important;
|
||||
flex-wrap: wrap !important;
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
@@ -778,6 +800,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 +819,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,11 +70,57 @@ 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 : "-";
|
||||
}
|
||||
|
||||
/**
|
||||
* 主账 inq_ticket_status_log.action 是内部码,禁止当备注展示。
|
||||
* transition 与「已报价」等 toStatus 重复,无用户备注时不再跟一段英文。
|
||||
*/
|
||||
const STATUS_LOG_ACTION_LABEL: Record<string, string> = {
|
||||
transition: "",
|
||||
retry: "系统重试",
|
||||
abnormal_handle: "异常已处理",
|
||||
};
|
||||
|
||||
function isInternalActionCode(v: string) {
|
||||
return Object.prototype.hasOwnProperty.call(STATUS_LOG_ACTION_LABEL, v);
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间线第二段:优先用户备注;否则把内部 action 译成中文;纯内部码且无文案则不展示。
|
||||
*/
|
||||
function timelineNote(x: any) {
|
||||
const remark = String(x?.remark ?? "").trim();
|
||||
if (remark && !isInternalActionCode(remark)) {
|
||||
return remark;
|
||||
}
|
||||
const action = String(x?.action ?? "").trim();
|
||||
const key = isInternalActionCode(remark) ? remark : action;
|
||||
if (isInternalActionCode(key)) {
|
||||
const label = STATUS_LOG_ACTION_LABEL[key];
|
||||
return label ? label : "-";
|
||||
}
|
||||
return dash(action);
|
||||
}
|
||||
|
||||
function fmtTime(v: any) {
|
||||
const s = (v ?? "").toString().trim().replace("T", " ");
|
||||
return s ? s.slice(0, 19) : "-";
|
||||
@@ -83,6 +132,90 @@ function fmtMeasure(v: any, unit: string) {
|
||||
return s ? `${s} ${unit}` : "-";
|
||||
}
|
||||
|
||||
/** 业务线英文码 → 中文,对齐旧主账 businessLineLabel。 */
|
||||
function businessLineLabel(v: any) {
|
||||
const raw = String(v ?? "").trim();
|
||||
const upper = raw.toUpperCase();
|
||||
if (upper === "AIR") return "空运";
|
||||
if (upper === "SEA") return "海运";
|
||||
if (upper === "LAND") return "陆运";
|
||||
if (upper === "MULTI") return "多段联运";
|
||||
return raw;
|
||||
}
|
||||
|
||||
/** FP:AIR|… 管道原文禁止当询价需求展示。 */
|
||||
function looksLikeRawFp(v: any) {
|
||||
const raw = String(v ?? "").trim();
|
||||
return raw.startsWith("FP:") || (raw.includes("|") && raw.includes(":"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 询价需求摘要:对齐旧后台 requirementSummaryLines。
|
||||
* 首行 = 业务线 · 路线;后续 = 货量 / 品名。结构化字段优先,不信 demandDetail 管道串。
|
||||
*/
|
||||
function requirementSummaryLines(t: any): string[] {
|
||||
const businessLine = String(t?.businessLine ?? "").trim().toUpperCase();
|
||||
const lineLabel = String(t?.bizTypeLabel ?? "").trim() || businessLineLabel(t?.businessLine);
|
||||
const origin = String(t?.origin ?? "").trim();
|
||||
const destination = String(t?.destination ?? "").trim();
|
||||
const route =
|
||||
origin || destination ? `${origin || "-"} → ${destination || "-"}` : "";
|
||||
const heading = [lineLabel, route].filter(Boolean).join(" · ");
|
||||
const lines: string[] = [];
|
||||
if (heading) lines.push(heading);
|
||||
|
||||
const isAir = businessLine === "AIR" || lineLabel.includes("空运");
|
||||
const isSea = businessLine === "SEA" || lineLabel.includes("海运");
|
||||
const isLand = businessLine === "LAND" || lineLabel.includes("陆运");
|
||||
const quantities: string[] = [];
|
||||
const pieces = t?.pieces;
|
||||
const weight = t?.weightKg;
|
||||
const volume = t?.volumeCbm;
|
||||
const plain = (v: any) => {
|
||||
if (v == null || v === "") return "";
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? String(n) : String(v).trim();
|
||||
};
|
||||
if (isAir) {
|
||||
if (pieces != null && pieces !== "") quantities.push(`件数 ${plain(pieces)}`);
|
||||
if (weight != null && weight !== "") quantities.push(`毛重 ${plain(weight)}kg`);
|
||||
if (volume != null && volume !== "") quantities.push(`体积 ${plain(volume)}CBM`);
|
||||
} else if (isSea) {
|
||||
if (pieces != null && pieces !== "") quantities.push(`件数 ${plain(pieces)}`);
|
||||
if (weight != null && weight !== "") quantities.push(`重量 ${plain(weight)}kg`);
|
||||
if (volume != null && volume !== "") quantities.push(`体积 ${plain(volume)}CBM`);
|
||||
} else if (isLand) {
|
||||
if (volume != null && volume !== "") quantities.push(`体积 ${plain(volume)}CBM`);
|
||||
if (weight != null && weight !== "") quantities.push(`重量 ${plain(weight)}kg`);
|
||||
if (pieces != null && pieces !== "") quantities.push(`件数 ${plain(pieces)}`);
|
||||
} else {
|
||||
if (pieces != null && pieces !== "") quantities.push(`件数 ${plain(pieces)}`);
|
||||
if (weight != null && weight !== "") quantities.push(`重量 ${plain(weight)}kg`);
|
||||
if (volume != null && volume !== "") quantities.push(`体积 ${plain(volume)}CBM`);
|
||||
}
|
||||
if (quantities.length) lines.push(quantities.join(" · "));
|
||||
|
||||
const cargo: string[] = [];
|
||||
const goods = String(t?.goodsName ?? "").trim();
|
||||
if (goods) cargo.push(`${isLand ? "货物品名" : "品名"} ${goods}`);
|
||||
if (isAir) {
|
||||
const customer = String(t?.customerName ?? "").trim();
|
||||
if (customer) cargo.push(`客户 ${customer}`);
|
||||
}
|
||||
if (cargo.length) lines.push(cargo.join(" · "));
|
||||
|
||||
// 后端已 enrich 时直接用;仅当结构化拼不出时才回退已有标题(仍过滤 FP 原文)
|
||||
if (!lines.length) {
|
||||
const title = String(t?.demandTitle ?? "").trim();
|
||||
const detail = String(t?.demandDetail ?? "").trim();
|
||||
if (title && !looksLikeRawFp(title)) lines.push(title);
|
||||
if (detail && !looksLikeRawFp(detail)) {
|
||||
detail.split(/\r?\n/).map((x) => x.trim()).filter(Boolean).forEach((x) => lines.push(x));
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function mapTicket(t: any, quotes: any[] = [], attachments: any[] = [], timeline: any[] = []): TicketRow {
|
||||
const attachRows: TicketAttach[] = (attachments || [])
|
||||
.map((a) => ({
|
||||
@@ -93,15 +226,15 @@ function mapTicket(t: any, quotes: any[] = [], attachments: any[] = [], timeline
|
||||
.filter((a) => a.name);
|
||||
const origin = dash(t.origin);
|
||||
const destination = dash(t.destination);
|
||||
const route =
|
||||
origin !== "-" || destination !== "-"
|
||||
? `${origin === "-" ? "" : origin} → ${destination === "-" ? "" : destination}`.trim()
|
||||
: "";
|
||||
const reqLines = requirementSummaryLines(t);
|
||||
const salesName = String(t.salesName ?? "").trim();
|
||||
// 发起人必须显示名称;企微 userid 仅作最后兜底(主账 enrich 后应已是中文名)
|
||||
const initiator = salesName || t.salesWecomId || "—";
|
||||
return {
|
||||
id: t.workOrderNo || t.id || "",
|
||||
initiator: t.salesName || t.salesWecomId || "—",
|
||||
demandTitle: t.demandTitle || route || "—",
|
||||
demandDetail: t.demandDetail || "",
|
||||
initiator,
|
||||
demandTitle: reqLines[0] || "—",
|
||||
demandDetail: reqLines.slice(1).join("\n"),
|
||||
quotes: (quotes || []).map((q) => ({
|
||||
source: q.sourceLabel || q.source || "报价",
|
||||
price: q.priceText || "-",
|
||||
@@ -111,7 +244,7 @@ function mapTicket(t: any, quotes: any[] = [], attachments: any[] = [], timeline
|
||||
attachRows,
|
||||
timeline: (timeline || []).map((x) => ({
|
||||
status: dash(x.toStatus || x.status),
|
||||
remark: dash(x.remark || x.action),
|
||||
remark: timelineNote(x),
|
||||
time: fmtTime(x.createTime),
|
||||
})),
|
||||
status: t.status || "询价中",
|
||||
@@ -142,6 +275,14 @@ function routeText(row: TicketRow) {
|
||||
return row.demandTitle || "-";
|
||||
}
|
||||
|
||||
/** 列表询价需求第二行起:与旧后台 summary-cell 多行展示一致。 */
|
||||
function demandDetailLines(row: TicketRow) {
|
||||
return String(row?.demandDetail || "")
|
||||
.split(/\r?\n/)
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
@@ -153,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 {
|
||||
@@ -168,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) {
|
||||
@@ -291,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>
|
||||
|
||||
@@ -350,17 +513,13 @@ function refresh() {
|
||||
<span style="font-size: 12px; font-weight: 700">{{ row.initiator }}</span>
|
||||
</td>
|
||||
<td class="align-top">
|
||||
<div style="font-weight: 800">{{ row.demandTitle }}</div>
|
||||
<div
|
||||
style="
|
||||
font-size: 10px;
|
||||
color: #64748b;
|
||||
margin-top: 4px;
|
||||
white-space: pre-line;
|
||||
line-height: 1.5;
|
||||
"
|
||||
>
|
||||
{{ row.demandDetail }}
|
||||
<div class="summary-cell">
|
||||
<strong>{{ row.demandTitle }}</strong>
|
||||
<span
|
||||
v-for="(line, idx) in demandDetailLines(row)"
|
||||
:key="row.id + '-demand-' + idx"
|
||||
>{{ line }}</span
|
||||
>
|
||||
</div>
|
||||
</td>
|
||||
<td class="align-top">
|
||||
@@ -436,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>
|
||||
|
||||
@@ -491,8 +655,9 @@ function refresh() {
|
||||
<ProtoIcon name="route" :size="14" />询价需求
|
||||
</div>
|
||||
<div class="ticket-detail-demand">
|
||||
<div style="font-weight: 800; color: #0f172a">
|
||||
{{ dash(current.bizType) }}:{{ routeText(current) }}
|
||||
<div class="summary-cell">
|
||||
<strong>{{ current.demandTitle }}</strong>
|
||||
<span v-for="(line, idx) in demandDetailLines(current)" :key="'detail-demand-' + idx">{{ line }}</span>
|
||||
</div>
|
||||
<div class="ticket-detail-grid" style="margin-top: 8px">
|
||||
<div class="ticket-detail-item"><label>业务类型</label><span>{{ dash(current.bizType) }}</span></div>
|
||||
@@ -503,7 +668,6 @@ function refresh() {
|
||||
<div class="ticket-detail-item"><label>品名</label><span>{{ current.goodsName }}</span></div>
|
||||
<div class="ticket-detail-item"><label>客户</label><span>{{ current.customerName }}</span></div>
|
||||
</div>
|
||||
<div v-if="current.demandDetail" class="ticket-detail-note">{{ current.demandDetail }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -570,3 +734,27 @@ function refresh() {
|
||||
</div>
|
||||
</AdminModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 询价需求列:对齐旧后台 summary-cell(首行加粗,后续灰色小字) */
|
||||
.summary-cell {
|
||||
min-width: 230px;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.summary-cell strong,
|
||||
.summary-cell span {
|
||||
display: block;
|
||||
}
|
||||
.summary-cell strong {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
}
|
||||
.summary-cell span {
|
||||
margin-top: 3px;
|
||||
color: #708090;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user