Compare commits

..
Author SHA1 Message Date
jillion886andCursor 43e633f359 修好多段含空运的群跟单、锁舱话术、相册报价通道,以及附件上传成功后电脑端关不掉 H5。
多段激活后无需再带工单号;含空运群回复走机器人;复制补问清单时空货好时间不再吃成下一行编号。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 15:23:06 +08:00
linmianxunandCursor 419b4a1bf7 智能体按 reply_code 读后台回复语;陆运整车报价单顶栏色与填表更稳,工单页展示同步收紧。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 11:41:09 +08:00
51 changed files with 2923 additions and 337 deletions
+1 -1
View File
@@ -118,7 +118,7 @@ async def _accept_upload_files(
return HTMLResponse(expired_upload_page(), status_code=410) return HTMLResponse(expired_upload_page(), status_code=410)
if phase in {"reject_type", "need_file"}: if phase in {"reject_type", "need_file"}:
return HTMLResponse(render_upload_message("无法上传", hint), status_code=400) return HTMLResponse(render_upload_message("无法上传", hint), status_code=400)
return HTMLResponse(render_upload_message("上传成功", copy.ATTACH_H5_OK)) return HTMLResponse(render_upload_message("上传成功", copy.attach_h5_ok()))
@router.get("/upload/{token}", response_class=HTMLResponse) @router.get("/upload/{token}", response_class=HTMLResponse)
@@ -319,4 +319,4 @@ def render_upload_message(title: str, body: str, *, expired: bool = False) -> st
def expired_upload_page() -> str: def expired_upload_page() -> str:
return render_upload_message("链接已过期", copy.ATTACH_H5_EXPIRED, expired=True) return render_upload_message("链接已过期", copy.attach_h5_expired(), expired=True)
+24 -21
View File
@@ -4,7 +4,7 @@
本文件职责:给附件上传成功、选线路成功页一段共用脚本。 本文件职责:给附件上传成功、选线路成功页一段共用脚本。
手机端只调一次 WeixinJSBridge.closeWindow,关掉当前 H5 后留在询价对话里。 手机端只调一次 WeixinJSBridge.closeWindow,关掉当前 H5 后留在询价对话里。
再调 ww/wx/window.close 会把询价小助手整个退出,回到企业微信首页。 再调 ww/wx/window.close 会把询价小助手整个退出,回到企业微信首页。
电脑企微没有这套桥,加载 wecom-jssdk、用 /inquiry-form/jsapi-signature 注册后再关一次。 电脑企微即使注入了 WeixinJSBridge,这一下也关不掉当前页,必须按电脑走 JS-SDK。
失败、过期页不要挂这段。 失败、过期页不要挂这段。
副作用:只产出 HTML 片段;关窗签名走浏览器短请求,不占智能体请求线程做慢活。 副作用:只产出 HTML 片段;关窗签名走浏览器短请求,不占智能体请求线程做慢活。
""" """
@@ -24,7 +24,8 @@ def wecom_auto_close_script() -> str:
成功页脚:加载企微 JS-SDK,register 后关掉当前 H5 窗口。 成功页脚:加载企微 JS-SDK,register 后关掉当前 H5 窗口。
线程:浏览器端执行。签名接口与复制页共用,ticket 已缓存在 Redis。 线程:浏览器端执行。签名接口与复制页共用,ticket 已缓存在 Redis。
为何这样写:上一版只调 WeixinJSBridge,电脑端企微关不掉,会停在成功页。 为何这样写:电脑企微也会带上 WeixinJSBridge,但关不掉当前页。
若一发现桥就只走手机关闭,成功页会停在「正在返回企微」。手机仍只关一次。
""" """
settings = get_settings() settings = get_settings()
corp = escape((settings.wecom_corp_id or "").strip(), quote=True) corp = escape((settings.wecom_corp_id or "").strip(), quote=True)
@@ -49,6 +50,10 @@ def wecom_auto_close_script() -> str:
var closed = false; var closed = false;
function isPhone() {{
return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent || "");
}}
function closeOnce(fn) {{ function closeOnce(fn) {{
if (closed) return; if (closed) return;
closed = true; closed = true;
@@ -60,7 +65,8 @@ def wecom_auto_close_script() -> str:
closeOnce(function () {{ WeixinJSBridge.call("closeWindow"); }}); closeOnce(function () {{ WeixinJSBridge.call("closeWindow"); }});
}} }}
// 电脑企微没有 WeixinJSBridge,注册 JS-SDK 后只关一次。 // 电脑企微即使有 WeixinJSBridge,也关不掉当前页。注册 JS-SDK 后再关一次。
// 不要调 window.close:手机会把询价小助手整个退出。
function closeDesktop() {{ function closeDesktop() {{
closeOnce(function () {{ closeOnce(function () {{
if (window.ww && typeof ww.closeWindow === "function") ww.closeWindow(); if (window.ww && typeof ww.closeWindow === "function") ww.closeWindow();
@@ -69,15 +75,16 @@ def wecom_auto_close_script() -> str:
}} }}
function boot() {{ function boot() {{
if (typeof WeixinJSBridge !== "undefined") {{ if (isPhone()) {{
setTimeout(closeMobile, {delay}); if (typeof WeixinJSBridge !== "undefined") {{
setTimeout(closeMobile, {delay});
return;
}}
document.addEventListener("WeixinJSBridgeReady", function () {{
setTimeout(closeMobile, {delay});
}}, false);
return; return;
}} }}
var sawBridge = false;
document.addEventListener("WeixinJSBridgeReady", function () {{
sawBridge = true;
setTimeout(closeMobile, {delay});
}}, false);
var ready = Promise.resolve(); var ready = Promise.resolve();
if (window.ww && ww.register && corpId) {{ if (window.ww && ww.register && corpId) {{
ready = ww.register({{ ready = ww.register({{
@@ -88,18 +95,14 @@ def wecom_auto_close_script() -> str:
getAgentConfigSignature: sign("agent") getAgentConfigSignature: sign("agent")
}}).catch(function () {{ return null; }}); }}).catch(function () {{ return null; }});
}} }}
ready.then(function () {{ var timed = Promise.race([
setTimeout(function () {{ ready,
if (sawBridge || typeof WeixinJSBridge !== "undefined") {{ new Promise(function (resolve) {{ setTimeout(resolve, 2500); }})
if (!closed) closeMobile(); ]);
return; timed.then(function () {{
}} setTimeout(closeDesktop, {delay});
closeDesktop();
}}, {delay});
}}).catch(function () {{ }}).catch(function () {{
setTimeout(function () {{ setTimeout(closeDesktop, {delay});
if (!closed && typeof WeixinJSBridge === "undefined") closeDesktop();
}}, {delay});
}}); }});
}} }}
@@ -2,7 +2,8 @@
意图:空运协同群入站(BOT @ 之后的文字或附件)。 意图:空运协同群入站(BOT @ 之后的文字或附件)。
本文件职责:激活/切单后交给 air_group_ops。海运应用群不进这里。 本文件职责:激活/切单后交给 air_group_ops。海运应用群不进这里。
禁止:存档文字当业务;锁舱用当前工单去猜;套 sea_group_ops 建群。 多段单已在本群激活后,没带工单号的后续消息跟这张多段单,不再要求重发工单号。
禁止:存档文字当业务;纯空运锁舱用当前工单去猜;套 sea_group_ops 建群。
""" """
from __future__ import annotations from __future__ import annotations
@@ -56,6 +57,54 @@ from agent.routing.air_group_intent import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _follow_activated_multi(
engine,
*,
chat_id: str,
sender_id: str,
text: str,
injected_quote: Optional[dict[str, Any]],
named_wo: str = "",
) -> Optional[str]:
"""
多段单激活后,后续消息跟这张单,不必再带工单号。
句中写了工单号就认那张;没写就认本群已经绑上的多段单。
纯空运、陆运旧单返回 None,仍走空运自己的当前单。
副作用:可能发群消息、写入分段报价。在入站线程调用。
"""
from agent.policy.multi_group_ops import handle_multi_inbound, ticket_is_multi
from agent.policy.sea_group_ops import ticket_no
ticket = None
named = (named_wo or "").strip()
if named:
getter = getattr(engine.ledger, "get_ticket", None)
raw = getter(work_order_no=named) if callable(getter) else None
if raw is not None and ticket_is_multi(raw):
ticket = raw
else:
bound = current_ticket(engine, chat_id)
if bound is not None and ticket_is_multi(bound):
ticket = bound
if ticket is None:
return None
logger.info(
"air_group 多段跟单 wo=%s chat=%s named=%s",
ticket_no(ticket),
chat_id,
"1" if named else "0",
)
return handle_multi_inbound(
flow=engine,
ticket=ticket,
chat_id=chat_id,
sender_id=sender_id,
text=text,
injected_quote=injected_quote,
)
def _attach_bot_client(engine, message: InboundMessage) -> None: def _attach_bot_client(engine, message: InboundMessage) -> None:
""" """
每次入站换一次 BOT 出站客户端。 每次入站换一次 BOT 出站客户端。
@@ -116,20 +165,18 @@ def handle_air_group(
return "duplicate_inbound" return "duplicate_inbound"
mark_air_text(chat_id=chat_id, sender_id=message.sender_id, text=text) mark_air_text(chat_id=chat_id, sender_id=message.sender_id, text=text)
wo = copy.extract_work_order_no(text) wo = copy.extract_work_order_no(text)
# 句中有多段工单号,或本群已经激活过多段单:跟这张单,不必每句再带工单号。
followed = _follow_activated_multi(
engine,
chat_id=chat_id,
sender_id=message.sender_id,
text=text,
injected_quote=injected_quote,
named_wo=wo,
)
if followed is not None:
return followed
if wo: if wo:
from agent.policy.multi_group_ops import handle_multi_inbound, ticket_is_multi
getter = getattr(engine.ledger, "get_ticket", None)
raw_ticket = getter(work_order_no=wo) if callable(getter) else None
if raw_ticket is not None and ticket_is_multi(raw_ticket):
return handle_multi_inbound(
flow=engine,
ticket=raw_ticket,
chat_id=chat_id,
sender_id=message.sender_id,
text=text,
injected_quote=injected_quote,
)
from agent.policy.handoff_human import try_commit_air_group_handoff from agent.policy.handoff_human import try_commit_air_group_handoff
handed = try_commit_air_group_handoff(engine, chat_id=chat_id, work_order_no=wo) handed = try_commit_air_group_handoff(engine, chat_id=chat_id, work_order_no=wo)
@@ -212,6 +259,17 @@ def _handle_file(engine, message: InboundMessage) -> str:
wo = copy.extract_work_order_no( wo = copy.extract_work_order_no(
" ".join([message.content or "", filename]) " ".join([message.content or "", filename])
) )
# 多段已激活的附件也跟当前单,不能再要求先发工单号。
followed = _follow_activated_multi(
engine,
chat_id=chat_id,
sender_id=message.sender_id,
text=" ".join(part for part in ((message.content or "").strip(), filename) if part),
injected_quote=None,
named_wo=wo,
)
if followed is not None:
return followed
if wo: if wo:
ticket, phase = activate_ticket( ticket, phase = activate_ticket(
flow=engine, chat_id=chat_id, work_order_no=wo, speak=False flow=engine, chat_id=chat_id, work_order_no=wo, speak=False
@@ -207,7 +207,7 @@ def join_attachment_image(
if not wave.ack_sent: if not wave.ack_sent:
msg_store.enqueue_outbound( msg_store.enqueue_outbound(
touser=message.sender_id, touser=message.sender_id,
content=copy.IMAGE_RECOGNIZING, content=copy.image_recognizing(),
dedupe_key=f"attach-ack:{wave.wave_id}", dedupe_key=f"attach-ack:{wave.wave_id}",
) )
book.mark_ack(message.sender_id, "image") book.mark_ack(message.sender_id, "image")
@@ -258,7 +258,7 @@ def accept_h5_uploads(
book = get_attach_wave_book() book = get_attach_wave_book()
wave = book.active(sid, now=stamp) wave = book.active(sid, now=stamp)
if wave is None: if wave is None:
return "expired", copy.ATTACH_H5_EXPIRED return "expired", copy.attach_h5_expired()
incoming = [(str(n or "").strip(), raw or b"") for n, raw in files if str(n or "").strip()] incoming = [(str(n or "").strip(), raw or b"") for n, raw in files if str(n or "").strip()]
if not incoming: if not incoming:
return "need_file", copy.ATTACH_H5_NEED_FILE return "need_file", copy.ATTACH_H5_NEED_FILE
@@ -268,12 +268,12 @@ def accept_h5_uploads(
start_index = len(wave.materials) start_index = len(wave.materials)
wave = book.add_files(sid, [n for n, _r in incoming], now=stamp) wave = book.add_files(sid, [n for n, _r in incoming], now=stamp)
if wave is None: if wave is None:
return "expired", copy.ATTACH_H5_EXPIRED return "expired", copy.attach_h5_expired()
msg_store = store or get_message_store() msg_store = store or get_message_store()
if not wave.ack_sent: if not wave.ack_sent:
msg_store.enqueue_outbound( msg_store.enqueue_outbound(
touser=sid, touser=sid,
content=copy.ATTACH_RECOGNIZING, content=copy.attach_recognizing(),
dedupe_key=f"attach-ack:{wave.wave_id}", dedupe_key=f"attach-ack:{wave.wave_id}",
) )
book.mark_ack(sid, "file") book.mark_ack(sid, "file")
@@ -304,8 +304,8 @@ def accept_h5_uploads(
wave = book.active(sid, now=stamp) wave = book.active(sid, now=stamp)
if process_now and wave is not None and book.pending(wave) <= 0: if process_now and wave is not None and book.pending(wave) <= 0:
finish_attach_wave(sid, store=msg_store) finish_attach_wave(sid, store=msg_store)
return "done", copy.ATTACH_H5_OK return "done", copy.attach_h5_ok()
return "recognizing", copy.ATTACH_H5_OK return "recognizing", copy.attach_h5_ok()
def finish_queued_attachment(payload: dict[str, Any]) -> dict[str, Any]: def finish_queued_attachment(payload: dict[str, Any]) -> dict[str, Any]:
@@ -403,7 +403,7 @@ def finish_attach_wave(
oral = book.compose_oral(sender_id) oral = book.compose_oral(sender_id)
reason = "" reason = ""
if not oral.strip(): if not oral.strip():
reason = copy.ATTACH_TECH_FAIL if book.has_errors(sender_id) else copy.ATTACH_UNREADABLE reason = copy.attach_tech_fail() if book.has_errors(sender_id) else copy.ATTACH_UNREADABLE
msg_store = store or get_message_store() msg_store = store or get_message_store()
prefix_used = {"v": False} prefix_used = {"v": False}
@@ -441,7 +441,7 @@ def finish_attach_wave(
if reason and not prefix_used["v"]: if reason and not prefix_used["v"]:
msg_store.enqueue_outbound( msg_store.enqueue_outbound(
touser=sender_id, touser=sender_id,
content=copy.image_fail_then_clarify(reason, copy.ASK_TRANSPORT), content=copy.image_fail_then_clarify(reason, copy.ask_transport()),
dedupe_key=f"attach-empty:{(wave.wave_id if wave else inbound.message_id)}", dedupe_key=f"attach-empty:{(wave.wave_id if wave else inbound.message_id)}",
) )
phase = "need_mode" phase = "need_mode"
+11 -9
View File
@@ -103,7 +103,7 @@ def handle_group_inbound(
or "" or ""
).upper() ).upper()
if line == "MULTI": if line == "MULTI":
from agent.policy.multi_group_ops import handle_multi_inbound, sender_mode from agent.policy.multi_group_ops import bot_sender_when_air_multi, handle_multi_inbound, sender_mode
if not ( if not (
_sender_allowed(ticket, message.sender_id) _sender_allowed(ticket, message.sender_id)
@@ -111,14 +111,16 @@ def handle_group_inbound(
): ):
logger.info("group_inbound 多段忽略非成员 sender=%s", message.sender_id) logger.info("group_inbound 多段忽略非成员 sender=%s", message.sender_id)
return "group_ignore" return "group_ignore"
return handle_multi_inbound( # 会话存档听到的报价也走这里。含空运时群是机器人建的,必须改走桥才能发得出去。
flow=engine, with bot_sender_when_air_multi(ticket):
ticket=ticket, return handle_multi_inbound(
chat_id=chat_id, flow=engine,
sender_id=message.sender_id, ticket=ticket,
text=message.content or "", chat_id=chat_id,
injected_quote=injected_quote, sender_id=message.sender_id,
) text=message.content or "",
injected_quote=injected_quote,
)
ticket = hydrate_ticket(engine, ticket) ticket = hydrate_ticket(engine, ticket)
if not _sender_allowed(ticket, message.sender_id): if not _sender_allowed(ticket, message.sender_id):
logger.info("group_inbound 忽略非成员 sender=%s chat=%s", message.sender_id, chat_id) logger.info("group_inbound 忽略非成员 sender=%s chat=%s", message.sender_id, chat_id)
@@ -79,7 +79,7 @@ def handle_image_inquiry(
drop_all_private_bookmarks(message.sender_id) drop_all_private_bookmarks(message.sender_id)
msg_store.enqueue_outbound( msg_store.enqueue_outbound(
touser=message.sender_id, touser=message.sender_id,
content=copy.IMAGE_RECOGNIZING, content=copy.image_recognizing(),
dedupe_key=f"img-ack:{wave.wave_id}", dedupe_key=f"img-ack:{wave.wave_id}",
) )
book.mark_ack(message.sender_id) book.mark_ack(message.sender_id)
@@ -341,7 +341,7 @@ def finish_wave(
oral = book.compose_oral(sender_id) oral = book.compose_oral(sender_id)
reason = "" reason = ""
if not oral.strip(): if not oral.strip():
reason = copy.IMAGE_TECH_FAIL if (wave and wave.errors) else copy.IMAGE_UNREADABLE reason = copy.image_tech_fail() if (wave and wave.errors) else copy.IMAGE_UNREADABLE
msg_store = store or get_message_store() msg_store = store or get_message_store()
prefix_used = {"v": False} prefix_used = {"v": False}
@@ -373,7 +373,7 @@ def finish_wave(
if reason and not prefix_used["v"]: if reason and not prefix_used["v"]:
msg_store.enqueue_outbound( msg_store.enqueue_outbound(
touser=sender_id, touser=sender_id,
content=copy.image_fail_then_clarify(reason, copy.ASK_TRANSPORT), content=copy.image_fail_then_clarify(reason, copy.ask_transport()),
dedupe_key=f"img-empty:{inbound.message_id}", dedupe_key=f"img-empty:{inbound.message_id}",
) )
phase = "need_mode" phase = "need_mode"
+1 -12
View File
@@ -478,18 +478,7 @@ def handle_text_other(message: InboundMessage, *, store=None) -> str:
logger.info("text_other 陆运确定/重查,改走询价流程 sender=%s", message.sender_id) logger.info("text_other 陆运确定/重查,改走询价流程 sender=%s", message.sender_id)
return handle_text_inquiry(message, store=msg_store) return handle_text_inquiry(message, store=msg_store)
from agent.schema.land_options import looks_like_confirm body = copy.first_chat()
if looks_like_confirm(message.content or ""):
body = copy.LAND_CONFIRM_EXPIRED
msg_store.enqueue_outbound(
touser=message.sender_id,
content=body,
dedupe_key=f"land-confirm-expired:{message.message_id}",
)
return "land_confirm_expired"
body = copy.FIRST_CHAT
msg_store.enqueue_outbound( msg_store.enqueue_outbound(
touser=message.sender_id, touser=message.sender_id,
content=body, content=body,
+9
View File
@@ -103,6 +103,15 @@ class LedgerApiShell:
params={"enabledOnly": str(bool(enabled_only)).lower()}, params={"enabledOnly": str(bool(enabled_only)).lower()},
) )
def list_bot_replies(self) -> LedgerResponse:
"""
已启用机器人回复语。
正式路径:GET /inquiry/agent/botReply/listEnabled
只读;停用项不在列表里。
"""
return self.client.request_json("GET", "/inquiry/agent/botReply/listEnabled")
def wake_agent_ack( def wake_agent_ack(
self, self,
*, *,
+15 -2
View File
@@ -477,8 +477,14 @@ def harvest_collab_labels(text: str) -> dict[str, str]:
「客户名称张三 包装方式编织袋 货值 10万」切成三项。 「客户名称张三 包装方式编织袋 货值 10万」切成三项。
只说「纸箱」没有标签则不收。值取到下一个标签之前。 只说「纸箱」没有标签则不收。值取到下一个标签之前。
「(如:随时可提)」整行丢掉,避免补问示例被当成已填。
""" """
return _harvest_labels(text, _COLLAB_LABELS) kept = "\n".join(
line
for line in (text or "").splitlines()
if not _CLARIFY_EXAMPLE_LINE.match(line.strip())
)
return _harvest_labels(kept, _COLLAB_LABELS)
def harvest_inquiry_optional_labels(text: str) -> dict[str, str]: def harvest_inquiry_optional_labels(text: str) -> dict[str, str]:
@@ -508,7 +514,11 @@ def clip_inquiry_value(raw: str) -> str:
if hit and hit.start() > 0: if hit and hit.start() > 0:
text = text[: hit.start()] text = text[: hit.start()]
text = re.sub(r"(?:\n|\r)+\s*\d+[\.、.]\s*$", "", text) text = re.sub(r"(?:\n|\r)+\s*\d+[\.、.]\s*$", "", text)
return text.strip() text = text.strip()
# 复制补问清单时空着的下一行是「4.提货地址」。切到下一栏后只剩编号,不能当成货好时间。
if re.fullmatch(r"\d+[\.、.]", text):
return ""
return text
def clip_sheet_field_value(raw: str) -> str: def clip_sheet_field_value(raw: str) -> str:
@@ -549,9 +559,12 @@ def clip_sheet_field_value(raw: str) -> str:
if not parts: if not parts:
return "" return ""
# 格子里只剩下一个字段名(体积、时效要求)时,这一栏是空的,不能把字段名当成值。 # 格子里只剩下一个字段名(体积、时效要求)时,这一栏是空的,不能把字段名当成值。
# 只剩「4.」也是空的:那是下一行「4.提货地址」被切掉字段名后留下的编号。
token = parts[0].strip().strip("::") token = parts[0].strip().strip("::")
if token in _SHEET_CLEAN_KEYS or token in _TIGHTEN_LABELS: if token in _SHEET_CLEAN_KEYS or token in _TIGHTEN_LABELS:
return "" return ""
if re.fullmatch(r"\d+[\.、.]", token):
return ""
return parts[0].strip() return parts[0].strip()
+9 -1
View File
@@ -484,7 +484,7 @@ def _send_air_activate_sheet(
return return
err = str(painted.get("error") or "") err = str(painted.get("error") or "")
if err == "NO_TEMPLATE": if err == "NO_TEMPLATE":
send_group_text(client, chat_id, painted.get("message") or copy.QUOTE_TEMPLATE_MISSING) send_group_text(client, chat_id, painted.get("message") or copy.quote_template_missing())
return return
if err in {"no_renderer_or_chat", "no_file_client"}: if err in {"no_renderer_or_chat", "no_file_client"}:
send_group_text(client, chat_id, copy.group_sheet_send_fail()) send_group_text(client, chat_id, copy.group_sheet_send_fail())
@@ -873,6 +873,14 @@ def apply_air_lock(
if str(out.get("error") or "") == "no_quote": if str(out.get("error") or "") == "no_quote":
send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_quote(no)) send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_quote(no))
return "no_quote" return "no_quote"
# 多段是产品限制,不是 TMS 故障。只回原因,不标系统异常、不暂停工单。
if str(out.get("error") or "") == "multi_no_lock":
send_group_text(
group_client_of(flow),
chat_id,
copy.multi_air_lock_unsupported(work_order_no=no, operator=name),
)
return "multi_lock_unsupported"
why = copy.air_cabin_fail_reason( why = copy.air_cabin_fail_reason(
tms_reason=str(out.get("failReason") or out.get("error") or ""), tms_reason=str(out.get("failReason") or out.get("error") or ""),
flight_date=_ticket_flight_date(ticket), flight_date=_ticket_flight_date(ticket),
+14 -15
View File
@@ -418,21 +418,21 @@ class AirTextInquiryFlow:
hit.business_line = str(view.get("business_line") or "").strip().upper() hit.business_line = str(view.get("business_line") or "").strip().upper()
if hit is None: if hit is None:
if not callable(getter): if not callable(getter):
reply(f"找不到工单 {no},请核对工单号后再试。") reply(copy.continue_ticket_not_found(no))
return "ticket_missing" return "ticket_missing"
if not view.get("found"): if not view.get("found"):
reply(f"找不到工单 {no},请核对工单号后再试。") reply(copy.continue_ticket_not_found(no))
return "ticket_missing" return "ticket_missing"
line = str(view.get("business_line") or "").strip().upper() line = str(view.get("business_line") or "").strip().upper()
owner = self._handoff_owner_for_line(line) owner = self._handoff_owner_for_line(line)
if owner is None: if owner is None:
reply(copy.ASK_TRANSPORT) reply(copy.ask_transport())
return "need_mode" return "need_mode"
hit = owner._session_from_ledger(sender_id=sender_id, view=view) hit = owner._session_from_ledger(sender_id=sender_id, view=view)
line = (hit.business_line or "").strip().upper() line = (hit.business_line or "").strip().upper()
owner = self._handoff_owner_for_line(line) owner = self._handoff_owner_for_line(line)
if owner is None: if owner is None:
reply(copy.ASK_TRANSPORT) reply(copy.ask_transport())
return "need_mode" return "need_mode"
logger.info( logger.info(
"handoff.named wo=%s line=%s phase=%s chat=%s", "handoff.named wo=%s line=%s phase=%s chat=%s",
@@ -474,7 +474,7 @@ class AirTextInquiryFlow:
mode = (sess.business_line if sess else "") or detect_transport_mode(text) or "" mode = (sess.business_line if sess else "") or detect_transport_mode(text) or ""
mode = (mode or "").upper() mode = (mode or "").upper()
if not mode: if not mode:
reply(copy.ASK_TRANSPORT) reply(copy.ask_transport())
self._save( self._save(
FlowSession( FlowSession(
sender_id=sender_id, sender_id=sender_id,
@@ -538,7 +538,7 @@ class AirTextInquiryFlow:
sess.business_line = "SEA" sess.business_line = "SEA"
line = (sess.business_line or "").upper() line = (sess.business_line or "").upper()
if has_collab_group(sess): if has_collab_group(sess):
reply(copy.HANDOFF_ALREADY_COLLAB) reply(copy.already_collab())
return "handoff_already" return "handoff_already"
why = copy.HANDOFF_CLARIFY_REASON if reason == "clarify" else copy.HANDOFF_INTENT_REASON why = copy.HANDOFF_CLARIFY_REASON if reason == "clarify" else copy.HANDOFF_INTENT_REASON
if line == "AIR": if line == "AIR":
@@ -711,7 +711,7 @@ class AirTextInquiryFlow:
""" """
no = (work_order_no or "").strip().upper() no = (work_order_no or "").strip().upper()
if not no: if not no:
reply("请告诉我要继续的工单号,例如 WO202609140034。") reply(copy.continue_need_wo())
return "need_work_order" return "need_work_order"
with self._lock: with self._lock:
sess = self._by_ticket.get(no) sess = self._by_ticket.get(no)
@@ -730,11 +730,11 @@ class AirTextInquiryFlow:
return self._replay_wait(keep, reply, source="session") return self._replay_wait(keep, reply, source="session")
getter = getattr(self._ledger, "get_for_agent", None) getter = getattr(self._ledger, "get_for_agent", None)
if not callable(getter): if not callable(getter):
reply(f"找不到工单 {no},请核对工单号后再试。") reply(copy.continue_ticket_not_found(no))
return "ticket_missing" return "ticket_missing"
view = getter(work_order_no=no, sender_id=sender_id) or {} view = getter(work_order_no=no, sender_id=sender_id) or {}
if not view.get("found"): if not view.get("found"):
reply(f"找不到工单 {no},请核对工单号后再试。") reply(copy.continue_ticket_not_found(no))
return "ticket_missing" return "ticket_missing"
if view.get("owned") is False: if view.get("owned") is False:
reply("这张工单不属于当前账号,不能继续。") reply("这张工单不属于当前账号,不能继续。")
@@ -879,7 +879,7 @@ class AirTextInquiryFlow:
if sess.phase == "wait_file": if sess.phase == "wait_file":
self._refresh_wait_file(sess) self._refresh_wait_file(sess)
if sess.phase == "wait_file": if sess.phase == "wait_file":
reply("报价单正在生成,请稍候。") reply(copy.quote_generating())
return "wait_file" return "wait_file"
if sess.phase == "wait_deal": if sess.phase == "wait_deal":
# 成交卡已经绑在报价文件那条出站上。只续办、没说成交结果时不再另发一张卡。 # 成交卡已经绑在报价文件那条出站上。只续办、没说成交结果时不再另发一张卡。
@@ -964,9 +964,8 @@ class AirTextInquiryFlow:
reply=reply, reply=reply,
sess=sess, sess=sess,
) )
if (not sess or sess.phase != "wait_confirm") and looks_like_confirm(text) and not sess: # 核对卡不再设时效。没有待确定会话时不当成「已过期」,
reply(copy.AIR_CONFIRM_EXPIRED) # 让本句按新询价走,避免正文里的「确认」误伤。
return "confirm_expired"
sess = self._detach_ticketed_for_new_inquiry(sess) sess = self._detach_ticketed_for_new_inquiry(sess)
snap = extract_inquiry_snapshot( snap = extract_inquiry_snapshot(
@@ -1006,7 +1005,7 @@ class AirTextInquiryFlow:
) )
if not mode: if not mode:
reply(copy.ASK_TRANSPORT) reply(copy.ask_transport())
self._save( self._save(
FlowSession( FlowSession(
sender_id=sender_id, sender_id=sender_id,
@@ -1055,7 +1054,7 @@ class AirTextInquiryFlow:
chat_fn=chat_fn, chat_fn=chat_fn,
inbound_raw=inbound_raw, inbound_raw=inbound_raw,
) )
reply(copy.ASK_TRANSPORT) reply(copy.ask_transport())
return "need_mode" return "need_mode"
if is_waiting_mode_session(sess): if is_waiting_mode_session(sess):
+156
View File
@@ -0,0 +1,156 @@
"""
后台机器人回复语:按 reply_code 取正文。
本文件职责:短缓存主账已启用列表,把 {{ticket_no}} 等变量填进模板。
禁止:当六态;禁止回调线程同步等完整 LLM;失败必须回退 inquiry_copy 默认句。
线程:进程内锁刷新;不同工单可读同一份缓存。
"""
from __future__ import annotations
import logging
import os
import re
import threading
import time
from typing import Any, Optional
logger = logging.getLogger(__name__)
# 后台改文案后最多这么久出现在销售侧;不当 checkpoint。
_TTL_SECONDS = 60.0
_PLACEHOLDER = re.compile(r"\{\{\s*([a-zA-Z0-9_]+)\s*\}\}")
_lock = threading.Lock()
_loaded_at = 0.0
_by_code: dict[str, str] = {}
# 单测注入;None 表示走主账/空缓存。
_override: Optional[dict[str, str]] = None
def override_catalog(mapping: Optional[dict[str, str]]) -> None:
"""
单测注入调用编码 → 正文。传 None 清掉注入。
副作用:清空加载时间,下次 render 重新取。不打主账。
"""
global _override, _loaded_at, _by_code
with _lock:
_override = dict(mapping) if mapping is not None else None
_loaded_at = 0.0
if mapping is None:
_by_code = {}
def fill(template: str, values: dict[str, Any] | None = None) -> str:
"""把 {{ticket_no}} 换成实值;未提供的占位原样保留。"""
src = dict(values or {})
def _repl(match: re.Match[str]) -> str:
key = match.group(1)
if key not in src:
return match.group(0)
raw = src.get(key)
if raw is None:
return ""
return str(raw)
return _PLACEHOLDER.sub(_repl, template or "")
def render(code: str, default: str, **kwargs: Any) -> str:
"""
取已启用回复语并填变量。
停用、库空、主账失败、编码对不上:返回 default(已是拼好的默认句)。
副作用:缓存过期时 GET 主账;超时/失败吞掉,不抛给销售。
"""
key = (code or "").strip()
fallback = default if default is not None else ""
if not key:
return fallback
tpl = _lookup(key)
if not (tpl or "").strip():
return fallback
return fill(tpl, kwargs)
def _lookup(code: str) -> str:
"""读缓存;过期再拉主账。override 优先。"""
global _loaded_at, _by_code
with _lock:
if _override is not None:
return str(_override.get(code) or "")
now = time.monotonic()
if _loaded_at > 0 and (now - _loaded_at) < _TTL_SECONDS:
return str(_by_code.get(code) or "")
fresh = _fetch()
with _lock:
if _override is not None:
return str(_override.get(code) or "")
if fresh is not None:
_by_code = fresh
_loaded_at = time.monotonic()
elif not _by_code:
_loaded_at = time.monotonic()
return str(_by_code.get(code) or "")
def _fetch() -> Optional[dict[str, str]]:
"""
GET /inquiry/agent/botReply/listEnabled。
未配主账地址视为空,返回 {{}} 以免每次空打。失败返回 None 以保留旧缓存。
"""
try:
from agent.config import get_settings
cfg = get_settings()
backend = str(
os.environ.get("LEDGER_BACKEND") or getattr(cfg, "ledger_backend", "") or ""
).strip().lower()
if backend in {"memory", "mem", "stub"}:
return {}
if not str(getattr(cfg, "biz_api_base_url", "") or "").strip():
return {}
from agent.ledger import LedgerClient
from agent.ledger.api_shell import LedgerApiShell
client = LedgerClient.from_settings()
client.timeout_seconds = min(float(client.timeout_seconds or 10), 3.0)
resp = LedgerApiShell(client=client).list_bot_replies()
if not resp.ok:
logger.warning("bot_reply listEnabled fail status=%s err=%s", resp.status_code, resp.error)
return None
rows = _unwrap_rows(resp.data)
out: dict[str, str] = {}
for row in rows:
if not isinstance(row, dict):
continue
code = str(row.get("replyCode") or row.get("reply_code") or "").strip()
text = str(row.get("replyText") or row.get("reply_text") or "")
if code and text.strip():
out[code] = text
return out
except Exception:
logger.warning("bot_reply listEnabled 异常,回退默认句", exc_info=True)
return None
def _unwrap_rows(data: Any) -> list[Any]:
"""解开 Jeecg Result 列表。"""
if isinstance(data, list):
return data
if not isinstance(data, dict):
return []
inner = data.get("result")
if isinstance(inner, list):
return inner
if isinstance(inner, dict):
rec = inner.get("records")
if isinstance(rec, list):
return rec
rec = data.get("records") or data.get("data")
if isinstance(rec, list):
return rec
return []
+345 -79
View File
@@ -2,7 +2,8 @@
空运文字询价出站话术(销售可见)。 空运文字询价出站话术(销售可见)。
本文件职责:集中文案;按钮字面量必须与 handler 精确匹配。 本文件职责:集中文案;按钮字面量必须与 handler 精确匹配。
禁止:在本文件调主账/TMS;禁止把密钥写进话术。 销售可见固定句经 bot_reply 按调用编码读后台;停用/失败回退本文件默认句。
禁止:在本文件直接 request 主账;禁止把密钥写进话术。
""" """
from __future__ import annotations from __future__ import annotations
@@ -27,6 +28,17 @@ from agent.schema.field_validate import (
from agent.schema.land_options import option_prompt as _land_option_prompt from agent.schema.land_options import option_prompt as _land_option_prompt
from agent.schema.quote_adjust import total_price_label from agent.schema.quote_adjust import total_price_label
def _r(code: str, default: str, **kwargs: Any) -> str:
"""
按后台调用编码取回复语。
停用、库空、主账失败返回 default。本函数不打主账,只调 bot_reply 短缓存。
"""
from agent.policy.bot_reply import render
return render(code, default, **kwargs)
# 空运补问:必填 7 项 + 非必填。报价日期默认当天,不进待补充。 # 空运补问:必填 7 项 + 非必填。报价日期默认当天,不进待补充。
_AIR_EXAMPLES = { _AIR_EXAMPLES = {
"起运港": "南京", "起运港": "南京",
@@ -50,9 +62,11 @@ _AIR_OPTIONAL_CLARIFY = tuple(
(key, label, _AIR_EXAMPLES.get(key, "")) for key, label in AIR_OPTIONAL_FIELDS (key, label, _AIR_EXAMPLES.get(key, "")) for key, label in AIR_OPTIONAL_FIELDS
) )
AIR_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。" AIR_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。"
# 核对卡已取消时效,不再对销售发送此句;常量仅保留避免旧引用报错。
AIR_CONFIRM_EXPIRED = "刚才的空运核对已过期,请把空运需求再发一次,出核对卡后再回复确定。" AIR_CONFIRM_EXPIRED = "刚才的空运核对已过期,请把空运需求再发一次,出核对卡后再回复确定。"
# 海运私聊与空运同一句。核对卡没有工单号,回复确定后才建单查价。 # 海运私聊与空运同一句。核对卡没有工单号,回复确定后才建单查价。
SEA_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。" SEA_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。"
# 核对卡已取消时效,不再对销售发送此句。
SEA_CONFIRM_EXPIRED = "刚才的海运核对已过期,请把海运需求再发一次,出核对卡后再回复确定。" SEA_CONFIRM_EXPIRED = "刚才的海运核对已过期,请把海运需求再发一次,出核对卡后再回复确定。"
# 海运补问:与合同 10 项一致。报价日期默认当天,不进待补充。 # 海运补问:与合同 10 项一致。报价日期默认当天,不进待补充。
@@ -125,10 +139,79 @@ ASK_TRANSPORT = (
"2.海运\n" "2.海运\n"
"3.空运" "3.空运"
) )
def ask_transport() -> str:
"""未识别运输方式。后台 inquiring.ask_transport。"""
return _r("inquiring.ask_transport", ASK_TRANSPORT)
def first_chat() -> str:
"""销售首次单聊。后台 inquiring.first_chat。"""
return _r("inquiring.first_chat", FIRST_CHAT)
def image_recognizing() -> str:
"""正在识别图片。后台 inquiring.image_recognizing。"""
return _r("inquiring.image_recognizing", IMAGE_RECOGNIZING)
def image_tech_fail() -> str:
"""图片技术失败。后台 inquiring.image_tech_fail。"""
return _r("inquiring.image_tech_fail", IMAGE_TECH_FAIL)
def attach_recognizing() -> str:
"""正在识别附件。后台 inquiring.attach_recognizing。"""
return _r("inquiring.attach_recognizing", ATTACH_RECOGNIZING)
def attach_tech_fail() -> str:
"""附件技术失败。后台 inquiring.attach_tech_fail。"""
return _r("inquiring.attach_tech_fail", ATTACH_TECH_FAIL)
def attach_h5_expired() -> str:
"""H5 上传过期。后台 inquiring.attach_h5_expired。"""
return _r("inquiring.attach_h5_expired", ATTACH_H5_EXPIRED)
def attach_h5_ok() -> str:
"""H5 上传成功。后台 inquiring.attach_h5_ok。"""
return _r("inquiring.attach_h5_ok", ATTACH_H5_OK)
def continue_need_wo() -> str:
"""续办但未提供工单号。后台 inquiring.continue_need_wo。"""
return _r("inquiring.continue_need_wo", "请告诉我要继续的工单号,例如 WO202609140034。")
def continue_ticket_not_found(work_order_no: str) -> str:
"""续办找不到工单。后台 inquiring.continue_not_found(工单号后有空格)。"""
no = (work_order_no or "").strip()
return _r(
"inquiring.continue_not_found",
f"找不到工单 {no},请核对工单号后再试。",
ticket_no=no,
)
def quote_generating() -> str:
"""报价单生成中。后台 inquiring.quote_generating。"""
return _r("inquiring.quote_generating", "报价单正在生成,请稍候。")
# 待补充清单:请销售整段复制,只在冒号后填值;示例行不算已填。 # 待补充清单:请销售整段复制,只在冒号后填值;示例行不算已填。
CLARIFY_PASTE_HINT = "请复制粘贴以下内容,通过回复数字或文字方式,告知我以下字段值:" CLARIFY_PASTE_HINT = "请复制粘贴以下内容,通过回复数字或文字方式,告知我以下字段值:"
_PENDING_UNITS = {} _PENDING_UNITS = {}
HANDOFF_ALREADY_COLLAB = "该工单已拉产品进群协同。" HANDOFF_ALREADY_COLLAB = "该工单已拉产品进群协同。"
def already_collab() -> str:
"""该工单已拉过产品进群。后台 transfer.already_collab。"""
return _r("transfer.already_collab", HANDOFF_ALREADY_COLLAB)
HANDOFF_CLARIFY_REASON = "参数提取不全(连续 3 次反问仍不完整)" HANDOFF_CLARIFY_REASON = "参数提取不全(连续 3 次反问仍不完整)"
HANDOFF_INTENT_REASON = "已识别您希望转人工处理" HANDOFF_INTENT_REASON = "已识别您希望转人工处理"
CLARIFY_LIMIT_HINT = "连续 3 次反问仍不完整将触发转人工" CLARIFY_LIMIT_HINT = "连续 3 次反问仍不完整将触发转人工"
@@ -214,6 +297,7 @@ def land_sample_image_fallback() -> str:
FIRST_CHAT = "你好,我是询价机器人。请直接描述客户需求,例如「上海到洛杉矶空运,3 件 180kg,下周三前到」,我会解析需求并查询 TMS 标准报价。" FIRST_CHAT = "你好,我是询价机器人。请直接描述客户需求,例如「上海到洛杉矶空运,3 件 180kg,下周三前到」,我会解析需求并查询 TMS 标准报价。"
ROUTE_MODEL_FAILED = "刚才没听清,请再说一次:是新询价,还是要接着办哪张工单?" ROUTE_MODEL_FAILED = "刚才没听清,请再说一次:是新询价,还是要接着办哪张工单?"
LAND_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。" LAND_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。"
# 核对卡已取消时效。仅工单号丢失时内部兜底,不对销售当「过期」主路径。
LAND_CONFIRM_EXPIRED = "刚才的陆运核对已过期,请把陆运需求再发一次,出核对卡后再回复确定。" LAND_CONFIRM_EXPIRED = "刚才的陆运核对已过期,请把陆运需求再发一次,出核对卡后再回复确定。"
LAND_TMS_RETRY = "工单 {wo} 上次查价没成功。回复「确定」按原需求再查一次,不用重新填。" LAND_TMS_RETRY = "工单 {wo} 上次查价没成功。回复「确定」按原需求再查一次,不用重新填。"
LAND_TMS_RETRYING = "正在重新查询TMS报价。工单:{wo}" LAND_TMS_RETRYING = "正在重新查询TMS报价。工单:{wo}"
@@ -274,8 +358,11 @@ def land_confirm_card(facts: dict[str, str] | None) -> str:
def land_group_no_staff() -> str: def land_group_no_staff() -> str:
"""没有对上线路的陆运产品,不建空群。""" """没有对上线路的陆运产品,不建空群。后台 inquiring.land_no_staff。"""
return "没有可拉的产品人员。请先在后台「员工管理」维护本线路的陆运产品岗后再试。" return _r(
"inquiring.land_no_staff",
"没有可拉的产品人员。请先在后台「员工管理」维护本线路的陆运产品岗后再试。",
)
def land_group_pair_rejected() -> str: def land_group_pair_rejected() -> str:
@@ -1354,8 +1441,13 @@ def skip_collab_card_update(*, response_code: str, replace_name: str = "") -> di
def tms_miss_quote_text(*, work_order_no: str) -> str: def tms_miss_quote_text(*, work_order_no: str) -> str:
"""文字兜底里的拉群句,与现网图一致。""" """请手动拉人进群并发送工单号。后台 inquiring.pull_group_manual。"""
return f"请手动拉相关人员和智能体到原有群,并在群聊发送工单编号{work_order_no}" no = (work_order_no or "").strip()
return _r(
"inquiring.pull_group_manual",
f"请手动拉相关人员和智能体到原有群,并在群聊发送工单编号{no}",
ticket_no=no,
)
def tms_miss_hint() -> str: def tms_miss_hint() -> str:
@@ -1438,7 +1530,11 @@ def tms_need_more(*, work_order_no: str, hint: str = "") -> str:
def skip_collab_ack() -> str: def skip_collab_ack() -> str:
return "好的,已跳过内部协同。正在按 TMS 标准报价生成报价单草稿…" """销售跳过内部协同。后台 inquiring.skip_collab_ack。"""
return _r(
"inquiring.skip_collab_ack",
"好的,已跳过内部协同。正在按 TMS 标准报价生成报价单草稿…",
)
def sea_collab_ask() -> str: def sea_collab_ask() -> str:
@@ -1584,13 +1680,13 @@ def sea_tms_miss_wecom_payload(*, work_order_no: str, action_url: str = "") -> d
MULTI_CONFIRM_TAIL = "请回复「确定」创建工单,内容有误时,请重新发内容。" MULTI_CONFIRM_TAIL = "请回复「确定」创建工单,内容有误时,请重新发内容。"
MULTI_SAME_MODE = "同一运输方式出现了两段,请拆开再发。" MULTI_SAME_MODE = "同一运输方式出现了两段,请拆开再发。"
# 核对卡已取消时效,不再对销售发送此句。
MULTI_CONFIRM_EXPIRED = "刚才的多段核对已过期,请把多段需求再发一次,出核对后再回复确定。" MULTI_CONFIRM_EXPIRED = "刚才的多段核对已过期,请把多段需求再发一次,出核对后再回复确定。"
def multi_air_manual_hint(*, work_order_no: str) -> str: def multi_air_manual_hint(*, work_order_no: str) -> str:
"""含空运的多段:不自动拉群,请销售把人和智能体拉进原有群并发送工单号。""" """含空运的多段:不自动拉群。与 inquiring.pull_group_manual 同一条后台。"""
no = (work_order_no or "").strip() return tms_miss_quote_text(work_order_no=work_order_no)
return f"请手动拉相关人员和智能体到原有群,并在群聊发送工单编号{no}"
def multi_pull_text(*, work_order_no: str) -> str: def multi_pull_text(*, work_order_no: str) -> str:
@@ -1629,11 +1725,16 @@ def multi_pull_wecom_payload(*, work_order_no: str) -> dict[str, Any]:
def handoff_air_hint(*, work_order_no: str) -> str: def handoff_air_hint(*, work_order_no: str) -> str:
"""空运转人工:只发文字,不建群。""" """空运转人工:只发文字,不建群。后台 transfer.air_hint。"""
no = (work_order_no or "").strip() no = (work_order_no or "").strip()
if no: if no:
return f"触发转人工,请手动把航线和智能体拉进原有空运群,并发送工单号 {no}。" default = f"触发转人工,请手动把航线和智能体拉进原有空运群,并发送工单号 {no}。"
return "触发转人工,请手动把航线和智能体拉进原有空运群,并发送工单号。" return _r("transfer.air_hint", default, ticket_no=no)
return _r(
"transfer.air_hint",
"触发转人工,请手动把航线和智能体拉进原有空运群,并发送工单号。",
ticket_no="",
)
def handoff_offer_text(*, reason: str, transport_mode: str) -> str: def handoff_offer_text(*, reason: str, transport_mode: str) -> str:
@@ -1729,27 +1830,45 @@ def _role_label(name: str, role: str) -> str:
def sea_group_created(*, work_order_no: str, sales_name: str, product_names: list[str]) -> str: def sea_group_created(*, work_order_no: str, sales_name: str, product_names: list[str]) -> str:
"""拉群成功后的私聊。已邀请写成「销售某某、产品某某」,岗位不能省略。""" """拉群成功后的私聊。后台 inquiring.group_created。已邀请写成「销售某某、产品某某」。"""
no = (work_order_no or "").strip() no = (work_order_no or "").strip()
sales = _role_label(sales_name, "销售") sales_raw = (sales_name or "").strip()
products = "、".join( sales = _role_label(sales_raw, "销售")
_role_label(n, "产品") for n in (product_names or []) if (n or "").strip() name_bits = [n.strip() for n in (product_names or []) if (n or "").strip()]
) or "产品" products = "、".join(_role_label(n, "产品") for n in name_bits) or "产品"
return ( default = (
f"协同群已创建,群名与工单号一致:{no}。" f"协同群已创建,群名与工单号一致:{no}。"
f"已邀请:{sales}、{products}。请在群聊中继续确认报价。" f"已邀请:{sales}、{products}。请在群聊中继续确认报价。"
) )
sales_var = sales_raw[2:] if sales_raw.startswith("销售") else sales_raw
prod_var = "、".join(
(n[2:] if n.startswith("产品") else n) for n in name_bits
) or "产品"
return _r(
"inquiring.group_created",
default,
ticket_no=no,
sales_name=sales_var,
product_names=prod_var,
)
def sea_group_exists(*, work_order_no: str) -> str: def sea_group_exists(*, work_order_no: str) -> str:
"""同一工单已有群,不再新建。""" """同一工单已有群,不再新建。后台 inquiring.group_exists。"""
no = (work_order_no or "").strip() no = (work_order_no or "").strip()
return f"协同群已在,群名与工单号一致:{no}。请到该群继续确认报价。" return _r(
"inquiring.group_exists",
f"协同群已在,群名与工单号一致:{no}。请到该群继续确认报价。",
ticket_no=no,
)
def sea_group_no_staff() -> str: def sea_group_no_staff() -> str:
"""没有可拉的产品人员,不建空群。""" """没有可拉的产品人员,不建空群。后台 inquiring.sea_no_staff。"""
return "没有可拉的产品人员。请先在后台「员工管理」维护本线路的海运产品岗后再试。" return _r(
"inquiring.sea_no_staff",
"没有可拉的产品人员。请先在后台「员工管理」维护本线路的海运产品岗后再试。",
)
def group_sheet_send_fail() -> str: def group_sheet_send_fail() -> str:
@@ -1758,11 +1877,10 @@ def group_sheet_send_fail() -> str:
def sea_group_create_fail(*, reason: str = "") -> str: def sea_group_create_fail(*, reason: str = "") -> str:
"""建群失败,按钮保持可点。""" """建群失败,按钮保持可点。后台 inquiring.group_create_fail(无原因短句)。"""
extra = (reason or "").strip() extra = (reason or "").strip()
if extra: default = f"建群失败:{extra}。请稍后重试。" if extra else "建群失败,请稍后重试。"
return f"建群失败:{extra}。请稍后重试。" return _r("inquiring.group_create_fail", default, reason=extra)
return "建群失败,请稍后重试。"
def quote_is_product(quote: dict[str, Any] | None) -> bool: def quote_is_product(quote: dict[str, Any] | None) -> bool:
@@ -2212,7 +2330,11 @@ def scheme_wecom_payload(
def reject_ack() -> str: def reject_ack() -> str:
return "请问如何调整报价单,以满足您的需求?请说明要调哪一项费用,例如「提货费加 10%」。" """销售选择不采用。后台 quoted.reject_ack。"""
return _r(
"quoted.reject_ack",
"请问如何调整报价单,以满足您的需求?请说明要调哪一项费用,例如「提货费加 10%」。",
)
def adjust_confirm(applied: list[dict[str, Any]]) -> str: def adjust_confirm(applied: list[dict[str, Any]]) -> str:
@@ -2262,8 +2384,14 @@ def adjust_ineligible(reason: str) -> str:
return "当前工单还没有可调的报价,不能按这套调价。" return "当前工单还没有可调的报价,不能按这套调价。"
def adjust_only_sales() -> str: def adjust_only_sales(*, air_room: bool = False) -> str:
return "只有这张单的销售能调价。航线/产品请继续报价,价格未改。" """
群里不是这张单的销售在发调价。价格不改。
空运群称呼航线;海运、陆运称呼产品。两句用句号分开,避免连成一句。
"""
who = "航线" if air_room else "产品"
return f"只有这张单的销售能调价。{who}请继续报价,价格未改。"
def adjust_group_body(quote: dict[str, Any] | None, business_line: str = "") -> str: def adjust_group_body(quote: dict[str, Any] | None, business_line: str = "") -> str:
@@ -2278,23 +2406,32 @@ def adjust_group_body(quote: dict[str, Any] | None, business_line: str = "") ->
def adopt_excel_ack() -> str: def adopt_excel_ack() -> str:
return "已确认采用该方案,正在生成 Excel 最终报价单…" """采用并生成 Excel。后台 quoted.adopt_excel。"""
return _r("quoted.adopt_excel", "已确认采用该方案,正在生成 Excel 最终报价单…")
def adopt_pdf_ack() -> str: def adopt_pdf_ack() -> str:
return "已确认采用该方案,正在生成PDF最终报价单…" """采用并生成 PDF。后台 quoted.adopt_pdf。"""
return _r("quoted.adopt_pdf", "已确认采用该方案,正在生成PDF最终报价单…")
QUOTE_TEMPLATE_MISSING = "生成报价单失败,请联系IT运维维护后台报价模板。" QUOTE_TEMPLATE_MISSING = "生成报价单失败,请联系IT运维维护后台报价模板。"
def quote_template_missing() -> str: def quote_template_missing() -> str:
"""关键词与默认模板都未维护时的销售提示(产品口径,勿改字)。""" """关键词与默认模板都未维护。后台 quoted.template_missing。"""
return QUOTE_TEMPLATE_MISSING return _r("quoted.template_missing", QUOTE_TEMPLATE_MISSING)
def file_ready(*, kind: str, work_order_no: str, filename: str) -> str: def file_ready(*, kind: str, work_order_no: str, filename: str) -> str:
return f"已生成{kind}最终报价单:{filename}(工单 {work_order_no})" """最终报价单生成完成。后台 quoted.file_ready。"""
return _r(
"quoted.file_ready",
f"已生成{kind}最终报价单:{filename}(工单 {work_order_no})",
kind=kind,
filename=filename,
ticket_no=work_order_no,
)
# 未成交原因说明跟在未成交后,不跟在协商中后。验收脚本认这个开关。 # 未成交原因说明跟在未成交后,不跟在协商中后。验收脚本认这个开关。
@@ -2429,7 +2566,11 @@ def group_only_excel_pdf() -> str:
def group_only_product_can_quote() -> str: def group_only_product_can_quote() -> str:
return "只有产品能报价格,请产品在群里发价格或 Excel/PDF" """非产品人员在群里报价。后台 inquiring.group_only_product。"""
return _r(
"inquiring.group_only_product",
"只有产品能报价格,请产品在群里发价格或 Excel/PDF",
)
_GROUP_QUOTE_TAIL = ( _GROUP_QUOTE_TAIL = (
@@ -2464,14 +2605,24 @@ def group_no_tms_quote_hint(*, business_line: str = "") -> str:
def quote_attachment_saved_app(work_order_no: str, count: int = 1) -> str: def quote_attachment_saved_app(work_order_no: str, count: int = 1) -> str:
"""群里告诉大家:模板或产品附件已进后台。""" """
群里告诉大家:模板或产品附件已进后台。
后台 inquiring.group_file_saved。默认句带工单号和份数;后台改成短句则原样用。
"""
no = (work_order_no or "").strip() or "WO" no = (work_order_no or "").strip() or "WO"
n = count if count > 0 else 1 n = count if count > 0 else 1
return f"工单{no}的{n}个附件已保存到后台工单" return _r(
"inquiring.group_file_saved",
f"工单{no}的{n}个附件已保存到后台工单",
ticket_no=no,
count=n,
)
def group_confirm_need_tms() -> str: def group_file_saved(*, work_order_no: str = "", count: int = 1) -> str:
return "本单还没有可沿用的 TMS 报价,请直接回复协同报价或上传报价附件。" """群附件已保存。与 quote_attachment_saved_app 同一条后台编码。"""
return quote_attachment_saved_app(work_order_no, count)
def group_ask_tms_or_product() -> str: def group_ask_tms_or_product() -> str:
@@ -2490,8 +2641,8 @@ def group_need_price_before_choice() -> str:
return "请产品先发价格,再说在TMS基础上改或不用TMS报价" return "请产品先发价格,再说在TMS基础上改或不用TMS报价"
def group_file_saved() -> str: def group_confirm_need_tms() -> str:
return "附件已保存后台" return "本单还没有可沿用的 TMS 报价,请直接回复协同报价或上传报价附件。"
def group_quote_not_recognized() -> str: def group_quote_not_recognized() -> str:
@@ -2561,18 +2712,26 @@ def deal_ack(
next_followup_at: str = "", next_followup_at: str = "",
lost_reason: str = "", lost_reason: str = "",
) -> str: ) -> str:
"""成交三态回执。协商中写明顺延三个工作日和下次再问的月日 9:00。""" """成交三态回执。后台 deal.recorded_won / lost.ask_reason / negotiating.recorded。"""
no = (work_order_no or "").strip()
if outcome == "已成交": if outcome == "已成交":
return f"已记录为「已成交」。工单 {work_order_no} 已关闭智能体跟进。" return _r(
"deal.recorded_won",
f"已记录为「已成交」。工单 {no} 已关闭智能体跟进。",
ticket_no=no,
)
if outcome == "未成交": if outcome == "未成交":
reason = (lost_reason or "").strip() reason = (lost_reason or "").strip()
if reason: if reason:
return f"已记录为「未成交」。{lost_reason_ack(reason)}" return f"已记录为「未成交」。{lost_reason_ack(reason)}"
return "已记录为「未成交」。请补充未成交原因。" return _r("lost.ask_reason", "已记录为「未成交」。请补充未成交原因。", ticket_no=no)
when = _followup_month_day(next_followup_at) when = _followup_month_day(next_followup_at) or "第四个工作日"
if when: return _r(
return f"已记录为「协商中」。已顺延三个工作日,{when}的9:00再次发起成交状态询问。" "negotiating.recorded",
return "已记录为「协商中」。已顺延三个工作日,第四个工作日早上9:00再次发起成交状态询问。" f"已记录为「协商中」。已顺延三个工作日,{when}的9:00再次发起成交状态询问。",
followup_when=when,
ticket_no=no,
)
def _followup_month_day(raw: str) -> str: def _followup_month_day(raw: str) -> str:
@@ -2592,11 +2751,13 @@ def _followup_month_day(raw: str) -> str:
def lost_reason_ack(reason: str = "") -> str: def lost_reason_ack(reason: str = "") -> str:
"""收下未成交原因后回显原句,不再追问。""" """收下未成交原因后回显原句。后台 lost.reason_ack。"""
text = (reason or "").strip() text = (reason or "").strip()
if text.endswith(("。", ".", "!", "!", "?", "?")): if text.endswith(("。", ".", "!", "!", "?", "?")):
return f"已记下未成交原因:{text}" default = f"已记下未成交原因:{text}"
return f"已记下未成交原因:{text}。" if text else "已记下未成交原因。" else:
default = f"已记下未成交原因:{text}。" if text else "已记下未成交原因。"
return _r("lost.reason_ack", default, lost_reason=text)
def deal_notify_card( def deal_notify_card(
@@ -2679,29 +2840,49 @@ def air_need_work_order() -> str:
def air_lock_need_work_order() -> str: def air_lock_need_work_order() -> str:
"""锁舱/释放必须自带工单号,不猜当前单。""" """锁舱/释放必须自带工单号。后台 inquiring.air_lock_need_wo。"""
return "锁舱或释放舱位请带上工单号,例如 WO202609160001锁舱。" return _r(
"inquiring.air_lock_need_wo",
"锁舱或释放舱位请带上工单号,例如 WO202609160001锁舱。",
)
def air_ticket_not_found(work_order_no: str) -> str: def air_ticket_not_found(work_order_no: str) -> str:
"""空运群找不到工单。后台 inquiring.air_ticket_not_found。"""
no = (work_order_no or "").strip() no = (work_order_no or "").strip()
return f"找不到工单{no},请核对工单号后再试。" return _r("inquiring.air_ticket_not_found", f"找不到工单{no},请核对工单号后再试。", ticket_no=no)
def air_ticket_not_air(work_order_no: str) -> str: def air_ticket_not_air(work_order_no: str) -> str:
"""工单不是空运单。后台 inquiring.air_ticket_not_air。"""
no = (work_order_no or "").strip() no = (work_order_no or "").strip()
return f"工单{no}不是空运单,不能在空运群里跟。" return _r(
"inquiring.air_ticket_not_air",
f"工单{no}不是空运单,不能在空运群里跟。",
ticket_no=no,
)
def air_ticket_closed(work_order_no: str, status: str) -> str: def air_ticket_closed(work_order_no: str, status: str) -> str:
"""空运工单已是终态仍要激活。后台 closed.air_cannot_activate。"""
no = (work_order_no or "").strip() no = (work_order_no or "").strip()
st = (status or "").strip() or "已关闭" st = (status or "").strip() or "已关闭"
return f"工单{no}已是「{st}」,不能再激活。" return _r(
"closed.air_cannot_activate",
f"工单{no}已是「{st}」,不能再激活。",
ticket_no=no,
status=st,
)
def air_ticket_occupied(work_order_no: str) -> str: def air_ticket_occupied(work_order_no: str) -> str:
"""空运工单已在另一个群。后台 inquiring.air_ticket_occupied。"""
no = (work_order_no or "").strip() no = (work_order_no or "").strip()
return f"工单{no}已经在另一个群跟了,请到那个群继续。" return _r(
"inquiring.air_ticket_occupied",
f"工单{no}已经在另一个群跟了,请到那个群继续。",
ticket_no=no,
)
def air_group_brief( def air_group_brief(
@@ -2820,7 +3001,11 @@ def air_airline_only_quote() -> str:
def air_airline_only_cabin() -> str: def air_airline_only_cabin() -> str:
return "只有岗位为「航线·空运」的人能锁舱或释放舱位。" """只有航线能锁舱或释放。后台 inquiring.air_airline_only_cabin。"""
return _r(
"inquiring.air_airline_only_cabin",
"只有岗位为「航线·空运」的人能锁舱或释放舱位。",
)
def air_sales_only_deal() -> str: def air_sales_only_deal() -> str:
@@ -2844,9 +3029,13 @@ def air_no_tms_quote_to_lock(work_order_no: str) -> str:
def air_lock_need_quote(work_order_no: str) -> str: def air_lock_need_quote(work_order_no: str) -> str:
"""没有任何报价时的锁舱拒绝。不提 TMS,避免把人工报价说成没报价。""" """没有任何报价时的锁舱拒绝。后台 inquiring.air_lock_need_quote。"""
no = (work_order_no or "").strip() no = (work_order_no or "").strip()
return f"工单{no}还没有报价,不能锁舱。" return _r(
"inquiring.air_lock_need_quote",
f"工单{no}还没有报价,不能锁舱。",
ticket_no=no,
)
def air_ask_lock_option(options: list) -> str: def air_ask_lock_option(options: list) -> str:
@@ -2869,23 +3058,67 @@ def air_lock_ok(
exec_no: str, exec_no: str,
quote_no: str, quote_no: str,
) -> str: ) -> str:
return ( """空运锁舱成功。后台 inquiring.air_lock_ok。"""
default = (
f"工单{work_order_no} 已通知TMS执行空运锁舱,执行成功。\n" f"工单{work_order_no} 已通知TMS执行空运锁舱,执行成功。\n"
f"操作人:{operator}\n" f"操作人:{operator}\n"
f"舱位选项:{option_no}\n" f"舱位选项:{option_no}\n"
f"执行编号:{exec_no}\n" f"执行编号:{exec_no}\n"
f"报价编号:{quote_no}" f"报价编号:{quote_no}"
) )
return _r(
"inquiring.air_lock_ok",
default,
ticket_no=work_order_no,
operator=operator,
option_no=option_no,
exec_no=exec_no,
quote_no=quote_no,
)
# 多段里有空运段时,航线锁舱的失败原因。产品暂不支持,不当 TMS 故障。
MULTI_LOCK_UNSUPPORTED = "多段询价,暂时不支持锁舱"
# 后台锁舱失败模板末尾常有「已通知IT…请稍等」。多段这条要整句拿掉。
_IT_NOTICE_LINE = re.compile(r"已通知\s*IT[^\n。]*。?")
def multi_air_lock_unsupported(*, work_order_no: str, operator: str) -> str:
"""
多段含空运、航线锁舱时的群回复。
抬头、操作人、失败原因仍走锁舱失败模板,只去掉通知 IT 那句。
不打主账、不标系统异常。
"""
text = air_lock_fail(
work_order_no=work_order_no,
operator=operator,
reason=MULTI_LOCK_UNSUPPORTED,
)
kept: list[str] = []
for line in text.splitlines():
cleaned = _IT_NOTICE_LINE.sub("", line).strip()
if cleaned:
kept.append(cleaned)
return "\n".join(kept).strip()
def air_lock_fail(*, work_order_no: str, operator: str, reason: str) -> str: def air_lock_fail(*, work_order_no: str, operator: str, reason: str) -> str:
"""空运锁舱失败。后台 inquiring.air_lock_fail。"""
why = (reason or "").strip() or "TMS未返回原因" why = (reason or "").strip() or "TMS未返回原因"
return ( default = (
f"工单{work_order_no} 空运锁舱失败。\n" f"工单{work_order_no} 空运锁舱失败。\n"
f"操作人:{operator}\n" f"操作人:{operator}\n"
f"锁舱失败的原因:{why}\n" f"锁舱失败的原因:{why}\n"
"已通知IT运维排查,请稍等。" "已通知IT运维排查,请稍等。"
) )
return _r(
"inquiring.air_lock_fail",
default,
ticket_no=work_order_no,
operator=operator,
reason=why,
)
def air_cabin_fail_reason(*, tms_reason: str = "", flight_date: str = "", today: str = "") -> str: def air_cabin_fail_reason(*, tms_reason: str = "", flight_date: str = "", today: str = "") -> str:
@@ -2907,33 +3140,61 @@ def air_release_ok(
exec_no: str, exec_no: str,
quote_no: str, quote_no: str,
) -> str: ) -> str:
return ( """空运释放成功。后台 inquiring.air_release_ok。"""
default = (
f"工单{work_order_no} 已通知TMS执行空运释放舱位成功\n" f"工单{work_order_no} 已通知TMS执行空运释放舱位成功\n"
f"操作人:{operator}\n" f"操作人:{operator}\n"
f"舱位选项:{option_no}\n" f"舱位选项:{option_no}\n"
f"执行编号:{exec_no}\n" f"执行编号:{exec_no}\n"
f"报价编号:{quote_no}" f"报价编号:{quote_no}"
) )
return _r(
"inquiring.air_release_ok",
default,
ticket_no=work_order_no,
operator=operator,
option_no=option_no,
exec_no=exec_no,
quote_no=quote_no,
)
def air_release_fail(*, work_order_no: str, operator: str, reason: str) -> str: def air_release_fail(*, work_order_no: str, operator: str, reason: str) -> str:
"""空运释放失败。后台 inquiring.air_release_fail。"""
why = (reason or "").strip() or "TMS未返回原因" why = (reason or "").strip() or "TMS未返回原因"
return ( default = (
f"工单{work_order_no} 空运释放舱位失败。\n" f"工单{work_order_no} 空运释放舱位失败。\n"
f"操作人:{operator}\n" f"操作人:{operator}\n"
f"释放舱位失败的原因:{why}\n" f"释放舱位失败的原因:{why}\n"
"已通知IT运维排查,请稍等。" "已通知IT运维排查,请稍等。"
) )
return _r(
"inquiring.air_release_fail",
default,
ticket_no=work_order_no,
operator=operator,
reason=why,
)
def air_release_deal_blocked(work_order_no: str) -> str: def air_release_deal_blocked(work_order_no: str) -> str:
"""已成交不能释放舱位。后台 inquiring.air_release_deal_blocked。"""
no = (work_order_no or "").strip() no = (work_order_no or "").strip()
return f"释放舱位失败,工单{no}已成交。" return _r(
"inquiring.air_release_deal_blocked",
f"释放舱位失败,工单{no}已成交。",
ticket_no=no,
)
def air_release_need_lock(work_order_no: str) -> str: def air_release_need_lock(work_order_no: str) -> str:
"""没有有效锁舱不能释放。后台 inquiring.air_release_need_lock。"""
no = (work_order_no or "").strip() no = (work_order_no or "").strip()
return f"工单{no}还没有有效锁舱,不能释放。" return _r(
"inquiring.air_release_need_lock",
f"工单{no}还没有有效锁舱,不能释放。",
ticket_no=no,
)
# —— 系统异常(UC024):销售/群兜底与恢复;告警卡只给有权限的人 —— # —— 系统异常(UC024):销售/群兜底与恢复;告警卡只给有权限的人 ——
@@ -2966,29 +3227,34 @@ IMPACT_GROUP = "当前建群/加人暂停,已建群不新建第二个群"
def system_exception_fallback(exception_type: str, *, names: list[str] | None = None) -> str: def system_exception_fallback(exception_type: str, *, names: list[str] | None = None) -> str:
"""判定系统异常后,给卡住的会话的兜底话术。""" """判定系统异常后,给卡住的会话的兜底话术。后台 sys.llm_fail / sys.tms_fail / sys.group_add_fail。"""
kind = (exception_type or "").strip() kind = (exception_type or "").strip()
if kind == TYPE_LLM: if kind == TYPE_LLM:
return SYS_EXC_LLM_FALLBACK return _r("sys.llm_fail", SYS_EXC_LLM_FALLBACK)
if kind == TYPE_GROUP: if kind == TYPE_GROUP:
people = [str(x).strip() for x in (names or []) if str(x).strip()] people = [str(x).strip() for x in (names or []) if str(x).strip()]
if not people:
return "无法添加同事,请联系管理员检查企业微信配置。"
joined = "、".join(people) joined = "、".join(people)
return f"无法添加 {joined} 同事,请联系管理员检查企业微信配置。" if joined:
return SYS_EXC_TMS_FALLBACK default = f"无法添加 {joined} 同事,请联系管理员检查企业微信配置。"
else:
default = "无法添加同事,请联系管理员检查企业微信配置。"
text = _r("sys.group_add_fail", default, staff_name=joined)
if not joined:
text = re.sub(r"无法添加\s+同事", "无法添加同事", text)
return text
return _r("sys.tms_fail", SYS_EXC_TMS_FALLBACK)
def system_exception_recovery(step: str) -> str: def system_exception_recovery(step: str) -> str:
"""后台点重试成功后,先发这一句,再补没做完的事。""" """后台点重试成功后,先发这一句,再补没做完的事。"""
key = (step or "").strip() key = (step or "").strip()
if key == STEP_LLM or key == STEP_LLM_NO_TICKET: if key == STEP_LLM or key == STEP_LLM_NO_TICKET:
return SYS_EXC_RECOVERY_LLM return _r("sys.llm_recovery", SYS_EXC_RECOVERY_LLM)
if key in {STEP_TMS_CABIN, STEP_TMS_LOCK, STEP_TMS_RELEASE}: if key in {STEP_TMS_CABIN, STEP_TMS_LOCK, STEP_TMS_RELEASE}:
return SYS_EXC_RECOVERY_CABIN return _r("sys.cabin_recovery", SYS_EXC_RECOVERY_CABIN)
if key == STEP_GROUP: if key == STEP_GROUP:
return SYS_EXC_RECOVERY_GROUP return _r("sys.group_recovery", SYS_EXC_RECOVERY_GROUP)
return SYS_EXC_RECOVERY_TMS return _r("sys.tms_recovery", SYS_EXC_RECOVERY_TMS)
def system_exception_impact(step: str) -> str: def system_exception_impact(step: str) -> str:
@@ -3022,7 +3288,7 @@ def system_exception_alert_payload(
""" """
系统异常通知卡:走询价消息服务,不走询价小助手。 系统异常通知卡:走询价消息服务,不走询价小助手。
五行对齐现网示例:异常类型、接口/服务、错误码、影响范围、发生时间。 五行:异常类型、接口/服务、报错原因、影响范围、发生时间。
标题不加 [test]:运维要看的就是这张卡本身,test_prefix 仅保留兼容入参。 标题不加 [test]:运维要看的就是这张卡本身,test_prefix 仅保留兼容入参。
""" """
no = (work_order_no or "").strip() or "无" no = (work_order_no or "").strip() or "无"
@@ -3031,7 +3297,7 @@ def system_exception_alert_payload(
rows = [ rows = [
{"keyname": "异常类型", "value": (exception_type or "").strip() or "-"}, {"keyname": "异常类型", "value": (exception_type or "").strip() or "-"},
{"keyname": "接口/服务", "value": (service or "").strip() or "-"}, {"keyname": "接口/服务", "value": (service or "").strip() or "-"},
{"keyname": "错误码", "value": (error_code or "").strip() or "-"}, {"keyname": "报错原因", "value": (error_code or "").strip() or "-"},
{"keyname": "影响范围", "value": system_exception_impact(step)}, {"keyname": "影响范围", "value": system_exception_impact(step)},
{"keyname": "发生时间", "value": (occurred_at or "").strip() or "-"}, {"keyname": "发生时间", "value": (occurred_at or "").strip() or "-"},
] ]
@@ -3072,7 +3338,7 @@ def system_exception_alert_text(
f"关联工单 {no}", f"关联工单 {no}",
f"异常类型:{(exception_type or '').strip() or '-'}", f"异常类型:{(exception_type or '').strip() or '-'}",
f"接口/服务:{(service or '').strip() or '-'}", f"接口/服务:{(service or '').strip() or '-'}",
f"错误码:{(error_code or '').strip() or '-'}", f"报错原因:{(error_code or '').strip() or '-'}",
f"影响范围:{system_exception_impact(step)}", f"影响范围:{system_exception_impact(step)}",
f"发生时间:{(occurred_at or '').strip() or '-'}", f"发生时间:{(occurred_at or '').strip() or '-'}",
] ]
+4 -3
View File
@@ -174,7 +174,7 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
if not mode: if not mode:
mode = "LAND" if (sess and (sess.business_line or "").upper() == "LAND") else "" mode = "LAND" if (sess and (sess.business_line or "").upper() == "LAND") else ""
if not mode: if not mode:
reply(copy.ASK_TRANSPORT) reply(copy.ask_transport())
self._save( self._save(
FlowSession( FlowSession(
sender_id=sender_id, sender_id=sender_id,
@@ -364,8 +364,9 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
no = (sess.work_order_no or "").strip() no = (sess.work_order_no or "").strip()
facts = dict(sess.facts or {}) facts = dict(sess.facts or {})
if not no or not facts: if not no or not facts:
reply(copy.LAND_CONFIRM_EXPIRED) # 核对卡已取消时效,禁止再回「陆运核对已过期」。
return "land_confirm_expired" logger.warning("land.retry 缺工单或字段,不提示过期")
return "land_retry_missing"
reply(copy.LAND_TMS_RETRYING.format(wo=no)) reply(copy.LAND_TMS_RETRYING.format(wo=no))
from agent.policy.system_exception import query_tms_resilient from agent.policy.system_exception import query_tms_resilient
+45 -9
View File
@@ -11,7 +11,8 @@ from __future__ import annotations
import json import json
import logging import logging
from typing import Any, Callable, Optional from contextlib import contextmanager
from typing import Any, Callable, Iterator, Optional
from agent.llm.extract_text import extract_collab_fields, extract_product_quote from agent.llm.extract_text import extract_collab_fields, extract_product_quote
from agent.policy import inquiry_copy as copy from agent.policy import inquiry_copy as copy
@@ -20,6 +21,7 @@ from agent.policy.sea_group_ops import (
apply_group_deal, apply_group_deal,
group_client_of, group_client_of,
is_sales, is_sales,
override_group_client,
send_filled_sheet, send_filled_sheet,
send_group_text, send_group_text,
ticket_no, ticket_no,
@@ -33,6 +35,24 @@ _ROLE_MODE = {"sea": "SEA", "land": "LAND", "air": "AIR"}
SheetFn = Callable[..., dict[str, Any]] SheetFn = Callable[..., dict[str, Any]]
@contextmanager
def bot_sender_when_air_multi(ticket: object) -> Iterator[None]:
"""
含空运的多段用的是机器人原来的群。应用接口发不进这个群,报价和模板都会没反应。
这一次出站改走机器人桥,发完还原。没有空运段的多段仍用应用自己建的群,这里不动。
只影响当前线程,不改流程上缓存的客户端。
"""
if not includes_air(load_segments(ticket)):
yield
return
from agent.channel.aibot.reply import AibotReplyClient
logger.info("multi.outbound 含空运走机器人桥 wo=%s", ticket_no(ticket))
with override_group_client(AibotReplyClient()):
yield
def ticket_is_multi(ticket: object) -> bool: def ticket_is_multi(ticket: object) -> bool:
"""主账业务线是多段联运。""" """主账业务线是多段联运。"""
view = ticket_view(ticket) view = ticket_view(ticket)
@@ -257,8 +277,17 @@ def _save_segment_quote(
def _cabin(flow: Any, ticket: object, chat_id: str, sender_id: str, text: str, intent: str) -> str: def _cabin(flow: Any, ticket: object, chat_id: str, sender_id: str, text: str, intent: str) -> str:
"""空运段的锁舱、释放走现有空运规则,不挡住其他段报价。""" """
from agent.policy.air_group_ops import apply_air_lock, apply_air_release 多段含空运时的锁舱、释放。
锁舱暂不支持:只回复失败原因,不打 TMS,也不标系统异常。
释放仍走空运规则。调用方已确认本单含空运段。群消息在 Worker 线程发出。
"""
from agent.policy.air_group_ops import (
apply_air_release,
is_airline,
operator_name,
)
if intent == "release": if intent == "release":
return apply_air_release( return apply_air_release(
@@ -268,13 +297,20 @@ def _cabin(flow: Any, ticket: object, chat_id: str, sender_id: str, text: str, i
chat_id=chat_id, chat_id=chat_id,
instruction=text, instruction=text,
) )
return apply_air_lock( if not is_airline(flow, sender_id):
flow=flow, send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin())
ticket=ticket, return "not_airline"
sender_id=sender_id, no = ticket_no(ticket)
chat_id=chat_id, send_group_text(
instruction=text, group_client_of(flow),
chat_id,
copy.multi_air_lock_unsupported(
work_order_no=no,
operator=operator_name(flow, sender_id),
),
) )
logger.info("multi.lock unsupported wo=%s sender=%s", no, sender_id)
return "multi_lock_unsupported"
def _cabin_intent(text: str) -> str: def _cabin_intent(text: str) -> str:
@@ -256,9 +256,7 @@ class MultiTextInquiryFlow:
allow_b=allow_b, allow_b=allow_b,
chat_fn=chat_fn, chat_fn=chat_fn,
) )
if sess and sess.phase == "wait_confirm": # 核对卡不再设时效。走到这里说明不是「确定」也不是补字段。
reply(copy.MULTI_CONFIRM_EXPIRED)
return "multi_confirm_expired"
reply(copy.MULTI_SAME_MODE if verdict == "same_mode" else "请按段发多段需求,例如段1海运、段2空运。") reply(copy.MULTI_SAME_MODE if verdict == "same_mode" else "请按段发多段需求,例如段1海运、段2空运。")
return "multi_need_text" return "multi_need_text"
@@ -260,7 +260,14 @@ def apply_group_adjust(
send_group_text(client, room, copy.adjust_ineligible("no_quote")) send_group_text(client, room, copy.adjust_ineligible("no_quote"))
return "adjust_blocked" return "adjust_blocked"
if not is_sales(ticket, sender_id): if not is_sales(ticket, sender_id):
send_group_text(client, room, copy.adjust_only_sales()) line = str(view.get("business_line") or view.get("businessLine") or "").upper()
air_room = line == "AIR"
if line == "MULTI":
from agent.policy.multi_group_ops import load_segments
from agent.policy.multi_segments import includes_air
air_room = includes_air(load_segments(ticket))
send_group_text(client, room, copy.adjust_only_sales(air_room=air_room))
return "adjust_not_sales" return "adjust_not_sales"
current = _current_quote(view, sess) current = _current_quote(view, sess)
names = [str(r.get("name") or "") for r in adjustable_rows(current)] names = [str(r.get("name") or "") for r in adjustable_rows(current)]
+30 -2
View File
@@ -9,7 +9,9 @@ from __future__ import annotations
import json import json
import logging import logging
from typing import Any, Optional from contextlib import contextmanager
from contextvars import ContextVar
from typing import Any, Iterator, Optional
from agent.llm.extract_text import extract_collab_fields, extract_product_quote from agent.llm.extract_text import extract_collab_fields, extract_product_quote
from agent.policy.quote_overlay import overlay_labeled_facts from agent.policy.quote_overlay import overlay_labeled_facts
@@ -244,14 +246,40 @@ def filename_is_excel_pdf(name: str) -> bool:
return any(raw.endswith(ext) for ext in EXCEL_PDF) return any(raw.endswith(ext) for ext in EXCEL_PDF)
# 这一次群出站临时改用的客户端。只在当前线程有效,不改流程单例上的企微客户端。
_outbound_client: ContextVar[Any] = ContextVar("inquiry_group_outbound_client", default=None)
def group_client_of(flow: Any) -> Any: def group_client_of(flow: Any) -> Any:
"""正式路径没有注入客户端时,走流程自己的默认企微客户端。""" """
取这一次群出站用的客户端。
有临时指定时用指定的(含空运的多段要走机器人桥)。
没有时走流程自己的客户端;正式路径默认是应用发群。
"""
override = _outbound_client.get()
if override is not None:
return override
getter = getattr(flow, "group_client", None) getter = getattr(flow, "group_client", None)
if callable(getter): if callable(getter):
return getter() return getter()
return getattr(flow, "_group_client", None) return getattr(flow, "_group_client", None)
@contextmanager
def override_group_client(client: Any) -> Iterator[None]:
"""
这一次群出站改用指定客户端,用完还原。
只影响当前线程。不改流程单例,海运自己建的群不会被别的工单抢走客户端。
"""
token = _outbound_client.set(client)
try:
yield
finally:
_outbound_client.reset(token)
def _archive_seat_ids(flow: Any) -> set[str]: def _archive_seat_ids(flow: Any) -> set[str]:
"""会话存档账号(测服 AI001 / 询价机器人)不能出现在群 @ 里。""" """会话存档账号(测服 AI001 / 询价机器人)不能出现在群 @ 里。"""
seats: set[str] = {"AI001", "询价机器人"} seats: set[str] = {"AI001", "询价机器人"}
+3 -5
View File
@@ -120,9 +120,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
reply=reply, reply=reply,
sess=sess, sess=sess,
) )
if not sess and looks_like_confirm(text): # 核对卡不再设时效。没有待确定会话时不当成「已过期」。
reply(copy.SEA_CONFIRM_EXPIRED)
return "confirm_expired"
# 上一单已出号:新一轮从本句重收,禁止把旧贸易条款/分类带进下一张工单。 # 上一单已出号:新一轮从本句重收,禁止把旧贸易条款/分类带进下一张工单。
sess = self._detach_ticketed_for_new_inquiry(sess) sess = self._detach_ticketed_for_new_inquiry(sess)
@@ -159,7 +157,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
mode = detect_transport_mode(f"{sess.immutable_text or ''}\n{text}") mode = detect_transport_mode(f"{sess.immutable_text or ''}\n{text}")
if not mode: if not mode:
reply(copy.ASK_TRANSPORT) reply(copy.ask_transport())
self._save( self._save(
FlowSession( FlowSession(
sender_id=sender_id, sender_id=sender_id,
@@ -888,7 +886,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
) )
if sess.collab_ended or (sess.collab_chat_id or "").strip(): if sess.collab_ended or (sess.collab_chat_id or "").strip():
reply(copy.HANDOFF_ALREADY_COLLAB) reply(copy.already_collab())
return "handoff_already" return "handoff_already"
display_no = ensure_virtual_no(sess) if not (sess.work_order_no or "").strip() else sess.work_order_no display_no = ensure_virtual_no(sess) if not (sess.work_order_no or "").strip() else sess.work_order_no
staff = self._match_handoff_staff(sess) staff = self._match_handoff_staff(sess)
+2 -1
View File
@@ -520,9 +520,10 @@ def harvest_oral_collab(text: str) -> dict[str, str]:
群里口语协同:含油/含电/含磁、货好时间、是否为危险品。 群里口语协同:含油/含电/含磁、货好时间、是否为危险品。
只认原话里已经说死的词(不含电、随时可提、普货),不猜贸易条款和海关编码。 只认原话里已经说死的词(不含电、随时可提、普货),不猜贸易条款和海关编码。
「(如:随时可提)」这种补问示例行不算销售说了货好时间。
危险品:否词(不是危险品/非危/普货)先于是词,避免「不是危险品」收成是。 危险品:否词(不是危险品/非危/普货)先于是词,避免「不是危险品」收成是。
""" """
raw = text or "" raw = _without_clarify_examples(text or "")
out: dict[str, str] = {} out: dict[str, str] = {}
yes_no = ( yes_no = (
("是否含电", ("不含电", "不带电", "无电"), ("含电", "带电")), ("是否含电", ("不含电", "不带电", "无电"), ("含电", "带电")),
+11 -3
View File
@@ -504,12 +504,20 @@ def looks_like_confirm(text: str) -> bool:
""" """
判断是否在表示确定查价。 判断是否在表示确定查价。
「确定」「确认」「查价」「可以查了」及相近说法都算。 「确定」「确认」「查价」「可以查了」及相近短句才算。
同一句像在改始发/目的/体积/车型/「改成」则不当确定,避免改内容被当成查价。 长询价里「需确认/请确认」不算点核对卡,避免误回「核对已过期」。
同一句像在改始发/目的/体积/车型/「改成」则不当确定。
纯函数,无副作用。
""" """
raw = (text or "").strip() raw = (text or "").strip()
if not raw: if not raw:
return False return False
if any(x in raw for x in _CONFIRM_EXCLUDE): if any(x in raw for x in _CONFIRM_EXCLUDE):
return False return False
return any(p in raw for p in _CONFIRM_PHRASES) # 超过约 20 字一定是需求正文,不是「确定」短答。
if len(raw) > 20:
return False
compact = re.sub(r"[\s。!?!?,,、]+", "", raw)
if not compact:
return False
return any(p in compact for p in _CONFIRM_PHRASES)
@@ -614,6 +614,45 @@ class AirGroupCollabTests(unittest.TestCase):
) )
self.assertEqual(phase, "need_work_order") self.assertEqual(phase, "need_work_order")
def test_activated_multi_quote_follows_without_work_order(self) -> None:
"""多段激活后,报价不必再带工单号,也不要回「请先 @我」。"""
import json
created = self.ledger.create_ticket(
sender_id="sales1",
business_line="MULTI",
facts={
"segments_json": json.dumps(
[
{"index": 1, "mode": "LAND", "facts": {"起运港": "北京", "目的港": "上海"}},
{"index": 2, "mode": "AIR", "facts": {"起运港": "上海", "目的港": "北宁"}},
],
ensure_ascii=False,
)
},
)
wo = created["work_order_no"]
self.ledger.bind_collab_group(
work_order_no=wo,
chat_id="wr_multi",
member_ids=["sales1", "air1"],
product_ids=["air1"],
product_names=["HangXian"],
)
phase = handle_air_group(
self._msg(sender="air1", text="空运费 100USD", chat="wr_multi", mid="mq1"),
flow=self.flow,
ledger=self.ledger,
injected_intent="product_quote",
)
self.assertEqual(phase, "segment_quoted")
joined = "\n".join(self._texts())
self.assertNotIn("请先 @我", joined)
self.assertNotIn("并发送要跟的工单号", joined)
ticket = self.ledger.get_ticket(work_order_no=wo)
air_quote = (ticket.quote or {}).get("segment_quotes", {}).get("AIR") or {}
self.assertTrue(air_quote.get("total") or air_quote.get("fee_rows"))
def test_sales_can_fill_battery(self) -> None: def test_sales_can_fill_battery(self) -> None:
wo = self._create_air() wo = self._create_air()
handle_air_group( handle_air_group(
@@ -108,6 +108,8 @@ class AttachmentUploadH5Tests(unittest.TestCase):
self.assertIn("closeWindow", ok) self.assertIn("closeWindow", ok)
self.assertIn("wecom-jssdk", ok) self.assertIn("wecom-jssdk", ok)
self.assertIn("jsapi-signature", ok) self.assertIn("jsapi-signature", ok)
self.assertIn("function isPhone()", ok)
self.assertNotIn("window.close()", ok)
self.assertNotIn("closeWindow", page) self.assertNotIn("closeWindow", page)
self.assertNotIn("closeWindow", dead) self.assertNotIn("closeWindow", dead)
+97
View File
@@ -0,0 +1,97 @@
"""后台回复语:按调用编码填句,失败回退默认。"""
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("LEDGER_BACKEND", "memory")
from agent.policy import inquiry_copy as copy
from agent.policy.bot_reply import fill, override_catalog, render
class BotReplyRenderTests(unittest.TestCase):
def tearDown(self) -> None:
override_catalog(None)
def test_fill_placeholder(self) -> None:
self.assertEqual(fill("工单{{ticket_no}}。", {"ticket_no": "WO1"}), "工单WO1。")
self.assertEqual(fill("工单{{ ticket_no }}。", {"ticket_no": "WO1"}), "工单WO1。")
self.assertIn("{{lost_reason}}", fill("因{{lost_reason}}", {}))
def test_default_when_empty(self) -> None:
override_catalog({})
self.assertEqual(copy.first_chat(), copy.FIRST_CHAT)
self.assertEqual(copy.ask_transport(), copy.ASK_TRANSPORT)
self.assertEqual(copy.deal_ack("未成交", "WO1"), "已记录为「未成交」。请补充未成交原因。")
def test_override_first_chat(self) -> None:
override_catalog({"inquiring.first_chat": "你好,测试问候。"})
self.assertEqual(copy.first_chat(), "你好,测试问候。")
self.assertEqual(copy.FIRST_CHAT, "你好,我是询价机器人。请直接描述客户需求,例如「上海到洛杉矶空运,3 件 180kg,下周三前到」,我会解析需求并查询 TMS 标准报价。")
def test_group_created_fills_names(self) -> None:
override_catalog(
{
"inquiring.group_created": (
"协同群已创建,群名与工单号一致:{{ticket_no}}。"
"已邀请:销售{{sales_name}}、产品{{product_names}}。请在群聊中继续确认报价。"
)
}
)
text = copy.sea_group_created(
work_order_no="WO202610060001",
sales_name="张三",
product_names=["李四"],
)
self.assertIn("WO202610060001", text)
self.assertIn("销售张三", text)
self.assertIn("产品李四", text)
self.assertNotIn("销售销售", text)
def test_disabled_falls_back(self) -> None:
override_catalog({"quoted.adopt_excel": ""})
self.assertEqual(copy.adopt_excel_ack(), "已确认采用该方案,正在生成 Excel 最终报价单…")
def test_group_file_saved_uses_admin(self) -> None:
override_catalog({"inquiring.group_file_saved": "附件已保存后台"})
self.assertEqual(
copy.quote_attachment_saved_app("WO202610060001", 2),
"附件已保存后台",
)
override_catalog({})
self.assertEqual(
copy.quote_attachment_saved_app("WO202610060001", 2),
"工单WO202610060001的2个附件已保存到后台工单",
)
def test_negotiate_without_date_still_reads_admin(self) -> None:
override_catalog(
{
"negotiating.recorded": (
"已记录为「协商中」。已顺延三个工作日,{{followup_when}}的9:00再次发起成交状态询问。"
)
}
)
text = copy.deal_ack("协商中", "WO1", next_followup_at="")
self.assertIn("第四个工作日", text)
self.assertIn("9:00", text)
def test_group_add_fail_empty_names_reads_admin(self) -> None:
override_catalog(
{"sys.group_add_fail": "无法添加 {{staff_name}} 同事,请联系管理员检查企业微信配置。"}
)
text = copy.system_exception_fallback(copy.TYPE_GROUP, names=[])
self.assertEqual(text, "无法添加同事,请联系管理员检查企业微信配置。")
named = copy.system_exception_fallback(copy.TYPE_GROUP, names=["张三"])
self.assertIn("张三", named)
if __name__ == "__main__":
unittest.main()
+23
View File
@@ -436,6 +436,29 @@ class ClarifyPasteTests(unittest.TestCase):
self.assertNotIn("包装方式", facts) self.assertNotIn("包装方式", facts)
self.assertNotEqual(facts.get("包装方式"), "托盘、散货") self.assertNotEqual(facts.get("包装方式"), "托盘、散货")
def test_blank_cargo_ready_does_not_take_next_item_number(self) -> None:
"""货好时间空着时,下一行「4.提货地址」的编号不能写进货好时间。示例行也不算填了。"""
text = """
1.包装类型(必填):托盘
(如:托盘、散货)
2.货源地(非必填):
(如:东莞)
3.货好时间(非必填):
(如:随时可提)
4.提货地址(非必填):
(如:东莞仓库)
5.收货地址(非必填):
(如:马尼拉)
6.贸易条款(非必填):
(如:DDP、DAP、CIF、CIP)
"""
facts = extract_inquiry_snapshot(text, allow_b=False)["facts"]
self.assertEqual(facts.get("包装方式") or facts.get("包装类型"), "托盘")
self.assertNotIn("货好时间", facts)
self.assertNotIn("提货地址", facts)
self.assertNotIn("货源地", facts)
self.assertNotIn("贸易条款", facts)
class ImageOptionalClipTests(unittest.TestCase): class ImageOptionalClipTests(unittest.TestCase):
def test_numbered_extra_lines_do_not_enter_fields(self) -> None: def test_numbered_extra_lines_do_not_enter_fields(self) -> None:
@@ -327,7 +327,13 @@ class LandGroupCollabTests(unittest.TestCase):
injected_intent=INTENT_ADJUST, injected_intent=INTENT_ADJUST,
) )
self.assertEqual(phase, "adjust_not_sales") self.assertEqual(phase, "adjust_not_sales")
self.assertIn("只有这张单的销售能调价", self._group_texts()) self.assertIn("只有这张单的销售能调价。产品请继续报价,价格未改。", self._group_texts())
def test_air_room_adjust_names_airline(self) -> None:
self.assertEqual(
copy.adjust_only_sales(air_room=True),
"只有这张单的销售能调价。航线请继续报价,价格未改。",
)
if __name__ == "__main__": if __name__ == "__main__":
+7
View File
@@ -220,9 +220,16 @@ class LandOptionsTest(unittest.TestCase):
def test_confirm_intent(self): def test_confirm_intent(self):
self.assertTrue(looks_like_confirm("确认")) self.assertTrue(looks_like_confirm("确认"))
self.assertTrue(looks_like_confirm("确定"))
self.assertTrue(looks_like_confirm("可以查了")) self.assertTrue(looks_like_confirm("可以查了"))
self.assertTrue(looks_like_confirm("查价")) self.assertTrue(looks_like_confirm("查价"))
self.assertFalse(looks_like_confirm("广州到深圳衣服")) self.assertFalse(looks_like_confirm("广州到深圳衣服"))
# 询价正文里的「确认」不是点核对卡。
self.assertFalse(
looks_like_confirm(
"服务器、不带电,预计7月初货好,因货值较高 派送需确认安全运输方案,比如押运。"
)
)
if __name__ == "__main__": if __name__ == "__main__":
+126 -2
View File
@@ -11,6 +11,10 @@ ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if ROOT not in sys.path: if ROOT not in sys.path:
sys.path.insert(0, ROOT) sys.path.insert(0, ROOT)
from unittest.mock import patch
from agent.channel.wecom.models import InboundMessage
from agent.handlers.group_collab import handle_group_inbound
from agent.ledger.memory_ledger import MemoryLedger from agent.ledger.memory_ledger import MemoryLedger
from agent.policy.multi_group_ops import deal_text, handle_multi_inbound from agent.policy.multi_group_ops import deal_text, handle_multi_inbound
from agent.policy.multi_segments import Segment from agent.policy.multi_segments import Segment
@@ -387,7 +391,8 @@ class QuoteTests(unittest.TestCase):
self.assertEqual(len(self.client.files), 2) self.assertEqual(len(self.client.files), 2)
self.assertTrue(self.ledger.last_render.get("ok")) self.assertTrue(self.ledger.last_render.get("ok"))
def test_lock_on_air_segment_does_not_wait_for_other_quotes(self) -> None: def test_lock_on_air_segment_says_multi_unsupported(self) -> None:
"""多段含空运时航线锁舱,原因写成暂不支持,不再说不是空运单。"""
created = self.ledger.create_ticket( created = self.ledger.create_ticket(
sender_id="sales", sender_id="sales",
business_line="MULTI", business_line="MULTI",
@@ -412,9 +417,128 @@ class QuoteTests(unittest.TestCase):
text=f"{no}锁舱", text=f"{no}锁舱",
sheet_fn=self.sheet, sheet_fn=self.sheet,
) )
self.assertEqual(phase, "lock_ok") self.assertEqual(phase, "multi_lock_unsupported")
joined = "\n".join(self.client.texts)
self.assertIn(f"工单{no} 空运锁舱失败。", joined)
self.assertIn("操作人:航线甲", joined)
self.assertIn("锁舱失败的原因:多段询价,暂时不支持锁舱", joined)
self.assertNotIn("不是空运单,不能锁舱", joined)
self.assertNotIn("已通知IT", joined)
self.assertNotIn("请稍等", joined)
self.assertFalse(any("其他段报完" in text for text in self.client.texts)) self.assertFalse(any("其他段报完" in text for text in self.client.texts))
class _Bot:
"""替掉真实桥,记下这一次群里实际会发出去的字。"""
made: list["_Bot"] = []
def __init__(self, *args: object, **kwargs: object) -> None:
_ = args, kwargs
self.texts: list[str] = []
_Bot.made.append(self)
def send_group(self, *, chat_id: str, content: str, mention_userids: list[str] | None = None) -> dict:
_ = chat_id, mention_userids
self.texts.append(content)
return {"ok": True}
def send_group_file(self, **kwargs: object) -> dict:
_ = kwargs
return {"ok": True}
class ArchiveSenderTests(unittest.TestCase):
"""会话存档听到的报价:含空运走机器人,海运陆运仍走应用自己建的群。"""
def setUp(self) -> None:
_Bot.made = []
self.ledger = MemoryLedger()
self.app = _Client()
self.flow = _Flow(self.ledger, self.app)
def _message(self, chat_id: str, sender_id: str, text: str) -> InboundMessage:
return InboundMessage(
sender_id=sender_id,
message_id="m1",
content=text,
chat_id=chat_id,
chat_type="group",
)
def test_air_multi_quote_goes_through_bot(self) -> None:
created = self.ledger.create_ticket(
sender_id="sales",
business_line="MULTI",
facts={
"segments_json": json.dumps(
[
{"index": 1, "mode": "LAND", "facts": {"线路类别": "国内长途/零担"}},
{"index": 2, "mode": "AIR", "facts": {"目的港": "北宁"}},
],
ensure_ascii=False,
)
},
)
self.ledger.upsert_staff({"wecomId": "sales", "name": "销售甲", "roleCode": "sales", "status": "active"})
self.ledger.upsert_staff({"wecomId": "air1", "name": "航线甲", "roleCode": "air", "status": "active"})
no = created["work_order_no"]
self.ledger.bind_collab_group(
work_order_no=no,
chat_id="bot-room",
member_ids=["sales", "air1"],
product_names=["航线甲"],
product_ids=["air1"],
)
with patch("agent.policy.multi_group_ops.send_filled_sheet", return_value={"ok": True}):
with patch("agent.channel.aibot.reply.AibotReplyClient", _Bot):
phase = handle_group_inbound(
self._message("bot-room", "air1", "空运费 9900CNY"),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "segment_quoted")
self.assertFalse(self.app.texts)
self.assertTrue(_Bot.made)
self.assertTrue(any("报价单已发出" in text for text in _Bot.made[-1].texts))
def test_sea_land_quote_stays_on_app_client(self) -> None:
created = self.ledger.create_ticket(
sender_id="sales",
business_line="MULTI",
facts={
"segments_json": json.dumps(
[
{"index": 1, "mode": "SEA", "facts": {"起运港": "广州港", "目的港": "巴生港"}},
{"index": 2, "mode": "LAND", "facts": {"线路类别": "国内长途/零担"}},
],
ensure_ascii=False,
)
},
)
self.ledger.set_sea_staff(
[{"wecomId": "sea1", "name": "海产", "roleCode": "sea", "status": "active", "routes": "海运·全部线路"}]
)
self.ledger.upsert_staff({"wecomId": "sales", "name": "销售甲", "roleCode": "sales", "status": "active"})
no = created["work_order_no"]
self.ledger.bind_collab_group(
work_order_no=no,
chat_id="app-room",
member_ids=["sales", "sea1"],
product_names=["海产"],
product_ids=["sea1"],
)
with patch("agent.policy.multi_group_ops.send_filled_sheet", return_value={"ok": True}):
with patch("agent.channel.aibot.reply.AibotReplyClient", _Bot):
phase = handle_group_inbound(
self._message("app-room", "sea1", "海运费 100CNY"),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "segment_quoted")
self.assertFalse(_Bot.made)
self.assertTrue(any("报价单已发出" in text for text in self.app.texts))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -379,6 +379,9 @@ class TestConfirmSystemException(unittest.TestCase):
) )
card = queued[0]["payload"]["template_card"] card = queued[0]["payload"]["template_card"]
values = {row["keyname"]: row["value"] for row in card["horizontal_content_list"]} values = {row["keyname"]: row["value"] for row in card["horizontal_content_list"]}
self.assertEqual(values["报错原因"], "LOCK_FAIL")
self.assertNotIn("错误码", values)
self.assertIn("报错原因:LOCK_FAIL", queued[0]["content"])
self.assertEqual(values["影响范围"], "锁舱失败,工单状态保持不变") self.assertEqual(values["影响范围"], "锁舱失败,工单状态保持不变")
self.assertEqual(card["main_title"]["title"], "系统异常通知") self.assertEqual(card["main_title"]["title"], "系统异常通知")
@@ -44,12 +44,28 @@ public class InquiryAgentController {
@Autowired @Autowired
private org.jeecg.modules.inquiry.tms.InquiryTmsCabinService cabinService; private org.jeecg.modules.inquiry.tms.InquiryTmsCabinService cabinService;
@Autowired
private org.jeecg.modules.inquiry.service.IInquiryBotReplyService botReplyService;
@Value("${inquiry.agent.callback-token:${AGENT_CALLBACK_TOKEN:}}") @Value("${inquiry.agent.callback-token:${AGENT_CALLBACK_TOKEN:}}")
private String agentToken; private String agentToken;
@Value("${inquiry.agent.biz-token:${BIZ_API_TOKEN:}}") @Value("${inquiry.agent.biz-token:${BIZ_API_TOKEN:}}")
private String bizToken; private String bizToken;
/**
* 智能体只读回复语。停用或不在列表里的编码由智能体回退默认句。
* 鉴权同其它 /inquiry/agent/** ,禁止后台菜单权限码。
*/
@ApiOperation("智能体-已启用回复语")
@GetMapping("/botReply/listEnabled")
public Result<java.util.List<java.util.Map<String, Object>>> botReplyListEnabled(HttpServletRequest http) {
if (!authed(http)) {
return Result.error(401, "unauthorized");
}
return Result.OK(botReplyService.listEnabledForAgent());
}
@ApiOperation("智能体-回写模板 AI 映射结果") @ApiOperation("智能体-回写模板 AI 映射结果")
@PutMapping("/quoteTemplate/parseResult") @PutMapping("/quoteTemplate/parseResult")
public Result<String> quoteTemplateParseResult(@RequestBody Map<String, Object> body, public Result<String> quoteTemplateParseResult(@RequestBody Map<String, Object> body,
@@ -21,7 +21,7 @@ import java.util.List;
/** /**
* 机器人回复语 API。 * 机器人回复语 API。
* 支持按分类拉取全部条目(后台九分类页)与分页查询。 * 支持按分类拉取全部条目(后台九分类页)与分页查询。
* 调用编码 replyCode 给智能体日后按码取文案;本期只维护、不向智能体下发。 * 调用编码 replyCode 供智能体 GET /inquiry/agent/botReply/listEnabled 按码取文案。
*/ */
@Slf4j @Slf4j
@Api(tags = "询价-机器人回复语") @Api(tags = "询价-机器人回复语")
@@ -123,7 +123,7 @@ public class InquiryTicket implements Serializable {
private List<InquiryPriceLine> saleLines; private List<InquiryPriceLine> saleLines;
/** /**
* 利润点逐行。不入库。没调过价为空。最后一行是整单利润点。 * 利润点逐行。不入库。没调过价每项都是 0%。最后一行是整单利润点。
*/ */
@TableField(exist = false) @TableField(exist = false)
@ApiModelProperty("利润点逐行(展示)") @ApiModelProperty("利润点逐行(展示)")
@@ -22,4 +22,10 @@ public interface IInquiryBotReplyService extends IService<InquiryBotReply> {
* 编辑正文/启停。已有调用编码不可被请求覆盖。 * 编辑正文/启停。已有调用编码不可被请求覆盖。
*/ */
void updateReply(InquiryBotReply body); void updateReply(InquiryBotReply body);
/**
* 智能体只读:已启用且已有调用编码的正文。
* 停用项不返回,智能体回退默认句。
*/
java.util.List<java.util.Map<String, Object>> listEnabledForAgent();
} }
@@ -9,7 +9,13 @@ import org.jeecg.modules.inquiry.service.IInquiryBotReplyService;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import java.util.ArrayList;
import java.util.Date; import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern; import java.util.regex.Pattern;
/** /**
@@ -90,6 +96,35 @@ public class InquiryBotReplyServiceImpl extends ServiceImpl<InquiryBotReplyMappe
body.setReplyCode(code); body.setReplyCode(code);
} }
@Override
public List<Map<String, Object>> listEnabledForAgent() {
QueryWrapper<InquiryBotReply> qw = new QueryWrapper<>();
qw.eq("enabled", 1);
qw.isNotNull("reply_code");
qw.ne("reply_code", "");
qw.orderByAsc("sort_no").orderByAsc("id");
Page<InquiryBotReply> page = new Page<>(1, 2000);
page.setSearchCount(false);
page.setMaxLimit(2000L);
List<InquiryBotReply> rows = page(page, qw).getRecords();
List<Map<String, Object>> out = new ArrayList<>();
if (rows == null) {
return out;
}
for (InquiryBotReply row : rows) {
if (row == null || StringUtils.isBlank(row.getReplyCode())) {
continue;
}
Map<String, Object> item = new LinkedHashMap<>();
item.put("replyCode", row.getReplyCode());
item.put("replyText", row.getReplyText());
item.put("triggerScene", row.getTriggerScene());
item.put("categoryCode", row.getCategoryCode());
out.add(item);
}
return out;
}
private void assertCodeUnique(String replyCode, String excludeId) { private void assertCodeUnique(String replyCode, String excludeId) {
if (StringUtils.isBlank(replyCode)) { if (StringUtils.isBlank(replyCode)) {
return; return;
@@ -686,7 +686,7 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
return; return;
} }
String username = StringUtils.trimToEmpty(login.getUsername()); String username = StringUtils.trimToEmpty(login.getUsername());
String wecom = StringUtils.trimToEmpty(login.getWorkNo()); String wecom = dashboardWecomId(login);
qw.and(w -> { qw.and(w -> {
boolean any = false; boolean any = false;
if (StringUtils.isNotBlank(wecom)) { if (StringUtils.isNotBlank(wecom)) {
@@ -734,4 +734,20 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
return false; return false;
} }
} }
/**
* 看板「只看自己」用的企微工号。
* 不用直接调 getWorkNo:测服打包时基座 LoginUser 可能还没有这个方法。
*/
private static String dashboardWecomId(LoginUser login) {
if (login == null) {
return "";
}
try {
Object v = login.getClass().getMethod("getWorkNo").invoke(login);
return v == null ? "" : StringUtils.trimToEmpty(String.valueOf(v));
} catch (ReflectiveOperationException ignored) {
return "";
}
}
} }
@@ -19,7 +19,9 @@ import java.util.List;
* <p> * <p>
* 职责:从已保存的报价明细铺开展示。不改调价算法,不把各行金额加总冒充费用合计。 * 职责:从已保存的报价明细铺开展示。不改调价算法,不把各行金额加总冒充费用合计。
* 海运/陆运:成本是调价前生效价(手工同名盖 TMS,多出来的补上);销售是最新调价。 * 海运/陆运:成本是调价前生效价(手工同名盖 TMS,多出来的补上);销售是最新调价。
* 空运:成本整列空;销售是当前生效价;利润点只在调过价之后,对调价前销售价。 * 空运:成本整列空;销售是当前生效价。
* 未调价:各项利润点与费用合计都是 0%,禁止拿金额去填利润点列。
* 已调价:利润点对调价前同名行。
* 副作用:改 ticket 的三列逐行;手工盖价算出新的官方总价时,同时改成本价和利润点字符串。不写库。 * 副作用:改 ticket 的三列逐行;手工盖价算出新的官方总价时,同时改成本价和利润点字符串。不写库。
* 调用:{@link InquiryTicketPriceSupport#apply} 末尾。列表 enrich 与保存报价后的 update 都走那里。 * 调用:{@link InquiryTicketPriceSupport#apply} 末尾。列表 enrich 与保存报价后的 update 都走那里。
*/ */
@@ -126,7 +128,7 @@ public final class InquiryQuoteLineSupport {
profitTotal = StringUtils.defaultString(InquiryTicketPriceSupport.profitText(saleTotal, baseTotal)); profitTotal = StringUtils.defaultString(InquiryTicketPriceSupport.profitText(saleTotal, baseTotal));
ticket.setProfitRate(profitTotal); ticket.setProfitRate(profitTotal);
} else if (!adjusted) { } else if (!adjusted) {
ticket.setProfitRate(""); ticket.setProfitRate("0%");
} }
if (air) { if (air) {
@@ -134,13 +136,13 @@ public final class InquiryQuoteLineSupport {
ticket.setSaleLines(moneyLines(saleFees, saleTotal)); ticket.setSaleLines(moneyLines(saleFees, saleTotal));
ticket.setProfitLines(adjusted && StringUtils.isNotBlank(profitTotal) ticket.setProfitLines(adjusted && StringUtils.isNotBlank(profitTotal)
? profitAgainst(baselineFees, saleFees, profitTotal) ? profitAgainst(baselineFees, saleFees, profitTotal)
: Collections.<InquiryPriceLine>emptyList()); : zeroPercentLines(saleFees));
return; return;
} }
if (!adjusted) { if (!adjusted) {
ticket.setCostLines(moneyLines(costFees, costTotal)); ticket.setCostLines(moneyLines(costFees, costTotal));
ticket.setSaleLines(Collections.<InquiryPriceLine>emptyList()); ticket.setSaleLines(Collections.<InquiryPriceLine>emptyList());
ticket.setProfitLines(Collections.<InquiryPriceLine>emptyList()); ticket.setProfitLines(zeroPercentLines(costFees));
return; return;
} }
List<Aligned> rows = align(costFees, saleFees); List<Aligned> rows = align(costFees, saleFees);
@@ -216,6 +218,21 @@ public final class InquiryQuoteLineSupport {
return formatMoney(base.add(delta), totalCcy); return formatMoney(base.add(delta), totalCcy);
} }
/**
* 未调价:每一项费用和费用合计都写 0%。
* 列表/详情都走这里,避免利润点列空白或误把金额填进去。
*/
private static List<InquiryPriceLine> zeroPercentLines(List<FeeRow> fees) {
List<InquiryPriceLine> lines = new ArrayList<InquiryPriceLine>();
if (fees != null) {
for (FeeRow fee : fees) {
lines.add(line(fee.name, "0%"));
}
}
lines.add(line(TOTAL_NAME, "0%"));
return lines;
}
private static List<InquiryPriceLine> moneyLines(List<FeeRow> fees, String totalText) { private static List<InquiryPriceLine> moneyLines(List<FeeRow> fees, String totalText) {
List<InquiryPriceLine> lines = new ArrayList<InquiryPriceLine>(); List<InquiryPriceLine> lines = new ArrayList<InquiryPriceLine>();
if (fees != null) { if (fees != null) {
@@ -14,7 +14,8 @@ import java.util.regex.Pattern;
* 后台成本价 / 销售价 / 利润点:按业务线从报价记录重算。 * 后台成本价 / 销售价 / 利润点:按业务线从报价记录重算。
* <p> * <p>
* 海运/陆运:TMS、产品报价是成本;销售调价后才有销售价,利润点=(销售-成本)/成本。 * 海运/陆运:TMS、产品报价是成本;销售调价后才有销售价,利润点=(销售-成本)/成本。
* 空运:成本价始终空;TMS/航线/调价都是销售侧;利润点只在销售调过价后算。 * 空运:成本价始终空;TMS/航线/调价都是销售侧。
* 未调价时利润点固定 0%(含各项),调过价才按差额算。
* 副作用:只改传入 ticket 的展示字段(三个总价字符串和三列逐行),不写库。调用方决定是否 updateById。 * 副作用:只改传入 ticket 的展示字段(三个总价字符串和三列逐行),不写库。调用方决定是否 updateById。
*/ */
public final class InquiryTicketPriceSupport { public final class InquiryTicketPriceSupport {
@@ -63,7 +64,7 @@ public final class InquiryTicketPriceSupport {
ticket.setProfitRate(profitText(adjustPrice, baseline)); ticket.setProfitRate(profitText(adjustPrice, baseline));
} else { } else {
ticket.setSalePrice(StringUtils.isNotBlank(manualPrice) ? manualPrice : tmsPrice); ticket.setSalePrice(StringUtils.isNotBlank(manualPrice) ? manualPrice : tmsPrice);
ticket.setProfitRate(""); ticket.setProfitRate("0%");
} }
} else { } else {
String cost = StringUtils.isNotBlank(manualPrice) ? manualPrice : tmsPrice; String cost = StringUtils.isNotBlank(manualPrice) ? manualPrice : tmsPrice;
@@ -73,7 +74,7 @@ public final class InquiryTicketPriceSupport {
ticket.setProfitRate(profitText(adjustPrice, cost)); ticket.setProfitRate(profitText(adjustPrice, cost));
} else { } else {
ticket.setSalePrice(""); ticket.setSalePrice("");
ticket.setProfitRate(""); ticket.setProfitRate("0%");
} }
} }
// 三个总价定完后,按同一批报价拆成逐行。有手工明细盖 TMS 时,这里会改写成本价和利润点。 // 三个总价定完后,按同一批报价拆成逐行。有手工明细盖 TMS 时,这里会改写成本价和利润点。
@@ -51,6 +51,24 @@ public class InquiryTmsCabinService {
@Lazy @Lazy
private InquiryTmsQueryService queryService; private InquiryTmsQueryService queryService;
/** 多段询价锁舱的产品口径。智能体群回复与主账拒绝用同一句。 */
public static final String MULTI_LOCK_UNSUPPORTED = "多段询价,暂时不支持锁舱";
/**
* 还没到 TMS 之前的拒绝原因。空运单返回空,表示可以继续锁。
* 多段整单不是 AIR,但航线会按空运段来锁,单独说明暂不支持。
*/
static String lockRejectReason(String businessLine) {
String line = StringUtils.trimToEmpty(businessLine);
if ("MULTI".equalsIgnoreCase(line)) {
return MULTI_LOCK_UNSUPPORTED;
}
if (!"AIR".equalsIgnoreCase(line)) {
return "不是空运单,不能锁舱";
}
return "";
}
/** /**
* 智能体锁舱入口。 * 智能体锁舱入口。
*/ */
@@ -61,8 +79,11 @@ public class InquiryTmsCabinService {
if (ticket == null) { if (ticket == null) {
return fail("ticket_not_found", "找不到工单"); return fail("ticket_not_found", "找不到工单");
} }
if (!"AIR".equalsIgnoreCase(StringUtils.trimToEmpty(ticket.getBusinessLine()))) { // 多段整单业务线是 MULTI。含空运段时航线仍会来锁舱,不能再说「不是空运单」。
return fail("not_air", "不是空运单,不能锁舱"); String lockReject = lockRejectReason(ticket.getBusinessLine());
if (StringUtils.isNotBlank(lockReject)) {
String code = MULTI_LOCK_UNSUPPORTED.equals(lockReject) ? "multi_no_lock" : "not_air";
return fail(code, lockReject);
} }
if (TicketStatuses.DEAL.equals(ticket.getStatus()) if (TicketStatuses.DEAL.equals(ticket.getStatus())
|| TicketStatuses.CLOSED.equals(ticket.getStatus())) { || TicketStatuses.CLOSED.equals(ticket.getStatus())) {
@@ -689,7 +689,7 @@ public class InquiryTmsQueryService {
/** /**
* 把本次 TMS 报价写回工单主表,供后台列表/详情展示。 * 把本次 TMS 报价写回工单主表,供后台列表/详情展示。
* 海运/陆运进成本价,空运进销售价;利润点必须等销售调价后才算。 * 海运/陆运进成本价,空运进销售价;未调价利润点为 0%,调过价才按差额算。
*/ */
static void applyQuoteToTicket(InquiryTicket ticket, Map<String, String> quote) { static void applyQuoteToTicket(InquiryTicket ticket, Map<String, String> quote) {
if (ticket == null || quote == null) { if (ticket == null || quote == null) {
@@ -28,13 +28,16 @@ public class InquiryQuoteLineSupportTest {
+ "]}"))); + "]}")));
assertEquals("CNY 1200.00", ticket.getCostPrice()); assertEquals("CNY 1200.00", ticket.getCostPrice());
assertEquals("", ticket.getSalePrice()); assertEquals("", ticket.getSalePrice());
assertEquals("", ticket.getProfitRate()); assertEquals("0%", ticket.getProfitRate());
assertLines(ticket.getCostLines(), assertLines(ticket.getCostLines(),
"运输费", "CNY 1000", "运输费", "CNY 1000",
"卸货费", "CNY 200", "卸货费", "CNY 200",
"费用合计", "CNY 1200.00"); "费用合计", "CNY 1200.00");
assertTrue(ticket.getSaleLines() == null || ticket.getSaleLines().isEmpty()); assertTrue(ticket.getSaleLines() == null || ticket.getSaleLines().isEmpty());
assertTrue(ticket.getProfitLines() == null || ticket.getProfitLines().isEmpty()); assertLines(ticket.getProfitLines(),
"运输费", "0%",
"卸货费", "0%",
"费用合计", "0%");
} }
@Test @Test
@@ -113,7 +116,12 @@ public class InquiryQuoteLineSupportTest {
assertTrue(before.getCostLines() == null || before.getCostLines().isEmpty()); assertTrue(before.getCostLines() == null || before.getCostLines().isEmpty());
assertEquals("", before.getCostPrice()); assertEquals("", before.getCostPrice());
assertEquals("CNY 2000.00", before.getSalePrice()); assertEquals("CNY 2000.00", before.getSalePrice());
assertTrue(before.getProfitLines() == null || before.getProfitLines().isEmpty()); assertEquals("0%", before.getProfitRate());
assertLines(before.getProfitLines(),
"空运费", "0%",
"增点费", "0%",
"拆板费", "0%",
"费用合计", "0%");
assertLines(before.getSaleLines(), assertLines(before.getSaleLines(),
"空运费", "CNY 1800", "空运费", "CNY 1800",
"增点费", "CNY 100", "增点费", "CNY 100",
@@ -189,6 +197,11 @@ public class InquiryQuoteLineSupportTest {
"费用合计", "CNY 1320.00"); "费用合计", "CNY 1320.00");
assertEquals("CNY 1320.00", ticket.getCostPrice()); assertEquals("CNY 1320.00", ticket.getCostPrice());
assertTrue(ticket.getSaleLines() == null || ticket.getSaleLines().isEmpty()); assertTrue(ticket.getSaleLines() == null || ticket.getSaleLines().isEmpty());
assertLines(ticket.getProfitLines(),
"运输费", "0%",
"卸货费", "0%",
"配送费", "0%",
"费用合计", "0%");
} }
private static void assertLines(List<InquiryPriceLine> lines, String... nameText) { private static void assertLines(List<InquiryPriceLine> lines, String... nameText) {
@@ -21,7 +21,7 @@ public class InquiryTicketPriceSupportTest {
InquiryTicketPriceSupport.apply(ticket, Collections.singletonList(quote("TMS", "TMS标准报价", "CNY 121.00"))); InquiryTicketPriceSupport.apply(ticket, Collections.singletonList(quote("TMS", "TMS标准报价", "CNY 121.00")));
assertEquals("CNY 121.00", ticket.getCostPrice()); assertEquals("CNY 121.00", ticket.getCostPrice());
assertEquals("", ticket.getSalePrice()); assertEquals("", ticket.getSalePrice());
assertEquals("", ticket.getProfitRate()); assertEquals("0%", ticket.getProfitRate());
} }
@Test @Test
@@ -53,7 +53,7 @@ public class InquiryTicketPriceSupportTest {
InquiryTicketPriceSupport.apply(ticket, Collections.singletonList(quote("TMS", "TMS标准报价", "USD 1280"))); InquiryTicketPriceSupport.apply(ticket, Collections.singletonList(quote("TMS", "TMS标准报价", "USD 1280")));
assertEquals("", ticket.getCostPrice()); assertEquals("", ticket.getCostPrice());
assertEquals("USD 1280", ticket.getSalePrice()); assertEquals("USD 1280", ticket.getSalePrice());
assertEquals("", ticket.getProfitRate()); assertEquals("0%", ticket.getProfitRate());
} }
@Test @Test
@@ -1543,6 +1543,9 @@ public class QuoteTemplateFillTest {
matrix.getCell(15).setCellStyle(matrixBlue); matrix.getCell(15).setCellStyle(matrixBlue);
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(7, 8, 2, 2)); sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(7, 8, 2, 2));
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(7, 8, 9, 9)); sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(7, 8, 9, 9));
org.apache.poi.ss.usermodel.Row sub = row(sheet, 8);
sub.createCell(3).setCellValue("最低收费");
sub.createCell(4).setCellValue("元/KG");
org.apache.poi.ss.usermodel.Font red = wb.createFont(); org.apache.poi.ss.usermodel.Font red = wb.createFont();
red.setColor(org.apache.poi.ss.usermodel.IndexedColors.RED.getIndex()); red.setColor(org.apache.poi.ss.usermodel.IndexedColors.RED.getIndex());
org.apache.poi.ss.usermodel.CellStyle redOnSky = wb.createCellStyle(); org.apache.poi.ss.usermodel.CellStyle redOnSky = wb.createCellStyle();
@@ -1553,6 +1556,14 @@ public class QuoteTemplateFillTest {
currencyHead.setCellStyle(redOnSky); currencyHead.setCellStyle(redOnSky);
matrix.getCell(10).setCellStyle(redOnSky); matrix.getCell(10).setCellStyle(redOnSky);
org.apache.poi.ss.usermodel.Row data = row(sheet, 9); org.apache.poi.ss.usermodel.Row data = row(sheet, 9);
org.apache.poi.ss.usermodel.Font blackData = wb.createFont();
blackData.setFontName("宋体");
blackData.setFontHeightInPoints((short) 10);
blackData.setColor(org.apache.poi.ss.usermodel.IndexedColors.BLACK.getIndex());
org.apache.poi.ss.usermodel.CellStyle priceStyle = wb.createCellStyle();
priceStyle.setFont(blackData);
data.createCell(4).setCellValue(12);
data.getCell(4).setCellStyle(priceStyle);
org.apache.poi.ss.usermodel.Cell dest = data.createCell(2); org.apache.poi.ss.usermodel.Cell dest = data.createCell(2);
dest.setCellStyle(redOnSky); dest.setCellStyle(redOnSky);
org.apache.poi.ss.usermodel.Row delivery = row(sheet, 18); org.apache.poi.ss.usermodel.Row delivery = row(sheet, 18);
@@ -1610,6 +1621,9 @@ public class QuoteTemplateFillTest {
assertEquals(org.apache.poi.ss.usermodel.VerticalAlignment.CENTER, assertEquals(org.apache.poi.ss.usermodel.VerticalAlignment.CENTER,
sheet.getRow(9).getCell(2).getCellStyle().getVerticalAlignment()); sheet.getRow(9).getCell(2).getCellStyle().getVerticalAlignment());
assertFalse(QuoteTemplateExcelSupport.fontIsRed(sheet.getRow(9).getCell(2))); assertFalse(QuoteTemplateExcelSupport.fontIsRed(sheet.getRow(9).getCell(2)));
assertFalse(sheet.getRow(9).getCell(2).getCellStyle().getShrinkToFit());
assertEquals(QuoteTemplateExcelSupport.fontHeight(sheet.getRow(9).getCell(4)),
QuoteTemplateExcelSupport.fontHeight(sheet.getRow(9).getCell(2)));
assertEquals("始发地", read(sheet, 7, 1)); assertEquals("始发地", read(sheet, 7, 1));
assertEquals("币种", read(sheet, 7, 9)); assertEquals("币种", read(sheet, 7, 9));
byte[] originFill = QuoteTemplateExcelSupport.readFillRgb(sheet.getRow(7).getCell(1)); byte[] originFill = QuoteTemplateExcelSupport.readFillRgb(sheet.getRow(7).getCell(1));
@@ -1678,6 +1692,133 @@ public class QuoteTemplateFillTest {
} }
} }
/**
* 跨境整车:列头是「起始地」,城市必须写在下一行白格,不能盖掉列名。
*/
@Test
public void landCrossBorderFtlKeepsOriginHeader() throws Exception {
Path src = Files.createTempFile("quote-land-cb-ftl-src", ".xlsx");
Path out = Files.createTempFile("quote-land-cb-ftl-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("跨境整车");
org.apache.poi.ss.usermodel.Row top = row(sheet, 6);
top.createCell(1).setCellValue("路线");
top.createCell(3).setCellValue("车型");
org.apache.poi.ss.usermodel.Row head = row(sheet, 7);
head.createCell(1).setCellValue("起始地");
head.createCell(2).setCellValue("目的地");
head.createCell(3).setCellValue("3T");
head.createCell(4).setCellValue("5T");
head.createCell(5).setCellValue("8T");
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject mapping = new JSONObject(true);
mapping.put("sheet", "跨境整车");
JSONObject fields = new JSONObject(true);
fields.put("origin", "跨境整车!B8");
fields.put("destination", "跨境整车!C8");
mapping.put("fields", fields);
JSONObject facts = new JSONObject(true);
facts.put("origin", "郑州");
facts.put("destination", "河内");
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled.getSheetAt(0);
assertEquals("起始地", read(sheet, 7, 1));
assertEquals("目的地", read(sheet, 7, 2));
assertEquals("郑州", read(sheet, 8, 1));
assertEquals("河内", read(sheet, 8, 2));
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
/**
* 跨境整车国内段只写境内起点,境外段只写境外终点,不能两段都写成郑州→吉隆坡。
*/
@Test
public void landCrossBorderFtlSplitsDomesticAndOverseasOd() throws Exception {
Path src = Files.createTempFile("quote-land-cb-split-src", ".xlsx");
Path out = Files.createTempFile("quote-land-cb-split-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("跨境整车");
row(sheet, 5).createCell(0).setCellValue("一、国内段报价");
org.apache.poi.ss.usermodel.Row head1 = row(sheet, 7);
head1.createCell(1).setCellValue("起始地");
head1.createCell(2).setCellValue("目的地");
head1.createCell(3).setCellValue("3T");
head1.createCell(4).setCellValue("5T");
head1.createCell(15).setCellValue("时效");
head1.createCell(16).setCellValue("币种");
head1.createCell(17).setCellValue("燃油附加费率");
head1.createCell(18).setCellValue("含税合计");
row(sheet, 24).createCell(0).setCellValue("二、境外段报价");
org.apache.poi.ss.usermodel.Row head2 = row(sheet, 26);
head2.createCell(1).setCellValue("起始地");
head2.createCell(2).setCellValue("目的地");
head2.createCell(3).setCellValue("1.25T");
head2.createCell(4).setCellValue("2.5T");
head2.createCell(15).setCellValue("时效");
head2.createCell(16).setCellValue("币种");
head2.createCell(17).setCellValue("燃油附加费率");
head2.createCell(18).setCellValue("含税合计");
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject mapping = new JSONObject(true);
mapping.put("sheet", "跨境整车");
JSONArray regions = new JSONArray();
JSONObject inland = new JSONObject(true);
inland.put("title", "国内段报价");
inland.put("headerRow", 8);
inland.put("dataStartRow", 8);
JSONObject cols1 = new JSONObject(true);
cols1.put("origin", "B");
cols1.put("destination", "C");
inland.put("columns", cols1);
regions.add(inland);
JSONObject overseas = new JSONObject(true);
overseas.put("title", "境外段报价");
overseas.put("headerRow", 27);
overseas.put("dataStartRow", 27);
JSONObject cols2 = new JSONObject(true);
cols2.put("origin", "B");
cols2.put("destination", "C");
overseas.put("columns", cols2);
regions.add(overseas);
mapping.put("routeRegions", regions);
JSONObject facts = new JSONObject(true);
facts.put("origin", "郑州");
facts.put("destination", "吉隆坡");
facts.put("currency", "CNY");
facts.put("transit_days", "N+3");
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled.getSheetAt(0);
assertEquals("起始地", read(sheet, 7, 1));
assertEquals("郑州", read(sheet, 8, 1));
assertEquals("", read(sheet, 8, 2));
assertEquals("起始地", read(sheet, 26, 1));
assertEquals("", read(sheet, 27, 1));
assertEquals("吉隆坡", read(sheet, 27, 2));
assertEquals("N+3", read(sheet, 8, 15));
assertEquals("CNY", read(sheet, 8, 16));
assertEquals("", read(sheet, 8, 17));
assertEquals("", read(sheet, 8, 18));
assertEquals("N+3", read(sheet, 27, 15));
assertEquals("CNY", read(sheet, 27, 16));
assertEquals("", read(sheet, 27, 17));
assertEquals("", read(sheet, 27, 18));
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
/** /**
* 国内整车:4.2米/3T 这类车型费用也是标签右侧,不是海运费目表。 * 国内整车:4.2米/3T 这类车型费用也是标签右侧,不是海运费目表。
*/ */
@@ -1967,6 +2108,113 @@ public class QuoteTemplateFillTest {
} }
} }
/**
* 国内拼车:元/CBM、元/KG 必须跟「始发站」同一浅蓝,不能留成顶栏深蓝。
*/
@Test
public void landDomesticLtlUnitHeaderRowIsKept() throws Exception {
Path src = Files.createTempFile("quote-land-ltl-src", ".xlsx");
Path out = Files.createTempFile("quote-land-ltl-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("国内拼车");
org.apache.poi.ss.usermodel.CellStyle sky = wb.createCellStyle();
sky.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.SKY_BLUE.getIndex());
sky.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND);
org.apache.poi.ss.usermodel.CellStyle navy = wb.createCellStyle();
navy.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.ROYAL_BLUE.getIndex());
navy.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND);
org.apache.poi.ss.usermodel.Row head = row(sheet, 7);
head.createCell(1).setCellValue("始发站");
head.getCell(1).setCellStyle(sky);
head.createCell(2).setCellValue("目的地");
head.getCell(2).setCellStyle(sky);
head.createCell(5).setCellValue("运输费");
head.getCell(5).setCellStyle(sky);
org.apache.poi.ss.usermodel.Row units = row(sheet, 8);
units.createCell(5).setCellValue("元/CBM");
units.getCell(5).setCellStyle(navy);
units.createCell(6).setCellValue("元/KG");
units.getCell(6).setCellStyle(navy);
row(sheet, 9);
boolean[] keep = QuoteTemplateExcelSupport.markLandHeaderRows(sheet);
assertTrue(keep[7]);
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject facts = new JSONObject(true);
facts.put("origin", "上海");
facts.put("destination", "江苏南京市");
facts.put("price_per_cbm", "100");
QuoteTemplateExcelSupport.fillWorkbook(src, out, new JSONObject(), facts);
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled.getSheetAt(0);
assertEquals("元/CBM", read(sheet, 8, 5));
assertEquals("元/KG", read(sheet, 8, 6));
byte[] unitRgb = QuoteTemplateExcelSupport.readFillRgb(sheet.getRow(8).getCell(5));
byte[] headRgb = QuoteTemplateExcelSupport.readFillRgb(sheet.getRow(7).getCell(1));
assertTrue(unitRgb != null && unitRgb.length >= 3);
assertTrue(headRgb != null && headRgb.length >= 3);
assertArrayEquals(headRgb, unitRgb);
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
/**
* 国内整车:车型格「4.2米/3T」是浅蓝表头,出单后不能变成业务员那种深蓝。
*/
@Test
public void landDomesticFtlVehicleHeaderFillSurvives() throws Exception {
Path src = Files.createTempFile("quote-land-ftl-src", ".xlsx");
Path out = Files.createTempFile("quote-land-ftl-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("国内整车");
org.apache.poi.ss.usermodel.CellStyle sky = wb.createCellStyle();
sky.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.SKY_BLUE.getIndex());
sky.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND);
org.apache.poi.ss.usermodel.CellStyle navy = wb.createCellStyle();
navy.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.ROYAL_BLUE.getIndex());
navy.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND);
org.apache.poi.ss.usermodel.Row head = row(sheet, 7);
head.createCell(1).setCellValue("始发站");
head.getCell(1).setCellStyle(sky);
head.createCell(2).setCellValue("目的地");
head.getCell(2).setCellStyle(sky);
head.createCell(3).setCellValue("车型/费用");
head.getCell(3).setCellStyle(sky);
head.createCell(8).setCellValue("时效");
head.getCell(8).setCellStyle(sky);
org.apache.poi.ss.usermodel.Row veh = row(sheet, 8);
veh.createCell(3).setCellValue("4.2米/3T");
veh.getCell(3).setCellStyle(navy);
veh.createCell(4).setCellValue("7.6米/8T");
veh.getCell(4).setCellStyle(navy);
row(sheet, 9);
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject facts = new JSONObject(true);
facts.put("origin", "北京");
facts.put("destination", "上海");
QuoteTemplateExcelSupport.fillWorkbook(src, out, new JSONObject(), facts);
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled.getSheetAt(0);
assertEquals("4.2米/3T", read(sheet, 8, 3));
assertEquals("7.6米/8T", read(sheet, 8, 4));
byte[] vehRgb = QuoteTemplateExcelSupport.readFillRgb(sheet.getRow(8).getCell(3));
byte[] headRgb = QuoteTemplateExcelSupport.readFillRgb(sheet.getRow(7).getCell(1));
assertTrue(vehRgb != null && vehRgb.length >= 3);
assertTrue(headRgb != null && headRgb.length >= 3);
assertArrayEquals(headRgb, vehRgb);
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
private static org.apache.poi.ss.usermodel.Row row(Sheet sheet, int r) { private static org.apache.poi.ss.usermodel.Row row(Sheet sheet, int r) {
org.apache.poi.ss.usermodel.Row exist = sheet.getRow(r); org.apache.poi.ss.usermodel.Row exist = sheet.getRow(r);
return exist == null ? sheet.createRow(r) : exist; return exist == null ? sheet.createRow(r) : exist;
@@ -15,6 +15,15 @@ import static org.junit.jupiter.api.Assertions.assertNull;
*/ */
public class InquiryTmsCabinServiceTest { public class InquiryTmsCabinServiceTest {
@Test
public void multiInquiryCannotLockCabin() {
assertEquals(
"多段询价,暂时不支持锁舱",
InquiryTmsCabinService.lockRejectReason("MULTI"));
assertEquals("不是空运单,不能锁舱", InquiryTmsCabinService.lockRejectReason("SEA"));
assertEquals("", InquiryTmsCabinService.lockRejectReason("AIR"));
}
@Test @Test
public void pickNamedOption() { public void pickNamedOption() {
Map<String, Object> quote = new HashMap<>(); Map<String, Object> quote = new HashMap<>();
@@ -1,4 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 账号管理:对接 Jeecg /sys/user(主账登录账号)。 * 账号管理:对接 Jeecg /sys/user(主账登录账号)。
* 与员工岗位页分离——本页管后台登录,员工页管企微业务角色。 * 与员工岗位页分离——本页管后台登录,员工页管企微业务角色。
@@ -305,7 +305,7 @@ function removeAccount(row: AccountRow) {
</div> </div>
</Teleport> </Teleport>
<div class="section-card"> <div class="section-card is-list-page">
<div class="toolbar"> <div class="toolbar">
<div class="search"> <div class="search">
<ProtoIcon class="search-icon" name="search" :size="16" /> <ProtoIcon class="search-icon" name="search" :size="16" />
@@ -1,4 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 角色管理:对接 Jeecg /sys/role 与菜单权限树。 * 角色管理:对接 Jeecg /sys/role 与菜单权限树。
* 数据权限按 Excel 线路目录树勾选,写入角色 description 的 [dataPerms:…],不另造 RBAC。 * 数据权限按 Excel 线路目录树勾选,写入角色 description 的 [dataPerms:…],不另造 RBAC。
@@ -255,7 +255,7 @@ function onDataPermCheck(checked: string[] | { checked: string[] }) {
</button> </button>
</Teleport> </Teleport>
<div class="section-card"> <div class="section-card is-list-page">
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table"> <table class="data-table">
<thead> <thead>
@@ -494,7 +494,7 @@ function filterRouteOption(input: string, option: any) {
</div> </div>
</Teleport> </Teleport>
<div class="section-card"> <div class="section-card is-list-page">
<div class="tabs-line-bar"> <div class="tabs-line-bar">
<div class="tabs-line"> <div class="tabs-line">
<button type="button" :class="{ 'is-active': tab === 'all' }" @click="tab = 'all'"> <button type="button" :class="{ 'is-active': tab === 'all' }" @click="tab = 'all'">
@@ -30,6 +30,13 @@ body {
background: #f7f8fb !important; background: #f7f8fb !important;
min-width: 0 !important; min-width: 0 !important;
overflow-x: hidden; overflow-x: hidden;
/* 主区锁视口高度:列表页表格区内滚;看板等非列表页在 .admin-content 内滚 */
height: 100vh !important;
max-height: 100vh !important;
min-height: 0 !important;
overflow: hidden !important;
display: flex !important;
flex-direction: column !important;
} }
.admin-content { .admin-content {
@@ -37,6 +44,9 @@ body {
gap: 12px !important; gap: 12px !important;
min-width: 0 !important; min-width: 0 !important;
overflow-x: hidden; overflow-x: hidden;
flex: 1 1 auto !important;
min-height: 0 !important;
overflow: auto !important;
} }
/* /*
@@ -559,12 +569,11 @@ body {
gap: 12px 16px !important; gap: 12px 16px !important;
} }
/* 中间列横滑;左右 sticky 列固定 */ /* 中间列横滑;左右 sticky 列固定。竖滚改由 .is-list-page 表格区承接,勿再用 visible 把滚动甩给整页 */
.ticket-page .table-wrap, .ticket-page .table-wrap,
.ticket-table-scroll { .ticket-table-scroll {
border-radius: 10px !important; border-radius: 10px !important;
overflow-x: auto !important; overflow-x: auto !important;
overflow-y: visible;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
@@ -1415,3 +1424,102 @@ textarea.field.field-tall {
align-items: start !important; align-items: start !important;
} }
/**
* 列表页固定搜索区 / 表头 / 底部分页,表格区内双轴滚动。
* 根节点加 .is-list-page(工单、员工、账号、角色、报价模板)。
* 这样横向滚动条始终贴在表格可视区底部,不必滚到文档末尾才看到。
*/
.admin-content:has(> .is-list-page) {
overflow: hidden !important;
display: flex !important;
flex-direction: column !important;
padding-bottom: 16px !important;
}
.admin-content > .is-list-page {
flex: 1 1 auto !important;
min-height: 0 !important;
display: flex !important;
flex-direction: column !important;
overflow: hidden !important;
}
/* 工单页:外壳 is-list-page,内层 section-card 撑满 */
.admin-content > .is-list-page > .section-card {
flex: 1 1 auto !important;
min-height: 0 !important;
display: flex !important;
flex-direction: column !important;
overflow: hidden !important;
gap: 12px !important;
}
/* 账号/员工/角色/模板:section-card 自身即 is-list-page */
.admin-content > .section-card.is-list-page {
display: flex !important;
flex-direction: column !important;
gap: 12px !important;
}
.is-list-page .section-card-header,
.is-list-page .tabs-line-bar,
.is-list-page .toolbar,
.is-list-page .pagination {
flex-shrink: 0 !important;
}
.is-list-page .table-wrap,
.is-list-page .qt-template-table-wrap {
flex: 1 1 auto !important;
min-height: 0 !important;
overflow: auto !important;
overflow-x: auto !important;
overflow-y: auto !important;
-webkit-overflow-scrolling: touch;
}
/* 竖向滚动时表头钉在表格区顶部 */
.is-list-page .data-table thead th {
position: sticky !important;
top: 0 !important;
z-index: 4 !important;
background: #f8fafc !important;
box-shadow: 0 1px 0 var(--line, #d9e2ef);
}
/* 左/右冻结列与表头交叉处抬高层级,避免横滑时被盖住 */
.is-list-page .data-table thead th:first-child,
.is-list-page .data-table thead th.col-sticky-left {
left: 0 !important;
z-index: 6 !important;
}
.is-list-page .data-table thead th:last-child,
.is-list-page .data-table thead th.col-sticky-right {
right: 0 !important;
z-index: 6 !important;
}
.is-list-page .data-table tbody td:first-child,
.is-list-page .data-table tbody td.col-sticky-left {
position: sticky !important;
left: 0 !important;
z-index: 3 !important;
background: #fff !important;
}
.is-list-page .data-table tbody td:last-child,
.is-list-page .data-table tbody td.col-sticky-right {
position: sticky !important;
right: 0 !important;
z-index: 3 !important;
background: #fff !important;
}
.is-list-page .data-table tbody tr:hover td:first-child,
.is-list-page .data-table tbody tr:hover td:last-child,
.is-list-page .data-table tbody tr:hover td.col-sticky-left,
.is-list-page .data-table tbody tr:hover td.col-sticky-right {
background: #f8fbff !important;
}
@@ -688,7 +688,7 @@ async function downloadPreviewOrOriginal() {
</button> </button>
</Teleport> </Teleport>
<div class="section-card qt-template-page"> <div class="section-card qt-template-page is-list-page">
<div class="toolbar"> <div class="toolbar">
<div class="search"> <div class="search">
<ProtoIcon class="search-icon" name="search" :size="16" /> <ProtoIcon class="search-icon" name="search" :size="16" />
@@ -126,6 +126,17 @@ function asPriceLines(raw: any): PriceLine[] {
.filter((row) => row.name); .filter((row) => row.name);
} }
/**
* 未调价利润点必须是 0%。主账应已逐行给 0%;旧接口没给时,按成本/销售费用名补 0%。
* 禁止把金额拿来填利润点列。
*/
function displayProfitLines(row: TicketRow): PriceLine[] {
if (row.profitLines.length) return row.profitLines;
const src = row.saleLines.length ? row.saleLines : row.costLines;
if (!src.length) return [];
return src.map((line) => ({ name: line.name, text: "0%" }));
}
/** /**
* 后台报价来源:TMS标准报价 / 手工报价 / 销售调价。 * 后台报价来源:TMS标准报价 / 手工报价 / 销售调价。
* 禁止把产品报价或销售调价再显示成 TMS。 * 禁止把产品报价或销售调价再显示成 TMS。
@@ -413,6 +424,44 @@ function quoteHasFees(row: TicketRow) {
return (row.quotes || []).some((q) => (q.fees || []).length > 0); return (row.quotes || []).some((q) => (q.fees || []).length > 0);
} }
/**
* 报价信息 / 成本价 / 销售价 / 利润点共用一把「展开」:
* 收起时只留摘要(报价来源+总额、费用合计行),展开才看逐费明细。
* 避免只收起报价列、成本/利润仍撑很高。
*/
const priceExpandedMap = reactive<Record<string, boolean>>({});
const detailPriceExpanded = ref(false);
function detailPriceNeedsToggle(row: TicketRow | null) {
if (!row) return false;
return priceBlockNeedsToggle(row);
}
function isPriceExpanded(id: string) {
return !!priceExpandedMap[id];
}
function togglePriceExpanded(id: string) {
priceExpandedMap[id] = !priceExpandedMap[id];
}
function priceBlockNeedsToggle(row: TicketRow) {
return (
quoteHasFees(row) ||
row.costLines.length > 1 ||
row.saleLines.length > 1 ||
displayProfitLines(row).length > 1
);
}
/** 收起:优先只留「费用合计」;没有合计则留首行,避免整列空白。 */
function visiblePriceLines(lines: PriceLine[], expanded: boolean): PriceLine[] {
if (!lines.length) return [];
if (expanded || lines.length <= 1) return lines;
const totals = lines.filter((l) => l.name === "费用合计");
return totals.length ? totals : lines.slice(0, 1);
}
/** 列表询价需求第二行起:与旧后台 summary-cell 多行展示一致。 */ /** 列表询价需求第二行起:与旧后台 summary-cell 多行展示一致。 */
function demandDetailLines(row: TicketRow) { function demandDetailLines(row: TicketRow) {
return String(row?.demandDetail || "") return String(row?.demandDetail || "")
@@ -472,6 +521,7 @@ onMounted(() => loadList());
async function openDetail(row: TicketRow) { async function openDetail(row: TicketRow) {
current.value = row; current.value = row;
detailPriceExpanded.value = false;
detailOpen.value = true; detailOpen.value = true;
try { try {
const res: any = await getWorkOrderDetail(row.id); const res: any = await getWorkOrderDetail(row.id);
@@ -540,7 +590,7 @@ function refresh() {
</div> </div>
</Teleport> </Teleport>
<div class="ticket-page"> <div class="ticket-page is-list-page">
<div class="section-card"> <div class="section-card">
<div class="section-card-header"> <div class="section-card-header">
<h2 class="section-card-title">询价工单列表</h2> <h2 class="section-card-title">询价工单列表</h2>
@@ -664,35 +714,41 @@ function refresh() {
</ExpandableClamp> </ExpandableClamp>
</td> </td>
<td class="align-top col-quote"> <td class="align-top col-quote">
<ExpandableClamp <div class="ticket-price-expand">
v-if="row.quotes.length" <template v-if="row.quotes.length">
v-slot="{ expanded }" <div
:content-key="row.id + '-quote-' + row.quotes.length" v-for="q in row.quotes"
:force-toggle="quoteHasFees(row)" :key="q.source + q.price"
:clip-collapsed="false" class="quote-row"
> style="margin-bottom: 8px"
<div >
v-for="q in row.quotes" <div class="quote-source">{{ q.source }}</div>
:key="q.source + q.price" <div class="quote-price" :style="q.highlight ? { color: '#1d4ed8' } : undefined">
class="quote-row" {{ q.price }}
style="margin-bottom: 8px"
>
<div class="quote-source">{{ q.source }}</div>
<div class="quote-price" :style="q.highlight ? { color: '#1d4ed8' } : undefined">
{{ q.price }}
</div>
<template v-if="expanded && q.fees.length">
<div
v-for="(fee, fi) in q.fees"
:key="q.source + '-fee-' + fi"
class="quote-fee-line"
>
{{ fee.name }}:{{ fee.amount }}
</div> </div>
</template> <template v-if="isPriceExpanded(row.id) && q.fees.length">
</div> <div
</ExpandableClamp> v-for="(fee, fi) in q.fees"
<span v-else style="color: #94a3b8">-</span> :key="q.source + '-fee-' + fi"
class="quote-fee-line"
>
{{ fee.name }}:{{ fee.amount }}
</div>
</template>
</div>
</template>
<span v-else style="color: #94a3b8">-</span>
<button
v-if="priceBlockNeedsToggle(row)"
type="button"
class="ticket-price-toggle"
:class="{ 'is-open': isPriceExpanded(row.id) }"
:title="isPriceExpanded(row.id) ? '收起费用明细' : '展开费用明细'"
@click.stop="togglePriceExpanded(row.id)"
>
<ProtoIcon name="chevron" :size="14" />
</button>
</div>
</td> </td>
<td class="align-top"> <td class="align-top">
<div v-if="row.attachments.length" class="attach-tags"> <div v-if="row.attachments.length" class="attach-tags">
@@ -702,9 +758,12 @@ function refresh() {
</td> </td>
<td class="align-top"><span class="ticket-status">{{ row.status }}</span></td> <td class="align-top"><span class="ticket-status">{{ row.status }}</span></td>
<td class="align-top"> <td class="align-top">
<div v-if="row.costLines.length" class="ticket-price-lines"> <div
v-if="visiblePriceLines(row.costLines, isPriceExpanded(row.id)).length"
class="ticket-price-lines"
>
<div <div
v-for="(line, i) in row.costLines" v-for="(line, i) in visiblePriceLines(row.costLines, isPriceExpanded(row.id))"
:key="row.id + '-cost-' + i" :key="row.id + '-cost-' + i"
class="ticket-price-line" class="ticket-price-line"
:class="{ 'is-total': line.name === '费用合计' }" :class="{ 'is-total': line.name === '费用合计' }"
@@ -715,9 +774,12 @@ function refresh() {
<span v-else class="ticket-price-cell">{{ row.costPrice }}</span> <span v-else class="ticket-price-cell">{{ row.costPrice }}</span>
</td> </td>
<td class="align-top"> <td class="align-top">
<div v-if="row.saleLines.length" class="ticket-price-lines"> <div
v-if="visiblePriceLines(row.saleLines, isPriceExpanded(row.id)).length"
class="ticket-price-lines"
>
<div <div
v-for="(line, i) in row.saleLines" v-for="(line, i) in visiblePriceLines(row.saleLines, isPriceExpanded(row.id))"
:key="row.id + '-sale-' + i" :key="row.id + '-sale-' + i"
class="ticket-price-line" class="ticket-price-line"
:class="{ 'is-total': line.name === '费用合计' }" :class="{ 'is-total': line.name === '费用合计' }"
@@ -728,9 +790,12 @@ function refresh() {
<span v-else class="ticket-price-cell" style="font-weight: 800">{{ row.salePrice }}</span> <span v-else class="ticket-price-cell" style="font-weight: 800">{{ row.salePrice }}</span>
</td> </td>
<td class="align-top"> <td class="align-top">
<div v-if="row.profitLines.length" class="ticket-price-lines"> <div
v-if="visiblePriceLines(displayProfitLines(row), isPriceExpanded(row.id)).length"
class="ticket-price-lines"
>
<div <div
v-for="(line, i) in row.profitLines" v-for="(line, i) in visiblePriceLines(displayProfitLines(row), isPriceExpanded(row.id))"
:key="row.id + '-profit-' + i" :key="row.id + '-profit-' + i"
class="ticket-price-line" class="ticket-price-line"
:class="{ 'is-total': line.name === '费用合计' }" :class="{ 'is-total': line.name === '费用合计' }"
@@ -738,7 +803,7 @@ function refresh() {
{{ line.name }}:{{ line.text || "-" }} {{ line.name }}:{{ line.text || "-" }}
</div> </div>
</div> </div>
<span v-else class="ticket-margin-cell">{{ row.profit }}</span> <span v-else class="ticket-margin-cell">{{ row.profit === "-" ? "0%" : row.profit }}</span>
</td> </td>
<td class="col-center"> <td class="col-center">
<span v-if="row.eventException !== '-'" class="tag tag-rose">{{ row.eventException }}</span> <span v-if="row.eventException !== '-'" class="tag tag-rose">{{ row.eventException }}</span>
@@ -843,56 +908,60 @@ function refresh() {
<div class="ticket-detail-title"> <div class="ticket-detail-title">
<ProtoIcon name="gauge" :size="14" />价格与利润 <ProtoIcon name="gauge" :size="14" />价格与利润
</div> </div>
<div class="ticket-detail-grid"> <div class="ticket-price-expand">
<div class="ticket-detail-item"> <div class="ticket-detail-grid">
<label>成本价</label> <div class="ticket-detail-item">
<div v-if="current.costLines.length" class="ticket-price-lines"> <label>成本价</label>
<div <div
v-for="(line, i) in current.costLines" v-if="visiblePriceLines(current.costLines, detailPriceExpanded).length"
:key="'detail-cost-' + i" class="ticket-price-lines"
class="ticket-price-line"
:class="{ 'is-total': line.name === '费用合计' }"
> >
{{ line.name }}:{{ line.text || "-" }} <div
v-for="(line, i) in visiblePriceLines(current.costLines, detailPriceExpanded)"
:key="'detail-cost-' + i"
class="ticket-price-line"
:class="{ 'is-total': line.name === '费用合计' }"
>
{{ line.name }}:{{ line.text || "-" }}
</div>
</div> </div>
<span v-else>{{ current.costPrice }}</span>
</div> </div>
<span v-else>{{ current.costPrice }}</span> <div class="ticket-detail-item">
</div> <label>销售价</label>
<div class="ticket-detail-item">
<label>销售价</label>
<div v-if="current.saleLines.length" class="ticket-price-lines">
<div <div
v-for="(line, i) in current.saleLines" v-if="visiblePriceLines(current.saleLines, detailPriceExpanded).length"
:key="'detail-sale-' + i" class="ticket-price-lines"
class="ticket-price-line"
:class="{ 'is-total': line.name === '费用合计' }"
> >
{{ line.name }}:{{ line.text || "-" }} <div
v-for="(line, i) in visiblePriceLines(current.saleLines, detailPriceExpanded)"
:key="'detail-sale-' + i"
class="ticket-price-line"
:class="{ 'is-total': line.name === '费用合计' }"
>
{{ line.name }}:{{ line.text || "-" }}
</div>
</div> </div>
<span v-else>{{ current.salePrice }}</span>
</div> </div>
<span v-else>{{ current.salePrice }}</span> <div class="ticket-detail-item">
</div> <label>利润点</label>
<div class="ticket-detail-item">
<label>利润点</label>
<div v-if="current.profitLines.length" class="ticket-price-lines">
<div <div
v-for="(line, i) in current.profitLines" v-if="visiblePriceLines(displayProfitLines(current), detailPriceExpanded).length"
:key="'detail-profit-' + i" class="ticket-price-lines"
class="ticket-price-line"
:class="{ 'is-total': line.name === '费用合计' }"
> >
{{ line.name }}:{{ line.text || "-" }} <div
v-for="(line, i) in visiblePriceLines(displayProfitLines(current), detailPriceExpanded)"
:key="'detail-profit-' + i"
class="ticket-price-line"
:class="{ 'is-total': line.name === '费用合计' }"
>
{{ line.name }}:{{ line.text || "-" }}
</div>
</div> </div>
<span v-else>{{ current.profit === "-" ? "0%" : current.profit }}</span>
</div> </div>
<span v-else>{{ current.profit }}</span>
</div> </div>
</div>
<ExpandableClamp
v-if="current.quotes.length"
class="ticket-detail-demand"
:content-key="current.id + '-detail-quote-' + current.quotes.length"
:max-height="220"
>
<div <div
v-for="q in current.quotes" v-for="q in current.quotes"
:key="q.source + q.price + (q.time || '')" :key="q.source + q.price + (q.time || '')"
@@ -901,7 +970,7 @@ function refresh() {
<div class="quote-source"> <div class="quote-source">
{{ q.source }}<span v-if="q.time && q.time !== '-'"> · {{ q.time }}</span> {{ q.source }}<span v-if="q.time && q.time !== '-'"> · {{ q.time }}</span>
</div> </div>
<div v-if="q.fees.length" class="quote-fee-list"> <div v-if="detailPriceExpanded && q.fees.length" class="quote-fee-list">
<div <div
v-for="(fee, fi) in q.fees" v-for="(fee, fi) in q.fees"
:key="q.source + '-detail-fee-' + fi" :key="q.source + '-detail-fee-' + fi"
@@ -918,7 +987,17 @@ function refresh() {
合计 {{ q.price }} 合计 {{ q.price }}
</div> </div>
</div> </div>
</ExpandableClamp> <button
v-if="detailPriceNeedsToggle(current)"
type="button"
class="ticket-price-toggle"
:class="{ 'is-open': detailPriceExpanded }"
:title="detailPriceExpanded ? '收起费用明细' : '展开费用明细'"
@click="detailPriceExpanded = !detailPriceExpanded"
>
<ProtoIcon name="chevron" :size="14" />
</button>
</div>
</section> </section>
<section class="ticket-detail-section"> <section class="ticket-detail-section">
@@ -1032,4 +1111,32 @@ function refresh() {
color: #708090; color: #708090;
font-size: 12px; font-size: 12px;
} }
/* 报价+成本+利润共用展开:箭头管整块费用明细 */
.ticket-price-expand {
position: relative;
min-width: 0;
}
.ticket-price-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
margin-top: 4px;
padding: 2px 0 0;
border: 0;
background: transparent;
color: #64748b;
cursor: pointer;
line-height: 1;
}
.ticket-price-toggle:hover {
color: #1d4ed8;
}
.ticket-price-toggle :deep(svg) {
transition: transform 0.15s ease;
}
.ticket-price-toggle.is-open :deep(svg) {
transform: rotate(180deg);
}
</style> </style>