Compare commits

...
51 changed files with 4748 additions and 188 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}",
)
+2 -1
View File
@@ -823,9 +823,10 @@ 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)
+137 -3
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())
@@ -705,6 +838,7 @@ 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")),
+7 -6
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:
+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"])
+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"
+57 -5
View File
@@ -1,7 +1,7 @@
"""
海运运输分类:只认 Port to Port、其他。
本文件职责:从原话收成标准词、生成补问清单。
本文件职责:从原话收成标准词、生成补问清单;贸易条款标签行原样优先。
禁止:发明第三项;把分类带去 TMS;改六态。
线程:纯函数,可在 Worker 槽调用。
"""
@@ -15,6 +15,15 @@ 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"),
@@ -58,16 +67,39 @@ def _without_clarify_examples(text: str) -> str:
)
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 副本。
举例行「(如:Port to Port、其他)」先丢掉,避免示例里的 Port to Port 盖掉冒号后的「其它」。
优先「运输分类:」标签行(WO202609280012:条款写 Port to Port、分类写其它)。
整句扫描时排除贸易条款行,避免条款里的 Port to Port 盖掉「其它」。
「其它」与「其他」都收成「其他」。非法旧值会丢掉,避免「门到门」一直占着必填位。
"""
merged = dict(current or {})
found = canonicalize_sea_class(_without_clarify_examples(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
@@ -98,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]:
"""
丢掉冒充贸易条款的「门到门」。
@@ -105,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={
@@ -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
+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",
+17 -5
View File
@@ -60,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);
@@ -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 无界面转换;已在文件锁内,禁止再开并行实例。
*/
@@ -28,7 +28,9 @@ import java.util.Map;
* 锁舱按 /air/cabin/lock:用工单起运地/目的地/报价日期/重量/体积拼正文。
* 没有报价不能锁舱,禁止编造 QT-{工单号}、AIR-OPT-01。
* 只看最新一版:库里 source=TMS(或航线确认 TMS)出站写成 source=SYSTEM;人工报价出站 source=MANUAL。
* 有真实 TMS 编号才带 quoteId。有值才出站,缺航班和费用不拦。
* 有真实 TMS 编号才带 quoteId。有值才出站,缺航班不拦。
* SYSTEM 出站精简(quoteId + 航班等标量,不带 validUntil/feeItems);
* MANUAL 费用名按 TMS 查价回包原样带回,有效期必须带时区 ISO8601。
* 已成交再释放:本地拦住。测服打开 TMS_V2_LOCK_ENABLED;现网验收期保持关闭。
* 禁止:按 userid 伪造报价;单测打真实 TMS。
*/
@@ -71,37 +73,64 @@ public class InquiryTmsCabinService {
return fail("no_quote", "这张单还没有报价,不能锁舱");
}
Map<String, Object> quote = payloadOf(rows.get(0));
boolean manual = isManualQuote(quote);
if (!manual && StringUtils.isBlank(resolveQuoteId(quote, no))) {
// 「AIR-OPT01 TH」被误落成航线报价不算真正人工价 → 仍走仅 TMS(SYSTEM)。
boolean latestManual = isManualQuote(quote) && !isFakeManualLockQuote(quote);
if (!latestManual && StringUtils.isBlank(resolveQuoteId(quote, no))) {
quote = refreshLockQuote(ticket, quote);
}
String quoteId = manual ? realTmsQuoteId(rows, no) : resolveQuoteId(quote, no);
if (!manual && StringUtils.isBlank(quoteId)) {
quoteId = realTmsQuoteId(rows, no);
String tmsQuoteId = realTmsQuoteId(rows, no);
if (!latestManual && StringUtils.isBlank(tmsQuoteId)) {
tmsQuoteId = resolveQuoteId(quote, no);
}
if (!manual && StringUtils.isBlank(quoteId)) {
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<>();
}
if (manual && StringUtils.isBlank(text(option, "optionNo", "option_no"))) {
// 手工价往往只有金额。舱位、航班仍用本单那条 TMS 报价,不另编编号。
Map<String, Object> tmsOption = tmsOptionForLock(rows, no, optionNo);
if (!tmsOption.isEmpty()) {
option = tmsOption;
}
// 手工价常只有金额/错误抽到的 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);
}
String resolvedOption = text(option, "optionNo", "option_no");
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", firstNonBlank(text(option, "flightDate"), quoteDate(ticket)));
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(),
@@ -110,7 +139,11 @@ public class InquiryTmsCabinService {
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, "航司", "航司代码"),
@@ -119,19 +152,39 @@ public class InquiryTmsCabinService {
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 (StringUtils.isNotBlank(text(body, "requestedUntil"))) {
req.put("requestedUntil", text(body, "requestedUntil"));
}
if (manual) {
fillManualLock(req, quote, option);
if ("MANUAL".equals(lockSource)) {
// 有 TMS 时保留 quoteId;纯人工才去掉。
fillManualLock(req, quote, option, body, StringUtils.isNotBlank(quoteId));
} else {
// 库里仍是 source=TMS。TMS 锁舱要求纯系统报价带 source=SYSTEM,只写在出站正文。
// 仅 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);
}
@@ -342,15 +395,22 @@ public class InquiryTmsCabinService {
}
/**
* 人工报价出站:source 只写在 TMS 正文,不回写报价表。
* 费用数组用报价里已有的;没有就不放。不用总价编一条空运费。
* 人工报价出站:对齐 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) {
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");
String until = firstNonBlank(text(quote, "validUntil", "valid_until"), text(option, "validUntil"));
if (StringUtils.isNotBlank(until) && !"-".equals(until)) {
req.put("validUntil", until);
if (!withQuoteId) {
req.remove("quoteId");
}
fillLockQuoteExtras(req, quote, option);
String transfer = firstNonBlank(text(option, "transferAirport", "transferAirportCode"),
text(quote, "transferAirport", "transferAirportCode"));
if (StringUtils.isNotBlank(transfer)) {
@@ -360,14 +420,46 @@ public class InquiryTmsCabinService {
if (StringUtils.isNotBlank(flightNo)) {
req.put("flightNo", flightNo);
}
String currency = firstNonBlank(text(quote, "currency", "currencyCode"), text(option, "currency"));
String currency = firstNonBlank(text(option, "currency"),
text(quote, "currency", "currencyCode"));
if (StringUtils.isNotBlank(currency)) {
req.put("currency", currency);
}
String total = firstNonBlank(text(quote, "totalSalePrice", "salePrice", "total"), text(option, "salePrice"));
// 舱位售价优先;避免手工误抽的 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);
@@ -379,6 +471,57 @@ public class InquiryTmsCabinService {
}
}
/**
* 锁舱 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。
@@ -400,10 +543,26 @@ public class InquiryTmsCabinService {
}
/**
* 本单更早报价里的真实 TMS 编号。自编 QT-工单号不算。
* 误把锁舱口令落成「航线报价」: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) {
@@ -423,7 +582,7 @@ public class InquiryTmsCabinService {
}
if (StringUtils.isNotBlank(optionNo)) {
for (Map<String, Object> item : options) {
if (optionNo.equals(text(item, "optionNo", "option_no"))) {
if (optionNo.equalsIgnoreCase(text(item, "optionNo", "option_no"))) {
return item;
}
}
@@ -437,6 +596,101 @@ public class InquiryTmsCabinService {
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 "";
@@ -546,8 +800,10 @@ public class InquiryTmsCabinService {
}
/**
* 费用行出站改成 TMS 字段:feeName/chargeName、unitPrice、数量和金额用数字。
* 库里的 name、unit_price 不原样送出,避免 TMS 报参数格式错误。
* 费用行出站:对齐 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<>();
@@ -559,7 +815,9 @@ public class InquiryTmsCabinService {
continue;
}
Map<String, Object> item = new LinkedHashMap<>();
String name = feeName(row);
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);
}
@@ -568,8 +826,19 @@ public class InquiryTmsCabinService {
item.put("feeCode", code);
}
putFeeNumber(item, "unitPrice", text(row, "unitPrice", "unit_price"));
putFeeNumber(item, "quantity", text(row, "quantity"));
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);
@@ -578,6 +847,34 @@ public class InquiryTmsCabinService {
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);
}
@@ -585,6 +882,18 @@ public class InquiryTmsCabinService {
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;
@@ -595,7 +904,7 @@ public class InquiryTmsCabinService {
text = text.substring(space + 1).trim();
}
try {
item.put(key, new java.math.BigDecimal(text));
item.put(key, plainFeeDecimal(new java.math.BigDecimal(text)));
} catch (NumberFormatException ignore) {
// 不是数字就不送,避免把「99.00元」这类原文塞进 TMS 数字栏。
}
@@ -630,8 +939,69 @@ public class InquiryTmsCabinService {
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 "";
}
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;
}
if (withFlight == 1) {
return lone;
}
return "";
}
/**
* 从 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);
@@ -643,6 +1013,11 @@ public class InquiryTmsCabinService {
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<>();
}
if (StringUtils.isBlank(optionNo)) {
@@ -653,6 +1028,22 @@ public class InquiryTmsCabinService {
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);
@@ -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 盖住「托盘」。
@@ -13,9 +13,11 @@ import java.util.UUID;
* <p>
* 锁舱唯一键:workOrderNo + quoteId + optionNo。flightDate 用工单报价日期,接口没有单独 quoteDate。
* 操作人角色固定 AIRLINE。数字字段保持 Number,禁止带 KGS/CBM。
* 只有 TMS 报价:沿用原字段,不带 source、不带费用数组。
* 人工报价:调用方放入 source=MANUAL 和已有的费用;空字符串、空数组不写出。
* 不在这里编造 quoteId / optionNo。
* 系统报价:source=SYSTEM + quoteId。
* 人工+TMS:source=MANUAL + 原 quoteId。
* 纯人工:source=MANUAL,不传 quoteId;validUntil/requestedUntil 用 ISO8601;
* 必带 transferAirport/flightNo/departureCharges/feeItems(有值才写)。
* 不在这里编造 optionNo。
*/
public final class TmsCabinRequestMapper {
@@ -23,34 +25,39 @@ 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, "source", internal.get("source"));
put(body, "validUntil", internal.get("validUntil"));
put(body, "transferAirport", internal.get("transferAirport"));
put(body, "flightNo", internal.get("flightNo"));
put(body, "currency", internal.get("currency"));
putNumber(body, "totalSalePrice", asDecimal(internal.get("totalSalePrice")));
putList(body, "departureCharges", internal.get("departureCharges"));
@@ -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;
@@ -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;
@@ -140,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"));
}
}
@@ -89,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;
@@ -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>