diff --git a/inquiry-agent/agent/handlers/air_group_collab.py b/inquiry-agent/agent/handlers/air_group_collab.py index bfabac3..0487e0d 100644 --- a/inquiry-agent/agent/handlers/air_group_collab.py +++ b/inquiry-agent/agent/handlers/air_group_collab.py @@ -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 "" diff --git a/inquiry-agent/agent/ledger/http_ledger.py b/inquiry-agent/agent/ledger/http_ledger.py index cedc20e..4cfb155 100644 --- a/inquiry-agent/agent/ledger/http_ledger.py +++ b/inquiry-agent/agent/ledger/http_ledger.py @@ -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}", ) diff --git a/inquiry-agent/agent/ledger/memory_ledger.py b/inquiry-agent/agent/ledger/memory_ledger.py index 27d9bad..ace1b1f 100644 --- a/inquiry-agent/agent/ledger/memory_ledger.py +++ b/inquiry-agent/agent/ledger/memory_ledger.py @@ -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) diff --git a/inquiry-agent/agent/policy/air_group_ops.py b/inquiry-agent/agent/policy/air_group_ops.py index b15652a..cd0fff6 100644 --- a/inquiry-agent/agent/policy/air_group_ops.py +++ b/inquiry-agent/agent/policy/air_group_ops.py @@ -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")), diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py index 1de492b..b9aa9cb 100644 --- a/inquiry-agent/agent/policy/inquiry_copy.py +++ b/inquiry-agent/agent/policy/inquiry_copy.py @@ -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: diff --git a/inquiry-agent/agent/policy/quote_overlay.py b/inquiry-agent/agent/policy/quote_overlay.py index 3e039b6..5477a13 100644 --- a/inquiry-agent/agent/policy/quote_overlay.py +++ b/inquiry-agent/agent/policy/quote_overlay.py @@ -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("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + ) + + +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'{_xml_escape(val)}' + ) + pat = re.compile(rf']*\br="{re.escape(ref)}"[^>]*(?:/>|>[\s\S]*?)') + if pat.search(sheet): + sheet = pat.sub(cell_xml, sheet, count=1) + continue + row_pat = re.compile(rf'(]*\br="{int(row)}"[^>]*>)([\s\S]*?)()') + m = row_pat.search(sheet) + if m: + sheet = sheet[: m.start(3)] + cell_xml + sheet[m.start(3) :] + continue + sheet = sheet.replace( + "", + f'{cell_xml}', + 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} diff --git a/inquiry-agent/agent/policy/sea_group_ops.py b/inquiry-agent/agent/policy/sea_group_ops.py index 3f79680..75a8754 100644 --- a/inquiry-agent/agent/policy/sea_group_ops.py +++ b/inquiry-agent/agent/policy/sea_group_ops.py @@ -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 diff --git a/inquiry-agent/agent/policy/sea_text_flow.py b/inquiry-agent/agent/policy/sea_text_flow.py index 8767fca..20f3a37 100644 --- a/inquiry-agent/agent/policy/sea_text_flow.py +++ b/inquiry-agent/agent/policy/sea_text_flow.py @@ -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"]) diff --git a/inquiry-agent/agent/schema/quote_adjust.py b/inquiry-agent/agent/schema/quote_adjust.py index ee84b77..557d4dd 100644 --- a/inquiry-agent/agent/schema/quote_adjust.py +++ b/inquiry-agent/agent/schema/quote_adjust.py @@ -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" diff --git a/inquiry-agent/agent/schema/sea_options.py b/inquiry-agent/agent/schema/sea_options.py index 7c9a9bf..b2a7a38 100644 --- a/inquiry-agent/agent/schema/sea_options.py +++ b/inquiry-agent/agent/schema/sea_options.py @@ -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: diff --git a/inquiry-agent/agent/schema/tms_air_query.py b/inquiry-agent/agent/schema/tms_air_query.py index 9771832..22a75af 100644 --- a/inquiry-agent/agent/schema/tms_air_query.py +++ b/inquiry-agent/agent/schema/tms_air_query.py @@ -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() diff --git a/inquiry-agent/tests/test_air_field_contract.py b/inquiry-agent/tests/test_air_field_contract.py index 0d8d61a..f63a042 100644 --- a/inquiry-agent/tests/test_air_field_contract.py +++ b/inquiry-agent/tests/test_air_field_contract.py @@ -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={ diff --git a/inquiry-agent/tests/test_air_group_collab.py b/inquiry-agent/tests/test_air_group_collab.py index 11813a5..e6a3b33 100644 --- a/inquiry-agent/tests/test_air_group_collab.py +++ b/inquiry-agent/tests/test_air_group_collab.py @@ -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 diff --git a/inquiry-agent/tests/test_quote_adjust.py b/inquiry-agent/tests/test_quote_adjust.py index 6472eed..c074638 100644 --- a/inquiry-agent/tests/test_quote_adjust.py +++ b/inquiry-agent/tests/test_quote_adjust.py @@ -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", diff --git a/inquiry-agent/tests/test_sea_options.py b/inquiry-agent/tests/test_sea_options.py index 22acff5..41fb283 100644 --- a/inquiry-agent/tests/test_sea_options.py +++ b/inquiry-agent/tests/test_sea_options.py @@ -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__": diff --git a/inquiry-api/jeecg-module-inquiry/pom.xml b/inquiry-api/jeecg-module-inquiry/pom.xml index 3107498..511b2eb 100644 --- a/inquiry-api/jeecg-module-inquiry/pom.xml +++ b/inquiry-api/jeecg-module-inquiry/pom.xml @@ -34,6 +34,12 @@ poi-ooxml 4.1.2 + + + org.apache.pdfbox + pdfbox + 2.0.29 + org.springframework.boot spring-boot-starter-test diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java index 948bfd6..bfff547 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java @@ -643,11 +643,15 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl 0) { return cachedPng; } @@ -660,8 +664,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl 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 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 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); diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryWorkOrderServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryWorkOrderServiceImpl.java index 0836b26..c6339bf 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryWorkOrderServiceImpl.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryWorkOrderServiceImpl.java @@ -503,7 +503,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl 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() .eq(InquiryTicket::getBusinessLine, req.getBusinessLine().trim().toUpperCase()) @@ -677,15 +678,24 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl YELLOW_RGB = new HashSet<>(Arrays.asList( "FFFFFF00", "FFFF00", "FFFFEB9C", "FFFFC000", "FFFFFF99", "FFFFCC00", "FFFFD966", "FFFFF2CC", "FFFFE699" @@ -553,7 +589,24 @@ public final class QuoteTemplateExcelSupport { */ public static void fillWorkbook(Path sourceXlsx, Path targetXlsx, JSONObject mapping, JSONObject facts) throws Exception { Set yellow = collectFixedCells(mapping); + // 仅海运询价单才保留/盖回 Form Control 与 WO011 sheet1 骨架。 + // 空运/陆运误走骨架会把「国际海运询价单」盖到空运出单上(WO038/039 混表)。 + boolean seaTemplate = sourceLooksLikeSeaTemplate(sourceXlsx); + // POI 重写会瘦身/弄坏海运 drawing(WO017:8KB→4KB,WPS 顶部单选标签空白)。 + // 先从模板抠出 drawing/vml,出单后再盖回去并只改勾选。 + Map seaDrawParts = seaTemplate ? readSeaDrawingParts(sourceXlsx) : null; try (InputStream in = Files.newInputStream(sourceXlsx); Workbook wb = WorkbookFactory.create(in)) { + if (!seaTemplate) { + for (int i = 0; i < wb.getNumberOfSheets(); i++) { + if (sheetLooksLikeSeaQuote(wb.getSheetAt(i))) { + seaTemplate = true; + break; + } + } + if (seaTemplate && seaDrawParts == null) { + seaDrawParts = readSeaDrawingParts(sourceXlsx); + } + } JSONObject fieldMeta = mapping.getJSONObject("fieldMeta"); JSONObject fields = mapping.getJSONObject("fields"); if (fields != null) { @@ -591,6 +644,29 @@ public final class QuoteTemplateExcelSupport { if (afterColon) { writeCellAfterColon(wb, cellRef, String.valueOf(val)); } else { + // #region agent log + if ("pol".equals(code) || "起运港".equals(code) + || String.valueOf(val).contains("广州") || String.valueOf(val).contains("Guangzhou")) { + agentDbg("H2", "fillWorkbook.fields", "mapping field write", + "{\"code\":\"" + code + "\",\"cellRef\":\"" + cellRef + + "\",\"val\":\"" + String.valueOf(val).replace("\"", "'") + "\"}"); + } + // #endregion + // 映射若误指费用名称列:跳过,避免无 TMS 时把起运港写入费用名称 + if (isPortFactCode(code)) { + ParsedRef pref = ParsedRef.parse(cellRef); + Sheet sh = pref == null ? null : wb.getSheet(pref.sheet); + if (sh == null && pref != null && wb.getNumberOfSheets() > 0) { + sh = wb.getSheetAt(0); + } + if (sh != null && isFeeNameColumnRef(sh, cellRef)) { + // #region agent log + agentDbg("H2", "fillWorkbook.fields.blockFeeName", "blocked mapping pol into fee name", + "{\"code\":\"" + code + "\",\"cellRef\":\"" + cellRef + "\"}"); + // #endregion + continue; + } + } writeCell(wb, cellRef, String.valueOf(val)); } } @@ -604,13 +680,34 @@ public final class QuoteTemplateExcelSupport { if (!hasFeeRegions(mapping)) { writeFeeAmountsByName(wb, facts, yellow); } - // 模板「预估总价」是 SUM。POI 写完数字不会重算,缓存仍是 0。 - // 企微/WPS 预览常不重算,销售会看到汇总为 0。这里把公式缓存刷成当前值。 + // 模板合计格常是 SUM。先刷缓存,再按 TMS 汇总盖掉公式(有 TMS 汇总就必须去公式)。 refreshFormulas(wb); // 其它标签可再写一遍;合计单独处理,避免「标签旁一个数 + SUM 格另一个数」。 writeFactsByLabel(wb, facts, yellow); - // 预估费用以 TMS 总销售价为准:盖掉 SUM(常把提送货/人工费加进去),本行只留一个数。 + // 海运运输分类: + // WPS 证据(WO030 vs WO029/031/032):VML 必须与 WO011 字节级一致才画得出原控件; + // 一改 Checked/文案整组空白。因此 zip 阶段只盖回 WO011 原件,不改 VML。 + // WO033 vs WO030:VML/drawing/styles 相同,唯 B3 有「其它…」明文时 WPS 仍空白—— + // B3:J3 是原控件锚点合并区,禁止往里写任何单元格字。 + clearSyntheticSeaTransportClassCell(wb); + writeSeaOtherClassFallbackLabel(wb, facts); + // #region agent log + agentDbg("T1", "fillWorkbook.facts", "sea subtotal facts present", + "{\"fee_total_pol\":\"" + String.valueOf(facts.getString("fee_total_polCostItems")).replace("\"", "'") + + "\",\"fee_total_ocean\":\"" + String.valueOf(facts.getString("fee_total_oceanCostItems")).replace("\"", "'") + + "\",\"fee_total_pod\":\"" + String.valueOf(facts.getString("fee_total_podCostItems")).replace("\"", "'") + + "\",\"total_amount\":\"" + String.valueOf(facts.getString("total_amount")).replace("\"", "'") + + "\",\"currency\":\"" + String.valueOf(facts.getString("currency")).replace("\"", "'") + + "\",\"sea_class\":\"" + String.valueOf(firstNonBlank( + facts.getString("运输分类"), facts.getString("transport_class"), + facts.getString("sea_class"))).replace("\"", "'") + "\"}"); + // #endregion + // 产品口径:TMS 已返回汇总金额 → Excel 汇总/合计格去掉公式,改写 TMS 数。 + // 海运三段汇总/小计:fee_total_*;整单:total_amount(预估费用/费用总计等)。 + writeSeaSectionSubtotals(wb, facts, yellow); overwriteEstimateTotal(wb, facts); + // 无费用明细时:清掉误写入费用名称列的起运港/目的港(WO202609280006) + clearPortSpillInFeeNameColumn(wb, facts); // 转 PDF 前把纸张改成 A4 居中,避免顶部裁字、整页偏左。 applyQuotePrintLayout(wb); Files.createDirectories(targetXlsx.getParent()); @@ -618,6 +715,1464 @@ public final class QuoteTemplateExcelSupport { wb.write(out); } } + // 只对海运模板做控件包/骨架盖回。空运到港等模板禁止走 WO011,否则标题变成「国际海运询价单」。 + if (!seaTemplate) { + // #region agent log + agentDbg("H_SEA", "fillWorkbook.skipSeaScaffold", "non-sea template skip WO011 scaffold", + "{\"path\":\"" + String.valueOf(sourceXlsx).replace("\\", "/").replace("\"", "'") + "\"}"); + // #endregion + return; + } + // 按运输分类盖回 WPS 可显示的控件包: + // Port=WO011 原件;其它=同 drawing/styles + 次钮 Checked(VML/ctrl 一致)。 + // 禁止文案对调 VML:drawing 仍写 Port 且首钮 Checked,界面勾在 Port 或整组空白(WO020)。 + // 禁止 Excel 重写 drawing(3983) 的 other_excel 包(WO013)。 + // 再盖回 WO011 sheet1 骨架,单选才稳定可见(WO030 vs POI 瘦 sheet)。 + syncSeaTransportClassFormControls(targetXlsx, facts, seaDrawParts); + boolean scaffoldOk = restoreSeaSheet1Scaffold(targetXlsx, seaDrawParts); + String seaCls = canonicalizeSeaTransportClass(facts == null ? null : firstNonBlank( + facts.getString("运输分类"), facts.getString("transport_class"), facts.getString("sea_class"))); + if ("其他".equals(seaCls)) { + // 有 Excel/WPS COM 时再点一次次钮并保存,字节与本机金样一致 + switchSeaTransportClassToOtherViaExcel(targetXlsx); + } + if (!scaffoldOk) { + // 骨架失败时至少用 ●/○ 明文让销售看到选中态 + writeSeaTransportClassVisibleLabelToFile(targetXlsx, facts); + } + } + + /** + * 源模板是否海运询价单:看 sheet/SST 是否含「国际海运/海运询价」,或已有运输分类 Form Control。 + *

