产品调价继承工单币种(港币不再默成美元);陆运集拼空分档只填询价重量那一档,跨境整车按已选线路写起讫。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-12 19:22:44 +08:00
co-authored by Cursor
parent 3a4c440354
commit 97f0219dfd
8 changed files with 678 additions and 47 deletions
+57 -24
View File
@@ -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<min>\d+)\s*[-~~—–到至]\s*(?P<max>\d+)\s*K\s*G"
r"(?:\s*以上)?"
r"\s*(?:单价)?"
r"\s*(?:(?P<ccy>USD|CNY|RMB|美金|美元|人民币)\s*)?"
rf"\s*(?:(?P<ccy>{_CCY_TOKEN})\s*)?"
r"(?P<price>\d+(?:\.\d+)?)",
re.I,
)
_RE_WEIGHT_BAND_OPEN = re.compile(
r"(?P<min>\d+)\s*K\s*G\s*以上"
r"\s*(?:单价)?"
r"\s*(?:(?P<ccy>USD|CNY|RMB|美金|美元|人民币)\s*)?"
rf"\s*(?:(?P<ccy>{_CCY_TOKEN})\s*)?"
r"(?P<price>\d+(?:\.\d+)?)",
re.I,
)
@@ -862,9 +867,9 @@ _RE_FEE_LINE = re.compile(
r"(?P<name>[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费|[A-Za-z]{2,10})"
r"\s*(?:改为|改成|调整为|调整成)?"
r"\s*[::=]?"
r"\s*(?:(?P<ccy1>USD|CNY|RMB|美金|美元|人民币)\s+)?"
rf"\s*(?:(?P<ccy1>{_CCY_TOKEN})\s+)?"
r"(?P<amount>\d+(?:\.\d+)?)"
r"\s*(?P<ccy2>USD|CNY|RMB|美金|美元|人民币|元)?",
rf"\s*(?P<ccy2>{_CCY_TOKEN}|元)?",
re.I,
)
# 「文件发改为:500」漏了「费」、中间还有冒号,不能丢掉。
@@ -873,19 +878,21 @@ _RE_FEE_CHANGE = re.compile(
r"\s*(?:改为|改成|调整为|调整成)"
r"\s*[::=]?"
r"\s*(?P<amount>\d+(?:\.\d+)?)"
r"\s*(?P<ccy>USD|CNY|RMB|美金|美元|人民币|元)?",
rf"\s*(?P<ccy>{_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<name>[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费)"
r"\s*单价\s*"
r"(?:(?P<unit_ccy>USD|CNY|RMB|美金|美元|人民币)\s*)?"
rf"(?:(?P<unit_ccy>{_CCY_TOKEN})\s*)?"
r"(?P<unit>\d+(?:\.\d+)?)"
r"(?:\s*[,,、]\s*数量\s*(?P<qty>\d+(?:\.\d+)?))?"
r"(?:\s*[,,、]\s*金额\s*"
r"(?:(?P<amt_ccy>USD|CNY|RMB|美金|美元|人民币)\s*)?"
rf"(?:(?P<amt_ccy>{_CCY_TOKEN})\s*)?"
r"(?P<amount>\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,
+8 -3
View File
@@ -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())
@@ -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:
+49 -1
View File
@@ -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 {})
@@ -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
@@ -1176,6 +1176,14 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
if (!routeRows.isEmpty()) {
QuoteTemplateExcelSupport.copyMissingWeightPrices(landSegs, rawLand);
}
// 销售已选线路透传:跨境整车国内/境外段按 legs 写完整起讫(多备选混在 route_rows 时不能靠同序列过滤)
Object selectedLand = q.get("selected_land_route");
if (selectedLand == null) {
selectedLand = q.get("selectedLandRoute");
}
if (selectedLand != null) {
out.put("selected_land_route", selectedLand);
}
if (!landSegs.isEmpty()) {
out.put("landSegments", landSegs);
out.put("route_rows", landSegs);
@@ -18,7 +18,6 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.*;
/**
@@ -2798,7 +2797,7 @@ public final class QuoteTemplateExcelSupport {
int mappedStart = rowIdx0;
rowIdx0 = resolveRouteDataStartRow(sheet, rowIdx0, region.getIntValue("headerRow"));
String kind = landRouteSectionKind(sheet, region);
JSONArray sectionRoutes = routesForLandRouteSection(routes, sheet, region, i, regions.size());
JSONArray sectionRoutes = routesForLandRouteSection(routes, sheet, region, i, regions.size(), facts);
if (sectionRoutes == null || sectionRoutes.isEmpty()) {
continue;
}
@@ -2858,7 +2857,7 @@ public final class QuoteTemplateExcelSupport {
if (StringUtils.isBlank(col)) {
continue;
}
String price = resolveRouteVehicleRate(rowObj, facts, vk, vehicleRates.size());
String price = resolveRouteVehicleRate(rowObj, facts, vk, vehicleRates.size(), kind);
if (StringUtils.isNotBlank(price) && !"-".equals(price)) {
writeFeeCol(sheet, ri, col, price, yellow, sheet.getSheetName());
}
@@ -2937,6 +2936,19 @@ public final class QuoteTemplateExcelSupport {
return firstNonBlank(rowObj.getString("amount"), rowObj.getString("salePrice"),
rowObj.getString("costPrice"));
}
// 集拼矩阵副表头「最低收费」:有分档单价时只用 min_charge;分档壳无单价时回退整票价。
if (header.contains("最低收费")) {
String minCharge = firstNonBlank(rowObj.getString("min_charge"), rowObj.getString("minCharge"));
if (StringUtils.isNotBlank(minCharge) && !"-".equals(minCharge)) {
return formatTierPrice(minCharge);
}
JSONArray tiers = weightPricesOf(rowObj);
if (tiers != null && !tiers.isEmpty() && !anyWeightTierHasPrice(tiers)) {
return formatTierPrice(firstNonBlank(rowObj.getString("salePrice"),
rowObj.getString("costPrice"), rowObj.getString("amount")));
}
return "";
}
return "";
}
@@ -3092,11 +3104,19 @@ public final class QuoteTemplateExcelSupport {
}
/**
* 国内段写境内城市,境外段写境外城市。两段 TMS 腿按腿序号对上。
* 只有一对郑州→吉隆坡时:国内段只写郑州,境外段只写吉隆坡,不发明口岸。
* 国内段 / 境外段各写对应 TMS 腿的完整起讫。
* <p>
* 优先用 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 &gt; 同 segmentSequence 且腿数最多的备选 &gt; 原数组。
*/
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<String, JSONArray> 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<String, JSONArray> 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 "";
}
/**
* 车型列是否对得上当前段表头。
* <p>
* 跨境整车:询价/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 对上分档表头。对不上就空着,禁止把一档价抄进所有列。
* <p>
* 档内单价优先读 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 写入询价重量所在那一档。
* <p>
* 为何: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 补回。
*/
@@ -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 是是否含税,不写进税率格。