完善空运锁舱与海运报价单单选回填,并增强模板预览与协同字段盖写。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-02 22:57:04 +08:00
co-authored by Cursor
parent 5402f94d30
commit d989154485
51 changed files with 4748 additions and 188 deletions
+88 -14
View File
@@ -20,7 +20,7 @@ _CLOSED = frozenset({"已成交", "未成交", "已关闭", "转人工"})
_QUOTED = frozenset({"已报价", "协商中"})
_PERCENT_UP = re.compile(
r"(上调|上涨|上升|上浮|提高|加|涨|升)\s*(\d+(?:\.\d+)?)\s*%"
r"(上调|上涨|上升|上浮|提高|提升|加|涨|升)\s*(\d+(?:\.\d+)?)\s*%"
)
_PERCENT_DOWN = re.compile(r"(下调|下降|降低|减|降)\s*(\d+(?:\.\d+)?)\s*%")
_TARGET = re.compile(
@@ -531,6 +531,7 @@ def extract_adjust(
if not raw:
return AdjustExtract(is_adjust=False)
names = [str(x).strip() for x in (fee_names or []) if str(x).strip()]
names_sorted = sorted(names, key=len, reverse=True)
use_model = allow_model
if use_model is None:
try:
@@ -558,11 +559,28 @@ def extract_adjust(
op = _item_from_chunk(raw, "")
if op:
modeled.items = [op]
# 模型常只抽出「费用合计」:本地再扫点名小计/费目,避免「始发港费用小计提升10%」被丢掉
local_items = _local_fee_items(raw, names_sorted)
if local_items:
seen = {
str(x.fee_name or "").strip()
for x in (modeled.items or [])
if str(x.fee_name or "").strip()
}
merged = list(modeled.items or [])
for it in local_items:
key = str(it.fee_name or "").strip()
if key and key not in seen:
merged.append(it)
seen.add(key)
modeled = AdjustExtract(
is_adjust=True,
items=merged,
wants_total=bool(modeled.wants_total),
)
return modeled
except Exception:
logger.exception("调价模型抽取失败,回退本地")
names_sorted = sorted(names, key=len, reverse=True)
blob = raw.replace(" ", "")
total_hit = any(w in raw for w in _TOTAL_WORDS)
has_op = bool(_PERCENT_UP.search(raw) or _PERCENT_DOWN.search(raw) or _TARGET.search(raw))
fee_in_text = [n for n in names_sorted if n and n in raw]
@@ -571,6 +589,17 @@ def extract_adjust(
return AdjustExtract(is_adjust=True, wants_total=True, items=[op] if op else [])
if _QUOTE_WORD in raw and has_op and not fee_in_text and not total_hit:
return AdjustExtract(is_adjust=True, wants_total=False)
items = _local_fee_items(raw, names_sorted)
if not items and has_op:
# 说了费用口语但和名单不完全一致:整句再扫一次
item = _item_from_chunk(raw, "")
if item:
items.append(item)
return AdjustExtract(is_adjust=bool(items or has_op), items=items)
def _local_fee_items(raw: str, names_sorted: list[str]) -> list[AdjustItem]:
"""按分句把点名费目/小计抽成调价项。提升/上调等口令都认。"""
items: list[AdjustItem] = []
for part in _SPLIT.split(raw) or [raw]:
chunk = (part or "").strip()
@@ -585,16 +614,15 @@ def extract_adjust(
found = _FEE_TOKEN.findall(chunk)
spoken = found[-1] if found else ""
if not spoken:
continue
# 「费用合计提升10%」:名单里有费用合计时上面已命中;否则用合计词
if any(w in chunk for w in _TOTAL_WORDS):
spoken = "费用合计"
else:
continue
item = _item_from_chunk(chunk, spoken)
if item:
items.append(item)
if not items and has_op:
# 说了费用口语但和名单不完全一致:整句再扫一次
item = _item_from_chunk(raw, "")
if item:
items.append(item)
return AdjustExtract(is_adjust=bool(items or has_op), items=items)
return items
def _item_from_chunk(chunk: str, fee_name: str) -> Optional[AdjustItem]:
@@ -763,6 +791,42 @@ def _apply_total_only(
)
def _fold_total_into_items(
extract: AdjustExtract,
*,
business_line: str = "",
quote: dict[str, Any] | None = None,
) -> AdjustExtract:
"""
合计与费目小计写在同一条指令里时:取消 wants_total 独占,把合计并进 items。
为何:wants_total 只改 total,海运「始发港费用小计」不会动,Excel 汇总仍是旧数。
"""
if not extract.wants_total:
return extract
named = [x for x in (extract.items or []) if str(x.fee_name or "").strip()]
if not named:
return extract
label = total_price_label(business_line, quote=quote)
has_total = any(fee_name_same(str(x.fee_name or ""), label) for x in named)
items = list(named)
if not has_total:
seed = named[0]
for raw in extract.items or []:
if not str(raw.fee_name or "").strip():
seed = raw
break
items.append(
AdjustItem(
fee_name=label,
kind=seed.kind,
direction=seed.direction,
value=seed.value,
)
)
return AdjustExtract(is_adjust=True, wants_total=False, items=items)
def apply_adjust(
quote: dict[str, Any] | None,
extract: AdjustExtract,
@@ -782,6 +846,8 @@ def apply_adjust(
rows = adjustable_rows(src)
if not extract.is_adjust:
return ApplyResult(ok=False, code="not_adjust", quote=src)
# 同时说了「始发港费用小计」和「费用合计」时:不能只走合计通道,否则小计不改、Excel 汇总也不动。
extract = _fold_total_into_items(extract, business_line=business_line, quote=src)
header = str(src.get("currency") or "").strip()
total_ccy = row_currency({"currency": header, "amount": src.get("total")}, header)
currencies = {row_currency(r, header) for r in rows if row_currency(r, header)}
@@ -798,6 +864,7 @@ def apply_adjust(
currency_hint = bool(multi)
total_amt, _ = parse_money(src.get("total"))
new_total = total_amt
total_touched = False
single_same_ccy = _single_line_same_currency(
[row for row in rows if not row.get("_subtotal_key") and not row.get("_is_total")],
total_ccy or header,
@@ -845,8 +912,10 @@ def apply_adjust(
bucket = str(row.get("_bucket") or "")
if row.get("_is_total"):
subtotal_only["__total__"] = new_amt
total_touched = True
continue
if row.get("_subtotal_key"):
# 只改小计数字写回 fee_total_*;明细是否另调看有没有点名费目
subtotal_only[bucket] = new_amt - old_amt
continue
if bucket:
@@ -861,10 +930,15 @@ def apply_adjust(
if (not multi) and new_total is not None:
if "__total__" in subtotal_only:
# 合计是整单改数;小计差额不再叠进合计,避免「合计×1.1 + 小计差额」重复
new_total = subtotal_only.pop("__total__")
for bucket, delta in subtotal_only.items():
if bucket not in touched_buckets:
new_total = new_total + delta
else:
for bucket, delta in subtotal_only.items():
if bucket not in touched_buckets:
new_total = new_total + delta
elif total_touched and "__total__" in subtotal_only:
# 多币种:点名「费用合计」仍改合计数字,不做汇率换算
new_total = subtotal_only["__total__"]
out = dict(src)
visible = []
@@ -876,7 +950,7 @@ def apply_adjust(
out["fee_rows"] = visible
out["fee_lines"] = visible
_write_adjusted_buckets(out, rows)
if applied and (not multi) and new_total is not None and new_total != total_amt:
if applied and new_total is not None and new_total != total_amt and ((not multi) or total_touched):
out["total"] = format_money(new_total, sample=str(src.get("total") or ""), currency=total_ccy)
if not applied:
code = "ambiguous" if ambiguous else "none_matched"
+57 -5
View File
@@ -1,7 +1,7 @@
"""
海运运输分类:只认 Port to Port、其他。
本文件职责:从原话收成标准词、生成补问清单。
本文件职责:从原话收成标准词、生成补问清单;贸易条款标签行原样优先。
禁止:发明第三项;把分类带去 TMS;改六态。
线程:纯函数,可在 Worker 槽调用。
"""
@@ -15,6 +15,15 @@ SEA_CLASSES = ("Port to Port", "其他")
# 补问清单下一行「(如:Port to Port、其他)」只是举例,不能当成销售已选。
_CLARIFY_EXAMPLE_LINE = re.compile(r"^\s*[((]如\s*[::]")
# 标签行优先:避免「贸易条款:Port to Port」整句扫描时把运输分类也收成 Port to Port。
_LABELED_SEA_CLASS = re.compile(
r"(?im)^\s*运输分类(?:(必填)|\(必填\))?\s*[::]\s*(.+?)\s*$"
)
_LABELED_TRADE_TERMS = re.compile(
r"(?im)^\s*贸易条款(?:(必填)|\(必填\)|(非必填)|\(非必填\))?\s*[::]\s*(.+?)\s*$"
)
_TRADE_TERMS_LINE = re.compile(r"(?im)^\s*贸易条款")
# 长词优先,避免 P2P 误伤其它词;门到门不在名单里。
_ALIASES = (
("port to port", "Port to Port"),
@@ -58,16 +67,39 @@ def _without_clarify_examples(text: str) -> str:
)
def _without_trade_terms_lines(text: str) -> str:
"""整句扫运输分类时丢掉「贸易条款:…」行,避免条款里的 Port to Port 冒充分类。"""
return "\n".join(
line for line in (text or "").splitlines() if not _TRADE_TERMS_LINE.match(line.strip())
)
def _labeled_value(text: str, pattern: re.Pattern[str]) -> str:
"""取原文最后一次标签行冒号后的值;没有则空串。"""
last = ""
for hit in pattern.finditer(_without_clarify_examples(text or "")):
last = (hit.group(1) or "").strip()
return last
def harvest_sea_class(text: str, current: dict[str, str] | None = None) -> dict[str, str]:
"""
当前句能认则覆盖;认不出则保留已有标准值。
参数:本轮原文、已有 facts。返回合并后的 facts 副本。
举例行「(如:Port to Port、其他)」先丢掉,避免示例里的 Port to Port 盖掉冒号后的「其它」。
优先「运输分类:」标签行(WO202609280012:条款写 Port to Port、分类写其它)。
整句扫描时排除贸易条款行,避免条款里的 Port to Port 盖掉「其它」。
「其它」与「其他」都收成「其他」。非法旧值会丢掉,避免「门到门」一直占着必填位。
"""
merged = dict(current or {})
found = canonicalize_sea_class(_without_clarify_examples(text or ""))
cleaned = _without_clarify_examples(text or "")
labeled = _labeled_value(cleaned, _LABELED_SEA_CLASS)
if labeled:
found_labeled = canonicalize_sea_class(labeled)
if found_labeled:
merged["运输分类"] = found_labeled
return merged
# 整句扫描:不含贸易条款行
found = canonicalize_sea_class(_without_trade_terms_lines(cleaned))
if found:
merged["运输分类"] = found
return merged
@@ -98,6 +130,20 @@ _DOOR_TRADE_TERMS = frozenset(
)
def harvest_sea_trade_terms(text: str, facts: dict[str, str] | None = None) -> dict[str, str]:
"""
海运贸易条款:标签行冒号后原样优先。
WO202609280012:销售写「贸易条款:Port to Port」,模型却收成「门到门」。
有标签行则强制用标签值;门到门是否丢掉仍交给 drop_invented_door_trade_terms。
"""
merged = dict(facts or {})
labeled = _labeled_value(text or "", _LABELED_TRADE_TERMS)
if labeled:
merged["贸易条款"] = labeled
return merged
def drop_invented_door_trade_terms(text: str, facts: dict[str, str] | None = None) -> dict[str, str]:
"""
丢掉冒充贸易条款的「门到门」。
@@ -105,11 +151,17 @@ def drop_invented_door_trade_terms(text: str, facts: dict[str, str] | None = Non
测服 WO202609200013:原话没写贸易条款,确认卡却出现「贸易条款:门到门」。
门到门只是运输分类说法,不是 FOB/CIF 这类条款;原话没有「贸易条款」四字时不准收下。
销售明确写了「贸易条款:门到门」则保留原文,不改六态。
若标签行写的是 Port to Port / 港到港等,禁止再用模型的「门到门」盖掉。
"""
merged = dict(facts or {})
merged = harvest_sea_trade_terms(text, facts)
trade = str(merged.get("贸易条款") or "").strip()
if not trade:
return merged
labeled = _labeled_value(text or "", _LABELED_TRADE_TERMS)
if labeled:
# 标签已写死条款:以标签为准(含 Port to Port)
merged["贸易条款"] = labeled.strip()
return merged
if "贸易条款" in (text or ""):
return merged
if trade.lower().replace(" ", "") in {x.replace(" ", "") for x in _DOOR_TRADE_TERMS} or trade in _DOOR_TRADE_TERMS:
+31 -1
View File
@@ -78,6 +78,10 @@ _IATA_PAIR_BLOCK = frozenset(
}
)
_NUMBER = re.compile(r"(\d+(?:\.\d+)?)")
# 销售写尺寸当体积:1.1*1.2*3 / 1.1x1.2x3 / 1.1×1.2×3
_DIM_PRODUCT = re.compile(
r"(\d+(?:\.\d+)?)\s*[*xX×]\s*(\d+(?:\.\d+)?)(?:\s*[*xX×]\s*(\d+(?:\.\d+)?))?"
)
def _first_number(raw: str) -> str:
@@ -85,6 +89,32 @@ def _first_number(raw: str) -> str:
return hit.group(1) if hit else ""
def volume_cbm_number(raw: str) -> str:
"""
体积出站用数字:尺寸式先相乘;否则取第一个数。
例:1.1*1.2*3 → 3.96;2CBM → 2。空串表示没有可用体积。
"""
text = (raw or "").strip()
if not text:
return ""
dims = _DIM_PRODUCT.search(text)
if dims:
try:
a = float(dims.group(1))
b = float(dims.group(2))
c = float(dims.group(3) or "1")
product = a * b * c
if product <= 0:
return ""
# 去掉多余尾零,保持可读小数
text_num = f"{product:.10f}".rstrip("0").rstrip(".")
return text_num or ""
except ValueError:
return ""
return _first_number(text)
def resolve_airport_code(raw: str) -> str:
"""销售原文是地名或三字码时,给出 TMS originCode/destinationCode。"""
text = (raw or "").strip()
@@ -161,7 +191,7 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]:
cargo = str(src.get("品名") or "").strip()
pieces = _first_number(str(src.get("件数") or ""))
weight = _first_number(str(src.get("毛重") or ""))
volume = _first_number(str(src.get("体积") or ""))
volume = volume_cbm_number(str(src.get("体积") or ""))
pack_raw = str(src.get("包装方式") or src.get("包装类型") or "").strip()
pack_code = resolve_package_type(pack_raw)
quote_date = str(src.get("报价日期") or "").strip()