From 97f0219dfdded9d183ae2d4624c3db7d7644c994 Mon Sep 17 00:00:00 2001 From: linmianxun <634841751@qq.com> Date: Mon, 12 Oct 2026 19:22:44 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BA=A7=E5=93=81=E8=B0=83=E4=BB=B7=E7=BB=A7?= =?UTF-8?q?=E6=89=BF=E5=B7=A5=E5=8D=95=E5=B8=81=E7=A7=8D=EF=BC=88=E6=B8=AF?= =?UTF-8?q?=E5=B8=81=E4=B8=8D=E5=86=8D=E9=BB=98=E6=88=90=E7=BE=8E=E5=85=83?= =?UTF-8?q?=EF=BC=89=EF=BC=9B=E9=99=86=E8=BF=90=E9=9B=86=E6=8B=BC=E7=A9=BA?= =?UTF-8?q?=E5=88=86=E6=A1=A3=E5=8F=AA=E5=A1=AB=E8=AF=A2=E4=BB=B7=E9=87=8D?= =?UTF-8?q?=E9=87=8F=E9=82=A3=E4=B8=80=E6=A1=A3=EF=BC=8C=E8=B7=A8=E5=A2=83?= =?UTF-8?q?=E6=95=B4=E8=BD=A6=E6=8C=89=E5=B7=B2=E9=80=89=E7=BA=BF=E8=B7=AF?= =?UTF-8?q?=E5=86=99=E8=B5=B7=E8=AE=AB=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- inquiry-agent/agent/llm/extract_text.py | 81 +++-- inquiry-agent/agent/policy/air_group_ops.py | 11 +- inquiry-agent/agent/policy/multi_group_ops.py | 8 + inquiry-agent/agent/policy/sea_group_ops.py | 50 ++- inquiry-agent/tests/test_land_group_collab.py | 42 +++ .../impl/InquiryQuoteTemplateServiceImpl.java | 8 + .../template/QuoteTemplateExcelSupport.java | 329 +++++++++++++++++- .../template/QuoteTemplateFillTest.java | 196 ++++++++++- 8 files changed, 678 insertions(+), 47 deletions(-) diff --git a/inquiry-agent/agent/llm/extract_text.py b/inquiry-agent/agent/llm/extract_text.py index 3fdcab6..6177bb1 100644 --- a/inquiry-agent/agent/llm/extract_text.py +++ b/inquiry-agent/agent/llm/extract_text.py @@ -11,14 +11,17 @@ import logging import re from typing import Any, Optional -from agent.schema.field_validate import QUOTE_DATE_SYNONYMS +try: + from agent.schema.field_validate import QUOTE_DATE_SYNONYMS +except ImportError: # 测服旧 release 未导出同义词时仍可抽产品价 + QUOTE_DATE_SYNONYMS = () logger = logging.getLogger(__name__) # 报价日期同义词。长的在前,避免短词截断。货好时间、提货地址不在这里。 _QUOTE_DATE_LABEL_RE = "|".join( re.escape(name) - for name in sorted(("报价日期",) + QUOTE_DATE_SYNONYMS, key=len, reverse=True) + for name in sorted(("报价日期",) + tuple(QUOTE_DATE_SYNONYMS or ()), key=len, reverse=True) ) _MODE_WORDS = ( @@ -829,9 +832,11 @@ def extract_air_collab_fields( return {k: got[k] for k in keys if str(got.get(k) or "").strip()} +# 金额旁币种词:含港币;禁止只认 USD/CNY 把港币单漏成美元。 +_CCY_TOKEN = r"USD|CNY|RMB|HKD|EUR|JPY|GBP|美金|美元|人民币|港币|港元|欧元|日元|英镑" _RE_MONEY = re.compile( - r"(?:USD|CNY|RMB|美金|美元|人民币)?\s*(\d+(?:\.\d+)?)\s*(?:USD|CNY|RMB|美金|美元|人民币|元)?" - r"|(?:USD|CNY|RMB|美金|美元)\s*(\d+(?:\.\d+)?)", + rf"(?:{_CCY_TOKEN}|元)?\s*(\d+(?:\.\d+)?)\s*(?:{_CCY_TOKEN}|元)?" + rf"|(?:{_CCY_TOKEN})\s*(\d+(?:\.\d+)?)", re.I, ) # 陆运集拼:「2-500KG 单价CNY990」是分档单价,不是「燃油附加费=2」。 @@ -839,14 +844,14 @@ _RE_WEIGHT_BAND_PRICE = re.compile( r"(?P\d+)\s*[-~~—–到至]\s*(?P\d+)\s*K\s*G" r"(?:\s*以上)?" r"\s*(?:单价)?" - r"\s*(?:(?PUSD|CNY|RMB|美金|美元|人民币)\s*)?" + rf"\s*(?:(?P{_CCY_TOKEN})\s*)?" r"(?P\d+(?:\.\d+)?)", re.I, ) _RE_WEIGHT_BAND_OPEN = re.compile( r"(?P\d+)\s*K\s*G\s*以上" r"\s*(?:单价)?" - r"\s*(?:(?PUSD|CNY|RMB|美金|美元|人民币)\s*)?" + rf"\s*(?:(?P{_CCY_TOKEN})\s*)?" r"(?P\d+(?:\.\d+)?)", re.I, ) @@ -862,9 +867,9 @@ _RE_FEE_LINE = re.compile( r"(?P[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费|[A-Za-z]{2,10})" r"\s*(?:改为|改成|调整为|调整成)?" r"\s*[::=]?" - r"\s*(?:(?PUSD|CNY|RMB|美金|美元|人民币)\s+)?" + rf"\s*(?:(?P{_CCY_TOKEN})\s+)?" r"(?P\d+(?:\.\d+)?)" - r"\s*(?PUSD|CNY|RMB|美金|美元|人民币|元)?", + rf"\s*(?P{_CCY_TOKEN}|元)?", re.I, ) # 「文件发改为:500」漏了「费」、中间还有冒号,不能丢掉。 @@ -873,19 +878,21 @@ _RE_FEE_CHANGE = re.compile( r"\s*(?:改为|改成|调整为|调整成)" r"\s*[::=]?" r"\s*(?P\d+(?:\.\d+)?)" - r"\s*(?PUSD|CNY|RMB|美金|美元|人民币|元)?", + rf"\s*(?P{_CCY_TOKEN}|元)?", re.I, ) -_SKIP_FEE_NAMES = frozenset({"usd", "cny", "rmb", "美金", "美元", "人民币", "元"}) +_SKIP_FEE_NAMES = frozenset( + {"usd", "cny", "rmb", "hkd", "eur", "jpy", "gbp", "美金", "美元", "人民币", "港币", "港元", "元"} +) # 航线常写「空运费 单价CNY99,数量100,金额CNY199」。单价和金额不是同一个数,必须认金额。 _RE_FEE_PRICED = re.compile( r"(?P[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费)" r"\s*单价\s*" - r"(?:(?PUSD|CNY|RMB|美金|美元|人民币)\s*)?" + rf"(?:(?P{_CCY_TOKEN})\s*)?" r"(?P\d+(?:\.\d+)?)" r"(?:\s*[,,、]\s*数量\s*(?P\d+(?:\.\d+)?))?" r"(?:\s*[,,、]\s*金额\s*" - r"(?:(?PUSD|CNY|RMB|美金|美元|人民币)\s*)?" + rf"(?:(?P{_CCY_TOKEN})\s*)?" r"(?P\d+(?:\.\d+)?))?", re.I, ) @@ -898,21 +905,40 @@ def _ascii_amount(raw: str) -> str: def _currency_of(text: str, hint: str = "") -> str: + """ + 从正文/提示抽三字码。正文没写币种时只回传 hint,禁止默认编造 USD。 + + 港币必须先于「元」判断,避免「港元」被误判成 CNY。 + WO202610090107:TMS 是 HKD,产品只写「运输费调整为1100」时必须继承 HKD。 + """ blob = f"{hint} {text}".upper() - if any(x in blob for x in ("CNY", "RMB", "人民币", "元")): + raw = f"{hint} {text}" + if any(x in blob for x in ("HKD", "港币", "港元", "HK$")) or "港币" in raw or "港元" in raw: + return "HKD" + if any(x in blob for x in ("CNY", "RMB", "人民币")) or ( + "元" in raw and "港元" not in raw and "港币" not in raw + ): return "CNY" if any(x in blob for x in ("USD", "美金", "美元")): return "USD" - return hint or "USD" + if any(x in blob for x in ("EUR", "欧元")): + return "EUR" + if any(x in blob for x in ("JPY", "日元")): + return "JPY" + if any(x in blob for x in ("GBP", "英镑")): + return "GBP" + return str(hint or "").strip().upper() -def _parse_fee_rows(text: str) -> list[dict[str, str]]: +def _parse_fee_rows(text: str, baseline_currency: str = "") -> list[dict[str, str]]: """ 按行抽费用:码头操作费100CNY、报关费改为300CNY。 只认带「费」的中文名或短英文费用码,避免把工单号当金额。 + 句里无币种时继承 baseline_currency(通常是当前 TMS/主账币种),禁止默认 USD。 """ raw = _ascii_amount(text or "") + base_ccy = str(baseline_currency or "").strip().upper() rows: list[dict[str, str]] = [] seen: set[str] = set() @@ -925,10 +951,11 @@ def _parse_fee_rows(text: str) -> list[dict[str, str]]: if name in seen: return seen.add(name) + line_ccy = _currency_of(raw, ccy or base_ccy) or base_ccy row = { "name": name, "amount": amount, - "currency": _currency_of(raw, ccy), + "currency": line_ccy, } for key, val in extra.items(): if val: @@ -986,6 +1013,7 @@ def extract_product_quote( injected: Optional[dict[str, Any]] = None, business_line: str = "", transport_mode: str = "", + baseline_currency: str = "", ) -> dict[str, Any]: """ 产品文字报价:先按费用行抽,再退回合计/币别。注入优先。 @@ -996,7 +1024,9 @@ def extract_product_quote( 陆运集拼:「2-500KG 单价CNY990,燃油附加费率1%,税率6%」走分档单价+费率, 禁止把公斤档下限「2」当成燃油附加费金额(WO021)。 无费用名时按 business_line/transport_mode 默认陆运费/空运费/海运费,禁止陆运硬套海运费。 + 正文未写币种时继承 baseline_currency(当前 TMS/主账币种),禁止默认 USD(WO107)。 """ + base_ccy = str(baseline_currency or "").strip().upper() if isinstance(injected, dict) and (injected.get("total") or injected.get("fee_rows")): out = dict(injected) out.setdefault("source_label", "产品报价") @@ -1008,14 +1038,14 @@ def extract_product_quote( compact = stripped.replace(" ", "").replace(" ", "") if re.fullmatch(r"WO\d{12}", compact, re.I): return {} - band = _parse_weight_band_quote(raw) + band = _parse_weight_band_quote(raw, baseline_currency=base_ccy) if band: return band # 费率句若仍落到普通费用行,会把「燃油附加费」配上句首公斤数。先剥掉再抽。 scrubbed = _strip_rate_and_band_noise(raw) - rows = _parse_fee_rows(scrubbed) if scrubbed.strip() else [] + rows = _parse_fee_rows(scrubbed, baseline_currency=base_ccy) if scrubbed.strip() else [] if rows: - ccy = rows[0].get("currency") or _currency_of(raw) + ccy = rows[0].get("currency") or _currency_of(raw, base_ccy) or base_ccy money_rows = [x for x in rows if "%" not in str(x.get("amount") or "")] total = (money_rows[0] if money_rows else rows[0])["amount"] if money_rows and len({x.get("currency") for x in money_rows}) == 1: @@ -1040,7 +1070,7 @@ def extract_product_quote( amount = _ascii_amount(hit.group(1) or hit.group(2) or "") if not amount: return {} - ccy = _currency_of(raw) + ccy = _currency_of(raw, base_ccy) or base_ccy default_fee = _default_product_fee_name(business_line, transport_mode) # 句里已经点了费用名(空运费)时,不能退回默认海运费。 named = re.search( @@ -1066,6 +1096,7 @@ def extract_product_quote( return out + def _format_percent_rate(raw: str) -> str: """费率展示统一带 %;已有 % 保留。""" text = (raw or "").strip() @@ -1100,13 +1131,15 @@ def _strip_rate_and_band_noise(text: str) -> str: return cleaned -def _parse_weight_band_quote(text: str) -> dict[str, Any]: +def _parse_weight_band_quote(text: str, baseline_currency: str = "") -> dict[str, Any]: """ 陆运集拼分档单价:2-500KG 单价CNY990 + 可选燃油附加费率/税率。 产出 weightPrices,供主账出单按「2-500KG」表头对档;费率进顶层字段填 Excel。 + 无币种时优先继承 baseline,再退回 CNY(集拼常见人民币报价)。 """ raw = _ascii_amount(text or "") + base_ccy = str(baseline_currency or "").strip().upper() tiers: list[dict[str, Any]] = [] rows: list[dict[str, str]] = [] ccy = "" @@ -1116,7 +1149,7 @@ def _parse_weight_band_quote(text: str) -> dict[str, Any]: price = _ascii_amount(hit.group("price") or "") if not price: continue - line_ccy = _currency_of(raw, hit.group("ccy") or "") + line_ccy = _currency_of(raw, hit.group("ccy") or base_ccy) or base_ccy ccy = ccy or line_ccy label = f"{lo}-{hi}KG" tiers.append({"min": lo, "max": hi, "price": price}) @@ -1133,7 +1166,7 @@ def _parse_weight_band_quote(text: str) -> dict[str, Any]: price = _ascii_amount(hit.group("price") or "") if not price: continue - line_ccy = _currency_of(raw, hit.group("ccy") or "") + line_ccy = _currency_of(raw, hit.group("ccy") or base_ccy) or base_ccy ccy = ccy or line_ccy label = f"{lo}KG以上" tiers.append({"min": lo, "max": None, "price": price}) @@ -1162,7 +1195,7 @@ def _parse_weight_band_quote(text: str) -> dict[str, Any]: # 分档是单价,不能把多档相加冒充费用合计。 out: dict[str, Any] = { "total": tiers[0]["price"], - "currency": ccy or "CNY", + "currency": ccy or base_ccy or "CNY", "source_label": "产品报价", "raw_text": (text or "").strip(), "fee_rows": rows, diff --git a/inquiry-agent/agent/policy/air_group_ops.py b/inquiry-agent/agent/policy/air_group_ops.py index 9edeba3..732bb48 100644 --- a/inquiry-agent/agent/policy/air_group_ops.py +++ b/inquiry-agent/agent/policy/air_group_ops.py @@ -629,14 +629,19 @@ def apply_air_quote( fields = extract_air_collab_fields(text) if fields: flow.ledger.patch_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id) + view = ticket_view(ticket) + current = dict(view.get("quote") or {}) + from agent.policy.sea_group_ops import _quote_currency_hint + quote = injected_quote or extract_product_quote( - text, business_line="AIR", transport_mode="空运" + text, + business_line="AIR", + transport_mode="空运", + baseline_currency=_quote_currency_hint(current), ) or {} if not quote: send_group_text(client, chat_id, copy.air_quote_not_recognized()) return "quote_empty" - view = ticket_view(ticket) - current = dict(view.get("quote") or {}) if _has_tms_quote(current) and not _is_product_quote(current): _park_air_pending(flow, no, quote, sender_id) send_group_text(client, chat_id, copy.group_ask_tms_or_product()) diff --git a/inquiry-agent/agent/policy/multi_group_ops.py b/inquiry-agent/agent/policy/multi_group_ops.py index 85cab8c..963cc8b 100644 --- a/inquiry-agent/agent/policy/multi_group_ops.py +++ b/inquiry-agent/agent/policy/multi_group_ops.py @@ -18,6 +18,7 @@ from agent.llm.extract_text import extract_collab_fields, extract_product_quote from agent.policy import inquiry_copy as copy from agent.policy.multi_segments import Segment, group_brief, includes_air, segments_from_payload from agent.policy.sea_group_ops import ( + _quote_currency_hint, apply_group_deal, group_client_of, is_sales, @@ -152,6 +153,12 @@ def handle_multi_inbound( ticket = _reload(flow, no) or ticket segments = load_segments(ticket) target = next((seg for seg in segments if seg.mode == mode), target) + # 段报价币种优先;否则用工单顶层 quote(产品未写币种时继承 TMS) + baseline = "" + if target is not None: + baseline = _quote_currency_hint((segment_quotes(ticket) or {}).get(target.mode) or {}) + if not baseline: + baseline = _quote_currency_hint(dict(ticket_view(ticket).get("quote") or {})) quote = extract_product_quote( text or "", injected=injected_quote, @@ -160,6 +167,7 @@ def handle_multi_inbound( "陆运" if str(mode or "").upper() == "LAND" else ("空运" if str(mode or "").upper() == "AIR" else "海运") ), + baseline_currency=baseline, ) if not quote: if fields: diff --git a/inquiry-agent/agent/policy/sea_group_ops.py b/inquiry-agent/agent/policy/sea_group_ops.py index 758db4d..26cf357 100644 --- a/inquiry-agent/agent/policy/sea_group_ops.py +++ b/inquiry-agent/agent/policy/sea_group_ops.py @@ -930,18 +930,19 @@ def apply_product_quote( merged.update(fields) sess.collab_facts = merged line = str(view.get("business_line") or view.get("businessLine") or "").strip().upper() + base = _current_quote(view, sess) quote = extract_product_quote( text, injected=injected_quote, business_line=line, transport_mode="陆运" if line == "LAND" else ("海运" if line == "SEA" else ""), + baseline_currency=_quote_currency_hint(base), ) if not quote: logger.info("产品报价未识别 wo=%s text=%s", no, (text or "")[:200]) send_group_text(client, chat_id, copy.group_quote_not_recognized()) return "quote_not_recognized" quote = _attach_land_weight_band_segment(quote, view) - base = _current_quote(view, sess) if _has_tms_quote(base): _park_pending_quote(no, quote, sess, flow) send_group_text(client, chat_id, copy.group_ask_tms_or_product()) @@ -1405,6 +1406,53 @@ def _current_quote(view: dict[str, Any], sess: Any) -> dict[str, Any]: return base +def _quote_currency_hint(quote: dict[str, Any] | None) -> str: + """ + 当前报价币种提示:顶层 currency → 金额前缀 → 费用行/线路段。 + + 产品改价未写币种时继承此值,禁止默认 USD(WO202610090107 中港 HKD)。 + """ + src = dict(quote or {}) + for key in ("currency", "currencyCode"): + code = str(src.get(key) or "").strip().upper() + if code: + return code + for key in ("cost", "total", "freight", "land_freight"): + raw = str(src.get(key) or "").strip().upper() + for prefix in ("HKD", "CNY", "USD", "EUR", "JPY", "GBP", "RMB"): + if raw.startswith(prefix): + return "CNY" if prefix == "RMB" else prefix + for row in _quote_fee_rows(src): + code = str(row.get("currency") or row.get("currencyCode") or "").strip().upper() + if code: + return code + for key in ("landSegments", "route_rows", "selected_land_route"): + raw = src.get(key) + if isinstance(raw, str) and raw.strip(): + try: + import json + + raw = json.loads(raw) + except Exception: + continue + if isinstance(raw, dict): + legs = raw.get("legs") if isinstance(raw.get("legs"), list) else [raw] + for leg in legs: + if not isinstance(leg, dict): + continue + code = str(leg.get("currency") or "").strip().upper() + if code: + return code + elif isinstance(raw, list): + for leg in raw: + if not isinstance(leg, dict): + continue + code = str(leg.get("currency") or "").strip().upper() + if code: + return code + return "" + + def _has_tms_quote(quote: dict[str, Any] | None) -> bool: """当前价来自 TMS 才要问;产品价、销售调价不再替用户选。""" src = dict(quote or {}) diff --git a/inquiry-agent/tests/test_land_group_collab.py b/inquiry-agent/tests/test_land_group_collab.py index 64a777c..13b30a0 100644 --- a/inquiry-agent/tests/test_land_group_collab.py +++ b/inquiry-agent/tests/test_land_group_collab.py @@ -374,6 +374,48 @@ class LandGroupCollabTests(unittest.TestCase): air = extract_product_quote("价格为1RMB/KG", business_line="AIR") self.assertEqual(str((air.get("fee_rows") or [{}])[0].get("name") or ""), "空运费") + def test_land_adjust_without_currency_inherits_tms_hkd(self) -> None: + """WO202610090107:TMS 港币,产品只写「运输费调整为1100」时继承 HKD,禁止默认 USD。""" + from agent.llm.extract_text import extract_product_quote + from agent.policy.sea_group_ops import _quote_currency_hint, merge_quote_fees + + tms = { + "currency": "HKD", + "cost": "HKD 1000", + "total": "1000", + "source": "TMS", + "fee_lines": [ + { + "name": "运输费", + "amount": "1000", + "currency": "HKD", + } + ], + "landSegments": [ + { + "origin": "广东省-深圳市-福田区", + "destination": "香港特别行政区", + "currency": "HKD", + "costPrice": "1000", + } + ], + } + self.assertEqual(_quote_currency_hint(tms), "HKD") + # 旧行为:无 baseline 会落到 USD + legacy = extract_product_quote("运输费调整为1100", business_line="LAND") + self.assertNotEqual(str(legacy.get("currency") or ""), "USD") + quote = extract_product_quote( + "运输费调整为1100", + business_line="LAND", + baseline_currency=_quote_currency_hint(tms), + ) + self.assertEqual(str(quote.get("currency") or ""), "HKD") + self.assertEqual(str((quote.get("fee_lines") or [{}])[0].get("currency") or ""), "HKD") + self.assertEqual(str((quote.get("fee_lines") or [{}])[0].get("amount") or ""), "1100") + merged = merge_quote_fees(tms, quote) + self.assertEqual(str((merged.get("fee_lines") or [{}])[0].get("currency") or ""), "HKD") + self.assertEqual(str(merged.get("currency") or ""), "HKD") + def test_land_requote_replaces_wrong_sea_fee_name(self) -> None: """旧陆运产品价误落海运费时,重报陆运费应盖掉而不是并排两行。""" from agent.policy.sea_group_ops import _align_wrong_default_freight, merge_quote_fees 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 223f882..c71d46d 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 @@ -1176,6 +1176,14 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl + * 优先用 selected_land_route.legs(销售已选线路);否则从多条备选里挑一条多腿线路。 + * 只有一对「境内→境外」且无两段腿时:国内段只写境内点、境外段只写境外点,不发明口岸。 + * WO202610120001:备选混在 route_rows 时若要求整表同 segmentSequence,会退回切片导致口岸空。 */ static JSONArray routesForLandRouteSection(JSONArray routes, Sheet sheet, JSONObject region, int regionIndex, int regionCount) { + return routesForLandRouteSection(routes, sheet, region, regionIndex, regionCount, null); + } + + static JSONArray routesForLandRouteSection(JSONArray routes, Sheet sheet, JSONObject region, + int regionIndex, int regionCount, JSONObject facts) { if (routes == null || routes.isEmpty()) { return routes; } @@ -3107,12 +3127,15 @@ public final class QuoteTemplateExcelSupport { if (StringUtils.isBlank(kind)) { return routes; } - JSONArray byLeg = pickLandRouteLegsForSection(routes, kind); + JSONArray preferred = preferredLandRouteLegs(facts, routes); + JSONArray byLeg = pickLandRouteLegsForSection(preferred, kind); if (byLeg != null && !byLeg.isEmpty()) { return byLeg; } + JSONObject seed = preferred != null && !preferred.isEmpty() + ? preferred.getJSONObject(0) : routes.getJSONObject(0); JSONArray sliced = new JSONArray(); - JSONObject one = sliceCrossBorderOd(routes.getJSONObject(0), kind); + JSONObject one = sliceCrossBorderOd(seed, kind); if (one != null && (StringUtils.isNotBlank(one.getString("origin")) || StringUtils.isNotBlank(one.getString("destination")))) { sliced.add(one); @@ -3120,16 +3143,91 @@ public final class QuoteTemplateExcelSupport { return sliced; } + /** + * 出单优先腿:已选线路 legs > 同 segmentSequence 且腿数最多的备选 > 原数组。 + */ + static JSONArray preferredLandRouteLegs(JSONObject facts, JSONArray allRoutes) { + if (facts != null) { + Object rawSel = facts.get("selected_land_route"); + if (rawSel == null) { + rawSel = facts.get("selectedLandRoute"); + } + JSONObject sel = null; + if (rawSel instanceof JSONObject) { + sel = (JSONObject) rawSel; + } else if (rawSel instanceof String && StringUtils.isNotBlank((String) rawSel)) { + try { + Object parsed = JSON.parse((String) rawSel); + if (parsed instanceof JSONObject) { + sel = (JSONObject) parsed; + } + } catch (Exception ignored) { + // 忽略坏 JSON + } + } else if (rawSel instanceof java.util.Map) { + sel = JSON.parseObject(JSON.toJSONString(rawSel)); + } + if (sel != null) { + JSONArray legs = parseFeeRows(sel.get("legs")); + if (legs != null && !legs.isEmpty()) { + return legs; + } + } + } + JSONArray grouped = pickPrimaryLandRouteGroup(allRoutes); + return grouped != null && !grouped.isEmpty() ? grouped : allRoutes; + } + + /** + * 多条备选(不同 segmentSequence)时,挑腿数最多的一条完整线路;并列取较小序号。 + */ + static JSONArray pickPrimaryLandRouteGroup(JSONArray routes) { + if (routes == null || routes.isEmpty()) { + return null; + } + java.util.Map groups = new java.util.LinkedHashMap<>(); + for (int i = 0; i < routes.size(); i++) { + JSONObject row = routes.getJSONObject(i); + if (row == null) { + continue; + } + String seq = StringUtils.trimToEmpty(row.getString("segmentSequence")); + if (seq.isEmpty()) { + seq = "1"; + } + groups.computeIfAbsent(seq, k -> new JSONArray()).add(row); + } + if (groups.isEmpty()) { + return null; + } + String bestKey = null; + int bestLegs = -1; + int bestSeqNum = Integer.MAX_VALUE; + for (java.util.Map.Entry e : groups.entrySet()) { + int legs = e.getValue() == null ? 0 : e.getValue().size(); + int seqNum; + try { + seqNum = Integer.parseInt(e.getKey().trim()); + } catch (RuntimeException ex) { + seqNum = Integer.MAX_VALUE; + } + if (legs > bestLegs || (legs == bestLegs && seqNum < bestSeqNum)) { + bestLegs = legs; + bestSeqNum = seqNum; + bestKey = e.getKey(); + } + } + return bestKey == null ? null : groups.get(bestKey); + } + + /** + * 同一条线路的多段腿:国内段取首腿完整 OD,境外段取末腿完整 OD。 + * 不足两腿时返回 null,交给 sliceCrossBorderOd。 + */ static JSONArray pickLandRouteLegsForSection(JSONArray routes, String kind) { if (routes == null || routes.size() < 2) { return null; } - String seq = StringUtils.trimToEmpty(routes.getJSONObject(0).getString("segmentSequence")); - for (int i = 1; i < routes.size(); i++) { - if (!seq.equals(StringUtils.trimToEmpty(routes.getJSONObject(i).getString("segmentSequence")))) { - return null; - } - } JSONArray ordered = new JSONArray(); ordered.addAll(routes); ordered.sort((a, b) -> { @@ -3140,8 +3238,10 @@ public final class QuoteTemplateExcelSupport { JSONArray out = new JSONArray(); if ("domestic".equals(kind)) { out.add(ordered.getJSONObject(0)); - } else { + } else if ("overseas".equals(kind)) { out.add(ordered.getJSONObject(ordered.size() - 1)); + } else { + return null; } return out; } @@ -3483,6 +3583,14 @@ public final class QuoteTemplateExcelSupport { * 禁止「无车型时把同一售价灌进所有费种列」。 */ static String resolveRouteVehicleRate(JSONObject rowObj, JSONObject facts, String headerKey, int headerCount) { + return resolveRouteVehicleRate(rowObj, facts, headerKey, headerCount, null); + } + + /** + * @param sectionKind domestic / overseas / 空;境外段要把国内标注 3T 对成 3.5T 再写价。 + */ + static String resolveRouteVehicleRate(JSONObject rowObj, JSONObject facts, String headerKey, + int headerCount, String sectionKind) { if (StringUtils.isBlank(headerKey)) { return ""; } @@ -3517,7 +3625,20 @@ public final class QuoteTemplateExcelSupport { } // 跨境集拼是分档公斤价(2-500KG=12 …),不是把同一个 price_per_kg 灌进每一列。 if (looksLikeKgBracketHeader(key)) { - return matchWeightBracketPrice(rowObj, key); + String band = matchWeightBracketPrice(rowObj, key); + if (StringUtils.isNotBlank(band)) { + return band; + } + // TMS 偶发只给分档壳(min/max)+ 整票 costPrice(计价单位:次),无档内单价。 + // 禁止灌进所有公斤列;只写入询价重量落入的那一档,避免报价单全空。 + return flatLandFeeForEmptyWeightBands(rowObj, facts, key); + } + if (key.contains("最低收费")) { + return formatTierPrice(firstNonBlank(rowObj == null ? null : rowObj.getString("min_charge"), + rowObj == null ? null : rowObj.getString("minCharge"), + rowObj == null ? null : rowObj.getString("salePrice"), + rowObj == null ? null : rowObj.getString("costPrice"), + rowObj == null ? null : rowObj.getString("amount"))); } if (key.toUpperCase(Locale.ROOT).contains("KG") || key.contains("公斤")) { return firstNonBlank(rowObj == null ? null : rowObj.getString("price_per_kg"), @@ -3527,16 +3648,17 @@ public final class QuoteTemplateExcelSupport { if (key.contains("运输") || key.contains("运费") || key.contains("陆运")) { return firstNonBlank(rowObj == null ? null : rowObj.getString("salePrice"), rowObj == null ? null : rowObj.getString("amount"), + rowObj == null ? null : rowObj.getString("costPrice"), rowObj == null ? null : rowObj.getString("land_freight"), facts == null ? null : facts.getString("land_freight")); } - // 2) 车型列:仅当 vehicleType 对得上才写售价 + // 2) 车型列:仅当 vehicleType 对得上才写售价(含跨境国内/国外标注换算) String vehicleType = firstNonBlank(rowObj == null ? null : rowObj.getString("vehicleType"), rowObj == null ? null : rowObj.getString("vehicle_type")); String price = firstNonBlank(rowObj == null ? null : rowObj.getString("salePrice"), rowObj == null ? null : rowObj.getString("costPrice"), rowObj == null ? null : rowObj.getString("amount")); - if (StringUtils.isNotBlank(vehicleType) && vehicleTypeEquals(vehicleType, key)) { + if (StringUtils.isNotBlank(vehicleType) && vehicleTypeMatchesSection(vehicleType, key, sectionKind)) { return price; } // 3) 仅一列且无车型信息时,允许写入该列(整车单列模板) @@ -3546,6 +3668,65 @@ public final class QuoteTemplateExcelSupport { return ""; } + /** + * 车型列是否对得上当前段表头。 + *

