海运补问保留贸易条款;陆运多车型价表按列回填,车型码不进起始地
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
|
||||
|
||||
Reference in New Issue
Block a user