Compare commits

..
Author SHA1 Message Date
jillion886andCursor 2aab38da99 国庆放假表把 10 月 8 日当成休息,超时和成交跟进当天不计时。按国务院安排改为当天上班,并补上 9 月 20 日调休。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 00:00:58 +08:00
jillion886andCursor 5e2a3e29aa 电脑企微上传成功后关不掉当前页,分析看板线路筛选被挤窄、往下也滚不动。按销售实际操作补上关窗,并让看板整页可以滚动。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 23:39:20 +08:00
jillion886andCursor baa4d81be9 陆运中港整车和中港零担/集拼查价时,TMS 只认「中港」。工单仍保留细类,避免把两类原样传出去对不上运价。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 18:30:12 +08:00
jillion886andCursor 6141978a9e 空运协同里手工改费用、有起运地、锁舱失败或缺参会被误判,报价单已出后再激活又重发模板。按销售侧口径收紧这些分支,并让价格卡和未成交回执带上真实工单号。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 18:05:29 +08:00
jillion886andCursor 43e633f359 修好多段含空运的群跟单、锁舱话术、相册报价通道,以及附件上传成功后电脑端关不掉 H5。
多段激活后无需再带工单号;含空运群回复走机器人;复制补问清单时空货好时间不再吃成下一行编号。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 15:23:06 +08:00
61 changed files with 2036 additions and 1736 deletions
+47 -35
View File
@@ -4,7 +4,8 @@
本文件职责:给附件上传成功、选线路成功页一段共用脚本。
手机端只调一次 WeixinJSBridge.closeWindow,关掉当前 H5 后留在询价对话里。
再调 ww/wx/window.close 会把询价小助手整个退出,回到企业微信首页。
电脑企微没有这套桥,加载 wecom-jssdk、用 /inquiry-form/jsapi-signature 注册后再关一次。
电脑企微 5.x 内置页经常加载不了 JS-SDK,也不会打签名接口。
这时 ww.closeWindow 不会执行。电脑改用 WeixinJSBridge.invoke,再补 window.close,只关当前页。
失败、过期页不要挂这段。
副作用:只产出 HTML 片段;关窗签名走浏览器短请求,不占智能体请求线程做慢活。
"""
@@ -24,7 +25,9 @@ def wecom_auto_close_script() -> str:
成功页脚:加载企微 JS-SDK,register 后关掉当前 H5 窗口。
线程:浏览器端执行。签名接口与复制页共用,ticket 已缓存在 Redis。
为何这样写:上一版只调 WeixinJSBridge,电脑端企微关不掉,会停在成功页。
为何这样写:21:27 的上传日志里,成功页已经返回,但没有 jsapi 签名请求。
电脑企微没跑起 ww.register,只调 ww.closeWindow 等于没关。
电脑改为 invoke + window.close,不依赖签名先成功。手机仍只关一次。
"""
settings = get_settings()
corp = escape((settings.wecom_corp_id or "").strip(), quote=True)
@@ -49,6 +52,14 @@ def wecom_auto_close_script() -> str:
var closed = false;
function isPhone() {{
var ua = navigator.userAgent || "";
if (/wxwork/i.test(ua) && /Windows|Macintosh/i.test(ua) && !/Android|iPhone|iPad|iPod/i.test(ua)) {{
return false;
}}
return /Android|iPhone|iPad|iPod/i.test(ua);
}}
function closeOnce(fn) {{
if (closed) return;
closed = true;
@@ -60,46 +71,47 @@ def wecom_auto_close_script() -> str:
closeOnce(function () {{ WeixinJSBridge.call("closeWindow"); }});
}}
// 电脑企微没有 WeixinJSBridge,注册 JS-SDK 后只关一次。
// 电脑企微:call 关不掉,JS-SDK 又经常没装上。invoke 关内置页,window.close 关独立窗口。
function closeDesktop() {{
closeOnce(function () {{
if (window.ww && typeof ww.closeWindow === "function") ww.closeWindow();
else if (window.wx && typeof wx.closeWindow === "function") wx.closeWindow();
}});
try {{
if (typeof WeixinJSBridge !== "undefined" && typeof WeixinJSBridge.invoke === "function") {{
WeixinJSBridge.invoke("closeWindow", {{}}, function () {{}});
}}
}} catch (e) {{}}
try {{
if (window.ww && typeof ww.closeWindow === "function") {{
var ret = ww.closeWindow();
if (ret && ret.catch) ret.catch(function () {{}});
}} else if (window.wx && typeof wx.closeWindow === "function") {{
wx.closeWindow();
}}
}} catch (e) {{}}
try {{ window.close(); }} catch (e) {{}}
}}
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({{
corpId: corpId,
agentId: agentId,
jsApiList: ["closeWindow"],
getConfigSignature: sign("corp"),
getAgentConfigSignature: sign("agent")
}}).catch(function () {{ return null; }});
}}
ready.then(function () {{
setTimeout(function () {{
if (sawBridge || typeof WeixinJSBridge !== "undefined") {{
if (!closed) closeMobile();
return;
}}
closeDesktop();
}}, {delay});
setTimeout(closeDesktop, {delay});
if (!(window.ww && ww.register && corpId)) return;
ww.register({{
corpId: corpId,
agentId: agentId,
jsApiList: ["closeWindow"],
getConfigSignature: sign("corp"),
getAgentConfigSignature: sign("agent")
}}).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 ""
+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
+2 -1
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"
+6 -19
View File
@@ -40,18 +40,8 @@ class HttpLedger:
def _post(self, path: str, body: dict[str, Any], *, idem: str = "") -> dict[str, Any]:
resp = self._client.request_json("POST", path, json_body=body, idempotency_key=idem)
if not resp.ok:
# 500 时 Jeecg 仍可能带回 message(如六态非法);勿只留 http_500,
# 否则成交补「已报价」重试认不出「询价中」(WO023)。
data = resp.data if isinstance(resp.data, dict) else {}
msg = ""
if isinstance(data, dict):
msg = str(data.get("message") or "").strip()
unwrapped = _unwrap(data)
if unwrapped.get("error"):
msg = str(unwrapped.get("error") or msg)
err = msg or resp.error or f"http_{resp.status_code}"
logger.warning("http_ledger fail path=%s status=%s err=%s", path, resp.status_code, err)
return {"ok": False, "error": err}
logger.warning("http_ledger fail path=%s status=%s err=%s", path, resp.status_code, resp.error)
return {"ok": False, "error": resp.error or f"http_{resp.status_code}"}
return _unwrap(resp.data)
def find_similar_open(
@@ -176,12 +166,8 @@ class HttpLedger:
"""
no = (work_order_no or "").strip()
src = dict(quote or {})
if not no:
return {"ok": False, "error": "work_order_blank"}
# 空报价不打主账(避免抹掉已有查价),但禁止伪装成「已写入已报价」——
# 否则会话以为已报价、主账仍停询价中,成交三态会失败(WO022)。
if not src:
return {"ok": False, "error": "quote_blank", "skipped": True}
if not no or not src:
return {"ok": True, "quote_version": 1, "status": to_status or "已报价"}
out = self._post(
"/inquiry/agent/ticket/replaceTmsQuote",
{"workOrderNo": no, "quote": src, "toStatus": to_status},
@@ -439,6 +425,7 @@ class HttpLedger:
airline: str = "",
flight_no: str = "",
) -> dict[str, Any]:
"""锁舱出站。航班号和航司一并带上,主账缺了这两项不能当锁舱失败。"""
body: dict[str, Any] = {
"workOrderNo": work_order_no,
"optionNo": option_no,
@@ -453,7 +440,7 @@ class HttpLedger:
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 -27
View File
@@ -829,12 +829,8 @@ class MemoryLedger:
airline: str = "",
flight_no: str = "",
) -> dict[str, Any]:
"""单测可注入 _cabin_lock_result;默认成功。
纯人工且视图无 TMS 舱位时,缺 option/航司/航班 → lock_incomplete,
对齐主账「现拉仍缺才回」的口径(内存账不会真查 TMS)。
"""
_ = sender_id, operator_name, instruction_text
"""单测可注入 _cabin_lock_result;默认成功。"""
_ = sender_id, operator_name, instruction_text, airline, flight_no
forced = getattr(self, "_cabin_lock_result", None)
if isinstance(forced, dict):
return dict(forced)
@@ -850,27 +846,6 @@ class MemoryLedger:
quote_id = str(quote.get("quoteId") or "").strip()
if quote_id.upper().startswith("QT-WO"):
quote_id = ""
air_opts = quote.get("airOptions") or quote.get("air_options") or []
has_tms_cabin = bool(quote_id) or bool(air_opts)
opt = (option_no or "").strip()
air = (airline or "").strip()
fn = (flight_no or "").strip()
if not has_tms_cabin:
missing: list[str] = []
if not opt:
missing.append("舱位编号optionNo")
if not air:
missing.append("航司airline")
if not fn:
missing.append("航班号flightNo")
if missing:
return {
"ok": False,
"error": "lock_incomplete",
"failReason": "锁舱参数不完整,缺少:"
+ "、".join(missing)
+ "。请按「AIR-OPT01 CZ 航班CZ327」补舱位、航司和航班号后再锁。",
}
return {
"ok": True,
"status": "LOCKED",
+27 -160
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()
@@ -757,30 +770,6 @@ _RE_MONEY = re.compile(
r"|(?:USD|CNY|RMB|美金|美元)\s*(\d+(?:\.\d+)?)",
re.I,
)
# 陆运集拼:「2-500KG 单价CNY990」是分档单价,不是「燃油附加费=2」。
_RE_WEIGHT_BAND_PRICE = re.compile(
r"(?P<min>\d+)\s*[-~~—–到至]\s*(?P<max>\d+)\s*K\s*G"
r"(?:\s*以上)?"
r"\s*(?:单价)?"
r"\s*(?:(?P<ccy>USD|CNY|RMB|美金|美元|人民币)\s*)?"
r"(?P<price>\d+(?:\.\d+)?)",
re.I,
)
_RE_WEIGHT_BAND_OPEN = re.compile(
r"(?P<min>\d+)\s*K\s*G\s*以上"
r"\s*(?:单价)?"
r"\s*(?:(?P<ccy>USD|CNY|RMB|美金|美元|人民币)\s*)?"
r"(?P<price>\d+(?:\.\d+)?)",
re.I,
)
_RE_FUEL_SURCHARGE_RATE = re.compile(
r"燃油附加费率\s*[::=]?\s*(?P<rate>\d+(?:\.\d+)?)\s*%?",
re.I,
)
_RE_TAX_RATE = re.compile(
r"(?<![A-Za-z\u4e00-\u9fff])税率\s*[::=]?\s*(?P<rate>\d+(?:\.\d+)?)\s*%?",
re.I,
)
_RE_FEE_LINE = re.compile(
r"(?P<name>[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费|[A-Za-z]{2,10})"
r"\s*(?:改为|改成|调整为|调整成)?"
@@ -891,8 +880,6 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
认不出价格返回空 dict,调用方回「没有识别到报价」。
群里常见「码头操作费100CNY / 报关费改为300CNY」,不能只把第一笔数当成海运费。
「空运费 单价…金额…」必须保留空运费,金额用「金额」后面的数,不用单价。
陆运集拼:「2-500KG 单价CNY990,燃油附加费率1%,税率6%」走分档单价+费率,
禁止把公斤档下限「2」当成燃油附加费金额(WO021)。
"""
if isinstance(injected, dict) and (injected.get("total") or injected.get("fee_rows")):
out = dict(injected)
@@ -905,22 +892,16 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
compact = stripped.replace(" ", "").replace(" ", "")
if re.fullmatch(r"WO\d{12}", compact, re.I):
return {}
band = _parse_weight_band_quote(raw)
if band:
return band
# 费率句若仍落到普通费用行,会把「燃油附加费」配上句首公斤数。先剥掉再抽。
scrubbed = _strip_rate_and_band_noise(raw)
rows = _parse_fee_rows(scrubbed) if scrubbed.strip() else []
rows = _parse_fee_rows(raw)
if rows:
ccy = rows[0].get("currency") or _currency_of(raw)
money_rows = [x for x in rows if "%" not in str(x.get("amount") or "")]
total = (money_rows[0] if money_rows else rows[0])["amount"]
if money_rows and len({x.get("currency") for x in money_rows}) == 1:
total = rows[0]["amount"]
if len({x.get("currency") for x in rows}) == 1:
try:
total = str(sum(float(x["amount"]) for x in money_rows))
total = str(sum(float(x["amount"]) for x in rows))
except ValueError:
total = money_rows[0]["amount"]
out = {
total = rows[0]["amount"]
return {
"total": total,
"currency": ccy,
"source_label": "产品报价",
@@ -928,12 +909,9 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
"fee_rows": rows,
"fee_lines": list(rows),
}
out.update(_extract_rate_fields(raw))
return out
hit = _RE_MONEY.search(_ascii_amount(scrubbed or raw))
hit = _RE_MONEY.search(_ascii_amount(raw))
if not hit:
rates = _extract_rate_fields(raw)
return rates if rates else {}
return {}
amount = _ascii_amount(hit.group(1) or hit.group(2) or "")
if not amount:
return {}
@@ -941,13 +919,12 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
# 句里已经点了费用名(空运费)时,不能退回默认海运费。
named = re.search(
r"([\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费)",
scrubbed or raw,
raw,
)
fee_name = (named.group(1) if named else "") or "海运费"
if fee_name in {"费用", "燃油附加费"}:
# 「燃油附加费率」里的「燃油附加费」不是费用行名;无其它费名时用运费。
fee_name = "海运费" if fee_name == "费用" else "陆运费"
out = {
if fee_name in {"费用"}:
fee_name = "海运费"
return {
"total": amount,
"currency": ccy,
"source_label": "产品报价",
@@ -955,113 +932,3 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
"fee_rows": [{"name": fee_name, "amount": amount, "currency": ccy}],
"fee_lines": [{"name": fee_name, "amount": amount, "currency": ccy}],
}
out.update(_extract_rate_fields(raw))
return out
def _format_percent_rate(raw: str) -> str:
"""费率展示统一带 %;已有 % 保留。"""
text = (raw or "").strip()
if not text:
return ""
if text.endswith("%"):
return text
return f"{text}%"
def _extract_rate_fields(text: str) -> dict[str, str]:
"""抽出燃油附加费率 / 税率;只返回出现过的键。"""
out: dict[str, str] = {}
fuel = _RE_FUEL_SURCHARGE_RATE.search(text or "")
if fuel:
out["fuel_surcharge_rate"] = _format_percent_rate(fuel.group("rate") or "")
out["fuelSurchargeRate"] = out["fuel_surcharge_rate"]
tax = _RE_TAX_RATE.search(text or "")
if tax:
out["tax_rate"] = _format_percent_rate(tax.group("rate") or "")
out["taxRate"] = out["tax_rate"]
return out
def _strip_rate_and_band_noise(text: str) -> str:
"""去掉分档价与费率短语,避免后续费用行/金额回退误伤。"""
cleaned = _ascii_amount(text or "")
cleaned = _RE_WEIGHT_BAND_PRICE.sub(" ", cleaned)
cleaned = _RE_WEIGHT_BAND_OPEN.sub(" ", cleaned)
cleaned = _RE_FUEL_SURCHARGE_RATE.sub(" ", cleaned)
cleaned = _RE_TAX_RATE.sub(" ", cleaned)
return cleaned
def _parse_weight_band_quote(text: str) -> dict[str, Any]:
"""
陆运集拼分档单价:2-500KG 单价CNY990 + 可选燃油附加费率/税率。
产出 weightPrices,供主账出单按「2-500KG」表头对档;费率进顶层字段填 Excel。
"""
raw = _ascii_amount(text or "")
tiers: list[dict[str, Any]] = []
rows: list[dict[str, str]] = []
ccy = ""
for hit in _RE_WEIGHT_BAND_PRICE.finditer(raw):
lo = int(hit.group("min") or 0)
hi = int(hit.group("max") or 0)
price = _ascii_amount(hit.group("price") or "")
if not price:
continue
line_ccy = _currency_of(raw, hit.group("ccy") or "")
ccy = ccy or line_ccy
label = f"{lo}-{hi}KG"
tiers.append({"min": lo, "max": hi, "price": price})
rows.append(
{
"name": label,
"amount": price,
"currency": line_ccy,
"unit_price": price,
}
)
for hit in _RE_WEIGHT_BAND_OPEN.finditer(raw):
lo = int(hit.group("min") or 0)
price = _ascii_amount(hit.group("price") or "")
if not price:
continue
line_ccy = _currency_of(raw, hit.group("ccy") or "")
ccy = ccy or line_ccy
label = f"{lo}KG以上"
tiers.append({"min": lo, "max": None, "price": price})
rows.append(
{
"name": label,
"amount": price,
"currency": line_ccy,
"unit_price": price,
}
)
if not tiers:
return {}
rates = _extract_rate_fields(raw)
# 费率行进明细方便群摘要展示;金额带 %,合计时只加数字价。
if rates.get("fuel_surcharge_rate"):
rows.append(
{
"name": "燃油附加费率",
"amount": rates["fuel_surcharge_rate"],
"currency": "",
}
)
if rates.get("tax_rate"):
rows.append({"name": "税率", "amount": rates["tax_rate"], "currency": ""})
# 分档是单价,不能把多档相加冒充费用合计。
out: dict[str, Any] = {
"total": tiers[0]["price"],
"currency": ccy or "CNY",
"source_label": "产品报价",
"raw_text": (text or "").strip(),
"fee_rows": rows,
"fee_lines": list(rows),
"weightPrices": tiers,
"price_per_kg": tiers[0]["price"],
}
out.update(rates)
return out
+421 -241
View File
@@ -3,14 +3,14 @@
本文件职责:群里副作用(主账 + BOT 回话)。海运应用群仍走 sea_group_ops。
禁止:自动建群/拉人/解散;拿当前工单去猜锁舱工单号。
锁舱按 TMS /air/cabin/lock 拼字段出站;没有 TMS 报价编号也可走 MANUAL。
禁止编造 optionNo;缺舱位编号/航司/计费重时先群里补问,禁止空壳打 TMS。
锁舱按 TMS /air/cabin/lock 拼字段出站;没有 TMS 报价编号也要锁,用工单字段补 quoteId/optionNo。
"""
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
@@ -44,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"
@@ -117,7 +120,7 @@ def activate_ticket(
把本群当前工单切到指定空运未结案单。
返回 (工单, phase)。失败时工单为 None。
航线已经用附件或文字落过价时,再发工单号只回到成交跟进,不再发空白模板。
已经在成交跟进,或航线/产品价已经落账时,再发工单号只回到成交跟进,不再发工单摘要和模板。
"""
book = flow.ledger
no = (work_order_no or "").strip()
@@ -175,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 {"", "-", "—", "-"}
@@ -777,46 +802,22 @@ def apply_air_deal(
if stopped:
return stopped
from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason
from agent.policy.sea_group_ops import _quote_fee_rows, facts_of_group
from agent.policy.sea_group_ops import facts_of_group
view = ticket_view(ticket)
no = ticket_no(ticket)
quote = dict(view.get("quote") or {})
status_now = ticket_status(ticket)
usable_total = str(quote.get("total") or "").strip()
if usable_total in {"", "-"}:
usable_total = ""
# 与海运一致:询价中且无报价时先说清原因,禁止笼统「主账状态更新失败」。
if not (usable_total or _quote_fee_rows(quote) or status_now in {"已报价", "协商中"}):
send_group_text(
group_client_of(flow), chat_id, copy.deal_fail_no_quote(work_order_no=no)
)
return "deal_need_quote"
inline = harvest_inline_lost_reason(text) if outcome == "未成交" else ""
result = finish_deal_outcome(
ledger=flow.ledger,
work_order_no=no,
work_order_no=ticket_no(ticket),
outcome=outcome,
facts=facts_of_group(view),
quote=quote,
quote=dict(view.get("quote") or {}),
business_line="AIR",
sales_name=str(view.get("sales_name") or ""),
lost_reason=inline,
)
if not result.get("ok"):
# 迁态失败且仍无报价时,同样用「没有报价」说明,避免旧笼统句。
err = str(result.get("error") or "")
still_no_quote = not (
usable_total or _quote_fee_rows(quote) or status_now in {"已报价", "协商中"}
)
if still_no_quote or "询价中" in err:
send_group_text(
group_client_of(flow), chat_id, copy.deal_fail_no_quote(work_order_no=no)
)
else:
send_group_text(
group_client_of(flow), chat_id, "主账状态更新失败,成交结果未入账。"
)
send_group_text(group_client_of(flow), chat_id, "主账状态更新失败,成交结果未入账。")
return "ledger_fail"
phase = str(result.get("phase") or "done")
ack_reason = inline if phase == "done" and outcome == "未成交" else ""
@@ -825,7 +826,7 @@ def apply_air_deal(
chat_id,
copy.deal_ack(
outcome,
no,
ticket_no(ticket),
str(result.get("next_followup_at") or ""),
lost_reason=ack_reason,
),
@@ -842,80 +843,45 @@ def apply_air_lock(
instruction: str,
option_no: str = "",
airline: str = "",
flight_no: str = "",
) -> str:
"""
空运群锁舱。
航线锁舱。
副作用:调主账 lockCabin(HMAC 出站 TMS);成功写协同 facts、推成交跟进。
舱位/航司/航班交给主账现拉 TMS 补全;仅计费重全空时群里补问。
主账仍缺则回 lock_incomplete,禁止空壳出站。
舱位编号、航司、航班号没齐时只请补全,不调 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 = ticket_view(ticket)
view_quote = dict(view.get("quote") or {})
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
# 最新价常是人工覆盖:舱位/航司/航班要从本单 TMS 原价自动补,不能逼航线手打。
tms_quote = copy.resolve_tms_quote(None, view)
if not tms_quote:
tms_quote = dict(view.get("tms_quote") or view.get("tmsQuote") or {})
quote_options = _air_options(quote)
tms_options = _air_options(tms_quote)
options = quote_options or tms_options
quote_id = _quote_id(quote) or _quote_id(tms_quote)
picked = (option_no or "").strip()
if not picked and len(options) > 1:
facts = dict(view.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()
# 没有任何可锁报价时先拒绝,不要把「无报价」误说成缺舱位。
if not _quote_ready_for_lock(quote) and not _quote_ready_for_lock(tms_quote):
send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_quote(no))
return "no_quote"
airline_code = (airline or "").strip().upper()
if not airline_code:
airline_code = _airline_from_quote_or_option(quote, quote_options, picked)
if not airline_code:
airline_code = _airline_from_quote_or_option(tms_quote, tms_options, picked)
chargeable = _chargeable_weight_kg(quote, quote_options, picked, ticket)
if not chargeable:
chargeable = _chargeable_weight_kg(tms_quote, tms_options, picked, ticket)
flight_no = _flight_no_from_quote_or_option(quote, quote_options, picked, instruction)
if not flight_no:
flight_no = _flight_no_from_quote_or_option(tms_quote, tms_options, picked, instruction)
# 舱位/航司/航班交给主账:库里无 TMS 时会现拉补全;仍缺才回 lock_incomplete。
# 这里只拦计费重全空(工单毛重也没有)——否则会逼航线手打航班。
need_chargeable = not chargeable
if need_chargeable:
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_manual_lock_fields(
need_option=False,
need_airline=False,
need_chargeable=True,
need_flight=False,
options=options,
),
quote_id = _quote_id(quote)
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,
)
return "wait_lock_fields"
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(
@@ -932,14 +898,6 @@ def apply_air_lock(
confirm_system_exception,
)
def _lock_terminal(x: object) -> bool:
"""成功,或业务上不必再重试(缺舱位/无报价)。"""
row = x if isinstance(x, dict) else {}
if bool(row.get("ok")):
return True
err = str(row.get("error") or "")
return err in {"lock_incomplete", "no_quote"}
out = call_with_retries(
lambda: locker(
work_order_no=no,
@@ -947,30 +905,38 @@ def apply_air_lock(
sender_id=sender_id,
operator_name=name,
instruction_text=instruction,
airline=airline_code,
flight_no=flight_no,
airline=air_code,
flight_no=flight,
)
or {},
is_ok=_lock_terminal,
is_ok=lambda x: bool((x or {}).get("ok")),
)
if not out.get("ok"):
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"
if str(out.get("error") or "") == "lock_incomplete":
# 主账现拉仍无舱位:业务补问,不算系统异常,不惊动 IT。
why = str(out.get("failReason") or out.get("error") or "锁舱参数不完整")
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
)
# 主账在出站前发现三项不齐:只请补,不当 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.air_lock_incomplete(work_order_no=no, operator=name, reason=why),
copy.multi_air_lock_unsupported(work_order_no=no, operator=name),
)
return "lock_incomplete"
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),
@@ -993,7 +959,8 @@ def apply_air_lock(
conversation_target=chat_id,
extra={
"option_no": picked,
"airline": airline_code,
"airline": air_code,
"flight_no": flight,
"instruction": instruction,
"sender_id": sender_id,
"operator_name": name,
@@ -1013,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,
)
@@ -1259,142 +1229,352 @@ def _is_product_quote(quote: dict[str, Any]) -> bool:
return "航线" in src or "产品" in src
def _quote_id(quote: dict[str, Any]) -> str:
return str(quote.get("quoteId") or quote.get("quote_id") or "").strip()
_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 _quote_ready_for_lock(quote: dict[str, Any]) -> bool:
def lock_params_from_text(text: str) -> tuple[str, str, str]:
"""
有没有可锁的报价:真实 TMS 编号,或人工总价/费用明细。
与内存账本口径一致,禁止把空报价当成「缺舱位」去补问。
从航线原话抽出舱位编号、航司、航班号。
不要求「AIR-OPT01 CZ 航班CZ327」这一种写法。
带「舱位 / 航司 / 航班」的说法、换顺序、航司用中文名,都认。
本地对不上、话里又在补这三项时,再问模型。没说的不猜。
"""
src = dict(quote or {})
label = str(src.get("source_label") or src.get("sourceLabel") or "")
if "确认" in label and "TMS" in label.upper():
qid = str(src.get("quoteId") or "").strip()
return bool(qid) and not qid.upper().startswith("QT-")
if str(src.get("total") or "").strip() not in {"", "-"}:
return True
if src.get("fee_rows") or src.get("fee_lines") or src.get("feeItems") or src.get("departureCharges"):
return True
if _air_options(src):
return True
qid = str(src.get("quoteId") or src.get("quote_id") or "").strip()
return bool(qid) and not qid.upper().startswith("QT-WO")
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 _picked_option(
options: list[dict[str, Any]], option_no: str
) -> dict[str, Any]:
"""按 optionNo 取舱位;对不上返回空 dict。"""
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 {}
for row in options:
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 {}
return None
def _airline_from_quote_or_option(
quote: dict[str, Any],
options: list[dict[str, Any]],
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,
) -> str:
"""
锁舱航司:舱位 airlineCode > 报价顶层 > 航班号前两位。
只回两位大写字母;猜不到就空,交给群里补问。
"""
opt = _picked_option(options, option_no)
raw = str(
opt.get("airlineCode")
or opt.get("airline")
or quote.get("airlineCode")
or quote.get("airline")
or ""
).strip().upper()
if len(raw) >= 2 and raw[:2].isalpha():
return raw[:2]
flight = str(
opt.get("flightNo") or opt.get("flight_no") or quote.get("flightNo") or ""
).strip().upper()
if len(flight) >= 2 and flight[0].isalpha() and flight[1].isalpha():
return flight[:2]
name = str(opt.get("airlineName") or quote.get("airlineName") or "").strip()
# 名称里不硬猜二字码,避免「南航」编成乱码。
_ = name
return ""
def _flight_no_from_quote_or_option(
quote: dict[str, Any],
options: list[dict[str, Any]],
option_no: str,
instruction: str = "",
) -> str:
"""航班号:舱位/报价字段,或句中「航班CZ327」「CZ327」形态。"""
import re
opt = _picked_option(options, option_no)
raw = str(opt.get("flightNo") or opt.get("flight_no") or quote.get("flightNo") or "").strip()
if raw:
return raw.upper()
blob = (instruction or "").upper()
m = re.search(r"(?:航班|FLIGHT)\s*([A-Z0-9]{2,8})", blob)
if m:
return m.group(1)
m = re.search(r"\b([A-Z]{2}\d{2,4})\b", blob)
if m:
return m.group(1)
return ""
def _chargeable_weight_kg(
quote: dict[str, Any],
options: list[dict[str, Any]],
option_no: str,
ticket: object,
) -> str:
"""
计费重:舱位 > 报价 > 工单事实「计费重」> 毛重。
有正数才返回;否则空串,门禁会补问。
"""
opt = _picked_option(options, option_no)
candidates = [
opt.get("chargeableWeightKg") or opt.get("chargeable_weight_kg"),
quote.get("chargeableWeightKg") or quote.get("chargeable_weight_kg"),
]
view = ticket_view(ticket)
facts = dict(view.get("facts") or {})
candidates.extend(
[
facts.get("计费重"),
facts.get("计费重量"),
view.get("chargeableWeightKg"),
facts.get("毛重"),
view.get("weightKg") or view.get("weight_kg"),
]
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,
)
for raw in candidates:
text = str(raw or "").strip().replace(",", "")
if not text or text in {"-", "null", "None"}:
continue
# 去掉「公斤」「KGS」等后缀,只留数字。
num = ""
for ch in text:
if ch.isdigit() or ch == ".":
num += ch
elif num:
break
if not num:
continue
try:
if float(num) > 0:
return num
except ValueError:
continue
return ""
def _quote_id(quote: dict[str, Any]) -> str:
return str(quote.get("quoteId") or quote.get("quote_id") or "").strip()
def _air_options(quote: dict[str, Any]) -> list[dict[str, Any]]:
+30 -27
View File
@@ -1260,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"):
@@ -1278,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,
@@ -1780,12 +1806,8 @@ class AirTextInquiryFlow:
extra,
)
from agent.redis_coord.quote_file import mark_quote_file_ready
from agent.policy.deal_outcome import ensure_ticket_quoted
mark_quote_file_ready(sess.work_order_no)
# 文件已出就必须让主账离开「询价中」,否则成交三态会被六态拒绝。
ensure_ticket_quoted(self._ledger, work_order_no=sess.work_order_no)
sess.status = sess.status or "已报价"
sess.phase = "wait_deal"
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
sess.wait_version += 1
@@ -1930,37 +1952,20 @@ class AirTextInquiryFlow:
"""点成交/未成交/协商中:先整卡置灰,再入主账。未成交同一句带原因则一次记完。"""
self._grey_clicked_card(reply, card_meta, replace_name=status)
from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason
from agent.policy.sea_group_ops import _quote_fee_rows
quote = dict(sess.quote or {})
status_now = str(sess.status or "").strip()
usable_total = str(quote.get("total") or "").strip()
if usable_total in {"", "-"}:
usable_total = ""
if not (
usable_total
or _quote_fee_rows(quote)
or status_now in {"已报价", "协商中"}
):
reply(copy.deal_fail_no_quote(work_order_no=sess.work_order_no))
return "deal_need_quote"
inline = harvest_inline_lost_reason(source_text) if status == "未成交" else ""
result = finish_deal_outcome(
ledger=self._ledger,
work_order_no=sess.work_order_no,
outcome=status,
facts=sess.facts,
quote=quote,
quote=sess.quote,
business_line=sess.business_line,
sales_name=sess.invited_sales_name,
lost_reason=inline,
)
if not result.get("ok"):
err = str(result.get("error") or "")
if (not usable_total and not _quote_fee_rows(quote)) or "询价中" in err:
reply(copy.deal_fail_no_quote(work_order_no=sess.work_order_no))
else:
reply("主账状态更新失败,成交结果未入账。")
reply("主账状态更新失败,成交结果未入账。")
return "ledger_fail"
phase = str(result.get("phase") or "done")
sess.status = status
@@ -2016,10 +2021,8 @@ class AirTextInquiryFlow:
if not no:
return
from agent.redis_coord.quote_file import mark_quote_file_ready
from agent.policy.deal_outcome import ensure_ticket_quoted
mark_quote_file_ready(no)
ensure_ticket_quoted(self._ledger, work_order_no=no)
if not sender_id:
return
with self._lock:
+3 -1
View File
@@ -46,6 +46,7 @@ _HOLIDAYS = frozenset(
date(2026, 9, 25),
date(2026, 9, 26),
date(2026, 9, 27),
# 国庆只休到 10 月 7 日。10 月 8 日周四正常上班,不能算假期。
date(2026, 10, 1),
date(2026, 10, 2),
date(2026, 10, 3),
@@ -53,7 +54,6 @@ _HOLIDAYS = frozenset(
date(2026, 10, 5),
date(2026, 10, 6),
date(2026, 10, 7),
date(2026, 10, 8),
# 2027 元旦预留
date(2027, 1, 1),
date(2027, 1, 2),
@@ -68,6 +68,8 @@ _MAKEUP_WORKDAYS = frozenset(
date(2026, 2, 14),
date(2026, 2, 28),
date(2026, 5, 9),
# 国庆调休:9 月 20 日周日、10 月 10 日周六上班。
date(2026, 9, 20),
date(2026, 10, 10),
}
)
+1 -78
View File
@@ -210,11 +210,7 @@ def finish_deal_outcome(
writer = getattr(ledger, "transition", None)
if not callable(writer):
return {"ok": False, "error": "no_transition", "phase": "ledger_fail"}
# 报价文件已出、会话已进入成交跟进,但主账仍停在「询价中」时,
# 直接迁「未成交/已成交/协商中」会被六态拒绝。先补「已报价」再迁目标态。
result = _transition_deal_status(
writer, work_order_no=no, to_status=status, extra=extra, ledger=ledger
)
result = writer(work_order_no=no, to_status=status, **extra) or {}
if not result.get("ok"):
return {"ok": False, "error": result.get("error") or "transition_failed", "phase": "ledger_fail"}
from agent.policy.event_exception import WAIT_LOST_REASON, clear_wait, start_wait
@@ -245,79 +241,6 @@ def finish_deal_outcome(
}
def _transition_deal_status(
writer: Callable[..., dict[str, Any]],
*,
work_order_no: str,
to_status: str,
extra: dict[str, Any],
ledger: Any = None,
) -> dict[str, Any]:
"""
写入成交三态。若主账仍停在询价中,先迁已报价再迁目标态。
运行证据:
- WO022/WO023:文件已发仍停询价中,直迁未成交被六态拒绝;
- HTTP 500 时 http_ledger 常只回 http_500(正文里的「询价中」丢失),
不能只靠错误文案判断,要以工单当前状态为准。
"""
no = (work_order_no or "").strip()
status = (to_status or "").strip()
result = writer(work_order_no=no, to_status=status, **extra) or {}
if result.get("ok"):
return result
if status not in {"已成交", "未成交", "协商中"}:
return result
err = str(result.get("error") or result.get("message") or "")
cur = ""
getter = getattr(ledger, "get_for_agent", None) if ledger is not None else None
if callable(getter):
try:
snap = getter(work_order_no=no, sender_id="") or {}
cur = str(snap.get("status") or "").strip()
except Exception:
cur = ""
# 状态确认询价中,或错误文案带询价中,或仅有 http_5xx 时仍尝试补已报价。
should_promote = (
cur == "询价中"
or "询价中" in err
or err.startswith("http_5")
or err in {"transition_failed", "ledger_error"}
)
if not should_promote:
return result
promoted = writer(
work_order_no=no,
to_status="已报价",
remark="成交跟进前补已报价",
) or {}
if not promoted.get("ok"):
return result
return writer(work_order_no=no, to_status=status, **extra) or {}
def ensure_ticket_quoted(ledger: Any, *, work_order_no: str) -> dict[str, Any]:
"""
报价文件发出 / 进入成交跟进时,保证主账至少是「已报价」。
幂等:已离开「询价中」则不再迁态(已报价→已报价会被六态拒绝)。
"""
no = (work_order_no or "").strip()
writer = getattr(ledger, "transition", None)
if not no or not callable(writer):
return {"ok": False, "error": "no_transition"}
getter = getattr(ledger, "get_for_agent", None)
if callable(getter):
try:
snap = getter(work_order_no=no, sender_id="") or {}
except Exception:
snap = {}
st = str(snap.get("status") or "").strip()
if st and st != "询价中":
return {"ok": True, "status": st, "skipped": True}
return writer(work_order_no=no, to_status="已报价", remark="报价文件已出") or {}
def capture_lost_reason(*, ledger: Any, work_order_no: str, reason: str) -> dict[str, Any]:
"""把销售下一句写进未成交原因。@询价小助手 不是原因。"""
text = strip_bot_mentions(reason or "")
+67 -114
View File
@@ -1023,12 +1023,7 @@ def quote_fee_lines(quote: dict[str, Any] | None) -> list[tuple[str, str]]:
amount = str(item.get("amount") or item.get("value") or "").strip()
line_ccy = str(item.get("currency") or item.get("currencyCode") or header_ccy).strip()
if name and amount and amount != "-":
# 燃油附加费率 / 税率是百分比,禁止套成「CNY 1%」。
if "%" in amount or name.endswith("费率") or name in {"税率", "燃油附加费率"}:
shown = amount if "%" in amount else f"{amount}%"
lines.append((name, shown))
else:
lines.append((name, money_text(amount, line_ccy)))
lines.append((name, money_text(amount, line_ccy)))
if lines:
return lines
freight = str(src.get("freight") or "").strip()
@@ -1224,21 +1219,10 @@ def sea_quote_detail_text(
def collab_hint(work_order_no: str) -> str:
"""
空运价格卡协同说明:紧挨「不需要协同,直接生成报价单」上方。
后台 inquiring.air_collab_hint;库内模板可用 {{ticket_no}}。
无后台模板时用本默认句(已带工单号)。销售侧示例:
…发送“ WO202610060030@询价小助手”,谢谢。
"""
no = (work_order_no or "").strip()
return _r(
"inquiring.air_collab_hint",
(
f"若需要咨询内部航线人员,请手动将相关方拉到空运原有协同群,"
f"并在空运协同群中发送“ {no}@询价小助手”,谢谢。"
),
ticket_no=no,
"""空运价格卡协同说明:请把相关方拉进原有协同群,并 @ 询价小助手。紧挨生成报价单按钮上方。"""
return (
f"请手动将相关方拉到空运原有协同群,并在空运协同群中发送"
f"“ {work_order_no}@询价小助手”,谢谢。"
)
@@ -2400,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:
@@ -2677,24 +2667,8 @@ def group_deal_stale(*, work_order_no: str) -> str:
return f"这张成交确认已过期。工单{no}已有更新的报价,请点击群里最新一条成交跟进。"
def group_deal_need_quote(*, work_order_no: str = "") -> str:
"""
群里确认成交/未成交时,主账还没有可确认报价。
空运 WO027:询价中且无 inq_quote,旧句「主账状态更新失败」不说明原因。
"""
no = (work_order_no or "").strip()
if no:
return (
f"工单{no}状态更新失败,原因:没有报价。\n"
f"请先报价,然后再更新工单状态。"
)
return "工单状态更新失败,原因:没有报价。\n请先报价,然后再更新工单状态。"
def deal_fail_no_quote(*, work_order_no: str = "") -> str:
"""成交三态写入失败且判定为没有报价时的销售可见说明。与群门禁同句。"""
return group_deal_need_quote(work_order_no=work_order_no)
def group_deal_need_quote() -> str:
return "还没有可确认的报价,请先完成报价再确认成交状态。"
def group_deal_sales_only() -> str:
@@ -2778,9 +2752,10 @@ def _followup_month_day(raw: str) -> str:
def lost_reason_ack(reason: str = "", work_order_no: str = "") -> str:
"""
收下未成交原因后回显。后台 lost.reason_ack。
收下未成交原因后的回执。后台 lost.reason_ack。
测服文案含 {{ticket_no}} / {{lost_reason}};缺 ticket_no 会原样打出占位符。
后台模板里的 {{lost_reason}}、{{ticket_no}} 都在这里填。
不传工单号时占位留空串,避免群里再露出 {{ticket_no}}。
"""
text = (reason or "").strip()
no = (work_order_no or "").strip()
@@ -2932,7 +2907,8 @@ def air_group_brief(
空运群激活摘要:与海运进群同一套结构,字段用空运。
先 @ 销售和航线,再工单号 + 询价字段 + 协同补充(含电/含磁)+ TMS报价。
TMS 段跟单聊同一套线路分项,总销售价只一行;禁止用「空运费=总价」冒充。
TMS 段跟单聊同一套线路分项,总销售价只一行。
已有运输费等分项时,不再叠一行「空运费=总价」;只有空运费这一项时要写出来。
不写「当前工单/询价要点/现在等到哪一步」。
"""
no = (work_order_no or "").strip()
@@ -3059,6 +3035,23 @@ 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:
"""没有任何报价时的锁舱拒绝。后台 inquiring.air_lock_need_quote。"""
no = (work_order_no or "").strip()
@@ -3081,53 +3074,6 @@ def air_ask_lock_option(options: list) -> str:
return "\n".join(lines)
def air_ask_manual_lock_fields(
*,
need_option: bool = False,
need_airline: bool = False,
need_chargeable: bool = False,
need_flight: bool = False,
options: list | None = None,
) -> str:
"""
手动报价锁舱缺 TMS MANUAL 必填项时,群里补问,禁止空壳出站。
例:请回复「AIR-OPT01 CZ 航班CZ327」。
"""
bits: list[str] = []
if need_option:
bits.append("舱位编号(如 AIR-OPT01)")
if need_airline:
bits.append("航司两位码(如 CZ)")
if need_flight:
bits.append("航班号(如 CZ327)")
if need_chargeable:
bits.append("计费重(公斤)")
need = "、".join(bits) if bits else "舱位编号、航司和航班号"
lines = [
f"锁舱还缺:{need}。",
"请按「AIR-OPT01 CZ 航班CZ327」回复;",
]
if need_chargeable:
lines.append("若还缺计费重,请再写「计费重xx公斤」。")
else:
lines.append("例如:AIR-OPT01 CZ 航班CZ327")
opts = options or []
shown = False
for item in opts:
if not isinstance(item, dict):
continue
no = str(item.get("optionNo") or item.get("option_no") or "").strip()
airline = str(item.get("airlineCode") or item.get("airlineName") or "").strip()
if not no:
continue
if not shown:
lines.append("可选舱位:")
shown = True
lines.append(f"{no} {airline}".strip() if airline else no)
return "\n".join(lines)
def air_lock_ok(
*,
work_order_no: str,
@@ -3155,8 +3101,34 @@ def air_lock_ok(
)
# 多段里有空运段时,航线锁舱的失败原因。产品暂不支持,不当 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。"""
"""空运锁舱失败。后台 inquiring.air_lock_fail。"""
why = (reason or "").strip() or "TMS未返回原因"
default = (
f"工单{work_order_no} 空运锁舱失败。\n"
@@ -3173,25 +3145,6 @@ def air_lock_fail(*, work_order_no: str, operator: str, reason: str) -> str:
)
def air_lock_incomplete(*, work_order_no: str, operator: str, reason: str = "") -> str:
"""
锁舱参数不齐(纯手工价、运价库无舱位等):业务补问,不算系统异常,不惊动 IT。
航线按示例回舱位+航司+航班后即可再锁。
"""
why = (reason or "").strip()
lines = [
f"工单{work_order_no} 还不能锁舱。",
f"操作人:{operator}",
]
if why:
lines.append(f"原因:{why}")
else:
lines.append("原因:本单没有可引用的 TMS 舱位(手工价或运价库无匹配)。")
lines.append("请直接回复「AIR-OPT01 CZ 航班CZ327」(换成实际舱位、航司、航班号)后再锁。")
return "\n".join(lines)
def air_cabin_fail_reason(*, tms_reason: str = "", flight_date: str = "", today: str = "") -> str:
"""
锁舱/释放失败原因:原样回 TMS 的 failReason。
@@ -3359,7 +3312,7 @@ def system_exception_alert_payload(
"""
系统异常通知卡:走询价消息服务,不走询价小助手。
五行对齐现网示例:异常类型、接口/服务、错误码、影响范围、发生时间。
五行:异常类型、接口/服务、报错原因、影响范围、发生时间。
标题不加 [test]:运维要看的就是这张卡本身,test_prefix 仅保留兼容入参。
"""
no = (work_order_no or "").strip() or "无"
@@ -3368,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 "-"},
]
@@ -3409,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 '-'}",
]
+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:
@@ -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)]
+34 -61
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", "询价机器人"}
@@ -906,14 +934,12 @@ def apply_product_quote(
logger.info("产品报价未识别 wo=%s text=%s", no, (text or "")[:200])
send_group_text(client, chat_id, copy.group_quote_not_recognized())
return "quote_not_recognized"
quote = _attach_land_weight_band_segment(quote, view)
base = _current_quote(view, sess)
if _has_tms_quote(base):
_park_pending_quote(no, quote, sess, flow)
send_group_text(client, chat_id, copy.group_ask_tms_or_product())
return "wait_tms_choice"
# 分档单价是完整人工价(含费率),禁止和旧错误行(如燃油附加费=2)相加。
if _quote_fee_rows(base) and not (quote.get("weightPrices") or []):
if _quote_fee_rows(base):
quote = merge_quote_fees(base, quote)
_save_product_quote(flow, no, quote, sess)
ticket2 = _reload(flow, chat_id, no) or ticket
@@ -1233,11 +1259,8 @@ def apply_group_deal(
return "not_sales"
quote = _current_quote(view, sess)
status_now = ticket_status(ticket) or (sess.status if sess else "")
usable_total = str(quote.get("total") or "").strip()
if usable_total in {"", "-"}:
usable_total = ""
if not (usable_total or _quote_fee_rows(quote) or status_now in {"已报价", "协商中"}):
send_group_text(client, chat_id, copy.group_deal_need_quote(work_order_no=no))
if not (quote.get("total") or _quote_fee_rows(quote) or status_now in {"已报价", "协商中"}):
send_group_text(client, chat_id, copy.group_deal_need_quote())
return "deal_need_quote"
from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason
@@ -1317,10 +1340,7 @@ def capture_group_lost_reason(
send_group_text(
client,
chat_id,
copy.lost_reason_ack(
str(result.get("lost_reason") or text),
work_order_no=no,
),
copy.lost_reason_ack(str(result.get("lost_reason") or text), work_order_no=no),
)
return "done"
@@ -1646,53 +1666,6 @@ def merge_quote_fees(base: dict[str, Any] | None, overlay: dict[str, Any] | None
return stamp_prices_for_sheet(out)
def _attach_land_weight_band_segment(
quote: dict[str, Any], view: dict[str, Any]
) -> dict[str, Any]:
"""
陆运产品分档价:把 weightPrices / 燃油税率挂到 landSegments,出单才能填「2-500KG」列。
无分档价时原样返回。OD 取工单询价事实,不另猜城市。
"""
src = dict(quote or {})
tiers = src.get("weightPrices")
if not isinstance(tiers, list) or not tiers:
return src
facts = dict(view.get("facts") or {})
origin = str(
facts.get("始发地")
or facts.get("起运地")
or facts.get("origin")
or view.get("origin")
or ""
).strip()
dest = str(
facts.get("目的地")
or facts.get("destination")
or view.get("destination")
or ""
).strip()
seg: dict[str, Any] = {
"origin": origin,
"destination": dest,
"weightPrices": list(tiers),
"currency": str(src.get("currency") or "CNY"),
"salePrice": str(src.get("total") or tiers[0].get("price") or ""),
"price_per_kg": str(src.get("price_per_kg") or tiers[0].get("price") or ""),
}
fuel = str(src.get("fuel_surcharge_rate") or src.get("fuelSurchargeRate") or "").strip()
tax = str(src.get("tax_rate") or src.get("taxRate") or "").strip()
if fuel:
seg["fuel_surcharge_rate"] = fuel
seg["fuelSurchargeRate"] = fuel
if tax:
seg["tax_rate"] = tax
seg["taxRate"] = tax
src["landSegments"] = [seg]
src["route_rows"] = [seg]
return src
def _save_product_quote(
flow: Any,
no: str,
+31 -8
View File
@@ -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
@@ -850,7 +879,6 @@ def _resume_cabin_step(
work_order_no=no,
lock_id=lock_id,
sender_id=sender,
operator_name=str(extra.get("operator_name") or "").strip(),
instruction_text=instruction,
)
or {}
@@ -861,20 +889,15 @@ def _resume_cabin_step(
service = "空运舱位"
reason = "空运释放舱位接口调用失败"
else:
# 与首次群锁舱对齐:重试必须带 operatorName / airline,否则 MANUAL 出站缺姓名。
locker = getattr(ledger, "lock_cabin", None)
op_name = str(extra.get("operator_name") or "").strip()
airline = str(extra.get("airline") or "").strip()
flight_no = str(extra.get("flight_no") or "").strip()
out = call_with_retries(
lambda: locker(
work_order_no=no,
option_no=option_no,
sender_id=sender,
operator_name=op_name,
instruction_text=instruction,
airline=airline,
flight_no=flight_no,
airline=str(extra.get("airline") or ""),
flight_no=str(extra.get("flight_no") or ""),
)
or {}
if callable(locker)
+16 -13
View File
@@ -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):
@@ -154,17 +164,10 @@ def _hint_lock_or_release(text: str) -> str:
def _only_work_order(text: str) -> bool:
"""
去掉工单号和 @询价小助手 后几乎没字,当作激活/切单。
销售常按话术复制「工单编号WO…@询价小助手」;残留「工单编号」不算业务意图。
运行证据:only_wo=false 后走 extract_product_quote→product_quote→航线岗拒绝。
"""
"""去掉工单号和 @询价小助手 后几乎没字,当作激活/切单。"""
no = copy.extract_work_order_no(text)
rest = (text or "").replace(no, "").replace(no.lower(), "")
rest = rest.replace("@询价小助手", "").replace("询价小助手", "")
for noise in ("工单编号", "工单号", "编号"):
rest = rest.replace(noise, "")
rest = "".join(ch for ch in rest if ch.isalnum() or "\u4e00" <= ch <= "\u9fff")
return not rest.strip()
@@ -183,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、只用刚报的价;"
@@ -195,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 = (
("是否含电", ("不含电", "不带电", "无电"), ("含电", "带电")),
@@ -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]
+338 -48
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
@@ -421,21 +527,6 @@ class AirGroupCollabTests(unittest.TestCase):
classify_air_group_text("@询价小助手 WO202609160002", allow_network=False),
INTENT_ACTIVATE,
)
# 话术前缀「工单编号」不能当成报价(WO026 复现:误走 product_quote)。
self.assertEqual(
classify_air_group_text(
"工单编号WO202610070026@询价小助手",
allow_network=False,
),
INTENT_ACTIVATE,
)
self.assertEqual(
classify_air_group_text(
"工单编号 WO202610060026@询价小助手",
allow_network=False,
),
INTENT_ACTIVATE,
)
from unittest.mock import patch
with patch(
@@ -449,13 +540,6 @@ class AirGroupCollabTests(unittest.TestCase):
),
INTENT_ACTIVATE,
)
self.assertEqual(
classify_air_group_text(
"工单编号WO202610070026@询价小助手",
allow_network=True,
),
INTENT_ACTIVATE,
)
def test_dispatch_aibot_goes_air(self) -> None:
from agent.routing.dispatch import dispatch_inbound as _disp
@@ -636,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(
@@ -928,8 +1051,7 @@ class AirGroupCollabTests(unittest.TestCase):
self.assertFalse(any("AIR-OPT-01" in t for t in self._texts()))
self.assertFalse(any("执行成功" in t for t in self._texts()))
def test_manual_quote_without_option_asks_fields(self) -> None:
"""纯人工价且口令无 AIR-OPT:交主账后仍缺 → lock_incomplete(不空壳成功)。"""
def test_manual_quote_without_quote_id_can_lock(self) -> None:
wo = self._create_air(
quote={
"total": "100",
@@ -944,35 +1066,146 @@ class AirGroupCollabTests(unittest.TestCase):
ledger=self.ledger,
injected_intent="air_lock",
)
self.assertEqual(phase, "lock_incomplete")
joined = "\n".join(self._texts())
self.assertIn("舱位编号", joined)
self.assertFalse(any("执行成功" in t for t in self._texts()))
def test_manual_quote_with_option_airline_can_lock(self) -> None:
"""人工价补齐舱位+航司+航班后可锁;计费重用工单毛重兜底。"""
wo = self._create_air(
quote={
"total": "100",
"source": "manual",
"source_label": "产品报价",
"currency": "CNY",
},
status="已报价",
)
self.assertEqual(phase, "wait_lock_params")
phase = handle_air_group(
self._msg(
sender="air1",
text=f"{wo}锁舱,锁AIR-OPT01 CZ 航班CZ327",
chat="wr_air_lock_manual2",
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.assertTrue(any("执行成功" in t for t in self._texts()))
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()
@@ -1060,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"},
],
}
)
@@ -1071,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,
@@ -1166,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(
@@ -1396,9 +1654,41 @@ 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()
wo = self._create_air(quote={}, status="询价中")
self.ledger._cabin_lock_result = {
"ok": False,
"error": "lock_fail",
+80 -1
View File
@@ -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()
@@ -331,7 +407,10 @@ class AirRouteOptionsTests(unittest.TestCase):
self.assertIn("closeWindow", ok)
self.assertIn("wecom-jssdk", ok)
self.assertIn("jsapi-signature", ok)
self.assertNotIn("window.close()", ok)
mobile = ok.split("function closeMobile()")[1].split("function closeDesktop()")[0]
desktop = ok.split("function closeDesktop()")[1].split("function boot()")[0]
self.assertNotIn("window.close()", mobile)
self.assertIn("window.close()", desktop)
self.assertNotIn("closeWindow", dead)
@@ -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,12 @@ class AttachmentUploadH5Tests(unittest.TestCase):
self.assertIn("closeWindow", ok)
self.assertIn("wecom-jssdk", ok)
self.assertIn("jsapi-signature", ok)
self.assertIn("function isPhone()", ok)
self.assertIn("WeixinJSBridge.invoke", ok)
mobile = ok.split("function closeMobile()")[1].split("function closeDesktop()")[0]
desktop = ok.split("function closeDesktop()")[1].split("function boot()")[0]
self.assertNotIn("window.close()", mobile)
self.assertIn("window.close()", desktop)
self.assertNotIn("closeWindow", page)
self.assertNotIn("closeWindow", dead)
+22 -12
View File
@@ -11,6 +11,7 @@ from zoneinfo import ZoneInfo
from agent.policy.deal_followup import (
SHANGHAI,
air_route_keys,
is_workday,
leader_matches,
next_followup_at,
shift_workdays,
@@ -20,33 +21,42 @@ from agent.policy.deal_followup import (
class TestWorkdays(unittest.TestCase):
def test_skip_weekend(self) -> None:
# 2026-09-18 周五,顺延 3 个工作日 → 21、22、23,第四个工作日 24
self.assertEqual(shift_workdays(date(2026, 9, 18), 3), date(2026, 9, 23))
self.assertEqual(shift_workdays(date(2026, 9, 18), 4), date(2026, 9, 24))
# 普通周末仍跳过。2026-11-13 周五 → 16、17、18 三个工作日,第四个是 19 日。
self.assertEqual(shift_workdays(date(2026, 11, 13), 3), date(2026, 11, 18))
self.assertEqual(shift_workdays(date(2026, 11, 13), 4), date(2026, 11, 19))
def test_sept20_makeup_pulls_followup_forward(self) -> None:
# 2026-09-18 周五。9 月 20 日周日调休上班,第四个工作日是 23 日,不是 24 日。
self.assertEqual(shift_workdays(date(2026, 9, 18), 4), date(2026, 9, 23))
def test_skip_national_day(self) -> None:
# 2026-09-30 周三,国庆休 → 第四个工作日落到节后
# 2026-09-30 周三。10 月 1–7 日放假,8 日上班,10 日周六调休。
# 节后第 1 个工作日 8 日,第 4 个是 12 日周一。
fourth = shift_workdays(date(2026, 9, 30), 4)
self.assertGreaterEqual(fourth, date(2026, 10, 9))
self.assertNotEqual(fourth.weekday(), 5)
self.assertNotEqual(fourth.weekday(), 6)
self.assertEqual(fourth, date(2026, 10, 12))
def test_oct8_works_and_sept20_is_makeup(self) -> None:
# 国办发明电〔2025〕7号:10 月 8 日正常上班,9 月 20 日周日调休上班。
self.assertTrue(is_workday(date(2026, 10, 8)))
self.assertTrue(is_workday(date(2026, 9, 20)))
self.assertFalse(is_workday(date(2026, 10, 7)))
def test_next_followup_is_9am(self) -> None:
when = next_followup_at(datetime(2026, 9, 18, 15, 30, tzinfo=SHANGHAI))
self.assertEqual(when.hour, 9)
self.assertEqual(when.minute, 0)
self.assertEqual(when.tzinfo, SHANGHAI)
self.assertEqual(when.date(), date(2026, 9, 24))
self.assertEqual(when.date(), date(2026, 9, 23))
def test_skip_weekend_keep_weekday_night(self) -> None:
start = datetime(2026, 9, 18, 17, 0, tzinfo=SHANGHAI) # 周五
now = datetime(2026, 9, 21, 17, 0, tzinfo=SHANGHAI) # 周一
start = datetime(2026, 11, 13, 17, 0, tzinfo=SHANGHAI) # 周五
now = datetime(2026, 11, 16, 17, 0, tzinfo=SHANGHAI) # 周一
# 周五 17–24 = 7h,周末不算,周一 0–17 = 17h
self.assertAlmostEqual(workday_elapsed_hours(start, now), 24.0, places=4)
def test_rest_day_start_counts_from_next_workday(self) -> None:
start = datetime(2026, 9, 19, 10, 0, tzinfo=SHANGHAI) # 周六
now = datetime(2026, 9, 21, 10, 0, tzinfo=SHANGHAI) # 周一
start = datetime(2026, 11, 14, 10, 0, tzinfo=SHANGHAI) # 周六
now = datetime(2026, 11, 16, 10, 0, tzinfo=SHANGHAI) # 周一
self.assertAlmostEqual(workday_elapsed_hours(start, now), 10.0, places=4)
def test_fractional_quarter_hour(self) -> None:
+9 -54
View File
@@ -144,60 +144,15 @@ class TestDealOutcome(unittest.TestCase):
view = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1")
self.assertEqual(view.get("wait_phase"), "done")
def test_lost_from_inquiring_promotes_quoted_first(self) -> None:
"""主账仍询价中时点未成交:先补已报价再入未成交,禁止直接失败。"""
class _StrictSix:
def __init__(self) -> None:
self.status = "询价中"
self.lost_reason = ""
self.event_wait_kind = ""
def transition(self, *, work_order_no, to_status, **kwargs):
_ = work_order_no
allowed = {
"询价中": {"已报价", "协商中", "已关闭"},
"已报价": {"协商中", "已成交", "未成交", "已关闭"},
}
if to_status not in allowed.get(self.status, set()):
# 模拟 http_ledger 在 500 时只回 http_500(丢失正文)
return {"ok": False, "error": "http_500"}
self.status = to_status
if to_status == "未成交" and kwargs.get("lost_reason"):
self.lost_reason = str(kwargs.get("lost_reason") or "")
return {"ok": True, "status": self.status}
def patch_lost_reason(self, *, work_order_no, lost_reason):
_ = work_order_no
self.lost_reason = lost_reason
return {"ok": True}
def get_for_agent(self, *, work_order_no, sender_id):
_ = (work_order_no, sender_id)
return {"status": self.status, "lost_reason": self.lost_reason}
ledger = _StrictSix()
out = finish_deal_outcome(
ledger=ledger,
work_order_no="WO202610070023",
outcome="未成交",
facts={},
quote={},
business_line="AIR",
)
self.assertTrue(out.get("ok"), out)
self.assertEqual(ledger.status, "未成交")
self.assertEqual(out.get("phase"), "wait_lost_reason")
def test_lost_reason_ack_repeats_user_text(self) -> None:
self.assertEqual(copy.lost_reason_ack("客户不要了"), "已记下未成交原因:客户不要了。")
self.assertEqual(copy.lost_reason_ack("价格高。"), "已记下未成交原因:价格高。")
def test_lost_reason_ack_fills_ticket_no_from_bot_reply(self) -> None:
"""后台文案带 {{ticket_no}} 时必须填实值,禁止原样打出占位符。"""
from agent.policy import bot_reply
def test_lost_reason_ack_fills_ticket_no(self) -> None:
"""后台模板带工单号时,必须换成真实单号,不能把占位符原样发出去。"""
from agent.policy.bot_reply import override_catalog
bot_reply.override_catalog(
override_catalog(
{
"lost.reason_ack": (
"已将未成交原因“{{lost_reason}}”记录在后台,"
@@ -206,12 +161,12 @@ class TestDealOutcome(unittest.TestCase):
}
)
try:
text = copy.lost_reason_ack("换时间", work_order_no="WO202610070018")
self.assertIn("WO202610070018", text)
self.assertNotIn("{{ticket_no}}", text)
self.assertIn("换时间", text)
self.assertEqual(
copy.lost_reason_ack("客户不需要了", work_order_no="WO202609100001"),
"已将未成交原因“客户不需要了”记录在后台,工单WO202609100001已关闭,智能体不再跟进该工单,请悉知。",
)
finally:
bot_reply.override_catalog(None)
override_catalog(None)
def test_harvest_inline_lost_reason(self) -> None:
self.assertEqual(harvest_inline_lost_reason("未成交,原因客户不想要了"), "客户不想要了")
+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__":
+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()
@@ -1341,28 +1341,6 @@ class SeaGroupCollabFieldTests(unittest.TestCase):
self.assertEqual(str(rows["海运费"].get("amount") or ""), "100.00")
self.assertEqual(str(rows["燃油附加费"].get("amount") or ""), "15.00")
def test_land_weight_band_unit_price_and_rates(self) -> None:
"""陆运集拼:2-500KG 单价 + 燃油附加费率/税率,不能把档下限 2 当成燃油附加费。"""
from agent.llm.extract_text import extract_product_quote
quote = extract_product_quote(
"2-500KG 单价CNY990,燃油附加费率1%,税率6%"
)
self.assertEqual(str(quote.get("total") or ""), "990")
self.assertEqual(str(quote.get("fuel_surcharge_rate") or ""), "1%")
self.assertEqual(str(quote.get("tax_rate") or ""), "6%")
tiers = list(quote.get("weightPrices") or [])
self.assertEqual(len(tiers), 1)
self.assertEqual(int(tiers[0].get("min") or 0), 2)
self.assertEqual(int(tiers[0].get("max") or 0), 500)
self.assertEqual(str(tiers[0].get("price") or ""), "990")
rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
self.assertIn("2-500KG", rows)
self.assertEqual(str(rows["2-500KG"].get("amount") or ""), "990")
self.assertNotEqual(str(rows.get("燃油附加费", {}).get("amount") or ""), "2")
self.assertEqual(str(rows.get("燃油附加费率", {}).get("amount") or ""), "1%")
self.assertEqual(str(rows.get("税率", {}).get("amount") or ""), "6%")
def test_air_unit_qty_amount_keeps_air_freight_name(self) -> None:
"""空运费 单价/数量/金额:费用名是空运费,金额用「金额」不是单价。"""
from agent.llm.extract_text import extract_product_quote
+10 -27
View File
@@ -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,45 +480,27 @@ 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:
# 空运单 + 可锁报价;重试必须带上 operator_name / airline。
air = self.ledger.create_ticket(
sender_id="sales1",
business_line="AIR",
facts={"起运地": "上海", "目的地": "洛杉矶", "毛重": "100"},
)
air_wo = str(air.get("work_order_no") or "")
self.ledger.upsert_quote(
work_order_no=air_wo,
quote={
"total": "800",
"source": "TMS",
"quoteId": "QT-AIR-1",
"airOptions": [{"optionNo": "AIR-OPT-01", "airlineCode": "CZ"}],
},
work_order_no=self.wo,
quote={"total": "100", "quoteId": "QT-AIR-1"},
to_status="已报价",
)
self.ledger.mark_system_exception(
work_order_no=air_wo,
work_order_no=self.wo,
system_exception=TYPE_TMS,
system_exception_reason="空运锁舱接口调用失败",
step=STEP_TMS_CABIN,
payload={
"conversation_kind": "group",
"conversation_target": "chat-air",
"extra": {
"option_no": "AIR-OPT-01",
"instruction": "锁舱",
"operator_name": "HangXian",
"airline": "CZ",
},
"extra": {"option_no": "AIR-OPT-01", "instruction": "锁舱"},
},
)
hits = {"n": 0, "kwargs": {}}
hits = {"n": 0}
orig_lock = self.ledger.lock_cabin
def counted(**kwargs):
hits["n"] += 1
hits["kwargs"] = dict(kwargs)
return orig_lock(**kwargs)
self.ledger.lock_cabin = counted # type: ignore[method-assign]
@@ -527,14 +512,12 @@ class TestConfirmSystemException(unittest.TestCase):
orig = se._send_to_conversation
se._send_to_conversation = lambda **kwargs: talks.append(str(kwargs.get("text") or ""))
try:
out = run_admin_retry(air_wo, ledger=self.ledger)
out = run_admin_retry(self.wo, ledger=self.ledger)
finally:
se._send_to_conversation = orig
self.assertTrue(out.get("ok"))
self.assertGreaterEqual(hits["n"], 1)
self.assertEqual(hits["kwargs"].get("operator_name"), "HangXian")
self.assertEqual(hits["kwargs"].get("airline"), "CZ")
ticket = self.ledger.get_ticket(work_order_no=air_wo)
ticket = self.ledger.get_ticket(work_order_no=self.wo)
self.assertFalse(ticket_is_paused(ticket))
self.assertTrue(any("空运舱位接口已恢复" in x for x in talks))
+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)
@@ -1171,27 +1171,6 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
one.put("delivery_fee", out.getString("delivery_fee"));
one.put("price_per_cbm", out.getString("price_per_cbm"));
one.put("price_per_kg", out.getString("price_per_kg"));
// 产品文字报「2-500KG 单价…」时 weightPrices / 费率在 quote 顶层,无 TMS landSegments。
Object wp = q.get("weightPrices");
if (wp != null) {
one.put("weightPrices", wp);
}
String fuelRate = firstNonBlankLocal(
out.getString("fuel_surcharge_rate"),
first(q, "fuel_surcharge_rate"),
first(q, "fuelSurchargeRate"));
if (StringUtils.isNotBlank(fuelRate)) {
one.put("fuel_surcharge_rate", fuelRate);
one.put("fuelSurchargeRate", fuelRate);
}
String taxRate = firstNonBlankLocal(
out.getString("tax_rate"),
first(q, "tax_rate"),
first(q, "taxRate"));
if (StringUtils.isNotBlank(taxRate)) {
one.put("tax_rate", taxRate);
one.put("taxRate", taxRate);
}
String landAmt = firstNonBlankLocal(out.getString("land_freight"), out.getString("price_per_cbm"));
for (int i = 0; i < rows.size(); i++) {
JSONObject fee = rows.getJSONObject(i);
@@ -243,9 +243,6 @@ public final class QuoteTemplateExcelSupport {
LAND_TMS_KEY_TO_CODE.put("transitDays", "transit_days");
LAND_TMS_KEY_TO_CODE.put("effectiveTime", "effective_time");
LAND_TMS_KEY_TO_CODE.put("landFreight", "land_freight");
LAND_TMS_KEY_TO_CODE.put("fuelSurchargeRate", "fuel_surcharge_rate");
LAND_TMS_KEY_TO_CODE.put("rate", "tax_rate");
LAND_TMS_KEY_TO_CODE.put("rateFlag", "tax_included");
LAND_TMS_KEY_TO_CODE.put("vehicle42Fee", "vehicle_42_fee");
LAND_TMS_KEY_TO_CODE.put("vehicle50Fee", "vehicle_50_fee");
LAND_TMS_KEY_TO_CODE.put("vehicle76Fee", "vehicle_76_fee");
@@ -2856,17 +2853,15 @@ public final class QuoteTemplateExcelSupport {
writeFeeCol(sheet, ri, columns.getString("remark"), rowObj.getString("remark"), yellow, sheet.getSheetName());
if (columns.containsKey("tax_rate")) {
writeFeeCol(sheet, ri, columns.getString("tax_rate"),
formatLandTaxRateDisplay(firstNonBlank(rowObj.getString("tax_rate"),
rowObj.getString("taxRate"),
rowObj.getString("rate"),
facts == null ? null : facts.getString("tax_rate"))),
firstNonBlank(rowObj.getString("tax_rate"), rowObj.getString("taxRate"),
facts == null ? null : facts.getString("tax_rate")),
yellow, sheet.getSheetName());
}
if (columns.containsKey("fuel_surcharge_rate")) {
writeFeeCol(sheet, ri, columns.getString("fuel_surcharge_rate"),
formatLandPercentDisplay(firstNonBlank(rowObj.getString("fuel_surcharge_rate"),
firstNonBlank(rowObj.getString("fuel_surcharge_rate"),
rowObj.getString("fuelSurchargeRate"),
facts == null ? null : facts.getString("fuel_surcharge_rate"))),
facts == null ? null : facts.getString("fuel_surcharge_rate")),
yellow, sheet.getSheetName());
}
writeLandMatrixSideCols(sheet, ri, region, rowObj, facts, yellow);
@@ -2942,19 +2937,12 @@ public final class QuoteTemplateExcelSupport {
facts == null ? null : facts.getString("currency"));
}
if (header.contains("燃油附加")) {
return formatLandPercentDisplay(firstNonBlank(rowObj.getString("fuel_surcharge_rate"),
rowObj.getString("fuelSurchargeRate"),
facts == null ? null : facts.getString("fuel_surcharge_rate")));
return firstNonBlank(rowObj.getString("fuel_surcharge_rate"), rowObj.getString("fuelSurchargeRate"),
facts == null ? null : facts.getString("fuel_surcharge_rate"));
}
if ("税率".equals(header)) {
return formatLandTaxRateDisplay(firstNonBlank(rowObj.getString("tax_rate"), rowObj.getString("taxRate"),
rowObj.getString("rate"),
facts == null ? null : facts.getString("tax_rate")));
}
if (header.contains("是否含税") || "含税".equals(header)) {
return firstNonBlank(rowObj.getString("tax_included"),
formatLandTaxIncludedDisplay(firstNonBlank(rowObj.getString("rateFlag"),
rowObj.getString("rate_flag"))));
return firstNonBlank(rowObj.getString("tax_rate"), rowObj.getString("taxRate"),
facts == null ? null : facts.getString("tax_rate"));
}
if (header.contains("含税合计") || header.contains("杂费合计")) {
return firstNonBlank(rowObj.getString("amount"), rowObj.getString("salePrice"),
@@ -3191,21 +3179,14 @@ public final class QuoteTemplateExcelSupport {
out.put("transit_time", transit);
out.put("transitDays", transit);
}
String fuel = formatLandPercentDisplay(firstNonBlank(
row.getString("fuel_surcharge_rate"), row.getString("fuelSurchargeRate")));
String fuel = firstNonBlank(row.getString("fuel_surcharge_rate"), row.getString("fuelSurchargeRate"));
if (StringUtils.isNotBlank(fuel)) {
out.put("fuel_surcharge_rate", fuel);
}
String tax = formatLandTaxRateDisplay(firstNonBlank(
row.getString("tax_rate"), row.getString("taxRate"), row.getString("rate")));
String tax = firstNonBlank(row.getString("tax_rate"), row.getString("taxRate"));
if (StringUtils.isNotBlank(tax)) {
out.put("tax_rate", tax);
}
String included = firstNonBlank(row.getString("tax_included"),
formatLandTaxIncludedDisplay(firstNonBlank(row.getString("rateFlag"), row.getString("rate_flag"))));
if (StringUtils.isNotBlank(included)) {
out.put("tax_included", included);
}
return out;
}
@@ -3848,68 +3829,6 @@ public final class QuoteTemplateExcelSupport {
return "";
}
/**
* 陆运燃油附加费率写入格子:数字带 %;已带 % 保留。空值不写。
*/
static String formatLandPercentDisplay(String raw) {
if (StringUtils.isBlank(raw) || "-".equals(raw) || "null".equalsIgnoreCase(raw.trim())) {
return "";
}
String text = raw.trim();
if (text.contains("%")) {
return text;
}
if (text.matches("-?\\d+(\\.\\d+)?")) {
if (text.contains(".")) {
text = text.replaceAll("0+$", "").replaceAll("\\.$", "");
}
return text + "%";
}
return text;
}
/**
* 税率 rate:0.09→9%;大于 1 按百分点(10→10%)。
*/
static String formatLandTaxRateDisplay(String raw) {
if (StringUtils.isBlank(raw) || "-".equals(raw) || "null".equalsIgnoreCase(raw.trim())) {
return "";
}
String text = raw.trim();
if (text.contains("%")) {
return text;
}
if (!text.matches("-?\\d+(\\.\\d+)?")) {
return text;
}
try {
java.math.BigDecimal n = new java.math.BigDecimal(text);
java.math.BigDecimal abs = n.abs();
if (abs.compareTo(java.math.BigDecimal.ZERO) > 0
&& abs.compareTo(java.math.BigDecimal.ONE) <= 0) {
n = n.multiply(new java.math.BigDecimal("100")).stripTrailingZeros();
}
return n.stripTrailingZeros().toPlainString() + "%";
} catch (RuntimeException ex) {
return text;
}
}
/** rateFlag:1=含税,0=不含税。 */
static String formatLandTaxIncludedDisplay(String raw) {
if (StringUtils.isBlank(raw) || "-".equals(raw) || "null".equalsIgnoreCase(raw.trim())) {
return "";
}
String text = raw.trim();
if ("1".equals(text) || "true".equalsIgnoreCase(text) || "是".equals(text) || "含税".equals(text)) {
return "含税";
}
if ("0".equals(text) || "false".equalsIgnoreCase(text) || "否".equals(text) || "不含税".equals(text)) {
return "不含税";
}
return text;
}
private static void writeFeeRows(Workbook wb, JSONObject mapping, JSONObject facts, Set<String> yellow) {
JSONObject feeRows = mapping.getJSONObject("fee_rows");
if (feeRows == null) {
@@ -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())) {
@@ -75,16 +96,11 @@ public class InquiryTmsCabinService {
Map<String, Object> quote = payloadOf(rows.get(0));
// 「AIR-OPT01 TH」被误落成航线报价不算真正人工价 → 仍走仅 TMS(SYSTEM)。
boolean latestManual = isManualQuote(quote) && !isFakeManualLockQuote(quote);
// 库里只有人工价、没有舱位时也要现拉 TMS:补 quoteId/airOptions,
// 避免 MANUAL 空壳 1001,也避免逼航线手打航班号。
boolean cabinSparse = StringUtils.isBlank(resolveQuoteId(quote, no))
|| airOptions(quote).isEmpty();
if (cabinSparse) {
if (!latestManual && StringUtils.isBlank(resolveQuoteId(quote, no))) {
quote = refreshLockQuote(ticket, quote);
}
String tmsQuoteId = realTmsQuoteId(rows, no);
// 现拉到的 TMS 编号也算:库里无历史行时仍可 MANUAL+quoteId。
if (StringUtils.isBlank(tmsQuoteId)) {
if (!latestManual && StringUtils.isBlank(tmsQuoteId)) {
tmsQuoteId = resolveQuoteId(quote, no);
}
boolean hasTms = StringUtils.isNotBlank(tmsQuoteId);
@@ -145,68 +161,26 @@ public class InquiryTmsCabinService {
text(option, "destination"), ticket.getDestination(),
fact(ticket, "目的地", "目的港")));
// 航司:句中两位码(如「AIR-OPT01 TH」)> 舱位/报价字段 > 航班号前两位。
// airlineName 中文名不能当航司码出站,只取两位码或航班前缀。
String airlineCode = firstNonBlank(
req.put("airline", firstNonBlank(
text(body, "airline", "airlineCode"),
airlineFromInstruction(text(body, "instructionText", "instruction_text")),
text(option, "airlineCode", "airline"),
text(quote, "airlineCode", "airline"),
fact(ticket, "航司代码"),
airlineFromFlight(firstNonBlank(text(option, "flightNo"), text(quote, "flightNo"))),
airlineCodeOrBlank(text(option, "airlineName")),
airlineCodeOrBlank(text(quote, "airlineName")),
airlineCodeOrBlank(fact(ticket, "航司")));
req.put("airline", airlineCode);
text(option, "airlineName"),
text(quote, "airlineCode", "airline", "airlineName"),
fact(ticket, "航司", "航司代码"),
airlineFromFlight(firstNonBlank(text(option, "flightNo"), text(quote, "flightNo")))));
req.put("weightKg", ticket.getWeightKg());
req.put("volumeCbm", ticket.getVolumeCbm());
req.put("pieceQty", ticket.getPieces());
// 计费重:舱位 > 报价 > 工单计费重事实 > 毛重(避免 MANUAL 空壳 1001)。
req.put("chargeableWeightKg", firstNonBlank(
text(option, "chargeableWeightKg", "chargeable_weight_kg"),
text(quote, "chargeableWeightKg", "chargeable_weight_kg"),
fact(ticket, "计费重", "计费重量"),
ticket.getWeightKg() == null ? "" : String.valueOf(ticket.getWeightKg()),
fact(ticket, "毛重")));
text(quote, "chargeableWeightKg", "chargeable_weight_kg")));
req.put("operatorUserId", text(body, "operatorUserId", "sender_id"));
// 重试漏传姓名时用 userid 兜底,仍要有 operatorName 键。
req.put("operatorName", firstNonBlank(
text(body, "operatorName", "operator_name"),
text(body, "operatorUserId", "sender_id")));
req.put("operatorName", text(body, "operatorName", "operator_name"));
req.put("instructionText", text(body, "instructionText", "instruction_text"));
if ("MANUAL".equals(lockSource)) {
// 有 TMS 时保留 quoteId;纯人工才去掉。
fillManualLock(req, quote, option, body, StringUtils.isNotBlank(quoteId));
// 人工价常不写有效期:用航班日当天 23:59:59+08:00 兜底,避免空壳 1001。
if (StringUtils.isBlank(text(req, "validUntil"))) {
String isoUntil = normalizeValidUntilIso(text(req, "flightDate"));
if (StringUtils.isNotBlank(isoUntil)) {
req.put("validUntil", isoUntil);
if (StringUtils.isBlank(text(req, "requestedUntil"))) {
req.put("requestedUntil", isoUntil);
}
}
}
// 缺必填仍出站会被 TMS 1001;在此拦住并回清原因。
String incomplete = validateManualLockOutbound(req);
// 现拉仍无舱位时,改成业务补问口径(禁止堆「缺少 optionNo」技术字段名)。
if (StringUtils.isNotBlank(incomplete)) {
String refreshClass = text(quote, "_lockRefreshClass");
String refreshMsg = text(quote, "_lockRefreshMsg");
boolean noMatch = "TMS_NO_QUOTE_1002".equals(refreshClass)
|| (StringUtils.isNotBlank(refreshMsg) && refreshMsg.contains("无匹配"));
if (noMatch) {
incomplete = "本单是手工报价,且运价库无匹配舱位,无法自动带出航班。"
+ "请按「AIR-OPT01 CZ 航班CZ327」补舱位、航司和航班号后再锁。"
+ "(现拉TMS:" + firstNonBlank(refreshMsg, "标准运价库无匹配报价") + ")";
} else if (StringUtils.isNotBlank(refreshMsg)) {
incomplete = incomplete + "(现拉TMS:" + refreshMsg + ")";
}
}
if (StringUtils.isNotBlank(incomplete)) {
log.info("tms.lock incomplete wo={} optionNo={} missing={}",
no, resolvedOption, incomplete);
return fail("lock_incomplete", incomplete);
}
} else {
// 仅 TMS:source=SYSTEM + quoteId。禁止再塞 validUntil/feeItems(会 1001)。
req.put("source", "SYSTEM");
@@ -233,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);
}
@@ -362,26 +356,14 @@ public class InquiryTmsCabinService {
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("workOrderNo", ticket.getWorkOrderNo());
// 锁舱现拉必须用「今天」报价日:历史询价日常已无运价(1002),
// 不能把销售初询那天的日期带出去导致永远补不到舱位。
JSONObject facts = new JSONObject();
if (StringUtils.isNotBlank(ticket.getFactsJson())) {
JSONObject stored = JSON.parseObject(ticket.getFactsJson());
if (stored != null) {
facts.putAll(stored);
JSONObject facts = JSON.parseObject(ticket.getFactsJson());
if (facts != null) {
body.put("facts", facts);
}
}
SimpleDateFormat dayFmt = new SimpleDateFormat("yyyy-MM-dd");
dayFmt.setTimeZone(java.util.TimeZone.getTimeZone("Asia/Shanghai"));
String today = dayFmt.format(new java.util.Date());
facts.put("报价日期", today);
body.put("facts", facts);
try {
Map<String, Object> live = queryService.queryFromAgent(body);
if (live != null && live.get("classification") != null) {
current.put("_lockRefreshClass", String.valueOf(live.get("classification")));
current.put("_lockRefreshMsg", String.valueOf(live.get("message") == null ? "" : live.get("message")));
}
Object raw = live == null ? null : live.get("quote");
if (!(raw instanceof Map)) {
return current;
@@ -394,19 +376,14 @@ public class InquiryTmsCabinService {
if (airOptions(current).isEmpty() && fetched.get("airOptions") != null) {
current.put("airOptions", fetched.get("airOptions"));
}
if (airOptions(current).isEmpty() && fetched.get("air_options") != null) {
current.put("airOptions", fetched.get("air_options"));
}
if (StringUtils.isBlank(text(current, "chargeableWeightKg", "chargeable_weight_kg"))
&& StringUtils.isNotBlank(text(fetched, "chargeableWeightKg", "chargeable_weight_kg"))) {
current.put("chargeableWeightKg", text(fetched, "chargeableWeightKg", "chargeable_weight_kg"));
}
log.info("tms.lock refresh wo={} hasQuoteId={} airOpt={} class={} quoteDate={}",
log.info("tms.lock refresh wo={} hasQuoteId={} airOpt={}",
ticket.getWorkOrderNo(),
StringUtils.isNotBlank(text(current, "quoteId", "quote_id")),
airOptions(current).size(),
live == null ? "" : live.get("classification"),
today);
airOptions(current).size());
} catch (RuntimeException ex) {
log.warn("tms.lock refresh fail wo={} err={}", ticket.getWorkOrderNo(), ex.getMessage());
}
@@ -475,16 +452,20 @@ 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)) {
req.put("transferAirport", transfer);
}
// 句中/请求体点名的航班号优先,其次舱位与报价。
String flightNo = firstNonBlank(
text(body, "flightNo", "flight_no"),
text(option, "flightNo", "flight_no"),
text(quote, "flightNo", "flight_no"));
String flightNo = firstNonBlank(text(option, "flightNo"), text(quote, "flightNo"));
if (StringUtils.isNotBlank(flightNo)) {
req.put("flightNo", flightNo);
}
@@ -531,41 +512,11 @@ public class InquiryTmsCabinService {
List<Map<String, Object>> departure = feeRows(quote, option, "departureCharges", "departure_charges");
List<Map<String, Object>> fees = feeRows(quote, option, "feeItems", "fee_items");
splitFeeRows(quote, departure, fees);
// 运行证据(2026-10-07):只要出站带 departureCharges,TMS 一律 1001「参数格式错误」;
// 去掉后变为业务码 2008。feeItems 可保留。故 MANUAL 锁舱不再出站 departureCharges。
if (!departure.isEmpty()) {
req.put("departureCharges", toLockFees(departure, true));
}
if (!fees.isEmpty()) {
req.put("feeItems", toLockFees(fees, false));
} else if (!departure.isEmpty()) {
// 仅有起运港费、无 feeItems 时,折进 feeItems,避免再走 departureCharges。
List<Map<String, Object>> asFees = new ArrayList<>();
for (Map<String, Object> row : toLockFees(departure, true)) {
Map<String, Object> fee = new LinkedHashMap<>();
String name = text(row, "chargeName", "feeName");
if (StringUtils.isNotBlank(name)) {
fee.put("feeName", name);
}
if (row.get("unitPrice") != null) {
fee.put("unitPrice", row.get("unitPrice"));
}
if (row.get("quantity") != null) {
fee.put("quantity", row.get("quantity"));
}
if (row.get("amount") != null) {
fee.put("amount", row.get("amount"));
}
if (row.get("currency") != null) {
fee.put("currency", row.get("currency"));
}
if (row.get("unit") != null) {
fee.put("unit", row.get("unit"));
}
if (!fee.isEmpty()) {
asFees.add(fee);
}
}
if (!asFees.isEmpty()) {
req.put("feeItems", asFees);
}
}
}
@@ -789,24 +740,10 @@ public class InquiryTmsCabinService {
return true;
}
/**
* 本单真实 TMS quoteId:优先非人工报价行;人工覆盖后再锁仍要带原编号。
* 跳过 QT-WO 假编号。
*/
static String realTmsQuoteId(List<InquiryQuote> rows, String workOrderNo) {
if (rows == null) {
return "";
}
for (InquiryQuote row : rows) {
Map<String, Object> payload = payloadOf(row);
if (isManualQuote(payload) && !isFakeManualLockQuote(payload)) {
continue;
}
String id = resolveQuoteId(payload, workOrderNo);
if (StringUtils.isNotBlank(id)) {
return id;
}
}
for (InquiryQuote row : rows) {
String id = resolveQuoteId(payloadOf(row), workOrderNo);
if (StringUtils.isNotBlank(id)) {
@@ -816,51 +753,6 @@ public class InquiryTmsCabinService {
return "";
}
/**
* MANUAL 出站必填校验。缺字段直接失败,禁止 HMAC 空壳打 TMS(会 1001)。
* 返回中文原因;齐了返回空串。
*/
static String validateManualLockOutbound(Map<String, Object> req) {
if (req == null) {
return "锁舱参数为空";
}
java.util.List<String> missing = new ArrayList<>();
if (StringUtils.isBlank(text(req, "optionNo", "option_no"))) {
missing.add("舱位编号optionNo");
}
if (StringUtils.isBlank(text(req, "airline", "airlineCode"))) {
missing.add("航司airline");
}
if (StringUtils.isBlank(text(req, "chargeableWeightKg", "chargeable_weight_kg"))) {
missing.add("计费重chargeableWeightKg");
}
if (StringUtils.isBlank(text(req, "flightNo", "flight_no"))) {
missing.add("航班号flightNo");
}
if (StringUtils.isBlank(text(req, "operatorName", "operator_name"))) {
missing.add("操作人operatorName");
}
if (StringUtils.isBlank(text(req, "validUntil", "valid_until"))) {
missing.add("有效期validUntil");
}
if (missing.isEmpty()) {
return "";
}
return "锁舱参数不完整,缺少:" + String.join("、", missing)
+ "。请按「AIR-OPT01 CZ 航班CZ327」补舱位、航司和航班号后再锁。";
}
/**
* 航司名只有恰好两位字母时才当代码;「南航」等中文名不当 airline 出站。
*/
static String airlineCodeOrBlank(String raw) {
String text = StringUtils.trimToEmpty(raw).toUpperCase();
if (text.length() == 2 && Character.isLetter(text.charAt(0)) && Character.isLetter(text.charAt(1))) {
return text;
}
return "";
}
private List<InquiryQuote> quoteRows(String workOrderNo) {
if (StringUtils.isBlank(workOrderNo)) {
return new ArrayList<>();
@@ -1185,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。
*/
@@ -484,21 +484,6 @@ public class InquiryTmsQueryService {
putExtra(row, pair[1], firstText(seg.get(pair[0]), seg.get(pair[1])));
putExtra(row, pair[0], firstText(seg.get(pair[0])));
}
// 陆运 TMS:fuelSurchargeRate=燃油附加费率;rate=税率;rateFlag=是否含税(1是0否)。
// 只投影 landSegments,禁止拿 fxRates.rate(汇率)冒充税率。
String fuelRate = formatLandPercentRate(firstText(
seg.get("fuelSurchargeRate"), seg.get("fuel_surcharge_rate")));
putExtra(row, "fuel_surcharge_rate", fuelRate);
putExtra(row, "fuelSurchargeRate", fuelRate);
String taxRate = formatLandTaxRate(firstText(
seg.get("rate"), seg.get("tax_rate"), seg.get("taxRate")));
putExtra(row, "tax_rate", taxRate);
putExtra(row, "taxRate", taxRate);
putExtra(row, "rate", firstText(seg.get("rate")));
String rateFlagRaw = firstText(seg.get("rateFlag"), seg.get("rate_flag"));
putExtra(row, "rateFlag", rateFlagRaw);
putExtra(row, "rate_flag", rateFlagRaw);
putExtra(row, "tax_included", formatLandTaxIncluded(rateFlagRaw));
if (StringUtils.isNotBlank(row.get("origin")) || StringUtils.isNotBlank(row.get("destination"))
|| StringUtils.isNotBlank(row.get("amount"))) {
out.add(row);
@@ -1019,72 +1004,6 @@ public class InquiryTmsQueryService {
}
}
/**
* 燃油附加费率展示:数字带 %;已带 % 保留。空值不编造。
*/
static String formatLandPercentRate(String raw) {
if (StringUtils.isBlank(raw) || "-".equals(raw) || "null".equalsIgnoreCase(raw.trim())) {
return "";
}
String text = raw.trim();
if (text.contains("%")) {
return text;
}
if (text.matches("-?\\d+(\\.\\d+)?")) {
if (text.contains(".")) {
text = text.replaceAll("0+$", "").replaceAll("\\.$", "");
}
return text + "%";
}
return text;
}
/**
* 税率 rate 展示:TMS 常回小数(0.09=9%);大于 1 的按百分点(10=10%)。
*/
static String formatLandTaxRate(String raw) {
if (StringUtils.isBlank(raw) || "-".equals(raw) || "null".equalsIgnoreCase(raw.trim())) {
return "";
}
String text = raw.trim();
if (text.contains("%")) {
return text;
}
if (!text.matches("-?\\d+(\\.\\d+)?")) {
return text;
}
try {
java.math.BigDecimal n = new java.math.BigDecimal(text);
java.math.BigDecimal abs = n.abs();
if (abs.compareTo(java.math.BigDecimal.ZERO) > 0
&& abs.compareTo(java.math.BigDecimal.ONE) <= 0) {
n = n.multiply(java.math.BigDecimal.TEN).multiply(java.math.BigDecimal.TEN)
.stripTrailingZeros();
}
String out = n.stripTrailingZeros().toPlainString();
return out + "%";
} catch (RuntimeException ex) {
return text;
}
}
/**
* rateFlag:是否含税。字典 1=是、0=否。
*/
static String formatLandTaxIncluded(String raw) {
if (StringUtils.isBlank(raw) || "-".equals(raw) || "null".equalsIgnoreCase(raw.trim())) {
return "";
}
String text = raw.trim();
if ("1".equals(text) || "true".equalsIgnoreCase(text) || "是".equals(text) || "含税".equals(text)) {
return "含税";
}
if ("0".equals(text) || "false".equalsIgnoreCase(text) || "否".equals(text) || "不含税".equals(text)) {
return "不含税";
}
return text;
}
private static void addFeeLine(List<Map<String, String>> lines, String name, String amount,
String currency, Map<String, String> extras) {
TmsQuoteCurrency.Money money = TmsQuoteCurrency.split(amount, currency);
@@ -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) {
@@ -69,7 +69,7 @@ VALUES
('brv10000000000000000000000000038','deal','已成交','deal.recorded_won','销售选择「成交」','已记录为「已成交」。工单 {{ticket_no}} 已关闭智能体跟进。',1,1,'admin',NOW()),
('brv10000000000000000000000000039','negotiating','协商中','negotiating.recorded','成交跟进中选择「协商中」','已记录为「协商中」。已顺延三个工作日,{{followup_when}}的9:00再次发起成交状态询问。',1,1,'admin',NOW()),
('brv10000000000000000000000000040','lost','未成交','lost.ask_reason','成交跟进中选择「未成交」且未写原因','已记录为「未成交」。请补充未成交原因。',1,1,'admin',NOW()),
('brv10000000000000000000000000041','lost','未成交','lost.reason_ack','销售回复未成交原因','已将未成交原因“{{lost_reason}}”记录在后台,工单 {{ticket_no}}已关闭,智能体不再跟进该工单,请悉知。',1,2,'admin',NOW()),
('brv10000000000000000000000000041','lost','未成交','lost.reason_ack','销售回复未成交原因','已记下未成交原因:{{lost_reason}}。',1,2,'admin',NOW()),
('brv10000000000000000000000000042','closed','已关闭','closed.air_cannot_activate','空运工单已是终态仍要激活','工单{{ticket_no}}已是「{{status}}」,不能再激活。',1,1,'admin',NOW()),
('brv10000000000000000000000000043','sys-ex','系统异常','sys.llm_fail','大模型调用失败(销售侧)','大模型服务异常,IT排查中...',1,1,'admin',NOW()),
('brv10000000000000000000000000044','sys-ex','系统异常','sys.tms_fail','TMS 接口失败(销售侧)','TMS接口异常,IT运维排查中...',1,2,'admin',NOW()),
@@ -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"},
@@ -2231,58 +2231,4 @@ public class QuoteTemplateFillTest {
}
return cell.getStringCellValue();
}
/**
* 陆运运价表:燃油附加费率、税率必须写到表头同列的数据行。
* rate=0.09→9%;rateFlag 是是否含税,不写进税率格。
*/
@Test
public void landGroupageFillsFuelSurchargeAndTaxRateOnMatrix() throws Exception {
Path src = Files.createTempFile("quote-land-fuel-tax-src", ".xlsx");
Path out = Files.createTempFile("quote-land-fuel-tax-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("中越集拼报价");
org.apache.poi.ss.usermodel.Row matrix = row(sheet, 7);
matrix.createCell(1).setCellValue("始发地");
matrix.createCell(2).setCellValue("目的地");
matrix.createCell(9).setCellValue("币种");
matrix.createCell(10).setCellValue("燃油附加费率");
matrix.createCell(11).setCellValue("税率");
row(sheet, 9);
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject mapping = new JSONObject(true);
mapping.put("sheet", "中越集拼报价");
JSONArray regions = new JSONArray();
JSONObject region = new JSONObject(true);
region.put("headerRow", 8);
region.put("dataStartRow", 10);
JSONObject columns = new JSONObject(true);
columns.put("origin", "B");
columns.put("destination", "C");
region.put("columns", columns);
regions.add(region);
mapping.put("routeRegions", regions);
JSONObject facts = new JSONObject(true);
facts.put("route_rows", QuoteTemplateExcelSupport.parseFeeRows(
"[{\"origin\":\"广州\",\"destination\":\"河内\",\"fuelSurchargeRate\":\"8\",\"rate\":\"0.09\",\"rateFlag\":\"1\"}]"));
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled.getSheetAt(0);
assertEquals("广州", read(sheet, 9, 1));
assertEquals("河内", read(sheet, 9, 2));
assertEquals("8%", read(sheet, 9, 10));
assertEquals("9%", read(sheet, 9, 11));
assertEquals("燃油附加费率", read(sheet, 7, 10));
assertEquals("税率", read(sheet, 7, 11));
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
assertEquals("9%", QuoteTemplateExcelSupport.formatLandTaxRateDisplay("0.09"));
assertEquals("8%", QuoteTemplateExcelSupport.formatLandPercentDisplay("8.0"));
assertEquals("含税", QuoteTemplateExcelSupport.formatLandTaxIncludedDisplay("1"));
}
}
@@ -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<>();
@@ -178,14 +187,44 @@ public class InquiryTmsCabinServiceTest {
InquiryTmsCabinService.fillLockQuoteExtras(req, quote, option);
assertEquals("2026-09-30T23:59:59+08:00", req.get("validUntil"));
// departureCharges 出站会 TMS 1001,只保留 feeItems。
org.junit.jupiter.api.Assertions.assertNull(req.get("departureCharges"));
@SuppressWarnings("unchecked")
java.util.List<Map<String, Object>> dep =
(java.util.List<Map<String, Object>>) req.get("departureCharges");
assertEquals("运输费", dep.get(0).get("chargeName"));
@SuppressWarnings("unchecked")
java.util.List<Map<String, Object>> fees =
(java.util.List<Map<String, Object>>) req.get("feeItems");
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(
@@ -259,40 +298,4 @@ public class InquiryTmsCabinServiceTest {
InquiryTmsCabinService.fillManualLock(req, quote, new HashMap<>(), new HashMap<>(), false);
org.junit.jupiter.api.Assertions.assertNull(req.get("quoteId"));
}
@Test
public void validateManualLockOutboundReportsMissingFields() {
Map<String, Object> empty = new HashMap<>();
String why = InquiryTmsCabinService.validateManualLockOutbound(empty);
org.junit.jupiter.api.Assertions.assertTrue(why.contains("optionNo"));
org.junit.jupiter.api.Assertions.assertTrue(why.contains("airline"));
org.junit.jupiter.api.Assertions.assertTrue(why.contains("chargeableWeightKg"));
Map<String, Object> ok = new HashMap<>();
ok.put("optionNo", "AIR-OPT01");
ok.put("airline", "CZ");
ok.put("chargeableWeightKg", "100");
ok.put("flightNo", "CZ327");
ok.put("operatorName", "航线");
ok.put("validUntil", "2026-10-01T23:59:59+08:00");
assertEquals("", InquiryTmsCabinService.validateManualLockOutbound(ok));
}
@Test
public void airlineCodeOrBlankRejectsChineseName() {
assertEquals("CZ", InquiryTmsCabinService.airlineCodeOrBlank("CZ"));
assertEquals("", InquiryTmsCabinService.airlineCodeOrBlank("南航"));
assertEquals("", InquiryTmsCabinService.airlineCodeOrBlank(""));
}
@Test
public void realTmsQuoteIdSkipsManualAndKeepsOlderTms() {
org.jeecg.modules.inquiry.entity.InquiryQuote manual = new org.jeecg.modules.inquiry.entity.InquiryQuote();
manual.setPayloadJson("{\"source\":\"PRODUCT\",\"source_label\":\"航线报价\",\"total\":\"200\"}");
org.jeecg.modules.inquiry.entity.InquiryQuote tms = new org.jeecg.modules.inquiry.entity.InquiryQuote();
tms.setPayloadJson("{\"source\":\"TMS\",\"quoteId\":\"AIR-REAL-1\",\"airOptions\":[{\"optionNo\":\"AIR-OPT01\"}]}");
String id = InquiryTmsCabinService.realTmsQuoteId(
java.util.Arrays.asList(manual, tms), "WO202610030009");
assertEquals("AIR-REAL-1", id);
}
}
@@ -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(
@@ -184,42 +184,6 @@ public class TmsQuoteCurrencyTest {
assertTrue(quote.get("fee_lines").contains("2800"));
}
/**
* 陆运 TMS:fuelSurchargeRate=燃油附加费率;rate=税率;rateFlag=是否含税。
* 禁止用 fxRates.rate(汇率)冒充税率。
*/
@Test
public void toAgentQuoteProjectsLandFuelAndTaxRate() {
Map<String, Object> seg = new HashMap<>();
seg.put("segmentSequence", 1);
seg.put("origin", "广州市");
seg.put("destination", "深圳市");
seg.put("costPrice", "110");
seg.put("currency", "CNY");
seg.put("fuelSurchargeRate", 8);
seg.put("rate", 0.09);
seg.put("rateFlag", 1);
Map<String, Object> data = new HashMap<>();
data.put("quoteFound", true);
data.put("currency", "CNY");
data.put("landSegments", Collections.singletonList(seg));
Map<String, Object> tms = new HashMap<>();
tms.put("code", 1);
tms.put("tmsData", data);
Map<String, String> quote = InquiryTmsQueryService.toAgentQuote(tms);
assertTrue(quote.get("route_rows").contains("\"fuel_surcharge_rate\":\"8%\""));
assertTrue(quote.get("route_rows").contains("\"tax_rate\":\"9%\""));
assertTrue(quote.get("route_rows").contains("\"tax_included\":\"含税\""));
assertTrue(quote.get("route_rows").contains("\"rateFlag\":\"1\""));
assertFalse(quote.get("route_rows").contains("fxRates"));
assertEquals("8%", InquiryTmsQueryService.formatLandPercentRate("8"));
assertEquals("9%", InquiryTmsQueryService.formatLandTaxRate("0.09"));
assertEquals("10%", InquiryTmsQueryService.formatLandTaxRate("10"));
assertEquals("含税", InquiryTmsQueryService.formatLandTaxIncluded("1"));
assertEquals("不含税", InquiryTmsQueryService.formatLandTaxIncluded("0"));
assertEquals("", InquiryTmsQueryService.formatLandPercentRate(null));
}
@Test
public void toAgentQuoteKeepsSeaBucketTotals() {
Map<String, Object> pol = new HashMap<>();
@@ -568,22 +568,13 @@ public class LoginController {
}
/**
* 切换菜单表为vue3的表。
* <p>
* 询价新库若已无 {@code sys_permission_v3},服务层会拒绝切换并返回错误,避免把现用菜单表 rename 掉。
* </p>
* 切换菜单表为vue3的表
*/
@RequiresRoles({"admin"})
@GetMapping(value = "/switchVue3Menu")
public Result<String> switchVue3Menu(HttpServletResponse response) {
Result<String> res = new Result<String>();
try {
sysPermissionService.switchVue3Menu();
res.success("切换成功");
} catch (Exception e) {
log.error("switchVue3Menu rejected or failed: {}", e.getMessage(), e);
res.error500(e.getMessage());
}
sysPermissionService.switchVue3Menu();
return res;
}
@@ -49,18 +49,6 @@ public interface SysPermissionMapper extends BaseMapper<SysPermission> {
public void backupVue2Menu();
@Update("alter table sys_permission_v3 rename to sys_permission")
public void changeVue3Menu();
/**
* 是否存在 Jeecg 官方 Vue3 菜单备份表 {@code sys_permission_v3}。
* <p>
* 询价新库 {@code inquiry_robot} 的菜单种子本身已是 Vue3 形态,通常没有 v3 表;
* 若此时仍执行 rename 切换,会把正在使用的 {@code sys_permission} 改名后找不到替代表,侧栏全挂。
* </p>
*
* @return 存在则 &gt; 0
*/
@Select("SELECT COUNT(1) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'sys_permission_v3'")
Long countVue3MenuTable();
/**
* 获取模糊匹配规则的数据权限URL
@@ -55,22 +55,8 @@ public class SysPermissionServiceImpl extends ServiceImpl<SysPermissionMapper, S
@Resource
private SysDepartRolePermissionMapper sysDepartRolePermissionMapper;
/**
* 将菜单表从 Vue2 备份切换为 Vue3 表(Jeecg 官方升级路径)。
* <p>
* 副作用:{@code sys_permission} rename 为 {@code sys_permission_v2},再把 {@code sys_permission_v3}
* rename 为 {@code sys_permission}。线程:请求线程,需 admin 角色。
* </p>
* <p>
* 为何加护栏:询价测服库菜单已是 Vue3,且没有 {@code sys_permission_v3};盲目切换会弄挂后台菜单。
* </p>
*/
@Override
public void switchVue3Menu() {
Long vue3TableCnt = sysPermissionMapper.countVue3MenuTable();
if (vue3TableCnt == null || vue3TableCnt.longValue() <= 0L) {
throw new JeecgBootException("当前库没有 sys_permission_v3,菜单已是 Vue3 或不可自动切换,已拒绝执行");
}
sysPermissionMapper.backupVue2Menu();
sysPermissionMapper.changeVue3Menu();
}
@@ -2,6 +2,7 @@ import type { AppRouteRecordRaw, Menu } from '/@/router/types';
import { defineStore } from 'pinia';
import { store } from '/@/store';
import { useI18n } from '/@/hooks/web/useI18n';
import { useUserStore } from './user';
import { useAppStoreWithOut } from './app';
import { toRaw } from 'vue';
@@ -17,9 +18,10 @@ import { ERROR_LOG_ROUTE, PAGE_NOT_FOUND_ROUTE } from '/@/router/routes/basic';
import { filter } from '/@/utils/helper/treeHelper';
import { getMenuList } from '/@/api/sys/menu';
import { getMenuList,switchVue3Menu } from '/@/api/sys/menu';
import { getPermCode } from '/@/api/sys/user';
import { useMessage } from '/@/hooks/web/useMessage';
import { PageEnum } from '/@/enums/pageEnum';
/**
@@ -244,6 +246,7 @@ export const usePermissionStore = defineStore({
this.setAuthData(systemPermission);
},
async buildRoutesAction(): Promise<AppRouteRecordRaw[]> {
const { t } = useI18n();
const userStore = useUserStore();
const appStore = useAppStoreWithOut();
@@ -319,18 +322,54 @@ export const usePermissionStore = defineStore({
// 后台菜单构建
case PermissionModeEnum.BACK:
const { createMessage, createWarningModal } = useMessage();
// 菜单加载提示
// createMessage.loading({
// content: t('sys.app.menuLoading'),
// duration: 1,
// });
// 从后台获取权限码,
// 这个函数可能只需要执行一次,并且实际的项目可以在正确的时间被放置
let routeList: AppRouteRecordRaw[] = [];
try {
this.changePermissionCode();
routeList = (await getMenuList()) as AppRouteRecordRaw[];
// Jeecg 原逻辑:顶层没有 layouts/default/index 且图标无冒号 → 弹窗并调用 /sys/switchVue3Menu
// (把 sys_permission rename 成 v2,再把 sys_permission_v3 rename 成正式表)。
// 询价库 inquiry_robot 菜单种子已是 Vue3,且测服没有 sys_permission_v3;点确认会弄挂侧栏。
// 因此禁用自动切表弹窗;后面仍会 ensureInquiryHandwrittenRoutes 补齐询价页。
// update-begin----author:ytd---date:20261007------for: 禁用危险的 Vue2→Vue3 菜单切表提示 ---
// update-end----author:ytd---date:20261007------for: 禁用危险的 Vue2→Vue3 菜单切表提示 ---
// update-begin----author:sunjianlei---date:20220315------for: 判断是否是 vue3 版本的菜单 ---
let hasIndex: boolean = false;
let hasIcon: boolean = false;
for (let menuItem of routeList) {
// 条件1:判断组件是否是 layouts/default/index
if (!hasIndex) {
hasIndex = menuItem.component === 'layouts/default/index';
}
// 条件2:判断图标是否带有 冒号
if (!hasIcon) {
hasIcon = !!menuItem.meta?.icon?.includes(':');
}
// 满足任何一个条件都直接跳出循环
if (hasIcon || hasIndex) {
break;
}
}
// 两个条件都不满足,就弹出提示框
if (!hasIcon && !hasIndex) {
// 延迟1.5秒之后再出现提示,否则提示框出不来
setTimeout(
() =>
createWarningModal({
title: '检测提示',
content:
'当前菜单表是 <b>Vue2版本</b>,导致菜单加载异常!<br>点击确认,切换到Vue3版菜单!',
onOk:function () {
switchVue3Menu();
location.reload();
}
}),
100
);
}
// update-end----author:sunjianlei---date:20220315------for: 判断是否是 vue3 版本的菜单 ---
} catch (error) {
console.error(error);
// 菜单接口失败时仍补挂询价手写页,避免侧栏点进去全是 404
@@ -325,7 +325,6 @@ function removeAccount(row: AccountRow) {
<th>账号</th>
<th>姓名</th>
<th>绑定角色</th>
<th>数据范围</th>
<th>手机号</th>
<th>最后登录</th>
<th>状态</th>
@@ -334,10 +333,10 @@ function removeAccount(row: AccountRow) {
</thead>
<tbody>
<tr v-if="loading">
<td colspan="8" style="text-align: center; color: #94a3b8">加载中…</td>
<td colspan="7" style="text-align: center; color: #94a3b8">加载中…</td>
</tr>
<tr v-else-if="!rows.length">
<td colspan="8" style="text-align: center; color: #94a3b8">暂无账号</td>
<td colspan="7" style="text-align: center; color: #94a3b8">暂无账号</td>
</tr>
<tr v-for="row in rows" :key="row.id">
<td class="account-username">{{ row.username }}</td>
@@ -345,20 +344,6 @@ function removeAccount(row: AccountRow) {
<td>
<span class="tag" :class="row.roleTag">{{ row.roleLabel }}</span>
</td>
<td>
<!-- 与编辑弹窗「数据范围」同源:dashboardSelfOnly -->
<span
class="tag"
:class="row.dashboardSelfOnly ? 'tag-amber' : 'tag-teal'"
:title="
row.dashboardSelfOnly
? '分析看板仅统计本账号企微归属的工单'
: '分析看板可看权限内全部数据'
"
>
{{ row.dashboardSelfOnly ? "只看自己数据" : "全部数据" }}
</span>
</td>
<td>{{ row.phoneMasked }}</td>
<td>{{ row.lastLogin }}</td>
<td>
@@ -666,9 +666,11 @@ onUnmounted(() => document.removeEventListener("click", onDocClick));
}
.dash-route-menu {
/* 盖过图表区;宽度在 dashboard-prototype.css 里写成 360px,避免按按钮宽度收缩 */
position: absolute;
right: 0;
top: calc(100% + 8px);
z-index: 120;
}
.dash-cost-footnote {
@@ -2,9 +2,6 @@
/**
* 询价后台登录页:视觉 1:1 对齐 admin/LoginView(居中白卡片 + 品牌区,无验证码)。
* 鉴权走 Jeecg loginApi;主账 inquiry 配置关闭图形验证码(jeecg.loginCaptchaEnabled=false)。
*
* 禁止写死 admin/123456:退出后再进登录页会误显示测试账号。
* 仅记住上次成功登录的账号(不存密码),方便换人登录时改账号。
*/
import { reactive, ref, toRaw } from 'vue';
import { ConfigProvider, message } from 'ant-design-vue';
@@ -13,29 +10,11 @@
import '/@/views/inquiry/styles/admin-prototype.css';
import '/@/views/inquiry/styles/admin-fidelity.css';
const LAST_USERNAME_KEY = 'inquiry_admin_last_username';
function readLastUsername(): string {
try {
return String(localStorage.getItem(LAST_USERNAME_KEY) || '').trim();
} catch {
return '';
}
}
function rememberUsername(username: string) {
try {
localStorage.setItem(LAST_USERNAME_KEY, username);
} catch {
/* 隐私模式等写失败时忽略 */
}
}
const userStore = useUserStore();
const loading = ref(false);
const form = reactive({
username: readLastUsername(),
password: '',
username: 'admin',
password: '123456',
});
const themeToken = {
@@ -57,10 +36,9 @@
}
loading.value = true;
try {
const username = form.username.trim();
const { userInfo } = await userStore.login(
toRaw({
username,
username: form.username.trim(),
password: form.password,
// 主账已关验证码;仍传空字段以兼容 LoginParams 类型
captcha: '',
@@ -70,9 +48,6 @@
})
);
if (userInfo) {
rememberUsername(username);
// 成功后清空密码,避免浏览器后退/再进登录页时残留明文
form.password = '';
message.success(`登录成功,欢迎 ${userInfo.realname || userInfo.username}`);
}
} catch (e: any) {
@@ -342,7 +342,7 @@ export const mockReplyCategories: ReplyCategory[] = [
id: "brv10000000000000000000000000041",
replyCode: "lost.reason_ack",
trigger: "销售回复未成交原因",
text: "已将未成交原因“{{lost_reason}}”记录在后台,工单 {{ticket_no}}已关闭,智能体不再跟进该工单,请悉知。",
text: "已记下未成交原因:{{lost_reason}}。",
enabled: true,
}
],
@@ -23,16 +23,13 @@ body {
grid-template-columns: 220px minmax(0, 1fr) !important;
width: 100%;
max-width: 100%;
/* 锁视口:避免 html overflow:hidden 裁切后整页既无文档滚、内容区又不内滚 */
height: 100vh !important;
max-height: 100vh !important;
min-height: 0 !important;
overflow: hidden !important;
overflow-x: hidden;
}
.main-stage {
background: #f7f8fb !important;
min-width: 0 !important;
overflow-x: hidden;
/* 主区锁视口高度:列表页表格区内滚;看板等非列表页在 .admin-content 内滚 */
height: 100vh !important;
max-height: 100vh !important;
@@ -47,15 +44,15 @@ body {
gap: 12px !important;
min-width: 0 !important;
/*
* flex-basis:0:先按剩余视口占高,再在区内滚动。
* 若用 1 1 auto,内容区会先被看板撑满,再被 .main-stage 裁掉 → 右侧无滚动条、滚轮无效。
* 主区被锁成一屏。flex-basis 用 auto 时,格子会按内容把内容区撑高,
* 再被主区裁掉,页面上就没有滚动条,鼠标也滚不动。
* basis 取 0、高度先归零,再由 flex 撑满顶栏下方,多出来的内容在这里滚。
*/
flex: 1 1 0 !important;
min-height: 0 !important;
flex: 1 1 0% !important;
height: 0 !important;
min-height: 0 !important;
overflow-x: hidden !important;
overflow-y: auto !important;
-webkit-overflow-scrolling: touch;
}
/*
@@ -136,6 +133,7 @@ body {
padding: 0 14px !important;
gap: 12px !important;
min-width: 0 !important;
flex-shrink: 0 !important;
/* 允许用户下拉露出;业务 Teleport 区单独 overflow:hidden 防幽灵点击层 */
overflow: visible !important;
}
@@ -1079,9 +1077,7 @@ h6 {
.admin-content:has(.ref-surface--metrics) {
padding: 14px 14px 36px !important;
gap: 10px !important;
/* 分析看板:明确在内容区竖滚,避免被列表页 overflow:hidden 规则误伤 */
overflow-x: hidden !important;
overflow-y: auto !important;
align-content: start !important;
}
/* ---- 话术 ---- */
@@ -1535,3 +1531,37 @@ textarea.field.field-tall {
background: #f8fbff !important;
}
/*
* 分析看板比一屏高。整页被 html / 主区锁死时,内容区即使设了 overflow,
* 格子也会跟着内容长高,再被主区裁掉,滚轮没有可滚的对象。
* 看板单独放开文档滚动;列表页仍锁在一屏里,表格自己滚。
*/
html:has(.ref-surface--metrics),
body:has(.ref-surface--metrics) {
height: auto !important;
min-height: 100% !important;
overflow-x: hidden !important;
overflow-y: auto !important;
}
#app:has(.ref-surface--metrics),
.admin-shell:has(.ref-surface--metrics) {
height: auto !important;
min-height: 100vh !important;
overflow: visible !important;
}
.main-stage:has(.ref-surface--metrics) {
height: auto !important;
max-height: none !important;
min-height: 100vh !important;
overflow: visible !important;
}
.admin-content:has(.ref-surface--metrics) {
height: auto !important;
max-height: none !important;
flex: none !important;
overflow: visible !important;
}
@@ -776,9 +776,13 @@
color: #155eef;
}
.ref-route-filter__menu {
position: fixed;
/* 必须用固定宽度。百分比会按「线路筛选」按钮的宽度算,面板只剩一字宽,中文被挤成竖排,全选也被裁掉。 */
position: absolute;
right: 0;
top: calc(100% + 8px);
z-index: 120;
width: min(360px, calc(100% - 24px));
width: 360px;
max-width: calc(100vw - 48px);
border: 1px solid #dfe5ee;
border-radius: 12px;
background: #fff;
@@ -797,12 +801,14 @@
color: #0f172a;
font-size: 11px;
font-weight: 900;
white-space: nowrap;
}
.ref-route-filter__menu-sub {
margin-top: 2px;
color: #64748b;
font-size: 9px;
font-weight: 700;
white-space: nowrap;
}
.ref-route-filter__actions.tabs-pill {
flex-shrink: 0;
@@ -861,6 +867,7 @@
}
.ref-route-chip__text {
line-height: 1.2;
white-space: nowrap;
}
.month-range-picker {
position: relative;
@@ -253,88 +253,9 @@ function parseQuoteFees(q: any): TicketQuoteFee[] {
pushRows(asFeeArray(payload[key] || payload[bucket]));
}
}
// 陆运 TMS:燃油附加费率 / 税率挂在线路段上,不是金额费目行。
for (const extra of parseLandRateFees(payload)) {
if (!out.some((x) => x.name === extra.name && x.amount === extra.amount)) {
out.push(extra);
}
}
return out;
}
/**
* 陆运线路:fuelSurchargeRate=燃油附加费率;rate=税率;rateFlag=是否含税(1是0否)。
* 只读 payload,不写库。海运/空运没有这些键则不出行。
*/
function parseLandRateFees(payload: Record<string, any>): TicketQuoteFee[] {
const rows = asFeeArray(payload.route_rows || payload.landSegments || payload.land_segments);
const out: TicketQuoteFee[] = [];
const seen = new Set<string>();
for (const row of rows) {
if (!row || typeof row !== "object") continue;
const fuel = formatLandPercent(row.fuel_surcharge_rate ?? row.fuelSurchargeRate);
const tax = formatLandTaxRate(row.tax_rate ?? row.taxRate ?? row.rate);
const included = formatLandTaxIncluded(
row.tax_included ?? row.rateFlag ?? row.rate_flag
);
if (fuel) {
const key = `fuel:${fuel}`;
if (!seen.has(key)) {
seen.add(key);
out.push({ name: "燃油附加费率", amount: fuel });
}
}
if (tax) {
const key = `tax:${tax}`;
if (!seen.has(key)) {
seen.add(key);
out.push({ name: "税率", amount: tax });
}
}
if (included) {
const key = `inc:${included}`;
if (!seen.has(key)) {
seen.add(key);
out.push({ name: "是否含税", amount: included });
}
}
}
return out;
}
/** 燃油附加费率:数字带 %。 */
function formatLandPercent(raw: unknown): string {
const s = String(raw ?? "").trim();
if (!s || s === "-" || s.toLowerCase() === "null") return "";
if (s.includes("%")) return s;
if (/^-?\d+(\.\d+)?$/.test(s)) {
return `${s.replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, "")}%`;
}
return s;
}
/** 税率 rate:0.09→9%;大于 1 按百分点。 */
function formatLandTaxRate(raw: unknown): string {
const s = String(raw ?? "").trim();
if (!s || s === "-" || s.toLowerCase() === "null") return "";
if (s.includes("%")) return s;
if (!/^-?\d+(\.\d+)?$/.test(s)) return s;
const n = Number(s);
if (!Number.isFinite(n)) return s;
const abs = Math.abs(n);
const pct = abs > 0 && abs <= 1 ? n * 100 : n;
return `${String(pct).replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, "")}%`;
}
/** rateFlag:1=含税,0=不含税。 */
function formatLandTaxIncluded(raw: unknown): string {
const s = String(raw ?? "").trim();
if (!s || s === "-" || s.toLowerCase() === "null") return "";
if (s === "1" || s.toLowerCase() === "true" || s === "是" || s === "含税") return "含税";
if (s === "0" || s.toLowerCase() === "false" || s === "否" || s === "不含税") return "不含税";
return s;
}
/**
* 主账 inq_ticket_status_log.action 是内部码,禁止当备注展示。
* transition 与「已报价」等 toStatus 重复,无用户备注时不再跟一段英文。