Compare commits

...
Author SHA1 Message Date
linmianxunandCursor d989154485 完善空运锁舱与海运报价单单选回填,并增强模板预览与协同字段盖写。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 22:57:04 +08:00
jillion886andCursor 5402f94d30 空运锁舱按报价来源出站,航线回 2 视为不用 TMS。补陆运常用线路超时,海运举例行不再当成已选分类。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 18:28:13 +08:00
jillion886andCursor 37278aa0db 目的地只留地名,货物数量不再用箱型,未成交原因去掉艾特。
口语里逗号后面的品名和包装不再粘在目的地上;件数被抽成箱型时改用件数;群里艾特机器人不记进未成交原因。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 16:17:23 +08:00
jillion886andCursor 4dfd756bcf 销售侧汇总统一叫费用合计,并认准空运费的金额。
企微跳转改为账号密码登录,报价卡上的费用按原金额改回;航线按单价数量金额报价时不再记成海运费。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 11:15:33 +08:00
83 changed files with 6218 additions and 390 deletions
@@ -11,6 +11,7 @@ from __future__ import annotations
import html
from typing import Any
from agent.channel.h5.wecom_close import wecom_auto_close_script
from agent.schema.air_route_options import (
option_fee_lines,
option_total,
@@ -226,10 +227,11 @@ def render_air_route_page(payload: dict[str, Any]) -> str:
def render_air_route_ok() -> str:
"""选定成功。"""
"""选定成功:短暂展示后关企微 H5。"""
return _wrap(
"已选定",
"<div class='msg'><h1>已选定</h1><p>请回企微查看价格明细。</p></div>",
"<div class='msg'><h1>已选定</h1><p>正在返回企微,请查看价格明细。</p></div>",
auto_close=True,
)
@@ -272,12 +274,13 @@ def _option_card(index: int, option: dict[str, Any], quote: dict[str, Any], fact
)
def _wrap(title: str, body: str) -> str:
"""整页壳:标题、viewport、共用样式。"""
def _wrap(title: str, body: str, *, auto_close: bool = False) -> str:
"""整页壳:标题、viewport、共用样式。成功页可挂企微关窗脚本。"""
closer = wecom_auto_close_script() if auto_close else ""
return (
"<!doctype html><html lang='zh-CN'><head><meta charset='utf-8' />"
"<meta name='viewport' content='width=device-width, initial-scale=1' />"
f"<title>{html.escape(title)}</title>"
f"<style>{_PAGE_CSS}</style></head><body>"
f"{body}</body></html>"
f"{body}{closer}</body></html>"
)
@@ -56,10 +56,11 @@ def render_land_route_page(payload: dict[str, Any]) -> str:
def render_land_route_ok() -> str:
"""选定成功。"""
"""选定成功:短暂展示后关企微 H5。"""
return _wrap(
"已选定",
"<div class='msg'><h1>已选定</h1><p>请回企微查看价格明细。</p></div>",
"<div class='msg'><h1>已选定</h1><p>正在返回企微,请查看价格明细。</p></div>",
auto_close=True,
)
@@ -10,6 +10,7 @@ from __future__ import annotations
from html import escape
import json
from agent.channel.h5.wecom_close import wecom_auto_close_script
from agent.policy import inquiry_copy as copy
# 三页共用:浅底、白卡片、企微蓝主按钮。禁止在页面里写 userid。
@@ -188,7 +189,8 @@ def _shell(*, title: str, body: str, tone: str = "ok") -> str:
"""成功 / 拒绝 / 过期共用卡片壳,避免三页三种简陋白底。"""
icons = {"ok": _ICON_OK, "warn": _ICON_WARN, "expired": _ICON_EXPIRED}
icon = icons.get(tone, _ICON_WARN)
extra = '<p class="back">请回企微查看识别结果。</p>' if tone == "ok" else ""
extra = '<p class="back">正在返回企微,请查看识别结果。</p>' if tone == "ok" else ""
closer = wecom_auto_close_script() if tone == "ok" else ""
return f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
@@ -210,6 +212,7 @@ def _shell(*, title: str, body: str, tone: str = "ok") -> str:
{extra}
</div>
</div>
{closer}
</body>
</html>
"""
@@ -0,0 +1,96 @@
"""
企微内嵌 H5 成功后自动关页。
本文件职责:给附件上传成功、选线路成功页一段共用脚本。
手机端可走 WeixinJSBridge;企微 Windows 客户端没有这套桥,必须像复制工单号页一样
先加载 wecom-jssdk、用现成 /inquiry-form/jsapi-signature 做 ww.register,再 closeWindow。
失败、过期页不要挂这段。
副作用:只产出 HTML 片段;关窗签名走浏览器短请求,不占智能体请求线程做慢活。
"""
from __future__ import annotations
from html import escape
from agent.config import get_settings
# 签名完成后再稍停,让销售看见「已成功」。
_CLOSE_DELAY_MS = 400
def wecom_auto_close_script() -> str:
"""
成功页脚:加载企微 JS-SDK,register 后关掉当前 H5 窗口。
线程:浏览器端执行。签名接口与复制页共用,ticket 已缓存在 Redis。
为何这样写:上一版只调 WeixinJSBridge,电脑端企微关不掉,会停在成功页。
"""
settings = get_settings()
corp = escape((settings.wecom_corp_id or "").strip(), quote=True)
agent = escape(str(settings.wecom_agent_id or "1000010").strip(), quote=True)
delay = _CLOSE_DELAY_MS
return f"""<script src="https://wwcdn.weixin.qq.com/node/wework/open/js/wecom-jssdk-2.3.2.js"></script>
<script>
(function () {{
var corpId = "{corp}";
var agentId = Number("{agent}") || 0;
function sign(kind) {{
return function (url) {{
return fetch("/inquiry-form/jsapi-signature?kind=" + encodeURIComponent(kind)
+ "&url=" + encodeURIComponent(url))
.then(function (r) {{
if (!r.ok) throw new Error("sign");
return r.json();
}});
}};
}}
function nativeClose() {{
try {{
if (typeof WeixinJSBridge !== "undefined") {{
WeixinJSBridge.call("closeWindow");
}}
}} catch (e) {{}}
try {{
if (window.ww && typeof ww.closeWindow === "function") {{
ww.closeWindow();
}}
}} catch (e) {{}}
try {{
if (window.wx && typeof wx.closeWindow === "function") {{
wx.closeWindow();
}}
}} catch (e) {{}}
try {{ window.close(); }} catch (e) {{}}
}}
function boot() {{
if (typeof WeixinJSBridge !== "undefined") {{
setTimeout(nativeClose, {delay});
}}
document.addEventListener("WeixinJSBridgeReady", nativeClose, false);
var ready = Promise.resolve();
if (window.ww && ww.register && corpId) {{
ready = ww.register({{
corpId: corpId,
agentId: agentId,
jsApiList: ["closeWindow"],
getConfigSignature: sign("corp"),
getAgentConfigSignature: sign("agent")
}}).catch(function () {{ return null; }});
}}
ready.then(function () {{
setTimeout(nativeClose, {delay});
}}).catch(function () {{
setTimeout(nativeClose, {delay});
}});
}}
if (document.readyState === "loading") {{
document.addEventListener("DOMContentLoaded", boot);
}} else {{
boot();
}}
}})();
</script>"""
@@ -136,6 +136,14 @@ def handle_air_group(
if handed:
return handed
intent = classify_air_group_text(text, injected=injected_intent)
# 注入/模型若仍判成激活,同一句里的成交跟进不能丢掉。
if wo and intent == INTENT_ACTIVATE:
from agent.routing.group_intent import _hint_deal_outcome
hinted = _hint_deal_outcome(text)
if hinted:
logger.info("air_group 工单号+成交跟进同一句,按 %s 办 wo=%s", hinted, wo)
intent = hinted
logger.info(
"air_group 文字 chat=%s sender=%s intent=%s wo=%s",
chat_id,
@@ -261,12 +269,15 @@ def _handle_cabin(
chat_id=chat_id,
instruction=message.content or "",
)
# 「WO…锁舱,锁AIR-OPT01 TH」要带上句中的舱位和航司,不能只靠报价里碰巧只有一条。
return apply_air_lock(
flow=engine,
ticket=ticket,
sender_id=message.sender_id,
chat_id=chat_id,
instruction=message.content or "",
option_no=_option_from_text(message.content or ""),
airline=_airline_from_text(message.content or ""),
)
@@ -300,6 +311,7 @@ def _handle_bound(
chat_id=chat_id,
instruction=message.content or "",
option_no=option,
airline=_airline_from_text(message.content or ""),
)
if intent in {INTENT_DEAL, INTENT_LOST, INTENT_NEGOTIATE}:
outcome = {
@@ -363,14 +375,25 @@ def _handle_bound(
def _option_from_text(text: str) -> str:
blob = (text or "").replace(" ", "").upper()
if "AIR-OPT" in blob or "AIROPT" in blob:
start = blob.find("AIR")
token = []
for ch in blob[start:]:
if ch.isalnum() or ch == "-":
token.append(ch)
else:
break
return "".join(token)
"""从「锁AIR-OPT01 TH」抽出舱位编号;不要把后面的航司码拼进 optionNo。"""
import re
m = re.search(r"AIR-?OPT-?\d+", (text or "").upper())
if not m:
return ""
raw = m.group(0)
# 统一成 TMS 常见写法 AIR-OPT01(中间可保留已有的第二根横杠)
if raw.startswith("AIROPT"):
return "AIR-OPT" + raw[6:]
return raw
def _airline_from_text(text: str) -> str:
"""从「AIR-OPT01 TH」抽出两位航司码;没有就不猜。"""
import re
blob = (text or "").upper()
m = re.search(r"AIR-?OPT-?\d+\s*([A-Z]{2})(?![A-Z0-9])", blob)
if m:
return m.group(1)
return ""
+11 -7
View File
@@ -402,16 +402,20 @@ class HttpLedger:
sender_id: str = "",
operator_name: str = "",
instruction_text: str = "",
airline: str = "",
) -> dict[str, Any]:
body: dict[str, Any] = {
"workOrderNo": work_order_no,
"optionNo": option_no,
"operatorUserId": sender_id,
"operatorName": operator_name,
"instructionText": instruction_text,
}
if (airline or "").strip():
body["airline"] = airline.strip().upper()
return self._post(
"/inquiry/agent/tms/lockCabin",
{
"workOrderNo": work_order_no,
"optionNo": option_no,
"operatorUserId": sender_id,
"operatorName": operator_name,
"instructionText": instruction_text,
},
body,
idem=f"air-lock:{work_order_no}:{option_no}",
)
+26 -2
View File
@@ -21,6 +21,20 @@ logger = logging.getLogger(__name__)
_SHANGHAI = ZoneInfo("Asia/Shanghai")
def _memory_quote_can_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():
return bool(str(src.get("quoteId") or "").strip()) and not str(src.get("quoteId") or "").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
quote_id = str(src.get("quoteId") or "").strip()
return bool(quote_id) and not quote_id.upper().startswith("QT-WO")
@dataclass
class MemoryTicket:
"""内存工单。"""
@@ -809,20 +823,30 @@ class MemoryLedger:
sender_id: str = "",
operator_name: str = "",
instruction_text: str = "",
airline: str = "",
) -> dict[str, Any]:
"""单测可注入 _cabin_lock_result;默认成功。"""
_ = sender_id, operator_name, instruction_text
_ = sender_id, operator_name, instruction_text, airline
forced = getattr(self, "_cabin_lock_result", None)
if isinstance(forced, dict):
return dict(forced)
no = (work_order_no or "").strip()
ticket = self.get_ticket(work_order_no=no)
quote = dict(ticket.quote or {}) if ticket else {}
if not _memory_quote_can_lock(quote):
return {
"ok": False,
"error": "no_quote",
"failReason": "这张单还没有报价,不能锁舱",
}
quote_id = str(quote.get("quoteId") or "").strip()
if quote_id.upper().startswith("QT-WO"):
quote_id = ""
return {
"ok": True,
"status": "LOCKED",
"lockId": f"CA-{no}-LOCK",
"quoteId": str(quote.get("quoteId") or f"QT-{no}"),
"quoteId": quote_id,
"optionNo": option_no,
"tmsRequestId": f"TMS-LOCK-{no}",
}
+44 -10
View File
@@ -671,6 +671,18 @@ _RE_FEE_CHANGE = re.compile(
re.I,
)
_SKIP_FEE_NAMES = frozenset({"usd", "cny", "rmb", "美金", "美元", "人民币", "元"})
# 航线常写「空运费 单价CNY99,数量100,金额CNY199」。单价和金额不是同一个数,必须认金额。
_RE_FEE_PRICED = re.compile(
r"(?P<name>[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费)"
r"\s*单价\s*"
r"(?:(?P<unit_ccy>USD|CNY|RMB|美金|美元|人民币)\s*)?"
r"(?P<unit>\d+(?:\.\d+)?)"
r"(?:\s*[,,、]\s*数量\s*(?P<qty>\d+(?:\.\d+)?))?"
r"(?:\s*[,,、]\s*金额\s*"
r"(?:(?P<amt_ccy>USD|CNY|RMB|美金|美元|人民币)\s*)?"
r"(?P<amount>\d+(?:\.\d+)?))?",
re.I,
)
_FULLWIDTH_NUM = str.maketrans("0123456789.", "0123456789.")
@@ -698,7 +710,7 @@ def _parse_fee_rows(text: str) -> list[dict[str, str]]:
rows: list[dict[str, str]] = []
seen: set[str] = set()
def _add(name: str, amount: str, ccy: str) -> None:
def _add(name: str, amount: str, ccy: str, **extra: str) -> None:
if not name or not amount or name.lower() in _SKIP_FEE_NAMES:
return
# 「WO202609160001」会被 [A-Za-z]{2,10}+数字 抽成费用名 WO,群里发工单号不能当报价。
@@ -707,14 +719,27 @@ def _parse_fee_rows(text: str) -> list[dict[str, str]]:
if name in seen:
return
seen.add(name)
rows.append(
{
"name": name,
"amount": amount,
"currency": _currency_of(raw, ccy),
}
)
row = {
"name": name,
"amount": amount,
"currency": _currency_of(raw, ccy),
}
for key, val in extra.items():
if val:
row[key] = val
rows.append(row)
for hit in _RE_FEE_PRICED.finditer(raw):
# 有「金额」用金额;只有单价时才用单价。禁止把单价误当成这一行的总金额。
amount = (hit.group("amount") or "").strip() or (hit.group("unit") or "").strip()
ccy = hit.group("amt_ccy") or hit.group("unit_ccy") or ""
_add(
(hit.group("name") or "").strip(),
_ascii_amount(amount),
ccy,
unit_price=_ascii_amount(hit.group("unit") or ""),
quantity=_ascii_amount(hit.group("qty") or ""),
)
for hit in _RE_FEE_LINE.finditer(raw):
_add(
(hit.group("name") or "").strip(),
@@ -736,6 +761,7 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
认不出价格返回空 dict,调用方回「没有识别到报价」。
群里常见「码头操作费100CNY / 报关费改为300CNY」,不能只把第一笔数当成海运费。
「空运费 单价…金额…」必须保留空运费,金额用「金额」后面的数,不用单价。
"""
if isinstance(injected, dict) and (injected.get("total") or injected.get("fee_rows")):
out = dict(injected)
@@ -772,11 +798,19 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
if not amount:
return {}
ccy = _currency_of(raw)
# 句里已经点了费用名(空运费)时,不能退回默认海运费。
named = re.search(
r"([\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费)",
raw,
)
fee_name = (named.group(1) if named else "") or "海运费"
if fee_name in {"费用"}:
fee_name = "海运费"
return {
"total": amount,
"currency": ccy,
"source_label": "产品报价",
"raw_text": raw,
"fee_rows": [{"name": "海运费", "amount": amount, "currency": ccy}],
"fee_lines": [{"name": "海运费", "amount": amount, "currency": ccy}],
"fee_rows": [{"name": fee_name, "amount": amount, "currency": ccy}],
"fee_lines": [{"name": fee_name, "amount": amount, "currency": ccy}],
}
@@ -114,7 +114,7 @@ def build_adjust_messages(text: str, fee_names: list[str]) -> list[dict[str, str
"必须调用 extract_quote_adjust。不要对销售说话,不要改价。"
"口令不写死:上升、上浮、往上抬、加一成、涨一点、改到某金额、在现有报价基础上调整都算调价。"
"没点名费用、只说报价:is_adjust=true,wants_total=false,items 为空。"
"只动总销售价/总成本价/合计/总价/汇总价:wants_total=true,items 仍要一条(fee_name 可空),带 kind/direction/value。"
"只动费用合计(销售也可能说总销售价、总成本价、合计、总价、汇总价):wants_total=true,items 仍要一条(fee_name 可空),带 kind/direction/value。"
"kind 只能 percent 或 target;direction 只能 up/down/set。"
"value 必须来自原话数字;一成=10。禁止编造费用名和金额。"
"不是调价(问候、新询价、谢谢)时 is_adjust=false。"
+141 -6
View File
@@ -226,6 +226,8 @@ def send_air_brief_and_sheet(
空运群再发一版摘要 + 匹配到的空运模板。对齐海运 send_brief_and_sheet。
必须先发出工单信息,模板等摘要发出后再发,禁止后台线程抢先发文件。
填表报价必须带上 TMS 总销售价:群票上的 latest quote 常只有增点费/拆板费明细,
若直接拿去出单,预估费用会落成明细加总(如 200)而不是 salePrice(如 7918.25)。
"""
ticket = hydrate_ticket(flow, ticket)
view = ticket_view(ticket)
@@ -247,6 +249,8 @@ def send_air_brief_and_sheet(
if not manual and copy.quote_is_product(override or view_q):
manual = override or view_q
quote = dict(override or view_q or tms or manual)
# 出单专用:把 TMS salePrice 盖回薄报价,避免预估费用=明细 SUM。
sheet_quote = _sheet_quote_with_tms_sale_total(quote, tms=tms, facts=facts)
sales_name, airline_names, mention_ids = resolve_air_mentions(flow, view)
send_group_text(
group_client_of(flow),
@@ -264,10 +268,133 @@ def send_air_brief_and_sheet(
),
mention_userids=mention_ids,
)
_send_air_activate_sheet(flow=flow, ticket=view, chat_id=room, quote=quote)
_send_air_activate_sheet(flow=flow, ticket=view, chat_id=room, quote=sheet_quote)
return "brief_and_sheet"
def _air_usable_sale_total(quote: Optional[dict[str, Any]]) -> str:
"""
空运出单可用的总销售价:顶层 total / salePrice,或选定/首条线路 salePrice。
「-」与空串不算。
"""
src = dict(quote or {})
for key in ("total", "total_amount", "salePrice", "totalSalePrice"):
val = str(src.get(key) or "").strip()
if val and val != "-":
return val
selected = src.get("selected_air_option") or src.get("selectedAirOption")
if isinstance(selected, dict):
val = str(selected.get("salePrice") or selected.get("sale_price") or "").strip()
if val and val != "-":
return val
raw_opts = src.get("airOptions") or src.get("air_options") or []
if isinstance(raw_opts, list) and raw_opts and isinstance(raw_opts[0], dict):
val = str(raw_opts[0].get("salePrice") or raw_opts[0].get("sale_price") or "").strip()
if val and val != "-":
return val
return ""
def _money_plain_number(text: str) -> Optional[float]:
"""从「CNY 7918.25」类文本抽出数字;解不开返回 None。"""
raw = str(text or "").strip()
if not raw or raw == "-":
return None
digits = "".join(ch if (ch.isdigit() or ch in ".-") else " " for ch in raw).split()
if not digits:
return None
try:
return float(digits[-1].replace(",", ""))
except ValueError:
return None
def _total_matches_fee_row_sum(quote: dict[str, Any], total_text: str) -> bool:
"""
顶层 total 是否只是明细金额相加(如增点费100+拆板费100=200)。
是则说明缺了 TMS 总销售价,不能当真预估费用。
"""
total_num = _money_plain_number(total_text)
if total_num is None:
return False
rows = quote.get("fee_rows") or quote.get("fee_lines") or quote.get("feeItems") or []
if isinstance(rows, str) and rows.strip():
try:
rows = json.loads(rows)
except json.JSONDecodeError:
rows = []
if not isinstance(rows, list) or not rows:
return False
summed = 0.0
hit = 0
for item in rows:
if not isinstance(item, dict):
continue
amt = _money_plain_number(
str(
item.get("amount")
or item.get("value")
or item.get("salePrice")
or item.get("price")
or ""
)
)
if amt is None:
continue
summed += amt
hit += 1
if hit <= 0:
return False
return abs(total_num - summed) < 0.01
def _sheet_quote_with_tms_sale_total(
quote: dict[str, Any],
*,
tms: Optional[dict[str, Any]] = None,
facts: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
"""
群填表用的报价包:保留产品/航线改过的明细,但预估费用必须能落到 TMS 总销售价。
协同群 latest quote 常只有 departureCharges 投影出的附加费行,total 空或被加成 200;
销售 1V1 会话里带了 salePrice,所以同一工单两边会不一致。这里出单前对齐。
"""
from agent.schema.air_route_options import project_group_air_quote
out = dict(quote or {})
source = dict(tms or {}) or dict(out)
if not source:
return out
projected = project_group_air_quote(source, dict(facts or {}))
tms_total = _air_usable_sale_total(projected)
cur_total = _air_usable_sale_total(out)
# 补齐线路对象 / 计费重,便于主账从 airOptions.salePrice 再兜底一次。
for key in (
"airOptions",
"air_options",
"selected_air_option",
"selectedAirOption",
"chargeableWeightKg",
"chargeable_weight_kg",
"currency",
"valid_until",
):
cur = out.get(key)
empty = cur in (None, "", "-", [], {})
proj_val = projected.get(key)
if empty and proj_val not in (None, "", "-", [], {}):
out[key] = proj_val
if not tms_total:
return out
need_tms_total = (not cur_total) or cur_total == "-" or _total_matches_fee_row_sum(out, cur_total)
if need_tms_total:
out["total"] = str(projected.get("total") or tms_total).strip()
if projected.get("currency") and not str(out.get("currency") or "").strip():
out["currency"] = projected.get("currency")
return out
def resolve_air_mentions(flow: Any, ticket: object) -> tuple[str, list[str], list[str]]:
"""
空运群摘要必须 @ 到本单销售和航线·空运,对上海运 @ 销售/@ 产品。
@@ -581,16 +708,21 @@ def apply_air_confirm_tms(*, flow: Any, ticket: object, sender_id: str, chat_id:
quote["source_label"] = "航线确认TMS"
no = ticket_no(ticket)
_clear_air_pending(flow, no, sender_id)
saved = flow.ledger.upsert_product_quote(work_order_no=no, quote=quote, to_status="已报价") or {}
# 确认 TMS 出单:同样用总销售价,避免只落附加费明细时预估费用=200。
sheet_quote = _sheet_quote_with_tms_sale_total(
quote, tms=quote, facts=dict(view.get("facts") or {})
)
saved = flow.ledger.upsert_product_quote(work_order_no=no, quote=sheet_quote, to_status="已报价") or {}
if not saved.get("ok"):
send_group_text(group_client_of(flow), chat_id, "主账写入报价失败。")
return "ledger_fail"
view["collab_chat_id"] = chat_id
view["quote"] = sheet_quote
_persist_quote_sheet(
flow=flow,
ticket=view,
sess=None,
quote=quote,
quote=sheet_quote,
sender_id=sender_id,
business_line="AIR",
)
@@ -658,6 +790,7 @@ def apply_air_lock(
chat_id: str,
instruction: str,
option_no: str = "",
airline: str = "",
) -> str:
if not is_airline(flow, sender_id):
send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin())
@@ -680,10 +813,8 @@ def apply_air_lock(
)
send_group_text(group_client_of(flow), chat_id, copy.air_ask_lock_option(options))
return "wait_lock_option"
if not picked and options:
if not picked and len(options) == 1:
picked = str(options[0].get("optionNo") or options[0].get("option_no") or "").strip()
if not picked:
picked = "AIR-OPT-01"
locker = getattr(flow.ledger, "lock_cabin", None)
if not callable(locker):
send_group_text(
@@ -707,11 +838,15 @@ def apply_air_lock(
sender_id=sender_id,
operator_name=name,
instruction_text=instruction,
airline=(airline or "").strip(),
)
or {},
is_ok=lambda x: bool((x or {}).get("ok")),
)
if not out.get("ok"):
if str(out.get("error") or "") == "no_quote":
send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_quote(no))
return "no_quote"
why = copy.air_cabin_fail_reason(
tms_reason=str(out.get("failReason") or out.get("error") or ""),
flight_date=_ticket_flight_date(ticket),
+1 -1
View File
@@ -1709,7 +1709,7 @@ class AirTextInquiryFlow:
sess.phase = "done"
sess.allowed = ()
self._save(sess)
reply(copy.lost_reason_ack(text))
reply(copy.lost_reason_ack(str(result.get("lost_reason") or text)))
return "done"
def mark_wait_deal(self, sender_id: str, work_order_no: str) -> None:
+17 -3
View File
@@ -8,6 +8,7 @@
from __future__ import annotations
import logging
import re
from typing import Any, Callable, Optional
from agent.config import get_settings
@@ -22,6 +23,14 @@ EnqueueFn = Callable[..., tuple[bool, str]]
_LOST_MARKERS = ("未成交", "没成交", "没有成交", "没做成", "黄了")
_QUESTION_TAILS = frozenset({"吗", "呢", "吧", "啊", "呀", "嘛", "?", "?"})
_KEEP_REASON_AS_IS = frozenset({"原因不明", "原因不详", "理由不明", "理由不详"})
# 群里 @询价小助手 是用来叫醒机器人的,不是未成交原因。
_MENTION = re.compile(r"@[^\s,,。.;;::、@]+")
def strip_bot_mentions(text: str) -> str:
"""去掉 @某人。原因前后都可能带,去掉后不留空标点。"""
cleaned = _MENTION.sub("", text or "")
return cleaned.strip(",,。.;;::、 ")
def harvest_inline_lost_reason(text: str) -> str:
@@ -56,7 +65,10 @@ def harvest_inline_lost_reason(text: str) -> str:
break
if rest in _QUESTION_TAILS:
return ""
return rest.strip()
rest = strip_bot_mentions(rest)
if rest in _QUESTION_TAILS:
return ""
return rest
def looks_like_lost_outcome(text: str) -> bool:
@@ -211,8 +223,8 @@ def finish_deal_outcome(
def capture_lost_reason(*, ledger: Any, work_order_no: str, reason: str) -> dict[str, Any]:
"""把销售下一句写进未成交原因。"""
text = (reason or "").strip()
"""把销售下一句写进未成交原因。@询价小助手 不是原因。"""
text = strip_bot_mentions(reason or "")
no = (work_order_no or "").strip()
if not no or not text:
return {"ok": False, "error": "blank"}
@@ -220,6 +232,8 @@ def capture_lost_reason(*, ledger: Any, work_order_no: str, reason: str) -> dict
if not callable(patcher):
return {"ok": False, "error": "no_patch"}
out = patcher(work_order_no=no, lost_reason=text) or {"ok": False}
if isinstance(out, dict):
out.setdefault("lost_reason", text)
if out.get("ok"):
from agent.policy.event_exception import clear_wait
@@ -36,6 +36,7 @@ _SALES_CODES = {
}
_LAND_PRODUCT_CODES = {
"普件(常用线路/常规车型)": "land_common",
"普件(东南亚、中港常用线路/常规车型)": "land_hk_common",
"普件(东南亚、中亚、中欧涉及国内+国外段)": "land_hk_cross",
"普件(东南亚、中亚、中欧涉及境外段清派等特殊要求)": "land_hk_special",
+33 -23
View File
@@ -856,7 +856,7 @@ def _total_label(
business_line: str = "",
transport_mode: str = "",
) -> str:
"""空运总销售价;海运/陆运看这版报价是成本还是销售调价。"""
"""销售侧汇总行。企微卡片、文字明细、成交通知都叫费用合计。"""
src = dict(quote or {})
return total_price_label(
business_line or str(src.get("business_line") or ""),
@@ -2239,13 +2239,14 @@ def adjust_only_sales() -> str:
def adjust_group_body(quote: dict[str, Any] | None, business_line: str = "") -> str:
"""群里调后全量明细,利润点不写给销售。"""
"""
群里调后全量明细,利润点不写给销售。
海运必须带三段标题与小计(始发港/海运/目的港),禁止只摊平 fee_lines 把小计弄丢。
"""
src = dict(quote or {})
lines = [f"{name}:{amount}" for name, amount in quote_fee_lines(src)]
header = str(src.get("currency") or "")
total = money_text(str(src.get("total") or "-").strip() or "-", header)
lines.append(f"{_total_label(src, business_line)}:{total}")
return "调价后价格:\n" + "\n".join(lines)
text = quote_area_fee_text(src, business_line=business_line)
return "调价后价格:\n" + text
def adopt_excel_ack() -> str:
@@ -2282,7 +2283,7 @@ def deal_horizontal_rows(
transport_mode: str = "",
) -> list[dict[str, str]]:
"""
成交跟进卡字段:总销售价、报价有效期;海运/陆运再加时效。
成交跟进卡字段:费用合计、报价有效期;海运/陆运再加时效。
空运业务确认没有时效,这里不写。
不要历史报价 / 工单状态 / 来源。来源只留卡片顶部红字。
@@ -2339,7 +2340,7 @@ def deal_wecom_payload(
成交跟进卡:对齐现网 button_interaction。
顶部来源红字;标题询价工单号;副标题跟进成交;三个按钮。
字段:总销售价、报价有效期;海运/陆运再加时效。空运不写时效。
字段:费用合计、报价有效期;海运/陆运再加时效。空运不写时效。
"""
src = dict(quote or {})
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
@@ -2593,6 +2594,19 @@ def deal_notify_card(
return "\n".join(lines)
def site_card_url() -> str:
"""
整张卡片点击后打开的后台地址。
事件异常、系统异常、成交通知都走这里,点开是同一个网站。
不用企业微信授权页,也不要用 work.weixin.qq.com。
"""
from agent.config import get_settings
base = str(getattr(get_settings(), "public_base_url", "") or "").strip().rstrip("/")
return base or "https://ai.ytd-scm.com"
def deal_notify_wecom_payload(
*,
work_order_no: str,
@@ -2615,10 +2629,7 @@ def deal_notify_wecom_payload(
quote=dict(quote or {}),
transport_mode=mode,
)
from agent.config import get_settings
base = str(getattr(get_settings(), "public_base_url", "") or "").strip().rstrip("/")
action_url = base or "https://work.weixin.qq.com"
action_url = site_card_url()
return {
"msgtype": "template_card",
"wecom_app": "notify",
@@ -2801,8 +2812,13 @@ def air_fields_denied() -> str:
def air_no_tms_quote_to_lock(work_order_no: str) -> str:
return air_lock_need_quote(work_order_no)
def air_lock_need_quote(work_order_no: str) -> str:
"""没有任何报价时的锁舱拒绝。不提 TMS,避免把人工报价说成没报价。"""
no = (work_order_no or "").strip()
return f"工单{no}还没有可锁的 TMS 报价,不能锁舱。"
return f"工单{no}还没有报价,不能锁舱。"
def air_ask_lock_option(options: list) -> str:
@@ -2847,7 +2863,7 @@ def air_lock_fail(*, work_order_no: str, operator: str, reason: str) -> str:
def air_cabin_fail_reason(*, tms_reason: str = "", flight_date: str = "", today: str = "") -> str:
"""
锁舱/释放失败原因:原样回 TMS 的 failReason。
没有报价也可以锁舱,禁止改写成「报价选项不存在」或「航班已起飞」。
禁止改写成「报价选项不存在」或「航班已起飞」。
flight_date / today 只为兼容旧调用,不再用来改写原文。
"""
_ = flight_date
@@ -2991,10 +3007,7 @@ def system_exception_alert_payload(
{"keyname": "影响范围", "value": system_exception_impact(step)},
{"keyname": "发生时间", "value": (occurred_at or "").strip() or "-"},
]
from agent.config import get_settings
base = str(getattr(get_settings(), "public_base_url", "") or "").strip().rstrip("/")
action_url = base or "https://work.weixin.qq.com"
action_url = site_card_url()
return {
"msgtype": "template_card",
"wecom_app": "notify",
@@ -3090,10 +3103,7 @@ def event_exception_alert_payload(
no = (work_order_no or "").strip() or "-"
title = (card_title or "").strip() or "事件响应异常"
hour_text = format_timeout_hours(hours)
from agent.config import get_settings
base = str(getattr(get_settings(), "public_base_url", "") or "").strip().rstrip("/")
action_url = base or "https://work.weixin.qq.com"
action_url = site_card_url()
return {
"msgtype": "template_card",
"wecom_app": "notify",
+12 -4
View File
@@ -80,17 +80,25 @@ def split_segments(text: str) -> SplitOutcome:
"""
按运输方式出现的先后切段。
「段1:海运」和同一句里的「陆运+海运」都认。同一方式出现两次则 same_mode,不建单。
已经标了两段以上时,只按「段N:」切,表里重复的「海运/陆运」不当新段。
「段1:海运」和同一句里的「陆运+海运」都认。
写了「段N:」就只按段标切:一份海运报价单文件名会先标「段1:海运」,
表里再写「运输方式:海运」仍是单段,不能报同一方式两段。
没写段标时,报价单反复出现「海运」也当单段;只有「陆运+海运」才切多段。
真的两段都是海运(段1:海运 + 段2:海运)才 same_mode,不建单。
每段正文只收「字段名:值」,不猜没写标签的港口。
"""
raw = text or ""
explicit = list(_EXPLICIT_SEG.finditer(raw))
hits = explicit if len(explicit) >= 2 else list(_MODE_RE.finditer(raw))
# explicit_only_segment_split:有段标就只认段标。一份附件常被标「段1:海运」,表里还会再写一次海运。
hits = explicit if explicit else list(_MODE_RE.finditer(raw))
if len(hits) < 2:
return SplitOutcome()
modes = [_MODE_BY_WORD[hit.group(2)] for hit in hits]
if len(modes) != len(set(modes)):
unique = set(modes)
if len(modes) != len(unique):
# 没标出两段时,同一种方式写两次仍是单段(海运报价单标题+运输方式)。
if len(explicit) < 2 and len(unique) == 1:
return SplitOutcome()
return SplitOutcome(same_mode=True)
segments: list[Segment] = []
for i, hit in enumerate(hits):
@@ -12,6 +12,7 @@ from typing import Any, Optional
from agent.policy import inquiry_copy as copy
from agent.schema.quote_adjust import (
adjustable_rows,
apply_adjust,
extract_adjust,
private_session_can_adjust,
@@ -151,7 +152,7 @@ def try_private_adjust(
"""
if not session_quoted(sess):
return None
names = [str(r.get("name") or "") for r in quote_fee_rows(getattr(sess, "quote", None))]
names = [str(r.get("name") or "") for r in adjustable_rows(getattr(sess, "quote", None))]
extract = extract_adjust(text, fee_names=names, injected=injected_adjust)
if not extract.is_adjust:
return None
@@ -262,7 +263,7 @@ def apply_group_adjust(
send_group_text(client, room, copy.adjust_only_sales())
return "adjust_not_sales"
current = _current_quote(view, sess)
names = [str(r.get("name") or "") for r in quote_fee_rows(current)]
names = [str(r.get("name") or "") for r in adjustable_rows(current)]
extract = extract_adjust(text, fee_names=names, injected=injected_adjust)
if not extract.is_adjust:
return "not_adjust"
+130 -6
View File
@@ -3,11 +3,16 @@
主账填表只认英文码,货好时间 / 海关编码 / 含油电磁常空着。
本文件只改 Excel 字节,不打主账、不占回调线程。
注意:openpyxl 保存会弄坏海运顶部 Form Control(drawing/VML/sheet1 锚点)。
因此只用来「读」标签位置;真正写入走 zip 内定点改格,不动控件骨架。
无待写字段、或目标格已是目标值时原样返回。
"""
from __future__ import annotations
import logging
import re
from pathlib import Path
from typing import Any, Mapping
@@ -30,6 +35,18 @@ _COLLAB_LABEL_MARKERS: tuple[tuple[str, tuple[str, ...]], ...] = (
("包装方式", ("包装方式", "包装类型")),
)
# 旧路径兜底:若仍误走 openpyxl 保存,至少盖回这些部件
_PRESERVE_ZIP_PARTS = (
"xl/drawings/drawing1.xml",
"xl/drawings/vmlDrawing1.vml",
"xl/styles.xml",
"xl/ctrlProps/ctrlProp1.xml",
"xl/ctrlProps/ctrlProp2.xml",
"xl/worksheets/sheet1.xml",
"xl/worksheets/_rels/sheet1.xml.rels",
"[Content_Types].xml",
)
def _fact_for_markers(facts: Mapping[str, Any], keys: tuple[str, ...]) -> str:
for key in keys:
@@ -39,6 +56,102 @@ def _fact_for_markers(facts: Mapping[str, Any], keys: tuple[str, ...]) -> str:
return ""
def _restore_sea_draw_parts(original: bytes, rewritten: bytes) -> bytes:
"""把原件里的 drawing/VML/ctrl/styles/sheet1 控件骨架盖回 openpyxl 重写后的 xlsx。"""
import io
import zipfile
try:
with zipfile.ZipFile(io.BytesIO(original)) as zin:
keep = {
name: zin.read(name)
for name in _PRESERVE_ZIP_PARTS
if name in zin.namelist()
}
if not keep:
return rewritten
out = io.BytesIO()
with zipfile.ZipFile(io.BytesIO(rewritten)) as zin, zipfile.ZipFile(
out, "w", compression=zipfile.ZIP_DEFLATED
) as zout:
for info in zin.infolist():
data = keep.get(info.filename) or zin.read(info.filename)
zout.writestr(info, data)
for name, data in keep.items():
if name not in zin.namelist():
zout.writestr(name, data)
return out.getvalue()
except Exception:
logger.exception("overlay 盖回海运 drawing 失败,退回 openpyxl 结果")
return rewritten
def _col_letter(col: int) -> str:
"""1-based 列号 → Excel 列字母。"""
n = int(col)
letters: list[str] = []
while n > 0:
n, rem = divmod(n - 1, 26)
letters.append(chr(65 + rem))
return "".join(reversed(letters)) or "A"
def _xml_escape(text: str) -> str:
return (
str(text)
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)
def _patch_cells_keep_structure(data: bytes, cells: list[tuple[int, int, str]]) -> bytes:
"""
只改 sheet1 里指定格的字符串,保留 AlternateContent / 控件关系。
禁止整表经 openpyxl 重存:否则拉群协同 Excel 顶部 Port/其它单选会空白,
而「直接生成」不经本函数所以看起来正常(WO031 vs WO033)。
"""
import io
import zipfile
if not cells:
return data
with zipfile.ZipFile(io.BytesIO(data)) as zin:
if "xl/worksheets/sheet1.xml" not in zin.namelist():
return data
sheet = zin.read("xl/worksheets/sheet1.xml").decode("utf-8", "replace")
others = {name: zin.read(name) for name in zin.namelist() if name != "xl/worksheets/sheet1.xml"}
for row, col, val in cells:
ref = f"{_col_letter(col)}{int(row)}"
cell_xml = (
f'<c r="{ref}" t="inlineStr"><is><t>{_xml_escape(val)}</t></is></c>'
)
pat = re.compile(rf'<c\b[^>]*\br="{re.escape(ref)}"[^>]*(?:/>|>[\s\S]*?</c>)')
if pat.search(sheet):
sheet = pat.sub(cell_xml, sheet, count=1)
continue
row_pat = re.compile(rf'(<row\b[^>]*\br="{int(row)}"[^>]*>)([\s\S]*?)(</row>)')
m = row_pat.search(sheet)
if m:
sheet = sheet[: m.start(3)] + cell_xml + sheet[m.start(3) :]
continue
sheet = sheet.replace(
"</sheetData>",
f'<row r="{int(row)}">{cell_xml}</row></sheetData>',
1,
)
out = io.BytesIO()
with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zout:
for name, blob in others.items():
zout.writestr(name, blob)
zout.writestr("xl/worksheets/sheet1.xml", sheet.encode("utf-8"))
return out.getvalue()
def overlay_labeled_facts(
*,
facts: Mapping[str, Any],
@@ -48,7 +161,8 @@ def overlay_labeled_facts(
"""
按模板标签再写协同格。失败则原样交回。
副作用:可能写临时 xlsx。
副作用:可能写临时 xlsx。无待写字段、或格子已是目标值时不改字节,
避免毁掉海运顶部运输分类单选。
"""
import base64
import tempfile
@@ -91,20 +205,30 @@ def overlay_labeled_facts(
if not val:
continue
dest = _label_value_cell(ws, cell)
# 主账已填过同值(如贸易条款):再写会经 openpyxl 毁掉单选
if str(dest.value or "").strip() == val:
used_rows.add(cell.row)
break
pending.append((dest.row, dest.column, val))
used_rows.add(cell.row)
break
for r, c, val in pending:
ws.cell(row=r, column=c, value=val)
written += 1
wb.save(tmp)
wb.close()
if not pending:
# 无增量可写:原样返回,禁止重存
logger.info("overlay 协同字段 written=0 keep_original")
return {
"filepath": filepath or str(tmp),
"file_b64": file_b64 or base64.b64encode(raw).decode("ascii"),
}
patched = _patch_cells_keep_structure(raw, pending)
tmp.write_bytes(patched)
written = len(pending)
except Exception:
logger.exception("overlay 协同字段失败")
return {"filepath": filepath, "file_b64": file_b64}
out_b64 = base64.b64encode(tmp.read_bytes()).decode("ascii")
logger.info("overlay 协同字段 written=%s", written)
logger.info("overlay 协同字段 written=%s keep_structure=1", written)
return {"filepath": str(tmp), "file_b64": out_b64}
+92 -8
View File
@@ -401,17 +401,21 @@ def hydrate_ticket(flow: Any, ticket: object) -> object:
)
)
quote = dict(view.get("quote") or {})
has_quote = bool(quote.get("total") or _quote_fee_rows(quote))
# total="-" 在 Python 里也是 truthy,不能当成已有总价;只有附加费明细也不算完整。
has_sale_total = _quote_has_usable_sale_total(quote)
has_fee_rows = bool(_quote_fee_rows(quote))
has_quote = has_sale_total or has_fee_rows
has_status = bool(ticket_status(ticket))
has_tms = bool(dict(view.get("tms_quote") or view.get("tmsQuote") or {}))
# 调价后 latest quote 仍有价,但不能因此跳过补 TMS 原价。
if has_facts and has_quote and has_status and has_tms:
return ticket
# 仅有 fee_rows、没有可用总价时仍要往下补全,否则空运出单预估费用会落成明细 SUM。
if has_facts and has_sale_total and has_status and has_tms:
return _soft_fill_quote_sale_fields(ticket)
no = ticket_no(ticket)
sales = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip()
book = getattr(flow, "ledger", None)
if book is None or not no:
return ticket
return _soft_fill_quote_sale_fields(ticket)
full: Any = None
getter = getattr(book, "get_for_agent", None)
if callable(getter) and sales:
@@ -423,13 +427,33 @@ def hydrate_ticket(flow: Any, ticket: object) -> object:
if callable(getter):
full = getter(work_order_no=no)
if full is None:
return ticket
return _soft_fill_quote_sale_fields(ticket)
merged = dict(view)
full_view = ticket_view(full)
if (not has_facts) and full_view.get("facts"):
merged["facts"] = dict(full_view.get("facts") or {})
if (not has_quote) and full_view.get("quote"):
merged["quote"] = dict(full_view.get("quote") or {})
if (not has_sale_total) and full_view.get("quote"):
# 薄报价保留已有明细,补上完整快照里的总价/线路。
rich = dict(full_view.get("quote") or {})
cur = dict(quote)
for key in (
"total",
"salePrice",
"totalSalePrice",
"airOptions",
"air_options",
"selected_air_option",
"selectedAirOption",
"chargeableWeightKg",
"chargeable_weight_kg",
"currency",
"valid_until",
):
if cur.get(key) in (None, "", "-", [], {}) and rich.get(key) not in (None, "", "-", [], {}):
cur[key] = rich[key]
if not has_fee_rows and (rich.get("fee_lines") or rich.get("fee_rows")):
cur["fee_lines"] = rich.get("fee_lines") or rich.get("fee_rows")
merged["quote"] = cur
if full_view.get("tms_quote") or full_view.get("tmsQuote"):
merged["tms_quote"] = dict(full_view.get("tms_quote") or full_view.get("tmsQuote") or {})
if (not has_status) and full_view.get("status"):
@@ -449,6 +473,66 @@ def hydrate_ticket(flow: Any, ticket: object) -> object:
):
if full_view.get(key) and not merged.get(key):
merged[key] = full_view.get(key)
return _soft_fill_quote_sale_fields(merged)
def _quote_has_usable_sale_total(quote: dict[str, Any] | None) -> bool:
"""
是否已有可用于出单预估费用的总价。
total="-" 不算;仅有 fee_rows 也不算。
"""
src = dict(quote or {})
total = str(src.get("total") or src.get("total_amount") or src.get("salePrice") or "").strip()
if total and total != "-":
return True
selected = src.get("selected_air_option") or src.get("selectedAirOption")
if isinstance(selected, dict):
sale = str(selected.get("salePrice") or selected.get("sale_price") or "").strip()
if sale and sale != "-":
return True
opts = src.get("airOptions") or src.get("air_options") or []
if isinstance(opts, list) and opts and isinstance(opts[0], dict):
sale = str(opts[0].get("salePrice") or opts[0].get("sale_price") or "").strip()
if sale and sale != "-":
return True
return False
def _soft_fill_quote_sale_fields(ticket: object) -> object:
"""
从 tms_quote 补齐当前 quote 缺失的总销售价 / 线路对象。
群 findByCollabChat 常只有薄 latest quote,出单前必须能落到 salePrice。
"""
view = ticket_view(ticket)
cur = dict(view.get("quote") or {})
rich = dict(view.get("tms_quote") or view.get("tmsQuote") or {})
if not rich:
return ticket if isinstance(ticket, dict) else view
changed = False
for key in (
"total",
"salePrice",
"totalSalePrice",
"airOptions",
"air_options",
"selected_air_option",
"selectedAirOption",
"chargeableWeightKg",
"chargeable_weight_kg",
"currency",
"valid_until",
):
if cur.get(key) in (None, "", "-", [], {}) and rich.get(key) not in (None, "", "-", [], {}):
cur[key] = rich[key]
changed = True
if not changed:
return ticket if isinstance(ticket, dict) else view
if isinstance(ticket, dict):
out = dict(ticket)
out["quote"] = cur
return out
merged = dict(view)
merged["quote"] = cur
return merged
@@ -1204,7 +1288,7 @@ def capture_group_lost_reason(
sess.phase = "done"
sess.allowed = ()
flow._save(sess)
send_group_text(client, chat_id, copy.lost_reason_ack(text))
send_group_text(client, chat_id, copy.lost_reason_ack(str(result.get("lost_reason") or text)))
return "done"
+1 -1
View File
@@ -190,7 +190,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
from agent.schema.sea_options import drop_invented_door_trade_terms, harvest_sea_class
facts = harvest_sea_class(text, facts)
# 旧陆运/补问草稿可能把「门到门」塞进贸易条款;原话没写条款则丢掉。
# 贸易条款标签行原样优先(Port to Port 等);再丢掉无标签的冒充「门到门」
facts = drop_invented_door_trade_terms(text, facts)
check = validate_required_fields(facts=facts, business_line="SEA")
facts = dict(check["facts"])
@@ -22,6 +22,7 @@ from agent.routing.group_intent import (
INTENT_NEGOTIATE,
INTENT_OTHER,
INTENT_QUOTE,
_exact_tms_choice,
_hint_confirm_tms,
_hint_deal_outcome,
_hint_tms_choice,
@@ -94,6 +95,14 @@ def classify_air_group_text(
locked = _hint_lock_or_release(raw)
if locked:
return locked
# 本单已有 TMS 报价时,航线回「2」是选不用 TMS,不是把价格改成 2。
exact = _exact_tms_choice(raw)
if exact:
return exact
# wo_plus_deal_not_activate:工单号+成交跟进同一句先关单,不能只当激活。
closed = _hint_deal_outcome(raw)
if copy.extract_work_order_no(raw) and closed:
return closed
# 只发工单号先当激活,不要先打模型;WO + 12 位数字也会被费用行误认成金额。
if copy.extract_work_order_no(raw) and _only_work_order(raw):
return INTENT_ACTIVATE
@@ -178,6 +187,7 @@ def _classify_with_model(text: str, settings) -> str:
"deal_negotiate=销售要继续协商;"
"other=闲聊。"
"一句话里既有锁舱又有工单号,回 air_lock。"
"一句话里既有工单号又确认成交/未成交/协商中,回 deal_won/deal_lost/deal_negotiate,不要回 air_activate。"
"改已有费用优先 quote_adjust;新报价格才是 product_quote。"
)
data = client.chat(
+21 -4
View File
@@ -134,6 +134,10 @@ def classify_group_text(
raw = (text or "").strip()
if not raw:
return INTENT_OTHER
# 问句让回 1 或 2。整句就是选项时先认,不能把「2」抽成价格。
exact = _exact_tms_choice(raw)
if exact:
return exact
try:
from agent.config import get_settings
@@ -155,9 +159,9 @@ def classify_group_text(
logger.info("群意图模型判成%s,按口令改回 %s text=%s", got, hinted_tms, raw[:40])
return hinted_tms
hinted_deal = _hint_deal_outcome(raw)
# 销售说成交时,模型不能把群拆了。
if hinted_deal and got == INTENT_DISSOLVE:
logger.warning("群意图模型把成交判成解散,改回 %s text=%s", hinted_deal, raw[:40])
# 销售说成交时,模型不能把群拆了,也不能把「工单号+未成交」判成闲聊。
if hinted_deal and got in {INTENT_DISSOLVE, INTENT_OTHER, INTENT_FIELDS}:
logger.warning("群意图模型把成交判成%s,改回 %s text=%s", got, hinted_deal, raw[:40])
return hinted_deal
return got
except Exception:
@@ -184,7 +188,20 @@ def classify_group_text(
def _norm_intent_text(text: str) -> str:
return (text or "").replace(" ", "").replace(" ", "").lower()
"""去掉空格和 @询价小助手,方便整句就是「2」时对上选项。"""
blob = (text or "").replace(" ", "").replace(" ", "").lower()
blob = blob.replace("@询价小助手", "").replace("询价小助手", "")
return blob.strip()
def _exact_tms_choice(text: str) -> str:
"""整句只是 1 / 2 / 第一个 / 第二个。这是在选题,不是报价金额。"""
blob = _norm_intent_text(text)
if blob in _KEEP_CHOICE:
return INTENT_KEEP_TMS
if blob in _DROP_CHOICE:
return INTENT_DROP_TMS
return ""
def _hint_confirm_tms(text: str) -> str:
+55 -4
View File
@@ -516,16 +516,35 @@ def harvest_oral_collab(text: str) -> dict[str, str]:
return out
# 箱型原文:40HQ*1、1x40HQ、40尺高柜。货物数量不能收这种串。
_CONTAINER_SPEC = re.compile(
r"(?i)(20|40|45)\s*['\"′尺]?\s*(HQ|GP|NOR|RH|HC|高柜|平柜|柜)"
)
def looks_like_container_spec(text: str) -> bool:
"""这串是箱型箱量,不是件数或吨数。20件、20吨不算。"""
return bool(_CONTAINER_SPEC.search(text or ""))
def project_sea_cargo_qty(facts: dict[str, str] | None) -> dict[str, str]:
"""
海运货物数量投影:原话「15件」常被抽成空运「件数」。
海运货物数量投影。
货量已有则不覆盖。件数有、货量空时,货量用件数。不猜吨位、不改空运校验。
抽字段常把「20件」放进件数,又把「40HQ*1」写进货量。
货量只要长得像箱型,就丢掉,改用件数里的数量;件数也是箱型则不搬。
箱型箱量空着时,把这串箱型补到箱型箱量。不限具体件数。
"""
merged = dict(facts or {})
qty = (merged.get("货量") or merged.get("货物数量") or "").strip()
pieces = (merged.get("件数") or "").strip()
if not qty and pieces:
if looks_like_container_spec(qty):
if not (merged.get("箱型箱量") or "").strip():
merged["箱型箱量"] = qty
merged.pop("货量", None)
merged.pop("货物数量", None)
qty = ""
if not qty and pieces and not looks_like_container_spec(pieces):
merged["货量"] = pieces
return merged
@@ -562,6 +581,38 @@ def _pricing_required_keys(
return []
_PLACE_KEYS = ("起运港", "目的港")
_PACK_IN_PLACE = ("散货", "纸箱", "木箱", "托盘", "卡板", "木托")
_PRICE_TAIL = re.compile(r"(的价格|的报价|价格|报价)$")
def peel_trailing_place_clauses(facts: dict[str, str] | None) -> dict[str, str]:
"""
起运地/目的地只留逗号前的地名。
口语「目的地吉隆坡,普货,散货的价格」会被抽成一整串目的地。
逗号后面的品名、包装、价格不是地名,拆回去;已有品名/包装不覆盖。
"""
merged = dict(facts or {})
for key in _PLACE_KEYS:
raw = (merged.get(key) or "").strip()
parts = [p.strip() for p in re.split(r"[,,、]", raw) if p.strip()]
if len(parts) <= 1:
continue
place = parts[0]
for part in parts[1:]:
part = _PRICE_TAIL.sub("", part).strip()
if not part or part in {"价格", "报价", "查价"}:
continue
if part in _PACK_IN_PLACE and not (merged.get("包装方式") or "").strip():
merged["包装方式"] = part
continue
if not (merged.get("品名") or "").strip() and not looks_like_container_spec(part):
merged["品名"] = part
merged[key] = place
return merged
def display_name(internal_key: str, business_line: str = "AIR") -> str:
"""补问时给销售看的名字。"""
line = (business_line or "").upper()
@@ -589,7 +640,7 @@ def validate_required_fields(
"""
_ = require_formal
contract = require_formal_contract("inquiry-required-fields-v1")
normalized = apply_quote_date_default(normalize_facts(facts))
normalized = peel_trailing_place_clauses(apply_quote_date_default(normalize_facts(facts)))
line = (business_line or "").strip().upper()
if line == "SEA":
normalized = project_sea_cargo_qty(normalized)
@@ -28,6 +28,7 @@ ROUTES = (
"东南亚",
)
LAND_CLASSES = (
"普件(常用线路/常规车型)",
"普件(东南亚、中港常用线路/常规车型)",
"普件(东南亚、中亚、中欧涉及国内+国外段)",
"普件(东南亚、中亚、中欧涉及境外段清派等特殊要求)",
+393 -52
View File
@@ -20,7 +20,7 @@ _CLOSED = frozenset({"已成交", "未成交", "已关闭", "转人工"})
_QUOTED = frozenset({"已报价", "协商中"})
_PERCENT_UP = re.compile(
r"(上调|上涨|上升|上浮|提高|加|涨|升)\s*(\d+(?:\.\d+)?)\s*%"
r"(上调|上涨|上升|上浮|提高|提升|加|涨|升)\s*(\d+(?:\.\d+)?)\s*%"
)
_PERCENT_DOWN = re.compile(r"(下调|下降|降低|减|降)\s*(\d+(?:\.\d+)?)\s*%")
_TARGET = re.compile(
@@ -28,7 +28,7 @@ _TARGET = re.compile(
)
_SPLIT = re.compile(r"[,,;;。!!??\n]|和(?=\S)")
_FEE_TOKEN = re.compile(r"([\u4e00-\u9fffA-Za-z0-9]{1,16}费)")
_TOTAL_WORDS = ("合计", "总价", "汇总价", "总销售价", "总成本价")
_TOTAL_WORDS = ("费用合计", "合计", "总价", "汇总价", "总销售价", "总成本价")
_QUOTE_WORD = "报价"
@@ -90,12 +90,17 @@ def _fee_key(name: str) -> str:
return raw.strip()
_TOTAL_ALIASES = frozenset({"费用合计", "总销售价", "总成本价", "汇总价", "总价"})
def fee_name_same(left: str, right: str) -> bool:
"""费用名对齐:海运费=海运费用;操作费 ≠ 码头操作费。"""
"""费用名对齐:海运费=海运费用;操作费 ≠ 码头操作费。汇总行的旧称呼也算同一行。"""
a = _fee_key(left)
b = _fee_key(right)
if not a or not b:
return False
if a in _TOTAL_ALIASES and b in _TOTAL_ALIASES:
return True
if a == b:
return True
if a == b + "用" or a == b + "费用" or b == a + "用" or b == a + "费用":
@@ -126,6 +131,240 @@ def match_fee_names(spoken: str, rows: list[dict[str, Any]]) -> list[str]:
return hits
# 海运卡按三段展示。小计是卡上的一行,不在扁平 fee_lines 里。
_SEA_BUCKETS = (
("polCostItems", "始发港费用小计"),
("oceanCostItems", "海运费小计"),
("podCostItems", "目的港费用小计"),
)
def _bucket_items(quote: dict[str, Any], key: str) -> list[dict[str, Any]]:
"""读海运分段。主账有时给列表,有时给 JSON 字符串。"""
raw = quote.get(key)
if raw in (None, ""):
raw = quote.get("fee_rows_" + key)
if isinstance(raw, str) and raw.strip():
try:
raw = json.loads(raw)
except json.JSONDecodeError:
return []
if not isinstance(raw, list):
return []
return [dict(row) for row in raw if isinstance(row, dict)]
def _item_fee_name(item: dict[str, Any]) -> str:
return str(item.get("name") or item.get("label") or item.get("feeName") or "").strip()
def _same_money(left: Any, right: Any) -> bool:
"""两条金额是否同一笔,用来把同名费用配回原来那一行。"""
a, _ = parse_money(left)
b, _ = parse_money(right)
if a is None or b is None:
return False
return abs(a - b) < 0.0001
def _plain_amount(amount: str) -> str:
"""写回分段时去掉币种前缀,避免小计被展示成 CNY CNY。"""
text = str(amount or "").strip()
for prefix in ("USD", "CNY", "EUR", "HKD", "JPY", "GBP", "RMB"):
if text.upper().startswith(prefix):
return text[len(prefix) :].strip()
return text
def _store_bucket(quote: dict[str, Any], key: str, items: list[dict[str, Any]]) -> None:
"""分段和 fee_rows_ 键一起改,卡和模板读的是这两处。"""
if key in quote or quote.get(key) not in (None, ""):
quote[key] = items
alt = "fee_rows_" + key
if alt in quote or quote.get(alt) not in (None, ""):
quote[alt] = json.dumps(items, ensure_ascii=False)
def adjustable_rows(quote: dict[str, Any] | None) -> list[dict[str, Any]]:
"""
调价能对上的行:扁平明细,加上海运分段里的费目和小计。
小计只用来对「始发港费用小计」这类话,不写进 fee_lines。
"""
src = dict(quote or {})
rows = [dict(x) for x in quote_fee_rows(src)]
for row in rows:
row["_origin"] = str(row.get("amount") or "")
header = str(src.get("currency") or src.get("currencyCode") or "").strip()
for key, sub_label in _SEA_BUCKETS:
used: set[int] = set()
for item in _bucket_items(src, key):
name = _item_fee_name(item)
amount = str(item.get("amount") or item.get("value") or item.get("salePrice") or "").strip()
if not name or not amount:
continue
idx = next(
(
i
for i, row in enumerate(rows)
if i not in used
and not row.get("_subtotal_key")
and not row.get("_is_total")
and fee_name_same(name, str(row.get("name") or ""))
and _same_money(row.get("_origin"), amount)
),
-1,
)
if idx < 0:
rows.append(
{
"name": name,
"amount": amount,
"currency": item.get("currency") or header,
"_bucket": key,
"_origin": amount,
}
)
used.add(len(rows) - 1)
else:
rows[idx]["_bucket"] = key
used.add(idx)
subtotal = str(src.get("fee_total_" + key) or "").strip()
if subtotal and subtotal != "-":
rows.append(
{
"name": sub_label,
"amount": subtotal,
"currency": header,
"_subtotal_key": "fee_total_" + key,
"_bucket": key,
}
)
_attach_land_legs(src, rows)
total = str(src.get("total") or "").strip()
if total and total != "-":
rows.append(
{
"name": total_price_label(
str(src.get("business_line") or ""),
quote=src,
),
"amount": total,
"currency": header,
"_is_total": True,
}
)
return rows
def _attach_land_legs(quote: dict[str, Any], rows: list[dict[str, Any]]) -> None:
"""陆运卡按段展示。同名费用每段一行,改价要写回路段成本。"""
from agent.schema.land_route_options import land_leg_fee_blocks
used: set[int] = set()
for index, (_title, fees, _eta) in enumerate(land_leg_fee_blocks(quote)):
for fee in fees:
name = str(fee.get("name") or "").strip()
amount = str(fee.get("amount") or "").strip()
if not name or not amount:
continue
idx = next(
(
i
for i, row in enumerate(rows)
if i not in used
and not row.get("_subtotal_key")
and not row.get("_is_total")
and fee_name_same(name, str(row.get("name") or ""))
and str(row.get("amount") or "") == amount
),
-1,
)
if idx < 0:
rows.append(
{
"name": name,
"amount": amount,
"currency": fee.get("currency") or quote.get("currency") or "",
"_land_leg": index,
}
)
used.add(len(rows) - 1)
else:
rows[idx]["_land_leg"] = index
used.add(idx)
def _write_adjusted_buckets(quote: dict[str, Any], rows: list[dict[str, Any]]) -> None:
"""把改过的费目和小计写回海运分段,价格卡才看得到。"""
for key, _label in _SEA_BUCKETS:
items = _bucket_items(quote, key)
changed = False
used: set[int] = set()
for item in items:
name = _item_fee_name(item)
origin = str(item.get("amount") or item.get("value") or item.get("salePrice") or "")
hit_i = next(
(
i
for i, row in enumerate(rows)
if i not in used
and row.get("_bucket") == key
and not row.get("_subtotal_key")
and fee_name_same(name, str(row.get("name") or ""))
and _same_money(row.get("_origin"), origin)
),
-1,
)
if hit_i < 0:
continue
used.add(hit_i)
plain = _plain_amount(str(rows[hit_i].get("amount") or ""))
if not plain:
continue
item["amount"] = plain
if item.get("name"):
item["name"] = name
for field_name in ("salePrice", "value", "unit_price"):
if item.get(field_name) not in (None, ""):
item[field_name] = plain
changed = True
if changed:
_store_bucket(quote, key, items)
for row in rows:
sub_key = str(row.get("_subtotal_key") or "")
if sub_key:
quote[sub_key] = _plain_amount(str(row.get("amount") or ""))
_write_land_legs(quote, rows)
def _write_land_legs(quote: dict[str, Any], rows: list[dict[str, Any]]) -> None:
"""路段成本改掉,下次出卡才不会又按旧成本显示。"""
from agent.schema.land_route_options import _selected_legs
legs = _selected_legs(quote)
if not legs:
return
for row in rows:
index = row.get("_land_leg")
if index is None or not isinstance(index, int) or index >= len(legs):
continue
plain = _plain_amount(str(row.get("amount") or ""))
if not plain:
continue
leg = legs[index]
leg["costPrice"] = plain
leg["cost_price"] = plain
if leg.get("salePrice") not in (None, ""):
leg["salePrice"] = plain
selected = quote.get("selected_land_route")
if isinstance(selected, dict):
selected["legs"] = legs
quote["selected_land_route"] = selected
if quote.get("landSegments") or quote.get("land_segments"):
quote["landSegments"] = legs
def quote_fee_rows(quote: dict[str, Any] | None) -> list[dict[str, Any]]:
"""抽出费用行。TMS 常把 fee_lines 存成 JSON 字符串。"""
src = dict(quote or {})
@@ -292,6 +531,7 @@ def extract_adjust(
if not raw:
return AdjustExtract(is_adjust=False)
names = [str(x).strip() for x in (fee_names or []) if str(x).strip()]
names_sorted = sorted(names, key=len, reverse=True)
use_model = allow_model
if use_model is None:
try:
@@ -319,11 +559,28 @@ def extract_adjust(
op = _item_from_chunk(raw, "")
if op:
modeled.items = [op]
# 模型常只抽出「费用合计」:本地再扫点名小计/费目,避免「始发港费用小计提升10%」被丢掉
local_items = _local_fee_items(raw, names_sorted)
if local_items:
seen = {
str(x.fee_name or "").strip()
for x in (modeled.items or [])
if str(x.fee_name or "").strip()
}
merged = list(modeled.items or [])
for it in local_items:
key = str(it.fee_name or "").strip()
if key and key not in seen:
merged.append(it)
seen.add(key)
modeled = AdjustExtract(
is_adjust=True,
items=merged,
wants_total=bool(modeled.wants_total),
)
return modeled
except Exception:
logger.exception("调价模型抽取失败,回退本地")
names_sorted = sorted(names, key=len, reverse=True)
blob = raw.replace(" ", "")
total_hit = any(w in raw for w in _TOTAL_WORDS)
has_op = bool(_PERCENT_UP.search(raw) or _PERCENT_DOWN.search(raw) or _TARGET.search(raw))
fee_in_text = [n for n in names_sorted if n and n in raw]
@@ -332,6 +589,17 @@ def extract_adjust(
return AdjustExtract(is_adjust=True, wants_total=True, items=[op] if op else [])
if _QUOTE_WORD in raw and has_op and not fee_in_text and not total_hit:
return AdjustExtract(is_adjust=True, wants_total=False)
items = _local_fee_items(raw, names_sorted)
if not items and has_op:
# 说了费用口语但和名单不完全一致:整句再扫一次
item = _item_from_chunk(raw, "")
if item:
items.append(item)
return AdjustExtract(is_adjust=bool(items or has_op), items=items)
def _local_fee_items(raw: str, names_sorted: list[str]) -> list[AdjustItem]:
"""按分句把点名费目/小计抽成调价项。提升/上调等口令都认。"""
items: list[AdjustItem] = []
for part in _SPLIT.split(raw) or [raw]:
chunk = (part or "").strip()
@@ -346,16 +614,15 @@ def extract_adjust(
found = _FEE_TOKEN.findall(chunk)
spoken = found[-1] if found else ""
if not spoken:
continue
# 「费用合计提升10%」:名单里有费用合计时上面已命中;否则用合计词
if any(w in chunk for w in _TOTAL_WORDS):
spoken = "费用合计"
else:
continue
item = _item_from_chunk(chunk, spoken)
if item:
items.append(item)
if not items and has_op:
# 说了费用口语但和名单不完全一致:整句再扫一次
item = _item_from_chunk(raw, "")
if item:
items.append(item)
return AdjustExtract(is_adjust=bool(items or has_op), items=items)
return items
def _item_from_chunk(chunk: str, fee_name: str) -> Optional[AdjustItem]:
@@ -377,21 +644,13 @@ def total_price_label(
quote: dict[str, Any] | None = None,
) -> str:
"""
销售可见总价名称。
销售在企微看到的汇总行名称,一律叫费用合计。
空运一律总销售价。海运/陆运:TMS、产品报价是总成本价,销售调价后才是总销售价。
禁止写「合计」。Excel/PDF 栏目名不走这里。
不区分空运/海运/陆运,也不区分 TMS 还是销售调价。
后台成本价、销售价不走这里。Excel/PDF 报价单模板也不走这里。
"""
src = dict(quote or {})
raw = f"{business_line or ''} {transport_mode or ''} {src.get('business_line') or ''}"
key = raw.upper()
if key.strip() in {"AIR"} or "AIR" in key or "空运" in raw:
return "总销售价"
source = str(src.get("source") or "").strip().upper()
label = str(src.get("source_label") or src.get("sourceLabel") or "").replace(" ", "")
if source == "SALES_ADJUST" or "销售调价" in label:
return "总销售价"
return "总成本价"
_ = business_line, transport_mode, quote
return "费用合计"
def _mark_sales_adjust(quote: dict[str, Any]) -> None:
@@ -532,6 +791,42 @@ def _apply_total_only(
)
def _fold_total_into_items(
extract: AdjustExtract,
*,
business_line: str = "",
quote: dict[str, Any] | None = None,
) -> AdjustExtract:
"""
合计与费目小计写在同一条指令里时:取消 wants_total 独占,把合计并进 items。
为何:wants_total 只改 total,海运「始发港费用小计」不会动,Excel 汇总仍是旧数。
"""
if not extract.wants_total:
return extract
named = [x for x in (extract.items or []) if str(x.fee_name or "").strip()]
if not named:
return extract
label = total_price_label(business_line, quote=quote)
has_total = any(fee_name_same(str(x.fee_name or ""), label) for x in named)
items = list(named)
if not has_total:
seed = named[0]
for raw in extract.items or []:
if not str(raw.fee_name or "").strip():
seed = raw
break
items.append(
AdjustItem(
fee_name=label,
kind=seed.kind,
direction=seed.direction,
value=seed.value,
)
)
return AdjustExtract(is_adjust=True, wants_total=False, items=items)
def apply_adjust(
quote: dict[str, Any] | None,
extract: AdjustExtract,
@@ -545,12 +840,14 @@ def apply_adjust(
明细只有一项且币种与总价一致:改总价或改明细都一起调。
多项同币种改合计:只改合计数字。多币种:只改点名的那一格。
副作用:无。原 quote 不被修改。
副作用:无。原 quote 不被修改。海运小计按卡上的「始发港费用小计」改。
"""
src = dict(quote or {})
rows = [dict(x) for x in quote_fee_rows(src)]
rows = adjustable_rows(src)
if not extract.is_adjust:
return ApplyResult(ok=False, code="not_adjust", quote=src)
# 同时说了「始发港费用小计」和「费用合计」时:不能只走合计通道,否则小计不改、Excel 汇总也不动。
extract = _fold_total_into_items(extract, business_line=business_line, quote=src)
header = str(src.get("currency") or "").strip()
total_ccy = row_currency({"currency": header, "amount": src.get("total")}, header)
currencies = {row_currency(r, header) for r in rows if row_currency(r, header)}
@@ -567,7 +864,13 @@ def apply_adjust(
currency_hint = bool(multi)
total_amt, _ = parse_money(src.get("total"))
new_total = total_amt
single_same_ccy = _single_line_same_currency(rows, total_ccy or header) is not None
total_touched = False
single_same_ccy = _single_line_same_currency(
[row for row in rows if not row.get("_subtotal_key") and not row.get("_is_total")],
total_ccy or header,
) is not None
subtotal_only: dict[str, float] = {}
touched_buckets: set[str] = set()
for item in extract.items:
spoken = (item.fee_name or "").strip()
@@ -581,35 +884,73 @@ def apply_adjust(
if not hits:
unmatched.append(spoken)
continue
idx = next(i for i, r in enumerate(rows) if str(r.get("name") or "") == hits[0])
row = dict(rows[idx])
old_amt, prefix = parse_money(row.get("amount"))
if old_amt is None:
indexes = [i for i, r in enumerate(rows) if str(r.get("name") or "") == hits[0]]
if not indexes:
unmatched.append(spoken)
continue
line_ccy = row_currency(row, header) or prefix
if item.kind == "target":
new_amt = float(item.value)
how = f"改到 {item.value:g}"
elif item.direction == "down":
new_amt = old_amt * (1 - float(item.value) / 100.0)
how = f"下调 {item.value:g}%"
else:
new_amt = old_amt * (1 + float(item.value) / 100.0)
how = f"上调 {item.value:g}%"
_apply_amount(row, format_money(new_amt, sample=str(row.get("amount") or ""), currency=line_ccy or prefix))
rows[idx] = row
applied.append({"name": hits[0], "how": how, "amount": row["amount"]})
if (not multi) and new_total is not None:
if single_same_ccy:
new_total = new_amt
any_applied = False
for idx in indexes:
row = dict(rows[idx])
old_amt, prefix = parse_money(row.get("amount"))
if old_amt is None:
continue
line_ccy = row_currency(row, header) or prefix
if item.kind == "target":
new_amt = float(item.value)
how = f"改到 {item.value:g}"
elif item.direction == "down":
new_amt = old_amt * (1 - float(item.value) / 100.0)
how = f"下调 {item.value:g}%"
else:
new_total = new_total + (new_amt - old_amt)
new_amt = old_amt * (1 + float(item.value) / 100.0)
how = f"上调 {item.value:g}%"
_apply_amount(row, format_money(new_amt, sample=str(row.get("amount") or ""), currency=line_ccy or prefix))
rows[idx] = row
if not any_applied:
applied.append({"name": hits[0], "how": how, "amount": row["amount"]})
any_applied = True
bucket = str(row.get("_bucket") or "")
if row.get("_is_total"):
subtotal_only["__total__"] = new_amt
total_touched = True
continue
if row.get("_subtotal_key"):
# 只改小计数字写回 fee_total_*;明细是否另调看有没有点名费目
subtotal_only[bucket] = new_amt - old_amt
continue
if bucket:
touched_buckets.add(bucket)
if (not multi) and new_total is not None:
if single_same_ccy:
new_total = new_amt
else:
new_total = new_total + (new_amt - old_amt)
if not any_applied:
unmatched.append(spoken)
if (not multi) and new_total is not None:
if "__total__" in subtotal_only:
# 合计是整单改数;小计差额不再叠进合计,避免「合计×1.1 + 小计差额」重复
new_total = subtotal_only.pop("__total__")
else:
for bucket, delta in subtotal_only.items():
if bucket not in touched_buckets:
new_total = new_total + delta
elif total_touched and "__total__" in subtotal_only:
# 多币种:点名「费用合计」仍改合计数字,不做汇率换算
new_total = subtotal_only["__total__"]
out = dict(src)
out["fee_rows"] = rows
out["fee_lines"] = rows
if applied and (not multi) and new_total is not None and new_total != total_amt:
visible = []
for row in rows:
if row.get("_subtotal_key") or row.get("_is_total"):
continue
clean = {k: v for k, v in row.items() if not str(k).startswith("_")}
visible.append(clean)
out["fee_rows"] = visible
out["fee_lines"] = visible
_write_adjusted_buckets(out, rows)
if applied and new_total is not None and new_total != total_amt and ((not multi) or total_touched):
out["total"] = format_money(new_total, sample=str(src.get("total") or ""), currency=total_ccy)
if not applied:
code = "ambiguous" if ambiguous else "none_matched"
+79 -7
View File
@@ -1,15 +1,29 @@
"""
海运运输分类:只认 Port to Port、其他。
本文件职责:从原话收成标准词、生成补问清单。
本文件职责:从原话收成标准词、生成补问清单;贸易条款标签行原样优先。
禁止:发明第三项;把分类带去 TMS;改六态。
线程:纯函数,可在 Worker 槽调用。
"""
from __future__ import annotations
import re
SEA_CLASSES = ("Port to Port", "其他")
# 补问清单下一行「(如:Port to Port、其他)」只是举例,不能当成销售已选。
_CLARIFY_EXAMPLE_LINE = re.compile(r"^\s*[((]如\s*[::]")
# 标签行优先:避免「贸易条款:Port to Port」整句扫描时把运输分类也收成 Port to Port。
_LABELED_SEA_CLASS = re.compile(
r"(?im)^\s*运输分类(?:(必填)|\(必填\))?\s*[::]\s*(.+?)\s*$"
)
_LABELED_TRADE_TERMS = re.compile(
r"(?im)^\s*贸易条款(?:(必填)|\(必填\)|(非必填)|\(非必填\))?\s*[::]\s*(.+?)\s*$"
)
_TRADE_TERMS_LINE = re.compile(r"(?im)^\s*贸易条款")
# 长词优先,避免 P2P 误伤其它词;门到门不在名单里。
_ALIASES = (
("port to port", "Port to Port"),
@@ -29,9 +43,16 @@ def canonicalize_sea_class(raw: str) -> str:
text = (raw or "").strip()
if not text:
return ""
lowered = text.lower()
# 企微常把空格发成不换行空格,Port to Port 对不上普通空格就会整段丢掉。
folded = re.sub(r"\s+", " ", text).strip()
lowered = folded.lower()
squashed = re.sub(r"\s+", "", lowered)
if "port to port" in lowered or "porttoport" in squashed:
return "Port to Port"
for needle, canon in _ALIASES:
if needle in lowered or needle in text:
if needle == "port to port":
continue
if needle in lowered or needle in text or needle in squashed:
return canon
stripped = text.replace("运输分类", "").replace(":", "").replace(":", "").strip()
if stripped in SEA_CLASSES:
@@ -39,15 +60,46 @@ def canonicalize_sea_class(raw: str) -> str:
return ""
def _without_clarify_examples(text: str) -> str:
"""丢掉补问举例行。销售只在冒号后作答,「(如:…)」整行不参与判断。"""
return "\n".join(
line for line in (text or "").splitlines() if not _CLARIFY_EXAMPLE_LINE.match(line)
)
def _without_trade_terms_lines(text: str) -> str:
"""整句扫运输分类时丢掉「贸易条款:…」行,避免条款里的 Port to Port 冒充分类。"""
return "\n".join(
line for line in (text or "").splitlines() if not _TRADE_TERMS_LINE.match(line.strip())
)
def _labeled_value(text: str, pattern: re.Pattern[str]) -> str:
"""取原文最后一次标签行冒号后的值;没有则空串。"""
last = ""
for hit in pattern.finditer(_without_clarify_examples(text or "")):
last = (hit.group(1) or "").strip()
return last
def harvest_sea_class(text: str, current: dict[str, str] | None = None) -> dict[str, str]:
"""
当前句能认则覆盖;认不出则保留已有标准值。
参数:本轮原文、已有 facts。返回合并后的 facts 副本。
非法旧值会丢掉,避免「门到门」一直占着必填位。
优先「运输分类:」标签行(WO202609280012:条款写 Port to Port、分类写其它)。
整句扫描时排除贸易条款行,避免条款里的 Port to Port 盖掉「其它」。
「其它」与「其他」都收成「其他」。非法旧值会丢掉,避免「门到门」一直占着必填位。
"""
merged = dict(current or {})
found = canonicalize_sea_class(text or "")
cleaned = _without_clarify_examples(text or "")
labeled = _labeled_value(cleaned, _LABELED_SEA_CLASS)
if labeled:
found_labeled = canonicalize_sea_class(labeled)
if found_labeled:
merged["运输分类"] = found_labeled
return merged
# 整句扫描:不含贸易条款行
found = canonicalize_sea_class(_without_trade_terms_lines(cleaned))
if found:
merged["运输分类"] = found
return merged
@@ -78,6 +130,20 @@ _DOOR_TRADE_TERMS = frozenset(
)
def harvest_sea_trade_terms(text: str, facts: dict[str, str] | None = None) -> dict[str, str]:
"""
海运贸易条款:标签行冒号后原样优先。
WO202609280012:销售写「贸易条款:Port to Port」,模型却收成「门到门」。
有标签行则强制用标签值;门到门是否丢掉仍交给 drop_invented_door_trade_terms。
"""
merged = dict(facts or {})
labeled = _labeled_value(text or "", _LABELED_TRADE_TERMS)
if labeled:
merged["贸易条款"] = labeled
return merged
def drop_invented_door_trade_terms(text: str, facts: dict[str, str] | None = None) -> dict[str, str]:
"""
丢掉冒充贸易条款的「门到门」。
@@ -85,11 +151,17 @@ def drop_invented_door_trade_terms(text: str, facts: dict[str, str] | None = Non
测服 WO202609200013:原话没写贸易条款,确认卡却出现「贸易条款:门到门」。
门到门只是运输分类说法,不是 FOB/CIF 这类条款;原话没有「贸易条款」四字时不准收下。
销售明确写了「贸易条款:门到门」则保留原文,不改六态。
若标签行写的是 Port to Port / 港到港等,禁止再用模型的「门到门」盖掉。
"""
merged = dict(facts or {})
merged = harvest_sea_trade_terms(text, facts)
trade = str(merged.get("贸易条款") or "").strip()
if not trade:
return merged
labeled = _labeled_value(text or "", _LABELED_TRADE_TERMS)
if labeled:
# 标签已写死条款:以标签为准(含 Port to Port)
merged["贸易条款"] = labeled.strip()
return merged
if "贸易条款" in (text or ""):
return merged
if trade.lower().replace(" ", "") in {x.replace(" ", "") for x in _DOOR_TRADE_TERMS} or trade in _DOOR_TRADE_TERMS:
+31 -1
View File
@@ -78,6 +78,10 @@ _IATA_PAIR_BLOCK = frozenset(
}
)
_NUMBER = re.compile(r"(\d+(?:\.\d+)?)")
# 销售写尺寸当体积:1.1*1.2*3 / 1.1x1.2x3 / 1.1×1.2×3
_DIM_PRODUCT = re.compile(
r"(\d+(?:\.\d+)?)\s*[*xX×]\s*(\d+(?:\.\d+)?)(?:\s*[*xX×]\s*(\d+(?:\.\d+)?))?"
)
def _first_number(raw: str) -> str:
@@ -85,6 +89,32 @@ def _first_number(raw: str) -> str:
return hit.group(1) if hit else ""
def volume_cbm_number(raw: str) -> str:
"""
体积出站用数字:尺寸式先相乘;否则取第一个数。
例:1.1*1.2*3 → 3.96;2CBM → 2。空串表示没有可用体积。
"""
text = (raw or "").strip()
if not text:
return ""
dims = _DIM_PRODUCT.search(text)
if dims:
try:
a = float(dims.group(1))
b = float(dims.group(2))
c = float(dims.group(3) or "1")
product = a * b * c
if product <= 0:
return ""
# 去掉多余尾零,保持可读小数
text_num = f"{product:.10f}".rstrip("0").rstrip(".")
return text_num or ""
except ValueError:
return ""
return _first_number(text)
def resolve_airport_code(raw: str) -> str:
"""销售原文是地名或三字码时,给出 TMS originCode/destinationCode。"""
text = (raw or "").strip()
@@ -161,7 +191,7 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]:
cargo = str(src.get("品名") or "").strip()
pieces = _first_number(str(src.get("件数") or ""))
weight = _first_number(str(src.get("毛重") or ""))
volume = _first_number(str(src.get("体积") or ""))
volume = volume_cbm_number(str(src.get("体积") or ""))
pack_raw = str(src.get("包装方式") or src.get("包装类型") or "").strip()
pack_code = resolve_package_type(pack_raw)
quote_date = str(src.get("报价日期") or "").strip()
+19 -1
View File
@@ -21,10 +21,28 @@ from agent.schema.field_validate import (
air_general_cargo_collab_defaults,
validate_required_fields,
)
from agent.schema.tms_air_query import assemble_air_query
from agent.schema.tms_air_query import assemble_air_query, volume_cbm_number
class AirFieldContractTests(unittest.TestCase):
def test_volume_dimension_product(self) -> None:
self.assertEqual(volume_cbm_number("1.1*1.2*3"), "3.96")
self.assertEqual(volume_cbm_number("1.1x1.2x3"), "3.96")
self.assertEqual(volume_cbm_number("2CBM"), "2")
out = assemble_air_query(
{
"目的港": "吉隆坡",
"品名": "普货",
"件数": "3件",
"毛重": "5",
"体积": "1.1*1.2*3",
"包装方式": "托盘",
"报价日期": "2026-10-08",
}
)
self.assertTrue(out["ok"], out)
self.assertEqual(out["facts"]["体积"], "1.1*1.2*3")
def test_origin_optional_still_ok(self) -> None:
result = validate_required_fields(
facts={
+133 -11
View File
@@ -38,6 +38,64 @@ class FakeBotGroup:
return {"ok": True}
class AirSheetQuoteTotalTests(unittest.TestCase):
"""群填表报价包必须带 TMS 总销售价,不能把附加费之和当预估费用。"""
def test_sheet_quote_replaces_fee_sum_total_with_tms_sale_price(self) -> None:
from agent.policy.air_group_ops import _sheet_quote_with_tms_sale_total
thin = {
"total": "200",
"currency": "CNY",
"fee_lines": [
{"name": "增点费", "amount": "100", "currency": "CNY"},
{"name": "拆板费", "amount": "100", "currency": "USD"},
],
}
tms = {
"source": "TMS",
"total": "7918.25",
"currency": "CNY",
"chargeableWeightKg": "833.5",
"airOptions": [
{
"optionNo": "AIR-1",
"salePrice": 7918.25,
"currency": "CNY",
"departureCharges": [
{"chargeName": "增点费", "unitPrice": 100, "currency": "CNY"},
{"chargeName": "拆板费", "unitPrice": 100, "currency": "USD"},
],
}
],
}
out = _sheet_quote_with_tms_sale_total(thin, tms=tms, facts={"起运港": "南京", "目的港": "吉隆坡"})
self.assertIn("7918.25", str(out.get("total") or ""))
self.assertNotEqual(str(out.get("total") or "").strip(), "200")
self.assertTrue(out.get("airOptions") or out.get("selected_air_option"))
def test_sheet_quote_keeps_product_total_when_not_fee_sum(self) -> None:
from agent.policy.air_group_ops import _sheet_quote_with_tms_sale_total
product = {
"total": "9000",
"currency": "CNY",
"source_label": "航线报价",
"fee_lines": [
{"name": "增点费", "amount": "100", "currency": "CNY"},
{"name": "拆板费", "amount": "100", "currency": "USD"},
],
}
tms = {
"source": "TMS",
"total": "7918.25",
"currency": "CNY",
"airOptions": [{"optionNo": "AIR-1", "salePrice": 7918.25, "currency": "CNY"}],
}
out = _sheet_quote_with_tms_sale_total(product, tms=tms, facts={})
self.assertEqual(str(out.get("total") or "").strip(), "9000")
class AirGroupCollabTests(unittest.TestCase):
def setUp(self) -> None:
from agent.redis_coord.air_inbound import reset_air_inbound_for_tests
@@ -210,8 +268,8 @@ class AirGroupCollabTests(unittest.TestCase):
tms_quote=tms,
)
tms_block = text.split("TMS报价:", 1)[1]
self.assertEqual(tms_block.count("总销售价:"), 1)
self.assertIn("总销售价:CNY 183370.0", tms_block)
self.assertEqual(tms_block.count("费用合计:"), 1)
self.assertIn("费用合计:CNY 183370.0", tms_block)
self.assertNotIn("时效", tms_block)
self.assertIn("报价有效期:2026-09-30", tms_block)
self.assertIn("请航线人员回复“确认报价”确定采用TMS报价。", text)
@@ -266,8 +324,8 @@ class AirGroupCollabTests(unittest.TestCase):
self.assertIn("运输费:CNY 11.0", tms_block)
self.assertIn("提送货费:CNY 6.0", tms_block)
self.assertIn("人工费:CNY 123.0", tms_block)
self.assertIn("总销售价:CNY 183370", tms_block)
self.assertEqual(tms_block.count("总销售价:"), 1)
self.assertIn("费用合计:CNY 183370", tms_block)
self.assertEqual(tms_block.count("费用合计:"), 1)
self.assertNotIn("空运费", tms_block)
def test_air_group_brief_multi_route_does_not_borrow_other_air_freight(self) -> None:
@@ -312,8 +370,45 @@ class AirGroupCollabTests(unittest.TestCase):
tms_block = text.split("TMS报价:", 1)[1]
self.assertNotIn("空运费", tms_block)
self.assertNotIn("运输费", tms_block)
self.assertIn("总销售价:CNY 200040", tms_block)
self.assertEqual(tms_block.count("总销售价:"), 1)
self.assertIn("费用合计:CNY 200040", tms_block)
self.assertEqual(tms_block.count("费用合计:"), 1)
def test_work_order_plus_lost_reason_is_deal_not_activate(self) -> None:
"""WO@助手 未成交,原因… 必须当关单,不能只激活。"""
from agent.routing.air_group_intent import INTENT_ACTIVATE, INTENT_LOST
text = "WO202609220014@询价小助手 未成交,原因客户不想送货了"
self.assertEqual(classify_air_group_text(text, allow_network=False), INTENT_LOST)
from unittest.mock import patch
with patch(
"agent.routing.air_group_intent._classify_with_model",
return_value=INTENT_ACTIVATE,
):
self.assertEqual(
classify_air_group_text(text, allow_network=True),
INTENT_LOST,
)
def test_activate_lost_and_reason_same_message(self) -> None:
wo = self._create_air()
phase = handle_air_group(
self._msg(
sender="sales1",
text=f"{wo}@询价小助手 未成交,原因客户不想送货了",
chat="wr_air_1",
mid="lost-inline-wo",
),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "done")
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.status, "未成交")
self.assertEqual(ticket.lost_reason, "客户不想送货了")
joined = "\n".join(self._texts())
self.assertIn("客户不想送货了", joined)
self.assertNotIn("请补充未成交原因", joined)
def test_work_order_only_is_activate_not_quote(self) -> None:
from agent.routing.air_group_intent import INTENT_ACTIVATE
@@ -658,6 +753,33 @@ class AirGroupCollabTests(unittest.TestCase):
self.assertEqual(getattr(self.ledger, "last_render", {}).get("templateId"), "air-default")
self.assertTrue(any(x.get("kind") == "file" for x in self.groups.sent))
def test_reply_2_means_drop_tms_not_price(self) -> None:
"""问 1/2 之后,航线回 @询价小助手 2 是选不用 TMS,不能把价格改成 2。"""
wo = self._create_air()
handle_air_group(
self._msg(sender="air1", text=wo, chat="wr_air_1", mid="d0"),
flow=self.flow,
ledger=self.ledger,
)
phase = handle_air_group(
self._msg(sender="air1", text="空运费 900USD", chat="wr_air_1", mid="d1"),
flow=self.flow,
ledger=self.ledger,
injected_intent="product_quote",
injected_quote={"total": "USD 900", "currency": "USD", "source_label": "航线报价"},
)
self.assertEqual(phase, "wait_tms_choice")
phase = handle_air_group(
self._msg(sender="air1", text="@询价小助手 2", chat="wr_air_1", mid="d2"),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "quoted")
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.quote.get("total"), "USD 900")
self.assertEqual(ticket.quote.get("source_label"), "航线报价")
self.assertNotEqual(ticket.quote.get("quoteId"), "QT-AIR-1")
def test_airline_quote_without_tms_sends_brief_sheet_deal(self) -> None:
wo = self._create_air(quote={}, status="询价中")
handle_air_group(
@@ -779,10 +901,10 @@ class AirGroupCollabTests(unittest.TestCase):
ledger=self.ledger,
injected_intent="air_lock",
)
self.assertEqual(phase, "lock_ok")
self.assertTrue(any("执行成功" in t for t in self._texts()))
self.assertTrue(any("舱位选项:AIR-OPT-01" in t for t in self._texts()))
self.assertFalse(any("还没有可锁的 TMS 报价" in t for t in self._texts()))
self.assertEqual(phase, "no_quote")
self.assertTrue(any("还没有报价,不能锁舱" 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()))
def test_manual_quote_without_quote_id_can_lock(self) -> None:
wo = self._create_air(
@@ -1196,7 +1318,7 @@ class AirGroupCollabTests(unittest.TestCase):
self.assertNotIn("与工单不匹配", joined)
def test_lock_without_quote_still_shows_tms_reason(self) -> None:
"""没有报价也可以锁舱;群里只回 TMS 原因,不要说选项不匹配。"""
"""TMS 拒绝锁舱时原样回失败原因,不要改写成选项不匹配。"""
wo = self._create_air(quote={}, status="询价中")
self.ledger._cabin_lock_result = {
"ok": False,
+12 -2
View File
@@ -218,7 +218,7 @@ class AirRouteOptionsTests(unittest.TestCase):
business_line="AIR",
)
self.assertNotIn("空运费", detail)
self.assertIn("总销售价", detail)
self.assertIn("费用合计", detail)
self.assertIn("200040", detail)
def test_group_air_quote_projects_route_fees_and_drops_total_alias(self) -> None:
@@ -281,7 +281,7 @@ class AirRouteOptionsTests(unittest.TestCase):
"NKG-KUL",
"增点费:CNY 100.0",
"拆板费:USD 100.0",
"总销售价:CNY 5001.0",
"费用合计:CNY 5001.0",
"报价有效期:2026-09-30",
]
),
@@ -323,6 +323,16 @@ class AirRouteOptionsTests(unittest.TestCase):
self.assertIn("/air-routes/tok-abc", url)
self.assertNotIn("userid", url)
def test_ok_page_auto_closes_wecom_h5(self) -> None:
from agent.channel.h5.air_route_page import expired_air_route_page, render_air_route_ok
ok = render_air_route_ok()
dead = expired_air_route_page()
self.assertIn("closeWindow", ok)
self.assertIn("wecom-jssdk", ok)
self.assertIn("jsapi-signature", ok)
self.assertNotIn("closeWindow", dead)
if __name__ == "__main__":
unittest.main()
+13 -13
View File
@@ -342,7 +342,7 @@ class AirTextInquiryTests(unittest.TestCase):
self.assertIn("已报价", self.flow.session_of("u1").status)
self.assertIn(copy.BTN_SKIP_COLLAB, joined)
self.assertIn("价格明细", joined)
self.assertIn("总销售价", joined)
self.assertIn("费用合计", joined)
self.assertNotIn("时效:", joined)
self.assertIn("报价有效期:", joined)
self.assertNotIn("相同询价内容检索", joined)
@@ -378,7 +378,7 @@ class AirTextInquiryTests(unittest.TestCase):
card = scheme_cards[0]["template_card"]
self.assertEqual(
card["quote_area"]["quote_text"],
"总销售价:USD 1,280\n共2项费用,详见上一条明细",
"费用合计:USD 1,280\n共2项费用,详见上一条明细",
)
self.assertEqual(len(card["button_list"]), 3)
self.assertEqual(
@@ -408,7 +408,7 @@ class AirTextInquiryTests(unittest.TestCase):
follow = file_extras[-1].get("followup_card") or {}
self.assertTrue(str(follow.get("task_id") or "").startswith("deal-"))
deal_rows = {r["keyname"]: r["value"] for r in follow.get("horizontal_content_list") or []}
self.assertIn("总销售价", deal_rows)
self.assertIn("费用合计", deal_rows)
self.assertNotIn("时效", deal_rows)
self.assertNotIn("历史报价", deal_rows)
self.assertNotIn("工单状态", deal_rows)
@@ -873,7 +873,7 @@ class AirTextInquiryTests(unittest.TestCase):
self.assertNotIn("title", card["quote_area"])
self.assertEqual(
quote_text,
"总销售价:USD 1,280\n共2项费用,详见上一条明细",
"费用合计:USD 1,280\n共2项费用,详见上一条明细",
)
self.assertNotIn("时效:", quote_text)
numeric = copy.tms_hit_wecom_payload(
@@ -888,7 +888,7 @@ class AirTextInquiryTests(unittest.TestCase):
)["template_card"]["quote_area"]["quote_text"]
self.assertEqual(
numeric,
"总销售价:CNY 14669.60\n共1项费用,详见上一条明细",
"费用合计:CNY 14669.60\n共1项费用,详见上一条明细",
)
self.assertEqual(copy.money_text("14669.60", ""), "14669.60")
self.assertNotIn(copy.collab_hint("WO202609140001"), quote_text)
@@ -928,11 +928,11 @@ class AirTextInquiryTests(unittest.TestCase):
self.assertIn("WO202609220005价格明细", text)
self.assertIn("操作费:CNY 1220.00", text)
self.assertIn("空运费:USD 1250.00", text)
self.assertIn("总销售价:CNY 142998.53", text)
self.assertIn("费用合计:CNY 142998.53", text)
self.assertNotIn("时效", text)
self.assertIn("报价有效期:2026-09-30", text)
card_text = copy.quote_area_safe_text(long_quote, business_line="AIR")
self.assertIn("总销售价:CNY 142998.53", card_text)
self.assertIn("费用合计:CNY 142998.53", card_text)
self.assertIn("详见上一条明细", card_text)
self.assertNotIn("操作费:CNY 1220.00", card_text)
payload = copy.tms_hit_wecom_payload(
@@ -965,7 +965,7 @@ class AirTextInquiryTests(unittest.TestCase):
card_text = copy.quote_area_safe_text(quote, business_line="AIR")
self.assertEqual(
card_text,
"总销售价:CNY 183370\n共3项费用,详见上一条明细",
"费用合计:CNY 183370\n共3项费用,详见上一条明细",
)
payload = copy.tms_hit_wecom_payload(
work_order_no="WO202609220006", quote=quote
@@ -979,7 +979,7 @@ class AirTextInquiryTests(unittest.TestCase):
)
self.assertIn("ZUH-CAN-CRK", detail)
self.assertIn("运输费:CNY 11.0", detail)
self.assertIn("总销售价:CNY 183370", detail)
self.assertIn("费用合计:CNY 183370", detail)
rows = {
x["keyname"]: x["value"]
for x in copy.scheme_horizontal_rows(
@@ -998,7 +998,7 @@ class AirTextInquiryTests(unittest.TestCase):
)
self.assertEqual(
scheme["template_card"]["quote_area"]["quote_text"],
"总销售价:CNY 183370\n共3项费用,详见上一条明细",
"费用合计:CNY 183370\n共3项费用,详见上一条明细",
)
def test_air_hit_sends_detail_text_before_card(self) -> None:
@@ -1072,7 +1072,7 @@ class AirTextInquiryTests(unittest.TestCase):
self.assertNotIn("title", card["quote_area"])
self.assertEqual(
quote_text,
"总销售价:USD 1,280\n共2项费用,详见上一条明细",
"费用合计:USD 1,280\n共2项费用,详见上一条明细",
)
self.assertNotIn("sub_title_text", card)
rows = {x["keyname"]: x["value"] for x in card["horizontal_content_list"]}
@@ -1124,7 +1124,7 @@ class AirTextInquiryTests(unittest.TestCase):
self.assertIn("询价工单 WO202608210017", text)
self.assertIn("报价单已发送,请跟进成交状态", text)
self.assertIn("咨询成交状态:请输入成交 / 未成交 (未成交需补充原因)/ 协商中。", text)
self.assertIn("总销售价:CNY 722.5", text)
self.assertIn("费用合计:CNY 722.5", text)
self.assertNotIn("时效", text)
self.assertNotIn("- / -", text)
self.assertIn("报价有效期:2026-08-24", text)
@@ -1144,7 +1144,7 @@ class AirTextInquiryTests(unittest.TestCase):
self.assertEqual(
rows,
{
"总销售价": "CNY 722.5",
"费用合计": "CNY 722.5",
"报价有效期": "2026-08-24",
},
)
@@ -105,6 +105,11 @@ class AttachmentUploadH5Tests(unittest.TestCase):
self.assertIn('class="card"', ok)
self.assertIn('class="card"', dead)
self.assertIn("过期", dead)
self.assertIn("closeWindow", ok)
self.assertIn("wecom-jssdk", ok)
self.assertIn("jsapi-signature", ok)
self.assertNotIn("closeWindow", page)
self.assertNotIn("closeWindow", dead)
def test_post_png_rejected(self) -> None:
token = self._open_card()
@@ -90,6 +90,22 @@ class AttachWaveTests(unittest.TestCase):
self.assertIn("纽约港", oral)
self.assertEqual(classify_multi_text(oral), "multi")
def test_one_sea_quote_filename_is_single(self) -> None:
"""只传一份海运报价单:文件名带海运,表里再写运输方式,不能报同一方式两段。"""
from agent.policy.multi_segments import classify_multi_text
self.book.open_wait("s1", precede_text="", token="tok1", now=1.0)
self.book.add_files("s1", ["WO202608250012_海运报价单.xlsx"], now=2.0)
self.book.complete_material(
"s1",
0,
text="运输方式:海运\n起运港:纽约港\n目的港:巴生西港\n海运询价",
)
oral = self.book.compose_oral("s1")
self.assertIn("段1:海运", oral)
self.assertIn("运输方式:海运", oral)
self.assertEqual(classify_multi_text(oral), "single")
def test_ltl_filename_counts_as_land_segment(self) -> None:
"""零担询价表文件名没有「陆运」二字,也要和海运表切成两段。"""
from agent.policy.multi_segments import classify_multi_text
+8
View File
@@ -150,9 +150,17 @@ class TestDealOutcome(unittest.TestCase):
def test_harvest_inline_lost_reason(self) -> None:
self.assertEqual(harvest_inline_lost_reason("未成交,原因客户不想要了"), "客户不想要了")
self.assertEqual(
harvest_inline_lost_reason("WO202609220014@询价小助手 未成交,原因客户不想送货了"),
"客户不想送货了",
)
self.assertEqual(harvest_inline_lost_reason("未成交,客户不想要了"), "客户不想要了")
self.assertEqual(harvest_inline_lost_reason("未成交:价格太高"), "价格太高")
self.assertEqual(harvest_inline_lost_reason("未成交原因客户不想要了"), "客户不想要了")
self.assertEqual(
harvest_inline_lost_reason("WO202609240010未成交,原因客户不需要了@询价小助手"),
"客户不需要了",
)
self.assertEqual(harvest_inline_lost_reason("未成交"), "")
self.assertEqual(harvest_inline_lost_reason("未成交吗"), "")
self.assertEqual(harvest_inline_lost_reason("成交"), "")
@@ -62,6 +62,18 @@ class TestTimeoutCode(unittest.TestCase):
timeout_code(wait_kind=WAIT_PRODUCT_QUOTE, business_line="LAND", transport_class="急件"),
"land_hk_express",
)
self.assertEqual(
timeout_code(
wait_kind=WAIT_PRODUCT_QUOTE,
business_line="LAND",
transport_class="普件(常用线路/常规车型)",
),
"land_common",
)
self.assertEqual(
timeout_code(wait_kind=WAIT_PRODUCT_QUOTE, business_line="LAND", transport_class="常用线路"),
"",
)
self.assertEqual(
timeout_code(wait_kind=WAIT_PRODUCT_QUOTE, business_line="SEA", transport_class=""),
"",
+24
View File
@@ -123,6 +123,30 @@ class OralExtractTests(unittest.TestCase):
self.assertEqual(bulk["facts"]["包装方式"], "散货")
self.assertNotIn("包装方式", bulk["missing"])
def test_place_stops_before_cargo_and_price(self) -> None:
"""目的地只留地名。后面的品名、包装、的价格不能粘在地名上。"""
from agent.schema.field_validate import validate_required_fields
check = validate_required_fields(
facts={
"目的港": "吉隆坡,普货,散货的价格",
"品名": "普货",
"包装方式": "散货",
"报价日期": "2026-09-29",
},
business_line="AIR",
)
self.assertEqual(check["facts"]["目的港"], "吉隆坡")
self.assertEqual(check["facts"]["品名"], "普货")
self.assertEqual(check["facts"]["包装方式"], "散货")
bare = validate_required_fields(
facts={"目的港": "洛杉矶,茶叶,纸箱的价格"},
business_line="AIR",
)
self.assertEqual(bare["facts"]["目的港"], "洛杉矶")
self.assertEqual(bare["facts"]["品名"], "茶叶")
self.assertEqual(bare["facts"]["包装方式"], "纸箱")
def test_fly_route_overwrites_stale_nkg_kul(self) -> None:
from agent.schema.field_validate import harvest_oral_measures
from agent.schema.tms_air_query import assemble_air_query
+17 -3
View File
@@ -115,8 +115,9 @@ class LandOptionsTest(unittest.TestCase):
self.assertIn("6.中港零担/集拼", text)
self.assertIn("线路类别(必填):\n1.1国内长途/零担", text)
self.assertIn("6.1中港/中亚/中欧", text)
self.assertIn("运输分类(必填):\n1.普件(东南亚、中港常用线路/常规车型)", text)
self.assertIn("4.急件", text)
self.assertIn("运输分类(必填):\n1.普件(常用线路/常规车型)", text)
self.assertIn("2.普件(东南亚、中港常用线路/常规车型)", text)
self.assertIn("5.急件", text)
self.assertNotIn("请一次选择:运输类型", text)
self.assertNotIn("可选择回复", text)
self.assertNotIn("填写样例见图", text)
@@ -185,13 +186,26 @@ class LandOptionsTest(unittest.TestCase):
pasted = (
"运输类型(必填):2\n"
"线路类别(必填):2.1\n"
"运输分类(必填):4"
"运输分类(必填):5"
)
got = harvest_land_options(pasted)
self.assertEqual(got.get("运输类型"), "国内运输整车")
self.assertEqual(got.get("线路类别"), "国内长途/零担")
self.assertEqual(got.get("运输分类"), "急件")
def test_common_route_class_only_full_text_or_number(self):
"""新项只认编号 1 或全文;短话和逗号写法不收。"""
full = "普件(常用线路/常规车型)"
self.assertEqual(harvest_land_options(full).get("运输分类"), full)
numbered = harvest_land_options(
"1",
current={"运输类型": "国内运输拼车", "线路类别": "国内长途/零担"},
)
self.assertEqual(numbered.get("运输分类"), full)
self.assertFalse(harvest_land_options("常用线路").get("运输分类"))
self.assertFalse(harvest_land_options("常规车型").get("运输分类"))
self.assertFalse(harvest_land_options("普件,常用线路/常规车型").get("运输分类"))
def test_route_code_infers_type_when_empty(self):
got = harvest_land_options("1.1")
self.assertEqual(got.get("运输类型"), "国内运输拼车")
+17 -7
View File
@@ -239,9 +239,9 @@ class LandRouteOptionsTests(unittest.TestCase):
self.assertGreater(eta1, fee1)
self.assertLess(eta1, od2)
self.assertGreater(eta2, fee2)
self.assertIn("总成本价:CNY 17100", text)
valid = text.find("报价有效期:", text.find("总成本价:"))
self.assertGreater(valid, text.find("总成本价:"))
self.assertIn("费用合计:CNY 17100", text)
valid = text.find("报价有效期:", text.find("费用合计:"))
self.assertGreater(valid, text.find("费用合计:"))
def test_land_detail_and_group_use_selected_route_valid_until(self) -> None:
"""选定广西这条后:私聊明细和群 TMS 段同一套正文,不摊开没选的线路。"""
@@ -312,9 +312,9 @@ class LandRouteOptionsTests(unittest.TestCase):
self.assertIn("运输费:CNY 7500.00", detail)
self.assertIn("广西壮族自治区 → 胡志明", detail)
self.assertIn("运输费:CNY 12500.00", detail)
self.assertIn("总成本价:CNY 20000", detail)
self.assertIn("费用合计:CNY 20000", detail)
self.assertIn("报价有效期:2026-10-31", detail)
self.assertGreater(detail.find("报价有效期:"), detail.find("总成本价:"))
self.assertGreater(detail.find("报价有效期:"), detail.find("费用合计:"))
self.assertNotIn("9600", detail)
self.assertNotIn("时效:N+3 / N+3", detail)
brief = copy.sea_group_brief(
@@ -342,12 +342,12 @@ class LandRouteOptionsTests(unittest.TestCase):
self.assertIn("上海-广西壮族自治区-胡志明", tms_block)
self.assertIn("上海 → 广西壮族自治区", tms_block)
self.assertIn("运输费:CNY 12500.00", tms_block)
self.assertIn("总成本价:CNY 20000", tms_block)
self.assertIn("费用合计:CNY 20000", tms_block)
self.assertIn("报价有效期:2026-10-31", tms_block)
self.assertNotIn("TMS标准报价", tms_block)
self.assertNotIn("价格明细", tms_block)
self.assertNotIn("9600", tms_block)
self.assertNotIn("总成本价:-", tms_block)
self.assertNotIn("费用合计:-", tms_block)
self.assertNotIn("时效:-", tms_block)
self.assertNotIn("时效:N+3 / N+3", tms_block)
self.assertIn("请产品人员回复“确认报价”确定采用TMS报价。", brief)
@@ -653,6 +653,16 @@ class LandRouteOptionsTests(unittest.TestCase):
self.assertEqual(seqs, {1})
self.assertTrue(any("报价方案" in x or "生成Excel" in x for x in replies[-3:]))
def test_ok_page_auto_closes_wecom_h5(self) -> None:
from agent.channel.h5.land_route_page import expired_land_route_page, render_land_route_ok
ok = render_land_route_ok()
dead = expired_land_route_page()
self.assertIn("closeWindow", ok)
self.assertIn("wecom-jssdk", ok)
self.assertIn("jsapi-signature", ok)
self.assertNotIn("closeWindow", dead)
if __name__ == "__main__":
unittest.main()
@@ -71,6 +71,25 @@ class SplitTests(unittest.TestCase):
def test_same_mode_twice_is_rejected(self) -> None:
self.assertEqual(classify_multi_text("段1:海运\n起运港:A\n段2:海运\n目的港:B"), "same_mode")
def test_one_sea_quote_sheet_is_single(self) -> None:
"""测服 WO202608250012 海运报价单:文件名标段1,表里再写运输方式,仍是一段。"""
oral = (
"段1:海运\n"
"运输方式:海运\n"
"起运港:纽约港\n"
"目的港:巴生西港\n"
"品名:机器设备\n"
"海运询价"
)
self.assertEqual(classify_multi_text(oral), "single")
def test_unlabeled_repeated_sea_is_single(self) -> None:
"""报价单原文没有段标、海运出现两次,不能拆开再发。"""
self.assertEqual(
classify_multi_text("运输方式:海运\n起运港:A\n备注:海运询价"),
"single",
)
def test_explicit_markers_ignore_mode_words_in_body(self) -> None:
"""文件名先标了段,表里再写「运输方式:海运」不能当成又一段海运。"""
oral = (
+157 -34
View File
@@ -15,6 +15,9 @@ if ROOT not in sys.path:
from agent.policy import inquiry_copy as copy
from agent.schema.quote_adjust import (
AdjustExtract,
AdjustItem,
adjustable_rows,
apply_adjust,
effective_quote,
extract_adjust,
@@ -164,6 +167,126 @@ class QuoteAdjustTests(unittest.TestCase):
self.assertFalse(got.ok)
self.assertEqual(got.code, "none_matched")
def test_sea_subtotal_and_bucket_lines(self) -> None:
src = {
"currency": "CNY",
"total": "2000",
"fee_lines": [
{"name": "操作费", "amount": "1000", "currency": "CNY"},
{"name": "箱单费", "amount": "200", "currency": "CNY"},
],
"polCostItems": [
{"name": "操作费", "amount": "1000", "currency": "CNY"},
{"name": "箱单费", "amount": "200", "currency": "CNY"},
],
"fee_total_polCostItems": "1200",
}
ext = AdjustExtract(
is_adjust=True,
items=[
AdjustItem("操作费", "percent", "up", 10),
AdjustItem("箱单费", "percent", "up", 5),
AdjustItem("始发港费用小计", "percent", "up", 12),
],
)
got = apply_adjust(src, ext, business_line="SEA")
self.assertTrue(got.ok)
self.assertEqual(got.unmatched, [])
names = {row["name"]: row["amount"] for row in got.quote["polCostItems"]}
self.assertEqual(names["操作费"], "1100.00")
self.assertEqual(names["箱单费"], "210.00")
self.assertEqual(got.quote["fee_total_polCostItems"], "1344.00")
def test_sea_pol_subtotal_and_total_up_ten_percent(self) -> None:
"""协同话术:始发港费用小计 + 费用合计 各提升 10%。"""
src = {
"currency": "CNY",
"total": "14300",
"fee_lines": [
{"name": "操作费", "amount": "1220", "currency": "CNY"},
{"name": "海运费", "amount": "USD 1250", "currency": "USD"},
],
"polCostItems": [
{"name": "操作费", "amount": "1220", "currency": "CNY"},
{"name": "箱单费", "amount": "50", "currency": "CNY"},
],
"oceanCostItems": [
{"name": "海运费", "amount": "1250", "currency": "USD"},
],
"podCostItems": [
{"name": "操作费", "amount": "240", "currency": "USD"},
],
"fee_total_polCostItems": "2335",
"fee_total_oceanCostItems": "8778.75",
"fee_total_podCostItems": "2985",
}
names = [str(r.get("name") or "") for r in adjustable_rows(src)]
text = "将始发港费用小计提升10%\n费用合计提升10%"
ext = extract_adjust(text, fee_names=names, allow_model=False)
self.assertTrue(ext.is_adjust)
fee_names = [x.fee_name for x in ext.items]
self.assertIn("始发港费用小计", fee_names)
self.assertIn("费用合计", fee_names)
# 即使模型误标 wants_total,有点名费目时也不能只改合计
ext.wants_total = True
got = apply_adjust(src, ext, business_line="SEA")
self.assertTrue(got.ok)
self.assertEqual(got.quote["fee_total_polCostItems"], "2568.50")
self.assertEqual(got.quote["total"], "15730.00")
body = copy.adjust_group_body(got.quote, business_line="SEA")
self.assertIn("始发港费用小计", body)
self.assertIn("海运费小计", body)
self.assertIn("目的港费用小计", body)
def test_same_fee_name_keeps_its_own_amount(self) -> None:
src = {
"currency": "USD",
"total": "500",
"fee_lines": [
{"name": "文件费", "amount": "450", "currency": "USD"},
{"name": "文件费", "amount": "50", "currency": "USD"},
],
"polCostItems": [
{"name": "文件费", "amount": "450", "currency": "USD"},
{"name": "文件费", "amount": "50", "currency": "USD"},
],
}
ext = AdjustExtract(is_adjust=True, items=[AdjustItem("文件费", "percent", "up", 10)])
got = apply_adjust(src, ext, business_line="SEA")
self.assertTrue(got.ok)
amounts = [row["amount"] for row in got.quote["polCostItems"]]
self.assertEqual(amounts, ["495.00", "55.00"])
def test_land_same_fee_name_each_leg_and_total(self) -> None:
src = {
"business_line": "LAND",
"currency": "CNY",
"total": "10000",
"fee_lines": [
{"name": "运输费", "amount": "7000", "currency": "CNY"},
{"name": "运输费", "amount": "3000", "currency": "CNY"},
],
"landSegments": [
{"origin": "上海", "destination": "谅山", "costPrice": "7000", "segmentSequence": 1, "routeLegSequence": 1},
{"origin": "谅山", "destination": "河内", "costPrice": "3000", "segmentSequence": 1, "routeLegSequence": 2},
],
}
ext = AdjustExtract(
is_adjust=True,
items=[
AdjustItem("运输费", "percent", "up", 10),
AdjustItem("总成本价", "percent", "up", 10),
],
)
got = apply_adjust(src, ext, business_line="LAND")
self.assertTrue(got.ok)
self.assertEqual(got.unmatched, [])
amounts = sorted(row["amount"] for row in got.quote["fee_lines"])
self.assertEqual(amounts, ["3300.00", "7700.00"])
costs = [leg["costPrice"] for leg in got.quote["landSegments"]]
self.assertEqual(costs, ["7700.00", "3300.00"])
self.assertEqual(got.quote["total"], "11000.00")
def test_overlay_manual_wins(self) -> None:
tms = _quote(("运输费", "1000", "CNY"), ("卸货费", "200", "CNY"), total="1200")
manual = _quote(("卸货费", "300", "CNY"), ("配送费", "20", "CNY"), total="320")
@@ -219,17 +342,18 @@ class QuoteAdjustTests(unittest.TestCase):
)
self.assertAlmostEqual(second.profit_point or 0, 30.0)
def test_total_label_cost_until_sales_adjust(self) -> None:
self.assertEqual(total_price_label("AIR"), "总销售价")
self.assertEqual(total_price_label("SEA"), "总成本价")
self.assertEqual(total_price_label("LAND"), "总成本价")
self.assertEqual(total_price_label("", transport_mode="空运"), "总销售价")
self.assertEqual(total_price_label("", transport_mode="陆运"), "总成本价")
def test_total_label_is_fee_total_for_every_line(self) -> None:
"""销售侧汇总一律叫费用合计,空运/海运/陆运、TMS/调价都一样。"""
self.assertEqual(total_price_label("AIR"), "费用合计")
self.assertEqual(total_price_label("SEA"), "费用合计")
self.assertEqual(total_price_label("LAND"), "费用合计")
self.assertEqual(total_price_label("", transport_mode="空运"), "费用合计")
self.assertEqual(total_price_label("", transport_mode="陆运"), "费用合计")
tms = {"source": "TMS", "source_label": "TMS标准报价"}
self.assertEqual(total_price_label("LAND", quote=tms), "总成本价")
self.assertEqual(total_price_label("SEA", quote={"source": "PRODUCT"}), "总成本价")
self.assertEqual(total_price_label("LAND", quote={"source": "SALES_ADJUST"}), "总销售价")
self.assertEqual(total_price_label("AIR", quote=tms), "总销售价")
self.assertEqual(total_price_label("LAND", quote=tms), "费用合计")
self.assertEqual(total_price_label("SEA", quote={"source": "PRODUCT"}), "费用合计")
self.assertEqual(total_price_label("LAND", quote={"source": "SALES_ADJUST"}), "费用合计")
self.assertEqual(total_price_label("AIR", quote=tms), "费用合计")
def test_tms_land_card_uses_cost_not_sale(self) -> None:
q = _quote(("运输费", "121.00", "CNY"), total="121.00")
@@ -239,7 +363,7 @@ class QuoteAdjustTests(unittest.TestCase):
work_order_no="WO202609200005", quote=q, business_line="LAND"
)
self.assertEqual(detail["template_card"]["source"]["desc"], "TMS标准报价")
self.assertIn("总成本价:CNY 121.00", detail["template_card"]["quote_area"]["quote_text"])
self.assertIn("费用合计:CNY 121.00", detail["template_card"]["quote_area"]["quote_text"])
self.assertNotIn("总销售价", detail["template_card"]["quote_area"]["quote_text"])
scheme = copy.scheme_wecom_payload(
work_order_no="WO202609200005",
@@ -248,9 +372,9 @@ class QuoteAdjustTests(unittest.TestCase):
transport_mode="陆运",
)
self.assertEqual(scheme["template_card"]["source"]["desc"], "TMS标准报价")
self.assertIn("总成本价:CNY 121.00", scheme["template_card"]["quote_area"]["quote_text"])
self.assertIn("费用合计:CNY 121.00", scheme["template_card"]["quote_area"]["quote_text"])
deal = copy.deal_horizontal_rows(quote=q, business_line="LAND")
self.assertEqual(deal[0]["keyname"], "总成本价")
self.assertEqual(deal[0]["keyname"], "费用合计")
def test_adjust_confirm_uses_sale_total_name(self) -> None:
src = _quote(("运输费", "110", "CNY"), total="110")
@@ -261,24 +385,22 @@ class QuoteAdjustTests(unittest.TestCase):
)
self.assertTrue(land.ok)
names = [x.get("name") for x in land.applied]
self.assertIn("总销售价", names)
self.assertNotIn("合计", names)
self.assertIn("费用合计", names)
self.assertNotIn("总销售价", names)
self.assertNotIn("总成本价", names)
text = copy.adjust_confirm(land.applied)
self.assertIn("总销售价在当前报价基础上", text)
self.assertNotIn("合计在当前报价基础上", text)
self.assertIn("费用合计在当前报价基础上", text)
body = copy.adjust_group_body(land.quote, business_line="LAND")
self.assertIn("总销售价:", body)
self.assertNotIn("合计:", body)
self.assertIn("费用合计:", body)
air = apply_adjust(
src,
extract_adjust("总销售价改为100CNY", fee_names=["运输费"], allow_model=False),
business_line="AIR",
)
self.assertIn("总销售价", [x.get("name") for x in air.applied])
self.assertNotIn("合计", [x.get("name") for x in air.applied])
self.assertIn("费用合计", [x.get("name") for x in air.applied])
note = copy.adjust_ledger_remark(land.applied)
self.assertTrue(note.startswith("销售调价:"))
self.assertIn("总销售价", note)
self.assertIn("费用合计", note)
def test_quote_copy_uses_line_and_source_total_name(self) -> None:
sea = {
@@ -293,16 +415,17 @@ class QuoteAdjustTests(unittest.TestCase):
"source": "TMS",
"fee_rows": [{"name": "空运费", "amount": "700", "currency": "CNY"}],
}
self.assertIn("总成本价:CNY 2287.65", copy.quote_area_fee_text(sea, business_line="SEA"))
self.assertIn("总销售价:CNY 722.5", copy.quote_area_fee_text(air, business_line="AIR"))
self.assertIn("费用合计:CNY 2287.65", copy.quote_area_fee_text(sea, business_line="SEA"))
self.assertIn("费用合计:CNY 722.5", copy.quote_area_fee_text(air, business_line="AIR"))
self.assertNotIn("总销售价合计", copy.quote_area_fee_text(air, business_line="AIR"))
self.assertNotIn("总销售价:CNY 2287.65", copy.quote_area_fee_text(sea, business_line="SEA"))
self.assertIn("总成本价:CNY 2287.65", "\n".join(copy.quote_detail_lines(sea, business_line="SEA")))
self.assertNotIn("总销售价", copy.quote_area_fee_text(sea, business_line="SEA"))
self.assertNotIn("总成本价", copy.quote_area_fee_text(sea, business_line="SEA"))
self.assertIn("费用合计:CNY 2287.65", "\n".join(copy.quote_detail_lines(sea, business_line="SEA")))
rows = copy.deal_horizontal_rows(quote=air, business_line="AIR")
self.assertEqual(rows[0]["keyname"], "总销售价")
self.assertEqual(rows[0]["keyname"], "费用合计")
self.assertNotIn("时效", [x["keyname"] for x in rows])
land_rows = copy.deal_horizontal_rows(quote=sea, business_line="LAND")
self.assertEqual(land_rows[0]["keyname"], "总成本价")
self.assertEqual(land_rows[0]["keyname"], "费用合计")
self.assertIn("时效", [x["keyname"] for x in land_rows])
def test_adjust_cards_use_sales_adjust_not_tms(self) -> None:
@@ -314,7 +437,7 @@ class QuoteAdjustTests(unittest.TestCase):
work_order_no="WO202609200004", quote=q, business_line="LAND"
)
self.assertEqual(detail["template_card"]["source"]["desc"], "销售调价")
self.assertIn("总销售价:CNY 121.00", detail["template_card"]["quote_area"]["quote_text"])
self.assertIn("费用合计:CNY 121.00", detail["template_card"]["quote_area"]["quote_text"])
self.assertNotIn("总成本价", detail["template_card"]["quote_area"]["quote_text"])
scheme = copy.scheme_wecom_payload(
work_order_no="WO202609200004",
@@ -323,7 +446,7 @@ class QuoteAdjustTests(unittest.TestCase):
transport_mode="陆运",
)
self.assertEqual(scheme["template_card"]["source"]["desc"], "销售调价")
self.assertIn("总销售价:CNY 121.00", scheme["template_card"]["quote_area"]["quote_text"])
self.assertIn("费用合计:CNY 121.00", scheme["template_card"]["quote_area"]["quote_text"])
def test_private_eligibility(self) -> None:
self.assertEqual(private_session_can_adjust("已报价", True, ""), "")
@@ -469,10 +592,10 @@ class QuoteAdjustTests(unittest.TestCase):
transport_mode="陆运",
)
tms_block = text.split("TMS报价:", 1)[1]
self.assertIn("总成本价:CNY 110.00", tms_block.split("销售调价:", 1)[0])
self.assertIn("费用合计:CNY 110.00", tms_block.split("销售调价:", 1)[0])
self.assertIn("销售调价:", text)
self.assertIn("总销售价:CNY 100.00", text.split("销售调价:", 1)[1])
self.assertNotIn("总销售价:CNY 100.00", tms_block.split("销售调价:", 1)[0])
self.assertIn("费用合计:CNY 100.00", text.split("销售调价:", 1)[1])
self.assertNotIn("费用合计:CNY 100.00", tms_block.split("销售调价:", 1)[0])
def test_group_brief_adjust_alone_does_not_become_tms(self) -> None:
"""没单独传入 TMS 时,调后价也不能写进 TMS 段。"""
@@ -492,7 +615,7 @@ class QuoteAdjustTests(unittest.TestCase):
self.assertIn("TMS报价:\nTMS 暂无匹配报价", text)
self.assertIn("产品人员可通过“纯文字”告知新报价,或直接将新报价excel表发给智能体。", text)
self.assertIn("销售调价:", text)
self.assertIn("总销售价:CNY 100.00", text.split("销售调价:", 1)[1])
self.assertIn("费用合计:CNY 100.00", text.split("销售调价:", 1)[1])
if __name__ == "__main__":
+28 -5
View File
@@ -26,6 +26,9 @@ class SeaOptionsTest(unittest.TestCase):
self.assertEqual(canonicalize_sea_class("port to port"), "Port to Port")
self.assertEqual(canonicalize_sea_class("P2P"), "Port to Port")
self.assertEqual(canonicalize_sea_class("港到港"), "Port to Port")
glued = "15件,Port\u00a0to\u00a0Port\n贸易条款:DOOR-DOOR"
self.assertEqual(canonicalize_sea_class(glued), "Port to Port")
self.assertEqual(harvest_sea_class(glued, {}).get("运输分类"), "Port to Port")
def test_other_alias(self):
self.assertEqual(canonicalize_sea_class("其他"), "其他")
@@ -42,6 +45,14 @@ class SeaOptionsTest(unittest.TestCase):
got = harvest_sea_class("运输分类:其他", {"运输分类": "Port to Port"})
self.assertEqual(got["运输分类"], "其他")
def test_clarify_example_does_not_override_filled_other(self):
"""举例行里的 Port to Port 不能盖掉冒号后的其它/其他。"""
pasted = "1.运输分类(必填):其它\n(如:Port to Port、其他)"
got = harvest_sea_class(pasted, {"运输分类": "Port to Port"})
self.assertEqual(got["运输分类"], "其他")
other = harvest_sea_class("运输分类(必填):其他\n(如:Port to Port、其他)", {})
self.assertEqual(other["运输分类"], "其他")
def test_door_to_door_not_kept_as_trade_terms(self):
text = "海运、纽约港到巴生西港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件"
got = drop_invented_door_trade_terms(text, {"贸易条款": "门到门", "品名": "机器设备"})
@@ -49,11 +60,23 @@ class SeaOptionsTest(unittest.TestCase):
kept = drop_invented_door_trade_terms("贸易条款:门到门", {"贸易条款": "门到门"})
self.assertEqual(kept["贸易条款"], "门到门")
def test_prompt_lists_two_options_only(self):
text = sea_class_prompt()
self.assertIn("Port to Port", text)
self.assertIn("其他", text)
self.assertNotIn("门到门", text)
def test_wo012_trade_port_class_other(self):
"""条款写 Port to Port、分类写其它:二者各归各栏,禁止互盖。"""
text = (
"运输方式:海运\n起运港:纽约港\n目的港:巴生西港\n品名:机器设备\n"
"货物数量:15件\n整柜或拼柜:整柜\n箱型箱量:40HQ*1\n"
"贸易条款:Port to Port\n运输分类:其它\n报价日期:2026-09-29"
)
got = harvest_sea_class(text, {"贸易条款": "门到门", "运输分类": "Port to Port"})
self.assertEqual(got.get("运输分类"), "其他")
got = drop_invented_door_trade_terms(text, {"贸易条款": "门到门", "运输分类": "其他"})
self.assertEqual(got.get("贸易条款"), "Port to Port")
self.assertEqual(got.get("运输分类"), "其他")
def test_trade_line_port_does_not_set_class_when_class_labeled_other(self):
text = "贸易条款:Port to Port\n运输分类:其它"
self.assertEqual(harvest_sea_class(text, {}).get("运输分类"), "其他")
if __name__ == "__main__":
+35 -3
View File
@@ -143,6 +143,23 @@ class SeaTextInquiryTests(unittest.TestCase):
self.assertEqual(check["facts"]["货量"], "15件")
self.assertNotIn("货量", check["missing"])
def test_container_shaped_cargo_qty_uses_pieces(self) -> None:
"""货量被抽成箱型时,改用件数。20件、15件同一条规则。"""
facts = {
"起运港": "广州港",
"目的港": "巴生港",
"品名": "机器设备",
"货量": "40HQ*1",
"件数": "20件",
"整柜或拼柜": "整柜",
"箱型箱量": "40HQ*1",
"贸易条款": "DOOR-DOOR",
"运输分类": "Port to Port",
}
check = validate_required_fields(facts=facts, business_line="SEA")
self.assertEqual(check["facts"]["货量"], "20件")
self.assertEqual(check["facts"]["箱型箱量"], "40HQ*1")
def test_sea_missing_container_only_asks_container(self) -> None:
facts = {
"起运港": "上海",
@@ -478,7 +495,7 @@ class SeaTmsCardTests(unittest.TestCase):
self.assertIn("海运费:USD 575", text)
self.assertIn("海运费小计:CNY 4038", text)
self.assertIn("目的港费用小计:CNY 470", text)
self.assertIn("总成本价:CNY 6952.77", text)
self.assertIn("费用合计:CNY 6952.77", text)
self.assertNotIn("时效", text)
def test_long_fee_lines_go_to_text_not_clipped_card(self) -> None:
@@ -515,14 +532,14 @@ class SeaTmsCardTests(unittest.TestCase):
"封条费(SEAI):CNY 50.00",
"设备费:CNY 50.00",
"报关费:CNY 250.00",
"总成本价:CNY 2287.65",
"费用合计:CNY 2287.65",
]
),
)
self.assertNotIn("时效", text)
self.assertNotIn("报价有效期", text)
card_text = copy.quote_area_safe_text(long_quote, business_line="SEA")
self.assertIn("总成本价:CNY 2287.65", card_text)
self.assertIn("费用合计:CNY 2287.65", card_text)
self.assertNotIn("海运费:USD 100.00\n燃油附加费", card_text)
self.assertLessEqual(len(card_text), 80)
payload = copy.sea_tms_hit_wecom_payload(
@@ -1120,6 +1137,21 @@ class SeaGroupCollabFieldTests(unittest.TestCase):
self.assertEqual(str(rows["海运费"].get("amount") or ""), "100.00")
self.assertEqual(str(rows["燃油附加费"].get("amount") or ""), "15.00")
def test_air_unit_qty_amount_keeps_air_freight_name(self) -> None:
"""空运费 单价/数量/金额:费用名是空运费,金额用「金额」不是单价。"""
from agent.llm.extract_text import extract_product_quote
quote = extract_product_quote(
"空运段,空运费 单价CNY99.00,数量100 ,金额CNY199.00@询价小助手"
)
rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
self.assertIn("空运费", rows)
self.assertNotIn("海运费", rows)
self.assertEqual(str(rows["空运费"].get("amount") or ""), "199.00")
self.assertEqual(str(rows["空运费"].get("currency") or ""), "CNY")
self.assertEqual(str(rows["空运费"].get("unit_price") or ""), "99.00")
self.assertEqual(str(quote.get("total") or ""), "199.0")
def test_fee_line_text_is_a_product_quote(self) -> None:
"""产品按行改价:码头操作费100CNY / 报关费改为300CNY,必须抽出费用名,不能只当海运费合计。"""
from agent.llm.extract_text import extract_product_quote
+6
View File
@@ -34,6 +34,12 @@
<artifactId>poi-ooxml</artifactId>
<version>4.1.2</version>
</dependency>
<!-- 模板预览:PDF→PNG 等比例渲染(替代 LibreOffice png 滤镜的非等比拉伸) -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>2.0.29</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
@@ -643,11 +643,15 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
});
}
/**
* 查找已缓存预览图。
* 只用 v2 文件名:旧版 LibreOffice 直转 PNG 可能非等比拉伸,废弃 *-snapshot.png。
*/
private Path findCachedSnapshot(String versionId) throws Exception {
Path previewDir = Paths.get(StringUtils.defaultIfBlank(uploadPath, "."), BIZ_PATH, "preview");
Files.createDirectories(previewDir);
Path cachedPng = previewDir.resolve(versionId + "-snapshot.png");
Path cachedPdf = previewDir.resolve(versionId + "-snapshot.pdf");
Path cachedPng = previewDir.resolve(versionId + "-snapshot-v4.png");
Path cachedPdf = previewDir.resolve(versionId + "-snapshot-v4.pdf");
if (Files.isRegularFile(cachedPng) && Files.size(cachedPng) > 0) {
return cachedPng;
}
@@ -660,8 +664,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
private Path generateAndCacheSnapshot(InquiryQuoteTemplateVersion ver) throws Exception {
Path previewDir = Paths.get(StringUtils.defaultIfBlank(uploadPath, "."), BIZ_PATH, "preview");
Files.createDirectories(previewDir);
Path cachedPng = previewDir.resolve(ver.getId() + "-snapshot.png");
Path cachedPdf = previewDir.resolve(ver.getId() + "-snapshot.pdf");
Path cachedPng = previewDir.resolve(ver.getId() + "-snapshot-v4.png");
Path cachedPdf = previewDir.resolve(ver.getId() + "-snapshot-v4.pdf");
Path source = resolveObjectPath(ver.getObjectKey());
QuoteTemplateSnapshotRenderer.PreviewBytes rendered = snapshotRenderer.render(source, ver.getId());
Path target = rendered.isImage() ? cachedPng : cachedPdf;
@@ -871,8 +875,31 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
out.put("quote_date", new java.text.SimpleDateFormat("yyyy-MM-dd").format(new Date()));
}
out.put("valid_until", first(q, "valid_until"));
out.put("total_amount", first(q, "total", "total_amount"));
// 预估费用必须用 TMS 总销售价。会话 quote 偶发只有 selected_air_option.salePrice、
// 顶层 total 空时,若仍走模板 SUM,会把增点费+拆板费加成 200(WO040/WO026)。
// 群出单还可能把明细之和误写成 total=200,此时仍以线路 salePrice 为准。
String fromOpt = airOptionSalePrice(q);
String totalAmount = first(q, "total", "total_amount", "salePrice", "totalSalePrice");
if (StringUtils.isBlank(totalAmount) || "-".equals(totalAmount)) {
totalAmount = fromOpt;
} else if (StringUtils.isNotBlank(fromOpt)
&& looksAirInquiry(src, q, businessLine)
&& totalLooksLikeFeeRowSum(q, totalAmount)
&& !sameMoneyAmount(totalAmount, fromOpt)) {
totalAmount = fromOpt;
}
if (StringUtils.isBlank(totalAmount) || "-".equals(totalAmount)) {
// freight 与总价同值时(无分项空运费)也可用
String freight = first(q, "freight");
if (StringUtils.isNotBlank(freight) && !"-".equals(freight)) {
totalAmount = freight;
}
}
out.put("total_amount", totalAmount);
String currency = first(q, "currency");
if (StringUtils.isBlank(currency)) {
currency = airOptionCurrency(q);
}
if (StringUtils.isNotBlank(currency)) {
out.put("currency", currency);
}
@@ -977,6 +1004,11 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
out.put("fee_rows_" + bucket, part);
out.put(bucket, part);
}
// 三段小计:卡片用 fee_total_*,出单写进「始发港/海运/目的港费用汇总」行
String sub = first(q, "fee_total_" + bucket);
if (StringUtils.isNotBlank(sub) && !"-".equals(sub)) {
out.put("fee_total_" + bucket, sub);
}
}
// 陆运线路:透传 landSegments / route_rows,供 routeRegions 填项目一
JSONArray landSegs = QuoteTemplateExcelSupport.parseFeeRows(q.get("route_rows"));
@@ -1071,6 +1103,178 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
return "";
}
/**
* 空运选定线路 / 首条 airOptions 的 salePrice。
* 会话 quote 有时只投影了线路对象,顶层 total 空,出单预估费用必须能落到这个数。
*/
private static String airOptionSalePrice(Map<String, Object> quote) {
if (quote == null || quote.isEmpty()) {
return "";
}
Object selected = quote.get("selected_air_option");
if (selected == null) {
selected = quote.get("selectedAirOption");
}
String fromSelected = salePriceFromOptionObj(selected);
if (StringUtils.isNotBlank(fromSelected)) {
return fromSelected;
}
Object raw = quote.get("airOptions");
if (raw == null) {
raw = quote.get("air_options");
}
JSONArray opts = QuoteTemplateExcelSupport.parseFeeRows(raw);
if (opts.isEmpty()) {
return "";
}
return salePriceFromOptionObj(opts.getJSONObject(0));
}
/** 空运线路币种:选定 > 首条 airOptions。 */
private static String airOptionCurrency(Map<String, Object> quote) {
if (quote == null || quote.isEmpty()) {
return "";
}
Object selected = quote.get("selected_air_option");
if (selected == null) {
selected = quote.get("selectedAirOption");
}
String ccy = currencyFromOptionObj(selected);
if (StringUtils.isNotBlank(ccy)) {
return ccy;
}
Object raw = quote.get("airOptions");
if (raw == null) {
raw = quote.get("air_options");
}
JSONArray opts = QuoteTemplateExcelSupport.parseFeeRows(raw);
if (opts.isEmpty()) {
return "";
}
return currencyFromOptionObj(opts.getJSONObject(0));
}
private static String salePriceFromOptionObj(Object raw) {
JSONObject opt = asJsonObject(raw);
if (opt == null) {
return "";
}
return firstNonBlankLocal(opt.getString("salePrice"), opt.getString("sale_price"),
opt.getString("totalSalePrice"), opt.getString("total"));
}
/**
* 顶层 total 是否只是 fee_lines 金额相加(如 100+100=200)。
* 空运群薄报价常把附加费之和误当总价,真总价在 airOptions.salePrice。
*/
private static boolean totalLooksLikeFeeRowSum(Map<String, Object> quote, String totalText) {
Double totalNum = parseMoneyPlain(totalText);
if (totalNum == null) {
return false;
}
JSONArray rows = QuoteTemplateExcelSupport.parseFeeRows(quote.get("fee_lines"));
if (rows.isEmpty()) {
rows = QuoteTemplateExcelSupport.parseFeeRows(quote.get("fee_rows"));
}
if (rows.isEmpty()) {
rows = QuoteTemplateExcelSupport.parseFeeRows(quote.get("feeItems"));
}
if (rows.isEmpty()) {
return false;
}
double sum = 0.0;
int hit = 0;
for (int i = 0; i < rows.size(); i++) {
JSONObject one = rows.getJSONObject(i);
if (one == null) {
continue;
}
Double amt = parseMoneyPlain(firstNonBlankLocal(
one.getString("amount"), one.getString("value"),
one.getString("salePrice"), one.getString("price")));
if (amt == null) {
continue;
}
sum += amt;
hit++;
}
if (hit <= 0) {
return false;
}
return Math.abs(totalNum - sum) < 0.01;
}
/** 去掉币种前缀后比较金额是否相同。 */
private static boolean sameMoneyAmount(String left, String right) {
Double a = parseMoneyPlain(left);
Double b = parseMoneyPlain(right);
if (a == null || b == null) {
return StringUtils.equals(StringUtils.trimToEmpty(left), StringUtils.trimToEmpty(right));
}
return Math.abs(a - b) < 0.01;
}
/** 从「CNY 7918.25」抽出数字;解不开返回 null。 */
private static Double parseMoneyPlain(String text) {
String raw = StringUtils.trimToEmpty(text);
if (raw.isEmpty() || "-".equals(raw)) {
return null;
}
java.util.regex.Matcher m = java.util.regex.Pattern
.compile("(-?\\d+(?:,\\d{3})*(?:\\.\\d+)?)")
.matcher(raw);
String last = null;
while (m.find()) {
last = m.group(1);
}
if (last == null) {
return null;
}
try {
return Double.parseDouble(last.replace(",", ""));
} catch (NumberFormatException e) {
return null;
}
}
private static String currencyFromOptionObj(Object raw) {
JSONObject opt = asJsonObject(raw);
if (opt == null) {
return "";
}
return StringUtils.trimToEmpty(opt.getString("currency"));
}
private static JSONObject asJsonObject(Object raw) {
if (raw == null) {
return null;
}
if (raw instanceof JSONObject) {
return (JSONObject) raw;
}
if (raw instanceof Map) {
return JSON.parseObject(JSON.toJSONString(raw));
}
if (raw instanceof String) {
String text = StringUtils.trimToEmpty((String) raw);
if (text.isEmpty() || "null".equalsIgnoreCase(text)) {
return null;
}
try {
Object parsed = JSON.parse(text);
if (parsed instanceof JSONObject) {
return (JSONObject) parsed;
}
if (parsed instanceof Map) {
return JSON.parseObject(JSON.toJSONString(parsed));
}
} catch (Exception ignored) {
return null;
}
}
return null;
}
/** 协同项没写,或只写了横杠。横杠是空占位,不能当成已经填过。 */
private static boolean collabBlank(String raw) {
String val = StringUtils.trimToEmpty(raw);
@@ -41,7 +41,7 @@ public class InquiryResponseTimeoutServiceImpl
private static final Map<String, String> ID_TO_TIMEOUT_CODE = new LinkedHashMap<>();
static {
// 陆运产品仅保留 4 项运输分类(与线路解耦);旧逗号写法一次性纠偏
// 陆运产品 5 项运输分类(与线路解耦);旧逗号写法一次性纠偏
LAND_PRODUCT_TYPE_RENAMES.put(
"普件,中港、东南亚常用线路/常规车型",
"普件(东南亚、中港常用线路/常规车型)");
@@ -58,7 +58,8 @@ public class InquiryResponseTimeoutServiceImpl
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000004", "land_sales");
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000005", "sea_product_ptp");
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000006", "sea_product_other");
// 陆运产品运输分类 4 项(编码沿用 land_hk_*,勿再按线路拆 ltl/city/sea)
// 陆运产品运输分类 5 项(编码勿再按线路拆 ltl/city/sea)
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000020", "land_common");
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000007", "land_hk_common");
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000008", "land_hk_cross");
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000009", "land_hk_special");
@@ -503,7 +503,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
ticket.setGoodsName(facts.get("品名"));
ticket.setPieces(parseInt(facts.get("件数")));
ticket.setWeightKg(parseDecimal(firstFact(facts, "重量", "毛重")));
ticket.setVolumeCbm(parseDecimal(facts.get("体积")));
// 体积常写尺寸 1.1*1.2*3,必须相乘;旧解析剥 * 会变成 1.11.23 直接 NumberFormat 炸建单。
ticket.setVolumeCbm(parseVolumeCbm(facts.get("体积")));
ticket.setQuoteDate(parseDate(facts.get("报价日期")));
ticket.setCargoQty(StringUtils.trimToNull(firstFact(facts, "货量", "货物数量")));
ticket.setFclLcl(StringUtils.trimToNull(facts.get("整柜或拼柜")));
@@ -578,7 +579,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
Map<String, String> facts = req.getFacts() == null ? new HashMap<>() : req.getFacts();
Integer pieces = parseInt(facts.get("件数"));
BigDecimal weight = parseDecimal(firstFact(facts, "重量", "毛重"));
BigDecimal volume = parseDecimal(facts.get("体积"));
BigDecimal volume = parseVolumeCbm(facts.get("体积"));
Date quoteDate = parseDate(facts.get("报价日期"));
return list(new LambdaQueryWrapper<InquiryTicket>()
.eq(InquiryTicket::getBusinessLine, req.getBusinessLine().trim().toUpperCase())
@@ -677,15 +678,24 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
return Integer.valueOf(digits);
}
/**
* 重量等:只取第一个数字(100KGS → 100)。坏串返回 null,禁止抛到建单接口。
*/
private static BigDecimal parseDecimal(String raw) {
if (StringUtils.isBlank(raw)) {
return null;
}
String cleaned = raw.replaceAll("[^0-9.]", "");
if (cleaned.isEmpty()) {
return org.jeecg.modules.inquiry.tms.TmsV2RequestMapper.asPositiveDecimal(raw);
}
/**
* 体积:尺寸式相乘得 CBM;普通「2CBM」取数字。失败返回 null,不炸建单。
*/
private static BigDecimal parseVolumeCbm(String raw) {
if (StringUtils.isBlank(raw)) {
return null;
}
return new BigDecimal(cleaned);
return org.jeecg.modules.inquiry.tms.TmsV2RequestMapper.asVolumeCbm(raw);
}
private static Date parseDate(String raw) {
@@ -2,12 +2,23 @@ package org.jeecg.modules.inquiry.template;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.ImageType;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.apache.poi.ss.usermodel.PrintSetup;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.charset.StandardCharsets;
@@ -20,8 +31,10 @@ import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* 管理端报价模板「原表截图」:本机 LibreOffice 把 xlsx 转 PNG(失败则 PDF)。
* 管理端报价模板「原表截图」:本机 LibreOffice 把 xlsx 转 PDF,再用 PDFBox 等比例渲 PNG。
* <p>
* 为何不用 LibreOffice 直接转 PNG:其 pdf→png 滤镜在部分版本会把页内容非等比拉伸,
* 后台预览出现「字被拉高/压扁」。PDFBox 按页 MediaBox 均匀换算 DPI,保证 1:1 像素比例。
* 为何放在主账:后台预览不应依赖智能体进程;与出单 PDF 错开靠文件锁互斥。
* 禁止:接到企微回调 / 智能体出单热路径;禁止本类起第二台 soffice 并行。
* 副作用:写临时目录与预览缓存;短阻塞当前 HTTP 线程(仅管理端预览)。
@@ -30,6 +43,9 @@ import java.util.stream.Stream;
@Component
public class QuoteTemplateSnapshotRenderer {
/** 预览 PNG 渲染 DPI;只影响清晰度,不改变宽高比。 */
private static final float PREVIEW_DPI = 144f;
@Value("${inquiry.quote-template.soffice-path:${LIBREOFFICE_PATH:C:/Program Files/LibreOffice/program/soffice.exe}}")
private String sofficePath;
@@ -73,15 +89,17 @@ public class QuoteTemplateSnapshotRenderer {
try {
Path localXlsx = workDir.resolve("source.xlsx");
Files.copy(xlsxPath, localXlsx, StandardCopyOption.REPLACE_EXISTING);
// 预览专用打印版式:按表内容宽高选横/竖,避免宽表硬塞竖版 A4 显得挤扁
preparePreviewPrintLayout(localXlsx);
Path pdf = convert(soffice, localXlsx, workDir, "pdf:calc_pdf_Export", ".pdf");
if (pdf == null || !Files.isRegularFile(pdf)) {
throw new IllegalStateException("LibreOffice 未生成 PDF");
}
Path png = convert(soffice, pdf, workDir, "png", ".png");
if (png != null && Files.isRegularFile(png) && Files.size(png) > 0) {
return new PreviewBytes(Files.readAllBytes(png), "image/png");
byte[] pngBytes = pdfToPngEqualRatio(pdf);
if (pngBytes != null && pngBytes.length > 0) {
return new PreviewBytes(pngBytes, "image/png");
}
log.warn("PNG 未生成,退回 PDF 预览 versionId={}", versionId);
log.warn("PDFBox 未生成 PNG,退回 PDF 预览 versionId={}", versionId);
return new PreviewBytes(Files.readAllBytes(pdf), "application/pdf");
} finally {
deleteQuietly(workDir);
@@ -94,6 +112,194 @@ public class QuoteTemplateSnapshotRenderer {
}
}
/**
* 预览前改写打印属性:按已用区域宽高比选横向/纵向 A4,并按页适配。
* 只动打印,不改单元格业务内容;写回同一 xlsx 供 soffice 使用。
*/
private void preparePreviewPrintLayout(Path xlsx) throws IOException {
try (InputStream in = Files.newInputStream(xlsx);
Workbook wb = WorkbookFactory.create(in)) {
for (int s = 0; s < wb.getNumberOfSheets(); s++) {
Sheet sheet = wb.getSheetAt(s);
if (sheet == null) {
continue;
}
boolean landscape = preferLandscape(sheet);
sheet.setMargin(Sheet.LeftMargin, 0.4);
sheet.setMargin(Sheet.RightMargin, 0.4);
sheet.setMargin(Sheet.TopMargin, 0.5);
sheet.setMargin(Sheet.BottomMargin, 0.5);
sheet.setHorizontallyCenter(true);
sheet.setVerticallyCenter(false);
sheet.setAutobreaks(true);
sheet.setFitToPage(true);
PrintSetup ps = sheet.getPrintSetup();
ps.setPaperSize(PrintSetup.A4_PAPERSIZE);
ps.setLandscape(landscape);
ps.setFitWidth((short) 1);
// 只按页宽适配:不把矮表纵向撑满整页,减少页底空白(最终仍靠 PNG 裁白边)
ps.setFitHeight((short) 0);
}
try (OutputStream out = Files.newOutputStream(xlsx, StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE)) {
wb.write(out);
}
}
}
/**
* 粗估工作表内容宽高:列宽合计 vs 行高合计,宽更大则预览用横向。
* 单位换算只需相对比较,不要求绝对毫米精度。
*/
private static boolean preferLandscape(Sheet sheet) {
int firstRow = Math.max(sheet.getFirstRowNum(), 0);
int lastRow = sheet.getLastRowNum();
if (lastRow < firstRow) {
return false;
}
int maxCol = 0;
for (int r = firstRow; r <= lastRow && r <= firstRow + 120; r++) {
Row row = sheet.getRow(r);
if (row == null) {
continue;
}
short last = row.getLastCellNum();
if (last > maxCol) {
maxCol = last;
}
}
if (maxCol <= 0) {
return false;
}
double widthPx = 0;
for (int c = 0; c < maxCol; c++) {
// POI 列宽:1/256 字符宽;约 7px/字符(96dpi 经验值)
widthPx += (sheet.getColumnWidth(c) / 256.0) * 7.0;
}
double heightPx = 0;
int rowLimit = Math.min(lastRow, firstRow + 120);
for (int r = firstRow; r <= rowLimit; r++) {
Row row = sheet.getRow(r);
float points = row != null ? row.getHeightInPoints() : sheet.getDefaultRowHeightInPoints();
heightPx += points * (96.0 / 72.0);
}
return widthPx > heightPx * 1.08;
}
/**
* PDF 首页等比例渲 PNG:PDFBox 按页尺寸 × DPI 均匀换算,禁止非等比拉伸。
* 渲完后裁掉近白页边,避免 A4 页底大块空白占预览高度。
*/
private byte[] pdfToPngEqualRatio(Path pdf) throws IOException {
try (PDDocument doc = PDDocument.load(pdf.toFile())) {
if (doc.getNumberOfPages() < 1) {
return null;
}
PDFRenderer renderer = new PDFRenderer(doc);
BufferedImage image = renderer.renderImageWithDPI(0, PREVIEW_DPI, ImageType.RGB);
image = trimNearWhiteMargins(image, 8);
ByteArrayOutputStream buf = new ByteArrayOutputStream();
if (!ImageIO.write(image, "png", buf)) {
throw new IOException("ImageIO 无法写出 PNG");
}
return buf.toByteArray();
}
}
/**
* 裁掉四周近白色边距,四周各留 {@code pad} 像素,避免预览弹窗底下留一大块空白。
* <p>
* 行/列允许极少量杂点(浅网格线);写出前必须拷贝到新 BufferedImage,
* 禁止直接 ImageIO.write(getSubimage())——部分 JDK 会把整页母图写出去,裁切等于没做。
*/
static BufferedImage trimNearWhiteMargins(BufferedImage src, int pad) {
if (src == null) {
return null;
}
int w = src.getWidth();
int h = src.getHeight();
if (w <= 0 || h <= 0) {
return src;
}
int top = 0;
int bottom = h - 1;
int left = 0;
int right = w - 1;
while (top < h && isMostlyWhiteRow(src, top)) {
top++;
}
while (bottom > top && isMostlyWhiteRow(src, bottom)) {
bottom--;
}
while (left < w && isMostlyWhiteCol(src, left, top, bottom)) {
left++;
}
while (right > left && isMostlyWhiteCol(src, right, top, bottom)) {
right--;
}
// 几乎全白:不裁,避免空图
if (top >= bottom || left >= right || (bottom - top + 1) < h / 20) {
return src;
}
int padSafe = Math.max(0, pad);
int x = Math.max(0, left - padSafe);
int y = Math.max(0, top - padSafe);
int x2 = Math.min(w - 1, right + padSafe);
int y2 = Math.min(h - 1, bottom + padSafe);
int cw = x2 - x + 1;
int ch = y2 - y + 1;
if (cw >= w && ch >= h) {
return src;
}
BufferedImage copy = new BufferedImage(cw, ch, BufferedImage.TYPE_INT_RGB);
java.awt.Graphics2D g = copy.createGraphics();
try {
g.drawImage(src, 0, 0, cw, ch, x, y, x + cw, y + ch, null);
} finally {
g.dispose();
}
return copy;
}
/** 一行里非近白像素不超过约 0.5% 则视为空白行(容忍浅网格)。 */
private static boolean isMostlyWhiteRow(BufferedImage img, int y) {
int w = img.getWidth();
int allow = Math.max(2, w / 200);
int dirty = 0;
for (int x = 0; x < w; x++) {
if (!isNearWhitePixel(img.getRGB(x, y))) {
dirty++;
if (dirty > allow) {
return false;
}
}
}
return true;
}
private static boolean isMostlyWhiteCol(BufferedImage img, int x, int y0, int y1) {
int h = y1 - y0 + 1;
int allow = Math.max(2, h / 200);
int dirty = 0;
for (int y = y0; y <= y1; y++) {
if (!isNearWhitePixel(img.getRGB(x, y))) {
dirty++;
if (dirty > allow) {
return false;
}
}
}
return true;
}
private static boolean isNearWhitePixel(int rgb) {
int r = (rgb >> 16) & 0xff;
int g = (rgb >> 8) & 0xff;
int b = rgb & 0xff;
// 略放宽:打印灰底/抗锯齿边也不挡裁切
return r >= 240 && g >= 240 && b >= 240;
}
/**
* 调用 soffice 无界面转换;已在文件锁内,禁止再开并行实例。
*/
@@ -26,10 +26,11 @@ import java.util.Map;
* 空运锁舱/释放:智能体只打主账,本类 HMAC 出站 TMS。
* <p>
* 锁舱按 /air/cabin/lock:用工单起运地/目的地/报价日期/重量/体积拼正文。
* 锁舱优先带 TMS 查价返回的 quoteId / optionNo。库里只有金额、没有编号时当场再查一次。
* 没有 5.2 报价也可以锁舱:TMS 锁舱这两项必填,缺了会 1001「参数格式错误」。
* 此时补 QT-{工单号} + AIR-OPT-01,禁止拿空字段出站。
* 有真实 TMS 编号时不准用自编号盖掉,否则会回 2006。
* 没有报价不能锁舱,禁止编造 QT-{工单号}、AIR-OPT-01。
* 只看最新一版:库里 source=TMS(或航线确认 TMS)出站写成 source=SYSTEM;人工报价出站 source=MANUAL。
* 有真实 TMS 编号才带 quoteId。有值才出站,缺航班不拦。
* SYSTEM 出站精简(quoteId + 航班等标量,不带 validUntil/feeItems);
* MANUAL 费用名按 TMS 查价回包原样带回,有效期必须带时区 ISO8601。
* 已成交再释放:本地拦住。测服打开 TMS_V2_LOCK_ENABLED;现网验收期保持关闭。
* 禁止:按 userid 伪造报价;单测打真实 TMS。
*/
@@ -67,40 +68,124 @@ public class InquiryTmsCabinService {
|| TicketStatuses.CLOSED.equals(ticket.getStatus())) {
return fail("ticket_closed", "工单已是「" + ticket.getStatus() + "」,不能锁舱");
}
Map<String, Object> quote = latestQuote(no);
quote = refreshLockQuote(ticket, quote);
String quoteId = lockQuoteId(resolveQuoteId(quote, no), no);
List<InquiryQuote> rows = quoteRows(no);
if (rows.isEmpty()) {
return fail("no_quote", "这张单还没有报价,不能锁舱");
}
Map<String, Object> quote = payloadOf(rows.get(0));
// 「AIR-OPT01 TH」被误落成航线报价不算真正人工价 → 仍走仅 TMS(SYSTEM)。
boolean latestManual = isManualQuote(quote) && !isFakeManualLockQuote(quote);
if (!latestManual && StringUtils.isBlank(resolveQuoteId(quote, no))) {
quote = refreshLockQuote(ticket, quote);
}
String tmsQuoteId = realTmsQuoteId(rows, no);
if (!latestManual && StringUtils.isBlank(tmsQuoteId)) {
tmsQuoteId = resolveQuoteId(quote, no);
}
boolean hasTms = StringUtils.isNotBlank(tmsQuoteId);
// 三种锁舱口径(产品):
// 1) 仅 TMS → SYSTEM + quoteId
// 2) TMS + 人工 → MANUAL + quoteId(引用原 TMS 编号)
// 3) 仅人工 → MANUAL,不传 quoteId
String lockSource;
String quoteId;
if (hasTms && !latestManual) {
lockSource = "SYSTEM";
quoteId = tmsQuoteId;
} else if (hasTms) {
lockSource = "MANUAL";
quoteId = tmsQuoteId;
} else if (latestManual) {
lockSource = "MANUAL";
quoteId = "";
} else {
return fail("no_quote", "这张单还没有报价,不能锁舱");
}
Map<String, Object> option = resolveOption(quote, optionNo);
if (option == null) {
option = new LinkedHashMap<>();
}
String resolvedOption = lockOptionNo(text(option, "optionNo", "option_no"));
option.put("optionNo", resolvedOption);
// 手工价常只有金额/错误抽到的 fee_lines(如把 AIR-OPT01 抽成 OPT)。
// 只要本单有真实 TMS 舱位,就用它补航班、费用、有效期,不能只因 stub 已有 optionNo 就跳过。
String wantOption = firstNonBlank(text(option, "optionNo", "option_no"), optionNo);
Map<String, Object> tmsOption = tmsOptionForLock(rows, no, wantOption);
if (!tmsOption.isEmpty()) {
option = mergeLockOption(option, tmsOption);
}
Map<String, Object> tmsQuote = tmsQuotePayload(rows, no);
if (tmsQuote != null && !tmsQuote.isEmpty()) {
quote = enrichLockQuoteFromTms(quote, tmsQuote);
}
// 航线句中已点名的 optionNo 不能因报价数组对不上就被丢掉。
String resolvedOption = firstNonBlank(text(option, "optionNo", "option_no"), optionNo);
if (!properties.lockConfigured()) {
return fail("lock_disabled", "主账未开放锁舱");
}
Map<String, Object> req = new LinkedHashMap<>();
req.put("workOrderNo", no);
req.put("quoteId", quoteId);
if (StringUtils.isNotBlank(quoteId)) {
req.put("quoteId", quoteId);
}
req.put("optionNo", resolvedOption);
req.put("flightDate", quoteDate(ticket));
req.put("origin", firstNonBlank(text(option, "originAirportCode"), ticket.getOrigin(),
req.put("flightDate", firstNonBlank(
text(option, "flightDate"),
flightDateFromDeparture(text(option, "departureTime")),
quoteDate(ticket)));
req.put("origin", firstNonBlank(text(option, "originAirportCode"),
fact(ticket, "originCode"),
text(option, "origin"), ticket.getOrigin(),
fact(ticket, "起运地", "起运港")));
req.put("destination", firstNonBlank(text(option, "destinationAirportCode"), ticket.getDestination(),
req.put("destination", firstNonBlank(text(option, "destinationAirportCode"),
fact(ticket, "destinationCode"),
text(option, "destination"), ticket.getDestination(),
fact(ticket, "目的地", "目的港")));
req.put("airline", firstNonBlank(text(option, "airlineCode", "airline"),
// 航司:句中两位码(如「AIR-OPT01 TH」)> 舱位/报价字段 > 航班号前两位。
req.put("airline", firstNonBlank(
text(body, "airline", "airlineCode"),
airlineFromInstruction(text(body, "instructionText", "instruction_text")),
text(option, "airlineCode", "airline"),
text(option, "airlineName"),
text(quote, "airlineCode", "airline", "airlineName"),
fact(ticket, "航司", "航司代码")));
fact(ticket, "航司", "航司代码"),
airlineFromFlight(firstNonBlank(text(option, "flightNo"), text(quote, "flightNo")))));
req.put("weightKg", ticket.getWeightKg());
req.put("volumeCbm", ticket.getVolumeCbm());
req.put("pieceQty", ticket.getPieces());
req.put("chargeableWeightKg", firstNonBlank(
text(quote, "chargeableWeightKg", "chargeable_weight_kg"),
text(option, "chargeableWeightKg", "chargeable_weight_kg")));
text(option, "chargeableWeightKg", "chargeable_weight_kg"),
text(quote, "chargeableWeightKg", "chargeable_weight_kg")));
req.put("operatorUserId", text(body, "operatorUserId", "sender_id"));
req.put("operatorName", text(body, "operatorName", "operator_name"));
req.put("instructionText", text(body, "instructionText", "instruction_text"));
if ("MANUAL".equals(lockSource)) {
// 有 TMS 时保留 quoteId;纯人工才去掉。
fillManualLock(req, quote, option, body, StringUtils.isNotBlank(quoteId));
} else {
// 仅 TMS:source=SYSTEM + quoteId。禁止再塞 validUntil/feeItems(会 1001)。
req.put("source", "SYSTEM");
req.remove("validUntil");
req.remove("requestedUntil");
req.remove("departureCharges");
req.remove("feeItems");
req.remove("currency");
req.remove("totalSalePrice");
String transfer = firstNonBlank(text(option, "transferAirport", "transferAirportCode"),
text(quote, "transferAirport", "transferAirportCode"));
if (StringUtils.isNotBlank(transfer)) {
req.put("transferAirport", transfer);
}
String flightNo = firstNonBlank(
text(option, "flightNo", "flight_no"),
text(quote, "flightNo", "flight_no"),
flightNoFromSelectedAirOption(quote),
flightNoFromAirOptions(quote, resolvedOption));
if (StringUtils.isNotBlank(flightNo)) {
req.put("flightNo", flightNo);
}
if (StringUtils.isNotBlank(text(body, "requestedUntil"))) {
req.put("requestedUntil", text(body, "requestedUntil"));
}
}
return invokeLock(ticket, req, quoteId, resolvedOption);
}
@@ -310,8 +395,523 @@ public class InquiryTmsCabinService {
}
/**
* 库里的真实 TMS 报价编号。自编 QT-工单号不算:先再查一次 5.2,
* 仍没有才在 {@link #lockQuoteId} 补占位,给无报价锁舱用。
* 人工报价出站:对齐 TMS MANUAL 合同。
* <p>
* 必带:source=MANUAL、optionNo、validUntil(ISO)、origin/destination、
* flightDate、airline、flightNo、重量体积件数、计费重、币种、总价、
* departureCharges、feeItems;可选 requestedUntil(缺则用 validUntil)。
* {@code withQuoteId=true}:本单还有 TMS 报价时必须带原 quoteId;
* {@code withQuoteId=false}:纯人工、无 TMS 时不传 quoteId。
*/
static void fillManualLock(Map<String, Object> req, Map<String, Object> quote,
Map<String, Object> option, Map<String, Object> body,
boolean withQuoteId) {
req.put("source", "MANUAL");
if (!withQuoteId) {
req.remove("quoteId");
}
fillLockQuoteExtras(req, quote, option);
String transfer = firstNonBlank(text(option, "transferAirport", "transferAirportCode"),
text(quote, "transferAirport", "transferAirportCode"));
if (StringUtils.isNotBlank(transfer)) {
req.put("transferAirport", transfer);
}
String flightNo = firstNonBlank(text(option, "flightNo"), text(quote, "flightNo"));
if (StringUtils.isNotBlank(flightNo)) {
req.put("flightNo", flightNo);
}
String currency = firstNonBlank(text(option, "currency"),
text(quote, "currency", "currencyCode"));
if (StringUtils.isNotBlank(currency)) {
req.put("currency", currency);
}
// 舱位售价优先;避免手工误抽的 total=1 盖住 TMS 真价。
String total = firstNonBlank(text(option, "salePrice", "totalSalePrice"),
text(quote, "totalSalePrice", "salePrice", "total"));
if (StringUtils.isNotBlank(total) && !"-".equals(total)) {
req.put("totalSalePrice", total);
}
// requestedUntil:调用方有就用;否则用 validUntil(合同样例两者都有)。
String requested = firstNonBlank(text(body, "requestedUntil"),
text(req, "validUntil"));
String isoReq = normalizeValidUntilIso(requested);
if (StringUtils.isNotBlank(isoReq)) {
req.put("requestedUntil", isoReq);
}
}
static String flightDateFromDeparture(String departureTime) {
if (StringUtils.isBlank(departureTime) || departureTime.length() < 10) {
return "";
}
return departureTime.substring(0, 10);
}
/**
* 锁舱共用:有效期转 ISO8601,费用名按 TMS 查价回包原样带回。
* <p>
* 查价落库常是 {@code valid_until=2026-09-30}(仅日期);锁舱要带时区的 ISO,
* 否则 TMS 回 1001「参数格式错误」。费用优先 {@code airOptions[].departureCharges} /
* {@code feeItems} 原文,禁止用本地改写过的展示名替换。
*/
static void fillLockQuoteExtras(Map<String, Object> req, Map<String, Object> quote, Map<String, Object> option) {
String until = firstNonBlank(text(quote, "validUntil", "valid_until"), text(option, "validUntil"));
String isoUntil = normalizeValidUntilIso(until);
if (StringUtils.isNotBlank(isoUntil)) {
req.put("validUntil", isoUntil);
}
List<Map<String, Object>> departure = feeRows(quote, option, "departureCharges", "departure_charges");
List<Map<String, Object>> fees = feeRows(quote, option, "feeItems", "fee_items");
splitFeeRows(quote, departure, fees);
if (!departure.isEmpty()) {
req.put("departureCharges", toLockFees(departure, true));
}
if (!fees.isEmpty()) {
req.put("feeItems", toLockFees(fees, false));
}
}
/**
* 锁舱 validUntil / requestedUntil:规范成带时区的 ISO8601。
* <ul>
* <li>{@code 2026-09-30} → {@code 2026-09-30T23:59:59+08:00}(有效期到当日结束)</li>
* <li>已有 {@code T} 但无时区 → 补 {@code +08:00}</li>
* <li>已是完整 ISO → 原样</li>
* </ul>
*/
static String normalizeValidUntilIso(String raw) {
if (StringUtils.isBlank(raw) || "-".equals(raw.trim())) {
return "";
}
String text = raw.trim();
// 已是完整带时区的 ISO
if (text.matches("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}([.,]\\d+)?(Z|[+-]\\d{2}:?\\d{2})")) {
return text;
}
// 有 T 无时区
if (text.matches("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}(:\\d{2})?([.,]\\d+)?")) {
if (text.length() == 16) {
// yyyy-MM-ddTHH:mm
return text + ":00+08:00";
}
return text + "+08:00";
}
// 仅日期:yyyy-MM-dd 或 yyyy/MM/dd
java.util.regex.Matcher m = java.util.regex.Pattern
.compile("^(\\d{4})[-/](\\d{1,2})[-/](\\d{1,2})$")
.matcher(text);
if (m.matches()) {
int month = Integer.parseInt(m.group(2));
int day = Integer.parseInt(m.group(3));
return String.format("%s-%02d-%02dT23:59:59+08:00", m.group(1), month, day);
}
// 空格分隔的本地时间:yyyy-MM-dd HH:mm[:ss]
java.util.regex.Matcher local = java.util.regex.Pattern
.compile("^(\\d{4})[-/](\\d{1,2})[-/](\\d{1,2})[ T](\\d{1,2}):(\\d{2})(?::(\\d{2}))?$")
.matcher(text);
if (local.matches()) {
String sec = local.group(6) == null ? "00" : local.group(6);
return String.format("%s-%02d-%02dT%02d:%02d:%s+08:00",
local.group(1),
Integer.parseInt(local.group(2)),
Integer.parseInt(local.group(3)),
Integer.parseInt(local.group(4)),
Integer.parseInt(local.group(5)),
sec.length() == 1 ? "0" + sec : sec);
}
return "";
}
/**
* 最新一版是不是人工报价。航线只确认沿用 TMS 不算。
* 认 payload 里的 source / source_label,因为落库时确认 TMS 也会被写成 PRODUCT。
*/
static boolean isManualQuote(Map<String, Object> quote) {
String label = text(quote, "source_label", "sourceLabel");
if (label.contains("确认") && label.toUpperCase().contains("TMS")) {
return false;
}
String src = text(quote, "source").toUpperCase();
if ("SALES_ADJUST".equals(src) || label.contains("销售调价")) {
return true;
}
if ("PRODUCT".equals(src) || "MANUAL".equals(src)) {
return true;
}
return label.contains("航线报价") || label.contains("航线附件")
|| label.contains("在TMS上改") || label.contains("手工");
}
/**
* 误把锁舱口令落成「航线报价」:raw_text 像 AIR-OPT / 锁舱,或费用名只有 OPT 碎片。
* 这种不算「有人工报价」,锁舱应按仅 TMS(SYSTEM)。
*/
static boolean isFakeManualLockQuote(Map<String, Object> quote) {
if (quote == null || quote.isEmpty()) {
return false;
}
if (looksLikeOptionTokenFees(quote)) {
return true;
}
String raw = text(quote, "raw_text", "rawText", "instructionText").toUpperCase();
if (raw.contains("AIR-OPT") || raw.contains("AIROPT") || raw.contains("锁舱")) {
return true;
}
return false;
}
/**
* 手工价没有舱位时,用本单 TMS 报价上的那一条。多条且没指定就不猜。
* optionNo 大小写、横杠差异按忽略大小写匹配。
*/
static Map<String, Object> tmsOptionForLock(List<InquiryQuote> rows, String workOrderNo, String optionNo) {
if (rows == null) {
return new LinkedHashMap<>();
}
for (InquiryQuote row : rows) {
Map<String, Object> payload = payloadOf(row);
if (StringUtils.isBlank(resolveQuoteId(payload, workOrderNo))) {
continue;
}
if (isManualQuote(payload)) {
continue;
}
List<Map<String, Object>> options = airOptions(payload);
if (options.isEmpty()) {
continue;
}
if (StringUtils.isNotBlank(optionNo)) {
for (Map<String, Object> item : options) {
if (optionNo.equalsIgnoreCase(text(item, "optionNo", "option_no"))) {
return item;
}
}
continue;
}
if (options.size() == 1) {
return options.get(0);
}
return new LinkedHashMap<>();
}
return new LinkedHashMap<>();
}
/**
* 本单最新一版非手工、带真实 quoteId 的 TMS 报价正文。
*/
static Map<String, Object> tmsQuotePayload(List<InquiryQuote> rows, String workOrderNo) {
if (rows == null) {
return new LinkedHashMap<>();
}
for (InquiryQuote row : rows) {
Map<String, Object> payload = payloadOf(row);
if (isManualQuote(payload)) {
continue;
}
if (StringUtils.isNotBlank(resolveQuoteId(payload, workOrderNo))) {
return payload;
}
}
return new LinkedHashMap<>();
}
/**
* 锁舱舱位:TMS 明细打底,航线点名的 optionNo 盖上去。
*/
static Map<String, Object> mergeLockOption(Map<String, Object> stub, Map<String, Object> tmsOption) {
Map<String, Object> out = new LinkedHashMap<>();
if (tmsOption != null) {
out.putAll(tmsOption);
}
String no = text(stub, "optionNo", "option_no");
if (StringUtils.isNotBlank(no)) {
out.put("optionNo", no);
}
return out;
}
/**
* 手工价缺有效期/计费重时,从本单 TMS 报价补;不覆盖手工已有金额。
* 费用明细优先走舱位上的 departureCharges(merge 后),这里只补标量。
*/
static Map<String, Object> enrichLockQuoteFromTms(Map<String, Object> quote, Map<String, Object> tmsQuote) {
Map<String, Object> out = new LinkedHashMap<>();
if (quote != null) {
out.putAll(quote);
}
if (tmsQuote == null || tmsQuote.isEmpty()) {
return out;
}
if (StringUtils.isBlank(text(out, "validUntil", "valid_until"))
|| "-".equals(text(out, "validUntil", "valid_until"))) {
String until = firstNonBlank(text(tmsQuote, "validUntil", "valid_until"));
if (StringUtils.isNotBlank(until) && !"-".equals(until)) {
out.put("validUntil", until);
out.put("valid_until", until);
}
}
if (StringUtils.isBlank(text(out, "chargeableWeightKg", "chargeable_weight_kg"))) {
String cw = firstNonBlank(text(tmsQuote, "chargeableWeightKg", "chargeable_weight_kg"));
if (StringUtils.isNotBlank(cw)) {
out.put("chargeableWeightKg", cw);
}
}
// 手工 fee_lines 若像「OPT」这种从舱位编号误抽的,清掉,避免盖住 TMS departureCharges。
if (looksLikeOptionTokenFees(out)) {
out.remove("fee_lines");
out.remove("fee_rows");
out.remove("feeItems");
}
// 补 TMS 顶层 feeItems(空运费等),手工价常常没有。
if (out.get("feeItems") == null && out.get("fee_items") == null
&& (tmsQuote.get("feeItems") != null || tmsQuote.get("fee_items") != null)) {
Object fees = tmsQuote.get("feeItems");
if (fees == null) {
fees = tmsQuote.get("fee_items");
}
out.put("feeItems", fees);
}
return out;
}
/**
* fee_lines 只有「OPT / AIR-OPT…」这类舱位编号碎片时,不是真费用。
*/
static boolean looksLikeOptionTokenFees(Map<String, Object> quote) {
List<Map<String, Object>> rows = feeRows(quote, null, "fee_lines", "fee_rows", "feeItems");
if (rows.isEmpty()) {
return false;
}
for (Map<String, Object> row : rows) {
String name = feeName(row).toUpperCase().replace("-", "");
if (!(name.equals("OPT") || name.startsWith("AIROPT") || name.matches("OPT\\d+"))) {
return false;
}
}
return true;
}
static String realTmsQuoteId(List<InquiryQuote> rows, String workOrderNo) {
if (rows == null) {
return "";
}
for (InquiryQuote row : rows) {
String id = resolveQuoteId(payloadOf(row), workOrderNo);
if (StringUtils.isNotBlank(id)) {
return id;
}
}
return "";
}
private List<InquiryQuote> quoteRows(String workOrderNo) {
if (StringUtils.isBlank(workOrderNo)) {
return new ArrayList<>();
}
List<InquiryQuote> rows = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
.eq(InquiryQuote::getWorkOrderNo, workOrderNo)
.orderByDesc(InquiryQuote::getCreateTime)
.last("LIMIT 8"));
return rows == null ? new ArrayList<>() : rows;
}
private static Map<String, Object> payloadOf(InquiryQuote row) {
Map<String, Object> quote = new LinkedHashMap<>();
if (row == null) {
return quote;
}
if (StringUtils.isNotBlank(row.getPayloadJson())) {
JSONObject parsed = JSON.parseObject(row.getPayloadJson());
if (parsed != null) {
quote.putAll(parsed);
}
}
if (StringUtils.isNotBlank(row.getSource()) && StringUtils.isBlank(text(quote, "source"))) {
quote.put("source", row.getSource());
}
if (StringUtils.isNotBlank(row.getSourceLabel()) && StringUtils.isBlank(text(quote, "source_label", "sourceLabel"))) {
quote.put("source_label", row.getSourceLabel());
}
return quote;
}
@SuppressWarnings("unchecked")
private static List<Map<String, Object>> feeRows(Map<String, Object> quote, Map<String, Object> option,
String... keys) {
List<Map<String, Object>> out = new ArrayList<>();
collectFeeRows(out, option, keys);
if (out.isEmpty()) {
collectFeeRows(out, quote, keys);
}
return out;
}
@SuppressWarnings("unchecked")
private static void collectFeeRows(List<Map<String, Object>> out, Map<String, Object> src, String... keys) {
if (src == null || keys == null) {
return;
}
for (String key : keys) {
Object raw = src.get(key);
if (raw instanceof String && String.valueOf(raw).trim().startsWith("[")) {
try {
raw = JSON.parseArray(String.valueOf(raw));
} catch (Exception ignore) {
raw = null;
}
}
if (raw instanceof JSONArray) {
raw = ((JSONArray) raw).toJavaList(Object.class);
}
if (!(raw instanceof List)) {
continue;
}
for (Object item : (List<?>) raw) {
if (item instanceof Map && StringUtils.isNotBlank(feeName((Map<String, Object>) item))) {
out.add((Map<String, Object>) item);
}
}
if (!out.isEmpty()) {
return;
}
}
}
/**
* 报价只存了 fee_rows 时,本地费进 departureCharges,其余进 feeItems。已分开的不再搬。
*/
private static void splitFeeRows(Map<String, Object> quote, List<Map<String, Object>> departure,
List<Map<String, Object>> fees) {
if (!departure.isEmpty() || !fees.isEmpty()) {
return;
}
for (Map<String, Object> row : feeRows(quote, null, "fee_rows", "fee_lines")) {
String name = feeName(row);
if ("运输费".equals(name) || "提送货费".equals(name) || "人工费".equals(name)) {
departure.add(row);
} else {
fees.add(row);
}
}
}
private static String feeName(Map<String, Object> row) {
return text(row, "chargeName", "feeName", "name", "label");
}
/**
* 费用行出站:对齐 MANUAL 合同字段。
* departureCharges:chargeName/unitPrice/quantity/amount/unit/currency;
* feeItems:feeCode/feeName/…/segmentSequence/remark。
* 起运港费缺 quantity 时默认 1(合同样例)。
*/
static List<Map<String, Object>> toLockFees(List<Map<String, Object>> rows, boolean departure) {
List<Map<String, Object>> out = new ArrayList<>();
if (rows == null) {
return out;
}
for (Map<String, Object> row : rows) {
if (row == null) {
continue;
}
Map<String, Object> item = new LinkedHashMap<>();
String name = departure
? firstNonBlank(text(row, "chargeName"), text(row, "feeName"), text(row, "name", "label"))
: firstNonBlank(text(row, "feeName"), text(row, "chargeName"), text(row, "name", "label"));
if (StringUtils.isNotBlank(name)) {
item.put(departure ? "chargeName" : "feeName", name);
}
String code = text(row, "feeCode", "fee_code");
if (!departure && StringUtils.isNotBlank(code)) {
item.put("feeCode", code);
}
putFeeNumber(item, "unitPrice", text(row, "unitPrice", "unit_price"));
String qtyRaw = text(row, "quantity");
if (StringUtils.isBlank(qtyRaw) && departure) {
qtyRaw = "1";
}
putFeeNumber(item, "quantity", qtyRaw);
putFeeNumber(item, "amount", text(row, "amount"));
// 有单价无金额时:金额 = 单价 × 数量
if (!item.containsKey("amount") && item.get("unitPrice") instanceof java.math.BigDecimal
&& item.get("quantity") instanceof java.math.BigDecimal) {
item.put("amount", plainFeeDecimal(
((java.math.BigDecimal) item.get("unitPrice"))
.multiply((java.math.BigDecimal) item.get("quantity"))));
}
String currency = text(row, "currency");
if (StringUtils.isNotBlank(currency)) {
item.put("currency", currency);
}
String unit = text(row, "unit", "单位");
if (StringUtils.isNotBlank(unit)) {
item.put("unit", unit);
}
if (!departure) {
// 合同样例:feeCode 在 feeName 前;上面已先写 feeName,这里保持有值即可。
String seg = text(row, "segmentSequence", "segment_sequence");
if (StringUtils.isNotBlank(seg)) {
try {
item.put("segmentSequence", Integer.parseInt(seg.trim()));
} catch (NumberFormatException ignore) {
// 非整数就不送
}
}
String remark = text(row, "remark", "备注");
if (StringUtils.isNotBlank(remark)) {
item.put("remark", remark);
}
}
// 合同样例 feeItems:feeCode 在 feeName 前
if (!departure && item.containsKey("feeCode") && item.containsKey("feeName")) {
Map<String, Object> ordered = new LinkedHashMap<>();
ordered.put("feeCode", item.get("feeCode"));
ordered.put("feeName", item.get("feeName"));
for (Map.Entry<String, Object> e : item.entrySet()) {
if ("feeCode".equals(e.getKey()) || "feeName".equals(e.getKey())) {
continue;
}
ordered.put(e.getKey(), e.getValue());
}
item = ordered;
}
if (!item.isEmpty()) {
out.add(item);
}
}
return out;
}
/**
* 费用数字:去掉尾零,但避免 100.000 → 1E+2(TMS 当参数格式错误)。
* 与重量字段不同:费用合同样例是 11 / 1333.6,整数千不要写成 11.0。
*/
static java.math.BigDecimal plainFeeDecimal(java.math.BigDecimal value) {
java.math.BigDecimal stripped = value.stripTrailingZeros();
if (stripped.scale() < 0) {
return stripped.setScale(0);
}
return stripped;
}
private static void putFeeNumber(Map<String, Object> item, String key, String raw) {
if (StringUtils.isBlank(raw) || "-".equals(raw)) {
return;
}
String text = raw.trim().replace(",", "");
int space = text.lastIndexOf(' ');
if (space >= 0) {
text = text.substring(space + 1).trim();
}
try {
item.put(key, plainFeeDecimal(new java.math.BigDecimal(text)));
} catch (NumberFormatException ignore) {
// 不是数字就不送,避免把「99.00元」这类原文塞进 TMS 数字栏。
}
}
/**
* 库里的真实 TMS 报价编号。自编 QT-工单号不算,缺了也不补占位。
*/
static String resolveQuoteId(Map<String, Object> quote, String workOrderNo) {
String id = text(quote, "quoteId", "quote_id");
@@ -326,33 +926,82 @@ public class InquiryTmsCabinService {
}
/**
* 锁舱出站用的报价编号。TMS /air/cabin/lock 的 quoteId 必填,缺了回 1001。
* 有 5.2 真编号就用真编号;没有才补 QT-{工单号},允许无报价锁舱。
* 报价没给航司时,用航班号前两位字母。TH739 → TH。
* 不是字母开头就不猜,避免把纯数字航班编成航司。
*/
static String lockQuoteId(String resolved, String workOrderNo) {
if (StringUtils.isNotBlank(resolved)) {
return resolved.trim();
static String airlineFromFlight(String flightNo) {
String raw = StringUtils.trimToEmpty(flightNo).toUpperCase();
if (raw.length() >= 2
&& Character.isLetter(raw.charAt(0))
&& Character.isLetter(raw.charAt(1))) {
return raw.substring(0, 2);
}
String wo = StringUtils.trimToEmpty(workOrderNo);
if (wo.isEmpty()) {
return "";
}
/**
* 从报价 airOptions 里按 optionNo 取航班号;对不上则取唯一有值的那条。
* SYSTEM 锁舱缺 flightNo 时补用,避免只带了航司码却没航班号。
*/
static String flightNoFromAirOptions(Map<String, Object> quote, String optionNo) {
List<Map<String, Object>> options = airOptions(quote);
if (options.isEmpty()) {
return "";
}
return "QT-" + wo;
}
/**
* 锁舱出站用的舱位选项。TMS optionNo 必填,缺了回 1001。
* 有选项用选项;没有补 AIR-OPT-01。
*/
static String lockOptionNo(String resolved) {
if (StringUtils.isNotBlank(resolved)) {
return resolved.trim();
String want = StringUtils.trimToEmpty(optionNo);
String lone = "";
int withFlight = 0;
for (Map<String, Object> row : options) {
String no = firstNonBlank(text(row, "optionNo", "option_no"));
String fn = firstNonBlank(text(row, "flightNo", "flight_no"));
if (StringUtils.isBlank(fn)) {
continue;
}
if (StringUtils.isNotBlank(want) && want.equalsIgnoreCase(no)) {
return fn;
}
withFlight++;
lone = fn;
}
return "AIR-OPT-01";
if (withFlight == 1) {
return lone;
}
return "";
}
/**
* 有舱位选项按名称挑;没有就补 AIR-OPT-01,无报价锁舱也要带上必填项。
* 从 selected_air_option(常为 JSON 字符串)取航班号。
*/
@SuppressWarnings("unchecked")
static String flightNoFromSelectedAirOption(Map<String, Object> quote) {
if (quote == null) {
return "";
}
Object raw = quote.get("selected_air_option");
if (raw == null) {
raw = quote.get("selectedAirOption");
}
if (raw instanceof String) {
String s = String.valueOf(raw).trim();
if (s.startsWith("{")) {
try {
raw = JSON.parseObject(s);
} catch (Exception ignore) {
return "";
}
} else {
return "";
}
}
if (raw instanceof Map) {
return firstNonBlank(text((Map<String, Object>) raw, "flightNo", "flight_no"));
}
return "";
}
/**
* 有舱位按名称挑。没有舱位时,航线已指定的编号原样带上,不编 AIR-OPT-01。
* 报价数组对不上时仍保留航线说的 optionNo,避免出站空编号。
*/
static Map<String, Object> resolveOption(Map<String, Object> quote, String optionNo) {
List<Map<String, Object>> options = airOptions(quote);
@@ -361,20 +1010,40 @@ public class InquiryTmsCabinService {
if (picked != null) {
return picked;
}
if (options.size() == 1) {
if (options.size() == 1 && StringUtils.isBlank(optionNo)) {
return options.get(0);
}
if (StringUtils.isNotBlank(optionNo)) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("optionNo", optionNo.trim());
return row;
}
return new LinkedHashMap<>();
}
Map<String, Object> row = new LinkedHashMap<>();
row.put("optionNo", lockOptionNo(optionNo));
String airline = text(quote, "airlineCode", "airline", "airlineName");
if (StringUtils.isNotBlank(airline)) {
row.put("airlineCode", airline);
if (StringUtils.isBlank(optionNo)) {
return new LinkedHashMap<>();
}
Map<String, Object> row = new LinkedHashMap<>();
row.put("optionNo", optionNo.trim());
return row;
}
/**
* 从锁舱口令抽出两位航司码,例如「锁AIR-OPT01 TH」→ TH。
*/
static String airlineFromInstruction(String instruction) {
if (StringUtils.isBlank(instruction)) {
return "";
}
java.util.regex.Matcher m = java.util.regex.Pattern
.compile("(?i)AIR-?OPT-?\\d+\\s*([A-Za-z]{2})(?![A-Za-z0-9])")
.matcher(instruction);
if (m.find()) {
return m.group(1).toUpperCase();
}
return "";
}
@SuppressWarnings("unchecked")
static Map<String, Object> pickOption(Map<String, Object> quote, String optionNo) {
List<Map<String, Object>> options = airOptions(quote);
@@ -459,7 +1128,7 @@ public class InquiryTmsCabinService {
/**
* 锁舱/释放失败话术:原样用 TMS 的 failReason。
* 禁止按航班日改写成「起飞」,禁止把 2006 映射成「报价选项不存在」——没有报价也可以锁舱。
* 禁止按航班日改写成「起飞」,禁止把 2006 改写成另一句业务话。
*/
static String cabinFailReason(int code, Map<String, Object> tms, String flightDate) {
return cabinFailReason(code, tms, flightDate, "");
@@ -109,7 +109,7 @@ public class InquiryTmsQueryService {
segment.put("destination", "");
segment.put("quantity", TmsV2RequestMapper.asInteger(facts.get("件数")));
segment.put("weightKg", TmsV2RequestMapper.asPositiveDecimal(facts.get("毛重")));
segment.put("volumeCbm", TmsV2RequestMapper.asPositiveDecimal(facts.get("体积")));
segment.put("volumeCbm", TmsV2RequestMapper.asVolumeCbm(facts.get("体积")));
fillAirportCode(segment, "originCode", text(first(facts.get("originCode"), origin)));
fillAirportCode(segment, "destinationCode", text(first(facts.get("destinationCode"), destination)));
// 包装优先销售原文,避免旧工单 packageType=2 盖住「托盘」。
@@ -4,6 +4,7 @@ import org.apache.commons.lang3.StringUtils;
import java.math.BigDecimal;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@@ -12,8 +13,11 @@ import java.util.UUID;
* <p>
* 锁舱唯一键:workOrderNo + quoteId + optionNo。flightDate 用工单报价日期,接口没有单独 quoteDate。
* 操作人角色固定 AIRLINE。数字字段保持 Number,禁止带 KGS/CBM。
* quoteId / optionNo 必填:有 TMS 报价用真编号;没有时由 CabinService 补 QT-{工单号} / AIR-OPT-01。
* mapper 仍跳过空字符串,避免把 null 写成 JSON。
* 系统报价:source=SYSTEM + quoteId。
* 人工+TMS:source=MANUAL + 原 quoteId。
* 纯人工:source=MANUAL,不传 quoteId;validUntil/requestedUntil 用 ISO8601;
* 必带 transferAirport/flightNo/departureCharges/feeItems(有值才写)。
* 不在这里编造 optionNo。
*/
public final class TmsCabinRequestMapper {
@@ -21,30 +25,43 @@ public final class TmsCabinRequestMapper {
}
/**
* 锁舱正文。字段顺序固定,签名按这份 JSON 原文计算。
* 锁舱正文。字段顺序按 TMS MANUAL/SYSTEM 样例排,签名按这份 JSON 原文计算。
*/
public static Map<String, Object> lock(Map<String, Object> internal) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("requestId", firstText(internal.get("requestId"), UUID.randomUUID().toString()));
put(body, "workOrderNo", internal.get("workOrderNo"));
put(body, "quoteId", internal.get("quoteId"));
put(body, "source", internal.get("source"));
put(body, "workOrderNo", internal.get("workOrderNo"));
put(body, "optionNo", internal.get("optionNo"));
Object until = internal.get("requestedUntil");
if (until != null && StringUtils.isNotBlank(String.valueOf(until))) {
body.put("requestedUntil", String.valueOf(until).trim());
Object requestedUntil = internal.get("requestedUntil");
if (requestedUntil != null && StringUtils.isNotBlank(String.valueOf(requestedUntil))) {
String iso = InquiryTmsCabinService.normalizeValidUntilIso(String.valueOf(requestedUntil).trim());
body.put("requestedUntil", StringUtils.isNotBlank(iso) ? iso : String.valueOf(requestedUntil).trim());
}
Object validUntil = internal.get("validUntil");
if (validUntil != null && StringUtils.isNotBlank(String.valueOf(validUntil))) {
String iso = InquiryTmsCabinService.normalizeValidUntilIso(String.valueOf(validUntil).trim());
put(body, "validUntil", StringUtils.isNotBlank(iso) ? iso : validUntil);
}
put(body, "origin", first(internal, "origin", "originCode"));
put(body, "transferAirport", internal.get("transferAirport"));
put(body, "destination", first(internal, "destination", "destinationCode"));
put(body, "flightDate", TmsV2RequestMapper.normalizeQuoteDate(internal.get("flightDate")));
put(body, "airline", first(internal, "airline", "airlineCode"));
put(body, "flightNo", internal.get("flightNo"));
putNumber(body, "weightKg", TmsV2RequestMapper.asPositiveDecimal(internal.get("weightKg")));
putNumber(body, "volumeCbm", TmsV2RequestMapper.asPositiveDecimal(internal.get("volumeCbm")));
putNumber(body, "volumeCbm", TmsV2RequestMapper.asVolumeCbm(internal.get("volumeCbm")));
putNumber(body, "pieceQty", TmsV2RequestMapper.asInteger(internal.get("pieceQty")));
putNumber(body, "chargeableWeightKg", TmsV2RequestMapper.asPositiveDecimal(internal.get("chargeableWeightKg")));
put(body, "operatorUserId", internal.get("operatorUserId"));
put(body, "operatorName", internal.get("operatorName"));
body.put("operatorRole", "AIRLINE");
put(body, "instructionText", internal.get("instructionText"));
put(body, "currency", internal.get("currency"));
putNumber(body, "totalSalePrice", asDecimal(internal.get("totalSalePrice")));
putList(body, "departureCharges", internal.get("departureCharges"));
putList(body, "feeItems", internal.get("feeItems"));
return body;
}
@@ -77,10 +94,53 @@ public final class TmsCabinRequestMapper {
}
}
/**
* 费用数组有行才写入。空数组不传,避免 TMS 把「没有明细」当成一条空费用。
*/
private static void putList(Map<String, Object> target, String key, Object value) {
if (value instanceof List && !((List<?>) value).isEmpty()) {
target.put(key, value);
}
}
private static BigDecimal asDecimal(Object value) {
if (value instanceof BigDecimal) {
return (BigDecimal) value;
}
if (value instanceof Number) {
return new BigDecimal(String.valueOf(value));
}
if (value == null || StringUtils.isBlank(String.valueOf(value))) {
return null;
}
String text = String.valueOf(value).trim().replace(",", "");
int space = text.lastIndexOf(' ');
if (space >= 0) {
text = text.substring(space + 1).trim();
}
try {
return new BigDecimal(text);
} catch (NumberFormatException ex) {
return null;
}
}
/**
* 100.000 去掉尾零会变成 1E+2,TMS 锁舱当参数格式错误。
* 重量和体积类型是 decimal,整数也写成 100.0,避免被收成没有小数点的整数。
*/
static BigDecimal plainDecimal(BigDecimal value) {
BigDecimal stripped = value.stripTrailingZeros();
if (stripped.scale() < 1) {
return stripped.setScale(1);
}
return stripped;
}
private static void putNumber(Map<String, Object> target, String key, Number value) {
if (value != null) {
if (value instanceof BigDecimal) {
target.put(key, ((BigDecimal) value).stripTrailingZeros());
target.put(key, plainDecimal((BigDecimal) value));
} else {
target.put(key, value);
}
@@ -122,7 +122,8 @@ public class TmsGatewayClient {
throw new IllegalStateException("TMS 锁舱未开放或签名未配置");
}
assertCircuitAvailable();
String json = JSON.toJSONString(request);
String json = JSON.toJSONString(request, com.alibaba.fastjson.serializer.SerializerFeature.WriteBigDecimalAsPlain);
log.info("tms.cabin req path={} json={}", path, json);
String timestamp = String.valueOf(System.currentTimeMillis());
String nonce = java.util.UUID.randomUUID().toString();
String signature = TmsHmacSigner.sign(properties.getSignatureSecret(), timestamp, nonce, json);
@@ -167,10 +168,10 @@ public class TmsGatewayClient {
consecutiveFailures.set(0);
body.put("formal", true);
body.put("source", "TMS");
log.info("tms.cabin path={} wo={} lockId={} quoteId={} code={} http={} costMs={} attempt={} bodyLen={}",
log.info("tms.cabin path={} wo={} lockId={} quoteId={} code={} http={} costMs={} attempt={} bodyLen={} resp={}",
path, request.get("workOrderNo"), request.get("lockId"), request.get("quoteId"),
body.get("code"), status,
System.currentTimeMillis() - started, attempt, response.bodyLen);
System.currentTimeMillis() - started, attempt, response.bodyLen, response.body);
return body;
} catch (SocketTimeoutException ex) {
log.warn("tms.cabin timeout path={} attempt={}", path, attempt);
@@ -52,7 +52,7 @@ public final class TmsLandSegmentMapper {
put(seg, "destination", text(source.get("目的港")));
put(seg, "cargoName", text(source.get("品名")));
if (LCL.contains(type)) {
put(seg, "volumeCbm", TmsV2RequestMapper.asPositiveDecimal(source.get("体积")));
put(seg, "volumeCbm", TmsV2RequestMapper.asVolumeCbm(source.get("体积")));
put(seg, "weightKg", TmsV2RequestMapper.asPositiveDecimal(source.get("毛重")));
} else if ("中港整车".equals(type)) {
// 中港整车:询价不要车型/数量,出站也不带
@@ -24,6 +24,13 @@ public final class TmsV2RequestMapper {
/** 从「100KGS」「2.5CBM」「10件」里取出第一个数字。 */
private static final Pattern FIRST_NUMBER = Pattern.compile("(\\d+(?:\\.\\d+)?)");
/**
* 销售常写尺寸当体积:1.1*1.2*3 / 1.1x1.2x3 / 1.1×1.2×3(米)。
* 必须先相乘得 CBM;禁止剥掉 * 后变成 1.11.23 再 new BigDecimal(建单会炸)。
*/
private static final Pattern DIM_PRODUCT = Pattern.compile(
"(\\d+(?:\\.\\d+)?)\\s*[*xX×]\\s*(\\d+(?:\\.\\d+)?)(?:\\s*[*xX×]\\s*(\\d+(?:\\.\\d+)?))?");
/** 文档 2.1:后缀 Date 的字段格式 yyyy-MM-dd;也容忍斜杠或后面跟时间。 */
private static final Pattern ISO_DATE = Pattern.compile("(\\d{4})[-/](\\d{1,2})[-/](\\d{1,2})");
@@ -88,7 +95,7 @@ public final class TmsV2RequestMapper {
// 5.2:quantity=int,weightKg/volumeCbm=decimal>0,禁止带单位字符串
put(target, "quantity", asInteger(first(source, "quantity", "pieces")));
put(target, "weightKg", asPositiveDecimal(first(source, "weightKg")));
put(target, "volumeCbm", asPositiveDecimal(first(source, "volumeCbm")));
put(target, "volumeCbm", asVolumeCbm(first(source, "volumeCbm")));
put(target, "packageType", first(source, "packageType", "packagingType"));
// 空运选填:有值才带。贸易条款不进出站,只给后台模板关键词匹配。
applyAirOptionalFields(source, target);
@@ -199,7 +206,8 @@ public final class TmsV2RequestMapper {
}
/**
* 毛重/体积:JSON 小数且 &gt;0。销售原文「100KGS」「2CBM」只取数字。
* 毛重等:JSON 小数且 &gt;0。销售原文「100KGS」「2CBM」只取第一个数字。
* 体积请用 {@link #asVolumeCbm},避免把尺寸式写成错误小数。
*/
public static BigDecimal asPositiveDecimal(Object raw) {
if (raw instanceof Number) {
@@ -213,6 +221,39 @@ public final class TmsV2RequestMapper {
return number;
}
/**
* 体积 CBM:尺寸式(长*宽*高)先相乘;否则与 {@link #asPositiveDecimal} 相同。
* 例:1.1*1.2*3 → 3.96;2CBM → 2。
*/
public static BigDecimal asVolumeCbm(Object raw) {
if (raw instanceof Number) {
return asPositiveDecimal(raw);
}
if (raw == null) {
return null;
}
String text = String.valueOf(raw).trim();
if (text.isEmpty()) {
return null;
}
Matcher dims = DIM_PRODUCT.matcher(text);
if (dims.find()) {
try {
BigDecimal a = new BigDecimal(dims.group(1));
BigDecimal b = new BigDecimal(dims.group(2));
BigDecimal c = dims.group(3) == null ? BigDecimal.ONE : new BigDecimal(dims.group(3));
BigDecimal product = a.multiply(b).multiply(c);
if (product.compareTo(BigDecimal.ZERO) <= 0) {
return null;
}
return product.stripTrailingZeros();
} catch (NumberFormatException ex) {
return null;
}
}
return asPositiveDecimal(raw);
}
private static BigDecimal firstDecimal(Object raw) {
if (raw == null) {
return null;
@@ -19,7 +19,8 @@ UPDATE `inq_response_timeout` SET `timeout_code`='land_hk_common' WHERE `id`='rt
UPDATE `inq_response_timeout` SET `timeout_code`='land_hk_cross' WHERE `id`='rt100000000000000000000000000008' AND (`timeout_code` IS NULL OR `timeout_code`='');
UPDATE `inq_response_timeout` SET `timeout_code`='land_hk_special' WHERE `id`='rt100000000000000000000000000009' AND (`timeout_code` IS NULL OR `timeout_code`='');
UPDATE `inq_response_timeout` SET `timeout_code`='land_hk_express' WHERE `id`='rt100000000000000000000000000010' AND (`timeout_code` IS NULL OR `timeout_code`='');
-- 陆运产品仅 4 项;ltl/city/sea 重复行已由 migration_inq_response_timeout_land_class_v2.sql 删除
-- 陆运产品 5 项;常用线路见 migration_inq_response_timeout_land_common_v1.sql
-- ltl/city/sea 重复行已由 migration_inq_response_timeout_land_class_v2.sql 删除
-- 3) 唯一索引(列已全部非空后再建;若仍有 NULL 会失败)
UPDATE `inq_response_timeout` SET `timeout_code`=CONCAT('rt_', `id`) WHERE `timeout_code` IS NULL OR `timeout_code`='';
@@ -0,0 +1,33 @@
-- 陆运产品运输分类增加「普件(常用线路/常规车型)」,超时小时 0=不监测。
-- 排在原 land_hk_common 之前;不改销售行,不改原 4 项文案和小时数。
-- 仅改 inquiry_robot;幂等可重复执行。
SET NAMES utf8mb4;
INSERT INTO `inq_response_timeout` (
`id`, `timeout_code`, `transport_mode`, `role_code`, `route_category`,
`transport_type`, `timeout_hours`, `sort_no`, `create_by`, `create_time`, `update_by`, `update_time`
)
SELECT
'rt100000000000000000000000000020',
'land_common',
'LAND',
'product',
'',
CONVERT(UNHEX('e699aee4bbb628e5b8b8e794a8e7babfe8b7af2fe5b8b8e8a784e8bda6e59e8b29') USING utf8mb4),
0,
IFNULL((
SELECT `sort_no` FROM (
SELECT `sort_no` FROM `inq_response_timeout` WHERE `timeout_code`='land_hk_common' LIMIT 1
) AS `hk`
), 7) - 1,
'system',
NOW(),
'system',
NOW()
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM (
SELECT `id` FROM `inq_response_timeout` WHERE `timeout_code`='land_common'
) AS `existed`
);
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<formControlPr xmlns="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" objectType="Radio" firstButton="1" val="0"/>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<formControlPr xmlns="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" objectType="Radio" firstButton="1" val="0"/>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<formControlPr xmlns="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" objectType="Radio" checked="Checked" val="0"/>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<formControlPr xmlns="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" objectType="Radio" checked="Checked" val="0"/>
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><xdr:twoCellAnchor><xdr:from><xdr:col>1</xdr:col><xdr:colOff>43180</xdr:colOff><xdr:row>2</xdr:row><xdr:rowOff>19685</xdr:rowOff></xdr:from><xdr:to><xdr:col>4</xdr:col><xdr:colOff>340995</xdr:colOff><xdr:row>2</xdr:row><xdr:rowOff>431165</xdr:rowOff></xdr:to><xdr:grpSp><xdr:nvGrpSpPr><xdr:cNvPr id="2" name="组合 1"/><xdr:cNvGrpSpPr/></xdr:nvGrpSpPr><xdr:grpSpPr><a:xfrm><a:off x="3018155" y="503555"/><a:ext cx="2124710" cy="342265"/><a:chOff x="4431" y="1131"/><a:chExt cx="3169" cy="649"/></a:xfrm></xdr:grpSpPr><mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice Requires="a14" xmlns:a14="http://schemas.microsoft.com/office/drawing/2010/main"><xdr:sp><xdr:nvSpPr><xdr:cNvPr id="2049" name="Option Button 1" hidden="1"><a:extLst><a:ext uri="{63B3BB69-23CF-44E3-9099-C40C66FF867C}"><a14:compatExt spid="_x0000_s2049"/></a:ext></a:extLst></xdr:cNvPr><xdr:cNvSpPr/></xdr:nvSpPr><xdr:spPr><a:xfrm><a:off x="4434" y="1131"/><a:ext cx="3166" cy="360"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></xdr:spPr><xdr:txBody><a:bodyPr vertOverflow="clip" wrap="square" lIns="27432" tIns="18288" rIns="0" bIns="18288" anchor="ctr" upright="1"/><a:lstStyle/><a:p><a:pPr algn="l" rtl="0"><a:defRPr sz="1000"/></a:pPr><a:r><a:rPr lang="zh-CN" altLang="en-US" sz="900" b="0" i="0" u="none" strike="noStrike" baseline="0"><a:solidFill><a:srgbClr val="000000"/></a:solidFill><a:latin typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/><a:ea typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/></a:rPr><a:t> Port to Port, 1工作天回复</a:t></a:r><a:endParaRPr lang="zh-CN" altLang="en-US" sz="900" b="0" i="0" u="none" strike="noStrike" baseline="0"><a:solidFill><a:srgbClr val="000000"/></a:solidFill><a:latin typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/><a:ea typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/></a:endParaRPr></a:p></xdr:txBody></xdr:sp></mc:Choice><mc:Fallback/></mc:AlternateContent><mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice Requires="a14" xmlns:a14="http://schemas.microsoft.com/office/drawing/2010/main"><xdr:sp><xdr:nvSpPr><xdr:cNvPr id="2050" name="Option Button 2" hidden="1"><a:extLst><a:ext uri="{63B3BB69-23CF-44E3-9099-C40C66FF867C}"><a14:compatExt spid="_x0000_s2050"/></a:ext></a:extLst></xdr:cNvPr><xdr:cNvSpPr/></xdr:nvSpPr><xdr:spPr><a:xfrm><a:off x="4431" y="1421"/><a:ext cx="2987" cy="359"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></xdr:spPr><xdr:txBody><a:bodyPr vertOverflow="clip" wrap="square" lIns="27432" tIns="18288" rIns="0" bIns="18288" anchor="ctr" upright="1"/><a:lstStyle/><a:p><a:pPr algn="l" rtl="0"><a:defRPr sz="1000"/></a:pPr><a:r><a:rPr lang="zh-CN" altLang="en-US" sz="900" b="0" i="0" u="none" strike="noStrike" baseline="0"><a:solidFill><a:srgbClr val="000000"/></a:solidFill><a:latin typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/><a:ea typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/></a:rPr><a:t> 其它, 3工作天回复</a:t></a:r><a:endParaRPr lang="zh-CN" altLang="en-US" sz="900" b="0" i="0" u="none" strike="noStrike" baseline="0"><a:solidFill><a:srgbClr val="000000"/></a:solidFill><a:latin typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/><a:ea typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/></a:endParaRPr></a:p></xdr:txBody></xdr:sp></mc:Choice><mc:Fallback/></mc:AlternateContent></xdr:grpSp><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><xdr:twoCellAnchor><xdr:from><xdr:col>1</xdr:col><xdr:colOff>43180</xdr:colOff><xdr:row>2</xdr:row><xdr:rowOff>19685</xdr:rowOff></xdr:from><xdr:to><xdr:col>4</xdr:col><xdr:colOff>340995</xdr:colOff><xdr:row>2</xdr:row><xdr:rowOff>431165</xdr:rowOff></xdr:to><xdr:grpSp><xdr:nvGrpSpPr><xdr:cNvPr id="2" name="组合 1"/><xdr:cNvGrpSpPr/></xdr:nvGrpSpPr><xdr:grpSpPr><a:xfrm><a:off x="3018155" y="503555"/><a:ext cx="2124710" cy="342265"/><a:chOff x="4431" y="1131"/><a:chExt cx="3169" cy="649"/></a:xfrm></xdr:grpSpPr><mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice xmlns:a14="http://schemas.microsoft.com/office/drawing/2010/main" Requires="a14"><xdr:sp><xdr:nvSpPr><xdr:cNvPr id="2049" name="Option Button 1" hidden="1"><a:extLst><a:ext uri="{63B3BB69-23CF-44E3-9099-C40C66FF867C}"><a14:compatExt spid="_x0000_s2049"/></a:ext></a:extLst></xdr:cNvPr><xdr:cNvSpPr/></xdr:nvSpPr><xdr:spPr><a:xfrm><a:off x="4434" y="1131"/><a:ext cx="3166" cy="360"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></xdr:spPr><xdr:txBody><a:bodyPr vertOverflow="clip" wrap="square" lIns="27432" tIns="18288" rIns="0" bIns="18288" anchor="ctr" upright="1"/><a:lstStyle/><a:p><a:pPr algn="l" rtl="0"><a:defRPr sz="1000"/></a:pPr><a:r><a:rPr lang="zh-CN" altLang="en-US" sz="900" b="0" i="0" u="none" strike="noStrike" baseline="0"><a:solidFill><a:srgbClr val="000000"/></a:solidFill><a:latin typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/><a:ea typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/></a:rPr><a:t> Port to Port, 1工作天回复</a:t></a:r><a:endParaRPr lang="zh-CN" altLang="en-US" sz="900" b="0" i="0" u="none" strike="noStrike" baseline="0"><a:solidFill><a:srgbClr val="000000"/></a:solidFill><a:latin typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/><a:ea typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/></a:endParaRPr></a:p></xdr:txBody></xdr:sp></mc:Choice><mc:Fallback/></mc:AlternateContent><mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice xmlns:a14="http://schemas.microsoft.com/office/drawing/2010/main" Requires="a14"><xdr:sp><xdr:nvSpPr><xdr:cNvPr id="2050" name="Option Button 2" hidden="1"><a:extLst><a:ext uri="{63B3BB69-23CF-44E3-9099-C40C66FF867C}"><a14:compatExt spid="_x0000_s2050"/></a:ext></a:extLst></xdr:cNvPr><xdr:cNvSpPr/></xdr:nvSpPr><xdr:spPr><a:xfrm><a:off x="4431" y="1421"/><a:ext cx="2987" cy="359"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></xdr:spPr><xdr:txBody><a:bodyPr vertOverflow="clip" wrap="square" lIns="27432" tIns="18288" rIns="0" bIns="18288" anchor="ctr" upright="1"/><a:lstStyle/><a:p><a:pPr algn="l" rtl="0"><a:defRPr sz="1000"/></a:pPr><a:r><a:rPr lang="zh-CN" altLang="en-US" sz="900" b="0" i="0" u="none" strike="noStrike" baseline="0"><a:solidFill><a:srgbClr val="000000"/></a:solidFill><a:latin typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/><a:ea typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/></a:rPr><a:t> 其它, 3工作天回复</a:t></a:r><a:endParaRPr lang="zh-CN" altLang="en-US" sz="900" b="0" i="0" u="none" strike="noStrike" baseline="0"><a:solidFill><a:srgbClr val="000000"/></a:solidFill><a:latin typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/><a:ea typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/></a:endParaRPr></a:p></xdr:txBody></xdr:sp></mc:Choice><mc:Fallback/></mc:AlternateContent></xdr:grpSp><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<xml xmlns:oa="urn:schemas-microsoft-com:office:activation" xmlns:p="urn:schemas-microsoft-com:office:powerpoint" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:v="urn:schemas-microsoft-com:vml"><o:shapelayout v:ext="edit"><o:idmap v:ext="edit" data="2"/></o:shapelayout><v:shapetype id="_x0000_t201" coordsize="21600,21600" o:spt="201" path="m,l,21600r21600,l21600,xe"
>
<v:stroke joinstyle="miter"/>
<v:path shadowok="f" o:extrusionok="f" strokeok="f" fillok="f" o:connecttype="rect"/>
<o:lock v:ext="edit" shapetype="t"/>
</v:shapetype>
<v:shape id="_x0000_s2049" o:spt="201" type="#_x0000_t201" style="position:absolute;left:0pt;top:0pt;margin-left:206.9pt;margin-top:39.65pt;height:14.65pt;width:107.95pt;" filled="f" stroked="f" o:insetmode="auto" coordsize="21600,21600"><v:path fillok="t" strokeok="t" shadowok="t"/><v:fill on="f" focussize="0,0"/><v:stroke on="f"/><v:imagedata o:title=""/><o:lock v:ext="edit" rotation="t" aspectratio="f"/><v:textbox style="direction:ltr;layout-flow:horizontal;mso-rotate:0;"><div style="text-align:left"><p><font face="宋体" size="180" color="#000000"><span style="mso-spacerun:yes"> Port to Port, 1工作天回复</span></font></p></div></v:textbox><x:ClientData ObjectType="Radio"><x:SizeWithCells/><x:Anchor>1,5,2,2,4,44,2,26</x:Anchor><x:FirstButton>1</x:FirstButton><x:TextVAlign>Center</x:TextVAlign></x:ClientData></v:shape><v:shape id="_x0000_s2050" o:spt="201" type="#_x0000_t201" style="position:absolute;left:0pt;top:0pt;margin-left:206.8pt;margin-top:51.45pt;height:14.65pt;width:101.8pt;" filled="f" stroked="f" o:insetmode="auto" coordsize="21600,21600"><v:path fillok="t" strokeok="t" shadowok="t"/><v:fill on="f" focussize="0,0"/><v:stroke on="f"/><v:imagedata o:title=""/><o:lock v:ext="edit" rotation="t" aspectratio="f"/><v:textbox style="direction:ltr;layout-flow:horizontal;mso-rotate:0;"><div style="text-align:left"><p><font face="宋体" size="180" color="#000000"><span style="mso-spacerun:yes"> 其它, 3工作天回复</span></font></p></div></v:textbox><x:ClientData ObjectType="Radio"><x:SizeWithCells/><x:Anchor>1,5,2,22,4,34,3,0</x:Anchor><x:Checked>1</x:Checked><x:FirstButton>0</x:FirstButton><x:TextVAlign>Center</x:TextVAlign></x:ClientData></v:shape></xml>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<xml xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:x="urn:schemas-microsoft-com:office:excel"><o:shapelayout v:ext="edit"><o:idmap data="2" v:ext="edit"/></o:shapelayout><v:shapetype coordsize="21600,21600" id="_x0000_t201" o:spt="201" path="m,l,21600r21600,l21600,xe">
<v:stroke joinstyle="miter"/>
<v:path fillok="f" o:connecttype="rect" o:extrusionok="f" shadowok="f" strokeok="f"/>
<o:lock shapetype="t" v:ext="edit"/>
</v:shapetype><v:shape coordsize="21600,21600" filled="f" id="_x0000_s2049" o:insetmode="auto" o:spt="201" stroked="f" style="position:absolute;left:0pt;top:0pt;margin-left:237.8pt;margin-top:39.65pt;height:14.9pt;width:167.15pt;" type="#_x0000_t201"><v:path fillok="t" shadowok="t" strokeok="t"/><v:fill focussize="0,0" on="f"/><v:stroke on="f"/><v:imagedata o:title=""/><o:lock aspectratio="f" rotation="t" v:ext="edit"/><v:textbox style="direction:ltr;layout-flow:horizontal;mso-rotate:0;"><div style="text-align:left"><p><font color="#000000" face="宋体" size="180"><span style="mso-spacerun:yes"> Port to Port, 1工作天回复</span></font></p></div></v:textbox><x:ClientData ObjectType="Radio"><x:SizeWithCells/><x:Checked>1</x:Checked><x:Anchor>1,5,2,2,4,44,2,27</x:Anchor><x:FirstButton>1</x:FirstButton><x:TextVAlign>Center</x:TextVAlign></x:ClientData></v:shape><v:shape coordsize="21600,21600" filled="f" id="_x0000_s2050" o:insetmode="auto" o:spt="201" stroked="f" style="position:absolute;left:0pt;top:0pt;margin-left:237.65pt;margin-top:51.65pt;height:14.95pt;width:157.65pt;" type="#_x0000_t201"><v:path fillok="t" shadowok="t" strokeok="t"/><v:fill focussize="0,0" on="f"/><v:stroke on="f"/><v:imagedata o:title=""/><o:lock aspectratio="f" rotation="t" v:ext="edit"/><v:textbox style="direction:ltr;layout-flow:horizontal;mso-rotate:0;"><div style="text-align:left"><p><font color="#000000" face="宋体" size="180"><span style="mso-spacerun:yes"> 其它, 3工作天回复</span></font></p></div></v:textbox><x:ClientData ObjectType="Radio"><x:SizeWithCells/><x:Anchor>1,5,2,22,4,28,3,0</x:Anchor><x:FirstButton>0</x:FirstButton><x:TextVAlign>Center</x:TextVAlign></x:ClientData></v:shape></xml>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<xml xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:x="urn:schemas-microsoft-com:office:excel"><o:shapelayout v:ext="edit"><o:idmap data="2" v:ext="edit"/></o:shapelayout><v:shapetype coordsize="21600,21600" id="_x0000_t201" o:spt="201" path="m,l,21600r21600,l21600,xe">
<v:stroke joinstyle="miter"/>
<v:path fillok="f" o:connecttype="rect" o:extrusionok="f" shadowok="f" strokeok="f"/>
<o:lock shapetype="t" v:ext="edit"/>
</v:shapetype><v:shape coordsize="21600,21600" filled="f" id="_x0000_s2049" o:insetmode="auto" o:spt="201" stroked="f" style="position:absolute;left:0pt;top:0pt;margin-left:237.8pt;margin-top:39.65pt;height:14.9pt;width:167.15pt;" type="#_x0000_t201"><v:path fillok="t" shadowok="t" strokeok="t"/><v:fill focussize="0,0" on="f"/><v:stroke on="f"/><v:imagedata o:title=""/><o:lock aspectratio="f" rotation="t" v:ext="edit"/><v:textbox style="direction:ltr;layout-flow:horizontal;mso-rotate:0;"><div style="text-align:left"><p><font color="#000000" face="宋体" size="180"><span style="mso-spacerun:yes"> Port to Port, 1工作天回复</span></font></p></div></v:textbox><x:ClientData ObjectType="Radio"><x:SizeWithCells/><x:Anchor>1,5,2,2,4,44,2,27</x:Anchor><x:FirstButton>1</x:FirstButton><x:TextVAlign>Center</x:TextVAlign></x:ClientData></v:shape><v:shape coordsize="21600,21600" filled="f" id="_x0000_s2050" o:insetmode="auto" o:spt="201" stroked="f" style="position:absolute;left:0pt;top:0pt;margin-left:237.65pt;margin-top:51.65pt;height:14.95pt;width:157.65pt;" type="#_x0000_t201"><v:path fillok="t" shadowok="t" strokeok="t"/><v:fill focussize="0,0" on="f"/><v:stroke on="f"/><v:imagedata o:title=""/><o:lock aspectratio="f" rotation="t" v:ext="edit"/><v:textbox style="direction:ltr;layout-flow:horizontal;mso-rotate:0;"><div style="text-align:left"><p><font color="#000000" face="宋体" size="180"><span style="mso-spacerun:yes"> 其它, 3工作天回复</span></font></p></div></v:textbox><x:ClientData ObjectType="Radio"><x:SizeWithCells/><x:Checked>1</x:Checked><x:Anchor>1,5,2,22,4,28,3,0</x:Anchor><x:FirstButton>0</x:FirstButton><x:TextVAlign>Center</x:TextVAlign></x:ClientData></v:shape></xml>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<xml xmlns:oa="urn:schemas-microsoft-com:office:activation" xmlns:p="urn:schemas-microsoft-com:office:powerpoint" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:v="urn:schemas-microsoft-com:vml"><o:shapelayout v:ext="edit"><o:idmap v:ext="edit" data="2"/></o:shapelayout><v:shapetype id="_x0000_t201" coordsize="21600,21600" o:spt="201" path="m,l,21600r21600,l21600,xe"
>
<v:stroke joinstyle="miter"/>
<v:path shadowok="f" o:extrusionok="f" strokeok="f" fillok="f" o:connecttype="rect"/>
<o:lock v:ext="edit" shapetype="t"/>
</v:shapetype>
<v:shape id="_x0000_s2049" o:spt="201" type="#_x0000_t201" style="position:absolute;left:0pt;top:0pt;margin-left:237.8pt;margin-top:39.65pt;height:14.9pt;width:167.15pt;" filled="f" stroked="f" o:insetmode="auto" coordsize="21600,21600"><v:path fillok="t" strokeok="t" shadowok="t"/><v:fill on="f" focussize="0,0"/><v:stroke on="f"/><v:imagedata o:title=""/><o:lock v:ext="edit" rotation="t" aspectratio="f"/><v:textbox style="direction:ltr;layout-flow:horizontal;mso-rotate:0;"><div style="text-align:left"><p><font face="宋体" size="180" color="#000000"><span style="mso-spacerun:yes"> Port to Port, 1工作天回复</span></font></p></div></v:textbox><x:ClientData ObjectType="Radio"><x:SizeWithCells/><x:Anchor>1,5,2,2,4,44,2,27</x:Anchor><x:FirstButton>1</x:FirstButton><x:TextVAlign>Center</x:TextVAlign></x:ClientData></v:shape><v:shape id="_x0000_s2050" o:spt="201" type="#_x0000_t201" style="position:absolute;left:0pt;top:0pt;margin-left:237.65pt;margin-top:51.65pt;height:14.95pt;width:157.65pt;" filled="f" stroked="f" o:insetmode="auto" coordsize="21600,21600"><v:path fillok="t" strokeok="t" shadowok="t"/><v:fill on="f" focussize="0,0"/><v:stroke on="f"/><v:imagedata o:title=""/><o:lock v:ext="edit" rotation="t" aspectratio="f"/><v:textbox style="direction:ltr;layout-flow:horizontal;mso-rotate:0;"><div style="text-align:left"><p><font face="宋体" size="180" color="#000000"><span style="mso-spacerun:yes"> 其它, 3工作天回复</span></font></p></div></v:textbox><x:ClientData ObjectType="Radio"><x:SizeWithCells/><x:Anchor>1,5,2,22,4,28,3,0</x:Anchor><x:Checked>1</x:Checked><x:FirstButton>0</x:FirstButton><x:TextVAlign>Center</x:TextVAlign></x:ClientData></v:shape></xml>
@@ -17,7 +17,9 @@ import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import org.apache.poi.ss.usermodel.CellType;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
@@ -236,6 +238,337 @@ public class QuoteTemplateFillTest {
}
}
/**
* 回归:无 TMS 费用行时,费用表「项目=起运港」不得把 pol 写进右侧「费用名称」。
* 对应 WO202609230026 空运协同出单:空运段旁费用名称被写成「广州」。
*/
@Test
public void airNoTmsMustNotWritePolIntoFeeNameColumn() throws Exception {
Path src = Files.createTempFile("quote-air-notms-src", ".xlsx");
Path out = Files.createTempFile("quote-air-notms-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("Sheet2");
// 货物区:与现网空运映射一致(B6 同格冒号后)
org.apache.poi.ss.usermodel.Row r5 = sheet.createRow(5);
r5.createCell(1).setCellValue("起运港:");
r5.createCell(6).setCellValue("目的港:");
// 费用表明细(headerRow=16 → 0-based 15;anchor A17 → 0-based 16)
org.apache.poi.ss.usermodel.Row head = sheet.createRow(15);
head.createCell(0).setCellValue("项目");
head.createCell(1).setCellValue("费用名称");
head.createCell(2).setCellValue("单位");
head.createCell(3).setCellValue("币别");
head.createCell(4).setCellValue("单价");
head.createCell(5).setCellValue("数量");
head.createCell(6).setCellValue("金额");
head.createCell(7).setCellValue("备注");
org.apache.poi.ss.usermodel.Row airSeg = sheet.createRow(16);
airSeg.createCell(0).setCellValue("空运段");
airSeg.createCell(1).setCellValue("");
org.apache.poi.ss.usermodel.Row polSeg = sheet.createRow(17);
polSeg.createCell(0).setCellValue("起运港");
polSeg.createCell(1).setCellValue("");
org.apache.poi.ss.usermodel.Row otherSeg = sheet.createRow(18);
otherSeg.createCell(0).setCellValue("其他");
otherSeg.createCell(1).setCellValue("");
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject mapping = JSONObject.parseObject(
java.nio.file.Files.readString(
java.nio.file.Paths.get("d:/own/友通达询价智能体(新)/inquiry-agent/scripts/_verify_quote/mapping_air_ddp.json")));
JSONObject facts = new JSONObject(true);
facts.put("pol", "广州");
facts.put("pod", "香港");
facts.put("quote_no", "WO202609230026");
// 无 TMS:不带 fee_rows
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled.getSheetAt(0);
String b16 = sheet.getRow(16).getCell(1) == null ? ""
: String.valueOf(sheet.getRow(16).getCell(1));
String b17 = sheet.getRow(17).getCell(1) == null ? ""
: String.valueOf(sheet.getRow(17).getCell(1));
String b18 = sheet.getRow(18).getCell(1) == null ? ""
: String.valueOf(sheet.getRow(18).getCell(1));
// 用 DataFormatter 读字符串更稳
org.apache.poi.ss.usermodel.DataFormatter fmt = new org.apache.poi.ss.usermodel.DataFormatter();
b16 = fmt.formatCellValue(sheet.getRow(16).getCell(1));
b17 = fmt.formatCellValue(sheet.getRow(17).getCell(1));
b18 = fmt.formatCellValue(sheet.getRow(18).getCell(1));
String cargoPol = fmt.formatCellValue(sheet.getRow(5).getCell(1));
assertTrue(cargoPol.contains("广州"), "货物区起运港应写入广州,实际=" + cargoPol);
assertFalse(b16.contains("广州"), "空运段旁费用名称不应是起运港,实际=" + b16);
assertFalse(b17.contains("广州"), "起运港项目旁费用名称不应是起运港,实际=" + b17);
assertFalse(b18.contains("广州"), "其他旁费用名称不应是起运港,实际=" + b18);
assertEquals("起运港", fmt.formatCellValue(sheet.getRow(17).getCell(0)));
assertEquals("空运段", fmt.formatCellValue(sheet.getRow(16).getCell(0)));
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
/**
* 回归:海运双栏同行时,「整柜或拼柜」不得跨过右侧「送货地址」标签写到 H 列;
* 运输分类单选(drawing 文字)不得被 POI 填表抹掉。
* 对应 WO202609240027:送货地址=整柜、表外残留、运输分类文字丢失。
*/
@Test
public void seaTwoColumnMustNotSpillFclIntoDeliveryAddress() throws Exception {
Path seed = java.nio.file.Paths.get(
"d:/own/友通达询价智能体(新)/inquiry-agent/scripts/_verify_quote/sea_template_seed.xlsx");
org.junit.jupiter.api.Assumptions.assumeTrue(Files.exists(seed), "缺海运 seed 模板");
Path out = Files.createTempFile("quote-sea-spill-out", ".xlsx");
try {
// 读模板 zip 内 drawing 文字作基线
java.util.List<String> before = readDrawingTexts(seed);
org.junit.jupiter.api.Assertions.assertFalse(before.isEmpty(), "模板应有运输分类 drawing 文字");
JSONObject mapping = new JSONObject();
mapping.put("fields", new JSONObject());
JSONObject facts = new JSONObject(true);
facts.put("quote_no", "WO202609240027");
facts.put("fcl_or_lcl", "整柜");
facts.put("pol", "广州港");
facts.put("pod", "巴生港");
facts.put("commodity", "机器设备");
facts.put("quantity", "15件");
facts.put("container_type_qty", "40HQ*1");
facts.put("trade_terms", "门到门");
facts.put("运输分类", "其他");
QuoteTemplateExcelSupport.fillWorkbook(seed, out, mapping, facts);
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled.getSheetAt(0);
org.apache.poi.ss.usermodel.DataFormatter fmt = new org.apache.poi.ss.usermodel.DataFormatter();
String b7 = fmt.formatCellValue(sheet.getRow(6).getCell(1));
String h7 = fmt.formatCellValue(sheet.getRow(6).getCell(7));
String e7 = fmt.formatCellValue(sheet.getRow(6).getCell(4));
assertTrue(e7.contains("送货地址"), "送货地址标签应保留,实际=" + e7);
assertEquals("整柜", b7, "整柜应写在左侧值区 B7");
assertFalse(h7.contains("整柜"), "送货地址值区不得写入整柜,实际=" + h7);
}
// 「其它」:ClientData 保持 WO011 首钮勾选(才能画);文案对调后勾选看起来是「其它」
String p1 = readZipEntryText(out, "xl/ctrlProps/ctrlProp1.xml");
String p2 = readZipEntryText(out, "xl/ctrlProps/ctrlProp2.xml");
assertTrue(p1.contains("checked=\"Checked\"") && p1.contains("firstButton"),
"其它也须保持 WO011 首钮勾选结构,实际=" + p1);
assertFalse(p2.contains("checked=\"Checked\""), "次钮不应 Checked,实际=" + p2);
String vml = readZipEntryText(out, "xl/drawings/vmlDrawing1.vml");
java.util.List<String> bodies = new java.util.ArrayList<>();
java.util.regex.Matcher vm = java.util.regex.Pattern.compile(
"<x:ClientData\\s+ObjectType=\"Radio\">([\\s\\S]*?)</x:ClientData>",
java.util.regex.Pattern.CASE_INSENSITIVE).matcher(vml);
while (vm.find()) {
bodies.add(vm.group(1));
}
assertEquals(2, bodies.size());
assertTrue(bodies.get(0).contains("<x:Checked>"), "ClientData 首钮须 Checked");
assertFalse(bodies.get(1).contains("<x:Checked>"), "ClientData 次钮不得 Checked");
// 文案已对调:第一颗显示「其它」
assertTrue(vml.contains("其它") && vml.indexOf("其它") < vml.indexOf("Port"),
"其它文案应出现在第一颗单选位置");
try (Workbook filled2 = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled2.getSheetAt(0);
org.apache.poi.ss.usermodel.DataFormatter fmt = new org.apache.poi.ss.usermodel.DataFormatter();
String b3 = fmt.formatCellValue(sheet.getRow(2).getCell(1));
assertFalse(b3.contains("●") || b3.contains("○") || b3.contains("工作天"),
"B3 不得写明文,实际=" + b3);
}
} finally {
Files.deleteIfExists(out);
}
}
/**
* 回归 WO007:运输分类 Port to Port 时,ctrlProp 与 VML 都必须勾 Port to Port(WPS 读 VML)。
*/
@Test
public void seaPortToPortMustCheckVmlAndCtrlProp() throws Exception {
Path seed = java.nio.file.Paths.get(
"d:/own/友通达询价智能体(新)/inquiry-agent/scripts/_verify_quote/sea_template_seed.xlsx");
org.junit.jupiter.api.Assumptions.assumeTrue(Files.exists(seed), "缺海运 seed 模板");
Path out = Files.createTempFile("quote-sea-p2p-out", ".xlsx");
try {
JSONObject mapping = new JSONObject();
mapping.put("fields", new JSONObject());
JSONObject facts = new JSONObject(true);
facts.put("quote_no", "WO202609280007");
facts.put("运输分类", "Port to Port");
facts.put("trade_terms", "Port to Port");
facts.put("贸易条款", "Port to Port");
facts.put("pol", "广州港");
facts.put("pod", "巴生港");
QuoteTemplateExcelSupport.fillWorkbook(seed, out, mapping, facts);
String p1 = readZipEntryText(out, "xl/ctrlProps/ctrlProp1.xml");
String p2 = readZipEntryText(out, "xl/ctrlProps/ctrlProp2.xml");
assertTrue(p1.contains("checked=\"Checked\""), "Port to Port 应勾选 ctrlProp1,实际=" + p1);
assertFalse(p2.contains("checked=\"Checked\""), "其它不应勾选,实际=" + p2);
// WO011 标准:强制瘦 drawing≈3966 + 原 Option Button;B3 无合成 ●/○
int outDraw = readZipEntryBytes(out, "xl/drawings/drawing1.xml").length;
assertTrue(outDraw > 3000 && outDraw < 5000,
"应使用 WO011 标准瘦 drawing,out=" + outDraw);
String outDrawXml = readZipEntryText(out, "xl/drawings/drawing1.xml");
assertTrue(outDrawXml.contains("Option Button"),
"drawing 应保留原 Option Button(与 WO011 一致)");
int stylesLen = readZipEntryBytes(out, "xl/styles.xml").length;
assertTrue(stylesLen > 50000,
"应盖回 WO011 styles,避免 POI 改瘦后 WPS 空白,styles=" + stylesLen);
String vml = readZipEntryText(out, "xl/drawings/vmlDrawing1.vml");
// VML 文案用 NBSP(\u00A0)分隔,不能直接 contains "Port to Port"
assertTrue(vml.contains("Port") && vml.contains("其它"),
"VML 应保留原 Form Control 文案");
assertTrue(vml.contains("xmlns:o=") && !vml.contains("xmlns:oa="),
"VML 应为 WO011 标准 xmlns:o");
assertTrue(vml.contains("ObjectType=\"Radio\""), "VML 应有 Radio ClientData");
java.util.regex.Matcher m = java.util.regex.Pattern.compile(
"<x:ClientData\\s+ObjectType=\"Radio\">([\\s\\S]*?)</x:ClientData>",
java.util.regex.Pattern.CASE_INSENSITIVE).matcher(vml);
boolean sawFirst = false;
boolean sawOther = false;
while (m.find()) {
String body = m.group(1);
boolean first = body.contains("<x:FirstButton>1</x:FirstButton>");
boolean checked = body.contains("<x:Checked>");
if (first) {
sawFirst = true;
assertTrue(checked, "Port to Port 的 VML 应 Checked,body=" + body);
} else {
sawOther = true;
assertFalse(checked, "其它的 VML 不应 Checked,body=" + body);
}
}
assertTrue(sawFirst && sawOther, "VML 应有两组 Radio ClientData");
// WO011 标准:B3 无合成 ●/○;原控件勾选即可
org.apache.poi.ss.usermodel.Workbook wb = org.apache.poi.ss.usermodel.WorkbookFactory.create(out.toFile());
try {
org.apache.poi.ss.usermodel.Sheet sh = null;
for (int i = 0; i < wb.getNumberOfSheets(); i++) {
if (wb.getSheetName(i).contains("海运")) {
sh = wb.getSheetAt(i);
break;
}
}
org.junit.jupiter.api.Assertions.assertNotNull(sh, "缺海运 sheet");
org.apache.poi.ss.usermodel.DataFormatter fmt = new org.apache.poi.ss.usermodel.DataFormatter();
String b3 = "";
if (sh.getRow(2) != null && sh.getRow(2).getCell(1) != null) {
b3 = fmt.formatCellValue(sh.getRow(2).getCell(1));
}
assertFalse(b3.contains("●") || b3.contains("○"),
"WO011 标准:B3 不得写合成 ●/○,实际=" + b3);
} finally {
wb.close();
}
} finally {
Files.deleteIfExists(out);
}
}
@Test
public void seaOtherKeepsWo011VmlAndEmptyB3() throws Exception {
Path seed = java.nio.file.Paths.get(
"d:/own/友通达询价智能体(新)/inquiry-agent/scripts/_verify_quote/sea_template_seed.xlsx");
Path wo011 = java.nio.file.Paths.get(
"d:/own/友通达询价智能体(新)/inquiry-agent/scripts/_verify_quote/wo011.xlsx");
org.junit.jupiter.api.Assumptions.assumeTrue(Files.exists(seed), "缺海运 seed 模板");
org.junit.jupiter.api.Assumptions.assumeTrue(Files.exists(wo011), "缺 WO011");
Path out = Files.createTempFile("quote-sea-other-out", ".xlsx");
try {
JSONObject mapping = new JSONObject();
mapping.put("fields", new JSONObject());
JSONObject facts = new JSONObject(true);
facts.put("quote_no", "WO202609280033");
facts.put("运输分类", "其它");
facts.put("pol", "纽约港");
QuoteTemplateExcelSupport.fillWorkbook(seed, out, mapping, facts);
byte[] vmlOut = readZipEntryBytes(out, "xl/drawings/vmlDrawing1.vml");
byte[] vml011 = readZipEntryBytes(wo011, "xl/drawings/vmlDrawing1.vml");
assertArrayEquals(vml011, vmlOut, "其它场景 VML 必须与 WO011 字节级一致(否则 WPS 空白)");
org.apache.poi.ss.usermodel.Workbook wb = org.apache.poi.ss.usermodel.WorkbookFactory.create(out.toFile());
try {
org.apache.poi.ss.usermodel.Sheet sh = null;
for (int i = 0; i < wb.getNumberOfSheets(); i++) {
if (wb.getSheetName(i).contains("海运")) {
sh = wb.getSheetAt(i);
break;
}
}
org.junit.jupiter.api.Assertions.assertNotNull(sh);
org.apache.poi.ss.usermodel.DataFormatter fmt = new org.apache.poi.ss.usermodel.DataFormatter();
String b3 = "";
if (sh.getRow(2) != null && sh.getRow(2).getCell(1) != null) {
b3 = fmt.formatCellValue(sh.getRow(2).getCell(1));
}
// WO033 证据:B3 明文会让 WPS 不画原控件;必须空着,与 WO011/WO030 一致
assertTrue(b3 == null || b3.trim().isEmpty(),
"其它场景 B3 必须空白(控件锚点区),实际=" + b3);
assertFalse(b3.contains("●") || b3.contains("○"), "不得写合成 ●/○");
} finally {
wb.close();
}
} finally {
Files.deleteIfExists(out);
}
}
private static byte[] readZipEntryBytes(Path xlsx, String entry) throws Exception {
try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(xlsx.toFile())) {
java.util.zip.ZipEntry e = zf.getEntry(entry);
org.junit.jupiter.api.Assertions.assertNotNull(e, "缺 zip 条目 " + entry);
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
try (java.io.InputStream in = zf.getInputStream(e)) {
byte[] buf = new byte[4096];
int n;
while ((n = in.read(buf)) >= 0) {
bos.write(buf, 0, n);
}
}
return bos.toByteArray();
}
}
private static String readZipEntryText(Path xlsx, String entry) throws Exception {
try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(xlsx.toFile())) {
java.util.zip.ZipEntry e = zf.getEntry(entry);
org.junit.jupiter.api.Assertions.assertNotNull(e, "缺 zip 条目 " + entry);
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
try (java.io.InputStream in = zf.getInputStream(e)) {
byte[] buf = new byte[4096];
int n;
while ((n = in.read(buf)) >= 0) {
bos.write(buf, 0, n);
}
}
return bos.toString("UTF-8");
}
}
private static java.util.List<String> readDrawingTexts(Path xlsx) throws Exception {
java.util.List<String> out = new java.util.ArrayList<>();
try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(xlsx.toFile())) {
java.util.zip.ZipEntry e = zf.getEntry("xl/drawings/drawing1.xml");
if (e != null) {
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
try (java.io.InputStream in = zf.getInputStream(e)) {
byte[] buf = new byte[4096];
int n;
while ((n = in.read(buf)) >= 0) {
bos.write(buf, 0, n);
}
}
String xml = bos.toString("UTF-8");
java.util.regex.Matcher m = java.util.regex.Pattern.compile("<a:t>([^<]+)</a:t>").matcher(xml);
while (m.find()) {
out.add(m.group(1));
}
}
}
return out;
}
/**
* 空运模板「预估总价」原是 SUM。有 total_amount(TMS 总销售价)时改写成该数,
* 避免刷公式后把固定费加进去、或标签旁再写一份形成两个总价。
@@ -351,6 +684,166 @@ public class QuoteTemplateFillTest {
}
}
/**
* TMS 已返回分段汇总时:海运「始发港费用汇总」格若是 SUM,必须去公式改写 fee_total_*。
*/
@Test
public void seaSectionSubtotalStripsSumWhenTmsTotalPresent() throws Exception {
Path src = Files.createTempFile("quote-sea-sub-src", ".xlsx");
Path out = Files.createTempFile("quote-sea-sub-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("询价单");
org.apache.poi.ss.usermodel.Row head = sheet.createRow(10);
head.createCell(0).setCellValue("项目");
head.createCell(1).setCellValue("币别");
head.createCell(7).setCellValue("金额");
sheet.createRow(11).createCell(0).setCellValue("THC");
sheet.getRow(11).createCell(7).setCellValue(100.0);
sheet.createRow(12).createCell(0).setCellValue("文件费");
sheet.getRow(12).createCell(7).setCellValue(50.0);
org.apache.poi.ss.usermodel.Row sub = sheet.createRow(13);
sub.createCell(0).setCellValue("始发港费用汇总");
sub.createCell(7).setCellFormula("SUM(H12:H13)");
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject mapping = new JSONObject();
mapping.put("fields", new JSONObject());
JSONObject facts = new JSONObject(true);
facts.put("currency", "CNY");
facts.put("fee_total_polCostItems", "CNY 888.00");
facts.put("total_amount", "9999.00");
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
try (Workbook got = new XSSFWorkbook(Files.newInputStream(out))) {
org.apache.poi.ss.usermodel.Cell amt = got.getSheetAt(0).getRow(13).getCell(7);
assertEquals(CellType.NUMERIC, amt.getCellType());
assertEquals(888.00, amt.getNumericCellValue(), 0.01);
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
/**
* WO040:departureCharges 只有增点费/拆板费时,模板 SUM=200;
* 预估费用必须盖成 TMS salePrice(7918.25),且 G 列不能再留 SUM 公式。
*/
@Test
public void estimateTotalUsesSalePriceWhenFeeLinesAreSurchargesOnly() throws Exception {
Path src = Files.createTempFile("quote-est-air-src", ".xlsx");
Path out = Files.createTempFile("quote-est-air-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("Sheet2");
org.apache.poi.ss.usermodel.Row head = sheet.createRow(15);
head.createCell(0).setCellValue("项目");
head.createCell(1).setCellValue("费用名称");
head.createCell(2).setCellValue("单位");
head.createCell(3).setCellValue("币别");
head.createCell(4).setCellValue("单价");
head.createCell(5).setCellValue("数量");
head.createCell(6).setCellValue("金额");
org.apache.poi.ss.usermodel.Row r1 = sheet.createRow(16);
r1.createCell(0).setCellValue("空运段");
r1.createCell(1).setCellValue("增点费");
r1.createCell(6).setCellValue(100.0);
org.apache.poi.ss.usermodel.Row r2 = sheet.createRow(17);
r2.createCell(0).setCellValue("起运港");
r2.createCell(1).setCellValue("拆板费");
r2.createCell(6).setCellValue(100.0);
org.apache.poi.ss.usermodel.Row total = sheet.createRow(22);
total.createCell(0).setCellValue("预估费用");
total.createCell(6).setCellFormula("SUM(G17:G22)");
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject mapping = new JSONObject();
mapping.put("fields", new JSONObject());
JSONObject facts = new JSONObject(true);
facts.put("total_amount", "7918.25");
facts.put("currency", "CNY");
facts.put("fee_rows", QuoteTemplateExcelSupport.parseFeeRows(
"["
+ "{\"name\":\"增点费\",\"amount\":\"100.0\",\"currency\":\"CNY\",\"unit\":\"点\",\"quantity\":\"1\",\"unit_price\":\"100.0\"},"
+ "{\"name\":\"拆板费\",\"amount\":\"100.0\",\"currency\":\"USD\",\"unit\":\"板数\",\"quantity\":\"1\",\"unit_price\":\"100.0\"}"
+ "]"));
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
try (Workbook got = new XSSFWorkbook(Files.newInputStream(out))) {
org.apache.poi.ss.usermodel.Cell sum = got.getSheetAt(0).getRow(22).getCell(6);
assertEquals(CellType.NUMERIC, sum.getCellType());
assertEquals(7918.25, sum.getNumericCellValue(), 0.01);
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
/**
* 顶层 total 空、只有 selected_air_option.salePrice 时,填表 total_amount 仍要落到销售价。
*/
@Test
public void buildFillFactsAirTotalFromSelectedOptionSalePrice() {
Map<String, Object> facts = new HashMap<>();
facts.put("运输方式", "空运");
facts.put("品名", "普货");
Map<String, Object> charge = new HashMap<>();
charge.put("chargeName", "增点费");
charge.put("unitPrice", 100.0);
charge.put("currency", "CNY");
charge.put("unit", "点");
Map<String, Object> option = new HashMap<>();
option.put("optionNo", "AIR-OPT01");
option.put("salePrice", 7918.25);
option.put("currency", "CNY");
option.put("departureCharges", java.util.Collections.singletonList(charge));
Map<String, Object> quote = new HashMap<>();
quote.put("selected_air_option", option);
quote.put("airOptions", java.util.Collections.singletonList(option));
quote.put("chargeableWeightKg", "833.5");
JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl
.buildFillFacts("WO202609290040", facts, quote, "WuJiLin", "AIR");
assertEquals("7918.25", out.getString("total_amount"));
assertEquals("CNY", out.getString("currency"));
assertTrue(out.getJSONArray("fee_rows").size() >= 1);
}
/**
* WO026/WO040:群薄报价把增点费+拆板费之和写成 total=200,
* 但 airOptions.salePrice=7918.25 时,预估费用仍必须用销售价。
*/
@Test
public void buildFillFactsAirPrefersSalePriceWhenTotalIsFeeSum() {
Map<String, Object> facts = new HashMap<>();
facts.put("运输方式", "空运");
facts.put("品名", "普货");
Map<String, Object> option = new HashMap<>();
option.put("optionNo", "AIR-OPT01");
option.put("salePrice", 7918.25);
option.put("currency", "CNY");
Map<String, Object> quote = new HashMap<>();
quote.put("total", "200");
quote.put("currency", "CNY");
quote.put("airOptions", java.util.Collections.singletonList(option));
quote.put("selected_air_option", option);
quote.put("fee_lines", java.util.Arrays.asList(
new HashMap<String, Object>() {{
put("name", "增点费");
put("amount", "100");
put("currency", "CNY");
}},
new HashMap<String, Object>() {{
put("name", "拆板费");
put("amount", "100");
put("currency", "USD");
}}
));
quote.put("chargeableWeightKg", "833.5");
JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl
.buildFillFacts("WO202609290026", facts, quote, "WuJiLin", "AIR");
assertEquals("7918.25", out.getString("total_amount"));
}
@Test
public void feeQuantityDefaultsToOneWhenBlank() {
assertEquals("1", QuoteTemplateExcelSupport.feeQuantityOrDefault(null, "提送货费"));
@@ -915,6 +1408,49 @@ public class QuoteTemplateFillTest {
}
}
/**
* 回归 WO202609220088:三段汇总写币别+金额;费用总计写在金额列并带币别,不得落在 B 列。
*/
@Test
public void seaSubtotalsAndTotalGoToFeeColumns() throws Exception {
Path seed = java.nio.file.Paths.get(
"d:/own/友通达询价智能体(新)/inquiry-agent/scripts/_verify_quote/sea_template_seed.xlsx");
org.junit.jupiter.api.Assumptions.assumeTrue(Files.exists(seed), "缺海运 seed 模板");
Path out = Files.createTempFile("quote-sea-subtotal-out", ".xlsx");
try {
JSONObject mapping = com.alibaba.fastjson.JSON.parseObject(
java.nio.file.Files.readString(
java.nio.file.Paths.get(
"d:/own/友通达询价智能体(新)/inquiry-agent/scripts/_last_sea_mapping.json")));
JSONObject facts = new JSONObject(true);
facts.put("currency", "CNY");
facts.put("total_amount", "14298.53");
facts.put("fee_total_polCostItems", "2335.00");
facts.put("fee_total_oceanCostItems", "8778.75");
facts.put("fee_total_podCostItems", "3184.78");
QuoteTemplateExcelSupport.fillWorkbook(seed, out, mapping, facts);
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = filled.getSheetAt(0);
org.apache.poi.ss.usermodel.DataFormatter fmt = new org.apache.poi.ss.usermodel.DataFormatter();
// 汇总行:B=币别 H=金额(0-based: 1 / 7)
assertEquals("CNY", fmt.formatCellValue(sheet.getRow(27).getCell(1)));
assertEquals("2335", fmt.formatCellValue(sheet.getRow(27).getCell(7)).replace(".0", "").replace(".00", ""));
assertTrue(fmt.formatCellValue(sheet.getRow(27).getCell(7)).contains("2335"));
assertTrue(fmt.formatCellValue(sheet.getRow(32).getCell(7)).contains("8778"));
assertEquals("CNY", fmt.formatCellValue(sheet.getRow(32).getCell(1)));
assertTrue(fmt.formatCellValue(sheet.getRow(42).getCell(7)).contains("3184"));
assertEquals("CNY", fmt.formatCellValue(sheet.getRow(42).getCell(1)));
// 费用总计:金额在 H,币别在 B;B 不得只是裸金额
String b44 = fmt.formatCellValue(sheet.getRow(43).getCell(1));
String h44 = fmt.formatCellValue(sheet.getRow(43).getCell(7));
assertEquals("CNY", b44);
assertTrue(h44.contains("14298"), "费用总计金额应在 H 列,实际 B=" + b44 + " H=" + h44);
}
} finally {
Files.deleteIfExists(out);
}
}
private static org.apache.poi.ss.usermodel.Row row(Sheet sheet, int r) {
org.apache.poi.ss.usermodel.Row exist = sheet.getRow(r);
return exist == null ? sheet.createRow(r) : exist;
@@ -48,28 +48,41 @@ public class InquiryTmsCabinServiceTest {
}
@Test
public void resolveOptionWithoutTmsUsesAirOpt01() {
public void resolveOptionWithoutTmsStaysBlank() {
Map<String, Object> option = InquiryTmsCabinService.resolveOption(new HashMap<>(), "");
assertEquals("AIR-OPT-01", option.get("optionNo"));
assertNull(option.get("optionNo"));
}
@Test
public void lockQuoteIdWithoutTmsUsesWorkOrderPlaceholder() {
assertEquals("QT-WO202609210003",
InquiryTmsCabinService.lockQuoteId("", "WO202609210003"));
public void manualQuoteKeepsCallerOptionText() {
Map<String, Object> option = InquiryTmsCabinService.resolveOption(new HashMap<>(), "AIR-OPT01");
assertEquals("AIR-OPT01", option.get("optionNo"));
}
@Test
public void lockQuoteIdKeepsRealAirId() {
assertEquals("AIR-2094721692181016578-2094721692654972929",
InquiryTmsCabinService.lockQuoteId(
"AIR-2094721692181016578-2094721692654972929",
"WO202609160015"));
public void airlineFromFlightUsesFirstTwoLetters() {
assertEquals("TH", InquiryTmsCabinService.airlineFromFlight("TH739"));
assertEquals("CZ", InquiryTmsCabinService.airlineFromFlight("cz327"));
assertEquals("", InquiryTmsCabinService.airlineFromFlight("739"));
assertEquals("", InquiryTmsCabinService.airlineFromFlight(""));
}
@Test
public void lockOptionNoWithoutTmsUsesAirOpt01() {
assertEquals("AIR-OPT-01", InquiryTmsCabinService.lockOptionNo(""));
public void confirmTmsIsNotManual() {
Map<String, Object> quote = new HashMap<>();
quote.put("source", "PRODUCT");
quote.put("source_label", "航线确认TMS");
org.junit.jupiter.api.Assertions.assertFalse(InquiryTmsCabinService.isManualQuote(quote));
}
@Test
public void salesAdjustAndProductAreManual() {
Map<String, Object> adjust = new HashMap<>();
adjust.put("source", "SALES_ADJUST");
Map<String, Object> edited = new HashMap<>();
edited.put("source_label", "航线在TMS上改");
org.junit.jupiter.api.Assertions.assertTrue(InquiryTmsCabinService.isManualQuote(adjust));
org.junit.jupiter.api.Assertions.assertTrue(InquiryTmsCabinService.isManualQuote(edited));
}
@Test
@@ -127,4 +140,125 @@ public class InquiryTmsCabinServiceTest {
String why = InquiryTmsCabinService.cabinFailReason(2006, new HashMap<>(), "2026-09-20", "2026-09-21");
assertEquals("TMS 业务码=2006", why);
}
@Test
public void validUntilDateOnlyBecomesIsoWithZone() {
assertEquals("2026-09-30T23:59:59+08:00",
InquiryTmsCabinService.normalizeValidUntilIso("2026-09-30"));
assertEquals("2026-09-30T23:59:59+08:00",
InquiryTmsCabinService.normalizeValidUntilIso("2026/09/30"));
}
@Test
public void validUntilAlreadyIsoKept() {
assertEquals("2026-09-30T12:00:00+08:00",
InquiryTmsCabinService.normalizeValidUntilIso("2026-09-30T12:00:00+08:00"));
assertEquals("2026-09-30T12:00:00+08:00",
InquiryTmsCabinService.normalizeValidUntilIso("2026-09-30T12:00:00"));
}
@Test
public void fillLockQuoteExtrasPutsIsoAndTmsFeeNames() {
Map<String, Object> quote = new HashMap<>();
quote.put("valid_until", "2026-09-30");
Map<String, Object> option = new HashMap<>();
Map<String, Object> charge = new HashMap<>();
charge.put("chargeName", "运输费");
charge.put("unitPrice", "11.00000");
charge.put("unit", "板数");
charge.put("currency", "CNY");
option.put("departureCharges", java.util.Arrays.asList(charge));
Map<String, Object> fee = new HashMap<>();
fee.put("feeName", "空运费");
fee.put("unitPrice", 18);
fee.put("currency", "CNY");
quote.put("feeItems", java.util.Arrays.asList(fee));
Map<String, Object> req = new HashMap<>();
InquiryTmsCabinService.fillLockQuoteExtras(req, quote, option);
assertEquals("2026-09-30T23:59:59+08:00", req.get("validUntil"));
@SuppressWarnings("unchecked")
java.util.List<Map<String, Object>> dep =
(java.util.List<Map<String, Object>>) req.get("departureCharges");
assertEquals("运输费", dep.get(0).get("chargeName"));
@SuppressWarnings("unchecked")
java.util.List<Map<String, Object>> fees =
(java.util.List<Map<String, Object>>) req.get("feeItems");
assertEquals("空运费", fees.get(0).get("feeName"));
}
@Test
public void airlineFromInstructionReadsCodeAfterOption() {
assertEquals("TH", InquiryTmsCabinService.airlineFromInstruction(
"WO202609290004锁舱,锁AIR-OPT01 TH@询价小助手"));
assertEquals("CZ", InquiryTmsCabinService.airlineFromInstruction("AIR-OPT-02 CZ"));
assertEquals("", InquiryTmsCabinService.airlineFromInstruction("WO202609290004锁舱"));
}
@Test
public void resolveOptionKeepsCallerOptionWhenNotInList() {
Map<String, Object> quote = new HashMap<>();
Map<String, Object> only = new HashMap<>();
only.put("optionNo", "AIR-OPT02");
quote.put("airOptions", java.util.Arrays.asList(only));
assertEquals("AIR-OPT01",
InquiryTmsCabinService.resolveOption(quote, "AIR-OPT01").get("optionNo"));
}
@Test
public void looksLikeOptionTokenFeesDetectsOptFragment() {
Map<String, Object> quote = new HashMap<>();
Map<String, Object> fee = new HashMap<>();
fee.put("name", "OPT");
fee.put("amount", "01");
quote.put("fee_lines", java.util.Arrays.asList(fee));
org.junit.jupiter.api.Assertions.assertTrue(InquiryTmsCabinService.looksLikeOptionTokenFees(quote));
}
@Test
public void enrichLockQuoteClearsOptFeesAndCopiesValidUntil() {
Map<String, Object> manual = new HashMap<>();
manual.put("source", "PRODUCT");
Map<String, Object> fee = new HashMap<>();
fee.put("name", "OPT");
fee.put("amount", "01");
manual.put("fee_lines", java.util.Arrays.asList(fee));
Map<String, Object> tms = new HashMap<>();
tms.put("valid_until", "2026-09-30");
tms.put("chargeableWeightKg", "66.68");
Map<String, Object> out = InquiryTmsCabinService.enrichLockQuoteFromTms(manual, tms);
assertEquals("2026-09-30", out.get("validUntil"));
org.junit.jupiter.api.Assertions.assertNull(out.get("fee_lines"));
assertEquals("66.68", out.get("chargeableWeightKg"));
}
@Test
public void plainFeeDecimalStripsTrailingZerosWithoutSci() {
assertEquals("100", InquiryTmsCabinService.plainFeeDecimal(new java.math.BigDecimal("100.00000")).toPlainString());
assertEquals("10.5", InquiryTmsCabinService.plainFeeDecimal(new java.math.BigDecimal("10.50")).toPlainString());
assertEquals("166.7", InquiryTmsCabinService.plainFeeDecimal(new java.math.BigDecimal("166.7000")).toPlainString());
}
@Test
public void lockModeTmsOnlyIsSystemWithQuoteId() {
// 口径1:仅 TMS → SYSTEM + quoteId(由 lockCabin 组装;此处用 isManual 判定)
Map<String, Object> tms = new HashMap<>();
tms.put("source", "TMS");
tms.put("source_label", "TMS 标准报价");
org.junit.jupiter.api.Assertions.assertFalse(InquiryTmsCabinService.isManualQuote(tms));
}
@Test
public void lockModeManualKeepsQuoteIdWhenFlagTrue() {
Map<String, Object> req = new HashMap<>();
req.put("quoteId", "AIR-1-2");
Map<String, Object> quote = new HashMap<>();
quote.put("valid_until", "2026-09-30");
InquiryTmsCabinService.fillManualLock(req, quote, new HashMap<>(), new HashMap<>(), true);
assertEquals("MANUAL", req.get("source"));
assertEquals("AIR-1-2", req.get("quoteId"));
InquiryTmsCabinService.fillManualLock(req, quote, new HashMap<>(), new HashMap<>(), false);
org.junit.jupiter.api.Assertions.assertNull(req.get("quoteId"));
}
}
@@ -2,6 +2,7 @@ package org.jeecg.modules.inquiry.tms;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.LinkedHashMap;
import java.util.Map;
@@ -53,15 +54,27 @@ public class TmsCabinRequestMapperTest {
}
@Test
public void lockKeepsNoQuotePlaceholders() {
Map<String, Object> filled = new LinkedHashMap<>();
filled.put("workOrderNo", "WO202609210003");
filled.put("quoteId", "QT-WO202609210003");
filled.put("optionNo", "AIR-OPT-01");
filled.put("flightDate", "2026-09-20");
Map<String, Object> sent = TmsCabinRequestMapper.lock(filled);
assertEquals("QT-WO202609210003", sent.get("quoteId"));
assertEquals("AIR-OPT-01", sent.get("optionNo"));
public void weightWithTrailingZerosStaysPlain() {
assertEquals("100.0", TmsCabinRequestMapper.plainDecimal(new BigDecimal("100.000")).toString());
assertEquals("2.0", TmsCabinRequestMapper.plainDecimal(new BigDecimal("2.0000")).toString());
}
@Test
public void manualLockOmitsEmptyFeesAndKeepsSource() {
Map<String, Object> internal = new LinkedHashMap<>();
internal.put("workOrderNo", "WO202609220001");
internal.put("source", "MANUAL");
internal.put("quoteId", "AIR-1");
internal.put("totalSalePrice", "14809.6");
internal.put("currency", "CNY");
internal.put("departureCharges", java.util.Collections.emptyList());
Map<String, Object> body = TmsCabinRequestMapper.lock(internal);
assertEquals("MANUAL", body.get("source"));
assertEquals("AIR-1", body.get("quoteId"));
assertEquals(new java.math.BigDecimal("14809.6"), body.get("totalSalePrice"));
assertEquals(null, body.get("departureCharges"));
assertEquals(null, body.get("feeItems"));
assertEquals(null, body.get("flightNo"));
}
@Test
@@ -76,4 +89,23 @@ public class TmsCabinRequestMapperTest {
assertEquals("2026-09-16", body.get("flightDate"));
assertEquals("AIRLINE", body.get("operatorRole"));
}
@Test
public void lockNormalizesValidUntilToIso() {
Map<String, Object> internal = new LinkedHashMap<>();
internal.put("workOrderNo", "WO202609290004");
internal.put("source", "SYSTEM");
internal.put("validUntil", "2026-09-30");
Map<String, Object> charge = new LinkedHashMap<>();
charge.put("chargeName", "运输费");
charge.put("unitPrice", new BigDecimal("11"));
internal.put("departureCharges", java.util.Collections.singletonList(charge));
Map<String, Object> body = TmsCabinRequestMapper.lock(internal);
assertEquals("2026-09-30T23:59:59+08:00", body.get("validUntil"));
assertEquals("SYSTEM", body.get("source"));
@SuppressWarnings("unchecked")
java.util.List<Map<String, Object>> dep =
(java.util.List<Map<String, Object>>) body.get("departureCharges");
assertEquals("运输费", dep.get(0).get("chargeName"));
}
}
@@ -222,4 +222,31 @@ public class TmsV2RequestMapperTest {
assertEquals("CRK", out.get("destinationCode"));
assertEquals("托盘", out.get("packageType"));
}
@Test
public void volumeDimensionProductBecomesCbm() {
assertEquals(new java.math.BigDecimal("3.96"),
TmsV2RequestMapper.asVolumeCbm("1.1*1.2*3"));
assertEquals(new java.math.BigDecimal("3.96"),
TmsV2RequestMapper.asVolumeCbm("1.1x1.2x3"));
assertEquals(new java.math.BigDecimal("3.96"),
TmsV2RequestMapper.asVolumeCbm("1.1×1.2×3"));
assertEquals(new java.math.BigDecimal("2"),
TmsV2RequestMapper.asVolumeCbm("2CBM"));
Map<String, Object> seg = new HashMap<>();
seg.put("originCode", "SZX");
seg.put("destinationCode", "KUL");
seg.put("volumeCbm", "1.1*1.2*3");
seg.put("weightKg", "5");
seg.put("quantity", "3");
seg.put("packageType", "托盘");
Map<String, Object> internal = new HashMap<>();
internal.put("transportMode", "AIR");
internal.put("quoteDate", "2026-10-08");
internal.put("segments", Collections.singletonList(seg));
Map<String, Object> body = TmsV2RequestMapper.quote(internal);
@SuppressWarnings("unchecked")
List<Map<String, Object>> segs = (List<Map<String, Object>>) body.get("segments");
assertEquals(new java.math.BigDecimal("3.96"), segs.get(0).get("volumeCbm"));
}
}
+3 -1
View File
@@ -30,6 +30,8 @@
<jeecgboot.version>3.5.3</jeecgboot.version>
<java.version>1.8</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!-- 默认跳过单测;本地排查时加 -DskipTests=false -->
<skipTests>true</skipTests>
<!-- 微服务 -->
<spring-cloud.version>2021.0.3</spring-cloud.version>
@@ -367,7 +369,7 @@
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<skipTests>true</skipTests>
<skipTests>${skipTests}</skipTests>
</configuration>
</plugin>
<!-- 避免font文件的二进制文件格式压缩破坏 -->
@@ -335,7 +335,6 @@ export const usePermissionStore = defineStore({
try {
this.changePermissionCode();
routeList = (await getMenuList()) as AppRouteRecordRaw[];
routeList = ensureInquiryHandwrittenRoutes(routeList);
// update-begin----author:sunjianlei---date:20220315------for: 判断是否是 vue3 版本的菜单 ---
let hasIndex: boolean = false;
let hasIcon: boolean = false;
@@ -373,7 +372,11 @@ export const usePermissionStore = defineStore({
// update-end----author:sunjianlei---date:20220315------for: 判断是否是 vue3 版本的菜单 ---
} catch (error) {
console.error(error);
// 菜单接口失败时仍补挂询价手写页,避免侧栏点进去全是 404
routeList = Array.isArray(routeList) ? routeList : [];
}
// 无论菜单接口成功与否,都按侧栏清单补齐手写路由
routeList = ensureInquiryHandwrittenRoutes(routeList || []);
// 组件地址前加斜杠处理 author: lsq date:2021-09-08
routeList = addSlashToRouteComponent(routeList);
// 动态引入组件
@@ -1262,6 +1262,43 @@ html {
font-size: 10px !important;
}
/**
* 报价模板预览截图:强制等比例(宽高同比例缩放),覆盖任何全局 img 拉满规则。
* 为何写在 fidelity:Teleport 弹窗挂 body,需非 scoped + !important 兜底。
*/
.qt-snapshot-wrap {
display: flex !important;
justify-content: center !important;
align-items: flex-start !important;
max-height: min(72vh, 760px) !important;
min-height: 0 !important;
overflow: auto !important;
box-sizing: border-box !important;
padding: 6px !important;
}
.qt-snapshot-img,
.modal-overlay .qt-snapshot-img,
.qt-preview-dialog .qt-snapshot-img {
display: block !important;
width: auto !important;
height: auto !important;
max-width: 100% !important;
max-height: min(70vh, 720px) !important;
object-fit: contain !important;
object-position: top center !important;
flex: 0 0 auto !important;
align-self: flex-start !important;
}
.qt-lightbox-img {
width: auto !important;
height: auto !important;
max-width: calc(100vw - 48px) !important;
max-height: calc(100vh - 72px) !important;
object-fit: contain !important;
}
.qt-upload-title,
.qt-file-name {
font-size: 11px !important;
@@ -770,6 +770,50 @@ textarea:focus-visible {
color: #0f172a;
}
/* 报价费用明细:列表紧凑一行;详情左右对齐名称/金额 */
.quote-fee-line {
font-size: 11px;
font-weight: 600;
color: #344054;
line-height: 1.45;
}
.quote-fee-list {
display: flex;
flex-direction: column;
gap: 4px;
margin: 6px 0 4px;
padding: 8px 10px;
border-radius: 8px;
background: #f8fafc;
border: 1px solid #eef2f6;
}
.ticket-detail-quote .quote-fee-line {
display: flex;
justify-content: space-between;
gap: 12px;
}
.ticket-detail-quote .quote-fee-name {
color: #475467;
font-weight: 600;
}
.ticket-detail-quote .quote-fee-amount {
color: #0f172a;
font-weight: 700;
white-space: nowrap;
}
.ticket-detail-quote {
margin-bottom: 10px;
}
.ticket-detail-quote:last-child {
margin-bottom: 0;
}
.attach-tags {
display: flex;
flex-wrap: wrap;
@@ -2249,6 +2293,47 @@ textarea:focus-visible {
color: #94a3b8;
}
/* 报价模板原表截图:等比例适配弹窗,禁止拉伸变形 */
.qt-snapshot-wrap {
display: flex;
justify-content: center;
align-items: flex-start;
max-height: min(72vh, 760px);
min-height: 0;
overflow: auto;
border: 1px solid #e2e8f0;
background: #f1f5f9;
border-radius: 8px;
text-align: center;
box-sizing: border-box;
padding: 6px;
}
.qt-snapshot-img {
display: block;
width: auto;
height: auto;
max-width: 100%;
max-height: min(70vh, 720px);
object-fit: contain;
object-position: top center;
flex: 0 0 auto;
align-self: flex-start;
background: #fff;
cursor: zoom-in;
}
.qt-lightbox-img {
display: block;
width: auto;
height: auto;
max-width: calc(100vw - 48px);
max-height: calc(100vh - 72px);
object-fit: contain;
background: #fff;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
}
/* 工单详情:对齐原型 admin-inquiry-tickets 分节网格 */
.ticket-detail-no {
display: inline-flex;
@@ -4,7 +4,7 @@
* 海运 / 空运 / 陆运同页自上而下分块展示,只允许改超时小时;无增删、无 Tab。
* 「编码」= timeoutCode,每条唯一(如 sea_sales / land_hk_express),供智能体单码调用;只读。
* roleCode(销售/产品)仅为角色维度,可重复,不是调用主键。
* 陆运产品运输分类仅 4 项(与线路解耦);不展示线路类别。
* 陆运产品运输分类 5 项(与线路解耦);不展示线路类别。
* 数据来自主账 inq_response_timeout;本页不做工单超时判定。
*/
import { computed, onMounted, ref } from 'vue';
@@ -70,6 +70,8 @@ const previewState = reactive({
isImage: true,
mappedCount: 0,
fileName: "",
/** 浏览器侧已裁过白边,防止 @load 重复裁切 */
trimmed: false,
});
/** 列表「解析状态」点击弹窗:已解析 / 未解析 */
@@ -125,16 +127,103 @@ const formSub = computed(() =>
: "上传 Excel 后 AI 异步解析映射;失败原因会显示在列表",
);
const formOkText = computed(() => (editing.value ? "保存" : "上传并解析"));
const previewFootHint = "按上传原表打印版式截图,比例 1:1 不变形;点击图片可放大。";
const previewFootHint = "按原表打印版式等比例截图(不拉高/压扁);点击图片可放大。";
const previewLightbox = ref(false);
const filtered = computed(() => rows.value);
function revokePreviewUrl() {
if (previewState.objectUrl) {
if (previewState.objectUrl && previewState.objectUrl.startsWith("blob:")) {
URL.revokeObjectURL(previewState.objectUrl);
previewState.objectUrl = "";
}
previewState.objectUrl = "";
previewState.trimmed = false;
}
/**
* 浏览器侧再裁一层近白边:主账若仍带 A4 页底空白,预览也能贴紧内容。
* 副作用:替换 objectUrl(旧 blob 释放);仅图片预览调用一次。
*/
function onSnapshotImgLoad(ev: Event) {
if (previewState.trimmed || !previewState.isImage) return;
const img = ev.target as HTMLImageElement | null;
if (!img?.naturalWidth || !img.naturalHeight) return;
try {
const next = cropNearWhiteObjectUrl(img);
if (!next) {
previewState.trimmed = true;
return;
}
const old = previewState.objectUrl;
previewState.trimmed = true;
previewState.objectUrl = next;
if (old && old.startsWith("blob:")) {
URL.revokeObjectURL(old);
}
} catch {
previewState.trimmed = true;
}
}
/**
* Canvas 裁近白边 → data URL。几乎无内容可裁时返回 null。
*/
function cropNearWhiteObjectUrl(img: HTMLImageElement): string | null {
const w = img.naturalWidth;
const h = img.naturalHeight;
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
if (!ctx) return null;
ctx.drawImage(img, 0, 0);
const data = ctx.getImageData(0, 0, w, h).data;
const isWhite = (i: number) => data[i] >= 240 && data[i + 1] >= 240 && data[i + 2] >= 240;
const rowMostlyWhite = (y: number) => {
const allow = Math.max(2, Math.floor(w / 200));
let dirty = 0;
const row = y * w * 4;
for (let x = 0; x < w; x++) {
if (!isWhite(row + x * 4)) {
dirty++;
if (dirty > allow) return false;
}
}
return true;
};
const colMostlyWhite = (x: number, y0: number, y1: number) => {
const allow = Math.max(2, Math.floor((y1 - y0 + 1) / 200));
let dirty = 0;
for (let y = y0; y <= y1; y++) {
if (!isWhite((y * w + x) * 4)) {
dirty++;
if (dirty > allow) return false;
}
}
return true;
};
let top = 0;
let bottom = h - 1;
let left = 0;
let right = w - 1;
while (top < h && rowMostlyWhite(top)) top++;
while (bottom > top && rowMostlyWhite(bottom)) bottom--;
while (left < w && colMostlyWhite(left, top, bottom)) left++;
while (right > left && colMostlyWhite(right, top, bottom)) right--;
if (top >= bottom || left >= right || bottom - top + 1 < h / 20) return null;
const pad = 8;
const x = Math.max(0, left - pad);
const y = Math.max(0, top - pad);
const cw = Math.min(w - 1, right + pad) - x + 1;
const ch = Math.min(h - 1, bottom + pad) - y + 1;
if (cw >= w && ch >= h) return null;
const out = document.createElement("canvas");
out.width = cw;
out.height = ch;
const octx = out.getContext("2d");
if (!octx) return null;
octx.drawImage(canvas, x, y, cw, ch, 0, 0, cw, ch);
return out.toDataURL("image/png");
}
/**
@@ -149,6 +238,7 @@ async function openPreview(row: TemplateRow) {
previewState.loading = true;
previewState.error = "";
previewState.isImage = true;
previewState.trimmed = false;
previewState.mappedCount = row.mappingPointCount || 0;
previewState.fileName = row.fileName || "";
if (!row.versionId) {
@@ -919,6 +1009,7 @@ async function downloadPreviewOrOriginal() {
:src="previewState.objectUrl"
alt="原表截图预览"
title="点击放大"
@load="onSnapshotImgLoad"
@click="openLightbox"
/>
<iframe
@@ -1248,19 +1339,35 @@ async function downloadPreviewOrOriginal() {
.qt-paper-loading--err {
color: #b42318;
}
/**
* 原表截图:等比例缩放;容器贴合图片,不人为撑出大块空白。
* 弹窗 Teleport 到 body,关键规则在 admin-fidelity 也有 !important 兜底。
*/
.qt-snapshot-wrap {
max-height: min(70vh, 720px);
display: flex;
justify-content: center;
align-items: flex-start;
max-height: min(72vh, 760px);
overflow: auto;
border: 1px solid #e2e8f0;
background: #f1f5f9;
border-radius: 8px;
text-align: center;
box-sizing: border-box;
padding: 6px;
/* 不设 min-height,避免图矮时底下留灰白空带 */
min-height: 0;
}
.qt-snapshot-img {
display: inline-block;
width: auto;
height: auto;
max-width: none;
display: block;
width: auto !important;
height: auto !important;
max-width: 100% !important;
max-height: min(70vh, 720px) !important;
object-fit: contain !important;
object-position: top center;
flex: 0 0 auto;
align-self: flex-start;
vertical-align: top;
background: #fff;
cursor: zoom-in;
@@ -17,7 +17,16 @@ import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
import AdminPagination from "/@/views/inquiry/components/AdminPagination.vue";
type TicketQuote = { source: string; price: string; time?: string; highlight?: boolean };
/** 单条费用明细:名称 + 带币种金额,对齐企微价格明细。 */
type TicketQuoteFee = { name: string; amount: string };
type TicketQuote = {
source: string;
price: string;
time?: string;
highlight?: boolean;
/** TMS/手工报价费用明细行;无明细时为空数组 */
fees: TicketQuoteFee[];
};
type TicketAttach = { name: string; uploadedAt: string; uploader: string };
type TicketTimeline = { status: string; remark: string; time: string };
@@ -108,6 +117,110 @@ function quoteSourceLabel(q: any) {
return label && label !== "报价" ? "手工报价" : "报价";
}
/** JSON 字符串或对象都解开;解不开当没有。 */
function asObject(raw: any): Record<string, any> | null {
if (raw == null) return null;
if (typeof raw === "object" && !Array.isArray(raw)) return raw as Record<string, any>;
if (typeof raw === "string" && raw.trim()) {
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, any>;
}
} catch {
return null;
}
}
return null;
}
/** 费用数组:List / JSON 字符串 / 二次编码都认。 */
function asFeeArray(raw: any): any[] {
if (raw == null) return [];
if (Array.isArray(raw)) return raw;
if (typeof raw === "string" && raw.trim()) {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return parsed;
if (typeof parsed === "string") {
const again = JSON.parse(parsed);
return Array.isArray(again) ? again : [];
}
} catch {
return [];
}
}
return [];
}
/**
* 把一条费用行收成「名称 + 金额」。
* TMS 字段:name/chargeName、amount/unitPrice、currency。
*/
function feeRowToDisplay(item: any): TicketQuoteFee | null {
if (!item || typeof item !== "object") return null;
const name = String(
item.name || item.chargeName || item.feeName || item.label || ""
).trim();
if (!name) return null;
let amount = String(item.amount ?? item.totalPrice ?? item.value ?? "").trim();
const unitPrice = String(item.unitPrice ?? item.unit_price ?? "").trim();
if (!amount || amount === "-") {
amount = unitPrice;
}
if (!amount || amount === "-") return null;
const ccy = String(item.currency || "").trim().toUpperCase();
const amountUpper = amount.toUpperCase();
if (ccy && !amountUpper.startsWith(ccy) && !/^[A-Z]{3}\s/.test(amountUpper)) {
amount = `${ccy} ${amount}`;
}
return { name, amount };
}
/**
* 从报价 payload 抽费用明细,供后台「价格与利润」展示。
* 优先 fee_lines / fee_rows;空运再看 departureCharges;海运再看三段桶。
* 副作用:无。不写库。
*/
function parseQuoteFees(q: any): TicketQuoteFee[] {
const payload = asObject(q?.payloadJson) || asObject(q?.payload) || {};
const out: TicketQuoteFee[] = [];
const pushRows = (rows: any[]) => {
for (const row of rows) {
const one = feeRowToDisplay(row);
if (one) out.push(one);
}
};
pushRows(asFeeArray(payload.fee_lines ?? payload.fee_rows ?? q?.fee_lines ?? q?.fee_rows));
if (!out.length) {
const selected =
asObject(payload.selected_air_option) || asObject(payload.selectedAirOption);
if (selected) {
pushRows(
asFeeArray(
selected.departureCharges || selected.departure_charges || selected.feeItems
)
);
}
}
if (!out.length) {
const opts = asFeeArray(payload.airOptions || payload.air_options);
const first = opts.find((x) => x && typeof x === "object") || null;
if (first) {
pushRows(
asFeeArray(first.departureCharges || first.departure_charges || first.feeItems)
);
}
}
if (!out.length) {
for (const bucket of ["polCostItems", "oceanCostItems", "podCostItems"] as const) {
const key = `fee_rows_${bucket}`;
pushRows(asFeeArray(payload[key] || payload[bucket]));
}
}
return out;
}
/**
* 主账 inq_ticket_status_log.action 是内部码,禁止当备注展示。
* transition 与「已报价」等 toStatus 重复,无用户备注时不再跟一段英文。
@@ -260,6 +373,7 @@ function mapTicket(t: any, quotes: any[] = [], attachments: any[] = [], timeline
price: q.priceText || "-",
time: fmtTime(q.createTime),
highlight: q.highlighted === 1 || q.highlighted === true,
fees: parseQuoteFees(q),
})),
attachments: attachRows.map((a) => a.name),
attachRows,
@@ -556,6 +670,13 @@ function refresh() {
style="margin-bottom: 8px"
>
<div class="quote-source">{{ q.source }}</div>
<div
v-for="(fee, fi) in q.fees"
:key="q.source + '-fee-' + fi"
class="quote-fee-line"
>
{{ fee.name }}:{{ fee.amount }}
</div>
<div class="quote-price" :style="q.highlight ? { color: '#1d4ed8' } : undefined">
{{ q.price }}
</div>
@@ -688,9 +809,30 @@ function refresh() {
<div class="ticket-detail-item"><label>利润点</label><span>{{ current.profit }}</span></div>
</div>
<div v-if="current.quotes.length" class="ticket-detail-demand">
<div v-for="q in current.quotes" :key="q.source + q.price + (q.time || '')" class="quote-row" style="margin-bottom: 6px">
<div class="quote-source">{{ q.source }}<span v-if="q.time && q.time !== '-'"> · {{ q.time }}</span></div>
<div class="quote-price" :style="q.highlight ? { color: '#1d4ed8' } : undefined">{{ q.price }}</div>
<div
v-for="q in current.quotes"
:key="q.source + q.price + (q.time || '')"
class="quote-row ticket-detail-quote"
>
<div class="quote-source">
{{ q.source }}<span v-if="q.time && q.time !== '-'"> · {{ q.time }}</span>
</div>
<div v-if="q.fees.length" class="quote-fee-list">
<div
v-for="(fee, fi) in q.fees"
:key="q.source + '-detail-fee-' + fi"
class="quote-fee-line"
>
<span class="quote-fee-name">{{ fee.name }}</span>
<span class="quote-fee-amount">{{ fee.amount }}</span>
</div>
</div>
<div
class="quote-price"
:style="q.highlight ? { color: '#1d4ed8' } : undefined"
>
合计 {{ q.price }}
</div>
</div>
</div>
</section>
@@ -169,7 +169,8 @@ async function checkPhone(rule, value, callback) {
* 判断是否是OAuth2APP环境
*/
export function isOAuth2AppEnv() {
return /wxwork|dingtalk/i.test(navigator.userAgent);
// 后台只用账号密码。企微里打开卡片也不能走企业微信授权,否则会跳去授权地址并不断刷新。
return false;
}
/**