Compare commits

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 15:23:06 +08:00
41 changed files with 632 additions and 1728 deletions
+24 -21
View File
@@ -4,7 +4,7 @@
本文件职责:给附件上传成功、选线路成功页一段共用脚本。 本文件职责:给附件上传成功、选线路成功页一段共用脚本。
手机端只调一次 WeixinJSBridge.closeWindow,关掉当前 H5 后留在询价对话里。 手机端只调一次 WeixinJSBridge.closeWindow,关掉当前 H5 后留在询价对话里。
再调 ww/wx/window.close 会把询价小助手整个退出,回到企业微信首页。 再调 ww/wx/window.close 会把询价小助手整个退出,回到企业微信首页。
电脑企微没有这套桥,加载 wecom-jssdk、用 /inquiry-form/jsapi-signature 注册后再关一次。 电脑企微即使注入了 WeixinJSBridge,这一下也关不掉当前页,必须按电脑走 JS-SDK。
失败、过期页不要挂这段。 失败、过期页不要挂这段。
副作用:只产出 HTML 片段;关窗签名走浏览器短请求,不占智能体请求线程做慢活。 副作用:只产出 HTML 片段;关窗签名走浏览器短请求,不占智能体请求线程做慢活。
""" """
@@ -24,7 +24,8 @@ def wecom_auto_close_script() -> str:
成功页脚:加载企微 JS-SDK,register 后关掉当前 H5 窗口。 成功页脚:加载企微 JS-SDK,register 后关掉当前 H5 窗口。
线程:浏览器端执行。签名接口与复制页共用,ticket 已缓存在 Redis。 线程:浏览器端执行。签名接口与复制页共用,ticket 已缓存在 Redis。
为何这样写:上一版只调 WeixinJSBridge,电脑端企微关不掉,会停在成功页。 为何这样写:电脑企微也会带上 WeixinJSBridge,但关不掉当前页。
若一发现桥就只走手机关闭,成功页会停在「正在返回企微」。手机仍只关一次。
""" """
settings = get_settings() settings = get_settings()
corp = escape((settings.wecom_corp_id or "").strip(), quote=True) corp = escape((settings.wecom_corp_id or "").strip(), quote=True)
@@ -49,6 +50,10 @@ def wecom_auto_close_script() -> str:
var closed = false; var closed = false;
function isPhone() {{
return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent || "");
}}
function closeOnce(fn) {{ function closeOnce(fn) {{
if (closed) return; if (closed) return;
closed = true; closed = true;
@@ -60,7 +65,8 @@ def wecom_auto_close_script() -> str:
closeOnce(function () {{ WeixinJSBridge.call("closeWindow"); }}); closeOnce(function () {{ WeixinJSBridge.call("closeWindow"); }});
}} }}
// 电脑企微没有 WeixinJSBridge,注册 JS-SDK 后只关一次。 // 电脑企微即使有 WeixinJSBridge,也关不掉当前页。注册 JS-SDK 后再关一次。
// 不要调 window.close:手机会把询价小助手整个退出。
function closeDesktop() {{ function closeDesktop() {{
closeOnce(function () {{ closeOnce(function () {{
if (window.ww && typeof ww.closeWindow === "function") ww.closeWindow(); if (window.ww && typeof ww.closeWindow === "function") ww.closeWindow();
@@ -69,15 +75,16 @@ def wecom_auto_close_script() -> str:
}} }}
function boot() {{ function boot() {{
if (typeof WeixinJSBridge !== "undefined") {{ if (isPhone()) {{
setTimeout(closeMobile, {delay}); if (typeof WeixinJSBridge !== "undefined") {{
setTimeout(closeMobile, {delay});
return;
}}
document.addEventListener("WeixinJSBridgeReady", function () {{
setTimeout(closeMobile, {delay});
}}, false);
return; return;
}} }}
var sawBridge = false;
document.addEventListener("WeixinJSBridgeReady", function () {{
sawBridge = true;
setTimeout(closeMobile, {delay});
}}, false);
var ready = Promise.resolve(); var ready = Promise.resolve();
if (window.ww && ww.register && corpId) {{ if (window.ww && ww.register && corpId) {{
ready = ww.register({{ ready = ww.register({{
@@ -88,18 +95,14 @@ def wecom_auto_close_script() -> str:
getAgentConfigSignature: sign("agent") getAgentConfigSignature: sign("agent")
}}).catch(function () {{ return null; }}); }}).catch(function () {{ return null; }});
}} }}
ready.then(function () {{ var timed = Promise.race([
setTimeout(function () {{ ready,
if (sawBridge || typeof WeixinJSBridge !== "undefined") {{ new Promise(function (resolve) {{ setTimeout(resolve, 2500); }})
if (!closed) closeMobile(); ]);
return; timed.then(function () {{
}} setTimeout(closeDesktop, {delay});
closeDesktop();
}}, {delay});
}}).catch(function () {{ }}).catch(function () {{
setTimeout(function () {{ setTimeout(closeDesktop, {delay});
if (!closed && typeof WeixinJSBridge === "undefined") closeDesktop();
}}, {delay});
}}); }});
}} }}
@@ -2,7 +2,8 @@
意图:空运协同群入站(BOT @ 之后的文字或附件)。 意图:空运协同群入站(BOT @ 之后的文字或附件)。
本文件职责:激活/切单后交给 air_group_ops。海运应用群不进这里。 本文件职责:激活/切单后交给 air_group_ops。海运应用群不进这里。
禁止:存档文字当业务;锁舱用当前工单去猜;套 sea_group_ops 建群。 多段单已在本群激活后,没带工单号的后续消息跟这张多段单,不再要求重发工单号。
禁止:存档文字当业务;纯空运锁舱用当前工单去猜;套 sea_group_ops 建群。
""" """
from __future__ import annotations from __future__ import annotations
@@ -56,6 +57,54 @@ from agent.routing.air_group_intent import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _follow_activated_multi(
engine,
*,
chat_id: str,
sender_id: str,
text: str,
injected_quote: Optional[dict[str, Any]],
named_wo: str = "",
) -> Optional[str]:
"""
多段单激活后,后续消息跟这张单,不必再带工单号。
句中写了工单号就认那张;没写就认本群已经绑上的多段单。
纯空运、陆运旧单返回 None,仍走空运自己的当前单。
副作用:可能发群消息、写入分段报价。在入站线程调用。
"""
from agent.policy.multi_group_ops import handle_multi_inbound, ticket_is_multi
from agent.policy.sea_group_ops import ticket_no
ticket = None
named = (named_wo or "").strip()
if named:
getter = getattr(engine.ledger, "get_ticket", None)
raw = getter(work_order_no=named) if callable(getter) else None
if raw is not None and ticket_is_multi(raw):
ticket = raw
else:
bound = current_ticket(engine, chat_id)
if bound is not None and ticket_is_multi(bound):
ticket = bound
if ticket is None:
return None
logger.info(
"air_group 多段跟单 wo=%s chat=%s named=%s",
ticket_no(ticket),
chat_id,
"1" if named else "0",
)
return handle_multi_inbound(
flow=engine,
ticket=ticket,
chat_id=chat_id,
sender_id=sender_id,
text=text,
injected_quote=injected_quote,
)
def _attach_bot_client(engine, message: InboundMessage) -> None: def _attach_bot_client(engine, message: InboundMessage) -> None:
""" """
每次入站换一次 BOT 出站客户端。 每次入站换一次 BOT 出站客户端。
@@ -116,20 +165,18 @@ def handle_air_group(
return "duplicate_inbound" return "duplicate_inbound"
mark_air_text(chat_id=chat_id, sender_id=message.sender_id, text=text) mark_air_text(chat_id=chat_id, sender_id=message.sender_id, text=text)
wo = copy.extract_work_order_no(text) wo = copy.extract_work_order_no(text)
# 句中有多段工单号,或本群已经激活过多段单:跟这张单,不必每句再带工单号。
followed = _follow_activated_multi(
engine,
chat_id=chat_id,
sender_id=message.sender_id,
text=text,
injected_quote=injected_quote,
named_wo=wo,
)
if followed is not None:
return followed
if wo: if wo:
from agent.policy.multi_group_ops import handle_multi_inbound, ticket_is_multi
getter = getattr(engine.ledger, "get_ticket", None)
raw_ticket = getter(work_order_no=wo) if callable(getter) else None
if raw_ticket is not None and ticket_is_multi(raw_ticket):
return handle_multi_inbound(
flow=engine,
ticket=raw_ticket,
chat_id=chat_id,
sender_id=message.sender_id,
text=text,
injected_quote=injected_quote,
)
from agent.policy.handoff_human import try_commit_air_group_handoff from agent.policy.handoff_human import try_commit_air_group_handoff
handed = try_commit_air_group_handoff(engine, chat_id=chat_id, work_order_no=wo) handed = try_commit_air_group_handoff(engine, chat_id=chat_id, work_order_no=wo)
@@ -212,6 +259,17 @@ def _handle_file(engine, message: InboundMessage) -> str:
wo = copy.extract_work_order_no( wo = copy.extract_work_order_no(
" ".join([message.content or "", filename]) " ".join([message.content or "", filename])
) )
# 多段已激活的附件也跟当前单,不能再要求先发工单号。
followed = _follow_activated_multi(
engine,
chat_id=chat_id,
sender_id=message.sender_id,
text=" ".join(part for part in ((message.content or "").strip(), filename) if part),
injected_quote=None,
named_wo=wo,
)
if followed is not None:
return followed
if wo: if wo:
ticket, phase = activate_ticket( ticket, phase = activate_ticket(
flow=engine, chat_id=chat_id, work_order_no=wo, speak=False flow=engine, chat_id=chat_id, work_order_no=wo, speak=False
+11 -9
View File
@@ -103,7 +103,7 @@ def handle_group_inbound(
or "" or ""
).upper() ).upper()
if line == "MULTI": if line == "MULTI":
from agent.policy.multi_group_ops import handle_multi_inbound, sender_mode from agent.policy.multi_group_ops import bot_sender_when_air_multi, handle_multi_inbound, sender_mode
if not ( if not (
_sender_allowed(ticket, message.sender_id) _sender_allowed(ticket, message.sender_id)
@@ -111,14 +111,16 @@ def handle_group_inbound(
): ):
logger.info("group_inbound 多段忽略非成员 sender=%s", message.sender_id) logger.info("group_inbound 多段忽略非成员 sender=%s", message.sender_id)
return "group_ignore" return "group_ignore"
return handle_multi_inbound( # 会话存档听到的报价也走这里。含空运时群是机器人建的,必须改走桥才能发得出去。
flow=engine, with bot_sender_when_air_multi(ticket):
ticket=ticket, return handle_multi_inbound(
chat_id=chat_id, flow=engine,
sender_id=message.sender_id, ticket=ticket,
text=message.content or "", chat_id=chat_id,
injected_quote=injected_quote, sender_id=message.sender_id,
) text=message.content or "",
injected_quote=injected_quote,
)
ticket = hydrate_ticket(engine, ticket) ticket = hydrate_ticket(engine, ticket)
if not _sender_allowed(ticket, message.sender_id): if not _sender_allowed(ticket, message.sender_id):
logger.info("group_inbound 忽略非成员 sender=%s chat=%s", message.sender_id, chat_id) logger.info("group_inbound 忽略非成员 sender=%s chat=%s", message.sender_id, chat_id)
+4 -21
View File
@@ -40,18 +40,8 @@ class HttpLedger:
def _post(self, path: str, body: dict[str, Any], *, idem: str = "") -> dict[str, Any]: 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) resp = self._client.request_json("POST", path, json_body=body, idempotency_key=idem)
if not resp.ok: if not resp.ok:
# 500 时 Jeecg 仍可能带回 message(如六态非法);勿只留 http_500, logger.warning("http_ledger fail path=%s status=%s err=%s", path, resp.status_code, resp.error)
# 否则成交补「已报价」重试认不出「询价中」(WO023)。 return {"ok": False, "error": resp.error or f"http_{resp.status_code}"}
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}
return _unwrap(resp.data) return _unwrap(resp.data)
def find_similar_open( def find_similar_open(
@@ -176,12 +166,8 @@ class HttpLedger:
""" """
no = (work_order_no or "").strip() no = (work_order_no or "").strip()
src = dict(quote or {}) src = dict(quote or {})
if not no: if not no or not src:
return {"ok": False, "error": "work_order_blank"} return {"ok": True, "quote_version": 1, "status": to_status or "已报价"}
# 空报价不打主账(避免抹掉已有查价),但禁止伪装成「已写入已报价」——
# 否则会话以为已报价、主账仍停询价中,成交三态会失败(WO022)。
if not src:
return {"ok": False, "error": "quote_blank", "skipped": True}
out = self._post( out = self._post(
"/inquiry/agent/ticket/replaceTmsQuote", "/inquiry/agent/ticket/replaceTmsQuote",
{"workOrderNo": no, "quote": src, "toStatus": to_status}, {"workOrderNo": no, "quote": src, "toStatus": to_status},
@@ -437,7 +423,6 @@ class HttpLedger:
operator_name: str = "", operator_name: str = "",
instruction_text: str = "", instruction_text: str = "",
airline: str = "", airline: str = "",
flight_no: str = "",
) -> dict[str, Any]: ) -> dict[str, Any]:
body: dict[str, Any] = { body: dict[str, Any] = {
"workOrderNo": work_order_no, "workOrderNo": work_order_no,
@@ -448,8 +433,6 @@ class HttpLedger:
} }
if (airline or "").strip(): if (airline or "").strip():
body["airline"] = airline.strip().upper() body["airline"] = airline.strip().upper()
if (flight_no or "").strip():
body["flightNo"] = flight_no.strip().upper()
return self._post( return self._post(
"/inquiry/agent/tms/lockCabin", "/inquiry/agent/tms/lockCabin",
body, body,
+2 -28
View File
@@ -827,14 +827,9 @@ class MemoryLedger:
operator_name: str = "", operator_name: str = "",
instruction_text: str = "", instruction_text: str = "",
airline: str = "", airline: str = "",
flight_no: str = "",
) -> dict[str, Any]: ) -> dict[str, Any]:
"""单测可注入 _cabin_lock_result;默认成功。 """单测可注入 _cabin_lock_result;默认成功。"""
_ = sender_id, operator_name, instruction_text, airline
纯人工且视图无 TMS 舱位时,缺 option/航司/航班 → lock_incomplete,
对齐主账「现拉仍缺才回」的口径(内存账不会真查 TMS)。
"""
_ = sender_id, operator_name, instruction_text
forced = getattr(self, "_cabin_lock_result", None) forced = getattr(self, "_cabin_lock_result", None)
if isinstance(forced, dict): if isinstance(forced, dict):
return dict(forced) return dict(forced)
@@ -850,27 +845,6 @@ class MemoryLedger:
quote_id = str(quote.get("quoteId") or "").strip() quote_id = str(quote.get("quoteId") or "").strip()
if quote_id.upper().startswith("QT-WO"): if quote_id.upper().startswith("QT-WO"):
quote_id = "" 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 { return {
"ok": True, "ok": True,
"status": "LOCKED", "status": "LOCKED",
+27 -160
View File
@@ -477,8 +477,14 @@ def harvest_collab_labels(text: str) -> dict[str, str]:
「客户名称张三 包装方式编织袋 货值 10万」切成三项。 「客户名称张三 包装方式编织袋 货值 10万」切成三项。
只说「纸箱」没有标签则不收。值取到下一个标签之前。 只说「纸箱」没有标签则不收。值取到下一个标签之前。
「(如:随时可提)」整行丢掉,避免补问示例被当成已填。
""" """
return _harvest_labels(text, _COLLAB_LABELS) kept = "\n".join(
line
for line in (text or "").splitlines()
if not _CLARIFY_EXAMPLE_LINE.match(line.strip())
)
return _harvest_labels(kept, _COLLAB_LABELS)
def harvest_inquiry_optional_labels(text: str) -> dict[str, str]: def harvest_inquiry_optional_labels(text: str) -> dict[str, str]:
@@ -508,7 +514,11 @@ def clip_inquiry_value(raw: str) -> str:
if hit and hit.start() > 0: if hit and hit.start() > 0:
text = text[: hit.start()] text = text[: hit.start()]
text = re.sub(r"(?:\n|\r)+\s*\d+[\.、.]\s*$", "", text) text = re.sub(r"(?:\n|\r)+\s*\d+[\.、.]\s*$", "", text)
return text.strip() text = text.strip()
# 复制补问清单时空着的下一行是「4.提货地址」。切到下一栏后只剩编号,不能当成货好时间。
if re.fullmatch(r"\d+[\.、.]", text):
return ""
return text
def clip_sheet_field_value(raw: str) -> str: def clip_sheet_field_value(raw: str) -> str:
@@ -549,9 +559,12 @@ def clip_sheet_field_value(raw: str) -> str:
if not parts: if not parts:
return "" return ""
# 格子里只剩下一个字段名(体积、时效要求)时,这一栏是空的,不能把字段名当成值。 # 格子里只剩下一个字段名(体积、时效要求)时,这一栏是空的,不能把字段名当成值。
# 只剩「4.」也是空的:那是下一行「4.提货地址」被切掉字段名后留下的编号。
token = parts[0].strip().strip("::") token = parts[0].strip().strip("::")
if token in _SHEET_CLEAN_KEYS or token in _TIGHTEN_LABELS: if token in _SHEET_CLEAN_KEYS or token in _TIGHTEN_LABELS:
return "" return ""
if re.fullmatch(r"\d+[\.、.]", token):
return ""
return parts[0].strip() return parts[0].strip()
@@ -757,30 +770,6 @@ _RE_MONEY = re.compile(
r"|(?:USD|CNY|RMB|美金|美元)\s*(\d+(?:\.\d+)?)", r"|(?:USD|CNY|RMB|美金|美元)\s*(\d+(?:\.\d+)?)",
re.I, 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( _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"(?P<name>[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费|[A-Za-z]{2,10})"
r"\s*(?:改为|改成|调整为|调整成)?" r"\s*(?:改为|改成|调整为|调整成)?"
@@ -891,8 +880,6 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
认不出价格返回空 dict,调用方回「没有识别到报价」。 认不出价格返回空 dict,调用方回「没有识别到报价」。
群里常见「码头操作费100CNY / 报关费改为300CNY」,不能只把第一笔数当成海运费。 群里常见「码头操作费100CNY / 报关费改为300CNY」,不能只把第一笔数当成海运费。
「空运费 单价…金额…」必须保留空运费,金额用「金额」后面的数,不用单价。 「空运费 单价…金额…」必须保留空运费,金额用「金额」后面的数,不用单价。
陆运集拼:「2-500KG 单价CNY990,燃油附加费率1%,税率6%」走分档单价+费率,
禁止把公斤档下限「2」当成燃油附加费金额(WO021)。
""" """
if isinstance(injected, dict) and (injected.get("total") or injected.get("fee_rows")): if isinstance(injected, dict) and (injected.get("total") or injected.get("fee_rows")):
out = dict(injected) out = dict(injected)
@@ -905,22 +892,16 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
compact = stripped.replace(" ", "").replace(" ", "") compact = stripped.replace(" ", "").replace(" ", "")
if re.fullmatch(r"WO\d{12}", compact, re.I): if re.fullmatch(r"WO\d{12}", compact, re.I):
return {} return {}
band = _parse_weight_band_quote(raw) rows = _parse_fee_rows(raw)
if band:
return band
# 费率句若仍落到普通费用行,会把「燃油附加费」配上句首公斤数。先剥掉再抽。
scrubbed = _strip_rate_and_band_noise(raw)
rows = _parse_fee_rows(scrubbed) if scrubbed.strip() else []
if rows: if rows:
ccy = rows[0].get("currency") or _currency_of(raw) 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 = rows[0]["amount"]
total = (money_rows[0] if money_rows else rows[0])["amount"] if len({x.get("currency") for x in rows}) == 1:
if money_rows and len({x.get("currency") for x in money_rows}) == 1:
try: try:
total = str(sum(float(x["amount"]) for x in money_rows)) total = str(sum(float(x["amount"]) for x in rows))
except ValueError: except ValueError:
total = money_rows[0]["amount"] total = rows[0]["amount"]
out = { return {
"total": total, "total": total,
"currency": ccy, "currency": ccy,
"source_label": "产品报价", "source_label": "产品报价",
@@ -928,12 +909,9 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
"fee_rows": rows, "fee_rows": rows,
"fee_lines": list(rows), "fee_lines": list(rows),
} }
out.update(_extract_rate_fields(raw)) hit = _RE_MONEY.search(_ascii_amount(raw))
return out
hit = _RE_MONEY.search(_ascii_amount(scrubbed or raw))
if not hit: if not hit:
rates = _extract_rate_fields(raw) return {}
return rates if rates else {}
amount = _ascii_amount(hit.group(1) or hit.group(2) or "") amount = _ascii_amount(hit.group(1) or hit.group(2) or "")
if not amount: if not amount:
return {} return {}
@@ -941,13 +919,12 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
# 句里已经点了费用名(空运费)时,不能退回默认海运费。 # 句里已经点了费用名(空运费)时,不能退回默认海运费。
named = re.search( named = re.search(
r"([\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费)", 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 "海运费" fee_name = (named.group(1) if named else "") or "海运费"
if fee_name in {"费用", "燃油附加费"}: if fee_name in {"费用"}:
# 「燃油附加费率」里的「燃油附加费」不是费用行名;无其它费名时用运费。 fee_name = "海运费"
fee_name = "海运费" if fee_name == "费用" else "陆运费" return {
out = {
"total": amount, "total": amount,
"currency": ccy, "currency": ccy,
"source_label": "产品报价", "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_rows": [{"name": fee_name, "amount": amount, "currency": ccy}],
"fee_lines": [{"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
+16 -241
View File
@@ -3,8 +3,7 @@
本文件职责:群里副作用(主账 + BOT 回话)。海运应用群仍走 sea_group_ops。 本文件职责:群里副作用(主账 + BOT 回话)。海运应用群仍走 sea_group_ops。
禁止:自动建群/拉人/解散;拿当前工单去猜锁舱工单号。 禁止:自动建群/拉人/解散;拿当前工单去猜锁舱工单号。
锁舱按 TMS /air/cabin/lock 拼字段出站;没有 TMS 报价编号也可走 MANUAL。 锁舱按 TMS /air/cabin/lock 拼字段出站;没有 TMS 报价编号也要锁,用工单字段补 quoteId/optionNo。
禁止编造 optionNo;缺舱位编号/航司/计费重时先群里补问,禁止空壳打 TMS。
""" """
from __future__ import annotations from __future__ import annotations
@@ -777,46 +776,22 @@ def apply_air_deal(
if stopped: if stopped:
return stopped return stopped
from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason 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) 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 "" inline = harvest_inline_lost_reason(text) if outcome == "未成交" else ""
result = finish_deal_outcome( result = finish_deal_outcome(
ledger=flow.ledger, ledger=flow.ledger,
work_order_no=no, work_order_no=ticket_no(ticket),
outcome=outcome, outcome=outcome,
facts=facts_of_group(view), facts=facts_of_group(view),
quote=quote, quote=dict(view.get("quote") or {}),
business_line="AIR", business_line="AIR",
sales_name=str(view.get("sales_name") or ""), sales_name=str(view.get("sales_name") or ""),
lost_reason=inline, lost_reason=inline,
) )
if not result.get("ok"): if not result.get("ok"):
# 迁态失败且仍无报价时,同样用「没有报价」说明,避免旧笼统句。 send_group_text(group_client_of(flow), chat_id, "主账状态更新失败,成交结果未入账。")
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, "主账状态更新失败,成交结果未入账。"
)
return "ledger_fail" return "ledger_fail"
phase = str(result.get("phase") or "done") phase = str(result.get("phase") or "done")
ack_reason = inline if phase == "done" and outcome == "未成交" else "" ack_reason = inline if phase == "done" and outcome == "未成交" else ""
@@ -825,7 +800,7 @@ def apply_air_deal(
chat_id, chat_id,
copy.deal_ack( copy.deal_ack(
outcome, outcome,
no, ticket_no(ticket),
str(result.get("next_followup_at") or ""), str(result.get("next_followup_at") or ""),
lost_reason=ack_reason, lost_reason=ack_reason,
), ),
@@ -843,13 +818,6 @@ def apply_air_lock(
option_no: str = "", option_no: str = "",
airline: str = "", airline: str = "",
) -> str: ) -> str:
"""
空运群锁舱。
副作用:调主账 lockCabin(HMAC 出站 TMS);成功写协同 facts、推成交跟进。
舱位/航司/航班交给主账现拉 TMS 补全;仅计费重全空时群里补问。
主账仍缺则回 lock_incomplete,禁止空壳出站。
"""
if not is_airline(flow, sender_id): if not is_airline(flow, sender_id):
send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin()) send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin())
return "not_airline" return "not_airline"
@@ -857,21 +825,14 @@ def apply_air_lock(
# 不能只回「TMS接口异常,IT运维排查中」。 # 不能只回「TMS接口异常,IT运维排查中」。
no = ticket_no(ticket) no = ticket_no(ticket)
name = operator_name(flow, sender_id) name = operator_name(flow, sender_id)
view = ticket_view(ticket) view_quote = dict(ticket_view(ticket).get("quote") or {})
view_quote = dict(view.get("quote") or {})
air_quote = dict((view_quote.get("segment_quotes") or {}).get("AIR") or {}) air_quote = dict((view_quote.get("segment_quotes") or {}).get("AIR") or {})
quote = air_quote or view_quote quote = air_quote or view_quote
# 最新价常是人工覆盖:舱位/航司/航班要从本单 TMS 原价自动补,不能逼航线手打。 options = _air_options(quote)
tms_quote = copy.resolve_tms_quote(None, view) quote_id = _quote_id(quote)
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() picked = (option_no or "").strip()
if not picked and len(options) > 1: if not picked and len(options) > 1:
facts = dict(view.get("collab_facts") or {}) facts = dict(ticket_view(ticket).get("collab_facts") or {})
if str(facts.get(_PENDING_LOCK) or "") != "1": if str(facts.get(_PENDING_LOCK) or "") != "1":
flow.ledger.patch_collab_facts( flow.ledger.patch_collab_facts(
work_order_no=no, facts={_PENDING_LOCK: "1"}, sender_id=sender_id work_order_no=no, facts={_PENDING_LOCK: "1"}, sender_id=sender_id
@@ -880,42 +841,6 @@ def apply_air_lock(
return "wait_lock_option" return "wait_lock_option"
if not picked and len(options) == 1: if not picked and len(options) == 1:
picked = str(options[0].get("optionNo") or options[0].get("option_no") or "").strip() 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,
),
)
return "wait_lock_fields"
locker = getattr(flow.ledger, "lock_cabin", None) locker = getattr(flow.ledger, "lock_cabin", None)
if not callable(locker): if not callable(locker):
send_group_text( send_group_text(
@@ -932,14 +857,6 @@ def apply_air_lock(
confirm_system_exception, 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( out = call_with_retries(
lambda: locker( lambda: locker(
work_order_no=no, work_order_no=no,
@@ -947,30 +864,23 @@ def apply_air_lock(
sender_id=sender_id, sender_id=sender_id,
operator_name=name, operator_name=name,
instruction_text=instruction, instruction_text=instruction,
airline=airline_code, airline=(airline or "").strip(),
flight_no=flight_no,
) )
or {}, or {},
is_ok=_lock_terminal, is_ok=lambda x: bool((x or {}).get("ok")),
) )
if not out.get("ok"): if not out.get("ok"):
if str(out.get("error") or "") == "no_quote": if str(out.get("error") or "") == "no_quote":
send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_quote(no)) send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_quote(no))
return "no_quote" return "no_quote"
if str(out.get("error") or "") == "lock_incomplete": # 多段是产品限制,不是 TMS 故障。只回原因,不标系统异常、不暂停工单。
# 主账现拉仍无舱位:业务补问,不算系统异常,不惊动 IT。 if str(out.get("error") or "") == "multi_no_lock":
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
)
send_group_text( send_group_text(
group_client_of(flow), group_client_of(flow),
chat_id, 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( why = copy.air_cabin_fail_reason(
tms_reason=str(out.get("failReason") or out.get("error") or ""), tms_reason=str(out.get("failReason") or out.get("error") or ""),
flight_date=_ticket_flight_date(ticket), flight_date=_ticket_flight_date(ticket),
@@ -993,7 +903,6 @@ def apply_air_lock(
conversation_target=chat_id, conversation_target=chat_id,
extra={ extra={
"option_no": picked, "option_no": picked,
"airline": airline_code,
"instruction": instruction, "instruction": instruction,
"sender_id": sender_id, "sender_id": sender_id,
"operator_name": name, "operator_name": name,
@@ -1263,140 +1172,6 @@ def _quote_id(quote: dict[str, Any]) -> str:
return str(quote.get("quoteId") or quote.get("quote_id") or "").strip() return str(quote.get("quoteId") or quote.get("quote_id") or "").strip()
def _quote_ready_for_lock(quote: dict[str, Any]) -> bool:
"""
有没有可锁的报价:真实 TMS 编号,或人工总价/费用明细。
与内存账本口径一致,禁止把空报价当成「缺舱位」去补问。
"""
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")
def _picked_option(
options: list[dict[str, Any]], option_no: str
) -> dict[str, Any]:
"""按 optionNo 取舱位;对不上返回空 dict。"""
want = (option_no or "").strip().upper()
if not want:
return {}
for row in options:
no = str(row.get("optionNo") or row.get("option_no") or "").strip().upper()
if no == want:
return row
return {}
def _airline_from_quote_or_option(
quote: dict[str, Any],
options: list[dict[str, Any]],
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"),
]
)
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 _air_options(quote: dict[str, Any]) -> list[dict[str, Any]]: def _air_options(quote: dict[str, Any]) -> list[dict[str, Any]]:
raw = quote.get("airOptions") or quote.get("air_options") raw = quote.get("airOptions") or quote.get("air_options")
if isinstance(raw, str) and raw.strip().startswith("["): if isinstance(raw, str) and raw.strip().startswith("["):
+3 -31
View File
@@ -1780,12 +1780,8 @@ class AirTextInquiryFlow:
extra, extra,
) )
from agent.redis_coord.quote_file import mark_quote_file_ready 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) 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.phase = "wait_deal"
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE) sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
sess.wait_version += 1 sess.wait_version += 1
@@ -1930,37 +1926,20 @@ class AirTextInquiryFlow:
"""点成交/未成交/协商中:先整卡置灰,再入主账。未成交同一句带原因则一次记完。""" """点成交/未成交/协商中:先整卡置灰,再入主账。未成交同一句带原因则一次记完。"""
self._grey_clicked_card(reply, card_meta, replace_name=status) 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.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 "" inline = harvest_inline_lost_reason(source_text) if status == "未成交" else ""
result = finish_deal_outcome( result = finish_deal_outcome(
ledger=self._ledger, ledger=self._ledger,
work_order_no=sess.work_order_no, work_order_no=sess.work_order_no,
outcome=status, outcome=status,
facts=sess.facts, facts=sess.facts,
quote=quote, quote=sess.quote,
business_line=sess.business_line, business_line=sess.business_line,
sales_name=sess.invited_sales_name, sales_name=sess.invited_sales_name,
lost_reason=inline, lost_reason=inline,
) )
if not result.get("ok"): if not result.get("ok"):
err = str(result.get("error") or "") reply("主账状态更新失败,成交结果未入账。")
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("主账状态更新失败,成交结果未入账。")
return "ledger_fail" return "ledger_fail"
phase = str(result.get("phase") or "done") phase = str(result.get("phase") or "done")
sess.status = status sess.status = status
@@ -1997,12 +1976,7 @@ class AirTextInquiryFlow:
sess.phase = "done" sess.phase = "done"
sess.allowed = () sess.allowed = ()
self._save(sess) self._save(sess)
reply( reply(copy.lost_reason_ack(str(result.get("lost_reason") or text)))
copy.lost_reason_ack(
str(result.get("lost_reason") or text),
work_order_no=sess.work_order_no,
)
)
return "done" return "done"
def mark_wait_deal(self, sender_id: str, work_order_no: str) -> None: def mark_wait_deal(self, sender_id: str, work_order_no: str) -> None:
@@ -2016,10 +1990,8 @@ class AirTextInquiryFlow:
if not no: if not no:
return return
from agent.redis_coord.quote_file import mark_quote_file_ready 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) mark_quote_file_ready(no)
ensure_ticket_quoted(self._ledger, work_order_no=no)
if not sender_id: if not sender_id:
return return
with self._lock: with self._lock:
+1 -78
View File
@@ -210,11 +210,7 @@ def finish_deal_outcome(
writer = getattr(ledger, "transition", None) writer = getattr(ledger, "transition", None)
if not callable(writer): if not callable(writer):
return {"ok": False, "error": "no_transition", "phase": "ledger_fail"} return {"ok": False, "error": "no_transition", "phase": "ledger_fail"}
# 报价文件已出、会话已进入成交跟进,但主账仍停在「询价中」时, result = writer(work_order_no=no, to_status=status, **extra) or {}
# 直接迁「未成交/已成交/协商中」会被六态拒绝。先补「已报价」再迁目标态。
result = _transition_deal_status(
writer, work_order_no=no, to_status=status, extra=extra, ledger=ledger
)
if not result.get("ok"): if not result.get("ok"):
return {"ok": False, "error": result.get("error") or "transition_failed", "phase": "ledger_fail"} 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 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]: def capture_lost_reason(*, ledger: Any, work_order_no: str, reason: str) -> dict[str, Any]:
"""把销售下一句写进未成交原因。@询价小助手 不是原因。""" """把销售下一句写进未成交原因。@询价小助手 不是原因。"""
text = strip_bot_mentions(reason or "") text = strip_bot_mentions(reason or "")
+49 -120
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() 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() line_ccy = str(item.get("currency") or item.get("currencyCode") or header_ccy).strip()
if name and amount and amount != "-": if name and amount and amount != "-":
# 燃油附加费率 / 税率是百分比,禁止套成「CNY 1%」。 lines.append((name, money_text(amount, line_ccy)))
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)))
if lines: if lines:
return lines return lines
freight = str(src.get("freight") or "").strip() freight = str(src.get("freight") or "").strip()
@@ -1224,21 +1219,10 @@ def sea_quote_detail_text(
def collab_hint(work_order_no: str) -> str: def collab_hint(work_order_no: str) -> str:
""" """价格卡协同说明:无「内部协同」前缀,紧挨生成报价单按钮上方。"""
空运价格卡协同说明:紧挨「不需要协同,直接生成报价单」上方。 return (
f"若需要咨询内部航线人员,请手动拉新人与智能体到原有群,"
后台 inquiring.air_collab_hint;库内模板可用 {{ticket_no}}。 f"并在群聊发送工单编号{work_order_no}。"
无后台模板时用本默认句(已带工单号)。销售侧示例:
…发送“ WO202610060030@询价小助手”,谢谢。
"""
no = (work_order_no or "").strip()
return _r(
"inquiring.air_collab_hint",
(
f"若需要咨询内部航线人员,请手动将相关方拉到空运原有协同群,"
f"并在空运协同群中发送“ {no}@询价小助手”,谢谢。"
),
ticket_no=no,
) )
@@ -2400,8 +2384,14 @@ def adjust_ineligible(reason: str) -> str:
return "当前工单还没有可调的报价,不能按这套调价。" return "当前工单还没有可调的报价,不能按这套调价。"
def adjust_only_sales() -> str: def adjust_only_sales(*, air_room: bool = False) -> str:
return "只有这张单的销售能调价。航线/产品请继续报价,价格未改。" """
群里不是这张单的销售在发调价。价格不改。
空运群称呼航线;海运、陆运称呼产品。两句用句号分开,避免连成一句。
"""
who = "航线" if air_room else "产品"
return f"只有这张单的销售能调价。{who}请继续报价,价格未改。"
def adjust_group_body(quote: dict[str, Any] | None, business_line: str = "") -> str: def adjust_group_body(quote: dict[str, Any] | None, business_line: str = "") -> str:
@@ -2677,24 +2667,8 @@ def group_deal_stale(*, work_order_no: str) -> str:
return f"这张成交确认已过期。工单{no}已有更新的报价,请点击群里最新一条成交跟进。" return f"这张成交确认已过期。工单{no}已有更新的报价,请点击群里最新一条成交跟进。"
def group_deal_need_quote(*, work_order_no: str = "") -> str: def group_deal_need_quote() -> str:
""" return "还没有可确认的报价,请先完成报价再确认成交状态。"
群里确认成交/未成交时,主账还没有可确认报价。
空运 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_sales_only() -> str: def group_deal_sales_only() -> str:
@@ -2749,7 +2723,7 @@ def deal_ack(
if outcome == "未成交": if outcome == "未成交":
reason = (lost_reason or "").strip() reason = (lost_reason or "").strip()
if reason: if reason:
return f"已记录为「未成交」。{lost_reason_ack(reason, work_order_no=no)}" return f"已记录为「未成交」。{lost_reason_ack(reason)}"
return _r("lost.ask_reason", "已记录为「未成交」。请补充未成交原因。", ticket_no=no) return _r("lost.ask_reason", "已记录为「未成交」。请补充未成交原因。", ticket_no=no)
when = _followup_month_day(next_followup_at) or "第四个工作日" when = _followup_month_day(next_followup_at) or "第四个工作日"
return _r( return _r(
@@ -2776,19 +2750,14 @@ def _followup_month_day(raw: str) -> str:
return "" return ""
def lost_reason_ack(reason: str = "", work_order_no: str = "") -> str: def lost_reason_ack(reason: str = "") -> str:
""" """收下未成交原因后回显原句。后台 lost.reason_ack。"""
收下未成交原因后回显。后台 lost.reason_ack。
测服文案含 {{ticket_no}} / {{lost_reason}};缺 ticket_no 会原样打出占位符。
"""
text = (reason or "").strip() text = (reason or "").strip()
no = (work_order_no or "").strip()
if text.endswith(("。", ".", "!", "!", "?", "?")): if text.endswith(("。", ".", "!", "!", "?", "?")):
default = f"已记下未成交原因:{text}" default = f"已记下未成交原因:{text}"
else: else:
default = f"已记下未成交原因:{text}。" if text else "已记下未成交原因。" default = f"已记下未成交原因:{text}。" if text else "已记下未成交原因。"
return _r("lost.reason_ack", default, lost_reason=text, ticket_no=no) return _r("lost.reason_ack", default, lost_reason=text)
def deal_notify_card( def deal_notify_card(
@@ -3081,53 +3050,6 @@ def air_ask_lock_option(options: list) -> str:
return "\n".join(lines) 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( def air_lock_ok(
*, *,
work_order_no: str, work_order_no: str,
@@ -3155,8 +3077,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: 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未返回原因" why = (reason or "").strip() or "TMS未返回原因"
default = ( default = (
f"工单{work_order_no} 空运锁舱失败。\n" f"工单{work_order_no} 空运锁舱失败。\n"
@@ -3173,25 +3121,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: def air_cabin_fail_reason(*, tms_reason: str = "", flight_date: str = "", today: str = "") -> str:
""" """
锁舱/释放失败原因:原样回 TMS 的 failReason。 锁舱/释放失败原因:原样回 TMS 的 failReason。
@@ -3359,7 +3288,7 @@ def system_exception_alert_payload(
""" """
系统异常通知卡:走询价消息服务,不走询价小助手。 系统异常通知卡:走询价消息服务,不走询价小助手。
五行对齐现网示例:异常类型、接口/服务、错误码、影响范围、发生时间。 五行:异常类型、接口/服务、报错原因、影响范围、发生时间。
标题不加 [test]:运维要看的就是这张卡本身,test_prefix 仅保留兼容入参。 标题不加 [test]:运维要看的就是这张卡本身,test_prefix 仅保留兼容入参。
""" """
no = (work_order_no or "").strip() or "无" no = (work_order_no or "").strip() or "无"
@@ -3368,7 +3297,7 @@ def system_exception_alert_payload(
rows = [ rows = [
{"keyname": "异常类型", "value": (exception_type or "").strip() or "-"}, {"keyname": "异常类型", "value": (exception_type or "").strip() or "-"},
{"keyname": "接口/服务", "value": (service or "").strip() or "-"}, {"keyname": "接口/服务", "value": (service or "").strip() or "-"},
{"keyname": "错误码", "value": (error_code or "").strip() or "-"}, {"keyname": "报错原因", "value": (error_code or "").strip() or "-"},
{"keyname": "影响范围", "value": system_exception_impact(step)}, {"keyname": "影响范围", "value": system_exception_impact(step)},
{"keyname": "发生时间", "value": (occurred_at or "").strip() or "-"}, {"keyname": "发生时间", "value": (occurred_at or "").strip() or "-"},
] ]
@@ -3409,7 +3338,7 @@ def system_exception_alert_text(
f"关联工单 {no}", f"关联工单 {no}",
f"异常类型:{(exception_type or '').strip() or '-'}", f"异常类型:{(exception_type or '').strip() or '-'}",
f"接口/服务:{(service or '').strip() or '-'}", f"接口/服务:{(service or '').strip() or '-'}",
f"错误码:{(error_code or '').strip() or '-'}", f"报错原因:{(error_code or '').strip() or '-'}",
f"影响范围:{system_exception_impact(step)}", f"影响范围:{system_exception_impact(step)}",
f"发生时间:{(occurred_at or '').strip() or '-'}", f"发生时间:{(occurred_at or '').strip() or '-'}",
] ]
+45 -9
View File
@@ -11,7 +11,8 @@ from __future__ import annotations
import json import json
import logging import logging
from typing import Any, Callable, Optional from contextlib import contextmanager
from typing import Any, Callable, Iterator, Optional
from agent.llm.extract_text import extract_collab_fields, extract_product_quote from agent.llm.extract_text import extract_collab_fields, extract_product_quote
from agent.policy import inquiry_copy as copy from agent.policy import inquiry_copy as copy
@@ -20,6 +21,7 @@ from agent.policy.sea_group_ops import (
apply_group_deal, apply_group_deal,
group_client_of, group_client_of,
is_sales, is_sales,
override_group_client,
send_filled_sheet, send_filled_sheet,
send_group_text, send_group_text,
ticket_no, ticket_no,
@@ -33,6 +35,24 @@ _ROLE_MODE = {"sea": "SEA", "land": "LAND", "air": "AIR"}
SheetFn = Callable[..., dict[str, Any]] SheetFn = Callable[..., dict[str, Any]]
@contextmanager
def bot_sender_when_air_multi(ticket: object) -> Iterator[None]:
"""
含空运的多段用的是机器人原来的群。应用接口发不进这个群,报价和模板都会没反应。
这一次出站改走机器人桥,发完还原。没有空运段的多段仍用应用自己建的群,这里不动。
只影响当前线程,不改流程上缓存的客户端。
"""
if not includes_air(load_segments(ticket)):
yield
return
from agent.channel.aibot.reply import AibotReplyClient
logger.info("multi.outbound 含空运走机器人桥 wo=%s", ticket_no(ticket))
with override_group_client(AibotReplyClient()):
yield
def ticket_is_multi(ticket: object) -> bool: def ticket_is_multi(ticket: object) -> bool:
"""主账业务线是多段联运。""" """主账业务线是多段联运。"""
view = ticket_view(ticket) view = ticket_view(ticket)
@@ -257,8 +277,17 @@ def _save_segment_quote(
def _cabin(flow: Any, ticket: object, chat_id: str, sender_id: str, text: str, intent: str) -> str: def _cabin(flow: Any, ticket: object, chat_id: str, sender_id: str, text: str, intent: str) -> str:
"""空运段的锁舱、释放走现有空运规则,不挡住其他段报价。""" """
from agent.policy.air_group_ops import apply_air_lock, apply_air_release 多段含空运时的锁舱、释放。
锁舱暂不支持:只回复失败原因,不打 TMS,也不标系统异常。
释放仍走空运规则。调用方已确认本单含空运段。群消息在 Worker 线程发出。
"""
from agent.policy.air_group_ops import (
apply_air_release,
is_airline,
operator_name,
)
if intent == "release": if intent == "release":
return apply_air_release( return apply_air_release(
@@ -268,13 +297,20 @@ def _cabin(flow: Any, ticket: object, chat_id: str, sender_id: str, text: str, i
chat_id=chat_id, chat_id=chat_id,
instruction=text, instruction=text,
) )
return apply_air_lock( if not is_airline(flow, sender_id):
flow=flow, send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin())
ticket=ticket, return "not_airline"
sender_id=sender_id, no = ticket_no(ticket)
chat_id=chat_id, send_group_text(
instruction=text, group_client_of(flow),
chat_id,
copy.multi_air_lock_unsupported(
work_order_no=no,
operator=operator_name(flow, sender_id),
),
) )
logger.info("multi.lock unsupported wo=%s sender=%s", no, sender_id)
return "multi_lock_unsupported"
def _cabin_intent(text: str) -> str: def _cabin_intent(text: str) -> str:
@@ -260,7 +260,14 @@ def apply_group_adjust(
send_group_text(client, room, copy.adjust_ineligible("no_quote")) send_group_text(client, room, copy.adjust_ineligible("no_quote"))
return "adjust_blocked" return "adjust_blocked"
if not is_sales(ticket, sender_id): if not is_sales(ticket, sender_id):
send_group_text(client, room, copy.adjust_only_sales()) line = str(view.get("business_line") or view.get("businessLine") or "").upper()
air_room = line == "AIR"
if line == "MULTI":
from agent.policy.multi_group_ops import load_segments
from agent.policy.multi_segments import includes_air
air_room = includes_air(load_segments(ticket))
send_group_text(client, room, copy.adjust_only_sales(air_room=air_room))
return "adjust_not_sales" return "adjust_not_sales"
current = _current_quote(view, sess) current = _current_quote(view, sess)
names = [str(r.get("name") or "") for r in adjustable_rows(current)] names = [str(r.get("name") or "") for r in adjustable_rows(current)]
+34 -65
View File
@@ -9,7 +9,9 @@ from __future__ import annotations
import json import json
import logging import logging
from typing import Any, Optional from contextlib import contextmanager
from contextvars import ContextVar
from typing import Any, Iterator, Optional
from agent.llm.extract_text import extract_collab_fields, extract_product_quote from agent.llm.extract_text import extract_collab_fields, extract_product_quote
from agent.policy.quote_overlay import overlay_labeled_facts from agent.policy.quote_overlay import overlay_labeled_facts
@@ -244,14 +246,40 @@ def filename_is_excel_pdf(name: str) -> bool:
return any(raw.endswith(ext) for ext in EXCEL_PDF) return any(raw.endswith(ext) for ext in EXCEL_PDF)
# 这一次群出站临时改用的客户端。只在当前线程有效,不改流程单例上的企微客户端。
_outbound_client: ContextVar[Any] = ContextVar("inquiry_group_outbound_client", default=None)
def group_client_of(flow: Any) -> Any: def group_client_of(flow: Any) -> Any:
"""正式路径没有注入客户端时,走流程自己的默认企微客户端。""" """
取这一次群出站用的客户端。
有临时指定时用指定的(含空运的多段要走机器人桥)。
没有时走流程自己的客户端;正式路径默认是应用发群。
"""
override = _outbound_client.get()
if override is not None:
return override
getter = getattr(flow, "group_client", None) getter = getattr(flow, "group_client", None)
if callable(getter): if callable(getter):
return getter() return getter()
return getattr(flow, "_group_client", None) return getattr(flow, "_group_client", None)
@contextmanager
def override_group_client(client: Any) -> Iterator[None]:
"""
这一次群出站改用指定客户端,用完还原。
只影响当前线程。不改流程单例,海运自己建的群不会被别的工单抢走客户端。
"""
token = _outbound_client.set(client)
try:
yield
finally:
_outbound_client.reset(token)
def _archive_seat_ids(flow: Any) -> set[str]: def _archive_seat_ids(flow: Any) -> set[str]:
"""会话存档账号(测服 AI001 / 询价机器人)不能出现在群 @ 里。""" """会话存档账号(测服 AI001 / 询价机器人)不能出现在群 @ 里。"""
seats: set[str] = {"AI001", "询价机器人"} seats: set[str] = {"AI001", "询价机器人"}
@@ -906,14 +934,12 @@ def apply_product_quote(
logger.info("产品报价未识别 wo=%s text=%s", no, (text or "")[:200]) logger.info("产品报价未识别 wo=%s text=%s", no, (text or "")[:200])
send_group_text(client, chat_id, copy.group_quote_not_recognized()) send_group_text(client, chat_id, copy.group_quote_not_recognized())
return "quote_not_recognized" return "quote_not_recognized"
quote = _attach_land_weight_band_segment(quote, view)
base = _current_quote(view, sess) base = _current_quote(view, sess)
if _has_tms_quote(base): if _has_tms_quote(base):
_park_pending_quote(no, quote, sess, flow) _park_pending_quote(no, quote, sess, flow)
send_group_text(client, chat_id, copy.group_ask_tms_or_product()) send_group_text(client, chat_id, copy.group_ask_tms_or_product())
return "wait_tms_choice" return "wait_tms_choice"
# 分档单价是完整人工价(含费率),禁止和旧错误行(如燃油附加费=2)相加。 if _quote_fee_rows(base):
if _quote_fee_rows(base) and not (quote.get("weightPrices") or []):
quote = merge_quote_fees(base, quote) quote = merge_quote_fees(base, quote)
_save_product_quote(flow, no, quote, sess) _save_product_quote(flow, no, quote, sess)
ticket2 = _reload(flow, chat_id, no) or ticket ticket2 = _reload(flow, chat_id, no) or ticket
@@ -1233,11 +1259,8 @@ def apply_group_deal(
return "not_sales" return "not_sales"
quote = _current_quote(view, sess) quote = _current_quote(view, sess)
status_now = ticket_status(ticket) or (sess.status if sess else "") status_now = ticket_status(ticket) or (sess.status if sess else "")
usable_total = str(quote.get("total") or "").strip() if not (quote.get("total") or _quote_fee_rows(quote) or status_now in {"已报价", "协商中"}):
if usable_total in {"", "-"}: send_group_text(client, chat_id, copy.group_deal_need_quote())
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))
return "deal_need_quote" return "deal_need_quote"
from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason
@@ -1314,14 +1337,7 @@ def capture_group_lost_reason(
sess.phase = "done" sess.phase = "done"
sess.allowed = () sess.allowed = ()
flow._save(sess) flow._save(sess)
send_group_text( send_group_text(client, chat_id, copy.lost_reason_ack(str(result.get("lost_reason") or text)))
client,
chat_id,
copy.lost_reason_ack(
str(result.get("lost_reason") or text),
work_order_no=no,
),
)
return "done" return "done"
@@ -1646,53 +1662,6 @@ def merge_quote_fees(base: dict[str, Any] | None, overlay: dict[str, Any] | None
return stamp_prices_for_sheet(out) 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( def _save_product_quote(
flow: Any, flow: Any,
no: str, no: str,
@@ -850,7 +850,6 @@ def _resume_cabin_step(
work_order_no=no, work_order_no=no,
lock_id=lock_id, lock_id=lock_id,
sender_id=sender, sender_id=sender,
operator_name=str(extra.get("operator_name") or "").strip(),
instruction_text=instruction, instruction_text=instruction,
) )
or {} or {}
@@ -861,20 +860,13 @@ def _resume_cabin_step(
service = "空运舱位" service = "空运舱位"
reason = "空运释放舱位接口调用失败" reason = "空运释放舱位接口调用失败"
else: else:
# 与首次群锁舱对齐:重试必须带 operatorName / airline,否则 MANUAL 出站缺姓名。
locker = getattr(ledger, "lock_cabin", None) 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( out = call_with_retries(
lambda: locker( lambda: locker(
work_order_no=no, work_order_no=no,
option_no=option_no, option_no=option_no,
sender_id=sender, sender_id=sender,
operator_name=op_name,
instruction_text=instruction, instruction_text=instruction,
airline=airline,
flight_no=flight_no,
) )
or {} or {}
if callable(locker) if callable(locker)
@@ -154,17 +154,10 @@ def _hint_lock_or_release(text: str) -> str:
def _only_work_order(text: str) -> bool: def _only_work_order(text: str) -> bool:
""" """去掉工单号和 @询价小助手 后几乎没字,当作激活/切单。"""
去掉工单号和 @询价小助手 后几乎没字,当作激活/切单。
销售常按话术复制「工单编号WO…@询价小助手」;残留「工单编号」不算业务意图。
运行证据:only_wo=false 后走 extract_product_quote→product_quote→航线岗拒绝。
"""
no = copy.extract_work_order_no(text) no = copy.extract_work_order_no(text)
rest = (text or "").replace(no, "").replace(no.lower(), "") rest = (text or "").replace(no, "").replace(no.lower(), "")
rest = rest.replace("@询价小助手", "").replace("询价小助手", "") 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") rest = "".join(ch for ch in rest if ch.isalnum() or "\u4e00" <= ch <= "\u9fff")
return not rest.strip() return not rest.strip()
+2 -1
View File
@@ -520,9 +520,10 @@ def harvest_oral_collab(text: str) -> dict[str, str]:
群里口语协同:含油/含电/含磁、货好时间、是否为危险品。 群里口语协同:含油/含电/含磁、货好时间、是否为危险品。
只认原话里已经说死的词(不含电、随时可提、普货),不猜贸易条款和海关编码。 只认原话里已经说死的词(不含电、随时可提、普货),不猜贸易条款和海关编码。
「(如:随时可提)」这种补问示例行不算销售说了货好时间。
危险品:否词(不是危险品/非危/普货)先于是词,避免「不是危险品」收成是。 危险品:否词(不是危险品/非危/普货)先于是词,避免「不是危险品」收成是。
""" """
raw = text or "" raw = _without_clarify_examples(text or "")
out: dict[str, str] = {} out: dict[str, str] = {}
yes_no = ( yes_no = (
("是否含电", ("不含电", "不带电", "无电"), ("含电", "带电")), ("是否含电", ("不含电", "不带电", "无电"), ("含电", "带电")),
+41 -52
View File
@@ -421,21 +421,6 @@ class AirGroupCollabTests(unittest.TestCase):
classify_air_group_text("@询价小助手 WO202609160002", allow_network=False), classify_air_group_text("@询价小助手 WO202609160002", allow_network=False),
INTENT_ACTIVATE, 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 from unittest.mock import patch
with patch( with patch(
@@ -449,13 +434,6 @@ class AirGroupCollabTests(unittest.TestCase):
), ),
INTENT_ACTIVATE, INTENT_ACTIVATE,
) )
self.assertEqual(
classify_air_group_text(
"工单编号WO202610070026@询价小助手",
allow_network=True,
),
INTENT_ACTIVATE,
)
def test_dispatch_aibot_goes_air(self) -> None: def test_dispatch_aibot_goes_air(self) -> None:
from agent.routing.dispatch import dispatch_inbound as _disp from agent.routing.dispatch import dispatch_inbound as _disp
@@ -636,6 +614,45 @@ class AirGroupCollabTests(unittest.TestCase):
) )
self.assertEqual(phase, "need_work_order") self.assertEqual(phase, "need_work_order")
def test_activated_multi_quote_follows_without_work_order(self) -> None:
"""多段激活后,报价不必再带工单号,也不要回「请先 @我」。"""
import json
created = self.ledger.create_ticket(
sender_id="sales1",
business_line="MULTI",
facts={
"segments_json": json.dumps(
[
{"index": 1, "mode": "LAND", "facts": {"起运港": "北京", "目的港": "上海"}},
{"index": 2, "mode": "AIR", "facts": {"起运港": "上海", "目的港": "北宁"}},
],
ensure_ascii=False,
)
},
)
wo = created["work_order_no"]
self.ledger.bind_collab_group(
work_order_no=wo,
chat_id="wr_multi",
member_ids=["sales1", "air1"],
product_ids=["air1"],
product_names=["HangXian"],
)
phase = handle_air_group(
self._msg(sender="air1", text="空运费 100USD", chat="wr_multi", mid="mq1"),
flow=self.flow,
ledger=self.ledger,
injected_intent="product_quote",
)
self.assertEqual(phase, "segment_quoted")
joined = "\n".join(self._texts())
self.assertNotIn("请先 @我", joined)
self.assertNotIn("并发送要跟的工单号", joined)
ticket = self.ledger.get_ticket(work_order_no=wo)
air_quote = (ticket.quote or {}).get("segment_quotes", {}).get("AIR") or {}
self.assertTrue(air_quote.get("total") or air_quote.get("fee_rows"))
def test_sales_can_fill_battery(self) -> None: def test_sales_can_fill_battery(self) -> None:
wo = self._create_air() wo = self._create_air()
handle_air_group( handle_air_group(
@@ -928,8 +945,7 @@ class AirGroupCollabTests(unittest.TestCase):
self.assertFalse(any("AIR-OPT-01" in t for t in self._texts())) self.assertFalse(any("AIR-OPT-01" in t for t in self._texts()))
self.assertFalse(any("执行成功" 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: def test_manual_quote_without_quote_id_can_lock(self) -> None:
"""纯人工价且口令无 AIR-OPT:交主账后仍缺 → lock_incomplete(不空壳成功)。"""
wo = self._create_air( wo = self._create_air(
quote={ quote={
"total": "100", "total": "100",
@@ -944,33 +960,6 @@ class AirGroupCollabTests(unittest.TestCase):
ledger=self.ledger, ledger=self.ledger,
injected_intent="air_lock", 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="已报价",
)
phase = handle_air_group(
self._msg(
sender="air1",
text=f"{wo}锁舱,锁AIR-OPT01 CZ 航班CZ327",
chat="wr_air_lock_manual2",
mid="lm2",
),
flow=self.flow,
ledger=self.ledger,
injected_intent="air_lock",
)
self.assertEqual(phase, "lock_ok") self.assertEqual(phase, "lock_ok")
self.assertTrue(any("执行成功" in t for t in self._texts())) self.assertTrue(any("执行成功" in t for t in self._texts()))
@@ -1398,7 +1387,7 @@ class AirGroupCollabTests(unittest.TestCase):
def test_lock_without_quote_still_shows_tms_reason(self) -> None: def test_lock_without_quote_still_shows_tms_reason(self) -> None:
"""TMS 拒绝锁舱时原样回失败原因,不要改写成选项不匹配。""" """TMS 拒绝锁舱时原样回失败原因,不要改写成选项不匹配。"""
wo = self._create_air() wo = self._create_air(quote={}, status="询价中")
self.ledger._cabin_lock_result = { self.ledger._cabin_lock_result = {
"ok": False, "ok": False,
"error": "lock_fail", "error": "lock_fail",
@@ -108,6 +108,8 @@ class AttachmentUploadH5Tests(unittest.TestCase):
self.assertIn("closeWindow", ok) self.assertIn("closeWindow", ok)
self.assertIn("wecom-jssdk", ok) self.assertIn("wecom-jssdk", ok)
self.assertIn("jsapi-signature", ok) self.assertIn("jsapi-signature", ok)
self.assertIn("function isPhone()", ok)
self.assertNotIn("window.close()", ok)
self.assertNotIn("closeWindow", page) self.assertNotIn("closeWindow", page)
self.assertNotIn("closeWindow", dead) self.assertNotIn("closeWindow", dead)
-65
View File
@@ -144,75 +144,10 @@ class TestDealOutcome(unittest.TestCase):
view = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1") view = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1")
self.assertEqual(view.get("wait_phase"), "done") 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: def test_lost_reason_ack_repeats_user_text(self) -> None:
self.assertEqual(copy.lost_reason_ack("客户不要了"), "已记下未成交原因:客户不要了。") self.assertEqual(copy.lost_reason_ack("客户不要了"), "已记下未成交原因:客户不要了。")
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
bot_reply.override_catalog(
{
"lost.reason_ack": (
"已将未成交原因“{{lost_reason}}”记录在后台,"
"工单{{ticket_no}}已关闭,智能体不再跟进该工单,请悉知。"
)
}
)
try:
text = copy.lost_reason_ack("换时间", work_order_no="WO202610070018")
self.assertIn("WO202610070018", text)
self.assertNotIn("{{ticket_no}}", text)
self.assertIn("换时间", text)
finally:
bot_reply.override_catalog(None)
def test_harvest_inline_lost_reason(self) -> None: def test_harvest_inline_lost_reason(self) -> None:
self.assertEqual(harvest_inline_lost_reason("未成交,原因客户不想要了"), "客户不想要了") self.assertEqual(harvest_inline_lost_reason("未成交,原因客户不想要了"), "客户不想要了")
self.assertEqual( self.assertEqual(
+23
View File
@@ -436,6 +436,29 @@ class ClarifyPasteTests(unittest.TestCase):
self.assertNotIn("包装方式", facts) self.assertNotIn("包装方式", facts)
self.assertNotEqual(facts.get("包装方式"), "托盘、散货") self.assertNotEqual(facts.get("包装方式"), "托盘、散货")
def test_blank_cargo_ready_does_not_take_next_item_number(self) -> None:
"""货好时间空着时,下一行「4.提货地址」的编号不能写进货好时间。示例行也不算填了。"""
text = """
1.包装类型(必填):托盘
(如:托盘、散货)
2.货源地(非必填):
(如:东莞)
3.货好时间(非必填):
(如:随时可提)
4.提货地址(非必填):
(如:东莞仓库)
5.收货地址(非必填):
(如:马尼拉)
6.贸易条款(非必填):
(如:DDP、DAP、CIF、CIP)
"""
facts = extract_inquiry_snapshot(text, allow_b=False)["facts"]
self.assertEqual(facts.get("包装方式") or facts.get("包装类型"), "托盘")
self.assertNotIn("货好时间", facts)
self.assertNotIn("提货地址", facts)
self.assertNotIn("货源地", facts)
self.assertNotIn("贸易条款", facts)
class ImageOptionalClipTests(unittest.TestCase): class ImageOptionalClipTests(unittest.TestCase):
def test_numbered_extra_lines_do_not_enter_fields(self) -> None: def test_numbered_extra_lines_do_not_enter_fields(self) -> None:
@@ -327,7 +327,13 @@ class LandGroupCollabTests(unittest.TestCase):
injected_intent=INTENT_ADJUST, injected_intent=INTENT_ADJUST,
) )
self.assertEqual(phase, "adjust_not_sales") self.assertEqual(phase, "adjust_not_sales")
self.assertIn("只有这张单的销售能调价", self._group_texts()) self.assertIn("只有这张单的销售能调价。产品请继续报价,价格未改。", self._group_texts())
def test_air_room_adjust_names_airline(self) -> None:
self.assertEqual(
copy.adjust_only_sales(air_room=True),
"只有这张单的销售能调价。航线请继续报价,价格未改。",
)
if __name__ == "__main__": if __name__ == "__main__":
+126 -2
View File
@@ -11,6 +11,10 @@ ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if ROOT not in sys.path: if ROOT not in sys.path:
sys.path.insert(0, ROOT) sys.path.insert(0, ROOT)
from unittest.mock import patch
from agent.channel.wecom.models import InboundMessage
from agent.handlers.group_collab import handle_group_inbound
from agent.ledger.memory_ledger import MemoryLedger from agent.ledger.memory_ledger import MemoryLedger
from agent.policy.multi_group_ops import deal_text, handle_multi_inbound from agent.policy.multi_group_ops import deal_text, handle_multi_inbound
from agent.policy.multi_segments import Segment from agent.policy.multi_segments import Segment
@@ -387,7 +391,8 @@ class QuoteTests(unittest.TestCase):
self.assertEqual(len(self.client.files), 2) self.assertEqual(len(self.client.files), 2)
self.assertTrue(self.ledger.last_render.get("ok")) self.assertTrue(self.ledger.last_render.get("ok"))
def test_lock_on_air_segment_does_not_wait_for_other_quotes(self) -> None: def test_lock_on_air_segment_says_multi_unsupported(self) -> None:
"""多段含空运时航线锁舱,原因写成暂不支持,不再说不是空运单。"""
created = self.ledger.create_ticket( created = self.ledger.create_ticket(
sender_id="sales", sender_id="sales",
business_line="MULTI", business_line="MULTI",
@@ -412,9 +417,128 @@ class QuoteTests(unittest.TestCase):
text=f"{no}锁舱", text=f"{no}锁舱",
sheet_fn=self.sheet, sheet_fn=self.sheet,
) )
self.assertEqual(phase, "lock_ok") self.assertEqual(phase, "multi_lock_unsupported")
joined = "\n".join(self.client.texts)
self.assertIn(f"工单{no} 空运锁舱失败。", joined)
self.assertIn("操作人:航线甲", joined)
self.assertIn("锁舱失败的原因:多段询价,暂时不支持锁舱", joined)
self.assertNotIn("不是空运单,不能锁舱", joined)
self.assertNotIn("已通知IT", joined)
self.assertNotIn("请稍等", joined)
self.assertFalse(any("其他段报完" in text for text in self.client.texts)) self.assertFalse(any("其他段报完" in text for text in self.client.texts))
class _Bot:
"""替掉真实桥,记下这一次群里实际会发出去的字。"""
made: list["_Bot"] = []
def __init__(self, *args: object, **kwargs: object) -> None:
_ = args, kwargs
self.texts: list[str] = []
_Bot.made.append(self)
def send_group(self, *, chat_id: str, content: str, mention_userids: list[str] | None = None) -> dict:
_ = chat_id, mention_userids
self.texts.append(content)
return {"ok": True}
def send_group_file(self, **kwargs: object) -> dict:
_ = kwargs
return {"ok": True}
class ArchiveSenderTests(unittest.TestCase):
"""会话存档听到的报价:含空运走机器人,海运陆运仍走应用自己建的群。"""
def setUp(self) -> None:
_Bot.made = []
self.ledger = MemoryLedger()
self.app = _Client()
self.flow = _Flow(self.ledger, self.app)
def _message(self, chat_id: str, sender_id: str, text: str) -> InboundMessage:
return InboundMessage(
sender_id=sender_id,
message_id="m1",
content=text,
chat_id=chat_id,
chat_type="group",
)
def test_air_multi_quote_goes_through_bot(self) -> None:
created = self.ledger.create_ticket(
sender_id="sales",
business_line="MULTI",
facts={
"segments_json": json.dumps(
[
{"index": 1, "mode": "LAND", "facts": {"线路类别": "国内长途/零担"}},
{"index": 2, "mode": "AIR", "facts": {"目的港": "北宁"}},
],
ensure_ascii=False,
)
},
)
self.ledger.upsert_staff({"wecomId": "sales", "name": "销售甲", "roleCode": "sales", "status": "active"})
self.ledger.upsert_staff({"wecomId": "air1", "name": "航线甲", "roleCode": "air", "status": "active"})
no = created["work_order_no"]
self.ledger.bind_collab_group(
work_order_no=no,
chat_id="bot-room",
member_ids=["sales", "air1"],
product_names=["航线甲"],
product_ids=["air1"],
)
with patch("agent.policy.multi_group_ops.send_filled_sheet", return_value={"ok": True}):
with patch("agent.channel.aibot.reply.AibotReplyClient", _Bot):
phase = handle_group_inbound(
self._message("bot-room", "air1", "空运费 9900CNY"),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "segment_quoted")
self.assertFalse(self.app.texts)
self.assertTrue(_Bot.made)
self.assertTrue(any("报价单已发出" in text for text in _Bot.made[-1].texts))
def test_sea_land_quote_stays_on_app_client(self) -> None:
created = self.ledger.create_ticket(
sender_id="sales",
business_line="MULTI",
facts={
"segments_json": json.dumps(
[
{"index": 1, "mode": "SEA", "facts": {"起运港": "广州港", "目的港": "巴生港"}},
{"index": 2, "mode": "LAND", "facts": {"线路类别": "国内长途/零担"}},
],
ensure_ascii=False,
)
},
)
self.ledger.set_sea_staff(
[{"wecomId": "sea1", "name": "海产", "roleCode": "sea", "status": "active", "routes": "海运·全部线路"}]
)
self.ledger.upsert_staff({"wecomId": "sales", "name": "销售甲", "roleCode": "sales", "status": "active"})
no = created["work_order_no"]
self.ledger.bind_collab_group(
work_order_no=no,
chat_id="app-room",
member_ids=["sales", "sea1"],
product_names=["海产"],
product_ids=["sea1"],
)
with patch("agent.policy.multi_group_ops.send_filled_sheet", return_value={"ok": True}):
with patch("agent.channel.aibot.reply.AibotReplyClient", _Bot):
phase = handle_group_inbound(
self._message("app-room", "sea1", "海运费 100CNY"),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "segment_quoted")
self.assertFalse(_Bot.made)
self.assertTrue(any("报价单已发出" in text for text in self.app.texts))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -1341,28 +1341,6 @@ class SeaGroupCollabFieldTests(unittest.TestCase):
self.assertEqual(str(rows["海运费"].get("amount") or ""), "100.00") self.assertEqual(str(rows["海运费"].get("amount") or ""), "100.00")
self.assertEqual(str(rows["燃油附加费"].get("amount") or ""), "15.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: def test_air_unit_qty_amount_keeps_air_freight_name(self) -> None:
"""空运费 单价/数量/金额:费用名是空运费,金额用「金额」不是单价。""" """空运费 单价/数量/金额:费用名是空运费,金额用「金额」不是单价。"""
from agent.llm.extract_text import extract_product_quote from agent.llm.extract_text import extract_product_quote
+8 -30
View File
@@ -379,6 +379,9 @@ class TestConfirmSystemException(unittest.TestCase):
) )
card = queued[0]["payload"]["template_card"] card = queued[0]["payload"]["template_card"]
values = {row["keyname"]: row["value"] for row in card["horizontal_content_list"]} values = {row["keyname"]: row["value"] for row in card["horizontal_content_list"]}
self.assertEqual(values["报错原因"], "LOCK_FAIL")
self.assertNotIn("错误码", values)
self.assertIn("报错原因:LOCK_FAIL", queued[0]["content"])
self.assertEqual(values["影响范围"], "锁舱失败,工单状态保持不变") self.assertEqual(values["影响范围"], "锁舱失败,工单状态保持不变")
self.assertEqual(card["main_title"]["title"], "系统异常通知") self.assertEqual(card["main_title"]["title"], "系统异常通知")
@@ -477,45 +480,22 @@ class TestConfirmSystemException(unittest.TestCase):
self.assertTrue("USD" in joined or "拉产品" in joined or "已报价" in joined) self.assertTrue("USD" in joined or "拉产品" in joined or "已报价" in joined)
def test_admin_retry_cabin_lock_runs_lock(self) -> None: 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"}],
},
to_status="已报价",
)
self.ledger.mark_system_exception( self.ledger.mark_system_exception(
work_order_no=air_wo, work_order_no=self.wo,
system_exception=TYPE_TMS, system_exception=TYPE_TMS,
system_exception_reason="空运锁舱接口调用失败", system_exception_reason="空运锁舱接口调用失败",
step=STEP_TMS_CABIN, step=STEP_TMS_CABIN,
payload={ payload={
"conversation_kind": "group", "conversation_kind": "group",
"conversation_target": "chat-air", "conversation_target": "chat-air",
"extra": { "extra": {"option_no": "AIR-OPT-01", "instruction": "锁舱"},
"option_no": "AIR-OPT-01",
"instruction": "锁舱",
"operator_name": "HangXian",
"airline": "CZ",
},
}, },
) )
hits = {"n": 0, "kwargs": {}} hits = {"n": 0}
orig_lock = self.ledger.lock_cabin orig_lock = self.ledger.lock_cabin
def counted(**kwargs): def counted(**kwargs):
hits["n"] += 1 hits["n"] += 1
hits["kwargs"] = dict(kwargs)
return orig_lock(**kwargs) return orig_lock(**kwargs)
self.ledger.lock_cabin = counted # type: ignore[method-assign] self.ledger.lock_cabin = counted # type: ignore[method-assign]
@@ -527,14 +507,12 @@ class TestConfirmSystemException(unittest.TestCase):
orig = se._send_to_conversation orig = se._send_to_conversation
se._send_to_conversation = lambda **kwargs: talks.append(str(kwargs.get("text") or "")) se._send_to_conversation = lambda **kwargs: talks.append(str(kwargs.get("text") or ""))
try: try:
out = run_admin_retry(air_wo, ledger=self.ledger) out = run_admin_retry(self.wo, ledger=self.ledger)
finally: finally:
se._send_to_conversation = orig se._send_to_conversation = orig
self.assertTrue(out.get("ok")) self.assertTrue(out.get("ok"))
self.assertGreaterEqual(hits["n"], 1) self.assertGreaterEqual(hits["n"], 1)
self.assertEqual(hits["kwargs"].get("operator_name"), "HangXian") ticket = self.ledger.get_ticket(work_order_no=self.wo)
self.assertEqual(hits["kwargs"].get("airline"), "CZ")
ticket = self.ledger.get_ticket(work_order_no=air_wo)
self.assertFalse(ticket_is_paused(ticket)) self.assertFalse(ticket_is_paused(ticket))
self.assertTrue(any("空运舱位接口已恢复" in x for x in talks)) self.assertTrue(any("空运舱位接口已恢复" in x for x in talks))
@@ -1171,27 +1171,6 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
one.put("delivery_fee", out.getString("delivery_fee")); one.put("delivery_fee", out.getString("delivery_fee"));
one.put("price_per_cbm", out.getString("price_per_cbm")); one.put("price_per_cbm", out.getString("price_per_cbm"));
one.put("price_per_kg", out.getString("price_per_kg")); 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")); String landAmt = firstNonBlankLocal(out.getString("land_freight"), out.getString("price_per_cbm"));
for (int i = 0; i < rows.size(); i++) { for (int i = 0; i < rows.size(); i++) {
JSONObject fee = rows.getJSONObject(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("transitDays", "transit_days");
LAND_TMS_KEY_TO_CODE.put("effectiveTime", "effective_time"); LAND_TMS_KEY_TO_CODE.put("effectiveTime", "effective_time");
LAND_TMS_KEY_TO_CODE.put("landFreight", "land_freight"); 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("vehicle42Fee", "vehicle_42_fee");
LAND_TMS_KEY_TO_CODE.put("vehicle50Fee", "vehicle_50_fee"); LAND_TMS_KEY_TO_CODE.put("vehicle50Fee", "vehicle_50_fee");
LAND_TMS_KEY_TO_CODE.put("vehicle76Fee", "vehicle_76_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()); writeFeeCol(sheet, ri, columns.getString("remark"), rowObj.getString("remark"), yellow, sheet.getSheetName());
if (columns.containsKey("tax_rate")) { if (columns.containsKey("tax_rate")) {
writeFeeCol(sheet, ri, columns.getString("tax_rate"), writeFeeCol(sheet, ri, columns.getString("tax_rate"),
formatLandTaxRateDisplay(firstNonBlank(rowObj.getString("tax_rate"), firstNonBlank(rowObj.getString("tax_rate"), rowObj.getString("taxRate"),
rowObj.getString("taxRate"), facts == null ? null : facts.getString("tax_rate")),
rowObj.getString("rate"),
facts == null ? null : facts.getString("tax_rate"))),
yellow, sheet.getSheetName()); yellow, sheet.getSheetName());
} }
if (columns.containsKey("fuel_surcharge_rate")) { if (columns.containsKey("fuel_surcharge_rate")) {
writeFeeCol(sheet, ri, columns.getString("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"), rowObj.getString("fuelSurchargeRate"),
facts == null ? null : facts.getString("fuel_surcharge_rate"))), facts == null ? null : facts.getString("fuel_surcharge_rate")),
yellow, sheet.getSheetName()); yellow, sheet.getSheetName());
} }
writeLandMatrixSideCols(sheet, ri, region, rowObj, facts, yellow); writeLandMatrixSideCols(sheet, ri, region, rowObj, facts, yellow);
@@ -2942,19 +2937,12 @@ public final class QuoteTemplateExcelSupport {
facts == null ? null : facts.getString("currency")); facts == null ? null : facts.getString("currency"));
} }
if (header.contains("燃油附加")) { if (header.contains("燃油附加")) {
return formatLandPercentDisplay(firstNonBlank(rowObj.getString("fuel_surcharge_rate"), return firstNonBlank(rowObj.getString("fuel_surcharge_rate"), rowObj.getString("fuelSurchargeRate"),
rowObj.getString("fuelSurchargeRate"), facts == null ? null : facts.getString("fuel_surcharge_rate"));
facts == null ? null : facts.getString("fuel_surcharge_rate")));
} }
if ("税率".equals(header)) { if ("税率".equals(header)) {
return formatLandTaxRateDisplay(firstNonBlank(rowObj.getString("tax_rate"), rowObj.getString("taxRate"), return firstNonBlank(rowObj.getString("tax_rate"), rowObj.getString("taxRate"),
rowObj.getString("rate"), facts == null ? null : facts.getString("tax_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"))));
} }
if (header.contains("含税合计") || header.contains("杂费合计")) { if (header.contains("含税合计") || header.contains("杂费合计")) {
return firstNonBlank(rowObj.getString("amount"), rowObj.getString("salePrice"), return firstNonBlank(rowObj.getString("amount"), rowObj.getString("salePrice"),
@@ -3191,21 +3179,14 @@ public final class QuoteTemplateExcelSupport {
out.put("transit_time", transit); out.put("transit_time", transit);
out.put("transitDays", transit); out.put("transitDays", transit);
} }
String fuel = formatLandPercentDisplay(firstNonBlank( String fuel = firstNonBlank(row.getString("fuel_surcharge_rate"), row.getString("fuelSurchargeRate"));
row.getString("fuel_surcharge_rate"), row.getString("fuelSurchargeRate")));
if (StringUtils.isNotBlank(fuel)) { if (StringUtils.isNotBlank(fuel)) {
out.put("fuel_surcharge_rate", fuel); out.put("fuel_surcharge_rate", fuel);
} }
String tax = formatLandTaxRateDisplay(firstNonBlank( String tax = firstNonBlank(row.getString("tax_rate"), row.getString("taxRate"));
row.getString("tax_rate"), row.getString("taxRate"), row.getString("rate")));
if (StringUtils.isNotBlank(tax)) { if (StringUtils.isNotBlank(tax)) {
out.put("tax_rate", 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; return out;
} }
@@ -3848,68 +3829,6 @@ public final class QuoteTemplateExcelSupport {
return ""; 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) { private static void writeFeeRows(Workbook wb, JSONObject mapping, JSONObject facts, Set<String> yellow) {
JSONObject feeRows = mapping.getJSONObject("fee_rows"); JSONObject feeRows = mapping.getJSONObject("fee_rows");
if (feeRows == null) { if (feeRows == null) {
@@ -51,6 +51,24 @@ public class InquiryTmsCabinService {
@Lazy @Lazy
private InquiryTmsQueryService queryService; private InquiryTmsQueryService queryService;
/** 多段询价锁舱的产品口径。智能体群回复与主账拒绝用同一句。 */
public static final String MULTI_LOCK_UNSUPPORTED = "多段询价,暂时不支持锁舱";
/**
* 还没到 TMS 之前的拒绝原因。空运单返回空,表示可以继续锁。
* 多段整单不是 AIR,但航线会按空运段来锁,单独说明暂不支持。
*/
static String lockRejectReason(String businessLine) {
String line = StringUtils.trimToEmpty(businessLine);
if ("MULTI".equalsIgnoreCase(line)) {
return MULTI_LOCK_UNSUPPORTED;
}
if (!"AIR".equalsIgnoreCase(line)) {
return "不是空运单,不能锁舱";
}
return "";
}
/** /**
* 智能体锁舱入口。 * 智能体锁舱入口。
*/ */
@@ -61,8 +79,11 @@ public class InquiryTmsCabinService {
if (ticket == null) { if (ticket == null) {
return fail("ticket_not_found", "找不到工单"); return fail("ticket_not_found", "找不到工单");
} }
if (!"AIR".equalsIgnoreCase(StringUtils.trimToEmpty(ticket.getBusinessLine()))) { // 多段整单业务线是 MULTI。含空运段时航线仍会来锁舱,不能再说「不是空运单」。
return fail("not_air", "不是空运单,不能锁舱"); String lockReject = lockRejectReason(ticket.getBusinessLine());
if (StringUtils.isNotBlank(lockReject)) {
String code = MULTI_LOCK_UNSUPPORTED.equals(lockReject) ? "multi_no_lock" : "not_air";
return fail(code, lockReject);
} }
if (TicketStatuses.DEAL.equals(ticket.getStatus()) if (TicketStatuses.DEAL.equals(ticket.getStatus())
|| TicketStatuses.CLOSED.equals(ticket.getStatus())) { || TicketStatuses.CLOSED.equals(ticket.getStatus())) {
@@ -75,16 +96,11 @@ public class InquiryTmsCabinService {
Map<String, Object> quote = payloadOf(rows.get(0)); Map<String, Object> quote = payloadOf(rows.get(0));
// 「AIR-OPT01 TH」被误落成航线报价不算真正人工价 → 仍走仅 TMS(SYSTEM)。 // 「AIR-OPT01 TH」被误落成航线报价不算真正人工价 → 仍走仅 TMS(SYSTEM)。
boolean latestManual = isManualQuote(quote) && !isFakeManualLockQuote(quote); boolean latestManual = isManualQuote(quote) && !isFakeManualLockQuote(quote);
// 库里只有人工价、没有舱位时也要现拉 TMS:补 quoteId/airOptions, if (!latestManual && StringUtils.isBlank(resolveQuoteId(quote, no))) {
// 避免 MANUAL 空壳 1001,也避免逼航线手打航班号。
boolean cabinSparse = StringUtils.isBlank(resolveQuoteId(quote, no))
|| airOptions(quote).isEmpty();
if (cabinSparse) {
quote = refreshLockQuote(ticket, quote); quote = refreshLockQuote(ticket, quote);
} }
String tmsQuoteId = realTmsQuoteId(rows, no); String tmsQuoteId = realTmsQuoteId(rows, no);
// 现拉到的 TMS 编号也算:库里无历史行时仍可 MANUAL+quoteId。 if (!latestManual && StringUtils.isBlank(tmsQuoteId)) {
if (StringUtils.isBlank(tmsQuoteId)) {
tmsQuoteId = resolveQuoteId(quote, no); tmsQuoteId = resolveQuoteId(quote, no);
} }
boolean hasTms = StringUtils.isNotBlank(tmsQuoteId); boolean hasTms = StringUtils.isNotBlank(tmsQuoteId);
@@ -145,68 +161,26 @@ public class InquiryTmsCabinService {
text(option, "destination"), ticket.getDestination(), text(option, "destination"), ticket.getDestination(),
fact(ticket, "目的地", "目的港"))); fact(ticket, "目的地", "目的港")));
// 航司:句中两位码(如「AIR-OPT01 TH」)> 舱位/报价字段 > 航班号前两位。 // 航司:句中两位码(如「AIR-OPT01 TH」)> 舱位/报价字段 > 航班号前两位。
// airlineName 中文名不能当航司码出站,只取两位码或航班前缀。 req.put("airline", firstNonBlank(
String airlineCode = firstNonBlank(
text(body, "airline", "airlineCode"), text(body, "airline", "airlineCode"),
airlineFromInstruction(text(body, "instructionText", "instruction_text")), airlineFromInstruction(text(body, "instructionText", "instruction_text")),
text(option, "airlineCode", "airline"), text(option, "airlineCode", "airline"),
text(quote, "airlineCode", "airline"), text(option, "airlineName"),
fact(ticket, "航司代码"), text(quote, "airlineCode", "airline", "airlineName"),
airlineFromFlight(firstNonBlank(text(option, "flightNo"), text(quote, "flightNo"))), fact(ticket, "航司", "航司代码"),
airlineCodeOrBlank(text(option, "airlineName")), airlineFromFlight(firstNonBlank(text(option, "flightNo"), text(quote, "flightNo")))));
airlineCodeOrBlank(text(quote, "airlineName")),
airlineCodeOrBlank(fact(ticket, "航司")));
req.put("airline", airlineCode);
req.put("weightKg", ticket.getWeightKg()); req.put("weightKg", ticket.getWeightKg());
req.put("volumeCbm", ticket.getVolumeCbm()); req.put("volumeCbm", ticket.getVolumeCbm());
req.put("pieceQty", ticket.getPieces()); req.put("pieceQty", ticket.getPieces());
// 计费重:舱位 > 报价 > 工单计费重事实 > 毛重(避免 MANUAL 空壳 1001)。
req.put("chargeableWeightKg", firstNonBlank( req.put("chargeableWeightKg", firstNonBlank(
text(option, "chargeableWeightKg", "chargeable_weight_kg"), text(option, "chargeableWeightKg", "chargeable_weight_kg"),
text(quote, "chargeableWeightKg", "chargeable_weight_kg"), text(quote, "chargeableWeightKg", "chargeable_weight_kg")));
fact(ticket, "计费重", "计费重量"),
ticket.getWeightKg() == null ? "" : String.valueOf(ticket.getWeightKg()),
fact(ticket, "毛重")));
req.put("operatorUserId", text(body, "operatorUserId", "sender_id")); req.put("operatorUserId", text(body, "operatorUserId", "sender_id"));
// 重试漏传姓名时用 userid 兜底,仍要有 operatorName 键。 req.put("operatorName", text(body, "operatorName", "operator_name"));
req.put("operatorName", firstNonBlank(
text(body, "operatorName", "operator_name"),
text(body, "operatorUserId", "sender_id")));
req.put("instructionText", text(body, "instructionText", "instruction_text")); req.put("instructionText", text(body, "instructionText", "instruction_text"));
if ("MANUAL".equals(lockSource)) { if ("MANUAL".equals(lockSource)) {
// 有 TMS 时保留 quoteId;纯人工才去掉。 // 有 TMS 时保留 quoteId;纯人工才去掉。
fillManualLock(req, quote, option, body, StringUtils.isNotBlank(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 { } else {
// 仅 TMS:source=SYSTEM + quoteId。禁止再塞 validUntil/feeItems(会 1001)。 // 仅 TMS:source=SYSTEM + quoteId。禁止再塞 validUntil/feeItems(会 1001)。
req.put("source", "SYSTEM"); req.put("source", "SYSTEM");
@@ -362,26 +336,14 @@ public class InquiryTmsCabinService {
} }
Map<String, Object> body = new LinkedHashMap<>(); Map<String, Object> body = new LinkedHashMap<>();
body.put("workOrderNo", ticket.getWorkOrderNo()); body.put("workOrderNo", ticket.getWorkOrderNo());
// 锁舱现拉必须用「今天」报价日:历史询价日常已无运价(1002),
// 不能把销售初询那天的日期带出去导致永远补不到舱位。
JSONObject facts = new JSONObject();
if (StringUtils.isNotBlank(ticket.getFactsJson())) { if (StringUtils.isNotBlank(ticket.getFactsJson())) {
JSONObject stored = JSON.parseObject(ticket.getFactsJson()); JSONObject facts = JSON.parseObject(ticket.getFactsJson());
if (stored != null) { if (facts != null) {
facts.putAll(stored); 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 { try {
Map<String, Object> live = queryService.queryFromAgent(body); 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"); Object raw = live == null ? null : live.get("quote");
if (!(raw instanceof Map)) { if (!(raw instanceof Map)) {
return current; return current;
@@ -394,19 +356,14 @@ public class InquiryTmsCabinService {
if (airOptions(current).isEmpty() && fetched.get("airOptions") != null) { if (airOptions(current).isEmpty() && fetched.get("airOptions") != null) {
current.put("airOptions", fetched.get("airOptions")); 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")) if (StringUtils.isBlank(text(current, "chargeableWeightKg", "chargeable_weight_kg"))
&& StringUtils.isNotBlank(text(fetched, "chargeableWeightKg", "chargeable_weight_kg"))) { && StringUtils.isNotBlank(text(fetched, "chargeableWeightKg", "chargeable_weight_kg"))) {
current.put("chargeableWeightKg", 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(), ticket.getWorkOrderNo(),
StringUtils.isNotBlank(text(current, "quoteId", "quote_id")), StringUtils.isNotBlank(text(current, "quoteId", "quote_id")),
airOptions(current).size(), airOptions(current).size());
live == null ? "" : live.get("classification"),
today);
} catch (RuntimeException ex) { } catch (RuntimeException ex) {
log.warn("tms.lock refresh fail wo={} err={}", ticket.getWorkOrderNo(), ex.getMessage()); log.warn("tms.lock refresh fail wo={} err={}", ticket.getWorkOrderNo(), ex.getMessage());
} }
@@ -480,11 +437,7 @@ public class InquiryTmsCabinService {
if (StringUtils.isNotBlank(transfer)) { if (StringUtils.isNotBlank(transfer)) {
req.put("transferAirport", transfer); req.put("transferAirport", transfer);
} }
// 句中/请求体点名的航班号优先,其次舱位与报价。 String flightNo = firstNonBlank(text(option, "flightNo"), text(quote, "flightNo"));
String flightNo = firstNonBlank(
text(body, "flightNo", "flight_no"),
text(option, "flightNo", "flight_no"),
text(quote, "flightNo", "flight_no"));
if (StringUtils.isNotBlank(flightNo)) { if (StringUtils.isNotBlank(flightNo)) {
req.put("flightNo", flightNo); req.put("flightNo", flightNo);
} }
@@ -531,41 +484,11 @@ public class InquiryTmsCabinService {
List<Map<String, Object>> departure = feeRows(quote, option, "departureCharges", "departure_charges"); List<Map<String, Object>> departure = feeRows(quote, option, "departureCharges", "departure_charges");
List<Map<String, Object>> fees = feeRows(quote, option, "feeItems", "fee_items"); List<Map<String, Object>> fees = feeRows(quote, option, "feeItems", "fee_items");
splitFeeRows(quote, departure, fees); splitFeeRows(quote, departure, fees);
// 运行证据(2026-10-07):只要出站带 departureCharges,TMS 一律 1001「参数格式错误」; if (!departure.isEmpty()) {
// 去掉后变为业务码 2008。feeItems 可保留。故 MANUAL 锁舱不再出站 departureCharges。 req.put("departureCharges", toLockFees(departure, true));
}
if (!fees.isEmpty()) { if (!fees.isEmpty()) {
req.put("feeItems", toLockFees(fees, false)); 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 +712,10 @@ public class InquiryTmsCabinService {
return true; return true;
} }
/**
* 本单真实 TMS quoteId:优先非人工报价行;人工覆盖后再锁仍要带原编号。
* 跳过 QT-WO 假编号。
*/
static String realTmsQuoteId(List<InquiryQuote> rows, String workOrderNo) { static String realTmsQuoteId(List<InquiryQuote> rows, String workOrderNo) {
if (rows == null) { if (rows == null) {
return ""; 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) { for (InquiryQuote row : rows) {
String id = resolveQuoteId(payloadOf(row), workOrderNo); String id = resolveQuoteId(payloadOf(row), workOrderNo);
if (StringUtils.isNotBlank(id)) { if (StringUtils.isNotBlank(id)) {
@@ -816,51 +725,6 @@ public class InquiryTmsCabinService {
return ""; 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) { private List<InquiryQuote> quoteRows(String workOrderNo) {
if (StringUtils.isBlank(workOrderNo)) { if (StringUtils.isBlank(workOrderNo)) {
return new ArrayList<>(); return new ArrayList<>();
@@ -484,21 +484,6 @@ public class InquiryTmsQueryService {
putExtra(row, pair[1], firstText(seg.get(pair[0]), seg.get(pair[1]))); putExtra(row, pair[1], firstText(seg.get(pair[0]), seg.get(pair[1])));
putExtra(row, pair[0], firstText(seg.get(pair[0]))); 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")) if (StringUtils.isNotBlank(row.get("origin")) || StringUtils.isNotBlank(row.get("destination"))
|| StringUtils.isNotBlank(row.get("amount"))) { || StringUtils.isNotBlank(row.get("amount"))) {
out.add(row); 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, private static void addFeeLine(List<Map<String, String>> lines, String name, String amount,
String currency, Map<String, String> extras) { String currency, Map<String, String> extras) {
TmsQuoteCurrency.Money money = TmsQuoteCurrency.split(amount, currency); TmsQuoteCurrency.Money money = TmsQuoteCurrency.split(amount, currency);
@@ -69,7 +69,7 @@ VALUES
('brv10000000000000000000000000038','deal','已成交','deal.recorded_won','销售选择「成交」','已记录为「已成交」。工单 {{ticket_no}} 已关闭智能体跟进。',1,1,'admin',NOW()), ('brv10000000000000000000000000038','deal','已成交','deal.recorded_won','销售选择「成交」','已记录为「已成交」。工单 {{ticket_no}} 已关闭智能体跟进。',1,1,'admin',NOW()),
('brv10000000000000000000000000039','negotiating','协商中','negotiating.recorded','成交跟进中选择「协商中」','已记录为「协商中」。已顺延三个工作日,{{followup_when}}的9:00再次发起成交状态询问。',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()), ('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()), ('brv10000000000000000000000000042','closed','已关闭','closed.air_cannot_activate','空运工单已是终态仍要激活','工单{{ticket_no}}已是「{{status}}」,不能再激活。',1,1,'admin',NOW()),
('brv10000000000000000000000000043','sys-ex','系统异常','sys.llm_fail','大模型调用失败(销售侧)','大模型服务异常,IT排查中...',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()), ('brv10000000000000000000000000044','sys-ex','系统异常','sys.tms_fail','TMS 接口失败(销售侧)','TMS接口异常,IT运维排查中...',1,2,'admin',NOW()),
@@ -2231,58 +2231,4 @@ public class QuoteTemplateFillTest {
} }
return cell.getStringCellValue(); 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 { public class InquiryTmsCabinServiceTest {
@Test
public void multiInquiryCannotLockCabin() {
assertEquals(
"多段询价,暂时不支持锁舱",
InquiryTmsCabinService.lockRejectReason("MULTI"));
assertEquals("不是空运单,不能锁舱", InquiryTmsCabinService.lockRejectReason("SEA"));
assertEquals("", InquiryTmsCabinService.lockRejectReason("AIR"));
}
@Test @Test
public void pickNamedOption() { public void pickNamedOption() {
Map<String, Object> quote = new HashMap<>(); Map<String, Object> quote = new HashMap<>();
@@ -178,8 +187,10 @@ public class InquiryTmsCabinServiceTest {
InquiryTmsCabinService.fillLockQuoteExtras(req, quote, option); InquiryTmsCabinService.fillLockQuoteExtras(req, quote, option);
assertEquals("2026-09-30T23:59:59+08:00", req.get("validUntil")); assertEquals("2026-09-30T23:59:59+08:00", req.get("validUntil"));
// departureCharges 出站会 TMS 1001,只保留 feeItems。 @SuppressWarnings("unchecked")
org.junit.jupiter.api.Assertions.assertNull(req.get("departureCharges")); java.util.List<Map<String, Object>> dep =
(java.util.List<Map<String, Object>>) req.get("departureCharges");
assertEquals("运输费", dep.get(0).get("chargeName"));
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
java.util.List<Map<String, Object>> fees = java.util.List<Map<String, Object>> fees =
(java.util.List<Map<String, Object>>) req.get("feeItems"); (java.util.List<Map<String, Object>>) req.get("feeItems");
@@ -259,40 +270,4 @@ public class InquiryTmsCabinServiceTest {
InquiryTmsCabinService.fillManualLock(req, quote, new HashMap<>(), new HashMap<>(), false); InquiryTmsCabinService.fillManualLock(req, quote, new HashMap<>(), new HashMap<>(), false);
org.junit.jupiter.api.Assertions.assertNull(req.get("quoteId")); 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);
}
} }
@@ -184,42 +184,6 @@ public class TmsQuoteCurrencyTest {
assertTrue(quote.get("fee_lines").contains("2800")); 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 @Test
public void toAgentQuoteKeepsSeaBucketTotals() { public void toAgentQuoteKeepsSeaBucketTotals() {
Map<String, Object> pol = new HashMap<>(); Map<String, Object> pol = new HashMap<>();
@@ -568,22 +568,13 @@ public class LoginController {
} }
/** /**
* 切换菜单表为vue3的表。 * 切换菜单表为vue3的表
* <p>
* 询价新库若已无 {@code sys_permission_v3},服务层会拒绝切换并返回错误,避免把现用菜单表 rename 掉。
* </p>
*/ */
@RequiresRoles({"admin"}) @RequiresRoles({"admin"})
@GetMapping(value = "/switchVue3Menu") @GetMapping(value = "/switchVue3Menu")
public Result<String> switchVue3Menu(HttpServletResponse response) { public Result<String> switchVue3Menu(HttpServletResponse response) {
Result<String> res = new Result<String>(); Result<String> res = new Result<String>();
try { sysPermissionService.switchVue3Menu();
sysPermissionService.switchVue3Menu();
res.success("切换成功");
} catch (Exception e) {
log.error("switchVue3Menu rejected or failed: {}", e.getMessage(), e);
res.error500(e.getMessage());
}
return res; return res;
} }
@@ -49,18 +49,6 @@ public interface SysPermissionMapper extends BaseMapper<SysPermission> {
public void backupVue2Menu(); public void backupVue2Menu();
@Update("alter table sys_permission_v3 rename to sys_permission") @Update("alter table sys_permission_v3 rename to sys_permission")
public void changeVue3Menu(); 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 * 获取模糊匹配规则的数据权限URL
@@ -55,22 +55,8 @@ public class SysPermissionServiceImpl extends ServiceImpl<SysPermissionMapper, S
@Resource @Resource
private SysDepartRolePermissionMapper sysDepartRolePermissionMapper; 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 @Override
public void switchVue3Menu() { public void switchVue3Menu() {
Long vue3TableCnt = sysPermissionMapper.countVue3MenuTable();
if (vue3TableCnt == null || vue3TableCnt.longValue() <= 0L) {
throw new JeecgBootException("当前库没有 sys_permission_v3,菜单已是 Vue3 或不可自动切换,已拒绝执行");
}
sysPermissionMapper.backupVue2Menu(); sysPermissionMapper.backupVue2Menu();
sysPermissionMapper.changeVue3Menu(); sysPermissionMapper.changeVue3Menu();
} }
@@ -2,6 +2,7 @@ import type { AppRouteRecordRaw, Menu } from '/@/router/types';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { store } from '/@/store'; import { store } from '/@/store';
import { useI18n } from '/@/hooks/web/useI18n';
import { useUserStore } from './user'; import { useUserStore } from './user';
import { useAppStoreWithOut } from './app'; import { useAppStoreWithOut } from './app';
import { toRaw } from 'vue'; 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 { 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 { getPermCode } from '/@/api/sys/user';
import { useMessage } from '/@/hooks/web/useMessage';
import { PageEnum } from '/@/enums/pageEnum'; import { PageEnum } from '/@/enums/pageEnum';
/** /**
@@ -244,6 +246,7 @@ export const usePermissionStore = defineStore({
this.setAuthData(systemPermission); this.setAuthData(systemPermission);
}, },
async buildRoutesAction(): Promise<AppRouteRecordRaw[]> { async buildRoutesAction(): Promise<AppRouteRecordRaw[]> {
const { t } = useI18n();
const userStore = useUserStore(); const userStore = useUserStore();
const appStore = useAppStoreWithOut(); const appStore = useAppStoreWithOut();
@@ -319,18 +322,54 @@ export const usePermissionStore = defineStore({
// 后台菜单构建 // 后台菜单构建
case PermissionModeEnum.BACK: case PermissionModeEnum.BACK:
const { createMessage, createWarningModal } = useMessage();
// 菜单加载提示
// createMessage.loading({
// content: t('sys.app.menuLoading'),
// duration: 1,
// });
// 从后台获取权限码, // 从后台获取权限码,
// 这个函数可能只需要执行一次,并且实际的项目可以在正确的时间被放置 // 这个函数可能只需要执行一次,并且实际的项目可以在正确的时间被放置
let routeList: AppRouteRecordRaw[] = []; let routeList: AppRouteRecordRaw[] = [];
try { try {
this.changePermissionCode(); this.changePermissionCode();
routeList = (await getMenuList()) as AppRouteRecordRaw[]; routeList = (await getMenuList()) as AppRouteRecordRaw[];
// Jeecg 原逻辑:顶层没有 layouts/default/index 且图标无冒号 → 弹窗并调用 /sys/switchVue3Menu // update-begin----author:sunjianlei---date:20220315------for: 判断是否是 vue3 版本的菜单 ---
// (把 sys_permission rename 成 v2,再把 sys_permission_v3 rename 成正式表)。 let hasIndex: boolean = false;
// 询价库 inquiry_robot 菜单种子已是 Vue3,且测服没有 sys_permission_v3;点确认会弄挂侧栏。 let hasIcon: boolean = false;
// 因此禁用自动切表弹窗;后面仍会 ensureInquiryHandwrittenRoutes 补齐询价页。 for (let menuItem of routeList) {
// update-begin----author:ytd---date:20261007------for: 禁用危险的 Vue2→Vue3 菜单切表提示 --- // 条件1:判断组件是否是 layouts/default/index
// update-end----author:ytd---date:20261007------for: 禁用危险的 Vue2→Vue3 菜单切表提示 --- 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) { } catch (error) {
console.error(error); console.error(error);
// 菜单接口失败时仍补挂询价手写页,避免侧栏点进去全是 404 // 菜单接口失败时仍补挂询价手写页,避免侧栏点进去全是 404
@@ -325,7 +325,6 @@ function removeAccount(row: AccountRow) {
<th>账号</th> <th>账号</th>
<th>姓名</th> <th>姓名</th>
<th>绑定角色</th> <th>绑定角色</th>
<th>数据范围</th>
<th>手机号</th> <th>手机号</th>
<th>最后登录</th> <th>最后登录</th>
<th>状态</th> <th>状态</th>
@@ -334,10 +333,10 @@ function removeAccount(row: AccountRow) {
</thead> </thead>
<tbody> <tbody>
<tr v-if="loading"> <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>
<tr v-else-if="!rows.length"> <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>
<tr v-for="row in rows" :key="row.id"> <tr v-for="row in rows" :key="row.id">
<td class="account-username">{{ row.username }}</td> <td class="account-username">{{ row.username }}</td>
@@ -345,20 +344,6 @@ function removeAccount(row: AccountRow) {
<td> <td>
<span class="tag" :class="row.roleTag">{{ row.roleLabel }}</span> <span class="tag" :class="row.roleTag">{{ row.roleLabel }}</span>
</td> </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.phoneMasked }}</td>
<td>{{ row.lastLogin }}</td> <td>{{ row.lastLogin }}</td>
<td> <td>
@@ -2,9 +2,6 @@
/** /**
* 询价后台登录页:视觉 1:1 对齐 admin/LoginView(居中白卡片 + 品牌区,无验证码)。 * 询价后台登录页:视觉 1:1 对齐 admin/LoginView(居中白卡片 + 品牌区,无验证码)。
* 鉴权走 Jeecg loginApi;主账 inquiry 配置关闭图形验证码(jeecg.loginCaptchaEnabled=false)。 * 鉴权走 Jeecg loginApi;主账 inquiry 配置关闭图形验证码(jeecg.loginCaptchaEnabled=false)。
*
* 禁止写死 admin/123456:退出后再进登录页会误显示测试账号。
* 仅记住上次成功登录的账号(不存密码),方便换人登录时改账号。
*/ */
import { reactive, ref, toRaw } from 'vue'; import { reactive, ref, toRaw } from 'vue';
import { ConfigProvider, message } from 'ant-design-vue'; import { ConfigProvider, message } from 'ant-design-vue';
@@ -13,29 +10,11 @@
import '/@/views/inquiry/styles/admin-prototype.css'; import '/@/views/inquiry/styles/admin-prototype.css';
import '/@/views/inquiry/styles/admin-fidelity.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 userStore = useUserStore();
const loading = ref(false); const loading = ref(false);
const form = reactive({ const form = reactive({
username: readLastUsername(), username: 'admin',
password: '', password: '123456',
}); });
const themeToken = { const themeToken = {
@@ -57,10 +36,9 @@
} }
loading.value = true; loading.value = true;
try { try {
const username = form.username.trim();
const { userInfo } = await userStore.login( const { userInfo } = await userStore.login(
toRaw({ toRaw({
username, username: form.username.trim(),
password: form.password, password: form.password,
// 主账已关验证码;仍传空字段以兼容 LoginParams 类型 // 主账已关验证码;仍传空字段以兼容 LoginParams 类型
captcha: '', captcha: '',
@@ -70,9 +48,6 @@
}) })
); );
if (userInfo) { if (userInfo) {
rememberUsername(username);
// 成功后清空密码,避免浏览器后退/再进登录页时残留明文
form.password = '';
message.success(`登录成功,欢迎 ${userInfo.realname || userInfo.username}`); message.success(`登录成功,欢迎 ${userInfo.realname || userInfo.username}`);
} }
} catch (e: any) { } catch (e: any) {
@@ -342,7 +342,7 @@ export const mockReplyCategories: ReplyCategory[] = [
id: "brv10000000000000000000000000041", id: "brv10000000000000000000000000041",
replyCode: "lost.reason_ack", replyCode: "lost.reason_ack",
trigger: "销售回复未成交原因", trigger: "销售回复未成交原因",
text: "已将未成交原因“{{lost_reason}}”记录在后台,工单 {{ticket_no}}已关闭,智能体不再跟进该工单,请悉知。", text: "已记下未成交原因:{{lost_reason}}。",
enabled: true, enabled: true,
} }
], ],
@@ -23,78 +23,30 @@ body {
grid-template-columns: 220px minmax(0, 1fr) !important; grid-template-columns: 220px minmax(0, 1fr) !important;
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
/* 锁视口:避免 html overflow:hidden 裁切后整页既无文档滚、内容区又不内滚 */ overflow-x: hidden;
height: 100vh !important;
max-height: 100vh !important;
min-height: 0 !important;
overflow: hidden !important;
} }
.main-stage { .main-stage {
background: #f7f8fb !important; background: #f7f8fb !important;
min-width: 0 !important; min-width: 0 !important;
/* overflow-x: hidden;
* 中间主区 = 滚动容器(看板/非列表页)。 /* 主区锁视口高度:列表页表格区内滚;看板等非列表页在 .admin-content 内滚 */
* 实测仅改 .admin-content 在生产壳层仍会被裁切且无条;改由本列横竖自滚最稳。
*/
height: 100vh !important; height: 100vh !important;
max-height: 100vh !important; max-height: 100vh !important;
min-height: 0 !important; min-height: 0 !important;
overflow-x: auto !important; overflow: hidden !important;
overflow-y: scroll !important;
scrollbar-gutter: stable;
-webkit-overflow-scrolling: touch;
display: flex !important; display: flex !important;
flex-direction: column !important; flex-direction: column !important;
} }
.main-stage {
scrollbar-width: thin;
scrollbar-color: #cbd5e1 transparent;
}
.main-stage::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.main-stage::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 999px;
border: 1px solid transparent;
background-clip: padding-box;
}
.main-stage::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
background-clip: padding-box;
}
.main-stage::-webkit-scrollbar-track {
background: transparent;
}
.main-stage::-webkit-scrollbar-corner {
background: transparent;
}
.admin-content { .admin-content {
padding: 16px 16px 40px !important; padding: 16px 16px 40px !important;
gap: 12px !important; gap: 12px !important;
min-width: 0 !important; min-width: 0 !important;
/* 随内容增高/增宽;滚动条在 .main-stage */ overflow-x: hidden;
flex: 0 0 auto !important; flex: 1 1 auto !important;
height: auto !important;
max-height: none !important;
min-height: 0 !important; min-height: 0 !important;
overflow: visible !important; overflow: auto !important;
}
/* 列表页:主区不滚,仍由表格区内滚(工单/账号等) */
.main-stage:has(.is-list-page) {
overflow: hidden !important;
}
.main-stage:has(.is-list-page) .admin-content {
flex: 1 1 0 !important;
height: 0 !important;
min-height: 0 !important;
max-height: none !important;
overflow: hidden !important;
} }
/* /*
@@ -110,12 +62,6 @@ body {
display: flex !important; display: flex !important;
align-items: center !important; align-items: center !important;
border-bottom: 1px solid #e8edf4 !important; border-bottom: 1px solid #e8edf4 !important;
/* 中间主区滚动时顶栏吸顶,不跟着内容滚走 */
position: sticky !important;
top: 0 !important;
z-index: 40 !important;
flex-shrink: 0 !important;
background: rgba(255, 255, 255, 0.96) !important;
} }
.admin-brand { .admin-brand {
@@ -1124,18 +1070,6 @@ h6 {
.admin-content:has(.ref-surface--metrics) { .admin-content:has(.ref-surface--metrics) {
padding: 14px 14px 36px !important; padding: 14px 14px 36px !important;
gap: 10px !important; gap: 10px !important;
/* 分析看板:内容区不裁切,滚动交给 .main-stage */
overflow: visible !important;
max-height: none !important;
}
/* 看板缩窄时允许卡片/图表收缩,避免右侧被裁且无横滚 */
.admin-content .ref-surface,
.admin-content .ref-charts-section,
.admin-content .ref-metrics-grid,
.admin-content .ref-chart-panel {
min-width: 0 !important;
max-width: 100% !important;
} }
/* ---- 话术 ---- */ /* ---- 话术 ---- */
@@ -253,88 +253,9 @@ function parseQuoteFees(q: any): TicketQuoteFee[] {
pushRows(asFeeArray(payload[key] || payload[bucket])); 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; 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 是内部码,禁止当备注展示。 * 主账 inq_ticket_status_log.action 是内部码,禁止当备注展示。
* transition 与「已报价」等 toStatus 重复,无用户备注时不再跟一段英文。 * transition 与「已报价」等 toStatus 重复,无用户备注时不再跟一段英文。