+ * 跨境整车:询价/TMS 常写国内标注「3T」,国外段表头是「3.5T」; + * 境外段用国内→国外映射后再比,国内段反之。禁止把 3T 误当成所有 T 列。 + */ + static boolean vehicleTypeMatchesSection(String vehicleType, String headerKey, String sectionKind) { + if (vehicleTypeEquals(vehicleType, headerKey)) { + return true; + } + String kind = StringUtils.trimToEmpty(sectionKind); + if ("overseas".equals(kind)) { + String mapped = mapDomesticVehicleToOverseas(vehicleType); + return StringUtils.isNotBlank(mapped) && vehicleTypeEquals(mapped, headerKey); + } + if ("domestic".equals(kind)) { + String mapped = mapOverseasVehicleToDomestic(vehicleType); + return StringUtils.isNotBlank(mapped) && vehicleTypeEquals(mapped, headerKey); + } + return false; + } + + /** + * 国内整车标注 → 跨境国外段表头。WO202610080006:3T 对 3.5T。 + * 未收录的返回空,禁止瞎猜。 + */ + static String mapDomesticVehicleToOverseas(String vehicleType) { + String a = normalizeVehicleToken(vehicleType); + if (a.isEmpty()) { + return ""; + } + if ("3T".equals(a) || "3吨".equals(a)) { + return "3.5T"; + } + return ""; + } + + /** + * 国外段标注 → 国内段表头(单腿 TMS 偶发直接回 3.5T 时,国内段仍能写进 3T)。 + */ + static String mapOverseasVehicleToDomestic(String vehicleType) { + String a = normalizeVehicleToken(vehicleType); + if (a.isEmpty()) { + return ""; + } + if ("3.5T".equals(a) || "3.5吨".equals(a)) { + return "3T"; + } + return ""; + } + + private static String normalizeVehicleToken(String vehicleType) { + if (StringUtils.isBlank(vehicleType)) { + return ""; + } + return vehicleType.replace(" ", "").toUpperCase(Locale.ROOT) + .replace("T", "T"); + } + /** * 表头是「2-500KG」「5001KG以上」这类分档,不是「元/KG」单价列。 */ @@ -3555,6 +3736,8 @@ public final class QuoteTemplateExcelSupport { /** * 用 landSegments.weightPrices 对上分档表头。对不上就空着,禁止把一档价抄进所有列。 + *

+ * 档内单价优先读 price,兼容 TMS 偶发的 unitPrice/salePrice/costPrice/amount。 */ static String matchWeightBracketPrice(JSONObject rowObj, String headerKey) { JSONArray tiers = weightPricesOf(rowObj); @@ -3571,12 +3754,122 @@ public final class QuoteTemplateExcelSupport { boolean open = tier.get("max") == null || StringUtils.isBlank(String.valueOf(tier.get("max"))); int max = open ? -1 : intOf(tier.get("max")); if (min == range[0] && max == range[1]) { - return formatTierPrice(tier.get("price")); + return formatTierPrice(tierPriceRaw(tier)); } } return ""; } + /** + * 分档壳有区间但没有任何档内单价时:把整票 salePrice/costPrice 写入询价重量所在那一档。 + *

+ * 为何:WO202610080043 跨境集拼 TMS 回 weightPrices 只有 min/max,费用是「次」18200; + * 若所有公斤列都空着,销售会以为报价没写入。有真实档内单价时禁止走这条。 + * 副作用:只读 row/facts,不改库。 + */ + static String flatLandFeeForEmptyWeightBands(JSONObject rowObj, JSONObject facts, String headerKey) { + int[] range = parseKgBracket(headerKey); + if (range == null || rowObj == null) { + return ""; + } + JSONArray tiers = weightPricesOf(rowObj); + if (tiers == null || tiers.isEmpty()) { + return ""; + } + if (anyWeightTierHasPrice(tiers)) { + return ""; + } + String flat = firstNonBlank(rowObj.getString("salePrice"), rowObj.getString("costPrice"), + rowObj.getString("amount")); + if (StringUtils.isBlank(flat) || "-".equals(flat)) { + return ""; + } + double weight = inquiryWeightKg(rowObj, facts); + boolean hit; + if (weight >= 0) { + hit = weightInKgBracket(weight, range[0], range[1]); + } else { + // 无询价重量:只写入 weightPrices 里的第一档,避免抄进所有列 + JSONObject first = tiers.getJSONObject(0); + int min = first == null ? -1 : intOf(first.get("min")); + boolean open = first == null || first.get("max") == null + || StringUtils.isBlank(String.valueOf(first.get("max"))); + int max = open ? -1 : intOf(first.get("max")); + hit = min == range[0] && max == range[1]; + } + return hit ? formatTierPrice(flat) : ""; + } + + /** 档内是否已有任何可用单价(有则禁止整票价回填)。 */ + static boolean anyWeightTierHasPrice(JSONArray tiers) { + if (tiers == null) { + return false; + } + for (int i = 0; i < tiers.size(); i++) { + JSONObject tier = tiers.getJSONObject(i); + if (tier != null && StringUtils.isNotBlank(formatTierPrice(tierPriceRaw(tier)))) { + return true; + } + } + return false; + } + + /** TMS 档内单价字段名不统一,按常见键取第一个非空。 */ + static Object tierPriceRaw(JSONObject tier) { + if (tier == null) { + return null; + } + Object raw = tier.get("price"); + if (raw == null || StringUtils.isBlank(String.valueOf(raw)) || "null".equalsIgnoreCase(String.valueOf(raw))) { + raw = tier.get("unitPrice"); + } + if (raw == null || StringUtils.isBlank(String.valueOf(raw)) || "null".equalsIgnoreCase(String.valueOf(raw))) { + raw = tier.get("salePrice"); + } + if (raw == null || StringUtils.isBlank(String.valueOf(raw)) || "null".equalsIgnoreCase(String.valueOf(raw))) { + raw = tier.get("costPrice"); + } + if (raw == null || StringUtils.isBlank(String.valueOf(raw)) || "null".equalsIgnoreCase(String.valueOf(raw))) { + raw = tier.get("amount"); + } + if (raw == null || StringUtils.isBlank(String.valueOf(raw)) || "null".equalsIgnoreCase(String.valueOf(raw))) { + raw = tier.get("pricePerKg"); + } + return raw; + } + + /** 询价毛重(KG);没有或解析失败返回 -1。 */ + static double inquiryWeightKg(JSONObject rowObj, JSONObject facts) { + String raw = firstNonBlank( + rowObj == null ? null : rowObj.getString("weight_kg"), + rowObj == null ? null : rowObj.getString("weightKg"), + facts == null ? null : facts.getString("weight_kg"), + facts == null ? null : facts.getString("weightKg"), + facts == null ? null : facts.getString("毛重"), + facts == null ? null : facts.getString("重量"), + facts == null ? null : facts.getString("weight")); + if (StringUtils.isBlank(raw) || "-".equals(raw)) { + return -1; + } + String num = raw.trim().replace(",", "").replaceAll("[Kk][Gg].*$", "").trim(); + try { + return new java.math.BigDecimal(num).doubleValue(); + } catch (RuntimeException ex) { + return -1; + } + } + + /** 重量是否落在 [min, max];max=-1 表示无上限。 */ + static boolean weightInKgBracket(double weight, int min, int max) { + if (weight < 0 || min < 0) { + return false; + } + if (weight < min) { + return false; + } + return max < 0 || weight <= max; + } + /** * 规范化 route_rows 会丢掉 weightPrices。出单前从原始 landSegments 补回。 */ 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 02e036e..9a254e2 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 @@ -1480,6 +1480,81 @@ public class QuoteTemplateFillTest { } } + /** + * WO202610080043:TMS weightPrices 只有 min/max、整票 costPrice=18200(计价单位:次)。 + * 询价毛重 2KG 落入 2-500KG 档时写入 18200;其它公斤列保持空;有真实档内单价时不走此回退。 + */ + @Test + public void landGroupageEmptyWeightBandsWritesFlatFeeIntoCargoBandOnly() throws Exception { + Path src = Files.createTempFile("quote-land-groupage-flat", ".xlsx"); + Path out = Files.createTempFile("quote-land-groupage-flat-out", ".xlsx"); + try (Workbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("跨境集拼"); + org.apache.poi.ss.usermodel.Row matrix = row(sheet, 7); + matrix.createCell(1).setCellValue("始发地"); + matrix.createCell(2).setCellValue("目的地"); + matrix.createCell(3).setCellValue("最低收费"); + matrix.createCell(4).setCellValue("2-500KG"); + matrix.createCell(5).setCellValue("501-1000KG"); + matrix.createCell(6).setCellValue("1001-3000KG"); + matrix.createCell(7).setCellValue("3001-5000KG"); + matrix.createCell(8).setCellValue("5001KG以上"); + row(sheet, 9); + try (java.io.OutputStream os = Files.newOutputStream(src)) { + wb.write(os); + } + } + JSONObject route = new JSONObject(true); + route.put("origin", "凉山-春疆货场"); + route.put("destination", "湖北省-武汉市"); + route.put("currency", "CNY"); + route.put("costPrice", "18200.00"); + route.put("amount", "18200.00"); + route.put("weightPrices", JSONArray.parseArray( + "[{\"min\":2,\"max\":500},{\"min\":501,\"max\":1000},{\"min\":1001,\"max\":3000}," + + "{\"min\":3001,\"max\":5000},{\"min\":5001}]")); + JSONArray routeRows = new JSONArray(); + routeRows.add(route); + JSONObject mapping = new JSONObject(true); + mapping.put("sheet", "跨境集拼"); + JSONArray regions = new JSONArray(); + JSONObject region = new JSONObject(true); + region.put("headerRow", 8); + region.put("dataStartRow", 10); + JSONObject columns = new JSONObject(true); + columns.put("origin", "B"); + columns.put("destination", "C"); + JSONObject vehicleRates = new JSONObject(true); + vehicleRates.put("最低收费", "D"); + vehicleRates.put("2-500KG", "E"); + vehicleRates.put("501-1000KG", "F"); + vehicleRates.put("1001-3000KG", "G"); + vehicleRates.put("3001-5000KG", "H"); + vehicleRates.put("5001KG以上", "I"); + columns.put("vehicleRates", vehicleRates); + region.put("columns", columns); + regions.add(region); + mapping.put("routeRegions", regions); + JSONObject facts = new JSONObject(true); + facts.put("毛重", "2"); + facts.put("route_rows", routeRows); + QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts); + try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) { + Sheet sheet = filled.getSheetAt(0); + assertEquals("凉山-春疆货场", read(sheet, 9, 1)); + assertEquals("湖北省-武汉市", read(sheet, 9, 2)); + assertEquals("18200", normalizeMoney(read(sheet, 9, 3))); + assertEquals("18200", normalizeMoney(read(sheet, 9, 4))); + assertEquals("", read(sheet, 9, 5)); + assertEquals("", read(sheet, 9, 6)); + assertEquals("", read(sheet, 9, 7)); + assertEquals("", read(sheet, 9, 8)); + } finally { + Files.deleteIfExists(src); + Files.deleteIfExists(out); + } + } + /** * 销售没填客户名称时,客户名称格保持空白,不能写成业务员。 * 询价条带的时效写在表头下一行;公斤分档价按 weightPrices 落入各列。 @@ -1878,6 +1953,7 @@ public class QuoteTemplateFillTest { /** * 跨境整车分段切片后仍须带上车型与运价;尺寸副表头行不能被 4000 盖掉,运价写在下一行。 + * WO006:单腿 vehicleType=3T 时,国内写 3T 列,国外段换算成 3.5T 再写,不能留空。 */ @Test public void landCrossBorderFtlKeepsVehiclePriceAfterOdSlice() throws Exception { @@ -1900,9 +1976,11 @@ public class QuoteTemplateFillTest { head2.createCell(2).setCellValue("目的地"); head2.createCell(3).setCellValue("1.25T"); head2.createCell(4).setCellValue("2.5T"); + head2.createCell(5).setCellValue("3.5T"); org.apache.poi.ss.usermodel.Row dim2 = row(sheet, 27); dim2.createCell(3).setCellValue("2.8x1.5x1.6M"); dim2.createCell(4).setCellValue("3.50x1.6x1.6M"); + dim2.createCell(5).setCellValue("4.2x1.8x1.8M"); try (java.io.OutputStream os = Files.newOutputStream(src)) { wb.write(os); } @@ -1933,6 +2011,7 @@ public class QuoteTemplateFillTest { JSONObject rates2 = new JSONObject(true); rates2.put("1.25T", "D"); rates2.put("2.5T", "E"); + rates2.put("3.5T", "F"); cols2.put("vehicleRates", rates2); overseas.put("columns", cols2); regions.add(overseas); @@ -1950,12 +2029,14 @@ public class QuoteTemplateFillTest { assertEquals("4.1*2.1*2.3M", read(sheet, 8, 3)); assertEquals("广东省-深圳市", read(sheet, 9, 1)); assertEquals("", read(sheet, 9, 2)); - assertEquals("4000.00", read(sheet, 9, 3)); + assertEquals("4000", normalizeMoney(read(sheet, 9, 3))); assertEquals("", read(sheet, 9, 4)); assertEquals("2.8x1.5x1.6M", read(sheet, 27, 3)); assertEquals("", read(sheet, 28, 1)); assertEquals("谅山-春疆货场", read(sheet, 28, 2)); assertEquals("", read(sheet, 28, 3)); + assertEquals("", read(sheet, 28, 4)); + assertEquals("4000", normalizeMoney(read(sheet, 28, 5))); } finally { Files.deleteIfExists(src); Files.deleteIfExists(out); @@ -2080,6 +2161,107 @@ public class QuoteTemplateFillTest { } } + /** + * WO202610120001:route_rows 混有多条备选(不同 segmentSequence),国内/境外段应写完整两段 OD, + * 不能因「整表必须同序列」退回切片导致口岸列空。 + */ + @Test + public void landCrossBorderFtlFillsFullOdFromMultiAltRouteRows() throws Exception { + Path src = Files.createTempFile("quote-land-cb-wo001-src", ".xlsx"); + Path out = Files.createTempFile("quote-land-cb-wo001-out", ".xlsx"); + try (Workbook wb = new XSSFWorkbook()) { + Sheet sheet = wb.createSheet("跨境整车"); + row(sheet, 5).createCell(0).setCellValue("一、国内段报价"); + org.apache.poi.ss.usermodel.Row head1 = row(sheet, 7); + head1.createCell(1).setCellValue("起始地"); + head1.createCell(2).setCellValue("目的地"); + head1.createCell(3).setCellValue("3T"); + row(sheet, 24).createCell(0).setCellValue("二、境外段报价"); + org.apache.poi.ss.usermodel.Row head2 = row(sheet, 26); + head2.createCell(1).setCellValue("起始地"); + head2.createCell(2).setCellValue("目的地"); + head2.createCell(3).setCellValue("3.5T"); + try (java.io.OutputStream os = Files.newOutputStream(src)) { + wb.write(os); + } + } + JSONObject mapping = new JSONObject(true); + mapping.put("sheet", "跨境整车"); + JSONArray regions = new JSONArray(); + JSONObject inland = new JSONObject(true); + inland.put("title", "国内段报价"); + inland.put("headerRow", 8); + inland.put("dataStartRow", 8); + JSONObject cols1 = new JSONObject(true); + cols1.put("origin", "B"); + cols1.put("destination", "C"); + cols1.put("3T", "D"); + inland.put("columns", cols1); + regions.add(inland); + JSONObject overseas = new JSONObject(true); + overseas.put("title", "境外段报价"); + overseas.put("headerRow", 27); + overseas.put("dataStartRow", 27); + JSONObject cols2 = new JSONObject(true); + cols2.put("origin", "B"); + cols2.put("destination", "C"); + cols2.put("3.5T", "D"); + overseas.put("columns", cols2); + regions.add(overseas); + mapping.put("routeRegions", regions); + JSONObject facts = new JSONObject(true); + facts.put("origin", "上海"); + facts.put("destination", "胡志明"); + JSONArray rows = new JSONArray(); + JSONObject a1 = new JSONObject(true); + a1.put("segmentSequence", "1"); + a1.put("routeLegSequence", "1"); + a1.put("origin", "上海"); + a1.put("destination", "谅山-春疆货场"); + a1.put("vehicleType", "3T"); + a1.put("costPrice", "7500"); + a1.put("currency", "CNY"); + rows.add(a1); + JSONObject a2 = new JSONObject(true); + a2.put("segmentSequence", "1"); + a2.put("routeLegSequence", "2"); + a2.put("origin", "谅山-春疆货场"); + a2.put("destination", "胡志明"); + a2.put("vehicleType", "3.5T"); + a2.put("costPrice", "9600"); + a2.put("currency", "CNY"); + rows.add(a2); + JSONObject b1 = new JSONObject(true); + b1.put("segmentSequence", "2"); + b1.put("routeLegSequence", "1"); + b1.put("origin", "上海"); + b1.put("destination", "凭祥友谊关口岸"); + b1.put("vehicleType", "3T"); + b1.put("costPrice", "7000"); + rows.add(b1); + JSONObject b2 = new JSONObject(true); + b2.put("segmentSequence", "2"); + b2.put("routeLegSequence", "2"); + b2.put("origin", "凭祥友谊关口岸"); + b2.put("destination", "胡志明"); + b2.put("vehicleType", "3T"); + b2.put("costPrice", "9000"); + rows.add(b2); + facts.put("route_rows", rows); + facts.put("landSegments", rows); + QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts); + try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) { + Sheet sheet = filled.getSheetAt(0); + assertEquals("上海", read(sheet, 8, 1)); + assertEquals("谅山-春疆货场", read(sheet, 8, 2)); + assertEquals("谅山-春疆货场", read(sheet, 27, 1)); + assertEquals("胡志明", read(sheet, 27, 2)); + } finally { + Files.deleteIfExists(src); + Files.deleteIfExists(out); + } + } + /** * WO202610080006:跨境整车上传版表头是「时效 | 币种(错行) | 燃油 | 税率 | 含税合计」。 * 起始地不能被费用名「运输费」盖掉;CNY 只能进币种列;燃油附加费率要写到燃油列。 @@ -2620,6 +2802,18 @@ public class QuoteTemplateFillTest { return cell.getStringCellValue(); } + /** 金额格可能是数字 18200.0 或文本 18200.00,统一成去尾零纯数字再比。 */ + private static String normalizeMoney(String raw) { + if (raw == null || raw.isBlank() || "-".equals(raw.trim())) { + return ""; + } + try { + return new java.math.BigDecimal(raw.trim()).stripTrailingZeros().toPlainString(); + } catch (RuntimeException ex) { + return raw.trim(); + } + } + /** * 陆运运价表:燃油附加费率、税率必须写到表头同列的数据行。 * rate=0.09→9%;rateFlag 是是否含税,不写进税率格。