海运补问保留贸易条款;陆运多车型价表按列回填,车型码不进起始地

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-29 10:53:30 +08:00
co-authored by Cursor
parent 56ba8e93b5
commit eca0a7ad93
10 changed files with 809 additions and 18 deletions
+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 + 可选燃油附加费率/税率。