Compare commits

..
11 changed files with 838 additions and 18 deletions
+29
View File
@@ -81,3 +81,32 @@ agent-release.zip
/友通达询价智能体监听包/
/_tmp_analyze_templates.py
/styles.css
# 本机调试/核对杂物(不进仓;消掉资源管理器叹号)
.vs/
/debug-*.json
/debug-*.log
/YTD*.docx
inquiry-agent/_debug_*.py
inquiry-agent/agent/debug_ndjson.py
inquiry-agent/scripts/_verify_quote/
inquiry-agent/scripts/_last_*.json
inquiry-api/**/db/_bot_reply_xlsx.json
inquiry-api/**/db/_gen_bot_reply_seed.py
inquiry-api/**/Debug*Test.java
inquiry-api/**/*DebugProbeTest.java
inquiry-backend/dist.bak-*/
inquiry-backend/scripts/
/tools/_*
/tools/*Probe.java
/tools/JeecgPwdEncrypt.java
/tools/*.json
/tools/*.txt
/tools/*.xlsx
/tools/e2e_*.py
/tools/sources.txt
/tools/wo*
/tools/clear_*
/tools/probe_*
/tools/implausible_*
/tools/tms_req_*
+234 -6
View File
@@ -1049,6 +1049,25 @@ _RE_WEIGHT_BAND_OPEN = re.compile(
r"(?P<price>\d+(?:\.\d+)?)",
re.I,
)
# 陆运整车产品口语:车型:5T/8T:13000 10T:14000 40HQ/45HQ:19500
# 标签可斜杠并价(5T/8T 同价);40/45HQ 要拆成 40HQ+45HQ。金额至少三位,避免吃到「段1」。
_RE_VEHICLE_RATE_ITEM = re.compile(
r"(?P<label>"
r"\d+(?:\.\d+)?\s*(?:T|吨|HQ|GP|尺)"
r"(?:\s*/\s*\d+(?:\.\d+)?\s*(?:T|吨|HQ|GP|尺)?)*"
r"|"
r"\d+\s*/\s*\d+\s*(?:HQ|GP|尺|T|吨)"
r")"
r"\s*[::=]?\s*"
rf"(?:(?P<ccy>{_CCY_TOKEN})\s*)?"
r"(?P<price>\d{3,}(?:\.\d+)?)",
re.I,
)
# 多车型价后的口语备注(非「备注:」格式)
_RE_VEHICLE_QUOTE_REMARK = re.compile(
r"(含\s*0\s*点\s*税?|含\s*0\s*点|国六车提货|不含税|含税)",
re.I,
)
_RE_FUEL_SURCHARGE_RATE = re.compile(
r"燃油附加费率\s*[::=]?\s*(?P<rate>\d+(?:\.\d+)?)\s*%?",
re.I,
@@ -1116,6 +1135,12 @@ _RE_FEE_PROJECT_PREFIX = re.compile(
r"^\s*(?P<project>空运段|空运|起运港|起运地|目的地|目的港|其它|其他)\s*[,,、::]\s*",
re.I,
)
# 航线常另起一行写「备注:1:250+ 吨上」;剥 @ 后换行常被压成空格,不能只认行首。
# 必须进费用行 remark,出单才有备注列(WO202610230028)。
_RE_STANDALONE_REMARK = re.compile(
r"备注\s*[::]\s*(?P<remark>[^\n\r@]+)",
re.I,
)
_FULLWIDTH_NUM = str.maketrans("0123456789.", "0123456789.")
@@ -1299,6 +1324,41 @@ def _strip_segment_index_labels(text: str) -> str:
return _RE_SEGMENT_INDEX_LABEL.sub(" ", text or "").strip()
def _extract_quote_remark(text: str) -> str:
"""
抽出文字报价里单独标注的「备注:…」。
例:备注:1:250+ 吨上 → 1:250+ 吨上。工单号、@机器人不进备注。
副作用:无。
"""
hit = _RE_STANDALONE_REMARK.search(text or "")
if not hit:
return ""
note = (hit.group("remark") or "").strip()
note = re.sub(r"\s*WO\d{12}\s*", " ", note, flags=re.I)
note = re.sub(r"\s+", " ", note).strip(" ,,;;")
return note
def _attach_quote_remark(rows: list[dict[str, str]], remark: str) -> list[dict[str, str]]:
"""
把整句备注挂到第一条非费率费用行。已有行内 remark 的不覆盖。
出单 Excel 备注列读 fee_rows[].remark;挂不到行上则报价单备注空白(WO028)。
"""
note = (remark or "").strip()
if not note or not rows:
return rows
for row in rows:
if "%" in str(row.get("amount") or ""):
continue
if str(row.get("remark") or "").strip():
continue
row["remark"] = note
break
return rows
def _parse_fee_rows(text: str, baseline_currency: str = "") -> list[dict[str, str]]:
"""
按行抽费用:码头操作费100CNY、报关费改为300CNY。
@@ -1338,8 +1398,24 @@ def _parse_fee_rows(text: str, baseline_currency: str = "") -> list[dict[str, st
priced_hits = 0
for hit in _RE_FEE_PRICED.finditer(raw):
priced_hits += 1
# 有「金额」用金额;只有单价时才用单价。禁止把单价误当成这一行的总金额。
amount = (hit.group("amount") or "").strip() or (hit.group("unit") or "").strip()
# 有「金额」用金额;无金额但有单价×数量时算行金额;只有单价才退回单价。
# 禁止「空运费单价CNY17 数量3500」把金额写成 17(WO202610230028)。
explicit_amount = _ascii_amount(hit.group("amount") or "")
unit_price = _ascii_amount(hit.group("unit") or "")
quantity = _ascii_amount(hit.group("qty") or "")
amount = explicit_amount
amount_source = "explicit"
if not amount and unit_price and quantity:
try:
product = float(unit_price) * float(quantity)
amount = f"{product:.10g}"
amount_source = "unit_x_qty"
except ValueError:
amount = unit_price
amount_source = "unit_fallback_bad_qty"
elif not amount:
amount = unit_price
amount_source = "unit_only"
ccy = hit.group("amt_ccy") or hit.group("unit_ccy") or ""
uom = (hit.groupdict().get("uom") or "").strip()
_add(
@@ -1347,8 +1423,8 @@ def _parse_fee_rows(text: str, baseline_currency: str = "") -> list[dict[str, st
_ascii_amount(amount),
ccy,
unit=uom,
unit_price=_ascii_amount(hit.group("unit") or ""),
quantity=_ascii_amount(hit.group("qty") or ""),
unit_price=unit_price,
quantity=quantity,
)
for hit in _RE_FEE_LINE.finditer(raw):
_add(
@@ -1398,6 +1474,8 @@ def extract_product_quote(
认不出价格返回空 dict,调用方回「没有识别到报价」。
群里常见「码头操作费100CNY / 报关费改为300CNY」,不能只把第一笔数当成海运费。
「空运费 单价…金额…」必须保留空运费,金额用「金额」后面的数,不用单价。
只写「单价…数量…」未写金额时,行金额按单价×数量(WO202610230028),禁止落成单价。
另起一行「备注:…」写入费用行 remark,出单备注列才有值(WO202610230028)。
陆运集拼:「2-500KG 单价CNY990,燃油附加费率1%,税率6%」走分档单价+费率,
禁止把公斤档下限「2」当成燃油附加费金额(WO021)。
无费用名时按 business_line/transport_mode 默认陆运费/空运费/海运费,禁止陆运硬套海运费。
@@ -1418,8 +1496,21 @@ def extract_product_quote(
# 只补报价日期/有效期/时效要求时直接空:年份、天数不能当费用金额。
if is_quote_date_only_message(raw) or is_non_fee_meta_only_message(raw):
return {}
quote_remark = _extract_quote_remark(stripped or raw)
# 多车型整车价必须先于普通费用行:否则「40HQ/45HQ:19500」会被抽成 HQ=19500。
vehicle = _parse_vehicle_rates_quote(stripped or raw, baseline_currency=base_ccy)
if vehicle:
note = quote_remark or str(vehicle.get("remark") or "")
if note:
vehicle["remark"] = note
_attach_quote_remark(list(vehicle.get("fee_rows") or []), note)
vehicle["fee_lines"] = list(vehicle.get("fee_rows") or [])
return vehicle
band = _parse_weight_band_quote(stripped or raw, baseline_currency=base_ccy)
if band:
if quote_remark:
_attach_quote_remark(list(band.get("fee_rows") or []), quote_remark)
band["fee_lines"] = list(band.get("fee_rows") or [])
return band
# 费率句若仍落到普通费用行,会把「燃油附加费」配上句首公斤数。先剥掉再抽。
# 必须用剥掉 @询价小助手 后的正文,否则句首「起运港、」分段标签匹配不到。
@@ -1428,8 +1519,11 @@ def extract_product_quote(
scrubbed = _strip_segment_index_labels(
_strip_rate_and_band_noise(_strip_non_fee_meta_chunks(stripped or raw))
)
# 备注整段不参与费用金额匹配,避免「备注:1:…」里的数字干扰。
scrubbed = _RE_STANDALONE_REMARK.sub(" ", scrubbed)
rows = _parse_fee_rows(scrubbed, baseline_currency=base_ccy) if scrubbed.strip() else []
if rows:
_attach_quote_remark(rows, quote_remark)
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"]
@@ -1471,13 +1565,15 @@ def extract_product_quote(
elif fee_name == "燃油附加费":
# 「燃油附加费率」误抽成费名时:陆运/空运用本单默认;海运旧行为落陆运费,避免当海运费金额。
fee_name = default_fee if default_fee != "海运费" else "陆运费"
fee_row: dict[str, str] = {"name": fee_name, "amount": amount, "currency": ccy}
rows = _attach_quote_remark([fee_row], quote_remark)
out = {
"total": amount,
"currency": ccy,
"source_label": "产品报价",
"raw_text": raw,
"fee_rows": [{"name": fee_name, "amount": amount, "currency": ccy}],
"fee_lines": [{"name": fee_name, "amount": amount, "currency": ccy}],
"fee_rows": rows,
"fee_lines": list(rows),
}
out.update(_extract_rate_fields(raw))
return out
@@ -1517,6 +1613,138 @@ def _strip_rate_and_band_noise(text: str) -> str:
return cleaned
def _normalize_vehicle_rate_token(token: str, *, sibling_suffix: str = "") -> str:
"""
车型口语归一:去空格、全角;裸「40」在「40/45HQ」里补成 40HQ。
副作用:无。
"""
raw = (token or "").strip().replace(" ", "").replace(" ", "")
if not raw:
return ""
raw = raw.upper().replace("T", "T").replace("吨", "T")
if sibling_suffix and not re.search(r"(T|HQ|GP|尺|厢车)$", raw, re.I):
if re.fullmatch(r"\d+(?:\.\d+)?", raw):
raw = raw + sibling_suffix.upper()
return raw
def _expand_vehicle_rate_label(label: str) -> list[str]:
"""
「5T/8T」「40HQ/45HQ」「40/45HQ」拆成可对表头的车型键。
斜杠并价时每个车型各占一列;禁止把整段「5T/8T」当唯一键(模板列是裸 5T/8T)。
"""
text = (label or "").strip()
if not text:
return []
parts = [p.strip() for p in re.split(r"[//]", text) if p.strip()]
suffix = ""
for part in reversed(parts):
hit = re.search(r"(T|HQ|GP|尺|厢车)$", part.replace(" ", ""), re.I)
if hit:
suffix = hit.group(1)
break
out: list[str] = []
seen: set[str] = set()
for part in parts:
key = _normalize_vehicle_rate_token(part, sibling_suffix=suffix)
if not key or key in seen:
continue
seen.add(key)
out.append(key)
return out
def _harvest_vehicle_quote_remark(text: str) -> str:
"""多车型价后的「含0点税 / 国六车提货」等口语备注。"""
hits = [re.sub(r"\s+", "", m.group(0)) for m in _RE_VEHICLE_QUOTE_REMARK.finditer(text or "")]
if not hits:
return ""
# 去重保序:含0点税 + 国六车提货 一并进备注
seen: set[str] = set()
parts: list[str] = []
for hit in hits:
if hit in seen:
continue
seen.add(hit)
parts.append(hit)
return " ".join(parts)
def _parse_vehicle_rates_quote(text: str, baseline_currency: str = "") -> dict[str, Any]:
"""
陆运整车产品多车型价:车型:5T/8T:13000 10T:14000 40HQ/45HQ:19500。
产出 vehicleRates(键→金额)供主账按表头车型列写入;fee_rows 便于群摘要。
至少两档车型价,或正文含「车型」且至少一档,才认定,避免普通金额误伤。
无币种时继承 baseline,再退回 CNY(整车群报常见人民币)。
"""
raw = _ascii_amount(text or "")
if not raw.strip():
return {}
base_ccy = str(baseline_currency or "").strip().upper()
rates: dict[str, str] = {}
rows: list[dict[str, str]] = []
ccy = ""
hits = list(_RE_VEHICLE_RATE_ITEM.finditer(raw))
for hit in hits:
label = (hit.group("label") or "").strip()
price = _ascii_amount(hit.group("price") or "")
if not label or not price:
continue
line_ccy = _currency_of(raw, hit.group("ccy") or base_ccy) or base_ccy
ccy = ccy or line_ccy
keys = _expand_vehicle_rate_label(label)
if not keys:
continue
for key in keys:
rates[key] = price
rows.append(
{
"name": key,
"amount": price,
"currency": line_ccy or ccy or base_ccy or "CNY",
"unit_price": price,
}
)
if not rates:
return {}
has_vehicle_word = "车型" in raw
if len(rates) < 2 and not has_vehicle_word:
return {}
remark = _harvest_vehicle_quote_remark(raw)
# 多车型不要相加冒充合计;取第一档价作展示合计。
first_price = next(iter(rates.values()))
# fee_rows 用「陆运费」摘要一行(金额取第一档),禁止把 5T/40HQ 当费目名——
# 否则出单 writeFeeAmountsByName 会命中车型表头,把车型名写进国内段起始地。
summary_rows: list[dict[str, str]] = [
{
"name": "陆运费",
"amount": first_price,
"currency": ccy or base_ccy or "CNY",
"unit_price": first_price,
}
]
if remark:
_attach_quote_remark(summary_rows, remark)
out: dict[str, Any] = {
"total": first_price,
"currency": ccy or base_ccy or "CNY",
"source_label": "产品报价",
"raw_text": (text or "").strip(),
"fee_rows": summary_rows,
"fee_lines": list(summary_rows),
"vehicleRates": dict(rates),
"vehicle_rates": dict(rates),
# 群摘要仍可展示分车型明细(不出单写费目名)
"vehicle_fee_rows": rows,
}
if remark:
out["remark"] = remark
return out
def _parse_weight_band_quote(text: str, baseline_currency: str = "") -> dict[str, Any]:
"""
陆运集拼分档单价:2-500KG 单价CNY990 + 可选燃油附加费率/税率。
+80 -2
View File
@@ -967,13 +967,18 @@ def apply_product_quote(
send_group_text(client, chat_id, copy.group_quote_not_recognized())
return "quote_not_recognized"
quote = _attach_land_weight_band_segment(quote, view)
quote = _attach_land_vehicle_rates_segment(quote, view)
if _has_tms_quote(base):
_park_pending_quote(no, quote, sess, flow)
send_group_text(client, chat_id, copy.group_ask_tms_or_product())
return "wait_tms_choice"
# 分档单价是完整人工价(含费率),禁止和旧错误行(如燃油附加费=2)相加。
# 分档单价 / 多车型价表是完整人工价,禁止和旧错误行相加。
# 旧错默认「海运费」的陆运产品价:重报时按本单默认运费对齐后再盖,避免海运费+陆运费并排。
if _quote_fee_rows(base) and not (quote.get("weightPrices") or []):
if (
_quote_fee_rows(base)
and not (quote.get("weightPrices") or [])
and not (quote.get("vehicleRates") or quote.get("vehicle_rates") or [])
):
base = _align_wrong_default_freight(base, quote, business_line=line)
quote = merge_quote_fees(base, quote)
_save_product_quote(flow, no, quote, sess)
@@ -2009,6 +2014,79 @@ def _attach_land_weight_band_segment(
return src
def _land_od_from_view(view: dict[str, Any]) -> tuple[str, str]:
"""
工单询价事实里的起运/目的。
合同归一后陆运常用「起运港/目的港」「提货地址/收货地址」;
「目的地→目的港」「送货地址→收货地址」。产品口语「太原富士康--春疆」不当目的港。
"""
facts = dict((view or {}).get("facts") or {})
origin = str(
facts.get("起运港")
or facts.get("始发站")
or facts.get("始发地")
or facts.get("起运地")
or facts.get("提货地址")
or facts.get("origin")
or (view or {}).get("origin")
or ""
).strip()
dest = str(
facts.get("目的港")
or facts.get("目的地")
or facts.get("收货地址")
or facts.get("送货地址")
or facts.get("destination")
or (view or {}).get("destination")
or ""
).strip()
return origin, dest
def _attach_land_vehicle_rates_segment(
quote: dict[str, Any], view: dict[str, Any]
) -> dict[str, Any]:
"""
陆运产品多车型价:把 vehicleRates 挂到 landSegments,出单才能填 5T/10T/40HQ 列。
无车型价表时原样返回。OD 取工单询价事实;备注进段上 remark(含0点税等)。
不写裸 salePrice 冒充单车型,避免多列表头误灌同一价。
"""
src = dict(quote or {})
rates = src.get("vehicleRates") or src.get("vehicle_rates")
if not isinstance(rates, dict) or not rates:
return src
clean = {
str(k).strip(): str(v).strip()
for k, v in rates.items()
if str(k).strip() and str(v).strip()
}
if not clean:
return src
origin, dest = _land_od_from_view(view)
remark = str(src.get("remark") or "").strip()
seg: dict[str, Any] = {
"origin": origin,
"destination": dest,
"vehicleRates": dict(clean),
"vehicle_rates": dict(clean),
"currency": str(src.get("currency") or "CNY"),
}
if remark:
seg["remark"] = remark
# 展示合计用第一档,不写入 salePrice,防止 resolve 在无 map 匹配时乱灌。
src["landSegments"] = [seg]
src["route_rows"] = [seg]
src["vehicleRates"] = dict(clean)
src["vehicle_rates"] = dict(clean)
# 多车型价只走 vehicleRates 矩阵列;fee_rows 若仍是「10T/40HQ」会被出单当成费目名
# 写进国内段「起始地」(运行证据:国内段出现 10T/40HQ/45HQ 行)。挂段后清空费目行。
src["fee_rows"] = []
src["fee_lines"] = []
return src
def _save_product_quote(
flow: Any,
no: str,
+16 -2
View File
@@ -237,13 +237,27 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
# 刚选定海运时,贸易条款、运输分类要对照定方式前的全部原话,不只看「海运」两个字。
source = waiting_mode_source(sess, text)
# 补问阶段 waiting_mode_source 只给本句;首句里的「港到港」必须拼回去,
# 否则 apply_spoken 会把会话已有条款当成模型冒充删掉(只补货量时再问贸易条款)。
trade_source = source
if sess and getattr(sess, "phase", "") in {"clarify", "wait_confirm", "sensitive_block"}:
trade_source = stack_user_text(getattr(sess, "immutable_text", "") or "", text)
# 整句只回「港到港」是贸易条款举例,不要改写成运输分类。
class_source = "" if short_sea_trade_reply(source) == "港到港" else source
facts = harvest_sea_class(class_source, facts)
# 贸易条款标签行原样优先;整句回复或长句里写了门到门/港到港都要收下。
# 原话没出现这两个词时,模型冒充的门到门、港到港继续丢掉。
facts = apply_spoken_sea_trade_reply(source, facts)
spoken = harvest_spoken_trade_term(source)
# 补问用 trade_source(首句+本句),避免只补货量时丢掉已确认的港到港。
facts = apply_spoken_sea_trade_reply(trade_source, facts)
# 本句显式改条款时覆盖历史(短回门到门/港到港,或写出 FOB/CIF 等)。
short_now = short_sea_trade_reply(text)
if short_now:
facts["贸易条款"] = short_now
else:
spoken_now = harvest_spoken_trade_term(text)
if spoken_now.get("贸易条款"):
facts["贸易条款"] = spoken_now["贸易条款"]
spoken = harvest_spoken_trade_term(trade_source)
if spoken.get("贸易条款") and not str(facts.get("贸易条款") or "").strip():
facts["贸易条款"] = spoken["贸易条款"]
check = validate_required_fields(facts=facts, business_line="SEA")
+53 -7
View File
@@ -169,15 +169,35 @@ ALL_COLLAB_KEYS = tuple(
# 口语里已经带上 TMS 文档规定的单位时,补进空字段。
# 只认「数字+单位」,不猜港口,不把长宽高换算成立方。
_RE_PIECES = re.compile(r"(?<![\d.])(\d+)\s*(件|pcs)\b", re.I)
_RE_WEIGHT = re.compile(r"(?<![\d.])(\d+(?:\.\d+)?)\s*(kgs|kg|公斤|千克)\b", re.I)
# 单位后不用 Unicode \b:CBM/KG 紧贴中文时 \w 含汉字,\b 不成立会整段漏抽。
_RE_PIECES = re.compile(r"(?<![\d.])(\d+)\s*(件|pcs)(?![A-Za-z0-9])", re.I)
_RE_WEIGHT = re.compile(
r"(?<![\d.])(\d+(?:\.\d+)?)\s*(kgs|kg|公斤|千克)(?![A-Za-z0-9])",
re.I,
)
_RE_VOLUME = re.compile(
r"(?<![\d.])(\d+(?:\.\d+)?)\s*(cbm|立方米|立方|m3|m³)\b",
r"(?<![\d.])(\d+(?:\.\d+)?)\s*(cbm|立方米|立方|m3|m³)(?![A-Za-z0-9])",
re.I,
)
_RE_VOLUME_FANG = re.compile(r"(?<![\d.])(\d+(?:\.\d+)?)\s*方(?!向)")
_RE_WEIGHT_WORD = re.compile(r"(?:毛重|重量)\s*[::]?\s*(\d+(?:\.\d+)?)")
_RE_VOLUME_WORD = re.compile(r"体积\s*[::]?\s*(\d+(?:\.\d+)?)")
# 销售常把小数点两侧空开,或误用全角点/中文句号:578. 5KG、1. 03CBM。
# 只在后面跟重量/体积单位时收拢,避免误伤普通句号。
_RE_BROKEN_DECIMAL = re.compile(
r"(\d+)\s*[..。]\s*(\d+)(?=\s*(?:kgs?|公斤|千克|cbm|立方米|立方|m3|m³|方))",
re.I,
)
def _glue_broken_decimals(text: str) -> str:
"""
把「578. 5KG」「1. 03CBM」收成「578.5KG」「1.03CBM」。
中英文句号/全角点两侧有空格时,机读否则只命中小数段变成 5KG/03CBM。
副作用:无。不改主账。
"""
return _RE_BROKEN_DECIMAL.sub(r"\1.\2", text or "")
# 销售原话里的包装词:当前句出现则覆盖旧值,散货也要收下
_PACK_WORDS = ("纸箱", "木箱", "托盘", "卡板", "散货")
# 销售口中的报价日期。内部键仍是报价日期。货好时间、提货地址不是这里。
@@ -439,10 +459,12 @@ def harvest_oral_measures(text: str, facts: dict[str, str] | None = None) -> dic
当前句写了的覆盖旧值:散货替换托盘,9月16日写入报价日期。
补问复制清单的示例行不参与机读。
小数点两侧空格先收拢,再匹配单位。
副作用:无。不改主账。不猜港口。
"""
merged = dict(facts or {})
raw = _without_clarify_examples(text or "")
raw0 = _without_clarify_examples(text or "")
raw = _glue_broken_decimals(raw0)
hit = _RE_PIECES.search(raw)
if hit:
merged["件数"] = f"{hit.group(1)}{hit.group(2)}"
@@ -656,6 +678,27 @@ def looks_like_container_spec(text: str) -> bool:
return bool(_CONTAINER_SPEC.search(text or ""))
# 货量里除箱型外还有装满/吨等意图时,不能当「误写入的箱型」丢掉。
_CARGO_QTY_INTENT = re.compile(
r"装满|满柜|满载|吨|件|KG|KGS|CBM|立方|\d+\s*方|票|报价\s*\d|\d+\s*个价格",
re.I,
)
def is_container_only_cargo_qty(text: str) -> bool:
"""
货量是否整段只是箱型箱量写法。
「40HQ*1」「1x40HQ」应从货量剔除;「40HQ、20GP 装满,报价2个价格」是销售在答货物数量,保留。
"""
raw = (text or "").strip()
if not looks_like_container_spec(raw):
return False
if _CARGO_QTY_INTENT.search(raw):
return False
return True
def promote_land_vehicle_from_container(facts: dict[str, str] | None) -> dict[str, str]:
"""
陆运时把误落入「箱型箱量」的车型改写到「车型/数量」。
@@ -697,18 +740,21 @@ def project_sea_cargo_qty(facts: dict[str, str] | None) -> dict[str, str]:
海运货物数量投影。
抽字段常把「20件」放进件数,又把「40HQ*1」写进货量。
货量只要长得像箱型,就丢掉,改用件数里的数量;件数也是箱型则不搬。
箱型箱量空着时,把这串箱型补到箱型箱量。不限具体件数。
货量若整段只是箱型写法,就丢掉,改用件数里的数量;件数也是箱型则不搬。
「40HQ 装满」这类带货量意图的保留,不能再逼销售重填。
箱型箱量空着时,把纯箱型串补到箱型箱量。不限具体件数。
"""
merged = dict(facts or {})
qty = (merged.get("货量") or merged.get("货物数量") or "").strip()
pieces = (merged.get("件数") or "").strip()
if looks_like_container_spec(qty):
if is_container_only_cargo_qty(qty):
if not (merged.get("箱型箱量") or "").strip():
merged["箱型箱量"] = qty
merged.pop("货量", None)
merged.pop("货物数量", None)
qty = ""
elif looks_like_container_spec(qty) and _CARGO_QTY_INTENT.search(qty):
pass
if not qty and pieces and not looks_like_container_spec(pieces):
merged["货量"] = pieces
return merged
+15
View File
@@ -335,6 +335,21 @@ class OralExtractTests(unittest.TestCase):
self.assertIn("重量(KG):1.5KG", shown)
self.assertNotIn("1.5KGS", shown)
def test_broken_decimal_space_keeps_full_weight_volume(self) -> None:
"""小数点后空格不得只抽到小数段:578. 5KG/1. 03CBM → 578.5KG/1.03CBM。"""
from agent.schema.field_validate import harvest_oral_measures
text = "1个木箱/578. 5KG/1. 03CBM空运普货1箱托盘到MAA"
facts = harvest_oral_measures(text, {})
self.assertEqual(facts.get("毛重"), "578.5KG")
self.assertEqual(facts.get("体积"), "1.03CBM")
self.assertNotEqual(facts.get("毛重"), "5KG")
self.assertNotEqual(facts.get("体积"), "03CBM")
fullwidth = harvest_oral_measures("茶叶,578. 5KG,1。03CBM,托盘", {})
self.assertEqual(fullwidth.get("毛重"), "578.5KG")
self.assertEqual(fullwidth.get("体积"), "1.03CBM")
def test_dimension_text_is_volume_without_multiplying(self) -> None:
from agent.schema.field_validate import harvest_oral_measures
@@ -360,6 +360,43 @@ class LandGroupCollabTests(unittest.TestCase):
"只有这张单的销售能调价。航线请继续报价,价格未改。",
)
def test_land_multi_vehicle_rates_attach_segment(self) -> None:
"""多车型价挂到 landSegments,出单才能按车型列写入;OD 用工单事实。"""
from agent.llm.extract_text import extract_product_quote
from agent.policy.sea_group_ops import _attach_land_vehicle_rates_segment
quote = extract_product_quote(
"太原富士康--春疆\n车型:\n5T/8T:13000\n10T:14000\n40HQ/45HQ:19500\n含0点税",
business_line="LAND",
)
view = {
"facts": {
"起运港": "太原富士康",
"目的港": "越南北寧市南山工業區",
}
}
out = _attach_land_vehicle_rates_segment(quote, view)
segs = list(out.get("landSegments") or [])
self.assertEqual(len(segs), 1)
seg = segs[0]
self.assertEqual(str(seg.get("origin") or ""), "太原富士康")
self.assertEqual(str(seg.get("destination") or ""), "越南北寧市南山工業區")
rates = dict(seg.get("vehicleRates") or {})
self.assertEqual(rates.get("5T"), "13000")
self.assertEqual(rates.get("45HQ"), "19500")
self.assertEqual(str(seg.get("remark") or ""), "含0点税")
self.assertFalse(str(seg.get("salePrice") or "").strip())
self.assertEqual(list(out.get("fee_rows") or []), [])
# 收货地址归一键也能取到目的地
out2 = _attach_land_vehicle_rates_segment(
quote,
{"facts": {"提货地址": "太原富士康", "收货地址": "越南北寧市南山工業區"}},
)
self.assertEqual(
str((out2.get("landSegments") or [{}])[0].get("destination") or ""),
"越南北寧市南山工業區",
)
def test_land_bare_price_defaults_to_land_fee(self) -> None:
"""陆运「价格为1RMB/KG」无费用名时默认陆运费,禁止落成海运费(WO118)。"""
from agent.llm.extract_text import extract_product_quote
@@ -518,6 +518,41 @@ class SeaTextInquiryTests(unittest.TestCase):
self.assertNotIn("1.贸易条款(必填)", body)
self.assertNotEqual(phase, "need_mode")
def test_clarify_qty_keeps_gangdaogang_and_accepts_full_box_qty(self) -> None:
"""已有港到港时只补「装满」货量:不再追问货物数量和贸易条款。"""
inq = "东莞到新西兰利特尔顿港和奥克兰港!报一下40HQ和20GP的价格,产品是装奶粉的罐子,条款港到港"
facts = {
"起运港": "东莞",
"目的港": "利特尔顿港",
"品名": "奶粉罐子",
"整柜或拼柜": "整柜",
"箱型箱量": "40HQ、20GP",
"贸易条款": "港到港",
"运输分类": "Port to Port",
}
phase = self.flow.on_text(
sender_id="s-full-box",
text=inq,
reply=self._reply,
injected_facts=facts,
injected_mode="SEA",
)
self.assertEqual(phase, "clarify")
self.replies.clear()
phase = self.flow.on_text(
sender_id="s-full-box",
text="1.货物数量(必填):40HQ、20GP 装满,报价2个价格\n(如:20吨)",
reply=self._reply,
)
self.assertEqual(phase, "wait_confirm")
sess = self.flow.session_of("s-full-box")
self.assertEqual(sess.facts.get("贸易条款"), "港到港")
self.assertIn("装满", str(sess.facts.get("货量") or ""))
body = "\n".join(self.replies)
self.assertIn("贸易条款(必填):港到港", body)
self.assertNotIn("1.货物数量(必填):\n", body)
self.assertNotIn("贸易条款(必填):\n(如:门到门、港到港)", body)
def test_harvest_class_from_reply_then_quotes(self) -> None:
first = dict(COMPLETE_SEA)
first.pop("运输分类")
@@ -1341,6 +1376,45 @@ class SeaGroupCollabFieldTests(unittest.TestCase):
self.assertEqual(str(rows["海运费"].get("amount") or ""), "100.00")
self.assertEqual(str(rows["燃油附加费"].get("amount") or ""), "15.00")
def test_land_multi_vehicle_rates_not_hq_fee(self) -> None:
"""陆运整车多车型价:不能抽成 HQ=19500;要拆成 5T/8T/10T/40HQ/45HQ 价表。"""
from agent.llm.extract_text import extract_product_quote
quote = extract_product_quote(
"太原富士康--春疆\n车型:\n5T/8T:13000\n10T:14000\n40HQ/45HQ:19500\n 含0点税",
business_line="LAND",
transport_mode="陆运",
)
rates = dict(quote.get("vehicleRates") or {})
self.assertEqual(rates.get("5T"), "13000")
self.assertEqual(rates.get("8T"), "13000")
self.assertEqual(rates.get("10T"), "14000")
self.assertEqual(rates.get("40HQ"), "19500")
self.assertEqual(rates.get("45HQ"), "19500")
self.assertEqual(str(quote.get("remark") or ""), "含0点税")
names = {str(x.get("name") or "") for x in (quote.get("fee_rows") or [])}
self.assertNotIn("HQ", names)
self.assertNotIn("5T", names)
self.assertNotIn("40HQ", names)
self.assertIn("陆运费", names)
veh_names = {str(x.get("name") or "") for x in (quote.get("vehicle_fee_rows") or [])}
self.assertIn("5T", veh_names)
self.assertIn("40HQ", veh_names)
def test_land_multi_vehicle_40_slash_45hq(self) -> None:
"""口语 40/45HQ 19500 要拆成 40HQ+45HQ,不能只认 45HQ。"""
from agent.llm.extract_text import extract_product_quote
quote = extract_product_quote(
"车型:5T/8T:13000 10T:14000 40/45HQ 19500 含0点 国六车提货",
business_line="LAND",
)
rates = dict(quote.get("vehicleRates") or {})
self.assertEqual(rates.get("40HQ"), "19500")
self.assertEqual(rates.get("45HQ"), "19500")
self.assertEqual(rates.get("5T"), "13000")
self.assertIn("含0点", str(quote.get("remark") or ""))
def test_land_weight_band_unit_price_and_rates(self) -> None:
"""陆运集拼:2-500KG 单价 + 燃油附加费率/税率,不能把档下限 2 当成燃油附加费。"""
from agent.llm.extract_text import extract_product_quote
@@ -1379,6 +1453,24 @@ class SeaGroupCollabFieldTests(unittest.TestCase):
self.assertEqual(str(rows["空运费"].get("quantity") or ""), "100")
self.assertEqual(str(quote.get("total") or ""), "199.0")
def test_air_unit_qty_without_amount_multiplies(self) -> None:
"""WO028:只写单价+数量、不写金额时,行金额必须是单价×数量,不能落成单价。"""
from agent.llm.extract_text import extract_product_quote
quote = extract_product_quote(
"空运费单价CNY17.00 数量3500@智价通AIQ\n备注:1:250+ 吨上\nWO202610230028",
business_line="AIR",
transport_mode="空运",
)
rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
self.assertIn("空运费", rows)
self.assertEqual(str(rows["空运费"].get("unit_price") or ""), "17.00")
self.assertEqual(str(rows["空运费"].get("quantity") or ""), "3500")
self.assertEqual(str(rows["空运费"].get("amount") or ""), "59500")
self.assertEqual(str(rows["空运费"].get("currency") or ""), "CNY")
self.assertEqual(str(rows["空运费"].get("remark") or ""), "1:250+ 吨上")
self.assertEqual(str(quote.get("total") or ""), "59500.0")
def test_air_fee_priced_with_unit_between_name_and_unit_price(self) -> None:
"""WO003:空运费, KG, 单价…数量…金额… 中间夹单位时不能把单价当行金额。"""
from agent.llm.extract_text import extract_product_quote
@@ -1374,9 +1374,10 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
JSONArray routeRows = QuoteTemplateExcelSupport.parseFeeRows(q.get("route_rows"));
JSONArray rawLand = QuoteTemplateExcelSupport.parseFeeRows(q.get("landSegments"));
JSONArray landSegs = routeRows.isEmpty() ? rawLand : routeRows;
// 已落库的 route_rows 是规范化扁平行,分档公斤价还在原始 landSegments 上。
// 已落库的 route_rows 是规范化扁平行,分档公斤价 / 多车型价表还在原始 landSegments 上。
if (!routeRows.isEmpty()) {
QuoteTemplateExcelSupport.copyMissingWeightPrices(landSegs, rawLand);
QuoteTemplateExcelSupport.copyMissingVehicleRates(landSegs, rawLand);
}
// 销售已选线路透传:跨境整车国内/境外段按 legs 写完整起讫(多备选混在 route_rows 时不能靠同序列过滤)
Object selectedLand = q.get("selected_land_route");
@@ -1389,6 +1390,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
if (!landSegs.isEmpty()) {
// 产品文字价/分档壳偶发无 vehicleType:用询价「车型/数量」补上,否则整车矩阵车型列空、金额只进合计。
enrichLandRouteVehicleFromInquiry(landSegs, out, src);
// 产品多车型挂段时 OD 偶发空:用询价 facts 顶层 origin/destination 回填(境外段目的地不能空)。
fillBlankLandRouteOdFromInquiry(landSegs, out, src);
out.put("landSegments", landSegs);
out.put("route_rows", landSegs);
// 首段时效/燃油/税率抬到顶层,供侧栏与标签回填(WO006 燃油列不能空着有数却写不上)
@@ -1522,6 +1525,41 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
return "";
}
/**
* 线路行缺起运/目的时,用询价顶层 OD / 提货收货地址回填。
* <p>
* 为何:产品多车型价挂段时智能体偶发只带上提货地址、目的港键名对不上,境外段目的地空着。
* 副作用:只改传入行;不写库。
*/
private static void fillBlankLandRouteOdFromInquiry(JSONArray landSegs, JSONObject out,
Map<String, Object> src) {
if (landSegs == null || landSegs.isEmpty()) {
return;
}
String origin = firstNonBlankLocal(
out == null ? null : out.getString("origin"),
first(src, "起运港", "起运地", "始发站", "始发地", "提货地址", "origin", "pol"));
String dest = firstNonBlankLocal(
out == null ? null : out.getString("destination"),
out == null ? null : out.getString("pod"),
first(src, "目的港", "目的地", "收货地址", "送货地址", "destination", "pod"));
if (StringUtils.isBlank(origin) && StringUtils.isBlank(dest)) {
return;
}
for (int i = 0; i < landSegs.size(); i++) {
JSONObject seg = landSegs.getJSONObject(i);
if (seg == null) {
continue;
}
if (StringUtils.isBlank(seg.getString("origin")) && StringUtils.isNotBlank(origin)) {
seg.put("origin", origin);
}
if (StringUtils.isBlank(seg.getString("destination")) && StringUtils.isNotBlank(dest)) {
seg.put("destination", dest);
}
}
}
/**
* 线路行缺 vehicleType 时,从询价「车型/数量」拆出车型补上。
* <p>
@@ -4209,6 +4209,11 @@ public final class QuoteTemplateExcelSupport {
rowObj == null ? null : rowObj.getString("land_freight"),
facts == null ? null : facts.getString("land_freight"));
}
// 1.5) 产品多车型价表:一行挂多个车型价,按表头键对列(5T/8T 同价拆键后分别命中)
String fromMap = matchVehicleRateFromMap(rowObj, key, sectionKind);
if (StringUtils.isNotBlank(fromMap) && !"-".equals(fromMap)) {
return fromMap;
}
// 2) 车型列:仅当 vehicleType 对得上才写售价(含跨境国内/国外标注换算)
String vehicleType = firstNonBlank(rowObj == null ? null : rowObj.getString("vehicleType"),
rowObj == null ? null : rowObj.getString("vehicle_type"));
@@ -4227,6 +4232,67 @@ public final class QuoteTemplateExcelSupport {
return "";
}
/**
* 产品文字多车型价:landSegments.vehicleRates(或 vehicle_rates)按表头车型匹配金额。
* <p>
* 为何:群报「5T/8T:13000 10T:14000」会拆成多键同价;不能只用单行 vehicleType+salePrice,
* 否则只能填一列。对不上的列空着,禁止把第一档价灌进所有车型列。
*/
static String matchVehicleRateFromMap(JSONObject rowObj, String headerKey, String sectionKind) {
JSONObject rates = vehicleRatesOf(rowObj);
if (rates == null || rates.isEmpty() || StringUtils.isBlank(headerKey)) {
return "";
}
for (String vk : rates.keySet()) {
if (StringUtils.isBlank(vk)) {
continue;
}
if (!vehicleTypeMatchesSection(vk, headerKey, sectionKind)) {
continue;
}
String amount = StringUtils.trimToEmpty(rates.getString(vk));
if (StringUtils.isNotBlank(amount) && !"-".equals(amount)) {
return formatTierPrice(amount);
}
}
return "";
}
/**
* 读取行上多车型价表;兼容 camel / snake。值为对象 {5T:13000,...}。
*/
static JSONObject vehicleRatesOf(JSONObject row) {
if (row == null) {
return null;
}
Object raw = row.get("vehicleRates");
if (raw == null) {
raw = row.get("vehicle_rates");
}
if (raw instanceof JSONObject) {
return (JSONObject) raw;
}
if (raw instanceof String) {
String text = StringUtils.trimToEmpty((String) raw);
if (text.isEmpty()) {
return null;
}
try {
Object parsed = JSON.parse(text);
if (parsed instanceof JSONObject) {
return (JSONObject) parsed;
}
} catch (Exception ignore) {
return null;
}
}
if (raw instanceof Map) {
Object converted = JSON.toJSON(raw);
return converted instanceof JSONObject ? (JSONObject) converted : null;
}
return null;
}
/**
* 车型列是否对得上当前段表头。
* <p>
@@ -4673,6 +4739,64 @@ public final class QuoteTemplateExcelSupport {
}
}
/**
* 规范化 route_rows 会丢掉 vehicleRates。出单前从原始 landSegments 补回。
*/
public static void copyMissingVehicleRates(JSONArray rows, JSONArray rawLand) {
if (rows == null || rawLand == null || rawLand.isEmpty()) {
return;
}
for (int i = 0; i < rows.size(); i++) {
JSONObject row = rows.getJSONObject(i);
if (row == null) {
continue;
}
JSONObject existing = vehicleRatesOf(row);
if (existing != null && !existing.isEmpty()) {
continue;
}
JSONObject src = i < rawLand.size() ? rawLand.getJSONObject(i) : null;
if (vehicleRatesOf(src) == null) {
src = findLandSegmentByVehicleRates(rawLand, row);
}
JSONObject rates = vehicleRatesOf(src);
if (rates != null && !rates.isEmpty()) {
row.put("vehicleRates", rates);
row.put("vehicle_rates", rates);
}
}
}
private static JSONObject findLandSegmentByVehicleRates(JSONArray rawLand, JSONObject row) {
if (rawLand == null) {
return null;
}
String origin = StringUtils.trimToEmpty(firstNonBlank(
row == null ? null : row.getString("origin"),
row == null ? null : row.getString("originCode")));
String dest = StringUtils.trimToEmpty(firstNonBlank(
row == null ? null : row.getString("destination"),
row == null ? null : row.getString("destinationCode")));
for (int i = 0; i < rawLand.size(); i++) {
JSONObject one = rawLand.getJSONObject(i);
if (one == null || vehicleRatesOf(one) == null) {
continue;
}
String o = StringUtils.trimToEmpty(firstNonBlank(one.getString("origin"), one.getString("originCode")));
String d = StringUtils.trimToEmpty(firstNonBlank(one.getString("destination"), one.getString("destinationCode")));
if (origin.equals(o) && dest.equals(d)) {
return one;
}
}
for (int i = 0; i < rawLand.size(); i++) {
JSONObject one = rawLand.getJSONObject(i);
if (vehicleRatesOf(one) != null) {
return one;
}
}
return null;
}
private static JSONObject findLandSegmentByOriginDest(JSONArray rawLand, JSONObject row) {
if (rawLand == null || row == null) {
return null;
@@ -5250,12 +5374,16 @@ public final class QuoteTemplateExcelSupport {
/**
* 费用名,不是城市。写进「起始地/目的地」会把深圳盖成运输费。
* 只拦常见运价费目,不拦「春疆货场」这类地名。
* 车型码(5T/40HQ)也不是地名:产品多车型 fee_rows 误写进起始地列时要拦。
*/
static boolean looksLikeFeeNameNotPlace(String value) {
if (StringUtils.isBlank(value)) {
return false;
}
String t = value.trim().replaceAll("\\s+", "");
if (looksLikeVehicleTypeToken(t)) {
return true;
}
if ("运输费".equals(t) || "提货费".equals(t) || "送货费".equals(t) || "派送费".equals(t)
|| "陆运费".equals(t) || "干线费".equals(t) || "空运费".equals(t) || "海运费".equals(t)
|| "运费".equals(t) || "燃油附加费".equals(t) || "操作费".equals(t) || "文件费".equals(t)
@@ -5267,6 +5395,33 @@ public final class QuoteTemplateExcelSupport {
&& !t.contains("站") && !t.contains("仓");
}
/**
* 车型口语/表头码:5T、10T、40HQ、45HQ、3.5T、53尺 等。
* 产品多车型价表键与矩阵列头同形;禁止当费目名找格、当城市写进起始地。
*/
static boolean looksLikeVehicleTypeToken(String value) {
if (StringUtils.isBlank(value)) {
return false;
}
String t = normalizeVehicleCompareToken(value);
if (t.isEmpty()) {
return false;
}
if (t.matches("^\\d+(\\.\\d+)?T$")) {
return true;
}
if (t.matches("^\\d+(HQ|GP|RH)$")) {
return true;
}
if (t.matches("^\\d+尺$")) {
return true;
}
if (t.contains("平板") || t.contains("厢车") || t.contains("冷柜")) {
return t.length() <= 12;
}
return false;
}
/**
* 这些列头不是币种:写 CNY 会把覆盖区域的城市、或燃油附加费率的空格盖掉。
* 表头是「币种 / 币别」,或上面没有列头时,仍允许写币种。
@@ -7530,6 +7685,10 @@ public final class QuoteTemplateExcelSupport {
if (StringUtils.isBlank(name) || StringUtils.isBlank(amount) || "-".equals(amount)) {
continue;
}
// 多车型价键(5T/40HQ)禁止当费目名:会命中矩阵列头,把车型写进国内段起始地。
if (looksLikeVehicleTypeToken(name)) {
continue;
}
Cell nameCell = findCellByFeeName(sheet, name, usedRows);
if (nameCell == null) {
nameCell = findCellByLandFeeAlias(sheet, name, usedRows);
@@ -7954,6 +8113,11 @@ public final class QuoteTemplateExcelSupport {
if (StringUtils.isBlank(text) || isFeeTableHeader(text) || isSectionTitle(sheet, cell)) {
continue;
}
// 跨境整车矩阵列头「10T/40HQ」不是费用名称格
if (looksLikeVehicleTypeToken(text) || isLandRouteMatrixHeaderCell(text)
|| looksLikeVehicleDimensionText(text)) {
continue;
}
if (feeNameMatches(text, want)) {
return cell;
}
@@ -3411,6 +3411,85 @@ public class QuoteTemplateFillTest {
}
}
/**
* 产品多车型价表:一行 vehicleRates 按表头写入多列;禁止无匹配时把第一档灌进所有列。
*/
@Test
public void landProductMultiVehicleRatesFillColumns() throws Exception {
Path src = Files.createTempFile("quote-land-mv-src", ".xlsx");
Path out = Files.createTempFile("quote-land-mv-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("跨境整车");
Row header = row(sheet, 8);
header.createCell(1).setCellValue("起始地");
header.createCell(2).setCellValue("目的地");
header.createCell(3).setCellValue("5T");
header.createCell(4).setCellValue("8T");
header.createCell(5).setCellValue("10T");
header.createCell(6).setCellValue("40HQ");
header.createCell(7).setCellValue("45HQ");
header.createCell(8).setCellValue("备注");
row(sheet, 9);
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject mapping = new JSONObject(true);
mapping.put("sheet", "跨境整车");
JSONArray regions = new JSONArray();
JSONObject region = new JSONObject(true);
region.put("title", "项目一");
region.put("headerRow", 9);
region.put("dataStartRow", 10);
JSONObject cols = new JSONObject(true);
cols.put("origin", "B");
cols.put("destination", "C");
cols.put("remark", "I");
JSONObject rates = new JSONObject(true);
rates.put("5T", "D");
rates.put("8T", "E");
rates.put("10T", "F");
rates.put("40HQ", "G");
rates.put("45HQ", "H");
cols.put("vehicleRates", rates);
region.put("columns", cols);
regions.add(region);
mapping.put("routeRegions", regions);
JSONObject facts = new JSONObject(true);
facts.put("origin", "太原富士康");
facts.put("destination", "越南北寧市南山工業區");
facts.put("currency", "CNY");
facts.put("route_rows",
"[{\"origin\":\"太原富士康\",\"destination\":\"越南北寧市南山工業區\","
+ "\"currency\":\"CNY\",\"remark\":\"含0点税\","
+ "\"vehicleRates\":{\"5T\":\"13000\",\"8T\":\"13000\",\"10T\":\"14000\","
+ "\"40HQ\":\"19500\",\"45HQ\":\"19500\"}}]");
assertEquals("13000", QuoteTemplateExcelSupport.resolveRouteVehicleRate(
JSON.parseObject("{\"vehicleRates\":{\"5T\":\"13000\",\"10T\":\"14000\"}}"),
facts, "5T", 5, ""));
assertEquals("14000", QuoteTemplateExcelSupport.resolveRouteVehicleRate(
JSON.parseObject("{\"vehicleRates\":{\"5T\":\"13000\",\"10T\":\"14000\"}}"),
facts, "10T", 5, ""));
assertEquals("", QuoteTemplateExcelSupport.resolveRouteVehicleRate(
JSON.parseObject("{\"vehicleRates\":{\"5T\":\"13000\"}}"),
facts, "40HQ", 5, ""));
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("13000", normalizeMoney(read(sheet, 9, 3)));
assertEquals("13000", normalizeMoney(read(sheet, 9, 4)));
assertEquals("14000", normalizeMoney(read(sheet, 9, 5)));
assertEquals("19500", normalizeMoney(read(sheet, 9, 6)));
assertEquals("19500", normalizeMoney(read(sheet, 9, 7)));
assertEquals("含0点税", read(sheet, 9, 8));
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
/**
* 中港:TMS 别名经国内映射后再对等中港表头;跨境:吨位列与 3T 族境外 3.5T;禁止 5T 误命中 3.5T。
*/