海运补问保留贸易条款;陆运多车型价表按列回填,车型码不进起始地
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 + 可选燃油附加费率/税率。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+39
-1
@@ -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>
|
||||
|
||||
+164
@@ -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;
|
||||
}
|
||||
|
||||
+79
@@ -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。
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user