海运补问保留贸易条款;陆运多车型价表按列回填,车型码不进起始地
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 + 可选燃油附加费率/税率。
|
||||
|
||||
Reference in New Issue
Block a user