+ * 空运到港/到门模板禁止据此误判:它们没有海运标题与海运单选控件。 + */ + static boolean sourceLooksLikeSeaTemplate(Path sourceXlsx) { + if (sourceXlsx == null || !Files.isRegularFile(sourceXlsx)) { + return false; + } + try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(sourceXlsx.toFile())) { + boolean hasCtrl = zf.getEntry("xl/ctrlProps/ctrlProp1.xml") != null; + boolean hasSeaTitle = false; + boolean hasAirTitle = false; + for (String name : new String[]{ + "xl/sharedStrings.xml", + "xl/worksheets/sheet1.xml", + "xl/worksheets/sheet2.xml" + }) { + java.util.zip.ZipEntry e = zf.getEntry(name); + if (e == null) { + continue; + } + String text; + try (InputStream in = zf.getInputStream(e)) { + text = new String(readStreamBytes(in), StandardCharsets.UTF_8); + } + if (text.contains("国际海运") || text.contains("海运询价")) { + hasSeaTitle = true; + } + if (text.contains("空运报价") || text.contains("空运到港") || text.contains("空运到门")) { + hasAirTitle = true; + } + } + if (hasSeaTitle && !hasAirTitle) { + return true; + } + // 海运单选:有 ctrlProp 且 VML 里有 Port to Port + if (hasCtrl) { + java.util.zip.ZipEntry ve = zf.getEntry("xl/drawings/vmlDrawing1.vml"); + if (ve != null) { + String vml; + try (InputStream in = zf.getInputStream(ve)) { + vml = new String(readStreamBytes(in), StandardCharsets.UTF_8); + } + if (vml.contains("Port to Port") || vml.contains("工作天回复")) { + return true; + } + } + } + return false; + } catch (Exception e) { + log.warn("判断海运模板失败 path={} err={}", sourceXlsx, e.getMessage()); + return false; + } + } + + /** + * drawing + VML 均强制换成 WO011 标准件(自测:仅换 drawing 不够,VML 命名空间也不同会导致 WPS 空白)。 + *

+ * WO011 vs WO026:drawing 已相同,但 VML 头分别为 xmlns:o vs xmlns:oa,norm 后仍 DIFF。 + */ + static Map readSeaDrawingParts(Path sourceXlsx) { + Map out = new LinkedHashMap<>(); + if (sourceXlsx == null || !Files.isRegularFile(sourceXlsx)) { + return out; + } + try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(sourceXlsx.toFile())) { + java.util.Enumeration en = zf.entries(); + while (en.hasMoreElements()) { + java.util.zip.ZipEntry entry = en.nextElement(); + String name = entry.getName(); + if (name == null) { + continue; + } + boolean keep = "xl/drawings/drawing1.xml".equals(name) + || (name.startsWith("xl/drawings/") && name.endsWith(".vml")) + // 骨架:POI 重写 sheet1 后 WPS 常不画原单选(WO034);出单后再盖回并合并单元格值 + || "xl/worksheets/sheet1.xml".equals(name); + if (!keep) { + continue; + } + try (InputStream in = zf.getInputStream(entry)) { + out.put(name, readStreamBytes(in)); + } + } + } catch (Exception e) { + log.warn("读取海运模板 drawing 失败 path={} err={}", sourceXlsx, e.getMessage()); + } + byte[] srcDraw = out.get("xl/drawings/drawing1.xml"); + byte[] srcVml = null; + for (Map.Entry e : out.entrySet()) { + if (e.getKey() != null && e.getKey().endsWith(".vml")) { + srcVml = e.getValue(); + break; + } + } + byte[] stdDraw = loadWo011SeaDrawing1(); + byte[] stdVml = loadWo011SeaVml(); + boolean usedDraw = false; + boolean usedVml = false; + if (stdDraw != null && stdDraw.length > 0) { + out.put("xl/drawings/drawing1.xml", stdDraw); + usedDraw = true; + } + if (stdVml != null && stdVml.length > 0) { + boolean replaced = false; + for (String key : new ArrayList<>(out.keySet())) { + if (key != null && key.endsWith(".vml")) { + out.put(key, stdVml); + replaced = true; + usedVml = true; + } + } + if (!replaced) { + out.put("xl/drawings/vmlDrawing1.vml", stdVml); + usedVml = true; + } + } + // #region agent log + agentDbg("R2", "readSeaDrawingParts", "preserved template drawings", + "{\"count\":" + out.size() + + ",\"srcDrawBytes\":" + (srcDraw == null ? 0 : srcDraw.length) + + ",\"srcVmlBytes\":" + (srcVml == null ? 0 : srcVml.length) + + ",\"usedWo011Draw\":" + usedDraw + + ",\"usedWo011Vml\":" + usedVml + + ",\"finalDrawBytes\":" + (out.get("xl/drawings/drawing1.xml") == null + ? 0 : out.get("xl/drawings/drawing1.xml").length) + + ",\"mode\":\"wo011-draw+vml+styles\"}"); + // #endregion + return out; + } + + /** + * 加载 WO011 标准 styles(POI 填表会改瘦 styles,WPS 即不画原单选;盖回后与 WO011 对齐)。 + */ + static byte[] loadWo011SeaStyles() { + return loadBundledBytes(new String[]{ + "inquiry/template/sea_styles_wo011.xml", + "sea_styles_wo011.xml" + }, new String[]{ + "C:/Users/ytd_extdev_0903/incoming/quote_hot2/sea_styles_wo011.xml", + "d:/own/友通达询价智能体(新)/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_styles_wo011.xml", + "d:/own/友通达询价智能体(新)/inquiry-agent/scripts/_verify_quote/sea_styles_wo011.xml" + }, 20000); + } + + /** + * 加载 WO011 标准瘦 drawing(约 3966,WPS 可显原 Form Control)。 + */ + static byte[] loadWo011SeaDrawing1() { + return loadBundledBytes(new String[]{ + "inquiry/template/sea_drawing1_wo011.xml", + "sea_drawing1_wo011.xml" + }, new String[]{ + "C:/Users/ytd_extdev_0903/incoming/quote_hot2/sea_drawing1_wo011.xml", + "d:/own/友通达询价智能体(新)/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_drawing1_wo011.xml", + "d:/own/友通达询价智能体(新)/inquiry-agent/scripts/_verify_quote/sea_drawing1_wo011.xml" + }, 3000); + } + + /** + * 加载 WO011 标准 VML(xmlns:o,与标准 drawing 配套;线上源件常被改成 xmlns:oa)。 + */ + static byte[] loadWo011SeaVml() { + return loadBundledBytes(new String[]{ + "inquiry/template/sea_vmlDrawing1_wo011.vml", + "sea_vmlDrawing1_wo011.vml" + }, new String[]{ + "C:/Users/ytd_extdev_0903/incoming/quote_hot2/sea_vmlDrawing1_wo011.vml", + "d:/own/友通达询价智能体(新)/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_vmlDrawing1_wo011.vml", + "d:/own/友通达询价智能体(新)/inquiry-agent/scripts/_verify_quote/sea_vmlDrawing1_wo011.vml" + }, 1500); + } + + /** + * 「其它」可见件:ClientData 与 {@link #loadWo011SeaVml()} 字节级一致(首钮 Checked), + * 仅对调两颗单选文案,使勾选看起来是「其它」。改 Checked 会导致 WPS 空白(WO040/044)。 + */ + /** + * 「其它」勾选 VML:WO011 文案顺序不变,仅次钮 {@code }(与 demo_v3 金样一致)。 + * 禁止再放文案对调件。 + */ + static byte[] loadWo011SeaVmlOther() { + return loadBundledBytes(new String[]{ + "inquiry/template/sea_vmlDrawing1_wo011_other.vml", + "sea_vmlDrawing1_wo011_other.vml" + }, new String[]{ + "C:/Users/ytd_extdev_0903/incoming/quote_hot2/sea_vmlDrawing1_wo011_other.vml", + "d:/own/友通达询价智能体(新)/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_vmlDrawing1_wo011_other.vml", + "d:/own/友通达询价智能体(新)/inquiry-agent/scripts/_verify_quote/sea_vmlDrawing1_wo011_other.vml" + }, 1500); + } + + /** + * Excel 在 WO011 Port 勾选文件上点选「其它」后导出的 VML(次钮 Checked,测服无 Excel 时用此件)。 + */ + static byte[] loadWo011SeaVmlOtherExcel() { + return loadBundledBytes(new String[]{ + "inquiry/template/sea_vmlDrawing1_wo011_other_excel.vml", + "sea_vmlDrawing1_wo011_other_excel.vml" + }, new String[]{ + "C:/Users/ytd_extdev_0903/incoming/quote_hot2/sea_vmlDrawing1_wo011_other_excel.vml", + "d:/own/友通达询价智能体(新)/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_vmlDrawing1_wo011_other_excel.vml", + "d:/own/友通达询价智能体(新)/inquiry-agent/scripts/_verify_quote/sea_vmlDrawing1_wo011_other_excel.vml" + }, 1500); + } + + /** Excel 切「其它」后配套 drawing(约 3983;与裸 WO011 3966 不同,缺则 WPS 空白)。 */ + static byte[] loadWo011SeaDrawingOtherExcel() { + return loadBundledBytes(new String[]{ + "inquiry/template/sea_drawing1_wo011_other_excel.xml", + "sea_drawing1_wo011_other_excel.xml" + }, new String[]{ + "C:/Users/ytd_extdev_0903/incoming/quote_hot2/sea_drawing1_wo011_other_excel.xml", + "d:/own/友通达询价智能体(新)/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_drawing1_wo011_other_excel.xml", + "d:/own/友通达询价智能体(新)/inquiry-agent/scripts/_verify_quote/sea_drawing1_wo011_other_excel.xml" + }, 3000); + } + + static byte[] loadWo011SeaCtrlProp1OtherExcel() { + return loadBundledBytes(new String[]{ + "inquiry/template/sea_ctrlProp1_wo011_other_excel.xml", + "sea_ctrlProp1_wo011_other_excel.xml" + }, new String[]{ + "C:/Users/ytd_extdev_0903/incoming/quote_hot2/sea_ctrlProp1_wo011_other_excel.xml", + "d:/own/友通达询价智能体(新)/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_ctrlProp1_wo011_other_excel.xml" + }, 50); + } + + static byte[] loadWo011SeaCtrlProp2OtherExcel() { + return loadBundledBytes(new String[]{ + "inquiry/template/sea_ctrlProp2_wo011_other_excel.xml", + "sea_ctrlProp2_wo011_other_excel.xml" + }, new String[]{ + "C:/Users/ytd_extdev_0903/incoming/quote_hot2/sea_ctrlProp2_wo011_other_excel.xml", + "d:/own/友通达询价智能体(新)/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_ctrlProp2_wo011_other_excel.xml" + }, 50); + } + + /** + * 加载 WO011 标准 sheet1(含可显示的 Form Control AC,无本地 xmlns:mc)。 + *

+ * WO036 证据:线上源模板 sheet1 的 AlternateContent 自带 xmlns:mc 且缺 printOptions, + * 与能显示的 WO030/WO011 不同;盖回源模板骨架后 WPS 仍空白。必须用 WO011 sheet1 作骨架。 + */ + static byte[] loadWo011SeaSheet1() { + return loadBundledBytes(new String[]{ + "inquiry/template/sea_sheet1_wo011.xml", + "sea_sheet1_wo011.xml" + }, new String[]{ + "C:/Users/ytd_extdev_0903/incoming/quote_hot2/sea_sheet1_wo011.xml", + "d:/own/友通达询价智能体(新)/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_sheet1_wo011.xml", + "d:/own/友通达询价智能体(新)/inquiry-agent/scripts/_verify_quote/sea_sheet1_wo011.xml" + }, 15000); + } + + /** + * 瘦 drawing:缺东亚字体/列表样式声明,WPS 常不画单选旁文字(WO019=3983 vs 完整≈8489)。 + */ + static boolean isThinSeaDrawing(byte[] drawingXml) { + if (drawingXml == null || drawingXml.length < 100) { + return true; + } + if (drawingXml.length >= 6000) { + return false; + } + String s = new String(drawingXml, StandardCharsets.UTF_8); + // 完整件含多级 lstStyle + a:cs;POI/误存瘦件只有短 txBody + return !s.contains("= minBytes) { + return b; + } + } + } + } catch (Exception ignored) { + // 试下一路径 + } + } + } + if (files != null) { + for (String f : files) { + try { + Path p = Paths.get(f); + if (Files.isRegularFile(p)) { + try (InputStream in = Files.newInputStream(p)) { + byte[] b = readStreamBytes(in); + if (b != null && b.length >= minBytes) { + return b; + } + } + } + } catch (Exception ignored) { + // 试下一路径 + } + } + } + return null; + } + + /** + * 海运三段汇总/小计行:TMS fee_total_pol/ocean/pod →「始发港/海运/目的港费用汇总|小计」。 + *

+ * 产品口径:TMS 已返回该段汇总时,金额列若是 SUM 等公式必须删掉再写数(writeAmountCell); + * 币别进币别列、金额进金额列。缺小计则跳过该行,保留模板公式。 + */ + static void writeSeaSectionSubtotals(Workbook wb, JSONObject facts, Set yellow) { + if (wb == null || facts == null) { + return; + } + // marker[0]=中文主标签,marker[1]=中文别名/英文,marker[2]=facts 键 + String[][] markers = { + {"始发港费用汇总", "POL Local", "fee_total_polCostItems"}, + {"始发港费用小计", "Origin Charges", "fee_total_polCostItems"}, + {"海运费用汇总", "/OF", "fee_total_oceanCostItems"}, + {"海运费用小计", "Ocean Freight", "fee_total_oceanCostItems"}, + {"目的港费用汇总", "POD Local", "fee_total_podCostItems"}, + {"目的港费用小计", "Destination Charges", "fee_total_podCostItems"} + }; + String headerCcy = firstNonBlank(facts.getString("currency"), "CNY"); + for (int s = 0; s < wb.getNumberOfSheets(); s++) { + Sheet sheet = wb.getSheetAt(s); + if (sheet == null) { + continue; + } + for (int r = 0; r <= sheet.getLastRowNum(); r++) { + Row row = sheet.getRow(r); + if (row == null) { + continue; + } + String rowText = ""; + for (Cell cell : row) { + if (cell != null) { + rowText += readCellText(cell); + } + } + if (StringUtils.isBlank(rowText) + || !(rowText.contains("汇总") || rowText.contains("小计"))) { + continue; + } + // 整单「费用总计」由 overwriteEstimateTotal 独占,避免与分段小计抢写 + if (rowText.contains("费用总计") || rowText.toLowerCase(Locale.ROOT).contains("total cost") + || rowText.contains("预估费用") || rowText.contains("预估总价") + || rowText.contains("报价合计")) { + continue; + } + for (String[] mk : markers) { + if (!(rowText.contains(mk[0]) || rowText.contains(mk[1]))) { + continue; + } + String raw = facts.getString(mk[2]); + if (StringUtils.isBlank(raw) || "-".equals(raw.trim())) { + // #region agent log + agentDbg("T2", "writeSeaSectionSubtotals.skip", "no subtotal fact", + "{\"row\":" + r + ",\"marker\":\"" + mk[0] + "\",\"key\":\"" + mk[2] + "\"}"); + // #endregion + continue; + } + String ccy = headerCcy; + String amt = stripMoneyPrefix(raw.trim()); + String upper = raw.trim().toUpperCase(Locale.ROOT); + if (upper.startsWith("CNY") || upper.startsWith("USD") || upper.startsWith("RMB") + || upper.startsWith("EUR") || upper.startsWith("HKD")) { + ccy = raw.trim().split("\\s+")[0].toUpperCase(Locale.ROOT); + if ("RMB".equals(ccy)) { + ccy = "CNY"; + } + } + Map cols = findNearbyFeeColumns(sheet, r); + int ccyCol = cols.containsKey("currency") ? cols.get("currency") : 1; + int amtCol = cols.containsKey("amount") ? cols.get("amount") : 7; + writeMappedText(sheet, r, ccyCol, ccy, yellow); + // 有 TMS 汇总:必须去掉公式再写数字 + writeAmountCell(sheet, r, amtCol, amt, yellow); + clearOtherSummaryFormulasOnRow(row, amtCol, ccyCol); + // #region agent log + agentDbg("T2", "writeSeaSectionSubtotals.write", "wrote section subtotal", + "{\"row\":" + r + ",\"marker\":\"" + mk[0] + "\",\"ccy\":\"" + ccy + + "\",\"amt\":\"" + amt.replace("\"", "'") + + "\",\"ccyCol\":" + ccyCol + ",\"amtCol\":" + amtCol + "}"); + // #endregion + break; + } + } + } + } + + /** + * 汇总行写入 TMS 金额后,清掉同行其它 SUM/公式格,避免保留模板计算。 + */ + private static void clearOtherSummaryFormulasOnRow(Row row, int keepAmtCol, int keepCcyCol) { + if (row == null) { + return; + } + short last = row.getLastCellNum(); + if (last < 0) { + return; + } + for (int c = 0; c < last; c++) { + if (c == keepAmtCol || c == keepCcyCol) { + continue; + } + Cell cell = row.getCell(c); + if (cell == null) { + continue; + } + if (cell.getCellType() != CellType.FORMULA) { + continue; + } + String formula = StringUtils.trimToEmpty(cell.getCellFormula()).toUpperCase(Locale.ROOT); + if (!(formula.contains("SUM") || formula.contains("*") || formula.contains("+"))) { + continue; + } + clearEstimateExtraCell(row, cell); + } + } + + /** + * 从当前行向上找最近费用表头列(币别/金额),供汇总行对齐写入。 + */ + static Map findNearbyFeeColumns(Sheet sheet, int rowIdx) { + Map empty = new LinkedHashMap<>(); + if (sheet == null || rowIdx < 0) { + return empty; + } + for (int r = rowIdx; r >= Math.max(0, rowIdx - 25); r--) { + Map cols = parseFeeHeaderRow(sheet, r); + if (!cols.isEmpty() && cols.containsKey("amount")) { + return cols; + } + } + return empty; + } + + /** + * 清掉历史上误写入单元格的运输分类文案,避免与模板原 Form Control 叠成两套。 + * B3:J3 是控件锚点:任何单元格字(含旧版「其它, 3工作天回复」)都会让 WPS 不画原单选。 + */ + static void clearSyntheticSeaTransportClassCell(Workbook wb) { + if (wb == null) { + return; + } + for (int s = 0; s < wb.getNumberOfSheets(); s++) { + Sheet sheet = wb.getSheetAt(s); + if (sheet == null) { + continue; + } + Cell target = findSeaTransportClassRadioCell(sheet); + if (target == null) { + continue; + } + String text = StringUtils.trimToEmpty(readCellText(target)); + boolean synthetic = text.contains("●") || text.contains("○") + || text.contains("工作天回复") + || text.contains("Port to Port") + || text.contains("其它") || text.contains("其他"); + if (synthetic) { + target.setCellValue(""); + // #region agent log + agentDbg("H_B3", "clearSyntheticSeaTransportClassCell", "cleared radio-anchor cell text", + "{\"ref\":\"" + new CellReference(target).formatAsString() + + "\",\"was\":\"" + text.replace("\"", "'") + "\"}"); + // #endregion + } + return; + } + } + + /** + * 海运模板顶部运输分类:按 facts 盖回 WPS 可显示的 Form Control 套件。 + *

+ * Port to Port → WO011 原件(首钮 Checked,文案 Port / 其它)。 + * 其它 → 仍用 WO011 drawing/styles(3966/50650),VML 与 ctrlProp 同步改为次钮 Checked; + * 文案顺序不变。本机 WPS 对 {@code sea_other_radio_demo_v3}/{@code wo_other_wo011_com} 可画且勾在「其它」。 + *

+ * 禁止文案对调 VML(WO020:drawing 仍 Port、首钮 Checked,界面勾错或空白)。 + * 禁止 Excel 重写 drawing≈3983 的 other_excel 整包(WO013 整组空白)。 + */ + static void syncSeaTransportClassFormControls(Path targetXlsx, JSONObject facts) { + syncSeaTransportClassFormControls(targetXlsx, facts, null); + } + + static void syncSeaTransportClassFormControls(Path targetXlsx, JSONObject facts, + Map preservedDrawParts) { + if (targetXlsx == null || !Files.isRegularFile(targetXlsx)) { + return; + } + String raw = facts == null ? null : firstNonBlank( + facts.getString("运输分类"), + facts.getString("transport_class"), + facts.getString("sea_class")); + String cls = canonicalizeSeaTransportClass(raw); + // 空分类默认 Port;其它=次钮 Checked(VML+ctrl 一致,drawing 仍 WO011) + boolean wantOther = "其他".equals(cls); + Path tmp = null; + try { + tmp = Files.createTempFile(targetXlsx.getParent(), "sea-radio-", ".xlsx"); + boolean touchedCtrl = false; + boolean touchedVml = false; + boolean restoredDrawing = false; + boolean restoredStyles = false; + byte[] stdStyles = loadWo011SeaStyles(); + byte[] portDraw = loadWo011SeaDrawing1(); + byte[] portVml = loadWo011SeaVml(); + byte[] otherVmlChecked2 = loadWo011SeaVmlOther(); + boolean useOtherChecked2 = wantOther && otherVmlChecked2 != null && otherVmlChecked2.length > 1000; + byte[] pickDraw = portDraw; + byte[] pickVml = useOtherChecked2 ? otherVmlChecked2 : portVml; + String mode = useOtherChecked2 ? "wo011-other-checked2" : "wo011-port-pack"; + try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(targetXlsx.toFile()); + OutputStream fos = Files.newOutputStream(tmp); + java.util.zip.ZipOutputStream zos = new java.util.zip.ZipOutputStream(fos)) { + java.util.Enumeration en = zf.entries(); + while (en.hasMoreElements()) { + java.util.zip.ZipEntry entry = en.nextElement(); + String name = entry.getName(); + zos.putNextEntry(new java.util.zip.ZipEntry(name)); + byte[] data; + try (InputStream in = zf.getInputStream(entry)) { + data = readStreamBytes(in); + } + if ("xl/drawings/drawing1.xml".equals(name)) { + if (pickDraw != null && pickDraw.length > 1000) { + data = pickDraw; + restoredDrawing = true; + } else if (preservedDrawParts != null && preservedDrawParts.containsKey(name)) { + data = preservedDrawParts.get(name); + restoredDrawing = true; + } + } else if ("xl/styles.xml".equals(name) && stdStyles != null && stdStyles.length > 10000) { + data = stdStyles; + restoredStyles = true; + } else if ("xl/ctrlProps/ctrlProp1.xml".equals(name)) { + // Port=首钮 Checked;其它=首钮不勾(次钮在 ctrlProp2) + data = buildSeaRadioCtrlPropXml(true, !wantOther).getBytes(StandardCharsets.UTF_8); + touchedCtrl = true; + } else if ("xl/ctrlProps/ctrlProp2.xml".equals(name)) { + data = buildSeaRadioCtrlPropXml(false, wantOther).getBytes(StandardCharsets.UTF_8); + touchedCtrl = true; + } else if (name != null && name.startsWith("xl/drawings/") && name.endsWith(".vml")) { + byte[] vmlPick = null; + if (pickVml != null && pickVml.length > 1000) { + vmlPick = pickVml; + } else if (preservedDrawParts != null && preservedDrawParts.containsKey(name)) { + vmlPick = preservedDrawParts.get(name); + } + if (vmlPick != null) { + data = vmlPick; + touchedVml = true; + restoredDrawing = true; + } + } + zos.write(data); + zos.closeEntry(); + } + } + if (touchedCtrl || touchedVml || restoredDrawing || restoredStyles) { + Files.move(tmp, targetXlsx, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + tmp = null; + } + // #region agent log + agentDbg("R1", "syncSeaTransportClassFormControls", "patched form control pack by transport class", + "{\"cls\":\"" + String.valueOf(cls).replace("\"", "'") + + "\",\"wantOther\":" + wantOther + + ",\"useOtherChecked2\":" + useOtherChecked2 + + ",\"touchedCtrl\":" + touchedCtrl + ",\"touchedVml\":" + touchedVml + + ",\"restoredDrawing\":" + restoredDrawing + + ",\"restoredStyles\":" + restoredStyles + + ",\"stylesBytes\":" + (stdStyles == null ? 0 : stdStyles.length) + + ",\"drawBytes\":" + (pickDraw == null ? 0 : pickDraw.length) + + ",\"vmlBytes\":" + (pickVml == null ? 0 : pickVml.length) + + ",\"mode\":\"" + mode + "\"}"); + // #endregion + } catch (Exception e) { + // #region agent log + agentDbg("R1", "syncSeaTransportClassFormControls.err", "patch failed", + "{\"err\":\"" + String.valueOf(e.getMessage()).replace("\"", "'") + "\"}"); + // #endregion + log.warn("海运运输分类 Form Control 勾选同步失败 path={}", targetXlsx, e); + } finally { + if (tmp != null) { + try { + Files.deleteIfExists(tmp); + } catch (Exception ignored) { + // 临时文件清不掉不影响出单 + } + } + } + } + + /** + * 「其它」:在已可显示的 WO011 Port 勾选文件上,用 Excel COM 点选第二颗 Option Button 再保存。 + * 直接改 OOXML Checked 会导致 WPS 空白;Excel 切勾后仍能显示(本机已验证)。 + * + * @return true=已切到其它且 zip 内次钮 Checked + */ + static boolean switchSeaTransportClassToOtherViaExcel(Path targetXlsx) { + if (targetXlsx == null || !Files.isRegularFile(targetXlsx)) { + return false; + } + Path ps1 = null; + try { + ps1 = Files.createTempFile("sea-radio-other-", ".ps1"); + String pathEsc = targetXlsx.toAbsolutePath().toString().replace("'", "''"); + String script = "" + + "$ErrorActionPreference='Stop'\n" + + "$path = '" + pathEsc + "'\n" + + "$app = $null\n" + + "foreach($id in @('Excel.Application','Ket.Application')){\n" + + " try { $app = New-Object -ComObject $id; break } catch {}\n" + + "}\n" + + "if(-not $app){ Write-Output 'NO_EXCEL'; exit 2 }\n" + + "try {\n" + + " $app.Visible = $false\n" + + " try { $app.DisplayAlerts = $false } catch {}\n" + + " $wb = $app.Workbooks.Open($path)\n" + + " $ws = $wb.Worksheets.Item(1)\n" + + " $g = $ws.Shapes.Item(1)\n" + + " $btn = $g.GroupItems.Item(2)\n" + + " $btn.ControlFormat.Value = 1\n" + + " $wb.Save()\n" + + " $wb.Close($false)\n" + + " Write-Output 'SWITCH_OK'\n" + + "} finally {\n" + + " try { $app.Quit() } catch {}\n" + + "}\n"; + Files.write(ps1, script.getBytes(StandardCharsets.UTF_8)); + ProcessBuilder pb = new ProcessBuilder( + "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", ps1.toAbsolutePath().toString()); + pb.redirectErrorStream(true); + Process p = pb.start(); + byte[] outBytes = readStreamBytes(p.getInputStream()); + boolean finished = p.waitFor(120, java.util.concurrent.TimeUnit.SECONDS); + if (!finished) { + p.destroyForcibly(); + agentDbg("R1", "switchSeaTransportClassToOtherViaExcel.timeout", "excel com timeout", "{}"); + return false; + } + String out = new String(outBytes, StandardCharsets.UTF_8); + if (!out.contains("SWITCH_OK")) { + agentDbg("R1", "switchSeaTransportClassToOtherViaExcel.fail", "excel com failed", + "{\"out\":\"" + out.replace("\"", "'").replace("\n", " ").replace("\r", "") + "\"}"); + return false; + } + // 校验 zip:次钮 Checked + try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(targetXlsx.toFile())) { + java.util.zip.ZipEntry ve = zf.getEntry("xl/drawings/vmlDrawing1.vml"); + java.util.zip.ZipEntry c2e = zf.getEntry("xl/ctrlProps/ctrlProp2.xml"); + if (ve == null || c2e == null) { + return false; + } + String vml; + String c2; + try (InputStream in = zf.getInputStream(ve)) { + vml = new String(readStreamBytes(in), StandardCharsets.UTF_8); + } + try (InputStream in = zf.getInputStream(c2e)) { + c2 = new String(readStreamBytes(in), StandardCharsets.UTF_8); + } + java.util.regex.Pattern cd = java.util.regex.Pattern.compile( + "([\\s\\S]*?)", + java.util.regex.Pattern.CASE_INSENSITIVE); + java.util.regex.Matcher m = cd.matcher(vml); + java.util.List bodies = new java.util.ArrayList<>(); + while (m.find()) { + bodies.add(m.group(1)); + } + boolean ok = bodies.size() == 2 + && !bodies.get(0).contains("") + && bodies.get(1).contains("") + && c2.contains("checked=\"Checked\""); + agentDbg("R1", "switchSeaTransportClassToOtherViaExcel", "excel switched other radio", + "{\"ok\":" + ok + ",\"vmlChecked2\":" + (bodies.size() > 1 && bodies.get(1).contains("")) + + ",\"ctrl2\":" + c2.contains("checked=\"Checked\"") + "}"); + return ok; + } + } catch (Exception e) { + agentDbg("R1", "switchSeaTransportClassToOtherViaExcel.err", "excel com exception", + "{\"err\":\"" + String.valueOf(e.getMessage()).replace("\"", "'") + "\"}"); + log.warn("海运「其它」Excel 切勾失败 path={}", targetXlsx, e); + return false; + } finally { + if (ps1 != null) { + try { + Files.deleteIfExists(ps1); + } catch (Exception ignored) { + // ignore + } + } + } + } + + /** + * 选「其它」时的处理:故意不写 B3。 + *

+ * B3:J3 是 Form Control 锚点合并区,往里写单元格字会与原控件叠层。 + * 「其它」无法在不改坏 VML 的前提下勾选第二钮;单选可见性靠 {@link #restoreSeaSheet1Scaffold}。 + */ + static void writeSeaOtherClassFallbackLabel(Workbook wb, JSONObject facts) { + if (wb == null || facts == null) { + return; + } + String raw = firstNonBlank( + facts.getString("运输分类"), + facts.getString("transport_class"), + facts.getString("sea_class")); + String cls = canonicalizeSeaTransportClass(raw); + if (!"其他".equals(cls)) { + return; + } + // #region agent log + agentDbg("H_B3", "writeSeaOtherClassFallbackLabel", "skip B3 write keep radio anchors empty", + "{\"cls\":\"其他\",\"reason\":\"B3 is radio anchor; scaffold restore keeps radios\"}"); + // #endregion + } + + /** + * 海运出单后盖回 WO011 sheet1 骨架,并把 POI 填好的单元格值合并进去。 + *

+ * Port / 其它共用同一骨架(与 WO030 一致),避免「其它」换种子 sheet/styles 导致红字变黑、控件长相漂移。 + * + * @return true=骨架盖回且合并成功;false=跳过/失败(调用方应写 B3 明文兜底) + */ + static boolean restoreSeaSheet1Scaffold(Path targetXlsx, Map preservedParts) { + return restoreSeaSheet1Scaffold(targetXlsx, preservedParts, null); + } + + static boolean restoreSeaSheet1Scaffold(Path targetXlsx, Map preservedParts, + String seaTransportClass) { + if (targetXlsx == null || !Files.isRegularFile(targetXlsx) || preservedParts == null) { + return false; + } + // seaTransportClass 保留入参兼容旧调用;骨架一律 WO011。 + byte[] scaffold = loadWo011SeaSheet1(); + String scaffoldSrc = "wo011-bundled"; + if (scaffold == null || scaffold.length < 1000) { + // #region agent log + agentDbg("H_SHEET", "restoreSeaSheet1Scaffold.skip", "no scaffold sheet1", + "{\"scaffoldBytes\":" + (scaffold == null ? 0 : scaffold.length) + + ",\"scaffoldSrc\":\"" + scaffoldSrc + "\"}"); + // #endregion + return false; + } + Path tmp = null; + try { + byte[] poiSheet; + byte[] poiSst; + try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(targetXlsx.toFile())) { + java.util.zip.ZipEntry sh = zf.getEntry("xl/worksheets/sheet1.xml"); + java.util.zip.ZipEntry sst = zf.getEntry("xl/sharedStrings.xml"); + if (sh == null) { + return false; + } + try (InputStream in = zf.getInputStream(sh)) { + poiSheet = readStreamBytes(in); + } + if (sst != null) { + try (InputStream in = zf.getInputStream(sst)) { + poiSst = readStreamBytes(in); + } + } else { + poiSst = new byte[0]; + } + } + Map poiValues = parseSheetCellTexts(poiSheet, poiSst); + byte[] scaffoldSst = loadWo011SeaSharedStrings(); + SeaSheetMerge merged = mergeValuesOntoSeaScaffold(scaffold, scaffoldSst, poiValues); + int rowCount = countSheetRows(merged.sheetXml); + // WO035 证据:错误正则把自闭合 的 / 吃进属性,一次匹配跨越多行, + // sheet 从 17KB 缩成 7KB、只剩 2 行。合并结果过瘦则放弃,保留 POI 原 sheet。 + if (merged.sheetXml.length() < scaffold.length / 2 || rowCount < 20) { + // #region agent log + agentDbg("H_SHEET", "restoreSeaSheet1Scaffold.abort", "merged sheet too thin, keep POI sheet", + "{\"scaffoldBytes\":" + scaffold.length + + ",\"mergedSheetBytes\":" + merged.sheetXml.length() + + ",\"rowCount\":" + rowCount + + ",\"poiCells\":" + poiValues.size() + "}"); + // #endregion + return false; + } + // WO011 骨架合并后若仍带本地 mc xmlns,则放弃(WO036) + if (merged.sheetXml.contains("AlternateContent xmlns:mc=")) { + agentDbg("H_SHEET", "restoreSeaSheet1Scaffold.abort", "scaffold still has local mc xmlns", + "{\"scaffoldSrc\":\"" + scaffoldSrc + "\"}"); + return false; + } + tmp = Files.createTempFile(targetXlsx.getParent(), "sea-sheet-", ".xlsx"); + try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(targetXlsx.toFile()); + OutputStream fos = Files.newOutputStream(tmp); + java.util.zip.ZipOutputStream zos = new java.util.zip.ZipOutputStream(fos)) { + java.util.Enumeration en = zf.entries(); + boolean wroteSst = false; + while (en.hasMoreElements()) { + java.util.zip.ZipEntry entry = en.nextElement(); + String name = entry.getName(); + zos.putNextEntry(new java.util.zip.ZipEntry(name)); + byte[] data; + try (InputStream in = zf.getInputStream(entry)) { + data = readStreamBytes(in); + } + if ("xl/worksheets/sheet1.xml".equals(name)) { + data = merged.sheetXml.getBytes(StandardCharsets.UTF_8); + } else if ("xl/sharedStrings.xml".equals(name)) { + data = merged.sstXml.getBytes(StandardCharsets.UTF_8); + wroteSst = true; + } + zos.write(data); + zos.closeEntry(); + } + if (!wroteSst && merged.sstXml != null) { + zos.putNextEntry(new java.util.zip.ZipEntry("xl/sharedStrings.xml")); + zos.write(merged.sstXml.getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + } + } + Files.move(tmp, targetXlsx, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + tmp = null; + // #region agent log + agentDbg("H_SHEET", "restoreSeaSheet1Scaffold", "restored template sheet1 scaffold", + "{\"scaffoldSrc\":\"" + scaffoldSrc + "\"" + + ",\"scaffoldBytes\":" + scaffold.length + + ",\"poiCells\":" + poiValues.size() + + ",\"mergedSheetBytes\":" + merged.sheetXml.length() + + ",\"sstCount\":" + merged.sstCount + + ",\"patchedCells\":" + merged.patchedCells + + ",\"rowCount\":" + rowCount + + ",\"acHasLocalMc\":" + merged.sheetXml.contains("AlternateContent xmlns:mc=") + "}"); + // #endregion + return true; + } catch (Exception e) { + // #region agent log + agentDbg("H_SHEET", "restoreSeaSheet1Scaffold.err", "scaffold restore failed", + "{\"err\":\"" + String.valueOf(e.getMessage()).replace("\"", "'") + "\"}"); + // #endregion + log.warn("海运 sheet1 骨架还原失败 path={}", targetXlsx, e); + return false; + } finally { + if (tmp != null) { + try { + Files.deleteIfExists(tmp); + } catch (Exception ignored) { + // ignore + } + } + } + } + + /** sheet1 骨架合并结果。 */ + static final class SeaSheetMerge { + final String sheetXml; + final String sstXml; + final int sstCount; + final int patchedCells; + + SeaSheetMerge(String sheetXml, String sstXml, int sstCount, int patchedCells) { + this.sheetXml = sheetXml; + this.sstXml = sstXml; + this.sstCount = sstCount; + this.patchedCells = patchedCells; + } + } + + /** + * 匹配单元格:必须先认自闭合 {@code },属性里不能吞掉 {@code /}, + * 否则会变成「开标签 + 一直找到下一个 </c>」,把多行揉进一个格子(WO035)。 + */ + private static final java.util.regex.Pattern SEA_CELL_PATTERN = java.util.regex.Pattern.compile( + "/]*)\\s*/>|]*)>([\\s\\S]*?)"); + + static int countSheetRows(String sheetXml) { + if (sheetXml == null || sheetXml.isEmpty()) { + return 0; + } + int n = 0; + java.util.regex.Matcher m = java.util.regex.Pattern.compile(" parseSheetCellTexts(byte[] sheetXml, byte[] sstXml) { + Map out = new LinkedHashMap<>(); + if (sheetXml == null || sheetXml.length == 0) { + return out; + } + List sst = parseSharedStrings(sstXml); + String sheet = new String(sheetXml, StandardCharsets.UTF_8); + java.util.regex.Matcher m = SEA_CELL_PATTERN.matcher(sheet); + while (m.find()) { + String ref; + String attrs; + String body; + if (m.group(1) != null) { + // 自闭合 + ref = m.group(1); + attrs = m.group(2) == null ? "" : m.group(2); + body = ""; + } else { + ref = m.group(3); + attrs = m.group(4) == null ? "" : m.group(4); + body = m.group(5) == null ? "" : m.group(5); + } + String val = ""; + if (attrs.contains("t=\"s\"") || attrs.contains("t='s'")) { + java.util.regex.Matcher vm = java.util.regex.Pattern.compile("(\\d+)").matcher(body); + if (vm.find()) { + int idx = Integer.parseInt(vm.group(1)); + if (idx >= 0 && idx < sst.size()) { + val = sst.get(idx); + } + } + } else { + java.util.regex.Matcher vm = java.util.regex.Pattern.compile("([^<]*)").matcher(body); + if (vm.find()) { + val = vm.group(1); + } + } + out.put(ref, val == null ? "" : val); + } + return out; + } + + static List parseSharedStrings(byte[] sstXml) { + List out = new ArrayList<>(); + if (sstXml == null || sstXml.length == 0) { + return out; + } + String xml = new String(sstXml, StandardCharsets.UTF_8); + java.util.regex.Matcher si = java.util.regex.Pattern.compile("([\\s\\S]*?)").matcher(xml); + while (si.find()) { + String body = si.group(1); + StringBuilder sb = new StringBuilder(); + java.util.regex.Matcher t = java.util.regex.Pattern.compile("]*>([^<]*)").matcher(body); + while (t.find()) { + sb.append(t.group(1)); + } + out.add(sb.toString()); + } + return out; + } + + /** + * 加载 WO011 共享字符串(与 sea_sheet1_wo011.xml 配套;合并时保留原索引,避免瘦表)。 + */ + static byte[] loadWo011SeaSharedStrings() { + return loadBundledBytes(new String[]{ + "inquiry/template/sea_sharedStrings_wo011.xml", + "sea_sharedStrings_wo011.xml" + }, new String[]{ + "C:/Users/ytd_extdev_0903/incoming/quote_hot2/sea_sharedStrings_wo011.xml", + "d:/own/友通达询价智能体(新)/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_sharedStrings_wo011.xml", + "d:/own/友通达询价智能体(新)/inquiry-agent/scripts/_verify_quote/sea_sharedStrings_wo011.xml" + }, 1000); + } + + /** + * 以模板 sheet1 为骨架,把 POI 填好的值写回对应格子。 + *

+ * 硬约束(WO010 vs WO030):控件包相同时,若重建整表 SST、把空值盖掉骨架格, + * sheetData 会从约 20KB 瘦到 17KB,WPS 界面可能不画顶部单选。 + * 策略:保留 WO011 原 SST 词序与索引,只追加新词;只改 POI 非空且与骨架不同的格; + * B3:J3 单选锚点区一律不动。 + */ + static SeaSheetMerge mergeValuesOntoSeaScaffold(byte[] scaffoldSheet, Map poiValues) { + return mergeValuesOntoSeaScaffold(scaffoldSheet, loadWo011SeaSharedStrings(), poiValues); + } + + static SeaSheetMerge mergeValuesOntoSeaScaffold(byte[] scaffoldSheet, byte[] scaffoldSst, + Map poiValues) { + String sheet = new String(scaffoldSheet, StandardCharsets.UTF_8); + List newSst = new ArrayList<>(parseSharedStrings(scaffoldSst)); + Map sstIndex = new LinkedHashMap<>(); + for (int i = 0; i < newSst.size(); i++) { + sstIndex.put(newSst.get(i), i); + } + final int[] patched = {0}; + + java.util.function.Function idxOf = (s) -> { + String key = s == null ? "" : s; + Integer hit = sstIndex.get(key); + if (hit != null) { + return hit; + } + int i = newSst.size(); + newSst.add(key); + sstIndex.put(key, i); + return i; + }; + + Map scaffoldVals = parseSheetCellTexts(scaffoldSheet, scaffoldSst); + + java.util.regex.Matcher m = SEA_CELL_PATTERN.matcher(sheet); + StringBuffer sb = new StringBuffer(); + while (m.find()) { + String original = m.group(0); + String ref; + String attrs; + if (m.group(1) != null) { + ref = m.group(1); + attrs = m.group(2) == null ? "" : m.group(2); + } else { + ref = m.group(3); + attrs = m.group(4) == null ? "" : m.group(4); + } + // 单选锚点合并区:任何写入都会让 WPS 不画原控件 + if (ref != null && ref.matches("^[B-J]3$")) { + m.appendReplacement(sb, java.util.regex.Matcher.quoteReplacement(original)); + continue; + } + if (poiValues == null || !poiValues.containsKey(ref)) { + m.appendReplacement(sb, java.util.regex.Matcher.quoteReplacement(original)); + continue; + } + String val = poiValues.get(ref); + if (val == null || val.isEmpty()) { + // 空值不覆盖骨架(标签/样式格) + m.appendReplacement(sb, java.util.regex.Matcher.quoteReplacement(original)); + continue; + } + // 去掉 POI/骨架可能带来的双重转义 + String norm = val.replace("&", "&").replace("<", "<").replace(">", ">"); + String scaffoldVal = scaffoldVals.get(ref); + String scaffoldNorm = scaffoldVal == null ? "" + : scaffoldVal.replace("&", "&").replace("<", "<").replace(">", ">"); + if (norm.equals(scaffoldNorm)) { + m.appendReplacement(sb, java.util.regex.Matcher.quoteReplacement(original)); + continue; + } + java.util.regex.Matcher sm = java.util.regex.Pattern.compile("\\bs=\"(\\d+)\"").matcher(attrs); + String style = sm.find() ? (" s=\"" + sm.group(1) + "\"") : ""; + String repl; + if (norm.matches("^-?\\d+(\\.\\d+)?$")) { + repl = "" + norm + ""; + } else { + int idx = idxOf.apply(norm); + repl = "" + idx + ""; + } + m.appendReplacement(sb, java.util.regex.Matcher.quoteReplacement(repl)); + patched[0]++; + } + m.appendTail(sb); + + StringBuilder sstXml = new StringBuilder(); + sstXml.append(""); + sstXml.append(""); + for (String t : newSst) { + sstXml.append("").append(xmlEscape(t)).append(""); + } + sstXml.append(""); + return new SeaSheetMerge(sb.toString(), sstXml.toString(), newSst.size(), patched[0]); + } + + static String xmlEscape(String s) { + if (s == null) { + return ""; + } + return s.replace("&", "&").replace("<", "<").replace(">", ">") + .replace("\"", """); + } + + /** + * @deprecated 已废弃:改 VML Checked/文案会导致 WPS 空白(WO029/032)。 + */ + @Deprecated + static String patchSeaRadioVmlForSelection(String vml, boolean portSelected) { + return vml; + } + + /** + * @deprecated 见 {@link #patchSeaRadioVmlForSelection}。 + */ + @Deprecated + static String swapSeaRadioLabels(String xml) { + return xml; + } + + /** + * @deprecated 已改为 {@link #patchSeaRadioVmlForSelection};保留供旧测试编译。 + */ + @Deprecated + static String patchSeaRadioVmlChecked(String vml, boolean portSelected) { + return vml; + } + + /** + * POI 重写 sheet 后 <mc:AlternateContent> 常丢 xmlns:mc,补回与模板一致。 + */ + static String ensureSeaControlsMcNamespace(String sheetXml) { + if (StringUtils.isBlank(sheetXml) || !sheetXml.contains("") + || !sheetXml.contains("mc:AlternateContent")) { + return sheetXml; + } + if (sheetXml.contains("", + ""); + } + + /** + * 生成海运 Form Control 单选 ctrlProp XML。 + * firstButton / checked 应同真同假:WPS 要求当前勾选钮带 firstButton,否则整组空白。 + */ + static String buildSeaRadioCtrlPropXml(boolean firstButton, boolean checked) { + StringBuilder sb = new StringBuilder(); + sb.append("\r\n"); + sb.append(""); + return sb.toString(); + } + + /** Java 8 兼容:把输入流读成 byte[](测服 javac 无 InputStream#readAllBytes)。 */ + static byte[] readStreamBytes(InputStream in) throws Exception { + java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); + byte[] buf = new byte[4096]; + int n; + while ((n = in.read(buf)) >= 0) { + bos.write(buf, 0, n); + } + return bos.toByteArray(); + } + + /** + * @deprecated 可见文案已迁到 {@link #writeSeaTransportClassVisibleLabel};原控件勾选仍走 sync。 + */ + @Deprecated + static void applySeaTransportClassRadio(Workbook wb, JSONObject facts) { + writeSeaTransportClassVisibleLabel(wb, facts); + } + + /** + * WPS 证据(WO020/021):zip 内 VML/drawing 有字但界面空白。 + * 必须写到 B3(RFQ 行合并区),禁止误写到 B9 贸易条款(WO021:find 命中含 Port 的 B9)。 + *

+ * 注意:B3 有字时原 Form Control 不画(WO033)。仅在 {@link #restoreSeaSheet1Scaffold} 失败时作兜底。 + */ + static void writeSeaTransportClassVisibleLabel(Workbook wb, JSONObject facts) { + if (wb == null || facts == null) { + return; + } + String raw = firstNonBlank( + facts.getString("运输分类"), + facts.getString("transport_class"), + facts.getString("sea_class")); + String cls = canonicalizeSeaTransportClass(raw); + if (StringUtils.isBlank(cls)) { + return; + } + boolean port = "Port to Port".equalsIgnoreCase(cls); + String label = port + ? "● Port to Port, 1工作天回复  ○ 其它, 3工作天回复" + : "○ Port to Port, 1工作天回复  ● 其它, 3工作天回复"; + for (int s = 0; s < wb.getNumberOfSheets(); s++) { + Sheet sheet = wb.getSheetAt(s); + if (sheet == null) { + continue; + } + String name = sheet.getSheetName(); + boolean seaSheet = name != null + && (name.contains("海运") || name.toLowerCase(Locale.ROOT).contains("sea")); + if (!seaSheet) { + continue; + } + // 固定 B3:模板 B3:J3 即运输分类单选所在合并区 + Row row = sheet.getRow(2); + if (row == null) { + row = sheet.createRow(2); + } + Cell target = row.getCell(1); + if (target == null) { + target = row.createCell(1); + } + target.setCellValue(label); + // #region agent log + agentDbg("R3", "writeSeaTransportClassVisibleLabel", "wrote visible radio label for WPS", + "{\"ref\":\"" + new CellReference(target).formatAsString() + + "\",\"cls\":\"" + cls.replace("\"", "'") + "\",\"port\":" + port + "}"); + // #endregion + return; + } + } + + /** + * 骨架还原失败时的明文兜底:只改 zip 内 sheet1/sharedStrings 的 B3,不经 POI, + * 避免再次瘦身 drawing(WO017)。销售侧至少能看到 ●/○ 选中态。 + */ + static void writeSeaTransportClassVisibleLabelToFile(Path targetXlsx, JSONObject facts) { + if (targetXlsx == null || !Files.isRegularFile(targetXlsx) || facts == null) { + return; + } + String raw = firstNonBlank( + facts.getString("运输分类"), + facts.getString("transport_class"), + facts.getString("sea_class")); + String cls = canonicalizeSeaTransportClass(raw); + if (StringUtils.isBlank(cls)) { + // #region agent log + agentDbg("R3", "writeSeaTransportClassVisibleLabelToFile.skip", "no transport class for fallback", + "{}"); + // #endregion + return; + } + boolean port = "Port to Port".equalsIgnoreCase(cls); + String label = port + ? "● Port to Port, 1工作天回复  ○ 其它, 3工作天回复" + : "○ Port to Port, 1工作天回复  ● 其它, 3工作天回复"; + Path tmp = null; + try { + byte[] sheetBytes; + byte[] sstBytes = new byte[0]; + try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(targetXlsx.toFile())) { + java.util.zip.ZipEntry sh = zf.getEntry("xl/worksheets/sheet1.xml"); + if (sh == null) { + return; + } + try (InputStream in = zf.getInputStream(sh)) { + sheetBytes = readStreamBytes(in); + } + java.util.zip.ZipEntry sst = zf.getEntry("xl/sharedStrings.xml"); + if (sst != null) { + try (InputStream in = zf.getInputStream(sst)) { + sstBytes = readStreamBytes(in); + } + } + } + Map cells = parseSheetCellTexts(sheetBytes, sstBytes); + cells.put("B3", label); + // 用当前 sheet 当骨架,只把 B3 换成明文;不动 drawing/VML/styles + SeaSheetMerge merged = mergeValuesOntoSeaScaffold(sheetBytes, cells); + tmp = Files.createTempFile(targetXlsx.getParent(), "sea-label-", ".xlsx"); + try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(targetXlsx.toFile()); + OutputStream fos = Files.newOutputStream(tmp); + java.util.zip.ZipOutputStream zos = new java.util.zip.ZipOutputStream(fos)) { + java.util.Enumeration en = zf.entries(); + boolean wroteSst = false; + while (en.hasMoreElements()) { + java.util.zip.ZipEntry entry = en.nextElement(); + String name = entry.getName(); + zos.putNextEntry(new java.util.zip.ZipEntry(name)); + byte[] data; + try (InputStream in = zf.getInputStream(entry)) { + data = readStreamBytes(in); + } + if ("xl/worksheets/sheet1.xml".equals(name)) { + data = merged.sheetXml.getBytes(StandardCharsets.UTF_8); + } else if ("xl/sharedStrings.xml".equals(name)) { + data = merged.sstXml.getBytes(StandardCharsets.UTF_8); + wroteSst = true; + } + zos.write(data); + zos.closeEntry(); + } + if (!wroteSst) { + zos.putNextEntry(new java.util.zip.ZipEntry("xl/sharedStrings.xml")); + zos.write(merged.sstXml.getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + } + } + Files.move(tmp, targetXlsx, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + tmp = null; + // #region agent log + agentDbg("R3", "writeSeaTransportClassVisibleLabelToFile", "B3 visible radio text for other or scaffold-fail", + "{\"cls\":\"" + cls.replace("\"", "'") + "\",\"port\":" + port + "}"); + // #endregion + } catch (Exception e) { + // #region agent log + agentDbg("R3", "writeSeaTransportClassVisibleLabelToFile.err", "fallback failed", + "{\"err\":\"" + String.valueOf(e.getMessage()).replace("\"", "'") + "\"}"); + // #endregion + log.warn("海运运输分类明文兜底失败 path={}", targetXlsx, e); + } finally { + if (tmp != null) { + try { + Files.deleteIfExists(tmp); + } catch (Exception ignored) { + // ignore + } + } + } + } + + /** + * 海运运输分类标准词:Port to Port / 其他(其它→其他)。 + */ + static String canonicalizeSeaTransportClass(String raw) { + if (StringUtils.isBlank(raw)) { + return ""; + } + String t = raw.replace('\u00a0', ' ').replaceAll("\\s+", " ").trim(); + String lower = t.toLowerCase(Locale.ROOT); + if (lower.contains("port to port") || t.contains("港到港") || lower.equals("p2p")) { + return "Port to Port"; + } + if (t.contains("其它") || t.contains("其他") || lower.contains("other")) { + return "其他"; + } + return ""; + } + + /** + * 是否海运询价单工作表(按表名;无表名时看是否含 Form Control 锚点区)。 + */ + static boolean sheetLooksLikeSeaQuote(Sheet sheet) { + if (sheet == null) { + return false; + } + String name = sheet.getSheetName(); + if (name != null) { + String lower = name.toLowerCase(Locale.ROOT); + if (name.contains("海运") || lower.contains("sea") || name.contains("国际海运")) { + return true; + } + } + return false; + } + + /** + * 找运输分类单选格:海运表固定 B3;不再扫描含 Port 的贸易条款格(WO021 误伤 B9)。 + */ + static Cell findSeaTransportClassRadioCell(Sheet sheet) { + if (sheet == null || !sheetLooksLikeSeaQuote(sheet)) { + return null; + } + Row row = sheet.getRow(2); + if (row == null) { + row = sheet.createRow(2); + } + Cell b3 = row.getCell(1); + if (b3 == null) { + b3 = row.createCell(1); + } + return b3; } /** @@ -1497,6 +3052,13 @@ public final class QuoteTemplateExcelSupport { } int col = CellReference.convertColStringToIndex(colLetter.trim()); String ref = sheetName + "!" + new CellReference(rowIdx, col).formatAsString(); + // #region agent log + if (StringUtils.isNotBlank(value) && (value.contains("广州") || "B".equalsIgnoreCase(colLetter.trim()))) { + agentDbg("H3", "writeFeeCol", "fee col write", + "{\"ref\":\"" + ref + "\",\"col\":\"" + colLetter + + "\",\"val\":\"" + value.replace("\"", "'") + "\"}"); + } + // #endregion if (yellow != null && yellow.contains(ref)) { return; } @@ -1752,11 +3314,11 @@ public final class QuoteTemplateExcelSupport { } /** - * 预估费用 / 预估总价行只保留一个总价。 + * 预估费用 / 预估总价 / 费用总计行只保留一个总价。 *

- * 为何:模板金额列是 SUM,刷公式后常把「发生时收取」的提送货/人工费加进去; - * 若再按标签把 TMS total 写到标签右侧空格,销售会看到同行两个数(如 183370 与 183499)。 - * 有 total_amount 时以它为准,写入原 SUM 格(或本行最右金额格),并清空同行其它多余数字。 + * 产品口径:TMS 已返回总销售价(total_amount)时,Excel 若仍是 SUM 等公式必须删掉再写数; + * 禁止用明细行加总冒充总价(WO040:增点费+拆板费 SUM=200,真总价 7918.25)。 + * 有 total_amount 时以它为准,写入原 SUM 格(或金额列),并清空同行其它多余数字。 */ static void overwriteEstimateTotal(Workbook wb, JSONObject facts) { if (wb == null || facts == null) { @@ -1798,59 +3360,151 @@ public final class QuoteTemplateExcelSupport { if (labelCol < 0) { continue; } - Cell target = null; - List extras = new ArrayList<>(); short last = row.getLastCellNum(); - for (int c = labelCol + 1; c < last; c++) { + // 海运「费用总计」行:币别进币别列、金额进金额列(与费目表头对齐),禁止写到标签旁 B 列 + boolean seaTotalRow = textLooksSeaFeeTotalLabel(row, labelCol); + if (seaTotalRow) { + Map cols = findNearbyFeeColumns(sheet, r); + int ccyCol = cols.containsKey("currency") ? cols.get("currency") : 1; + int amtCol = cols.containsKey("amount") ? cols.get("amount") : 7; + // 模板常把 C44:J44 整行合并,金额只能进合并原点;拆开后才能分写币别/金额列 + unmergeRowCellsSpanning(sheet, r, ccyCol, amtCol); + String ccy = firstNonBlank(facts.getString("currency"), "CNY"); + String upper = total.trim().toUpperCase(Locale.ROOT); + if (upper.startsWith("CNY") || upper.startsWith("USD") || upper.startsWith("RMB")) { + ccy = total.trim().split("\\s+")[0].toUpperCase(Locale.ROOT); + if ("RMB".equals(ccy)) { + ccy = "CNY"; + } + } + writeMappedText(sheet, r, ccyCol, ccy, null); + if (num != null) { + writeAmountCell(sheet, r, amtCol, String.valueOf(num), null); + } else { + writeMappedText(sheet, r, amtCol, norm, null); + } + // 清掉标签右侧误写的金额(旧逻辑写在 B);勿清币别/金额列及其合并原点 + int[] amtOrigin = resolveMergeOrigin(sheet, r, amtCol); + int[] ccyOrigin = resolveMergeOrigin(sheet, r, ccyCol); + for (int c = labelCol + 1; c < Math.min((int) last, Math.max(amtCol, 10)); c++) { + int[] dest = resolveMergeOrigin(sheet, r, c); + if ((dest[0] == amtOrigin[0] && dest[1] == amtOrigin[1]) + || (dest[0] == ccyOrigin[0] && dest[1] == ccyOrigin[1]) + || c == ccyCol || c == amtCol) { + continue; + } + Cell cell = row.getCell(dest[1]); + if (cell == null) { + continue; + } + String exist = readCellText(cell); + if (looksLikeMoneyText(exist) || cell.getCellType() == CellType.NUMERIC + || cell.getCellType() == CellType.FORMULA) { + clearEstimateExtraCell(row, cell); + } + } + // #region agent log + agentDbg("T3", "overwriteEstimateTotal.sea", "wrote total at fee columns", + "{\"row\":" + r + ",\"ccyCol\":" + ccyCol + ",\"amtCol\":" + amtCol + + ",\"ccy\":\"" + ccy + "\",\"amt\":\"" + norm.replace("\"", "'") + "\"}"); + // #endregion + continue; + } + // 空运:优先写到费目表「金额」列(常为 G),避免写到标签旁 B 而 SUM 仍在 G + Map feeCols = findNearbyFeeColumns(sheet, r); + int preferAmtCol = feeCols.containsKey("amount") ? feeCols.get("amount") : -1; + int scanLast = Math.max(last < 0 ? 0 : (int) last, Math.max(preferAmtCol + 1, 12)); + int targetCol = -1; + List extraCols = new ArrayList<>(); + for (int c = labelCol + 1; c < scanLast; c++) { int[] dest = resolveMergeOrigin(sheet, r, c); Cell cell = row.getCell(dest[1]); if (cell == null) { continue; } String exist = readCellText(cell); + String formulaText = cell.getCellType() == CellType.FORMULA + ? StringUtils.trimToEmpty(cell.getCellFormula()) + : ""; if (isFeeTableHeader(exist) || isLabelLike(exist) || isSectionTitle(sheet, cell)) { continue; } - if (cell.getCellType() == CellType.FORMULA) { - if (target == null) { - target = cell; + boolean sumLike = cell.getCellType() == CellType.FORMULA + || formulaText.toUpperCase(Locale.ROOT).contains("SUM") + || (exist.startsWith("=") && exist.toUpperCase(Locale.ROOT).contains("SUM")); + if (sumLike) { + if (targetCol < 0 || dest[1] == preferAmtCol) { + if (targetCol >= 0 && targetCol != dest[1]) { + extraCols.add(targetCol); + } + targetCol = dest[1]; } else { - extras.add(cell); + extraCols.add(dest[1]); } continue; } - if (cell.getCellType() == CellType.NUMERIC - || looksLikeMoneyText(exist)) { - extras.add(cell); + if (cell.getCellType() == CellType.NUMERIC || looksLikeMoneyText(exist)) { + extraCols.add(dest[1]); } } - if (target == null && !extras.isEmpty()) { + if (targetCol < 0 && preferAmtCol >= 0) { + targetCol = preferAmtCol; + } + if (targetCol < 0 && !extraCols.isEmpty()) { // 无公式时取最右金额格作为唯一总价格 - target = extras.remove(extras.size() - 1); + targetCol = extraCols.remove(extraCols.size() - 1); } - if (target == null) { - // 标签右侧尚无格:落在金额列常见位置(与费目表头对齐时由调用方保证) - target = row.getCell(labelCol + 1); - if (target == null) { - target = row.createCell(labelCol + 1); - } + if (targetCol < 0) { + targetCol = labelCol + 1; } if (num != null) { - target.setCellValue(num.doubleValue()); + // 与 writeAmountCell 相同:先删公式再写,避免 XSSF 残留 SUM + writeAmountCell(sheet, r, targetCol, String.valueOf(num), null); } else { - target.setCellValue(norm); + writeMappedText(sheet, r, targetCol, norm, null); } - for (Cell extra : extras) { - if (extra == null || extra == target) { + int[] targetOrigin = resolveMergeOrigin(sheet, r, targetCol); + for (Integer extraCol : extraCols) { + if (extraCol == null || extraCol == targetCol || extraCol == targetOrigin[1]) { continue; } - // POI 旧版无 setBlank;清空即可,避免同行残留第二份总价 - extra.setCellValue(""); + int[] dest = resolveMergeOrigin(sheet, r, extraCol); + if (dest[1] == targetOrigin[1]) { + continue; + } + Cell extra = row.getCell(dest[1]); + clearEstimateExtraCell(row, extra); } + // #region agent log + agentDbg("T3", "overwriteEstimateTotal.air", "wrote tms total over SUM", + "{\"row\":" + r + ",\"col\":" + targetCol + + ",\"amt\":\"" + norm.replace("\"", "'") + "\"}"); + // #endregion } } } + /** + * 清掉预估费用行上多余金额/公式格。公式格必须先 remove,否则只改缓存仍显示 SUM。 + */ + private static void clearEstimateExtraCell(Row row, Cell extra) { + if (row == null || extra == null) { + return; + } + if (extra.getCellType() == CellType.FORMULA) { + int col = extra.getColumnIndex(); + CellStyle style = extra.getCellStyle(); + row.removeCell(extra); + Cell blank = row.createCell(col); + if (style != null) { + blank.setCellStyle(style); + } + blank.setCellValue(""); + return; + } + extra.setCellValue(""); + } + /** 是否像金额文本(含小数或纯数字),用于清掉预估费用行上误写的第二份数。 */ private static boolean looksLikeMoneyText(String raw) { if (StringUtils.isBlank(raw) || "-".equals(raw.trim())) { @@ -1868,6 +3522,58 @@ public final class QuoteTemplateExcelSupport { } } + /** + * 海运费用总计行:标签含「费用总计/Total Costs」,且同行或上方有费用表头结构。 + * 空运「预估总价」仍走原 SUM/标签旁逻辑。 + */ + static boolean textLooksSeaFeeTotalLabel(Row row, int labelCol) { + if (row == null || labelCol < 0) { + return false; + } + Cell label = row.getCell(labelCol); + String t = readCellText(label); + if (StringUtils.isBlank(t)) { + return false; + } + if (!(t.contains("费用总计") || t.toLowerCase(Locale.ROOT).contains("total cost"))) { + return false; + } + // 预估费用/预估总价是空运模板用语,不走海运双列写入 + if (t.contains("预估费用") || t.contains("预估总价")) { + return false; + } + Sheet sheet = row.getSheet(); + Map cols = findNearbyFeeColumns(sheet, row.getRowNum()); + return cols.containsKey("amount") || cols.containsKey("currency"); + } + + /** + * 拆掉覆盖 [fromCol, toCol] 的同行合并区,使币别列与金额列可分别写入。 + */ + static void unmergeRowCellsSpanning(Sheet sheet, int rowIdx, int fromCol, int toCol) { + if (sheet == null || rowIdx < 0) { + return; + } + int lo = Math.min(fromCol, toCol); + int hi = Math.max(fromCol, toCol); + for (int i = sheet.getNumMergedRegions() - 1; i >= 0; i--) { + org.apache.poi.ss.util.CellRangeAddress range = sheet.getMergedRegion(i); + if (range == null) { + continue; + } + if (range.getFirstRow() > rowIdx || range.getLastRow() < rowIdx) { + continue; + } + if (range.getLastColumn() < lo || range.getFirstColumn() > hi) { + continue; + } + // 只拆横向跨币别~金额的合并,保留标签自身纵向合并 + if (range.getLastColumn() - range.getFirstColumn() >= 1) { + sheet.removeMergedRegion(i); + } + } + } + /** * 回填后重算公式,把 SUM 等缓存从 0 刷成真实合计。 * 失败不抛:出单仍要发出,只记日志。 @@ -1945,6 +3651,13 @@ public final class QuoteTemplateExcelSupport { } String name = StringUtils.trimToEmpty(rowObj.getString("name")); String amount = StringUtils.trimToEmpty(rowObj.getString("amount")); + // #region agent log + if (StringUtils.isNotBlank(name)) { + agentDbg("H3", "writeFeeAmountsByName", "fee row candidate", + "{\"name\":\"" + name.replace("\"", "'") + "\",\"amount\":\"" + + amount.replace("\"", "'") + "\"}"); + } + // #endregion if (StringUtils.isBlank(name) || StringUtils.isBlank(amount) || "-".equals(amount)) { continue; } @@ -2519,6 +4232,14 @@ public final class QuoteTemplateExcelSupport { } private static void writeCell(Workbook wb, String cellRef, String value) { + // #region agent log + if (StringUtils.isNotBlank(value) && (value.contains("广州") || value.contains("Guangzhou") + || "广州".equals(value.trim()))) { + agentDbg("H5", "writeCell", "cell write pol-like", + "{\"cellRef\":\"" + String.valueOf(cellRef).replace("\"", "'") + + "\",\"val\":\"" + value.replace("\"", "'") + "\"}"); + } + // #endregion ParsedRef ref = ParsedRef.parse(cellRef); Sheet sheet = wb.getSheet(ref.sheet); if (sheet == null && wb.getNumberOfSheets() > 0) { @@ -2645,15 +4366,16 @@ public final class QuoteTemplateExcelSupport { private static String resolveFillTarget(Sheet sheet, Cell labelCell, String sheetName, String text, DataFormatter fmt) { int idx = Math.max(text.indexOf(':'), text.indexOf(':')); // 「RFQ No:」或冒号后已有样例:值写在本格冒号后,不跨到右侧单选/标题合并格 + // 注意:CellReference(Cell) 的 formatAsString 可能已带 sheet 名,禁止再拼 sheetName! 否则变成 Sheet2!Sheet2!B6 if (idx >= 0) { String code = matchLabel(text.replace(":", ":").trim(), FILL_BY_LABEL); if ("quote_no".equals(code) || text.toUpperCase(Locale.ROOT).contains("RFQ") || text.contains("编号")) { - return sheetName + "!" + new CellReference(labelCell).formatAsString(); + return sheetName + "!" + new CellReference(labelCell.getRowIndex(), labelCell.getColumnIndex()).formatAsString(); } if (idx < text.length() - 1 && StringUtils.isNotBlank(text.substring(idx + 1).trim())) { - return sheetName + "!" + new CellReference(labelCell).formatAsString(); + return sheetName + "!" + new CellReference(labelCell.getRowIndex(), labelCell.getColumnIndex()).formatAsString(); } } int rowIdx = labelCell.getRowIndex(); @@ -2678,6 +4400,11 @@ public final class QuoteTemplateExcelSupport { String exist = readCellText(cand); if (isFeeTableHeader(exist) || isLabelLike(exist) || isSectionTitle(sheet, cand) || isUnsuitableValueCell(exist)) { + // 左右双栏:已找到左侧候选后,遇到右侧标签必须停,禁止再跨到送货地址值区 + if ((firstEmpty != null || wideEmpty != null) + && (isLabelLike(exist) || isSectionTitle(sheet, cand))) { + break; + } continue; } String ref = sheetName + "!" + new CellReference(dest[0], dest[1]).formatAsString(); @@ -2722,8 +4449,21 @@ public final class QuoteTemplateExcelSupport { } boolean feeHeader = !parseFeeHeaderRow(sheet, r).isEmpty(); // 费用明细数据行:「起运港/目的港」是项目列值,不是询价标签; - // 否则会把 pol=ZUH 写进右侧「费用名称」,盖掉提送货费/人工费。 + // 否则会把 pol 写进右侧「费用名称」(WO202609230026:广州写入 B18)。 if (!feeHeader && isFeeDetailDataRow(sheet, r)) { + // #region agent log + Row peek = sheet.getRow(r); + String peek0 = peek == null ? "" : StringUtils.trimToEmpty( + new DataFormatter().formatCellValue(peek.getCell(0))); + String peek1 = peek == null ? "" : StringUtils.trimToEmpty( + new DataFormatter().formatCellValue(peek.getCell(1))); + if (peek0.contains("起运港") || peek1.contains("起运港") + || peek0.contains("空运段") || peek1.contains("空运段")) { + agentDbg("H1", "writeFactsByLabel.skipFeeDetail", "skipped fee detail row", + "{\"row\":" + r + ",\"c0\":\"" + peek0.replace("\"", "'") + + "\",\"c1\":\"" + peek1.replace("\"", "'") + "\"}"); + } + // #endregion continue; } short lastCol = row.getLastCellNum(); @@ -2744,6 +4484,10 @@ public final class QuoteTemplateExcelSupport { if ("total_amount".equals(code)) { continue; } + // 海运运输分类由顶部 Form Control 勾选,禁止再写单元格字(写进 B3 锚点会空白) + if ("运输分类".equals(code) && sheetLooksLikeSeaQuote(sheet)) { + continue; + } if ("currency".equals(code) && feeHeader) { continue; } @@ -2755,6 +4499,30 @@ public final class QuoteTemplateExcelSupport { if (StringUtils.isBlank(fillRef)) { continue; } + // 硬防护:pol/pod 等港口字段禁止写入费用「名称」列(无 TMS 时曾把「广州」写进费用名称) + if (isPortFactCode(code) && isFeeNameColumnRef(sheet, fillRef)) { + // #region agent log + agentDbg("H1", "writeFactsByLabel.blockFeeName", "blocked pol/pod into fee name col", + "{\"row\":" + r + ",\"code\":\"" + code + "\",\"fillRef\":\"" + fillRef + + "\",\"text\":\"" + text.replace("\"", "'") + + "\",\"val\":\"" + val.replace("\"", "'") + "\"}"); + // #endregion + continue; + } + // #region agent log + if ("pol".equals(code) || text.contains("起运港") + || "fcl_or_lcl".equals(code) || text.contains("整柜") + || "运输分类".equals(code) || text.contains("送货地址")) { + boolean feeDetail = isFeeDetailDataRow(sheet, r); + agentDbg("S1", "writeFactsByLabel.sea", "label matched sea-sensitive", + "{\"sheet\":\"" + sheetName + "\",\"row\":" + r + ",\"col\":" + c + + ",\"text\":\"" + text.replace("\"", "'") + + "\",\"code\":\"" + code + "\",\"fillRef\":\"" + fillRef + + "\",\"feeHeader\":" + feeHeader + + ",\"feeDetailDataRow\":" + feeDetail + + ",\"val\":\"" + val.replace("\"", "'") + "\"}"); + } + // #endregion boolean labelAfterColon = textContainsColon(text) && ("quote_no".equals(code) || "quote_date".equals(code) || "valid_until".equals(code)); // 编号/日期类:标签同格冒号后允许写黄区;其它字段仍跳过固定区 @@ -2777,6 +4545,146 @@ public final class QuoteTemplateExcelSupport { } } + /** 港口/OD 类事实码:禁止误写入费用名称列。 */ + static boolean isPortFactCode(String code) { + return "pol".equals(code) || "pod".equals(code) || "origin".equals(code) || "destination".equals(code) + || "起运港".equals(code) || "目的港".equals(code) || "起运地".equals(code) || "目的地".equals(code); + } + + /** + * 出单收尾:若费用名称格内容等于起运港/目的港事实值,清空。 + * 覆盖「跳过逻辑偶发未命中」或模板样例残留;有真实费目名(运输费等)不会被清。 + */ + static void clearPortSpillInFeeNameColumn(Workbook wb, JSONObject facts) { + if (wb == null || facts == null) { + return; + } + Set ports = new LinkedHashSet<>(); + for (String k : new String[]{"pol", "pod", "origin", "destination", "起运港", "目的港"}) { + String v = StringUtils.trimToEmpty(facts.getString(k)); + if (StringUtils.isNotBlank(v) && !"-".equals(v)) { + ports.add(v); + } + } + if (ports.isEmpty()) { + return; + } + DataFormatter fmt = new DataFormatter(); + int cleared = 0; + for (int s = 0; s < wb.getNumberOfSheets(); s++) { + Sheet sheet = wb.getSheetAt(s); + if (sheet == null) { + continue; + } + for (int r = 0; r <= sheet.getLastRowNum(); r++) { + // 与 isFeeNameColumnRef 一致:表头下方即费用区,不单独依赖 isFeeDetailDataRow + boolean underHeader = false; + for (int hr = r - 1; hr >= Math.max(0, r - 40); hr--) { + if (!parseFeeHeaderRow(sheet, hr).isEmpty()) { + underHeader = true; + break; + } + } + if (!underHeader) { + continue; + } + // 汇总行不清 + Row row = sheet.getRow(r); + if (row == null) { + continue; + } + boolean totalRow = false; + for (Cell cell : row) { + if (cell == null) { + continue; + } + String t = StringUtils.trimToEmpty(fmt.formatCellValue(cell)); + if (t.contains("预估费用") || t.contains("预估总价") || t.contains("费用总计") + || t.contains("报价合计")) { + totalRow = true; + break; + } + } + if (totalRow) { + continue; + } + Map cols = findFeeTableColumnsAbove(sheet, r); + Integer nameCol = cols.get("name"); + if (nameCol == null) { + Integer projectCol = cols.get("project"); + if (projectCol != null) { + nameCol = projectCol + 1; + } + } + if (nameCol == null) { + continue; + } + Cell cell = row.getCell(nameCol); + if (cell == null) { + continue; + } + String text = StringUtils.trimToEmpty(fmt.formatCellValue(cell)); + if (ports.contains(text)) { + cell.setCellValue(""); + cleared++; + } + } + } + // #region agent log + if (cleared > 0) { + agentDbg("H1", "clearPortSpillInFeeNameColumn", "cleared port spill in fee names", + "{\"cleared\":" + cleared + "}"); + } + // #endregion + } + + /** + * fillRef 是否落在费用表「费用名称/name」列(含仅有「项目」当 name 兜底的海运表)。 + * 用于挡住无 TMS 时把起运港名写进费用名称。 + */ + static boolean isFeeNameColumnRef(Sheet sheet, String fillRef) { + if (sheet == null || StringUtils.isBlank(fillRef)) { + return false; + } + try { + ParsedRef ref = ParsedRef.parse(fillRef); + if (ref == null || ref.row < 0 || ref.col < 0) { + return false; + } + // 不依赖 isFeeDetailDataRow:表头识别偶发失败时仍要挡住 name 列 + Map cols = findFeeTableColumnsAbove(sheet, ref.row); + if (cols.isEmpty()) { + // 向上 40 行内找不到表头则再全表扫一次 + cols = findFeeTableColumns(sheet); + } + if (cols.isEmpty()) { + return false; + } + // 目标行必须在某个费用表头之下,避免误伤货物区 B 列 + boolean underHeader = false; + for (int r = ref.row - 1; r >= Math.max(0, ref.row - 40); r--) { + if (!parseFeeHeaderRow(sheet, r).isEmpty()) { + underHeader = true; + break; + } + } + if (!underHeader) { + return false; + } + Integer nameCol = cols.get("name"); + if (nameCol != null && nameCol == ref.col) { + return true; + } + Integer projectCol = cols.get("project"); + if (projectCol != null && ref.col == projectCol + 1) { + return true; + } + return false; + } catch (Exception e) { + return false; + } + } + /** 是否含中英文冒号(标签同格回填用)。 */ private static boolean textContainsColon(String text) { return text != null && (text.contains(":") || text.contains(":")); @@ -2841,7 +4749,10 @@ public final class QuoteTemplateExcelSupport { if (dk.equalsIgnoreCase(compact) || compact.equals(dk)) { return e.getValue(); } - if (dk.length() >= 3 && (compact.startsWith(dk) || dk.startsWith(compact))) { + // 只认「格子文字以词典标签开头」(如「整柜或拼柜/FCL…」→ 整柜或拼柜)。 + // 禁止反向:词典标签以格子开头(「整柜或拼柜」startsWith「整柜」), + // 否则第一次写入的「整柜」会被二次扫描当成标签,跨栏写进送货地址(WO202609240027)。 + if (dk.length() >= 3 && compact.length() >= dk.length() && compact.startsWith(dk)) { return e.getValue(); } } diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateSnapshotRenderer.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateSnapshotRenderer.java index 3f99e39..33b206d 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateSnapshotRenderer.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteTemplateSnapshotRenderer.java @@ -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。 *

+ * 为何不用 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} 像素,避免预览弹窗底下留一大块空白。 + *

+ * 行/列允许极少量杂点(浅网格线);写出前必须拷贝到新 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 无界面转换;已在文件锁内,禁止再开并行实例。 */ diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java index e0a9643..d902bc3 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinService.java @@ -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 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 option = resolveOption(quote, optionNo); if (option == null) { option = new LinkedHashMap<>(); } - if (manual && StringUtils.isBlank(text(option, "optionNo", "option_no"))) { - // 手工价往往只有金额。舱位、航班仍用本单那条 TMS 报价,不另编编号。 - Map 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 tmsOption = tmsOptionForLock(rows, no, wantOption); + if (!tmsOption.isEmpty()) { + option = mergeLockOption(option, tmsOption); } - String resolvedOption = text(option, "optionNo", "option_no"); + Map 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 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 合同。 + *

+ * 必带: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 req, Map quote, Map option) { + static void fillManualLock(Map req, Map quote, + Map option, Map 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 查价回包原样带回。 + *

+ * 查价落库常是 {@code valid_until=2026-09-30}(仅日期);锁舱要带时区的 ISO, + * 否则 TMS 回 1001「参数格式错误」。费用优先 {@code airOptions[].departureCharges} / + * {@code feeItems} 原文,禁止用本地改写过的展示名替换。 + */ + static void fillLockQuoteExtras(Map req, Map quote, Map 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> departure = feeRows(quote, option, "departureCharges", "departure_charges"); List> fees = feeRows(quote, option, "feeItems", "fee_items"); splitFeeRows(quote, departure, fees); @@ -379,6 +471,57 @@ public class InquiryTmsCabinService { } } + /** + * 锁舱 validUntil / requestedUntil:规范成带时区的 ISO8601。 + *

    + *
  • {@code 2026-09-30} → {@code 2026-09-30T23:59:59+08:00}(有效期到当日结束)
  • + *
  • 已有 {@code T} 但无时区 → 补 {@code +08:00}
  • + *
  • 已是完整 ISO → 原样
  • + *
+ */ + 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 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 tmsOptionForLock(List rows, String workOrderNo, String optionNo) { if (rows == null) { @@ -423,7 +582,7 @@ public class InquiryTmsCabinService { } if (StringUtils.isNotBlank(optionNo)) { for (Map 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 tmsQuotePayload(List rows, String workOrderNo) { + if (rows == null) { + return new LinkedHashMap<>(); + } + for (InquiryQuote row : rows) { + Map payload = payloadOf(row); + if (isManualQuote(payload)) { + continue; + } + if (StringUtils.isNotBlank(resolveQuoteId(payload, workOrderNo))) { + return payload; + } + } + return new LinkedHashMap<>(); + } + + /** + * 锁舱舱位:TMS 明细打底,航线点名的 optionNo 盖上去。 + */ + static Map mergeLockOption(Map stub, Map tmsOption) { + Map 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 enrichLockQuoteFromTms(Map quote, Map tmsQuote) { + Map 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 quote) { + List> rows = feeRows(quote, null, "fee_lines", "fee_rows", "feeItems"); + if (rows.isEmpty()) { + return false; + } + for (Map 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 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> toLockFees(List> rows, boolean departure) { List> out = new ArrayList<>(); @@ -559,7 +815,9 @@ public class InquiryTmsCabinService { continue; } Map 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 ordered = new LinkedHashMap<>(); + ordered.put("feeCode", item.get("feeCode")); + ordered.put("feeName", item.get("feeName")); + for (Map.Entry 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 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 quote, String optionNo) { + List> options = airOptions(quote); + if (options.isEmpty()) { + return ""; + } + String want = StringUtils.trimToEmpty(optionNo); + String lone = ""; + int withFlight = 0; + for (Map 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 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) raw, "flightNo", "flight_no")); + } + return ""; + } + /** * 有舱位按名称挑。没有舱位时,航线已指定的编号原样带上,不编 AIR-OPT-01。 + * 报价数组对不上时仍保留航线说的 optionNo,避免出站空编号。 */ static Map resolveOption(Map quote, String optionNo) { List> 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 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 pickOption(Map quote, String optionNo) { List> options = airOptions(quote); diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java index 49bf59d..1ab13b8 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java @@ -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 盖住「托盘」。 diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsCabinRequestMapper.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsCabinRequestMapper.java index b7c0db3..a6bc2af 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsCabinRequestMapper.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsCabinRequestMapper.java @@ -13,9 +13,11 @@ import java.util.UUID; *

* 锁舱唯一键: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 lock(Map internal) { Map 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")); diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapper.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapper.java index bd42efb..2acbf5f 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapper.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsLandSegmentMapper.java @@ -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)) { // 中港整车:询价不要车型/数量,出站也不带 diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapper.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapper.java index 6cd55e0..cd891e6 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapper.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapper.java @@ -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 小数且 >0。销售原文「100KGS」「2CBM」只取数字。 + * 毛重等:JSON 小数且 >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; diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_ctrlProp1_good.xml b/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_ctrlProp1_good.xml new file mode 100644 index 0000000..1060226 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_ctrlProp1_good.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_ctrlProp1_wo011_other_excel.xml b/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_ctrlProp1_wo011_other_excel.xml new file mode 100644 index 0000000..1060226 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_ctrlProp1_wo011_other_excel.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_ctrlProp2_good.xml b/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_ctrlProp2_good.xml new file mode 100644 index 0000000..0253c1c --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_ctrlProp2_good.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_ctrlProp2_wo011_other_excel.xml b/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_ctrlProp2_wo011_other_excel.xml new file mode 100644 index 0000000..0253c1c --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_ctrlProp2_wo011_other_excel.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_drawing1_good.xml b/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_drawing1_good.xml new file mode 100644 index 0000000..a2a9b86 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_drawing1_good.xml @@ -0,0 +1,2 @@ + +14318021968543409952431165 Port to Port, 1工作天回复 其它, 3工作天回复 \ No newline at end of file diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_drawing1_wo011.xml b/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_drawing1_wo011.xml new file mode 100644 index 0000000..56189fe --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_drawing1_wo011.xml @@ -0,0 +1,2 @@ + +14318021968543409952431165 Port to Port, 1工作天回复 其它, 3工作天回复 \ No newline at end of file diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_drawing1_wo011_other_excel.xml b/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_drawing1_wo011_other_excel.xml new file mode 100644 index 0000000..76f6b55 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_drawing1_wo011_other_excel.xml @@ -0,0 +1,2 @@ + +14318021968543409952431165 Port to Port, 1工作天回复 其它, 3工作天回复 \ No newline at end of file diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_sharedStrings_wo011.xml b/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_sharedStrings_wo011.xml new file mode 100644 index 0000000..1d43a34 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_sharedStrings_wo011.xml @@ -0,0 +1,4 @@ + +表单编号:PC-RFQ-S01国际海运询价单RFQ No:*询价日期/Inquiry Date:起运港/POL(有指定起运港口的必填)*品名/Commodity:目的港/POD(有指定目的港口的必填)*货物数量/Quantity (或提供装箱单/Packing List)提货地址/Place of Receipt(门到门或门到港的必填)*整柜或拼柜/FCL or LCL:送货地址/Place of Delivery(门到门或港到门的必填)*箱型箱量/Type & Qty of Cont.:要求货物抵达时间/Requested Arrival Time*贸易条款/Terms:要求运输时效/Requested Transit Time*货好时间/Cargo Ready Date:要求特定时间的船期/Requested Shipping Schedule*货值/Cargo Value:开船日/On Board Date(有信用证押汇要求的必填)*商品海关编码/HS Code:要求特定船公司/Requested Shipping Line*是否含油,电,磁/OIL,ELEC,MAG:其他要求/Other Request:特别说明/Special Remarks:报价有效期至/period:航程时间/Navigation time:始发港费用/Origin Charges项目/Items币别/Cur.箱型 +(动态填充箱型)单位/Unit数量/Qty.金额/Ammount备注始发港费用汇总/POL Local Charges海运费用/Ocean Freight船司/Shipping Company海运费用汇总/OF目的港费用/Destination Charges目的港费用汇总/POD Local Charges费用总计/Total Costs关税及增值税说明/T&VAT Desc.委托单位公司名称/Company Name联络人/Contact:报价单位/ Quotation CompanyCompany/Stamp机器设备15件整柜DOOR-DOORN+52026-09-3014298.532026-09-28表单编号:WO20260928001140HQ*1RFQ No:WO202609280011广州港巴生港箱型 +(40HQ)操作费柜CNY40HQ箱单费VGM申报费封条费(SEAI)文件费票电放费更改费报关费海运费USD进口集装箱检验费(CLTY)电子数据交换费(EDI)进口设备监控费(EMCI)进仓操作费 \ No newline at end of file diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_sheet1_good.xml b/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_sheet1_good.xml new file mode 100644 index 0000000..e63aef0 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_sheet1_good.xml @@ -0,0 +1,2 @@ + +0123456789101112131415161718192021222324252627282930313233343536372728293031323334383940D31*G310414227282930313233343543D36*G3604404504604704849SUM(D36:D42)0SUM(H36:H42)05051525354555654144450219685434099522057401431802169545426162030 \ No newline at end of file diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_sheet1_wo011.xml b/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_sheet1_wo011.xml new file mode 100644 index 0000000..29596a5 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_sheet1_wo011.xml @@ -0,0 +1,2 @@ + +5515735445854765974889491011561213505115161718192021222324522526272860303132336264651220.0631.01220.06166646550.0631.050.06167646530.0631.030.06168646535.0631.035.061696465450.0701.0450.061716465100.0701.0100.061726465450.0701.0450.0617364650.0701.00.0616161616161616134642335.035272860303132367475651250.0631.01250.0616161616161616137648778.75382728603031323376756580.0631.080.06169756550.0701.050.06177756520.0701.020.06178756535.0631.035.061627565240.0631.0240.061796465200.0631.0200.0616161616161616139643184.78406414298.5341424344454644145085219685434099522089151431802172085421844030 \ No newline at end of file diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_styles_good.xml b/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_styles_good.xml new file mode 100644 index 0000000..ab74cd4 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_styles_good.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_styles_wo011.xml b/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_styles_wo011.xml new file mode 100644 index 0000000..e35f1fa --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_styles_wo011.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_vmlDrawing1_good.vml b/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_vmlDrawing1_good.vml new file mode 100644 index 0000000..003e43f --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_vmlDrawing1_good.vml @@ -0,0 +1,9 @@ + + + + + + + +

 Port to Port, 1工作天回复

1,5,2,2,4,44,2,261Center

 其它, 3工作天回复

1,5,2,22,4,34,3,010Center
\ No newline at end of file diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_vmlDrawing1_wo011.vml b/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_vmlDrawing1_wo011.vml new file mode 100644 index 0000000..c5e7e5b --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_vmlDrawing1_wo011.vml @@ -0,0 +1,6 @@ + + + + + +

 Port to Port, 1工作天回复

11,5,2,2,4,44,2,271Center

 其它, 3工作天回复

1,5,2,22,4,28,3,00Center
\ No newline at end of file diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_vmlDrawing1_wo011_other.vml b/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_vmlDrawing1_wo011_other.vml new file mode 100644 index 0000000..ae9bd57 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_vmlDrawing1_wo011_other.vml @@ -0,0 +1,6 @@ + + + + + +

 Port to Port, 1工作天回复

1,5,2,2,4,44,2,271Center

 其它, 3工作天回复

11,5,2,22,4,28,3,00Center
\ No newline at end of file diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_vmlDrawing1_wo011_other_excel.vml b/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_vmlDrawing1_wo011_other_excel.vml new file mode 100644 index 0000000..5838515 --- /dev/null +++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/inquiry/template/sea_vmlDrawing1_wo011_other_excel.vml @@ -0,0 +1,9 @@ + + + + + + + +

 Port to Port, 1工作天回复

1,5,2,2,4,44,2,271Center

 其它, 3工作天回复

1,5,2,22,4,28,3,010Center
\ No newline at end of file diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java index 2b79add..d5b25d9 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteTemplateFillTest.java @@ -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 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 bodies = new java.util.ArrayList<>(); + java.util.regex.Matcher vm = java.util.regex.Pattern.compile( + "([\\s\\S]*?)", + 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(""), "ClientData 首钮须 Checked"); + assertFalse(bodies.get(1).contains(""), "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( + "([\\s\\S]*?)", + 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("1"); + boolean checked = body.contains(""); + 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 readDrawingTexts(Path xlsx) throws Exception { + java.util.List 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("([^<]+)").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 facts = new HashMap<>(); + facts.put("运输方式", "空运"); + facts.put("品名", "普货"); + Map charge = new HashMap<>(); + charge.put("chargeName", "增点费"); + charge.put("unitPrice", 100.0); + charge.put("currency", "CNY"); + charge.put("unit", "点"); + Map 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 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 facts = new HashMap<>(); + facts.put("运输方式", "空运"); + facts.put("品名", "普货"); + Map option = new HashMap<>(); + option.put("optionNo", "AIR-OPT01"); + option.put("salePrice", 7918.25); + option.put("currency", "CNY"); + Map 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() {{ + put("name", "增点费"); + put("amount", "100"); + put("currency", "CNY"); + }}, + new HashMap() {{ + 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; diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java index 198c8f6..b55c21c 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsCabinServiceTest.java @@ -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 quote = new HashMap<>(); + quote.put("valid_until", "2026-09-30"); + Map option = new HashMap<>(); + Map 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 fee = new HashMap<>(); + fee.put("feeName", "空运费"); + fee.put("unitPrice", 18); + fee.put("currency", "CNY"); + quote.put("feeItems", java.util.Arrays.asList(fee)); + + Map req = new HashMap<>(); + InquiryTmsCabinService.fillLockQuoteExtras(req, quote, option); + + assertEquals("2026-09-30T23:59:59+08:00", req.get("validUntil")); + @SuppressWarnings("unchecked") + java.util.List> dep = + (java.util.List>) req.get("departureCharges"); + assertEquals("运输费", dep.get(0).get("chargeName")); + @SuppressWarnings("unchecked") + java.util.List> fees = + (java.util.List>) 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 quote = new HashMap<>(); + Map 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 quote = new HashMap<>(); + Map 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 manual = new HashMap<>(); + manual.put("source", "PRODUCT"); + Map fee = new HashMap<>(); + fee.put("name", "OPT"); + fee.put("amount", "01"); + manual.put("fee_lines", java.util.Arrays.asList(fee)); + Map tms = new HashMap<>(); + tms.put("valid_until", "2026-09-30"); + tms.put("chargeableWeightKg", "66.68"); + Map 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 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 req = new HashMap<>(); + req.put("quoteId", "AIR-1-2"); + Map 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")); + } } diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsCabinRequestMapperTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsCabinRequestMapperTest.java index 6fb663e..14f7942 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsCabinRequestMapperTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsCabinRequestMapperTest.java @@ -89,4 +89,23 @@ public class TmsCabinRequestMapperTest { assertEquals("2026-09-16", body.get("flightDate")); assertEquals("AIRLINE", body.get("operatorRole")); } + + @Test + public void lockNormalizesValidUntilToIso() { + Map internal = new LinkedHashMap<>(); + internal.put("workOrderNo", "WO202609290004"); + internal.put("source", "SYSTEM"); + internal.put("validUntil", "2026-09-30"); + Map charge = new LinkedHashMap<>(); + charge.put("chargeName", "运输费"); + charge.put("unitPrice", new BigDecimal("11")); + internal.put("departureCharges", java.util.Collections.singletonList(charge)); + Map 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> dep = + (java.util.List>) body.get("departureCharges"); + assertEquals("运输费", dep.get(0).get("chargeName")); + } } diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapperTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapperTest.java index f0ce881..375dc6b 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapperTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapperTest.java @@ -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 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 internal = new HashMap<>(); + internal.put("transportMode", "AIR"); + internal.put("quoteDate", "2026-10-08"); + internal.put("segments", Collections.singletonList(seg)); + Map body = TmsV2RequestMapper.quote(internal); + @SuppressWarnings("unchecked") + List> segs = (List>) body.get("segments"); + assertEquals(new java.math.BigDecimal("3.96"), segs.get(0).get("volumeCbm")); + } } diff --git a/inquiry-api/pom.xml b/inquiry-api/pom.xml index 907fc86..52f21f1 100644 --- a/inquiry-api/pom.xml +++ b/inquiry-api/pom.xml @@ -30,6 +30,8 @@ 3.5.3 1.8 UTF-8 + + true 2021.0.3 @@ -367,7 +369,7 @@ org.apache.maven.plugins maven-surefire-plugin - true + ${skipTests} diff --git a/inquiry-backend/src/store/modules/permission.ts b/inquiry-backend/src/store/modules/permission.ts index e8e880e..2c3d5d7 100644 --- a/inquiry-backend/src/store/modules/permission.ts +++ b/inquiry-backend/src/store/modules/permission.ts @@ -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); // 动态引入组件 diff --git a/inquiry-backend/src/views/inquiry/styles/admin-fidelity.css b/inquiry-backend/src/views/inquiry/styles/admin-fidelity.css index 28f6988..a1fd4e2 100644 --- a/inquiry-backend/src/views/inquiry/styles/admin-fidelity.css +++ b/inquiry-backend/src/views/inquiry/styles/admin-fidelity.css @@ -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; diff --git a/inquiry-backend/src/views/inquiry/styles/admin-prototype.css b/inquiry-backend/src/views/inquiry/styles/admin-prototype.css index d771d36..18205c5 100644 --- a/inquiry-backend/src/views/inquiry/styles/admin-prototype.css +++ b/inquiry-backend/src/views/inquiry/styles/admin-prototype.css @@ -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; diff --git a/inquiry-backend/src/views/inquiry/template/index.vue b/inquiry-backend/src/views/inquiry/template/index.vue index 6dd49f5..5008ea0 100644 --- a/inquiry-backend/src/views/inquiry/template/index.vue +++ b/inquiry-backend/src/views/inquiry/template/index.vue @@ -70,6 +70,8 @@ const previewState = reactive({ isImage: true, mappedCount: 0, fileName: "", + /** 浏览器侧已裁过白边,防止 @load 重复裁切 */ + trimmed: false, }); /** 列表「解析状态」点击弹窗:已解析 / 未解析 */ @@ -125,16 +127,103 @@ const formSub = computed(() => : "上传 Excel 后 AI 异步解析映射;失败原因会显示在列表", ); const formOkText = computed(() => (editing.value ? "保存" : "上传并解析")); -const previewFootHint = "按上传原表打印版式截图,比例 1:1 不变形;点击图片可放大。"; +const previewFootHint = "按原表打印版式等比例截图(不拉高/压扁);点击图片可放大。"; const previewLightbox = ref(false); const filtered = computed(() => rows.value); function revokePreviewUrl() { - if (previewState.objectUrl) { + if (previewState.objectUrl && previewState.objectUrl.startsWith("blob:")) { URL.revokeObjectURL(previewState.objectUrl); - previewState.objectUrl = ""; } + previewState.objectUrl = ""; + previewState.trimmed = false; +} + +/** + * 浏览器侧再裁一层近白边:主账若仍带 A4 页底空白,预览也能贴紧内容。 + * 副作用:替换 objectUrl(旧 blob 释放);仅图片预览调用一次。 + */ +function onSnapshotImgLoad(ev: Event) { + if (previewState.trimmed || !previewState.isImage) return; + const img = ev.target as HTMLImageElement | null; + if (!img?.naturalWidth || !img.naturalHeight) return; + try { + const next = cropNearWhiteObjectUrl(img); + if (!next) { + previewState.trimmed = true; + return; + } + const old = previewState.objectUrl; + previewState.trimmed = true; + previewState.objectUrl = next; + if (old && old.startsWith("blob:")) { + URL.revokeObjectURL(old); + } + } catch { + previewState.trimmed = true; + } +} + +/** + * Canvas 裁近白边 → data URL。几乎无内容可裁时返回 null。 + */ +function cropNearWhiteObjectUrl(img: HTMLImageElement): string | null { + const w = img.naturalWidth; + const h = img.naturalHeight; + const canvas = document.createElement("canvas"); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + ctx.drawImage(img, 0, 0); + const data = ctx.getImageData(0, 0, w, h).data; + const isWhite = (i: number) => data[i] >= 240 && data[i + 1] >= 240 && data[i + 2] >= 240; + const rowMostlyWhite = (y: number) => { + const allow = Math.max(2, Math.floor(w / 200)); + let dirty = 0; + const row = y * w * 4; + for (let x = 0; x < w; x++) { + if (!isWhite(row + x * 4)) { + dirty++; + if (dirty > allow) return false; + } + } + return true; + }; + const colMostlyWhite = (x: number, y0: number, y1: number) => { + const allow = Math.max(2, Math.floor((y1 - y0 + 1) / 200)); + let dirty = 0; + for (let y = y0; y <= y1; y++) { + if (!isWhite((y * w + x) * 4)) { + dirty++; + if (dirty > allow) return false; + } + } + return true; + }; + let top = 0; + let bottom = h - 1; + let left = 0; + let right = w - 1; + while (top < h && rowMostlyWhite(top)) top++; + while (bottom > top && rowMostlyWhite(bottom)) bottom--; + while (left < w && colMostlyWhite(left, top, bottom)) left++; + while (right > left && colMostlyWhite(right, top, bottom)) right--; + if (top >= bottom || left >= right || bottom - top + 1 < h / 20) return null; + const pad = 8; + const x = Math.max(0, left - pad); + const y = Math.max(0, top - pad); + const cw = Math.min(w - 1, right + pad) - x + 1; + const ch = Math.min(h - 1, bottom + pad) - y + 1; + if (cw >= w && ch >= h) return null; + const out = document.createElement("canvas"); + out.width = cw; + out.height = ch; + const octx = out.getContext("2d"); + if (!octx) return null; + octx.drawImage(canvas, x, y, cw, ch, 0, 0, cw, ch); + return out.toDataURL("image/png"); } /** @@ -149,6 +238,7 @@ async function openPreview(row: TemplateRow) { previewState.loading = true; previewState.error = ""; previewState.isImage = true; + previewState.trimmed = false; previewState.mappedCount = row.mappingPointCount || 0; previewState.fileName = row.fileName || ""; if (!row.versionId) { @@ -919,6 +1009,7 @@ async function downloadPreviewOrOriginal() { :src="previewState.objectUrl" alt="原表截图预览" title="点击放大" + @load="onSnapshotImgLoad" @click="openLightbox" />