Compare commits

...
62 changed files with 5324 additions and 234 deletions
@@ -269,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 ""),
)
@@ -308,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 = {
@@ -371,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}",
}
+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),
@@ -36,6 +36,7 @@ _SALES_CODES = {
}
_LAND_PRODUCT_CODES = {
"普件(常用线路/常规车型)": "land_common",
"普件(东南亚、中港常用线路/常规车型)": "land_hk_common",
"普件(东南亚、中亚、中欧涉及国内+国外段)": "land_hk_cross",
"普件(东南亚、中亚、中欧涉及境外段清派等特殊要求)": "land_hk_special",
+14 -8
View File
@@ -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:
@@ -2811,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:
@@ -2857,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
+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}
+91 -7
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
+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,10 @@ 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:
+18 -1
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
@@ -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:
@@ -28,6 +28,7 @@ ROUTES = (
"东南亚",
)
LAND_CLASSES = (
"普件(常用线路/常规车型)",
"普件(东南亚、中港常用线路/常规车型)",
"普件(东南亚、中亚、中欧涉及国内+国外段)",
"普件(东南亚、中亚、中欧涉及境外段清派等特殊要求)",
+88 -14
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(
@@ -531,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:
@@ -558,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]
@@ -571,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()
@@ -585,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]:
@@ -763,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,
@@ -782,6 +846,8 @@ def apply_adjust(
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)}
@@ -798,6 +864,7 @@ def apply_adjust(
currency_hint = bool(multi)
total_amt, _ = parse_money(src.get("total"))
new_total = total_amt
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,
@@ -845,8 +912,10 @@ def apply_adjust(
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:
@@ -861,10 +930,15 @@ def apply_adjust(
if (not multi) and new_total is not None:
if "__total__" in subtotal_only:
# 合计是整单改数;小计差额不再叠进合计,避免「合计×1.1 + 小计差额」重复
new_total = subtotal_only.pop("__total__")
for bucket, delta in subtotal_only.items():
if bucket not in touched_buckets:
new_total = new_total + delta
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)
visible = []
@@ -876,7 +950,7 @@ def apply_adjust(
out["fee_rows"] = visible
out["fee_lines"] = visible
_write_adjusted_buckets(out, rows)
if applied and (not multi) and new_total is not None and new_total != total_amt:
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={
+90 -5
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
@@ -695,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(
@@ -816,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(
@@ -1233,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,
@@ -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=""),
"",
+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("运输类型"), "国内运输拼车")
+42
View File
@@ -17,6 +17,7 @@ from agent.policy import inquiry_copy as copy
from agent.schema.quote_adjust import (
AdjustExtract,
AdjustItem,
adjustable_rows,
apply_adjust,
effective_quote,
extract_adjust,
@@ -196,6 +197,47 @@ class QuoteAdjustTests(unittest.TestCase):
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",
+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__":
+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>