Compare commits

..
71 changed files with 4295 additions and 446 deletions
+1 -1
View File
@@ -118,7 +118,7 @@ async def _accept_upload_files(
return HTMLResponse(expired_upload_page(), status_code=410)
if phase in {"reject_type", "need_file"}:
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)
@@ -319,4 +319,4 @@ def render_upload_message(title: str, body: str, *, expired: bool = False) -> st
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 后留在询价对话里。
再调 ww/wx/window.close 会把询价小助手整个退出,回到企业微信首页。
电脑企微没有这套桥,加载 wecom-jssdk、用 /inquiry-form/jsapi-signature 注册后再关一次。
电脑企微即使注入了 WeixinJSBridge,这一下也关不掉当前页,必须按电脑走 JS-SDK。
失败、过期页不要挂这段。
副作用:只产出 HTML 片段;关窗签名走浏览器短请求,不占智能体请求线程做慢活。
"""
@@ -24,7 +24,8 @@ def wecom_auto_close_script() -> str:
成功页脚:加载企微 JS-SDK,register 后关掉当前 H5 窗口。
线程:浏览器端执行。签名接口与复制页共用,ticket 已缓存在 Redis。
为何这样写:上一版只调 WeixinJSBridge,电脑端企微关不掉,会停在成功页。
为何这样写:电脑企微也会带上 WeixinJSBridge,但关不掉当前页。
若一发现桥就只走手机关闭,成功页会停在「正在返回企微」。手机仍只关一次。
"""
settings = get_settings()
corp = escape((settings.wecom_corp_id or "").strip(), quote=True)
@@ -49,6 +50,10 @@ def wecom_auto_close_script() -> str:
var closed = false;
function isPhone() {{
return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent || "");
}}
function closeOnce(fn) {{
if (closed) return;
closed = true;
@@ -60,7 +65,8 @@ def wecom_auto_close_script() -> str:
closeOnce(function () {{ WeixinJSBridge.call("closeWindow"); }});
}}
// 电脑企微没有 WeixinJSBridge,注册 JS-SDK 后只关一次。
// 电脑企微即使有 WeixinJSBridge,也关不掉当前页。注册 JS-SDK 后再关一次。
// 不要调 window.close:手机会把询价小助手整个退出。
function closeDesktop() {{
closeOnce(function () {{
if (window.ww && typeof ww.closeWindow === "function") ww.closeWindow();
@@ -69,15 +75,16 @@ def wecom_auto_close_script() -> str:
}}
function boot() {{
if (typeof WeixinJSBridge !== "undefined") {{
setTimeout(closeMobile, {delay});
if (isPhone()) {{
if (typeof WeixinJSBridge !== "undefined") {{
setTimeout(closeMobile, {delay});
return;
}}
document.addEventListener("WeixinJSBridgeReady", function () {{
setTimeout(closeMobile, {delay});
}}, false);
return;
}}
var sawBridge = false;
document.addEventListener("WeixinJSBridgeReady", function () {{
sawBridge = true;
setTimeout(closeMobile, {delay});
}}, false);
var ready = Promise.resolve();
if (window.ww && ww.register && corpId) {{
ready = ww.register({{
@@ -88,18 +95,14 @@ def wecom_auto_close_script() -> str:
getAgentConfigSignature: sign("agent")
}}).catch(function () {{ return null; }});
}}
ready.then(function () {{
setTimeout(function () {{
if (sawBridge || typeof WeixinJSBridge !== "undefined") {{
if (!closed) closeMobile();
return;
}}
closeDesktop();
}}, {delay});
var timed = Promise.race([
ready,
new Promise(function (resolve) {{ setTimeout(resolve, 2500); }})
]);
timed.then(function () {{
setTimeout(closeDesktop, {delay});
}}).catch(function () {{
setTimeout(function () {{
if (!closed && typeof WeixinJSBridge === "undefined") closeDesktop();
}}, {delay});
setTimeout(closeDesktop, {delay});
}});
}}
+133 -50
View File
@@ -2,7 +2,8 @@
意图:空运协同群入站(BOT @ 之后的文字或附件)。
本文件职责:激活/切单后交给 air_group_ops。海运应用群不进这里。
禁止:存档文字当业务;锁舱用当前工单去猜;套 sea_group_ops 建群。
多段单已在本群激活后,没带工单号的后续消息跟这张多段单,不再要求重发工单号。
禁止:存档文字当业务;纯空运锁舱用当前工单去猜;套 sea_group_ops 建群。
"""
from __future__ import annotations
@@ -21,6 +22,8 @@ from agent.policy.air_group_ops import (
apply_air_file,
apply_air_lock,
apply_air_quote,
lock_params_from_text,
text_has_lock_supplement,
apply_air_release,
apply_air_tms_choice,
current_ticket,
@@ -56,6 +59,54 @@ from agent.routing.air_group_intent import (
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:
"""
每次入站换一次 BOT 出站客户端。
@@ -116,20 +167,18 @@ def handle_air_group(
return "duplicate_inbound"
mark_air_text(chat_id=chat_id, sender_id=message.sender_id, text=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:
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
handed = try_commit_air_group_handoff(engine, chat_id=chat_id, work_order_no=wo)
@@ -182,10 +231,18 @@ def handle_air_group(
if is_handoff_ticket(ticket):
logger.info("air_group 转人工不再跟 chat=%s", chat_id)
return "handoff_ignore"
from agent.policy.system_exception import reply_paused_ticket, ticket_is_paused
from agent.policy.system_exception import (
cabin_failure_pause,
reply_paused_ticket,
ticket_is_paused,
)
# 锁舱失败留下的暂停只拦还要打 TMS 的操作。成交、未成交和补原因不调用 TMS。
if ticket_is_paused(ticket) and not _deal_ignores_tms_pause(ticket, intent):
# 查价失败的暂停仍要拦住。锁舱/释放失败已经告诉过原因,不能挡住后面的报价和成交。
if (
ticket_is_paused(ticket)
and not cabin_failure_pause(ticket)
and not _deal_ignores_tms_pause(ticket, intent)
):
reply_paused_ticket(
ticket=ticket,
reply=lambda text: send_group_text(group_client_of(engine), chat_id, text),
@@ -212,6 +269,17 @@ def _handle_file(engine, message: InboundMessage) -> str:
wo = copy.extract_work_order_no(
" ".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:
ticket, phase = activate_ticket(
flow=engine, chat_id=chat_id, work_order_no=wo, speak=False
@@ -252,6 +320,29 @@ def _handle_cabin(
_ = injected_quote
chat_id = message.chat_id
if not work_order_no:
# 本群已经在锁这张单时,补舱位不用再报工单号。
bound = current_ticket(engine, chat_id)
if bound is not None and is_air_ticket(bound):
ticket = bound
if intent == INTENT_RELEASE:
return apply_air_release(
flow=engine,
ticket=ticket,
sender_id=message.sender_id,
chat_id=chat_id,
instruction=message.content or "",
)
option_no, airline, flight_no = lock_params_from_text(message.content or "")
return apply_air_lock(
flow=engine,
ticket=ticket,
sender_id=message.sender_id,
chat_id=chat_id,
instruction=message.content or "",
option_no=option_no,
airline=airline,
flight_no=flight_no,
)
send_group_text(group_client_of(engine), chat_id, copy.air_lock_need_work_order())
return "cabin_need_work_order"
ticket, phase = resolve_cabin_ticket(
@@ -270,15 +361,17 @@ def _handle_cabin(
chat_id=chat_id,
instruction=message.content or "",
)
# 「WO…锁舱,锁AIR-OPT01 TH」要带上句中的舱位和航司,不能只靠报价里碰巧只有一条。
# 「锁 AIR-OPT01 CZ 航班CZ327」要带上舱位、航司、航班号。缺了先补,不能直接锁。
option_no, airline, flight_no = lock_params_from_text(message.content or "")
return apply_air_lock(
flow=engine,
ticket=ticket,
sender_id=message.sender_id,
chat_id=chat_id,
instruction=message.content or "",
option_no=_option_from_text(message.content or ""),
airline=_airline_from_text(message.content or ""),
option_no=option_no,
airline=airline,
flight_no=flight_no,
)
@@ -315,19 +408,20 @@ def _handle_bound(
if lost:
return lost
facts = dict(ticket_view(ticket).get("collab_facts") or {})
if intent == INTENT_PICK_OPTION or (
intent == INTENT_OTHER and str(facts.get("__pending_air_lock") or "") == "1"
):
option = _option_from_text(message.content or "")
if option:
pending_lock = str(facts.get("__pending_air_lock") or "") == "1"
# 等补舱位时,报价/选线路等意图让路:话里带了舱位、航司或航班号就继续锁。
if intent == INTENT_PICK_OPTION or pending_lock or text_has_lock_supplement(message.content or ""):
option_no, airline, flight_no = lock_params_from_text(message.content or "")
if option_no or airline or flight_no:
return apply_air_lock(
flow=engine,
ticket=ticket,
sender_id=message.sender_id,
chat_id=chat_id,
instruction=message.content or "",
option_no=option,
airline=_airline_from_text(message.content or ""),
option_no=option_no,
airline=airline,
flight_no=flight_no,
)
if intent in {INTENT_DEAL, INTENT_LOST, INTENT_NEGOTIATE}:
outcome = {
@@ -363,6 +457,18 @@ def _handle_bound(
sender_id=message.sender_id,
chat_id=chat_id,
)
from agent.policy.sea_group_ops import is_sales
from agent.routing.group_intent import resolve_price_intent
# 航线写出「空运费改为900」是报价。模型若判成调价,这里按说话人改回来。
priced = resolve_price_intent(
intent,
message.content or "",
sales=is_sales(ticket, message.sender_id),
)
if priced != intent:
logger.info("air_group 报价/调价改判 %s -> %s chat=%s", intent, priced, chat_id)
intent = priced
if intent == INTENT_ADJUST:
from agent.policy.quote_adjust_ops import apply_group_adjust
@@ -390,26 +496,3 @@ def _handle_bound(
return "group_ignore"
def _option_from_text(text: str) -> str:
"""从「锁AIR-OPT01 TH」抽出舱位编号;不要把后面的航司码拼进 optionNo。"""
import re
m = re.search(r"AIR-?OPT-?\d+", (text or "").upper())
if not m:
return ""
raw = m.group(0)
# 统一成 TMS 常见写法 AIR-OPT01(中间可保留已有的第二根横杠)
if raw.startswith("AIROPT"):
return "AIR-OPT" + raw[6:]
return raw
def _airline_from_text(text: str) -> str:
"""从「AIR-OPT01 TH」抽出两位航司码;没有就不猜。"""
import re
blob = (text or "").upper()
m = re.search(r"AIR-?OPT-?\d+\s*([A-Z]{2})(?![A-Z0-9])", blob)
if m:
return m.group(1)
return ""
@@ -207,7 +207,7 @@ def join_attachment_image(
if not wave.ack_sent:
msg_store.enqueue_outbound(
touser=message.sender_id,
content=copy.IMAGE_RECOGNIZING,
content=copy.image_recognizing(),
dedupe_key=f"attach-ack:{wave.wave_id}",
)
book.mark_ack(message.sender_id, "image")
@@ -258,7 +258,7 @@ def accept_h5_uploads(
book = get_attach_wave_book()
wave = book.active(sid, now=stamp)
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()]
if not incoming:
return "need_file", copy.ATTACH_H5_NEED_FILE
@@ -268,12 +268,12 @@ def accept_h5_uploads(
start_index = len(wave.materials)
wave = book.add_files(sid, [n for n, _r in incoming], now=stamp)
if wave is None:
return "expired", copy.ATTACH_H5_EXPIRED
return "expired", copy.attach_h5_expired()
msg_store = store or get_message_store()
if not wave.ack_sent:
msg_store.enqueue_outbound(
touser=sid,
content=copy.ATTACH_RECOGNIZING,
content=copy.attach_recognizing(),
dedupe_key=f"attach-ack:{wave.wave_id}",
)
book.mark_ack(sid, "file")
@@ -304,8 +304,8 @@ def accept_h5_uploads(
wave = book.active(sid, now=stamp)
if process_now and wave is not None and book.pending(wave) <= 0:
finish_attach_wave(sid, store=msg_store)
return "done", copy.ATTACH_H5_OK
return "recognizing", copy.ATTACH_H5_OK
return "done", copy.attach_h5_ok()
return "recognizing", copy.attach_h5_ok()
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)
reason = ""
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()
prefix_used = {"v": False}
@@ -441,7 +441,7 @@ def finish_attach_wave(
if reason and not prefix_used["v"]:
msg_store.enqueue_outbound(
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)}",
)
phase = "need_mode"
+30 -11
View File
@@ -103,7 +103,7 @@ def handle_group_inbound(
or ""
).upper()
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 (
_sender_allowed(ticket, message.sender_id)
@@ -111,22 +111,29 @@ def handle_group_inbound(
):
logger.info("group_inbound 多段忽略非成员 sender=%s", message.sender_id)
return "group_ignore"
return handle_multi_inbound(
flow=engine,
ticket=ticket,
chat_id=chat_id,
sender_id=message.sender_id,
text=message.content or "",
injected_quote=injected_quote,
)
# 会话存档听到的报价也走这里。含空运时群是机器人建的,必须改走桥才能发得出去。
with bot_sender_when_air_multi(ticket):
return handle_multi_inbound(
flow=engine,
ticket=ticket,
chat_id=chat_id,
sender_id=message.sender_id,
text=message.content or "",
injected_quote=injected_quote,
)
ticket = hydrate_ticket(engine, ticket)
if not _sender_allowed(ticket, message.sender_id):
logger.info("group_inbound 忽略非成员 sender=%s chat=%s", message.sender_id, chat_id)
return "group_ignore"
from agent.policy.system_exception import reply_paused_ticket, ticket_is_paused
from agent.policy.system_exception import (
cabin_failure_pause,
reply_paused_ticket,
ticket_is_paused,
)
if ticket_is_paused(ticket):
# 锁舱失败不挡海运/陆运群里的后续报价。查价失败的暂停仍要停。
if ticket_is_paused(ticket) and not cabin_failure_pause(ticket):
reply_paused_ticket(
ticket=ticket,
reply=lambda text: send_group_text(group_client_of(engine), chat_id, text),
@@ -236,6 +243,18 @@ def handle_group_inbound(
sender_id=message.sender_id,
sess=sess,
)
from agent.policy.sea_group_ops import is_sales
from agent.routing.group_intent import resolve_price_intent
# 产品写出「报关费改为300」是报价。不是这张单的销售时,不能回调价话术。
priced = resolve_price_intent(
intent,
message.content or "",
sales=is_sales(ticket, message.sender_id),
)
if priced != intent:
logger.info("group_inbound 报价/调价改判 %s -> %s chat=%s", intent, priced, chat_id)
intent = priced
if intent == INTENT_ADJUST:
from agent.policy.quote_adjust_ops import apply_group_adjust
@@ -79,7 +79,7 @@ def handle_image_inquiry(
drop_all_private_bookmarks(message.sender_id)
msg_store.enqueue_outbound(
touser=message.sender_id,
content=copy.IMAGE_RECOGNIZING,
content=copy.image_recognizing(),
dedupe_key=f"img-ack:{wave.wave_id}",
)
book.mark_ack(message.sender_id)
@@ -341,7 +341,7 @@ def finish_wave(
oral = book.compose_oral(sender_id)
reason = ""
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()
prefix_used = {"v": False}
@@ -373,7 +373,7 @@ def finish_wave(
if reason and not prefix_used["v"]:
msg_store.enqueue_outbound(
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}",
)
phase = "need_mode"
+3 -13
View File
@@ -404,6 +404,7 @@ def handle_text_inquiry(
return info if ok else ""
from agent.policy.system_exception import (
cabin_failure_pause,
extract_work_order_nos,
reply_paused_ticket,
ticket_is_paused,
@@ -420,7 +421,7 @@ def handle_text_inquiry(
getter = getattr(ledger, "get_for_agent", None)
if callable(getter):
ticket = getter(work_order_no=no, sender_id=message.sender_id)
if ticket_is_paused(ticket):
if ticket_is_paused(ticket) and not cabin_failure_pause(ticket):
reply_paused_ticket(ticket=ticket, reply=reply)
logger.info("text_inquiry 暂停工单只回兜底 wo=%s sender=%s", no, message.sender_id)
return "system_exception_paused"
@@ -478,18 +479,7 @@ def handle_text_other(message: InboundMessage, *, store=None) -> str:
logger.info("text_other 陆运确定/重查,改走询价流程 sender=%s", message.sender_id)
return handle_text_inquiry(message, store=msg_store)
from agent.schema.land_options import looks_like_confirm
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
body = copy.first_chat()
msg_store.enqueue_outbound(
touser=message.sender_id,
content=body,
+9
View File
@@ -103,6 +103,15 @@ class LedgerApiShell:
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(
self,
*,
+5 -1
View File
@@ -423,7 +423,9 @@ class HttpLedger:
operator_name: str = "",
instruction_text: str = "",
airline: str = "",
flight_no: str = "",
) -> dict[str, Any]:
"""锁舱出站。航班号和航司一并带上,主账缺了这两项不能当锁舱失败。"""
body: dict[str, Any] = {
"workOrderNo": work_order_no,
"optionNo": option_no,
@@ -433,10 +435,12 @@ class HttpLedger:
}
if (airline or "").strip():
body["airline"] = airline.strip().upper()
if (flight_no or "").strip():
body["flightNo"] = flight_no.strip().upper()
return self._post(
"/inquiry/agent/tms/lockCabin",
body,
idem=f"air-lock:{work_order_no}:{option_no}",
idem=f"air-lock:{work_order_no}:{option_no}:{(airline or '').strip()}:{(flight_no or '').strip()}",
)
def release_cabin(
+2 -1
View File
@@ -827,9 +827,10 @@ class MemoryLedger:
operator_name: str = "",
instruction_text: str = "",
airline: str = "",
flight_no: str = "",
) -> dict[str, Any]:
"""单测可注入 _cabin_lock_result;默认成功。"""
_ = sender_id, operator_name, instruction_text, airline
_ = sender_id, operator_name, instruction_text, airline, flight_no
forced = getattr(self, "_cabin_lock_result", None)
if isinstance(forced, dict):
return dict(forced)
+15 -2
View File
@@ -477,8 +477,14 @@ def harvest_collab_labels(text: str) -> dict[str, str]:
「客户名称张三 包装方式编织袋 货值 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]:
@@ -508,7 +514,11 @@ def clip_inquiry_value(raw: str) -> str:
if hit and hit.start() > 0:
text = text[: hit.start()]
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:
@@ -549,9 +559,12 @@ def clip_sheet_field_value(raw: str) -> str:
if not parts:
return ""
# 格子里只剩下一个字段名(体积、时效要求)时,这一栏是空的,不能把字段名当成值。
# 只剩「4.」也是空的:那是下一行「4.提货地址」被切掉字段名后留下的编号。
token = parts[0].strip().strip("::")
if token in _SHEET_CLEAN_KEYS or token in _TIGHTEN_LABELS:
return ""
if re.fullmatch(r"\d+[\.、.]", token):
return ""
return parts[0].strip()
+434 -21
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import json
import logging
import re
from typing import Any, Optional
from agent.llm.extract_text import extract_air_collab_fields, extract_product_quote
@@ -43,6 +44,9 @@ AIRLINE_ROLE = "air"
_PENDING_QUOTE = "__pending_air_quote"
_PENDING_LOCK = "__pending_air_lock"
_PENDING_LOCK_OPTION = "__pending_lock_option"
_PENDING_LOCK_AIRLINE = "__pending_lock_airline"
_PENDING_LOCK_FLIGHT = "__pending_lock_flight"
_LOCK_ID = "__air_lock_id"
_LOCK_OPTION = "__air_lock_option"
_LOCK_QUOTE = "__air_lock_quote"
@@ -116,7 +120,7 @@ def activate_ticket(
把本群当前工单切到指定空运未结案单。
返回 (工单, phase)。失败时工单为 None。
航线已经用附件或文字落过价时,再发工单号只回到成交跟进,不再发空白模板。
已经在成交跟进,或航线/产品价已经落账时,再发工单号只回到成交跟进,不再发工单摘要和模板。
"""
book = flow.ledger
no = (work_order_no or "").strip()
@@ -174,23 +178,45 @@ def activate_ticket(
def _air_already_quoted_for_deal(ticket: object) -> bool:
"""
航线报价已经落账,再激活应回成交跟进。
再激活应回成交跟进,而不是重发工单摘要和空运模板。
附件报价会把来源标成「航线附件报价」,但报价编号仍可能留着 TMS。
只看 source 会误当成还没报价。还只有 TMS 标准价时仍发模板。
报价单已经发给销售(quoteFileIssued)或主账停在 wait_deal 时,
即使报价来源仍是 TMS 标准价,也已经在成交跟进。
协商中、航线/产品价、销售调价同样只回成交跟进。
报价单还没出、也没在等成交时,仍发模板给航线填。
"""
status = ticket_status(ticket)
if status == "协商中":
return True
view = ticket_view(ticket)
phase = str(view.get("wait_phase") or view.get("waitPhase") or "").strip()
if phase == "wait_deal":
return True
quote = dict(view.get("quote") or {})
if _quote_file_issued(quote):
return True
if status != "已报价":
return False
quote = dict(ticket_view(ticket).get("quote") or {})
label = str(quote.get("source_label") or quote.get("sourceLabel") or "")
if "航线" in label or "产品" in label:
return True
return copy.quote_is_sales_adjust(quote)
def _quote_file_issued(quote: dict[str, Any]) -> bool:
"""报价单已发给销售,且没有被作废。作废后重新激活仍要发模板。"""
if _flag_on(quote.get("quoteFileVoided") or quote.get("quote_file_voided")):
return False
return _flag_on(quote.get("quoteFileIssued") or quote.get("quote_file_issued"))
def _flag_on(val: object) -> bool:
"""主账布尔可能是 true / \"true\" / 1。"""
if isinstance(val, str):
return val.strip().lower() in {"1", "true", "yes"}
return bool(val)
def _collab_slot_empty(val: object) -> bool:
"""协同项没写,或只写了横杠。横杠是摘要占位,不能当成已经填过。"""
return str(val or "").strip() in {"", "-", "—", "-"}
@@ -484,7 +510,7 @@ def _send_air_activate_sheet(
return
err = str(painted.get("error") or "")
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
if err in {"no_renderer_or_chat", "no_file_client"}:
send_group_text(client, chat_id, copy.group_sheet_send_fail())
@@ -817,30 +843,45 @@ def apply_air_lock(
instruction: str,
option_no: str = "",
airline: str = "",
flight_no: str = "",
) -> str:
"""
航线锁舱。
舱位编号、航司、航班号没齐时只请补全,不调 TMS 锁舱,不标系统异常。
三项齐了(话里写的优先,报价里已有的可以补上)才打主账锁舱。
上次锁舱失败标过暂停的,航线再锁仍要打 TMS。
"""
if not is_airline(flow, sender_id):
send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin())
return "not_airline"
# 锁舱是航线主动再试:即使工单因上次失败标了系统异常,也要再打 TMS,
# 不能只回「TMS接口异常,IT运维排查中」。
no = ticket_no(ticket)
name = operator_name(flow, sender_id)
view_quote = dict(ticket_view(ticket).get("quote") or {})
air_quote = dict((view_quote.get("segment_quotes") or {}).get("AIR") or {})
quote = air_quote or view_quote
options = _air_options(quote)
quote_id = _quote_id(quote)
picked = (option_no or "").strip()
if not picked and len(options) > 1:
facts = dict(ticket_view(ticket).get("collab_facts") or {})
if str(facts.get(_PENDING_LOCK) or "") != "1":
flow.ledger.patch_collab_facts(
work_order_no=no, facts={_PENDING_LOCK: "1"}, sender_id=sender_id
)
send_group_text(group_client_of(flow), chat_id, copy.air_ask_lock_option(options))
return "wait_lock_option"
if not picked and len(options) == 1:
picked = str(options[0].get("optionNo") or options[0].get("option_no") or "").strip()
facts = dict(ticket_view(ticket).get("collab_facts") or {})
picked, air_code, flight = _resolve_lock_params(
quote,
facts,
option_no=option_no,
airline=airline,
flight_no=flight_no,
instruction=instruction,
)
missing = _missing_lock_labels(picked, air_code, flight)
if missing and _quote_ready_for_lock(quote):
_remember_partial_lock(
flow,
work_order_no=no,
sender_id=sender_id,
option_no=picked,
airline=air_code,
flight_no=flight,
)
send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_params(missing))
return "wait_lock_params"
locker = getattr(flow.ledger, "lock_cabin", None)
if not callable(locker):
send_group_text(
@@ -864,7 +905,8 @@ def apply_air_lock(
sender_id=sender_id,
operator_name=name,
instruction_text=instruction,
airline=(airline or "").strip(),
airline=air_code,
flight_no=flight,
)
or {},
is_ok=lambda x: bool((x or {}).get("ok")),
@@ -873,6 +915,28 @@ def apply_air_lock(
if str(out.get("error") or "") == "no_quote":
send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_quote(no))
return "no_quote"
# 主账在出站前发现三项不齐:只请补,不当 TMS 故障,不通知 IT。
reason_text = str(out.get("failReason") or "")
if str(out.get("error") or "") == "lock_params_incomplete" or "锁舱参数不完整" in reason_text:
labels = missing or _labels_from_incomplete_reason(reason_text)
_remember_partial_lock(
flow,
work_order_no=no,
sender_id=sender_id,
option_no=picked,
airline=air_code,
flight_no=flight,
)
send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_params(labels))
return "wait_lock_params"
# 多段是产品限制,不是 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(
tms_reason=str(out.get("failReason") or out.get("error") or ""),
flight_date=_ticket_flight_date(ticket),
@@ -895,6 +959,8 @@ def apply_air_lock(
conversation_target=chat_id,
extra={
"option_no": picked,
"airline": air_code,
"flight_no": flight,
"instruction": instruction,
"sender_id": sender_id,
"operator_name": name,
@@ -914,6 +980,9 @@ def apply_air_lock(
_LOCK_QUOTE: quote_no,
_LOCK_EXEC: exec_no,
_PENDING_LOCK: "",
_PENDING_LOCK_OPTION: "",
_PENDING_LOCK_AIRLINE: "",
_PENDING_LOCK_FLIGHT: "",
},
sender_id=sender_id,
)
@@ -1160,6 +1229,350 @@ def _is_product_quote(quote: dict[str, Any]) -> bool:
return "航线" in src or "产品" in src
_OPTION_RE = re.compile(r"AIR-?OPT-?\d+")
_AIRLINE_AFTER_OPTION_RE = re.compile(r"AIR-?OPT-?\d+\s*([A-Z]{2})(?![A-Z0-9])")
_AIRLINE_LABELED_CODE_RE = re.compile(
r"航司(?:代码|二字码)?\s*[::]?\s*(?:是|为)?\s*([A-Z]{2}|[A-Z]\d|\d[A-Z])(?![A-Z0-9])"
)
_AIRLINE_LABELED_NAME_RE = re.compile(
r"航司(?:代码|二字码)?\s*[::]?\s*(?:是|为)?\s*([\u4e00-\u9fffA-Za-z0-9]{2,12})"
)
_FLIGHT_LABELED_RE = re.compile(
r"(?:航班号|航班)\s*[::]?\s*(?:是|为)?\s*([A-Z]{2}\s*\d{2,4}|[A-Z]\d\s*\d{2,4}|\d[A-Z]\s*\d{2,4})"
)
_FLIGHT_TRAIL_RE = re.compile(
r"AIR-?OPT-?\d+\s+(?:[A-Z]{2}\s+)?([A-Z]{2}\s*\d{2,4})"
)
_FLIGHT_LOOSE_RE = re.compile(r"(?<![A-Z0-9])(?!WO)([A-Z]{2}\d{2,4})(?!\d)")
_AIRLINE_CODE_RE = re.compile(r"^(?:[A-Z]{2}|[A-Z]\d|\d[A-Z])$")
_FLIGHT_CODE_RE = re.compile(r"^(?:[A-Z]{2}|[A-Z]\d|\d[A-Z])\d{2,4}$")
# 口语航司名归一成二字码。认不出的原样留下,交给 TMS,不因此再追问。
_AIRLINE_ALIASES = (
("中国南方航空", "CZ"),
("南方航空", "CZ"),
("南航", "CZ"),
("中国国际航空", "CA"),
("国际航空", "CA"),
("国航", "CA"),
("中国东方航空", "MU"),
("东方航空", "MU"),
("东航", "MU"),
("海南航空", "HU"),
("海航", "HU"),
("厦门航空", "MF"),
("厦航", "MF"),
("深圳航空", "ZH"),
("深航", "ZH"),
("四川航空", "3U"),
("川航", "3U"),
("山东航空", "SC"),
("山航", "SC"),
("春秋航空", "9C"),
("吉祥航空", "HO"),
("新加坡航空", "SQ"),
("新航", "SQ"),
("泰国航空", "TG"),
("泰航", "TG"),
("国泰航空", "CX"),
("国泰", "CX"),
("卡塔尔航空", "QR"),
("顺丰航空", "O3"),
)
def lock_params_from_text(text: str) -> tuple[str, str, str]:
"""
从航线原话抽出舱位编号、航司、航班号。
不要求「AIR-OPT01 CZ 航班CZ327」这一种写法。
带「舱位 / 航司 / 航班」的说法、换顺序、航司用中文名,都认。
本地对不上、话里又在补这三项时,再问模型。没说的不猜。
"""
option, airline, flight = _lock_params_local(text)
if option and airline and flight:
return option, airline, flight
if not _should_ask_lock_model(text, option, airline, flight):
return option, airline, flight
got_opt, got_air, got_flight = _lock_params_from_model(text)
return (
option or got_opt,
airline or _normalize_airline(got_air),
flight or _normalize_flight(got_flight),
)
def text_has_lock_supplement(text: str) -> bool:
"""这句话是在补锁舱三项,不是报价或闲聊。凑齐两项,或点名了舱位/航司/航班,都算。"""
option, airline, flight = _lock_params_local(text)
filled = sum(1 for item in (option, airline, flight) if item)
if filled >= 2:
return True
raw = text or ""
if filled < 1 or not any(word in raw for word in ("舱位", "航司", "航班")):
return False
from agent.routing.group_intent import text_is_staff_fee_quote
return not text_is_staff_fee_quote(raw)
def _lock_params_local(text: str) -> tuple[str, str, str]:
"""按原话里的编号、标签和常见航司名收三项。不调模型。"""
raw = text or ""
blob = raw.upper()
option = _option_from_blob(blob)
flight = _flight_from_blob(blob)
airline = _airline_from_text(raw, blob, option=option, flight=flight)
return option, airline, flight
def _option_from_blob(blob: str) -> str:
found = _OPTION_RE.search(blob or "")
if not found:
return ""
raw = found.group(0)
if raw.startswith("AIROPT"):
return "AIR-OPT" + raw[6:]
return raw
def _flight_from_blob(blob: str) -> str:
labeled = _FLIGHT_LABELED_RE.search(blob or "")
if labeled:
return _normalize_flight(labeled.group(1))
trailed = _FLIGHT_TRAIL_RE.search(blob or "")
if trailed:
return _normalize_flight(trailed.group(1))
loose = _FLIGHT_LOOSE_RE.search(blob or "")
if loose:
return _normalize_flight(loose.group(1))
return ""
def _airline_from_text(raw: str, blob: str, *, option: str, flight: str) -> str:
"""航司可以是二字码,也可以是「航司南航」这种说法。"""
labeled = _AIRLINE_LABELED_CODE_RE.search(blob or "")
if labeled:
return labeled.group(1)
named = _AIRLINE_LABELED_NAME_RE.search(raw or "")
if named:
token = named.group(1).strip()
if token.upper() not in {"AIR", "OPT"} and not token.upper().startswith("AIR-OPT"):
return _normalize_airline(token)
after = _AIRLINE_AFTER_OPTION_RE.search(blob or "")
if after:
return after.group(1)
# 没写「航司」二字时,只有这句话已经带了舱位或航班,才从航司名里认,避免报价句子被误伤。
if option or flight:
found = _alias_in_text(raw)
if found:
return found
return ""
def _normalize_flight(value: str) -> str:
compact = re.sub(r"\s+", "", (value or "").upper())
return compact if _FLIGHT_CODE_RE.match(compact) else ""
def _normalize_airline(value: str) -> str:
text = (value or "").strip()
if not text:
return ""
upper = re.sub(r"\s+", "", text.upper())
if _FLIGHT_CODE_RE.match(upper):
return ""
if _AIRLINE_CODE_RE.match(upper):
return upper
alias = _alias_in_text(text)
if alias:
return alias
if re.fullmatch(r"[\u4e00-\u9fffA-Za-z0-9]{2,12}", text) and not text.upper().startswith("AIR"):
return text
return ""
def _alias_in_text(text: str) -> str:
raw = text or ""
for name, code in _AIRLINE_ALIASES:
if name in raw:
return code
return ""
def _airline_ready(airline: str) -> bool:
text = (airline or "").strip()
if _AIRLINE_CODE_RE.match(text.upper()):
return True
return bool(_normalize_airline(text))
def _should_ask_lock_model(text: str, option: str, airline: str, flight: str) -> bool:
"""三项已经齐,或这句话根本没在说舱位,就不要再打模型。"""
if option and airline and flight:
return False
raw = text or ""
if any(word in raw for word in ("舱位", "航司", "航班")):
return True
blob = raw.upper()
return "AIR-OPT" in blob or "AIROPT" in blob
def _lock_params_from_model(text: str) -> tuple[str, str, str]:
"""
模型只抽舱位编号、航司、航班号。
网络未开或调用失败时当没抽到,群里继续按缺项追问。不在请求里猜。
"""
try:
from agent.config import get_settings
from agent.llm.http_client import LlmHttpClient
from agent.llm.modes import LlmMode
settings = get_settings()
if not (
getattr(settings, "llm_allow_network", False)
and getattr(settings, "llm_data_usage_confirmed", False)
):
return "", "", ""
client = LlmHttpClient.from_settings(settings)
data = client.chat(
mode=LlmMode.EXTRACT_FIELDS,
messages=[
{
"role": "system",
"content": (
"从航线这句话抽出锁舱三项。只回复一行 JSON,不要解释。"
'键为 optionNo、airline、flightNo。没有的填空字符串。'
"optionNo 是舱位编号,如 AIR-OPT01;airline 是航司二字码或航司名;"
"flightNo 是航班号,如 CZ327。不要编造原文没有的值。"
),
},
{"role": "user", "content": (text or "")[:500]},
],
)
content = str((data or {}).get("content") or "")
start = content.find("{")
end = content.rfind("}")
if start < 0 or end <= start:
return "", "", ""
payload = json.loads(content[start : end + 1])
if not isinstance(payload, dict):
return "", "", ""
option = _option_from_blob(str(payload.get("optionNo") or "").upper())
airline = _normalize_airline(str(payload.get("airline") or ""))
flight = _normalize_flight(str(payload.get("flightNo") or ""))
return option, airline, flight
except Exception:
logger.exception("锁舱三项模型抽取失败")
return "", "", ""
def _resolve_lock_params(
quote: dict[str, Any],
facts: dict[str, Any],
*,
option_no: str,
airline: str,
flight_no: str,
instruction: str,
) -> tuple[str, str, str]:
"""
拼出锁舱三项。
这一句里写的优先;没写的用上一句暂存的;再没有才用报价上已有的舱位。
航司写成南航、东方航空这类名字时归一成二字码;归一不了也算已经补充,不再追问。
"""
said_opt, said_air, said_flight = lock_params_from_text(instruction)
option = (option_no or said_opt or str(facts.get(_PENDING_LOCK_OPTION) or "")).strip()
air = _normalize_airline(airline or said_air or str(facts.get(_PENDING_LOCK_AIRLINE) or ""))
flight = _normalize_flight(flight_no or said_flight or str(facts.get(_PENDING_LOCK_FLIGHT) or ""))
chosen = _option_row(quote, option)
if chosen is None and not option:
options = _air_options(quote)
if len(options) == 1:
chosen = options[0]
option = str(chosen.get("optionNo") or chosen.get("option_no") or "").strip()
if not _airline_ready(air) and chosen is not None:
air = _normalize_airline(
str(chosen.get("airlineCode") or chosen.get("airline") or "")
)
if not flight and chosen is not None:
flight = _normalize_flight(str(chosen.get("flightNo") or chosen.get("flight_no") or ""))
if not flight:
flight = _normalize_flight(str(quote.get("flightNo") or quote.get("flight_no") or ""))
if not _airline_ready(air):
air = ""
if not air and len(flight) >= 2 and flight[:2].isalpha():
air = flight[:2]
return option, air, flight
def _option_row(quote: dict[str, Any], option_no: str) -> Optional[dict[str, Any]]:
"""按舱位编号找报价里的那一条;对不上返回空。"""
want = (option_no or "").strip().upper()
if not want:
return None
for row in _air_options(quote):
no = str(row.get("optionNo") or row.get("option_no") or "").strip().upper()
if no == want:
return row
return None
def _missing_lock_labels(option_no: str, airline: str, flight_no: str) -> list[str]:
"""缺的中文名,顺序固定:舱位编号、航司、航班号。"""
missing: list[str] = []
if not (option_no or "").strip():
missing.append("舱位编号")
if not _airline_ready(airline):
missing.append("航司")
if not _FLIGHT_CODE_RE.match((flight_no or "").strip().upper()):
missing.append("航班号")
return missing
def _labels_from_incomplete_reason(reason: str) -> list[str]:
"""主账回了「缺少:…」时,按中文名收回缺项,丢掉英文字段名。"""
found = [label for label in ("舱位编号", "航司", "航班号") if label in (reason or "")]
return found or ["舱位编号", "航司", "航班号"]
def _quote_ready_for_lock(quote: dict[str, Any]) -> bool:
"""有总价、费用或报价编号才值得让航线补舱位。完全没报价仍走「还没有报价」。"""
if str(quote.get("total") or "").strip() not in {"", "-"}:
return True
if quote.get("fee_lines") or quote.get("feeItems") or quote.get("departureCharges"):
return True
if _air_options(quote):
return True
return bool(str(quote.get("quoteId") or quote.get("quote_id") or "").strip())
def _remember_partial_lock(
flow: Any,
*,
work_order_no: str,
sender_id: str,
option_no: str,
airline: str,
flight_no: str,
) -> None:
"""先记下已经说清的项,下一句补齐后接着锁,不用重说。"""
patcher = getattr(flow.ledger, "patch_collab_facts", None)
if not callable(patcher):
return
patcher(
work_order_no=work_order_no,
facts={
_PENDING_LOCK: "1",
_PENDING_LOCK_OPTION: option_no,
_PENDING_LOCK_AIRLINE: airline,
_PENDING_LOCK_FLIGHT: flight_no,
},
sender_id=sender_id,
)
def _quote_id(quote: dict[str, Any]) -> str:
return str(quote.get("quoteId") or quote.get("quote_id") or "").strip()
+48 -18
View File
@@ -418,21 +418,21 @@ class AirTextInquiryFlow:
hit.business_line = str(view.get("business_line") or "").strip().upper()
if hit is None:
if not callable(getter):
reply(f"找不到工单 {no},请核对工单号后再试。")
reply(copy.continue_ticket_not_found(no))
return "ticket_missing"
if not view.get("found"):
reply(f"找不到工单 {no},请核对工单号后再试。")
reply(copy.continue_ticket_not_found(no))
return "ticket_missing"
line = str(view.get("business_line") or "").strip().upper()
owner = self._handoff_owner_for_line(line)
if owner is None:
reply(copy.ASK_TRANSPORT)
reply(copy.ask_transport())
return "need_mode"
hit = owner._session_from_ledger(sender_id=sender_id, view=view)
line = (hit.business_line or "").strip().upper()
owner = self._handoff_owner_for_line(line)
if owner is None:
reply(copy.ASK_TRANSPORT)
reply(copy.ask_transport())
return "need_mode"
logger.info(
"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 = (mode or "").upper()
if not mode:
reply(copy.ASK_TRANSPORT)
reply(copy.ask_transport())
self._save(
FlowSession(
sender_id=sender_id,
@@ -538,7 +538,7 @@ class AirTextInquiryFlow:
sess.business_line = "SEA"
line = (sess.business_line or "").upper()
if has_collab_group(sess):
reply(copy.HANDOFF_ALREADY_COLLAB)
reply(copy.already_collab())
return "handoff_already"
why = copy.HANDOFF_CLARIFY_REASON if reason == "clarify" else copy.HANDOFF_INTENT_REASON
if line == "AIR":
@@ -711,7 +711,7 @@ class AirTextInquiryFlow:
"""
no = (work_order_no or "").strip().upper()
if not no:
reply("请告诉我要继续的工单号,例如 WO202609140034。")
reply(copy.continue_need_wo())
return "need_work_order"
with self._lock:
sess = self._by_ticket.get(no)
@@ -730,11 +730,11 @@ class AirTextInquiryFlow:
return self._replay_wait(keep, reply, source="session")
getter = getattr(self._ledger, "get_for_agent", None)
if not callable(getter):
reply(f"找不到工单 {no},请核对工单号后再试。")
reply(copy.continue_ticket_not_found(no))
return "ticket_missing"
view = getter(work_order_no=no, sender_id=sender_id) or {}
if not view.get("found"):
reply(f"找不到工单 {no},请核对工单号后再试。")
reply(copy.continue_ticket_not_found(no))
return "ticket_missing"
if view.get("owned") is False:
reply("这张工单不属于当前账号,不能继续。")
@@ -879,7 +879,7 @@ class AirTextInquiryFlow:
if sess.phase == "wait_file":
self._refresh_wait_file(sess)
if sess.phase == "wait_file":
reply("报价单正在生成,请稍候。")
reply(copy.quote_generating())
return "wait_file"
if sess.phase == "wait_deal":
# 成交卡已经绑在报价文件那条出站上。只续办、没说成交结果时不再另发一张卡。
@@ -964,9 +964,8 @@ class AirTextInquiryFlow:
reply=reply,
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)
snap = extract_inquiry_snapshot(
@@ -1006,7 +1005,7 @@ class AirTextInquiryFlow:
)
if not mode:
reply(copy.ASK_TRANSPORT)
reply(copy.ask_transport())
self._save(
FlowSession(
sender_id=sender_id,
@@ -1055,7 +1054,7 @@ class AirTextInquiryFlow:
chat_fn=chat_fn,
inbound_raw=inbound_raw,
)
reply(copy.ASK_TRANSPORT)
reply(copy.ask_transport())
return "need_mode"
if is_waiting_mode_session(sess):
@@ -1261,13 +1260,39 @@ class AirTextInquiryFlow:
quote = tms["quote"]
from agent.schema.air_route_options import (
choose_stated_origin_option,
needs_route_pick,
options_matching_origin,
parse_air_options,
project_selected_quote,
)
options = parse_air_options(quote)
if options and not needs_route_pick(quote):
stated_origin = str(
(sess.facts or {}).get("起运港") or (sess.facts or {}).get("起运地") or ""
).strip()
# 写了起运地:只认这个起运地的线路,直接报价,不让销售再选。
# 没写起运地:TMS 按目的地回多条时,才进选线页。
if stated_origin:
matched = options_matching_origin(options, stated_origin)
if options and not matched:
logger.info(
"air 起运地对不上回包线路 origin=%s wo=%s options=%s",
stated_origin,
no,
len(options),
)
self._emit_tms_miss(reply, no)
sess.phase = "tms_miss"
sess.status = "询价中"
sess.allowed = ()
self._save(sess)
return "tms_miss"
if matched:
quote = project_selected_quote(
quote, choose_stated_origin_option(matched, quote)
)
elif options and not needs_route_pick(quote):
quote = project_selected_quote(quote, options[0])
up = self._ledger.upsert_quote(work_order_no=no, quote=quote, to_status="已报价")
if not up.get("ok"):
@@ -1279,7 +1304,7 @@ class AirTextInquiryFlow:
copy.remember_tms_quote(sess, quote)
sess.tms_quote = dict(quote)
sess.status = "已报价"
if needs_route_pick(quote):
if not stated_origin and needs_route_pick(quote):
return self._offer_air_routes(
sess=sess,
work_order_no=no,
@@ -1977,7 +2002,12 @@ class AirTextInquiryFlow:
sess.phase = "done"
sess.allowed = ()
self._save(sess)
reply(copy.lost_reason_ack(str(result.get("lost_reason") or text)))
reply(
copy.lost_reason_ack(
str(result.get("lost_reason") or text),
work_order_no=sess.work_order_no,
)
)
return "done"
def mark_wait_deal(self, sender_id: str, work_order_no: str) -> None:
+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 []
+375 -85
View File
@@ -2,7 +2,8 @@
空运文字询价出站话术(销售可见)。
本文件职责:集中文案;按钮字面量必须与 handler 精确匹配。
禁止:在本文件调主账/TMS;禁止把密钥写进话术。
销售可见固定句经 bot_reply 按调用编码读后台;停用/失败回退本文件默认句。
禁止:在本文件直接 request 主账;禁止把密钥写进话术。
"""
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.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 项 + 非必填。报价日期默认当天,不进待补充。
_AIR_EXAMPLES = {
"起运港": "南京",
@@ -50,9 +62,11 @@ _AIR_OPTIONAL_CLARIFY = tuple(
(key, label, _AIR_EXAMPLES.get(key, "")) for key, label in AIR_OPTIONAL_FIELDS
)
AIR_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。"
# 核对卡已取消时效,不再对销售发送此句;常量仅保留避免旧引用报错。
AIR_CONFIRM_EXPIRED = "刚才的空运核对已过期,请把空运需求再发一次,出核对卡后再回复确定。"
# 海运私聊与空运同一句。核对卡没有工单号,回复确定后才建单查价。
SEA_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。"
# 核对卡已取消时效,不再对销售发送此句。
SEA_CONFIRM_EXPIRED = "刚才的海运核对已过期,请把海运需求再发一次,出核对卡后再回复确定。"
# 海运补问:与合同 10 项一致。报价日期默认当天,不进待补充。
@@ -125,10 +139,79 @@ ASK_TRANSPORT = (
"2.海运\n"
"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 = "请复制粘贴以下内容,通过回复数字或文字方式,告知我以下字段值:"
_PENDING_UNITS = {}
HANDOFF_ALREADY_COLLAB = "该工单已拉产品进群协同。"
def already_collab() -> str:
"""该工单已拉过产品进群。后台 transfer.already_collab。"""
return _r("transfer.already_collab", HANDOFF_ALREADY_COLLAB)
HANDOFF_CLARIFY_REASON = "参数提取不全(连续 3 次反问仍不完整)"
HANDOFF_INTENT_REASON = "已识别您希望转人工处理"
CLARIFY_LIMIT_HINT = "连续 3 次反问仍不完整将触发转人工"
@@ -214,6 +297,7 @@ def land_sample_image_fallback() -> str:
FIRST_CHAT = "你好,我是询价机器人。请直接描述客户需求,例如「上海到洛杉矶空运,3 件 180kg,下周三前到」,我会解析需求并查询 TMS 标准报价。"
ROUTE_MODEL_FAILED = "刚才没听清,请再说一次:是新询价,还是要接着办哪张工单?"
LAND_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。"
# 核对卡已取消时效。仅工单号丢失时内部兜底,不对销售当「过期」主路径。
LAND_CONFIRM_EXPIRED = "刚才的陆运核对已过期,请把陆运需求再发一次,出核对卡后再回复确定。"
LAND_TMS_RETRY = "工单 {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:
"""没有对上线路的陆运产品,不建空群。"""
return "没有可拉的产品人员。请先在后台「员工管理」维护本线路的陆运产品岗后再试。"
"""没有对上线路的陆运产品,不建空群。后台 inquiring.land_no_staff。"""
return _r(
"inquiring.land_no_staff",
"没有可拉的产品人员。请先在后台「员工管理」维护本线路的陆运产品岗后再试。",
)
def land_group_pair_rejected() -> str:
@@ -1132,10 +1219,10 @@ def sea_quote_detail_text(
def collab_hint(work_order_no: str) -> str:
"""价格卡协同说明:无「内部协同」前缀,紧挨生成报价单按钮上方。"""
"""空运价格卡协同说明:请把相关方拉进原有协同群,并 @ 询价小助手。紧挨生成报价单按钮上方。"""
return (
f"若需要咨询内部航线人员,请手动拉新人与智能体到原有群,"
f"并在群聊发送工单编号{work_order_no}。"
f"请手动将相关方拉到空运原有协同群,并在空运协同群中发送"
f"“ {work_order_no}@询价小助手”,谢谢。"
)
@@ -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:
"""文字兜底里的拉群句,与现网图一致。"""
return f"请手动拉相关人员和智能体到原有群,并在群聊发送工单编号{work_order_no}"
"""请手动拉人进群并发送工单号。后台 inquiring.pull_group_manual。"""
no = (work_order_no or "").strip()
return _r(
"inquiring.pull_group_manual",
f"请手动拉相关人员和智能体到原有群,并在群聊发送工单编号{no}",
ticket_no=no,
)
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:
return "好的,已跳过内部协同。正在按 TMS 标准报价生成报价单草稿…"
"""销售跳过内部协同。后台 inquiring.skip_collab_ack。"""
return _r(
"inquiring.skip_collab_ack",
"好的,已跳过内部协同。正在按 TMS 标准报价生成报价单草稿…",
)
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_SAME_MODE = "同一运输方式出现了两段,请拆开再发。"
# 核对卡已取消时效,不再对销售发送此句。
MULTI_CONFIRM_EXPIRED = "刚才的多段核对已过期,请把多段需求再发一次,出核对后再回复确定。"
def multi_air_manual_hint(*, work_order_no: str) -> str:
"""含空运的多段:不自动拉群,请销售把人和智能体拉进原有群并发送工单号。"""
no = (work_order_no or "").strip()
return f"请手动拉相关人员和智能体到原有群,并在群聊发送工单编号{no}"
"""含空运的多段:不自动拉群。与 inquiring.pull_group_manual 同一条后台。"""
return tms_miss_quote_text(work_order_no=work_order_no)
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:
"""空运转人工:只发文字,不建群。"""
"""空运转人工:只发文字,不建群。后台 transfer.air_hint。"""
no = (work_order_no or "").strip()
if no:
return f"触发转人工,请手动把航线和智能体拉进原有空运群,并发送工单号 {no}。"
return "触发转人工,请手动把航线和智能体拉进原有空运群,并发送工单号。"
default = f"触发转人工,请手动把航线和智能体拉进原有空运群,并发送工单号 {no}。"
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:
@@ -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:
"""拉群成功后的私聊。已邀请写成「销售某某、产品某某」,岗位不能省略。"""
"""拉群成功后的私聊。后台 inquiring.group_created。已邀请写成「销售某某、产品某某」。"""
no = (work_order_no or "").strip()
sales = _role_label(sales_name, "销售")
products = "、".join(
_role_label(n, "产品") for n in (product_names or []) if (n or "").strip()
) or "产品"
return (
sales_raw = (sales_name or "").strip()
sales = _role_label(sales_raw, "销售")
name_bits = [n.strip() for n in (product_names or []) if (n or "").strip()]
products = "、".join(_role_label(n, "产品") for n in name_bits) or "产品"
default = (
f"协同群已创建,群名与工单号一致:{no}。"
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:
"""同一工单已有群,不再新建。"""
"""同一工单已有群,不再新建。后台 inquiring.group_exists。"""
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:
"""没有可拉的产品人员,不建空群。"""
return "没有可拉的产品人员。请先在后台「员工管理」维护本线路的海运产品岗后再试。"
"""没有可拉的产品人员,不建空群。后台 inquiring.sea_no_staff。"""
return _r(
"inquiring.sea_no_staff",
"没有可拉的产品人员。请先在后台「员工管理」维护本线路的海运产品岗后再试。",
)
def group_sheet_send_fail() -> str:
@@ -1758,11 +1877,10 @@ def group_sheet_send_fail() -> str:
def sea_group_create_fail(*, reason: str = "") -> str:
"""建群失败,按钮保持可点。"""
"""建群失败,按钮保持可点。后台 inquiring.group_create_fail(无原因短句)。"""
extra = (reason or "").strip()
if extra:
return f"建群失败:{extra}。请稍后重试。"
return "建群失败,请稍后重试。"
default = f"建群失败:{extra}。请稍后重试。" if extra else "建群失败,请稍后重试。"
return _r("inquiring.group_create_fail", default, reason=extra)
def quote_is_product(quote: dict[str, Any] | None) -> bool:
@@ -2212,7 +2330,11 @@ def scheme_wecom_payload(
def reject_ack() -> str:
return "请问如何调整报价单,以满足您的需求?请说明要调哪一项费用,例如「提货费加 10%」。"
"""销售选择不采用。后台 quoted.reject_ack。"""
return _r(
"quoted.reject_ack",
"请问如何调整报价单,以满足您的需求?请说明要调哪一项费用,例如「提货费加 10%」。",
)
def adjust_confirm(applied: list[dict[str, Any]]) -> str:
@@ -2262,8 +2384,14 @@ def adjust_ineligible(reason: str) -> str:
return "当前工单还没有可调的报价,不能按这套调价。"
def adjust_only_sales() -> str:
return "只有这张单的销售能调价。航线/产品请继续报价,价格未改。"
def adjust_only_sales(*, air_room: bool = False) -> str:
"""
群里不是这张单的销售在发调价。价格不改。
空运群称呼航线;海运、陆运称呼产品。两句用句号分开,避免连成一句。
"""
who = "航线" if air_room else "产品"
return f"只有这张单的销售能调价。{who}请继续报价,价格未改。"
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:
return "已确认采用该方案,正在生成 Excel 最终报价单…"
"""采用并生成 Excel。后台 quoted.adopt_excel。"""
return _r("quoted.adopt_excel", "已确认采用该方案,正在生成 Excel 最终报价单…")
def adopt_pdf_ack() -> str:
return "已确认采用该方案,正在生成PDF最终报价单…"
"""采用并生成 PDF。后台 quoted.adopt_pdf。"""
return _r("quoted.adopt_pdf", "已确认采用该方案,正在生成PDF最终报价单…")
QUOTE_TEMPLATE_MISSING = "生成报价单失败,请联系IT运维维护后台报价模板。"
def quote_template_missing() -> str:
"""关键词与默认模板都未维护时的销售提示(产品口径,勿改字)。"""
return QUOTE_TEMPLATE_MISSING
"""关键词与默认模板都未维护。后台 quoted.template_missing。"""
return _r("quoted.template_missing", QUOTE_TEMPLATE_MISSING)
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:
return "只有产品能报价格,请产品在群里发价格或 Excel/PDF"
"""非产品人员在群里报价。后台 inquiring.group_only_product。"""
return _r(
"inquiring.group_only_product",
"只有产品能报价格,请产品在群里发价格或 Excel/PDF",
)
_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:
"""群里告诉大家:模板或产品附件已进后台。"""
"""
群里告诉大家:模板或产品附件已进后台。
后台 inquiring.group_file_saved。默认句带工单号和份数;后台改成短句则原样用。
"""
no = (work_order_no or "").strip() or "WO"
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:
return "本单还没有可沿用的 TMS 报价,请直接回复协同报价或上传报价附件。"
def group_file_saved(*, work_order_no: str = "", count: int = 1) -> str:
"""群附件已保存。与 quote_attachment_saved_app 同一条后台编码。"""
return quote_attachment_saved_app(work_order_no, count)
def group_ask_tms_or_product() -> str:
@@ -2490,8 +2641,8 @@ def group_need_price_before_choice() -> str:
return "请产品先发价格,再说在TMS基础上改或不用TMS报价"
def group_file_saved() -> str:
return "附件已保存后台"
def group_confirm_need_tms() -> str:
return "本单还没有可沿用的 TMS 报价,请直接回复协同报价或上传报价附件。"
def group_quote_not_recognized() -> str:
@@ -2561,18 +2712,26 @@ def deal_ack(
next_followup_at: str = "",
lost_reason: str = "",
) -> str:
"""成交三态回执。协商中写明顺延三个工作日和下次再问的月日 9:00。"""
"""成交三态回执。后台 deal.recorded_won / lost.ask_reason / negotiating.recorded。"""
no = (work_order_no or "").strip()
if outcome == "已成交":
return f"已记录为「已成交」。工单 {work_order_no} 已关闭智能体跟进。"
return _r(
"deal.recorded_won",
f"已记录为「已成交」。工单 {no} 已关闭智能体跟进。",
ticket_no=no,
)
if outcome == "未成交":
reason = (lost_reason or "").strip()
if reason:
return f"已记录为「未成交」。{lost_reason_ack(reason)}"
return "已记录为「未成交」。请补充未成交原因。"
when = _followup_month_day(next_followup_at)
if when:
return f"已记录为「协商中」。已顺延三个工作日,{when}的9:00再次发起成交状态询问。"
return "已记录为「协商中」。已顺延三个工作日,第四个工作日早上9:00再次发起成交状态询问。"
return f"已记录为「未成交」。{lost_reason_ack(reason, work_order_no=no)}"
return _r("lost.ask_reason", "已记录为「未成交」。请补充未成交原因。", ticket_no=no)
when = _followup_month_day(next_followup_at) or "第四个工作日"
return _r(
"negotiating.recorded",
f"已记录为「协商中」。已顺延三个工作日,{when}的9:00再次发起成交状态询问。",
followup_when=when,
ticket_no=no,
)
def _followup_month_day(raw: str) -> str:
@@ -2591,12 +2750,20 @@ def _followup_month_day(raw: str) -> str:
return ""
def lost_reason_ack(reason: str = "") -> str:
"""收下未成交原因后回显原句,不再追问。"""
def lost_reason_ack(reason: str = "", work_order_no: str = "") -> str:
"""
收下未成交原因后的回执。后台 lost.reason_ack。
后台模板里的 {{lost_reason}}、{{ticket_no}} 都在这里填。
不传工单号时占位留空串,避免群里再露出 {{ticket_no}}。
"""
text = (reason or "").strip()
no = (work_order_no or "").strip()
if text.endswith(("。", ".", "!", "!", "?", "?")):
return f"已记下未成交原因:{text}"
return f"已记下未成交原因:{text}。" if text else "已记下未成交原因。"
default = f"已记下未成交原因:{text}"
else:
default = f"已记下未成交原因:{text}。" if text else "已记下未成交原因。"
return _r("lost.reason_ack", default, lost_reason=text, ticket_no=no)
def deal_notify_card(
@@ -2679,29 +2846,49 @@ def air_need_work_order() -> str:
def air_lock_need_work_order() -> str:
"""锁舱/释放必须自带工单号,不猜当前单。"""
return "锁舱或释放舱位请带上工单号,例如 WO202609160001锁舱。"
"""锁舱/释放必须自带工单号。后台 inquiring.air_lock_need_wo。"""
return _r(
"inquiring.air_lock_need_wo",
"锁舱或释放舱位请带上工单号,例如 WO202609160001锁舱。",
)
def air_ticket_not_found(work_order_no: str) -> str:
"""空运群找不到工单。后台 inquiring.air_ticket_not_found。"""
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:
"""工单不是空运单。后台 inquiring.air_ticket_not_air。"""
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:
"""空运工单已是终态仍要激活。后台 closed.air_cannot_activate。"""
no = (work_order_no or "").strip()
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:
"""空运工单已在另一个群。后台 inquiring.air_ticket_occupied。"""
no = (work_order_no or "").strip()
return f"工单{no}已经在另一个群跟了,请到那个群继续。"
return _r(
"inquiring.air_ticket_occupied",
f"工单{no}已经在另一个群跟了,请到那个群继续。",
ticket_no=no,
)
def air_group_brief(
@@ -2720,7 +2907,8 @@ def air_group_brief(
空运群激活摘要:与海运进群同一套结构,字段用空运。
先 @ 销售和航线,再工单号 + 询价字段 + 协同补充(含电/含磁)+ TMS报价。
TMS 段跟单聊同一套线路分项,总销售价只一行;禁止用「空运费=总价」冒充。
TMS 段跟单聊同一套线路分项,总销售价只一行。
已有运输费等分项时,不再叠一行「空运费=总价」;只有空运费这一项时要写出来。
不写「当前工单/询价要点/现在等到哪一步」。
"""
no = (work_order_no or "").strip()
@@ -2820,7 +3008,11 @@ def air_airline_only_quote() -> 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:
@@ -2843,10 +3035,31 @@ def air_no_tms_quote_to_lock(work_order_no: str) -> str:
return air_lock_need_quote(work_order_no)
def air_lock_need_params(missing=None) -> str:
"""
舱位编号、航司、航班号没齐时,只请航线补全。
这时还没锁舱,不能说 TMS 锁舱失败,也不能通知 IT。
缺哪项就点哪项;三项都缺时是产品指定的那一句。
"""
labels = [str(x).strip() for x in (missing or []) if str(x).strip()]
if not labels:
labels = ["舱位编号", "航司", "航班号"]
joined = "、".join(labels)
return (
f"锁舱参数不完整,缺少:{joined}。"
"请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。"
)
def air_lock_need_quote(work_order_no: str) -> str:
"""没有任何报价时的锁舱拒绝。不提 TMS,避免把人工报价说成没报价。"""
"""没有任何报价时的锁舱拒绝。后台 inquiring.air_lock_need_quote。"""
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:
@@ -2869,23 +3082,67 @@ def air_lock_ok(
exec_no: str,
quote_no: str,
) -> str:
return (
"""空运锁舱成功。后台 inquiring.air_lock_ok。"""
default = (
f"工单{work_order_no} 已通知TMS执行空运锁舱,执行成功。\n"
f"操作人:{operator}\n"
f"舱位选项:{option_no}\n"
f"执行编号:{exec_no}\n"
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:
"""空运锁舱失败。后台 inquiring.air_lock_fail。"""
why = (reason or "").strip() or "TMS未返回原因"
return (
default = (
f"工单{work_order_no} 空运锁舱失败。\n"
f"操作人:{operator}\n"
f"锁舱失败的原因:{why}\n"
"已通知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:
@@ -2907,33 +3164,61 @@ def air_release_ok(
exec_no: str,
quote_no: str,
) -> str:
return (
"""空运释放成功。后台 inquiring.air_release_ok。"""
default = (
f"工单{work_order_no} 已通知TMS执行空运释放舱位成功\n"
f"操作人:{operator}\n"
f"舱位选项:{option_no}\n"
f"执行编号:{exec_no}\n"
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:
"""空运释放失败。后台 inquiring.air_release_fail。"""
why = (reason or "").strip() or "TMS未返回原因"
return (
default = (
f"工单{work_order_no} 空运释放舱位失败。\n"
f"操作人:{operator}\n"
f"释放舱位失败的原因:{why}\n"
"已通知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:
"""已成交不能释放舱位。后台 inquiring.air_release_deal_blocked。"""
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:
"""没有有效锁舱不能释放。后台 inquiring.air_release_need_lock。"""
no = (work_order_no or "").strip()
return f"工单{no}还没有有效锁舱,不能释放。"
return _r(
"inquiring.air_release_need_lock",
f"工单{no}还没有有效锁舱,不能释放。",
ticket_no=no,
)
# —— 系统异常(UC024):销售/群兜底与恢复;告警卡只给有权限的人 ——
@@ -2966,29 +3251,34 @@ IMPACT_GROUP = "当前建群/加人暂停,已建群不新建第二个群"
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()
if kind == TYPE_LLM:
return SYS_EXC_LLM_FALLBACK
return _r("sys.llm_fail", SYS_EXC_LLM_FALLBACK)
if kind == TYPE_GROUP:
people = [str(x).strip() for x in (names or []) if str(x).strip()]
if not people:
return "无法添加同事,请联系管理员检查企业微信配置。"
joined = "、".join(people)
return f"无法添加 {joined} 同事,请联系管理员检查企业微信配置。"
return SYS_EXC_TMS_FALLBACK
if joined:
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:
"""后台点重试成功后,先发这一句,再补没做完的事。"""
key = (step or "").strip()
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}:
return SYS_EXC_RECOVERY_CABIN
return _r("sys.cabin_recovery", SYS_EXC_RECOVERY_CABIN)
if key == STEP_GROUP:
return SYS_EXC_RECOVERY_GROUP
return SYS_EXC_RECOVERY_TMS
return _r("sys.group_recovery", SYS_EXC_RECOVERY_GROUP)
return _r("sys.tms_recovery", SYS_EXC_RECOVERY_TMS)
def system_exception_impact(step: str) -> str:
@@ -3022,7 +3312,7 @@ def system_exception_alert_payload(
"""
系统异常通知卡:走询价消息服务,不走询价小助手。
五行对齐现网示例:异常类型、接口/服务、错误码、影响范围、发生时间。
五行:异常类型、接口/服务、报错原因、影响范围、发生时间。
标题不加 [test]:运维要看的就是这张卡本身,test_prefix 仅保留兼容入参。
"""
no = (work_order_no or "").strip() or "无"
@@ -3031,7 +3321,7 @@ def system_exception_alert_payload(
rows = [
{"keyname": "异常类型", "value": (exception_type 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": (occurred_at or "").strip() or "-"},
]
@@ -3072,7 +3362,7 @@ def system_exception_alert_text(
f"关联工单 {no}",
f"异常类型:{(exception_type 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"发生时间:{(occurred_at or '').strip() or '-'}",
]
+4 -3
View File
@@ -174,7 +174,7 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
if not mode:
mode = "LAND" if (sess and (sess.business_line or "").upper() == "LAND") else ""
if not mode:
reply(copy.ASK_TRANSPORT)
reply(copy.ask_transport())
self._save(
FlowSession(
sender_id=sender_id,
@@ -364,8 +364,9 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
no = (sess.work_order_no or "").strip()
facts = dict(sess.facts or {})
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))
from agent.policy.system_exception import query_tms_resilient
+45 -9
View File
@@ -11,7 +11,8 @@ from __future__ import annotations
import json
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.policy import inquiry_copy as copy
@@ -20,6 +21,7 @@ from agent.policy.sea_group_ops import (
apply_group_deal,
group_client_of,
is_sales,
override_group_client,
send_filled_sheet,
send_group_text,
ticket_no,
@@ -33,6 +35,24 @@ _ROLE_MODE = {"sea": "SEA", "land": "LAND", "air": "AIR"}
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:
"""主账业务线是多段联运。"""
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:
"""空运段的锁舱、释放走现有空运规则,不挡住其他段报价。"""
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":
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,
instruction=text,
)
return apply_air_lock(
flow=flow,
ticket=ticket,
sender_id=sender_id,
chat_id=chat_id,
instruction=text,
if not is_airline(flow, sender_id):
send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin())
return "not_airline"
no = ticket_no(ticket)
send_group_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:
@@ -256,9 +256,7 @@ class MultiTextInquiryFlow:
allow_b=allow_b,
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空运。")
return "multi_need_text"
@@ -260,7 +260,14 @@ def apply_group_adjust(
send_group_text(client, room, copy.adjust_ineligible("no_quote"))
return "adjust_blocked"
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"
current = _current_quote(view, sess)
names = [str(r.get("name") or "") for r in adjustable_rows(current)]
+35 -3
View File
@@ -9,7 +9,9 @@ from __future__ import annotations
import json
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.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)
# 这一次群出站临时改用的客户端。只在当前线程有效,不改流程单例上的企微客户端。
_outbound_client: ContextVar[Any] = ContextVar("inquiry_group_outbound_client", default=None)
def group_client_of(flow: Any) -> Any:
"""正式路径没有注入客户端时,走流程自己的默认企微客户端。"""
"""
取这一次群出站用的客户端。
有临时指定时用指定的(含空运的多段要走机器人桥)。
没有时走流程自己的客户端;正式路径默认是应用发群。
"""
override = _outbound_client.get()
if override is not None:
return override
getter = getattr(flow, "group_client", None)
if callable(getter):
return getter()
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]:
"""会话存档账号(测服 AI001 / 询价机器人)不能出现在群 @ 里。"""
seats: set[str] = {"AI001", "询价机器人"}
@@ -1309,7 +1337,11 @@ def capture_group_lost_reason(
sess.phase = "done"
sess.allowed = ()
flow._save(sess)
send_group_text(client, chat_id, copy.lost_reason_ack(str(result.get("lost_reason") or text)))
send_group_text(
client,
chat_id,
copy.lost_reason_ack(str(result.get("lost_reason") or text), work_order_no=no),
)
return "done"
+3 -5
View File
@@ -120,9 +120,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
reply=reply,
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)
@@ -159,7 +157,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
mode = detect_transport_mode(f"{sess.immutable_text or ''}\n{text}")
if not mode:
reply(copy.ASK_TRANSPORT)
reply(copy.ask_transport())
self._save(
FlowSession(
sender_id=sender_id,
@@ -888,7 +886,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
)
if sess.collab_ended or (sess.collab_chat_id or "").strip():
reply(copy.HANDOFF_ALREADY_COLLAB)
reply(copy.already_collab())
return "handoff_already"
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)
@@ -95,6 +95,35 @@ def ticket_is_paused(ticket: Any) -> bool:
return bool(ticket_exception_type(ticket))
def ticket_pause_step(ticket: Any) -> str:
"""暂停是哪一步留下的。查价、锁舱、释放各算各的。"""
if ticket is None:
return ""
if isinstance(ticket, dict):
step = str(
ticket.get("system_exception_step") or ticket.get("systemExceptionStep") or ""
).strip()
payload = ticket.get("system_exception_payload") or ticket.get("systemExceptionPayload") or {}
else:
step = str(getattr(ticket, "system_exception_step", "") or "").strip()
payload = getattr(ticket, "system_exception_payload", {}) or {}
if step:
return step
if isinstance(payload, dict):
return str(payload.get("step") or "").strip()
return ""
def cabin_failure_pause(ticket: Any) -> bool:
"""
锁舱或释放失败留下的暂停。
群里已经回过失败原因,并通知了 IT。之后的手工报价、补字段、成交不能再被
「TMS接口异常」挡住。查价失败的暂停不在这里,那种仍要停。
"""
return ticket_pause_step(ticket) in {STEP_TMS_LOCK, STEP_TMS_RELEASE, STEP_TMS_CABIN}
def extract_work_order_nos(text: str) -> list[str]:
"""从原话抽出正式工单号(WO + 日期流水)。不认虚拟号。"""
import re
@@ -867,6 +896,8 @@ def _resume_cabin_step(
option_no=option_no,
sender_id=sender,
instruction_text=instruction,
airline=str(extra.get("airline") or ""),
flight_no=str(extra.get("flight_no") or ""),
)
or {}
if callable(locker)
@@ -26,6 +26,8 @@ from agent.routing.group_intent import (
_hint_confirm_tms,
_hint_deal_outcome,
_hint_tms_choice,
_prefer_staff_quote,
text_is_staff_fee_quote,
)
logger = logging.getLogger(__name__)
@@ -95,6 +97,11 @@ def classify_air_group_text(
locked = _hint_lock_or_release(raw)
if locked:
return locked
# 补舱位不限写法。凑齐舱位编号、航司、航班号就当选舱,不要先当成报价。
from agent.policy.air_group_ops import text_has_lock_supplement
if text_has_lock_supplement(raw):
return INTENT_PICK_OPTION
# 本单已有 TMS 报价时,航线回「2」是选不用 TMS,不是把价格改成 2。
exact = _exact_tms_choice(raw)
if exact:
@@ -118,12 +125,15 @@ def classify_air_group_text(
if use_net and getattr(settings, "llm_data_usage_confirmed", False):
got = _classify_with_model(raw, settings)
if got in _VALID:
return got
return _prefer_staff_quote(got, raw)
except Exception:
logger.exception("空运群意图模型失败,回退规则")
from agent.llm.extract_text import extract_air_collab_fields, extract_product_quote
from agent.schema.quote_adjust import extract_adjust
# 费用金额先当航线报价。「空运费改为900」不是销售调价。
if text_is_staff_fee_quote(raw):
return INTENT_QUOTE
if extract_adjust(raw).is_adjust:
return INTENT_ADJUST
if extract_product_quote(raw):
@@ -176,10 +186,10 @@ def _classify_with_model(text: str, settings) -> str:
"你是空运协同群意图分类。只回复一个英文词,不要解释。"
"air_lock=航线要锁舱;air_release=航线要释放舱位;"
"air_activate=只是指定/切换工单号;"
"air_pick_option=在选舱位选项编号;"
"air_pick_option=在补充或选择舱位编号、航司、航班号,写法不限;"
"collab_fields=在补是否含电/是否含磁;"
"quote_adjust=销售在调已有费用,口令不写死(上升、往上抬、加一成、改到某金额都算),不是新产品报价;"
"product_quote=航线在报价格或费用(含具体金额);"
"quote_adjust=销售在按比例改已有费用(上调、上浮、加10%、减5%、加一成),或只改总价/总销售价;"
"product_quote=航线在报某一项费用的金额。写出「空运费9900」「空运费改为900USD」是报价,不是调价;"
"quote_confirm_tms=确认沿用当前TMS报价;"
"quote_keep_tms=要在已有TMS报价上改;"
"quote_drop_tms=不要TMS、只用刚报的价;"
@@ -188,7 +198,7 @@ def _classify_with_model(text: str, settings) -> str:
"other=闲聊。"
"一句话里既有锁舱又有工单号,回 air_lock。"
"一句话里既有工单号又确认成交/未成交/协商中,回 deal_won/deal_lost/deal_negotiate,不要回 air_activate。"
"改已有费用优先 quote_adjust;新报价格才是 product_quote。"
"有具体费用金额就是 product_quote。只有比例或只改总价才是 quote_adjust。"
)
data = client.chat(
mode=LlmMode.ORDERED_ACTIONS,
+65 -4
View File
@@ -117,6 +117,63 @@ _DROP_HINTS = (
)
def text_is_staff_fee_quote(text: str) -> bool:
"""
航线/产品在报某一项费用的金额。
「空运费 9900」「空运费改为900USD」「报关费改为300CNY」都算手工报价。
比例调价和只改总价/总销售价不算,那两句仍留给销售调价。
句里没有费用名、只剩一个数字,也不算,避免把选项编号当成报价。
不打模型。分类和入站改判都会用。
"""
raw = (text or "").strip()
if not raw:
return False
from agent.schema.quote_adjust import text_has_relative_adjust, text_has_total_only_adjust
if text_has_relative_adjust(raw) or text_has_total_only_adjust(raw):
return False
from agent.llm.extract_text import extract_product_quote
quote = extract_product_quote(raw)
rows = [row for row in (quote.get("fee_rows") or []) if isinstance(row, dict)]
if not rows:
return False
# 「费用合计」里的「费」不能单独把改总价认成报价;上面已经排除只改总价。
if "费" in raw:
return True
names = [str(row.get("name") or "").strip() for row in rows]
return any(name and name != "海运费" for name in names)
def resolve_price_intent(intent: str, text: str, *, sales: bool) -> str:
"""
按说话人把「报价」和「调价」分开。
航线/产品写出费用金额:按报价办,不能回「只有销售能调价」。
销售在做比例、改总价,或点名把某项费用改到某个金额:仍按调价办。
不打模型。群入站分发前调用,避免手工报价被调价话术拦住。
"""
if intent not in {INTENT_ADJUST, INTENT_QUOTE}:
return intent
from agent.schema.quote_adjust import extract_adjust
# 这里只用本地规则。正式调价抽取仍在调价处理里,必要时再问模型。
if sales and extract_adjust(text, allow_model=False).is_adjust:
return INTENT_ADJUST
if text_is_staff_fee_quote(text):
return INTENT_QUOTE
return intent
def _prefer_staff_quote(intent: str, text: str) -> str:
"""模型或本地规则把费用金额判成调价时,改回报价。比例和改总价不动。"""
if intent == INTENT_ADJUST and text_is_staff_fee_quote(text):
logger.info("判成调价,但这是费用报价,改回报价 text=%s", (text or "")[:40])
return INTENT_QUOTE
return intent
def classify_group_text(
text: str,
*,
@@ -163,12 +220,15 @@ def classify_group_text(
if hinted_deal and got in {INTENT_DISSOLVE, INTENT_OTHER, INTENT_FIELDS}:
logger.warning("群意图模型把成交判成%s,改回 %s text=%s", got, hinted_deal, raw[:40])
return hinted_deal
return got
return _prefer_staff_quote(got, raw)
except Exception:
logger.exception("群意图模型失败,回退标签字段")
from agent.llm.extract_text import extract_product_quote
from agent.schema.quote_adjust import extract_adjust
# 费用金额先当报价。「改为900」是航线/产品常用写法,不能先被调价规则抢走。
if text_is_staff_fee_quote(raw):
return INTENT_QUOTE
if extract_adjust(raw).is_adjust:
return INTENT_ADJUST
if extract_product_quote(raw):
@@ -249,8 +309,8 @@ def _classify_with_model(text: str, settings) -> str:
system = (
"你是海运协同群意图分类。只回复一个英文词,不要解释。"
"collab_fields=在补货好时间/商品海关编码/含油含电含磁;"
"quote_adjust=销售在调已有费用,口令不写死(上升、上浮、往上抬、加一成、减、改到某金额都算),不是新产品报价;"
"product_quote=产品在报价格或费用(含具体金额);"
"quote_adjust=销售在按比例改已有费用(上调、上浮、加10%、减5%、加一成),或只改总价/总销售价;"
"product_quote=产品在报某一项费用的金额。写出「运费1000」「报关费改为300CNY」是报价,不是调价;"
"quote_confirm_tms=产品确认沿用当前TMS报价,没有报新金额;"
"quote_keep_tms=要在已有TMS报价上改,没有报新金额;"
"quote_drop_tms=不要TMS、只用产品刚报的价,没有报新金额;"
@@ -260,7 +320,8 @@ def _classify_with_model(text: str, settings) -> str:
"dissolve=要把这个协同群解散;"
"other=闲聊、问候、对询价港口的随口更正(不当改单)。"
"一句话里既补字段又报价,回 product_quote。"
"有金额就是 product_quote,不要判成 quote_keep_tms。"
"有具体费用金额就是 product_quote,不要判成 quote_adjust 或 quote_keep_tms。"
"只有比例或只改总价才是 quote_adjust。"
)
data = client.chat(
mode=LlmMode.ORDERED_ACTIONS,
+64 -10
View File
@@ -45,10 +45,55 @@ def parse_air_options(quote: dict[str, Any] | None) -> list[dict[str, Any]]:
def needs_route_pick(quote: dict[str, Any] | None) -> bool:
"""多于一条线路才进 H5 选。"""
"""没写起运地、且多于一条线路,才进 H5 选。有起运地时由调用方先按起运地收口。"""
return len(parse_air_options(quote)) > 1
def options_matching_origin(
options: list[dict[str, Any]],
origin_text: str,
) -> list[dict[str, Any]]:
"""
销售已经写了起运地时,只留起运机场对得上的线路。
杭州对 HGH,珠海对 ZUH。回包里的南京/深圳/郑州不能拿来给这张单选。
对不上返回空列表。不改入参。
"""
text = (origin_text or "").strip()
if not text:
return list(options)
code = resolve_airport_code(text).upper()
raw = text.upper()
matched: list[dict[str, Any]] = []
for opt in options:
origin = str(opt.get("originAirportCode") or opt.get("origin") or "").strip().upper()
if not origin:
continue
if code and origin == code:
matched.append(opt)
elif origin == raw:
matched.append(opt)
return matched
def choose_stated_origin_option(
options: list[dict[str, Any]],
quote: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""
起运地已指定时直接用一条,不再让销售选线。
只有一条就用它。多条时优先和报价总价相同的那条,否则用第一条。
"""
if len(options) == 1:
return options[0]
total = (quote or {}).get("total")
for opt in options:
if _same_amount(opt.get("salePrice"), total):
return opt
return options[0]
def _place_name(code: str, fallback: str = "") -> str:
"""三字码能对上就用中文,否则用原文或 fallback。"""
raw = (code or "").strip()
@@ -331,14 +376,14 @@ def _same_amount(left: Any, right: Any) -> bool:
def drop_air_freight_total_alias(quote: dict[str, Any] | None) -> dict[str, Any]:
"""
整单兜底「空运费=总价」不是线路分项,群摘要禁止再写。
已经有其它费用分项时,去掉「空运费=总价」这一行兜底。
真有空运费且金额不等于总价(例如另有附加费)则保留。
舱位上有运输费、提送货费时,主账快照常再塞一行空运费,金额就是总价,不能和分项叠在一起。
只有空运费这一项时必须留下:TMS 经常只回这一项,金额自然等于总价,群里仍要写出费用名。
不写库。群摘要投影之后调用。
"""
src = dict(quote or {})
total = src.get("total")
if _same_amount(src.get("freight"), total):
src["freight"] = "-"
raw = src.get("fee_lines") or src.get("fee_rows")
parsed: Any = raw
if isinstance(raw, str) and raw.strip():
@@ -348,17 +393,26 @@ def drop_air_freight_total_alias(quote: dict[str, Any] | None) -> dict[str, Any]
parsed = None
if not isinstance(parsed, list):
return src
kept: list[Any] = []
others: list[Any] = []
alias: list[Any] = []
for item in parsed:
if not isinstance(item, dict):
kept.append(item)
others.append(item)
continue
name = str(item.get("name") or item.get("label") or "").strip()
name = str(item.get("name") or item.get("label") or item.get("feeName") or "").strip()
amount = item.get("amount") or item.get("value")
if name == "空运费" and _same_amount(amount, total):
alias.append(item)
continue
kept.append(item)
src["fee_lines"] = kept
others.append(item)
if others:
# 分项已经在,空运费=总价是重复兜底。运费字段同值也不再补一行。
src["fee_lines"] = others
if _same_amount(src.get("freight"), total):
src["freight"] = "-"
return src
# 明细就是这一条空运费,群摘要要显示费用名,不能只剩费用合计。
src["fee_lines"] = alias
return src
+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] = {}
yes_no = (
("是否含电", ("不含电", "不带电", "无电"), ("含电", "带电")),
+11 -3
View File
@@ -504,12 +504,20 @@ def looks_like_confirm(text: str) -> bool:
"""
判断是否在表示确定查价。
「确定」「确认」「查价」「可以查了」及相近说法都算。
同一句像在改始发/目的/体积/车型/「改成」则不当确定,避免改内容被当成查价。
「确定」「确认」「查价」「可以查了」及相近短句才算。
长询价里「需确认/请确认」不算点核对卡,避免误回「核对已过期」。
同一句像在改始发/目的/体积/车型/「改成」则不当确定。
纯函数,无副作用。
"""
raw = (text or "").strip()
if not raw:
return False
if any(x in raw for x in _CONFIRM_EXCLUDE):
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)
@@ -493,6 +493,40 @@ def effective_quote(tms: dict[str, Any] | None, overlay: dict[str, Any] | None)
return out
def text_has_relative_adjust(text: str) -> bool:
"""
这句话是在按比例改价:加10%、减5%、上调、上浮、加一成。
这种是销售调价,不是航线/产品报一个新金额。不打模型。
"""
raw = text or ""
if _PERCENT_UP.search(raw) or _PERCENT_DOWN.search(raw):
return True
blob = raw.replace(" ", "").replace(" ", "")
return any(phrase in blob for phrase in ("加一成", "减一成", "往上抬", "往下压", "上浮", "下浮"))
def text_has_total_only_adjust(text: str) -> bool:
"""
只动总价/总销售价/费用合计,没有点名某一项费用。
「总销售价改为180」仍是销售调价。点了「空运费」「报关费」则不是。不打模型。
"""
raw = text or ""
if not (_TARGET.search(raw) or _PERCENT_UP.search(raw) or _PERCENT_DOWN.search(raw)):
return False
if not any(word in raw for word in _TOTAL_WORDS) and "报价" not in raw:
return False
real: list[str] = []
for name in _FEE_TOKEN.findall(raw):
if name in {"费用", "费用合计"}:
continue
if any(word in name for word in ("合计", "总价", "汇总")):
continue
real.append(name)
return not real
def extract_adjust(
text: str,
*,
+4 -1
View File
@@ -35,6 +35,9 @@ _AIRPORTS = {
"南京": "NKG",
"吉隆坡": "KUL",
"北京": "PEK",
"杭州": "HGH",
"萧山": "HGH",
"杭州萧山": "HGH",
}
# 历史字典:只给对照,空运出站不再编成 1/2/3。
@@ -199,7 +202,7 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]:
包装只看销售是否说了,散货等原文原样保留。
"""
src = dict(facts or {})
origin = str(src.get("起运港") or "").strip()
origin = str(src.get("起运港") or src.get("起运地") or "").strip()
dest = str(src.get("目的港") or "").strip()
cargo = str(src.get("品名") or "").strip()
pieces = _first_number(str(src.get("件数") or ""))
+5 -5
View File
@@ -3,8 +3,8 @@
本文件职责:核对卡通过后、建单查价前,确认当前运输类型的货物字段能拼出查价正文。
带去:运输类型、线路类别、始发/目的/品名/报价日期,以及该类型的体积重量或车型/数量;通关口岸有值才带。
出站类型/线路必须译成 TMS 6.1.1 英文枚举,线路键名 routeCategory。工单与卡片仍用中文。
中港整车、中港零担/集拼出站类型都是 CHINA_HK。中港整车不带车型/数量。
出站类型/线路译成 TMS 用的值,线路键名 routeCategory。工单与卡片仍用销售中文。
中港整车、中港零担/集拼出站类型都只写「中港」。中港整车不带车型/数量。
禁止:把运输分类放进出站;直连 TMS;改六态。
线程:纯函数。
"""
@@ -29,8 +29,8 @@ _TYPE_ENUM = {
"国内运输整车": "DOMESTIC_FTL",
"跨境集拼": "CROSS_BORDER_LTL",
"跨境整车": "CROSS_BORDER_FTL",
"中港整车": "CHINA_HK",
"中港零担/集拼": "CHINA_HK",
"中港整车": "中港",
"中港零担/集拼": "中港",
}
_ROUTE_ENUM = {
"国内长途/零担": "DOMESTIC_LONG_LTL",
@@ -45,7 +45,7 @@ _TAIL_QTY = re.compile(r"^(.*?)[\s,,]+(\d+)\s*(台|辆)?$")
def tms_land_type_code(raw: str) -> str:
"""销售中文运输类型 → TMS 6.1.1 landTransportType。对不上空串。"""
"""销售中文运输类型 → 查价 landTransportType。中港两类都是「中港」。对不上空串。"""
text = (raw or "").strip()
if text in _TYPE_ENUM:
return _TYPE_ENUM[text]
+344 -4
View File
@@ -160,7 +160,7 @@ class AirGroupCollabTests(unittest.TestCase):
"source_label": "TMS 标准报价",
"quoteId": "QT-AIR-1",
"airOptions": [
{"optionNo": "AIR-OPT-01", "airlineCode": "CZ", "airlineName": "南航"}
{"optionNo": "AIR-OPT-01", "airlineCode": "CZ", "airlineName": "南航", "flightNo": "CZ327"}
],
}
if quote:
@@ -203,6 +203,67 @@ class AirGroupCollabTests(unittest.TestCase):
self.assertEqual(classify_air_group_text("帮我锁舱 WO202609160001", allow_network=False), INTENT_LOCK)
self.assertEqual(classify_air_group_text("WO202609160001释放舱位", allow_network=False), INTENT_RELEASE)
def test_airline_fee_wording_is_quote_not_adjust(self) -> None:
"""航线写「空运费改为900」是手工报价,不能先判成调价。比例和改总价仍是调价。"""
from agent.routing.air_group_intent import INTENT_ADJUST, INTENT_QUOTE
self.assertEqual(
classify_air_group_text("空运费改为900USD", allow_network=False),
INTENT_QUOTE,
)
self.assertEqual(
classify_air_group_text("报关费改为300CNY", allow_network=False),
INTENT_QUOTE,
)
self.assertEqual(
classify_air_group_text("空运费 9900CNY", allow_network=False),
INTENT_QUOTE,
)
self.assertEqual(
classify_air_group_text("空运费加10%", allow_network=False),
INTENT_ADJUST,
)
self.assertEqual(
classify_air_group_text("总销售价改为180", allow_network=False),
INTENT_ADJUST,
)
def test_airline_rewritten_fee_does_not_say_only_sales(self) -> None:
"""已有报价后,航线再写费用金额,不能提示只有销售能调价。"""
wo = self._create_air()
handle_air_group(
self._msg(sender="air1", text=wo, chat="wr_air_1", mid="fee0"),
flow=self.flow,
ledger=self.ledger,
)
self.groups.sent.clear()
phase = handle_air_group(
self._msg(sender="air1", text="空运费改为900USD", chat="wr_air_1", mid="fee1"),
flow=self.flow,
ledger=self.ledger,
)
self.assertNotEqual(phase, "adjust_not_sales")
self.assertNotIn("只有这张单的销售能调价", "\n".join(self._texts()))
self.assertIn(phase, {"wait_tms_choice", "quoted"})
def test_sales_percent_still_adjusts(self) -> None:
"""销售说加百分之几,仍是调价,不会被改成航线报价。"""
wo = self._create_air()
handle_air_group(
self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="adj0"),
flow=self.flow,
ledger=self.ledger,
)
self.groups.sent.clear()
phase = handle_air_group(
self._msg(sender="sales1", text="空运费加10%", chat="wr_air_1", mid="adj1"),
flow=self.flow,
ledger=self.ledger,
)
self.assertNotEqual(phase, "not_airline")
self.assertNotIn("只有这张单的销售能调价", "\n".join(self._texts()))
self.assertIn(phase, {"group_adjusted", "adjust_need_fee", "adjust_none", "adjust_need_lines"})
def test_air_group_brief_matches_sea_sections(self) -> None:
text = copy.air_group_brief(
work_order_no="WO202609160009",
@@ -373,6 +434,51 @@ class AirGroupCollabTests(unittest.TestCase):
self.assertIn("费用合计:CNY 200040", tms_block)
self.assertEqual(tms_block.count("费用合计:"), 1)
def test_air_group_brief_keeps_sole_air_freight_line(self) -> None:
"""TMS 只有空运费、金额等于总价时,群摘要必须写出这一行,不能只剩费用合计。"""
tms = {
"source": "TMS",
"source_label": "TMS标准报价",
"total": "1833.7",
"freight": "1833.70",
"currency": "CNY",
"valid_until": "2026-10-21",
"fee_lines": '[{"name":"空运费","amount":"CNY 1833.70"}]',
"feeItems": [
{
"feeName": "空运费",
"feeCode": "AIR_FREIGHT",
"amount": 1833.70,
"unitPrice": 11.00,
"quantity": 166.7,
"currency": "CNY",
}
],
"airOptions": [
{
"originAirportCode": "CGO",
"destinationAirportCode": "KUL",
"salePrice": 1833.70,
"currency": "CNY",
"departureCharges": [],
}
],
}
text = copy.air_group_brief(
work_order_no="WO202610080020",
facts={"起运地": "CGO", "目的地": "吉隆坡", "originCode": "CGO"},
quote=tms,
collab_facts={},
has_price=True,
tms_quote=tms,
)
tms_block = text.split("TMS报价:", 1)[1]
self.assertIn("空运费:CNY 1833.70", tms_block)
self.assertLess(tms_block.find("空运费:"), tms_block.find("费用合计:"))
self.assertIn("费用合计:CNY 1833.7", tms_block)
self.assertEqual(tms_block.count("费用合计:"), 1)
self.assertEqual(tms_block.count("费用合计:"), 1)
def test_work_order_plus_lost_reason_is_deal_not_activate(self) -> None:
"""WO@助手 未成交,原因… 必须当关单,不能只激活。"""
from agent.routing.air_group_intent import INTENT_ACTIVATE, INTENT_LOST
@@ -614,6 +720,45 @@ class AirGroupCollabTests(unittest.TestCase):
)
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:
wo = self._create_air()
handle_air_group(
@@ -921,9 +1066,147 @@ class AirGroupCollabTests(unittest.TestCase):
ledger=self.ledger,
injected_intent="air_lock",
)
self.assertEqual(phase, "wait_lock_params")
phase = handle_air_group(
self._msg(
sender="air1",
text="AIR-OPT01 CZ 航班CZ327",
chat="wr_air_lock_manual",
mid="lm2",
),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_pick_option",
)
self.assertEqual(phase, "lock_ok")
self.assertTrue(any("执行成功" in t for t in self._texts()))
def test_lock_missing_params_asks_and_does_not_call_tms(self) -> None:
"""缺舱位、航司、航班号时只请补,不锁舱、不报 TMS 失败、不通知 IT。"""
wo = self._create_air(
quote={
"total": "CNY 1800",
"source": "TMS",
"source_label": "TMS 标准报价",
"quoteId": "QT-AIR-23",
}
)
calls: list[dict] = []
orig = self.ledger.lock_cabin
def spy(**kwargs):
calls.append(kwargs)
return orig(**kwargs)
self.ledger.lock_cabin = spy # type: ignore
phase = handle_air_group(
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_ask", mid="ask1"),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_lock",
)
self.assertEqual(phase, "wait_lock_params")
self.assertEqual(calls, [])
asked = self._texts()[-1]
self.assertEqual(
asked,
"锁舱参数不完整,缺少:舱位编号、航司、航班号。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。",
)
self.assertNotIn("已通知", asked)
self.assertNotIn("锁舱失败", asked)
phase = handle_air_group(
self._msg(
sender="air1",
text="AIR-OPT01 CZ 航班CZ327",
chat="wr_air_lock_ask",
mid="ask2",
),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_pick_option",
)
self.assertEqual(phase, "lock_ok")
self.assertEqual(len(calls), 1)
self.assertEqual(calls[0].get("option_no"), "AIR-OPT01")
self.assertEqual(calls[0].get("airline"), "CZ")
self.assertEqual(calls[0].get("flight_no"), "CZ327")
def test_lock_accepts_free_wording_not_only_sample_format(self) -> None:
"""补舱位不限格式。点名舱位、航司、航班号,换顺序、用航司中文名,也要锁。"""
wo = self._create_air(
quote={
"total": "CNY 1800",
"source": "TMS",
"source_label": "TMS 标准报价",
"quoteId": "QT-AIR-24",
}
)
calls: list[dict] = []
orig = self.ledger.lock_cabin
def spy(**kwargs):
calls.append(kwargs)
return orig(**kwargs)
self.ledger.lock_cabin = spy # type: ignore
phase = handle_air_group(
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_free", mid="free1"),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_lock",
)
self.assertEqual(phase, "wait_lock_params")
self.assertEqual(calls, [])
phase = handle_air_group(
self._msg(
sender="air1",
text="航班号:CZ327,航司南航,舱位编号是AIR-OPT01",
chat="wr_air_lock_free",
mid="free2",
),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "lock_ok")
self.assertEqual(calls[0].get("option_no"), "AIR-OPT01")
self.assertEqual(calls[0].get("airline"), "CZ")
self.assertEqual(calls[0].get("flight_no"), "CZ327")
def test_lock_sentence_with_labeled_params_locks_immediately(self) -> None:
"""同一句里已经说齐三项,直接锁,不再要求按示例格式重说。"""
wo = self._create_air(
quote={
"total": "CNY 1800",
"source": "manual",
"source_label": "产品报价",
}
)
calls: list[dict] = []
orig = self.ledger.lock_cabin
def spy(**kwargs):
calls.append(kwargs)
return orig(**kwargs)
self.ledger.lock_cabin = spy # type: ignore
phase = handle_air_group(
self._msg(
sender="air1",
text=f"{wo}锁舱,舱位 AIR-OPT02,东方航空,航班号 MU5123",
chat="wr_air_lock_once",
mid="once1",
),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_lock",
)
self.assertEqual(phase, "lock_ok")
self.assertEqual(len(calls), 1)
self.assertEqual(calls[0].get("option_no"), "AIR-OPT02")
self.assertEqual(calls[0].get("airline"), "MU")
self.assertEqual(calls[0].get("flight_no"), "MU5123")
self.assertFalse(any("锁舱参数不完整" in t for t in self._texts()))
def test_lock_success_and_release_after_deal_blocked(self) -> None:
wo = self._create_air()
phase = handle_air_group(
@@ -1010,8 +1293,8 @@ class AirGroupCollabTests(unittest.TestCase):
"source": "TMS",
"quoteId": "QT-AIR-2",
"airOptions": [
{"optionNo": "AIR-OPT-01", "airlineCode": "CZ"},
{"optionNo": "AIR-OPT-02", "airlineCode": "MU"},
{"optionNo": "AIR-OPT-01", "airlineCode": "CZ", "flightNo": "CZ327"},
{"optionNo": "AIR-OPT-02", "airlineCode": "MU", "flightNo": "MU5123"},
],
}
)
@@ -1021,7 +1304,7 @@ class AirGroupCollabTests(unittest.TestCase):
ledger=self.ledger,
injected_intent="air_lock",
)
self.assertEqual(phase, "wait_lock_option")
self.assertEqual(phase, "wait_lock_params")
phase = handle_air_group(
self._msg(sender="air1", text="AIR-OPT-02", chat="wr_air_1", mid="m2"),
flow=self.flow,
@@ -1116,6 +1399,31 @@ class AirGroupCollabTests(unittest.TestCase):
self.assertIn("请确认成交状态", texts)
self.assertFalse(any(x.get("kind") == "file" for x in added))
def test_reactivate_quoted_tms_file_is_deal_not_sheet(self) -> None:
"""TMS 价的报价单已经发出、停在成交跟进:再发工单号只回成交跟进。"""
wo = self._create_air(
quote={
"total": "CNY 3500.70",
"source": "TMS",
"source_label": "TMS 标准报价",
"quoteId": "QT-AIR-FILE",
"quoteFileIssued": True,
"airOptions": [
{"optionNo": "AIR-OPT-01", "airlineCode": "CZ", "flightNo": "CZ327"}
],
}
)
phase = handle_air_group(
self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="react-deal"),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "activated_deal")
texts = "\n".join(self._texts())
self.assertIn("请确认成交状态", texts)
self.assertNotIn("询价字段:", texts)
self.assertFalse(any(x.get("kind") == "file" for x in self.groups.sent))
def test_image_rejected(self) -> None:
wo = self._create_air()
handle_air_group(
@@ -1346,6 +1654,38 @@ class AirGroupCollabTests(unittest.TestCase):
self.assertNotIn("报价选项不存在", joined)
self.assertNotIn("与工单不匹配", joined)
def test_lock_fail_does_not_block_airline_quote(self) -> None:
"""锁舱失败后,航线再报手工价必须收下,不能回 TMS 接口异常。"""
wo = self._create_air()
handle_air_group(
self._msg(sender="air1", text=wo, chat="wr_air_lock_quote", mid="lq0"),
flow=self.flow,
ledger=self.ledger,
)
self.ledger._cabin_lock_result = {
"ok": False,
"error": "lock_fail",
"failReason": "该航班日期不存在可锁定的配载记录",
}
phase = handle_air_group(
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_quote", mid="lq1"),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_lock",
)
self.assertEqual(phase, "system_exception")
self.groups.sent.clear()
phase = handle_air_group(
self._msg(sender="air1", text="空运费改为900USD", chat="wr_air_lock_quote", mid="lq2"),
flow=self.flow,
ledger=self.ledger,
injected_intent="product_quote",
)
self.assertNotEqual(phase, "system_exception_paused")
joined = "\n".join(self._texts())
self.assertNotIn("TMS接口异常", joined)
self.assertIn(phase, {"wait_tms_choice", "quoted"})
def test_lock_without_quote_still_shows_tms_reason(self) -> None:
"""TMS 拒绝锁舱时原样回失败原因,不要改写成选项不匹配。"""
wo = self._create_air(quote={}, status="询价中")
@@ -106,6 +106,82 @@ class AirRouteOptionsTests(unittest.TestCase):
self.assertEqual(sess.facts.get("起运港"), "深圳")
self.assertIn("SZX-CRK", "\n".join(replies[-2:]))
def test_hangzhou_query_uses_airport_and_skips_other_cities(self) -> None:
"""写了杭州就不能按目的地把南京、深圳、郑州拿来选。"""
from agent.ledger.memory_ledger import MemoryLedger
from agent.policy.air_text_flow import AirTextInquiryFlow
from agent.schema.tms_air_query import assemble_air_query
ready = assemble_air_query({"起运港": "杭州", "目的港": "吉隆坡"})
self.assertEqual(ready["facts"].get("originCode"), "HGH")
self.assertEqual(ready["facts"].get("destinationCode"), "KUL")
class Ledger(MemoryLedger):
def query_tms(self, **kwargs):
out = super().query_tms(**kwargs)
quote = out["quote"]
quote["total"] = "1750.35"
quote["currency"] = "CNY"
quote["airOptions"] = [
{"originAirportCode": "NKG", "destinationAirportCode": "KUL", "salePrice": 1750.35, "currency": "CNY"},
{"originAirportCode": "SZX", "destinationAirportCode": "KUL", "salePrice": 1833.7, "currency": "CNY"},
{"originAirportCode": "CGO", "destinationAirportCode": "KUL", "salePrice": 1833.7, "currency": "CNY"},
]
return out
flow = AirTextInquiryFlow(Ledger())
replies: list[str] = []
def reply(text: str, extra=None) -> None:
replies.append(text)
_ = extra
facts = {
"起运港": "杭州",
"目的港": "吉隆坡",
"品名": "化妆品",
"件数": "12",
"毛重": "21",
"体积": "1",
"包装方式": "散货",
}
self.assertEqual(
flow.on_text(sender_id="u_hgh", text="空运", reply=reply, injected_facts=facts, injected_mode="AIR"),
"wait_confirm",
)
phase = flow.on_text(sender_id="u_hgh", text="确定", reply=reply)
self.assertEqual(phase, "tms_miss")
self.assertFalse(any("条线路" in x for x in replies))
self.assertEqual(flow.session_of("u_hgh").facts.get("originCode"), "HGH")
def test_stated_origin_quotes_directly(self) -> None:
"""起运地已经是珠海时,即使 TMS 还带回别的城市,也不再让销售选线。"""
from agent.ledger.memory_ledger import MemoryLedger
from agent.policy.air_text_flow import AirTextInquiryFlow
flow = AirTextInquiryFlow(MemoryLedger())
replies: list[str] = []
def reply(text: str, extra=None) -> None:
replies.append(text)
_ = extra
facts = {
"起运港": "珠海",
"目的港": "克拉克",
"品名": "茶叶",
"件数": "10",
"毛重": "100",
"体积": "2",
"包装方式": "托盘",
"tms_multi": "1",
}
flow.on_text(sender_id="u_zuh", text="空运", reply=reply, injected_facts=facts, injected_mode="AIR")
phase = flow.on_text(sender_id="u_zuh", text="确定", reply=reply)
self.assertEqual(phase, "wait_collab")
self.assertFalse(any("条线路" in x for x in replies))
self.assertEqual(flow.session_of("u_zuh").facts.get("起运港"), "珠海")
def test_reselect_after_adopt_voids_old_file(self) -> None:
"""采用后再改选:旧报价单作废,回到方案卡重新采用。"""
ledger = MemoryLedger()
@@ -900,6 +900,10 @@ class AirTextInquiryTests(unittest.TestCase):
self.assertNotIn("引用历史工单号", sub)
self.assertNotIn("工单状态", sub)
self.assertTrue(sub.endswith(copy.collab_hint("WO202609140001")))
self.assertIn(
"请手动将相关方拉到空运原有协同群,并在空运协同群中发送“ WO202609140001@询价小助手”,谢谢。",
sub,
)
self.assertNotIn("内部协同", json.dumps(card, ensure_ascii=False))
def test_long_fee_lines_go_to_text_not_clipped_card(self) -> None:
@@ -108,6 +108,8 @@ class AttachmentUploadH5Tests(unittest.TestCase):
self.assertIn("closeWindow", ok)
self.assertIn("wecom-jssdk", ok)
self.assertIn("jsapi-signature", ok)
self.assertIn("function isPhone()", ok)
self.assertNotIn("window.close()", ok)
self.assertNotIn("closeWindow", page)
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()
+20
View File
@@ -148,6 +148,26 @@ class TestDealOutcome(unittest.TestCase):
self.assertEqual(copy.lost_reason_ack("客户不要了"), "已记下未成交原因:客户不要了。")
self.assertEqual(copy.lost_reason_ack("价格高。"), "已记下未成交原因:价格高。")
def test_lost_reason_ack_fills_ticket_no(self) -> None:
"""后台模板带工单号时,必须换成真实单号,不能把占位符原样发出去。"""
from agent.policy.bot_reply import override_catalog
override_catalog(
{
"lost.reason_ack": (
"已将未成交原因“{{lost_reason}}”记录在后台,"
"工单{{ticket_no}}已关闭,智能体不再跟进该工单,请悉知。"
)
}
)
try:
self.assertEqual(
copy.lost_reason_ack("客户不需要了", work_order_no="WO202609100001"),
"已将未成交原因“客户不需要了”记录在后台,工单WO202609100001已关闭,智能体不再跟进该工单,请悉知。",
)
finally:
override_catalog(None)
def test_harvest_inline_lost_reason(self) -> None:
self.assertEqual(harvest_inline_lost_reason("未成交,原因客户不想要了"), "客户不想要了")
self.assertEqual(
+23
View File
@@ -436,6 +436,29 @@ class ClarifyPasteTests(unittest.TestCase):
self.assertNotIn("包装方式", facts)
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):
def test_numbered_extra_lines_do_not_enter_fields(self) -> None:
+32 -1
View File
@@ -317,6 +317,31 @@ class LandGroupCollabTests(unittest.TestCase):
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.status, "已报价")
def test_product_fee_change_is_quote_not_adjust(self) -> None:
"""产品写「报关费改为300」是手工报价。已有报价后也不能被拦成只有销售能调价。"""
from agent.routing.group_intent import classify_group_text
self.assertEqual(classify_group_text("报关费改为300CNY", allow_network=False), INTENT_QUOTE)
self.assertEqual(classify_group_text("运费加 10%", allow_network=False), INTENT_ADJUST)
wo, _ = self._open(option_text="国内运输拼车 国内长途/零担 急件")
handle_group_collab(
self._msg(sender="p_dom", text="运费 1000", mid="q_fee0"),
flow=self.flow,
ledger=self.ledger,
injected_intent=INTENT_QUOTE,
injected_quote={"total": "1000", "source_label": "产品报价"},
)
self.groups.sent.clear()
phase = handle_group_collab(
self._msg(sender="p_dom", text="报关费改为300CNY", mid="q_fee1"),
flow=self.flow,
ledger=self.ledger,
)
self.assertNotEqual(phase, "adjust_not_sales")
self.assertNotIn("只有这张单的销售能调价", self._group_texts())
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertTrue(ticket.quote)
def test_product_cannot_adjust(self) -> None:
self._open(option_text="国内运输拼车 国内长途/零担 急件")
self.groups.sent.clear()
@@ -327,7 +352,13 @@ class LandGroupCollabTests(unittest.TestCase):
injected_intent=INTENT_ADJUST,
)
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__":
+7
View File
@@ -220,9 +220,16 @@ class LandOptionsTest(unittest.TestCase):
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.assertFalse(looks_like_confirm("广州到深圳衣服"))
# 询价正文里的「确认」不是点核对卡。
self.assertFalse(
looks_like_confirm(
"服务器、不带电,预计7月初货好,因货值较高 派送需确认安全运输方案,比如押运。"
)
)
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:
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.policy.multi_group_ops import deal_text, handle_multi_inbound
from agent.policy.multi_segments import Segment
@@ -387,7 +391,8 @@ class QuoteTests(unittest.TestCase):
self.assertEqual(len(self.client.files), 2)
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(
sender_id="sales",
business_line="MULTI",
@@ -412,9 +417,128 @@ class QuoteTests(unittest.TestCase):
text=f"{no}锁舱",
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))
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__":
unittest.main()
@@ -379,6 +379,9 @@ class TestConfirmSystemException(unittest.TestCase):
)
card = queued[0]["payload"]["template_card"]
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(card["main_title"]["title"], "系统异常通知")
@@ -477,6 +480,11 @@ class TestConfirmSystemException(unittest.TestCase):
self.assertTrue("USD" in joined or "拉产品" in joined or "已报价" in joined)
def test_admin_retry_cabin_lock_runs_lock(self) -> None:
self.ledger.upsert_quote(
work_order_no=self.wo,
quote={"total": "100", "quoteId": "QT-AIR-1"},
to_status="已报价",
)
self.ledger.mark_system_exception(
work_order_no=self.wo,
system_exception=TYPE_TMS,
+2 -1
View File
@@ -30,7 +30,7 @@ class TmsLandQueryTest(unittest.TestCase):
got = assemble_land_query(facts)
self.assertTrue(got["ok"])
out = got["outbound"]
self.assertEqual(out["landTransportType"], "CHINA_HK")
self.assertEqual(out["landTransportType"], "中港")
self.assertEqual(out["routeCategory"], "SOUTHEAST_ASIA")
self.assertNotIn("landRouteCategory", out)
self.assertEqual(out["vehicleType"], "9米6")
@@ -52,6 +52,7 @@ class TmsLandQueryTest(unittest.TestCase):
got = assemble_land_query(facts)
self.assertTrue(got["ok"])
out = got["outbound"]
self.assertEqual(out["landTransportType"], "中港")
self.assertNotIn("vehicleType", out)
self.assertNotIn("quantity", out)
@@ -44,12 +44,28 @@ public class InquiryAgentController {
@Autowired
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:}}")
private String agentToken;
@Value("${inquiry.agent.biz-token:${BIZ_API_TOKEN:}}")
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 映射结果")
@PutMapping("/quoteTemplate/parseResult")
public Result<String> quoteTemplateParseResult(@RequestBody Map<String, Object> body,
@@ -21,7 +21,7 @@ import java.util.List;
/**
* 机器人回复语 API。
* 支持按分类拉取全部条目(后台九分类页)与分页查询。
* 调用编码 replyCode 给智能体日后按码取文案;本期只维护、不向智能体下发。
* 调用编码 replyCode 供智能体 GET /inquiry/agent/botReply/listEnabled 按码取文案。
*/
@Slf4j
@Api(tags = "询价-机器人回复语")
@@ -123,7 +123,7 @@ public class InquiryTicket implements Serializable {
private List<InquiryPriceLine> saleLines;
/**
* 利润点逐行。不入库。没调过价为空。最后一行是整单利润点。
* 利润点逐行。不入库。没调过价每项都是 0%。最后一行是整单利润点。
*/
@TableField(exist = false)
@ApiModelProperty("利润点逐行(展示)")
@@ -22,4 +22,10 @@ public interface IInquiryBotReplyService extends IService<InquiryBotReply> {
* 编辑正文/启停。已有调用编码不可被请求覆盖。
*/
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.transaction.annotation.Transactional;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
/**
@@ -90,6 +96,35 @@ public class InquiryBotReplyServiceImpl extends ServiceImpl<InquiryBotReplyMappe
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) {
if (StringUtils.isBlank(replyCode)) {
return;
@@ -686,7 +686,7 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
return;
}
String username = StringUtils.trimToEmpty(login.getUsername());
String wecom = StringUtils.trimToEmpty(login.getWorkNo());
String wecom = dashboardWecomId(login);
qw.and(w -> {
boolean any = false;
if (StringUtils.isNotBlank(wecom)) {
@@ -734,4 +734,20 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
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>
* 职责:从已保存的报价明细铺开展示。不改调价算法,不把各行金额加总冒充费用合计。
* 海运/陆运:成本是调价前生效价(手工同名盖 TMS,多出来的补上);销售是最新调价。
* 空运:成本整列空;销售是当前生效价;利润点只在调过价之后,对调价前销售价。
* 空运:成本整列空;销售是当前生效价。
* 未调价:各项利润点与费用合计都是 0%,禁止拿金额去填利润点列。
* 已调价:利润点对调价前同名行。
* 副作用:改 ticket 的三列逐行;手工盖价算出新的官方总价时,同时改成本价和利润点字符串。不写库。
* 调用:{@link InquiryTicketPriceSupport#apply} 末尾。列表 enrich 与保存报价后的 update 都走那里。
*/
@@ -126,7 +128,7 @@ public final class InquiryQuoteLineSupport {
profitTotal = StringUtils.defaultString(InquiryTicketPriceSupport.profitText(saleTotal, baseTotal));
ticket.setProfitRate(profitTotal);
} else if (!adjusted) {
ticket.setProfitRate("");
ticket.setProfitRate("0%");
}
if (air) {
@@ -134,13 +136,13 @@ public final class InquiryQuoteLineSupport {
ticket.setSaleLines(moneyLines(saleFees, saleTotal));
ticket.setProfitLines(adjusted && StringUtils.isNotBlank(profitTotal)
? profitAgainst(baselineFees, saleFees, profitTotal)
: Collections.<InquiryPriceLine>emptyList());
: zeroPercentLines(saleFees));
return;
}
if (!adjusted) {
ticket.setCostLines(moneyLines(costFees, costTotal));
ticket.setSaleLines(Collections.<InquiryPriceLine>emptyList());
ticket.setProfitLines(Collections.<InquiryPriceLine>emptyList());
ticket.setProfitLines(zeroPercentLines(costFees));
return;
}
List<Aligned> rows = align(costFees, saleFees);
@@ -216,6 +218,21 @@ public final class InquiryQuoteLineSupport {
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) {
List<InquiryPriceLine> lines = new ArrayList<InquiryPriceLine>();
if (fees != null) {
@@ -14,7 +14,8 @@ import java.util.regex.Pattern;
* 后台成本价 / 销售价 / 利润点:按业务线从报价记录重算。
* <p>
* 海运/陆运:TMS、产品报价是成本;销售调价后才有销售价,利润点=(销售-成本)/成本。
* 空运:成本价始终空;TMS/航线/调价都是销售侧;利润点只在销售调过价后算。
* 空运:成本价始终空;TMS/航线/调价都是销售侧。
* 未调价时利润点固定 0%(含各项),调过价才按差额算。
* 副作用:只改传入 ticket 的展示字段(三个总价字符串和三列逐行),不写库。调用方决定是否 updateById。
*/
public final class InquiryTicketPriceSupport {
@@ -63,7 +64,7 @@ public final class InquiryTicketPriceSupport {
ticket.setProfitRate(profitText(adjustPrice, baseline));
} else {
ticket.setSalePrice(StringUtils.isNotBlank(manualPrice) ? manualPrice : tmsPrice);
ticket.setProfitRate("");
ticket.setProfitRate("0%");
}
} else {
String cost = StringUtils.isNotBlank(manualPrice) ? manualPrice : tmsPrice;
@@ -73,7 +74,7 @@ public final class InquiryTicketPriceSupport {
ticket.setProfitRate(profitText(adjustPrice, cost));
} else {
ticket.setSalePrice("");
ticket.setProfitRate("");
ticket.setProfitRate("0%");
}
}
// 三个总价定完后,按同一批报价拆成逐行。有手工明细盖 TMS 时,这里会改写成本价和利润点。
@@ -51,6 +51,24 @@ public class InquiryTmsCabinService {
@Lazy
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) {
return fail("ticket_not_found", "找不到工单");
}
if (!"AIR".equalsIgnoreCase(StringUtils.trimToEmpty(ticket.getBusinessLine()))) {
return fail("not_air", "不是空运单,不能锁舱");
// 多段整单业务线是 MULTI。含空运段时航线仍会来锁舱,不能再说「不是空运单」。
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())
|| TicketStatuses.CLOSED.equals(ticket.getStatus())) {
@@ -186,6 +207,26 @@ public class InquiryTmsCabinService {
req.put("requestedUntil", text(body, "requestedUntil"));
}
}
// 航线口令里的航班号盖过报价。三项没齐就地拦住,不去调 TMS 锁舱。
String flightFromCaller = firstNonBlank(
text(body, "flightNo", "flight_no"),
flightNoFromInstruction(text(body, "instructionText", "instruction_text")));
if (StringUtils.isNotBlank(flightFromCaller)) {
req.put("flightNo", flightFromCaller);
}
if (StringUtils.isBlank(text(req, "airline"))) {
String fromFlight = airlineFromFlight(text(req, "flightNo"));
if (StringUtils.isNotBlank(fromFlight)) {
req.put("airline", fromFlight);
}
}
String incomplete = missingLockParamReason(
text(req, "optionNo"), text(req, "airline"), text(req, "flightNo"));
if (StringUtils.isNotBlank(incomplete)) {
log.info("tms.lock skip incomplete wo={} optionNo={} airline={} flightNo={}",
no, text(req, "optionNo"), text(req, "airline"), text(req, "flightNo"));
return fail("lock_params_incomplete", incomplete);
}
return invokeLock(ticket, req, quoteId, resolvedOption);
}
@@ -411,6 +452,14 @@ public class InquiryTmsCabinService {
req.remove("quoteId");
}
fillLockQuoteExtras(req, quote, option);
if (StringUtils.isBlank(text(req, "validUntil"))) {
// 航线手工价经常不写有效期。TMS 人工锁舱必填 validUntil,
// 用工单报价日(调用前已放进 flightDate)的当天结束,不再空着出站。
String iso = normalizeValidUntilIso(text(req, "flightDate"));
if (StringUtils.isNotBlank(iso)) {
req.put("validUntil", iso);
}
}
String transfer = firstNonBlank(text(option, "transferAirport", "transferAirportCode"),
text(quote, "transferAirport", "transferAirportCode"));
if (StringUtils.isNotBlank(transfer)) {
@@ -1028,6 +1077,50 @@ public class InquiryTmsCabinService {
return row;
}
/**
* 锁舱三项没齐时的群回复。不叫锁舱失败,不提 TMS 查价结果。
* 三项都缺时是:锁舱参数不完整,缺少:舱位编号、航司、航班号。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。
*/
static String missingLockParamReason(String optionNo, String airline, String flightNo) {
java.util.List<String> missing = new ArrayList<>();
if (StringUtils.isBlank(optionNo)) {
missing.add("舱位编号");
}
if (StringUtils.isBlank(airline)) {
missing.add("航司");
}
if (StringUtils.isBlank(flightNo)) {
missing.add("航班号");
}
if (missing.isEmpty()) {
return "";
}
return "锁舱参数不完整,缺少:" + String.join("、", missing)
+ "。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。";
}
/**
* 从「AIR-OPT01 CZ 航班CZ327」抽出航班号。没有就不猜。
*/
static String flightNoFromInstruction(String instruction) {
if (StringUtils.isBlank(instruction)) {
return "";
}
java.util.regex.Matcher named = java.util.regex.Pattern
.compile("(?i)(?:航班号|航班)\\s*[::]?\\s*(?:是|为)?\\s*([A-Za-z]{2}\\s*\\d{2,4})")
.matcher(instruction);
if (named.find()) {
return named.group(1).toUpperCase();
}
java.util.regex.Matcher trailed = java.util.regex.Pattern
.compile("(?i)AIR-?OPT-?\\d+\\s+(?:[A-Za-z]{2}\\s+)?([A-Za-z]{2}\\d{2,4})")
.matcher(instruction);
if (trailed.find()) {
return trailed.group(1).toUpperCase();
}
return "";
}
/**
* 从锁舱口令抽出两位航司码,例如「锁AIR-OPT01 TH」→ TH。
*/
@@ -689,7 +689,7 @@ public class InquiryTmsQueryService {
/**
* 把本次 TMS 报价写回工单主表,供后台列表/详情展示。
* 海运/陆运进成本价,空运进销售价;利润点必须等销售调价后才算。
* 海运/陆运进成本价,空运进销售价;未调价利润点为 0%,调过价才按差额算。
*/
static void applyQuoteToTicket(InquiryTicket ticket, Map<String, String> quote) {
if (ticket == null || quote == null) {
@@ -4,6 +4,7 @@ package org.jeecg.modules.inquiry.tms;
* 陆运运输类型 / 线路类别出站:产品要求原文直传,不再翻成 TMS 英文枚举。
* <p>
* 职责:统一 trim;空串表示未填。工单/卡片/拉人本来就是中文,出站与之一致。
* 中港整车、中港零担/集拼查价只传「中港」,工单仍留细类。
* 禁止:再把「跨境整车」写成 CROSS_BORDER_FTL;把运输分类、中港装载方式塞进出站。
* 历史英文码若仍传入,原样放过(兼容旧快照),不做双向翻译。
*/
@@ -14,10 +15,15 @@ public final class TmsLandEnumMapper {
/**
* @param raw 销售中文(或历史英文码)
* @return trim 后原文;空则 ""
* @return 中港两类收成「中港」;其余 trim 后原文;空则 ""
*/
public static String transportType(String raw) {
return trimOrEmpty(raw);
String text = trimOrEmpty(raw);
if ("中港整车".equals(text) || "中港零担/集拼".equals(text)) {
// zhonggang: 两类细类查价都只传「中港」
return "中港";
}
return text;
}
/**
@@ -18,8 +18,8 @@ import java.util.regex.Pattern;
* 职责:把工单 facts 里的中文键写成 TMS 段;运输分类只留在工单,不进出站。
* 拼车/跨境集拼带体积重量;国内整车/跨境整车/中港零担带「车型/数量」一项再拆成出站车型与台数;
* 中港整车不带车型/数量。通关口岸有值才放。
* 中港整车、中港零担/集拼:出站类型仍用销售中文「中港整车」「中港零担/集拼」(产品要求不做英文枚举映射)。
* 类型/线路键名仍是 landTransportType / routeCategory,值用中文原文。
* 中港整车、中港零担/集拼:出站 landTransportType 只写「中港」,工单事实仍留细类。
* 类型/线路键名仍是 landTransportType / routeCategory。除中港两类外,值用中文原文。
* 禁止:按空运要包装件数;把分类当查价条件;直连 TMS;再把中文翻成 CROSS_BORDER_*。
*/
public final class TmsLandSegmentMapper {
@@ -151,7 +151,7 @@ public final class TmsV2RequestMapper {
}
/**
* 陆运出站:类型+线路用销售中文原文(产品要求不做英文枚举映射);线路字段名是 routeCategory。
* 陆运出站:类型+线路用销售中文;中港整车、中港零担/集拼收成「中港」。线路字段名是 routeCategory。
* 分类和中港装载即使误放在 source 也不抄走。
*/
private static void applyLandFields(Map<String, Object> source, Map<String, Object> target) {
@@ -13,7 +13,13 @@
{"source_name": "北京首都", "source_code": "PEK"},
{"source_name": "北京大兴", "source_code": "PKX"},
{"source_name": "珠海", "source_code": "ZUH"},
{"source_name": "克拉克", "source_code": "CRK"}
{"source_name": "克拉克", "source_code": "CRK"},
{"source_name": "南京", "source_code": "NKG"},
{"source_name": "吉隆坡", "source_code": "KUL"},
{"source_name": "北京", "source_code": "PEK"},
{"source_name": "杭州", "source_code": "HGH"},
{"source_name": "萧山", "source_code": "HGH"},
{"source_name": "杭州萧山", "source_code": "HGH"}
],
"air_packaging_type": [
{"source_name": "全部", "source_code": "1"},
@@ -28,13 +28,16 @@ public class InquiryQuoteLineSupportTest {
+ "]}")));
assertEquals("CNY 1200.00", ticket.getCostPrice());
assertEquals("", ticket.getSalePrice());
assertEquals("", ticket.getProfitRate());
assertEquals("0%", ticket.getProfitRate());
assertLines(ticket.getCostLines(),
"运输费", "CNY 1000",
"卸货费", "CNY 200",
"费用合计", "CNY 1200.00");
assertTrue(ticket.getSaleLines() == null || ticket.getSaleLines().isEmpty());
assertTrue(ticket.getProfitLines() == null || ticket.getProfitLines().isEmpty());
assertLines(ticket.getProfitLines(),
"运输费", "0%",
"卸货费", "0%",
"费用合计", "0%");
}
@Test
@@ -113,7 +116,12 @@ public class InquiryQuoteLineSupportTest {
assertTrue(before.getCostLines() == null || before.getCostLines().isEmpty());
assertEquals("", before.getCostPrice());
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(),
"空运费", "CNY 1800",
"增点费", "CNY 100",
@@ -189,6 +197,11 @@ public class InquiryQuoteLineSupportTest {
"费用合计", "CNY 1320.00");
assertEquals("CNY 1320.00", ticket.getCostPrice());
assertTrue(ticket.getSaleLines() == null || ticket.getSaleLines().isEmpty());
assertLines(ticket.getProfitLines(),
"运输费", "0%",
"卸货费", "0%",
"配送费", "0%",
"费用合计", "0%");
}
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")));
assertEquals("CNY 121.00", ticket.getCostPrice());
assertEquals("", ticket.getSalePrice());
assertEquals("", ticket.getProfitRate());
assertEquals("0%", ticket.getProfitRate());
}
@Test
@@ -53,7 +53,7 @@ public class InquiryTicketPriceSupportTest {
InquiryTicketPriceSupport.apply(ticket, Collections.singletonList(quote("TMS", "TMS标准报价", "USD 1280")));
assertEquals("", ticket.getCostPrice());
assertEquals("USD 1280", ticket.getSalePrice());
assertEquals("", ticket.getProfitRate());
assertEquals("0%", ticket.getProfitRate());
}
@Test
@@ -1543,6 +1543,9 @@ public class QuoteTemplateFillTest {
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, 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();
red.setColor(org.apache.poi.ss.usermodel.IndexedColors.RED.getIndex());
org.apache.poi.ss.usermodel.CellStyle redOnSky = wb.createCellStyle();
@@ -1553,6 +1556,14 @@ public class QuoteTemplateFillTest {
currencyHead.setCellStyle(redOnSky);
matrix.getCell(10).setCellStyle(redOnSky);
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);
dest.setCellStyle(redOnSky);
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,
sheet.getRow(9).getCell(2).getCellStyle().getVerticalAlignment());
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, 9));
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 这类车型费用也是标签右侧,不是海运费目表。
*/
@@ -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) {
org.apache.poi.ss.usermodel.Row exist = sheet.getRow(r);
return exist == null ? sheet.createRow(r) : exist;
@@ -15,6 +15,15 @@ import static org.junit.jupiter.api.Assertions.assertNull;
*/
public class InquiryTmsCabinServiceTest {
@Test
public void multiInquiryCannotLockCabin() {
assertEquals(
"多段询价,暂时不支持锁舱",
InquiryTmsCabinService.lockRejectReason("MULTI"));
assertEquals("不是空运单,不能锁舱", InquiryTmsCabinService.lockRejectReason("SEA"));
assertEquals("", InquiryTmsCabinService.lockRejectReason("AIR"));
}
@Test
public void pickNamedOption() {
Map<String, Object> quote = new HashMap<>();
@@ -188,6 +197,34 @@ public class InquiryTmsCabinServiceTest {
assertEquals("空运费", fees.get(0).get("feeName"));
}
@Test
public void manualLockFillsValidUntilFromFlightDateWhenQuoteHasNone() {
Map<String, Object> req = new HashMap<>();
req.put("flightDate", "2026-10-11");
InquiryTmsCabinService.fillManualLock(req, new HashMap<>(), new HashMap<>(), new HashMap<>(), false);
assertEquals("2026-10-11T23:59:59+08:00", req.get("validUntil"));
assertEquals("2026-10-11T23:59:59+08:00", req.get("requestedUntil"));
}
@Test
public void missingLockParamsAsksWithoutCallingTmsWording() {
assertEquals(
"锁舱参数不完整,缺少:舱位编号、航司、航班号。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。",
InquiryTmsCabinService.missingLockParamReason("", "", ""));
assertEquals(
"锁舱参数不完整,缺少:航班号。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。",
InquiryTmsCabinService.missingLockParamReason("AIR-OPT01", "CZ", ""));
assertEquals("", InquiryTmsCabinService.missingLockParamReason("AIR-OPT01", "CZ", "CZ327"));
}
@Test
public void flightNoFromInstructionReadsSampleFormat() {
assertEquals("CZ327", InquiryTmsCabinService.flightNoFromInstruction(
"AIR-OPT01 CZ 航班CZ327"));
assertEquals("CZ327", InquiryTmsCabinService.flightNoFromInstruction("AIR-OPT01 CZ CZ327"));
assertEquals("", InquiryTmsCabinService.flightNoFromInstruction("WO202610080023锁舱"));
}
@Test
public void airlineFromInstructionReadsCodeAfterOption() {
assertEquals("TH", InquiryTmsCabinService.airlineFromInstruction(
@@ -32,4 +32,11 @@ public class TmsExactProjectorTest {
assertTrue(hit.ok());
assertEquals("SHA", hit.code);
}
@Test
public void hangzhouMapsToXiaoshan() {
TmsExactProjector.Projection hit = projector.resolveAirport("杭州");
assertTrue(hit.ok());
assertEquals("HGH", hit.code);
}
}
@@ -27,6 +27,13 @@ public class TmsLandEnumMapperTest {
assertEquals("", TmsLandEnumMapper.routeCategory(" "));
}
@Test
public void hkTypesCollapseToZhonggang() {
assertEquals("中港", TmsLandEnumMapper.transportType("中港整车"));
assertEquals("中港", TmsLandEnumMapper.transportType("中港零担/集拼"));
assertEquals("中港集拼", TmsLandEnumMapper.transportType("中港集拼"));
}
@Test
public void legacyEnglishCodesPassThrough() {
assertEquals("DOMESTIC_LTL", TmsLandEnumMapper.transportType("DOMESTIC_LTL"));
@@ -46,7 +46,7 @@ public class TmsLandSegmentMapperTest {
facts.put("车型/数量", "9米6/1");
facts.put("体积", "99");
Map<String, Object> seg = TmsLandSegmentMapper.build(facts);
assertEquals("中港零担/集拼", seg.get("landTransportType"));
assertEquals("中港", seg.get("landTransportType"));
assertEquals("9米6", seg.get("vehicleType"));
assertNull(seg.get("volumeCbm"));
assertFalse(seg.containsKey("hkLoadType"));
@@ -62,7 +62,7 @@ public class TmsLandSegmentMapperTest {
facts.put("目的港", "香港");
facts.put("品名", "衣服");
Map<String, Object> seg = TmsLandSegmentMapper.build(facts);
assertEquals("中港整车", seg.get("landTransportType"));
assertEquals("中港", seg.get("landTransportType"));
assertNull(seg.get("vehicleType"));
assertNull(seg.get("quantity"));
java.util.List<String> missing = TmsLandSegmentMapper.missingQuery(
@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
/**
* 账号管理:对接 Jeecg /sys/user(主账登录账号)。
* 与员工岗位页分离——本页管后台登录,员工页管企微业务角色。
@@ -305,7 +305,7 @@ function removeAccount(row: AccountRow) {
</div>
</Teleport>
<div class="section-card">
<div class="section-card is-list-page">
<div class="toolbar">
<div class="search">
<ProtoIcon class="search-icon" name="search" :size="16" />
@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
/**
* 角色管理:对接 Jeecg /sys/role 与菜单权限树。
* 数据权限按 Excel 线路目录树勾选,写入角色 description 的 [dataPerms:…],不另造 RBAC。
@@ -255,7 +255,7 @@ function onDataPermCheck(checked: string[] | { checked: string[] }) {
</button>
</Teleport>
<div class="section-card">
<div class="section-card is-list-page">
<div class="table-wrap">
<table class="data-table">
<thead>
@@ -494,7 +494,7 @@ function filterRouteOption(input: string, option: any) {
</div>
</Teleport>
<div class="section-card">
<div class="section-card is-list-page">
<div class="tabs-line-bar">
<div class="tabs-line">
<button type="button" :class="{ 'is-active': tab === 'all' }" @click="tab = 'all'">
@@ -30,6 +30,13 @@ body {
background: #f7f8fb !important;
min-width: 0 !important;
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 {
@@ -37,6 +44,9 @@ body {
gap: 12px !important;
min-width: 0 !important;
overflow-x: hidden;
flex: 1 1 auto !important;
min-height: 0 !important;
overflow: auto !important;
}
/*
@@ -559,12 +569,11 @@ body {
gap: 12px 16px !important;
}
/* 中间列横滑;左右 sticky 列固定 */
/* 中间列横滑;左右 sticky 列固定。竖滚改由 .is-list-page 表格区承接,勿再用 visible 把滚动甩给整页 */
.ticket-page .table-wrap,
.ticket-table-scroll {
border-radius: 10px !important;
overflow-x: auto !important;
overflow-y: visible;
-webkit-overflow-scrolling: touch;
}
@@ -1415,3 +1424,102 @@ textarea.field.field-tall {
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>
</Teleport>
<div class="section-card qt-template-page">
<div class="section-card qt-template-page is-list-page">
<div class="toolbar">
<div class="search">
<ProtoIcon class="search-icon" name="search" :size="16" />
@@ -126,6 +126,17 @@ function asPriceLines(raw: any): PriceLine[] {
.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。
@@ -413,6 +424,44 @@ function quoteHasFees(row: TicketRow) {
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 多行展示一致。 */
function demandDetailLines(row: TicketRow) {
return String(row?.demandDetail || "")
@@ -472,6 +521,7 @@ onMounted(() => loadList());
async function openDetail(row: TicketRow) {
current.value = row;
detailPriceExpanded.value = false;
detailOpen.value = true;
try {
const res: any = await getWorkOrderDetail(row.id);
@@ -540,7 +590,7 @@ function refresh() {
</div>
</Teleport>
<div class="ticket-page">
<div class="ticket-page is-list-page">
<div class="section-card">
<div class="section-card-header">
<h2 class="section-card-title">询价工单列表</h2>
@@ -664,35 +714,41 @@ function refresh() {
</ExpandableClamp>
</td>
<td class="align-top col-quote">
<ExpandableClamp
v-if="row.quotes.length"
v-slot="{ expanded }"
:content-key="row.id + '-quote-' + row.quotes.length"
:force-toggle="quoteHasFees(row)"
:clip-collapsed="false"
>
<div
v-for="q in row.quotes"
:key="q.source + q.price"
class="quote-row"
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 class="ticket-price-expand">
<template v-if="row.quotes.length">
<div
v-for="q in row.quotes"
:key="q.source + q.price"
class="quote-row"
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>
</div>
</ExpandableClamp>
<span v-else style="color: #94a3b8">-</span>
<template v-if="isPriceExpanded(row.id) && 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>
</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 class="align-top">
<div v-if="row.attachments.length" class="attach-tags">
@@ -702,9 +758,12 @@ function refresh() {
</td>
<td class="align-top"><span class="ticket-status">{{ row.status }}</span></td>
<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
v-for="(line, i) in row.costLines"
v-for="(line, i) in visiblePriceLines(row.costLines, isPriceExpanded(row.id))"
:key="row.id + '-cost-' + i"
class="ticket-price-line"
:class="{ 'is-total': line.name === '费用合计' }"
@@ -715,9 +774,12 @@ function refresh() {
<span v-else class="ticket-price-cell">{{ row.costPrice }}</span>
</td>
<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
v-for="(line, i) in row.saleLines"
v-for="(line, i) in visiblePriceLines(row.saleLines, isPriceExpanded(row.id))"
:key="row.id + '-sale-' + i"
class="ticket-price-line"
: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>
</td>
<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
v-for="(line, i) in row.profitLines"
v-for="(line, i) in visiblePriceLines(displayProfitLines(row), isPriceExpanded(row.id))"
:key="row.id + '-profit-' + i"
class="ticket-price-line"
:class="{ 'is-total': line.name === '费用合计' }"
@@ -738,7 +803,7 @@ function refresh() {
{{ line.name }}:{{ line.text || "-" }}
</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 class="col-center">
<span v-if="row.eventException !== '-'" class="tag tag-rose">{{ row.eventException }}</span>
@@ -843,56 +908,60 @@ function refresh() {
<div class="ticket-detail-title">
<ProtoIcon name="gauge" :size="14" />价格与利润
</div>
<div class="ticket-detail-grid">
<div class="ticket-detail-item">
<label>成本价</label>
<div v-if="current.costLines.length" class="ticket-price-lines">
<div class="ticket-price-expand">
<div class="ticket-detail-grid">
<div class="ticket-detail-item">
<label>成本价</label>
<div
v-for="(line, i) in current.costLines"
:key="'detail-cost-' + i"
class="ticket-price-line"
:class="{ 'is-total': line.name === '费用合计' }"
v-if="visiblePriceLines(current.costLines, detailPriceExpanded).length"
class="ticket-price-lines"
>
{{ 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>
<span v-else>{{ current.costPrice }}</span>
</div>
<span v-else>{{ current.costPrice }}</span>
</div>
<div class="ticket-detail-item">
<label>销售价</label>
<div v-if="current.saleLines.length" class="ticket-price-lines">
<div class="ticket-detail-item">
<label>销售价</label>
<div
v-for="(line, i) in current.saleLines"
:key="'detail-sale-' + i"
class="ticket-price-line"
:class="{ 'is-total': line.name === '费用合计' }"
v-if="visiblePriceLines(current.saleLines, detailPriceExpanded).length"
class="ticket-price-lines"
>
{{ 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>
<span v-else>{{ current.salePrice }}</span>
</div>
<span v-else>{{ current.salePrice }}</span>
</div>
<div class="ticket-detail-item">
<label>利润点</label>
<div v-if="current.profitLines.length" class="ticket-price-lines">
<div class="ticket-detail-item">
<label>利润点</label>
<div
v-for="(line, i) in current.profitLines"
:key="'detail-profit-' + i"
class="ticket-price-line"
:class="{ 'is-total': line.name === '费用合计' }"
v-if="visiblePriceLines(displayProfitLines(current), detailPriceExpanded).length"
class="ticket-price-lines"
>
{{ 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>
<span v-else>{{ current.profit === "-" ? "0%" : current.profit }}</span>
</div>
<span v-else>{{ current.profit }}</span>
</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
v-for="q in current.quotes"
:key="q.source + q.price + (q.time || '')"
@@ -901,7 +970,7 @@ function refresh() {
<div class="quote-source">
{{ q.source }}<span v-if="q.time && q.time !== '-'"> · {{ q.time }}</span>
</div>
<div v-if="q.fees.length" class="quote-fee-list">
<div v-if="detailPriceExpanded && q.fees.length" class="quote-fee-list">
<div
v-for="(fee, fi) in q.fees"
:key="q.source + '-detail-fee-' + fi"
@@ -918,7 +987,17 @@ function refresh() {
合计 {{ q.price }}
</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 class="ticket-detail-section">
@@ -1032,4 +1111,32 @@ function refresh() {
color: #708090;
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>