调价后的新价格写入报价单;多段段前的货物带到各段,协同群不再把体积里的星号吃掉。海运字段齐了先核对再查价。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-03 22:39:54 +08:00
co-authored by Cursor
parent 0a14b4cc15
commit 7c77e45e03
32 changed files with 1384 additions and 75 deletions
@@ -387,6 +387,13 @@ def harvest_oral_measures(text: str, facts: dict[str, str] | None = None) -> dic
hit = _RE_VOLUME_FANG.search(raw) or _RE_VOLUME_WORD.search(raw)
if hit:
merged["体积"] = hit.group(1)
else:
# 尺寸式不换算成立方,原文留给报价单;TMS 出站再乘。
from agent.schema.tms_air_query import dimension_volume_text
dim = dimension_volume_text(raw)
if dim:
merged["体积"] = dim
pack = _harvest_packaging(raw)
if pack:
merged["包装方式"] = pack
+241
View File
@@ -659,6 +659,246 @@ def _mark_sales_adjust(quote: dict[str, Any]) -> None:
quote["source_label"] = "销售调价"
def _sheet_follows_adjusted_fees(quote: dict[str, Any]) -> bool:
"""
产品、航线、销售改过的价,报价单必须跟着这份走。
航线只是确认沿用 TMS 时不改写舱位明细,避免把附加费之和当成总价。
"""
label = str(quote.get("source_label") or quote.get("sourceLabel") or "").replace(" ", "")
if "确认TMS" in label:
return False
code = str(quote.get("source") or "").strip().upper()
if code == "SALES_ADJUST" or "销售调价" in label:
return True
if code == "PRODUCT":
return True
return any(word in label for word in ("产品", "航线", "手工"))
def _json_struct(value: Any) -> tuple[Any, bool]:
"""字符串 JSON 先解开。返回 (对象, 原来是不是字符串)。"""
if isinstance(value, str) and value.strip()[:1] in {"{", "["}:
try:
return json.loads(value), True
except json.JSONDecodeError:
return value, False
return value, False
def _put_struct(quote: dict[str, Any], key: str, value: Any, was_str: bool) -> None:
quote[key] = json.dumps(value, ensure_ascii=False) if was_str else value
def _fee_title(item: dict[str, Any]) -> str:
return str(
item.get("name") or item.get("feeName") or item.get("chargeName") or item.get("label") or ""
).strip()
def _write_price(item: dict[str, Any], plain: str) -> None:
"""同一笔钱写进报价单会读的金额字段。单价有值也一起改,否则模板仍按旧单价重算。"""
if not plain:
return
for key in (
"amount",
"costPrice",
"cost_price",
"salePrice",
"sale_price",
"unitPrice",
"unit_price",
"value",
"price",
):
if item.get(key) not in (None, ""):
item[key] = plain
item["amount"] = plain
if item.get("costPrice") not in (None, "") or item.get("cost_price") not in (None, "") or "costPrice" in item:
item["costPrice"] = plain
def _match_fee_index(name: str, fees: list[dict[str, Any]], used: set[int]) -> int:
return next(
(
i
for i, fee in enumerate(fees)
if i not in used and fee_name_same(name, _fee_title(fee))
),
-1,
)
def _stamp_fee_items(quote: dict[str, Any], fees: list[dict[str, Any]]) -> None:
raw, was_str = _json_struct(quote.get("feeItems"))
items = [dict(x) for x in raw if isinstance(x, dict)] if isinstance(raw, list) else []
if not items:
items = [
{
"name": _fee_title(fee),
"feeName": _fee_title(fee),
"amount": _plain_amount(str(fee.get("amount") or "")),
"currency": fee.get("currency") or quote.get("currency") or "",
}
for fee in fees
if _fee_title(fee)
]
else:
used: set[int] = set()
for item in items:
idx = _match_fee_index(_fee_title(item), fees, used)
if idx < 0:
continue
used.add(idx)
_write_price(item, _plain_amount(str(fees[idx].get("amount") or "")))
if not item.get("name"):
item["name"] = _fee_title(fees[idx])
if not item.get("feeName"):
item["feeName"] = _fee_title(fees[idx])
if items:
_put_struct(quote, "feeItems", items, was_str)
def _stamp_one_route(row: dict[str, Any], fees: list[dict[str, Any]]) -> None:
"""一段线路:运输费写金额,提货/送货写到对应列。"""
if len(fees) == 1:
_write_price(row, _plain_amount(str(fees[0].get("amount") or "")))
return
transport = [fee for fee in fees if any(word in _fee_title(fee) for word in ("运输", "运费", "陆运"))]
if len(transport) == 1:
_write_price(row, _plain_amount(str(transport[0].get("amount") or "")))
for fee in fees:
title = _fee_title(fee)
plain = _plain_amount(str(fee.get("amount") or ""))
if not plain:
continue
if "提货" in title:
row["pickup_fee"] = plain
row["pickupFee"] = plain
elif "送货" in title or "派送" in title:
row["delivery_fee"] = plain
row["deliveryFee"] = plain
def _stamp_route_list(rows: list[Any], fees: list[dict[str, Any]]) -> None:
legs = [row for row in rows if isinstance(row, dict)]
if not legs or not fees:
return
if len(legs) == len(fees):
for leg, fee in zip(legs, fees):
_write_price(leg, _plain_amount(str(fee.get("amount") or "")))
return
if len(legs) == 1:
_stamp_one_route(legs[0], fees)
def _stamp_route_copies(quote: dict[str, Any], fees: list[dict[str, Any]]) -> None:
"""陆运报价单优先读 route_rows,不读已经改过的 fee_lines。"""
for key in ("route_rows", "landSegments", "land_segments"):
raw, was_str = _json_struct(quote.get(key))
if not isinstance(raw, list) or not raw:
continue
copied = [dict(x) if isinstance(x, dict) else x for x in raw]
_stamp_route_list(copied, fees)
_put_struct(quote, key, copied, was_str)
for key in ("selected_land_route", "selectedLandRoute"):
raw, was_str = _json_struct(quote.get(key))
if not isinstance(raw, dict):
continue
selected = dict(raw)
legs = [dict(x) if isinstance(x, dict) else x for x in list(selected.get("legs") or [])]
if not legs:
continue
_stamp_route_list(legs, fees)
selected["legs"] = legs
_put_struct(quote, key, selected, was_str)
def _stamp_buckets(quote: dict[str, Any], fees: list[dict[str, Any]]) -> None:
"""海运报价单按始发港/海运/目的港三段读,不能只改扁平费用行。"""
for key, _label in _SEA_BUCKETS:
items = _bucket_items(quote, key)
if not items:
continue
used: set[int] = set()
changed = False
for item in items:
idx = _match_fee_index(_item_fee_name(item), fees, used)
if idx < 0:
continue
used.add(idx)
plain = _plain_amount(str(fees[idx].get("amount") or ""))
if not plain:
continue
_write_price(item, plain)
changed = True
if changed:
_store_bucket(quote, key, items)
def _stamp_air_copies(quote: dict[str, Any], fees: list[dict[str, Any]]) -> None:
"""空运报价单读舱位 departureCharges 和 salePrice,不读改过的 fee_lines。"""
total_plain = _plain_amount(str(quote.get("total") or ""))
for key in ("airOptions", "air_options", "selected_air_option", "selectedAirOption"):
raw, was_str = _json_struct(quote.get(key))
single = isinstance(raw, dict)
options = [dict(raw)] if single else [dict(x) if isinstance(x, dict) else x for x in raw] if isinstance(raw, list) else []
if not options:
continue
for opt in options:
if not isinstance(opt, dict):
continue
charge_key = "departureCharges" if "departureCharges" in opt or "departure_charges" not in opt else "departure_charges"
if charge_key not in opt and "departureCharges" not in opt and "departure_charges" not in opt:
charge_key = ""
if charge_key:
charges, charges_str = _json_struct(opt.get(charge_key))
if isinstance(charges, list) and charges:
used: set[int] = set()
copied = [dict(x) if isinstance(x, dict) else x for x in charges]
for charge in copied:
if not isinstance(charge, dict):
continue
idx = _match_fee_index(_fee_title(charge), fees, used)
if idx < 0:
continue
used.add(idx)
_write_price(charge, _plain_amount(str(fees[idx].get("amount") or "")))
opt[charge_key] = json.dumps(copied, ensure_ascii=False) if charges_str else copied
if total_plain:
if opt.get("salePrice") not in (None, ""):
opt["salePrice"] = total_plain
if opt.get("sale_price") not in (None, ""):
opt["sale_price"] = total_plain
if single:
_put_struct(quote, key, options[0], was_str)
else:
_put_struct(quote, key, options, was_str)
def stamp_prices_for_sheet(quote: dict[str, Any] | None) -> dict[str, Any]:
"""
把当前生效费用写进报价单实际读取的每一份副本。
销售调价、产品报价、航线报价都会改 fee_lines,但陆运单读 route_rows,
海运单读三段费用,空运单读舱位 departureCharges。这些副本不跟着改,
报价单上就还是 TMS 原价。TMS 原价和「确认沿用 TMS」不改写这些副本。
"""
src = dict(quote or {})
if not _sheet_follows_adjusted_fees(src):
return src
fees = [dict(row) for row in quote_fee_rows(src)]
if not fees:
return src
src["fee_lines"] = fees
src["fee_rows"] = fees
_stamp_fee_items(src, fees)
_stamp_route_copies(src, fees)
_stamp_buckets(src, fees)
_stamp_air_copies(src, fees)
return src
def compute_profit(*, x: Optional[float], baseline: str) -> Optional[float]:
"""
只在销售调价后算一个利润点。
@@ -975,6 +1215,7 @@ def apply_adjust(
if tms_sale and "tms_baseline_sale" not in out:
out["tms_baseline_sale"] = tms_sale
_mark_sales_adjust(out)
out = stamp_prices_for_sheet(out)
logger.info(
"quote_adjust applied=%s unmatched=%s hint=%s profit=%s",
[x.get("name") for x in applied],
+41 -4
View File
@@ -144,7 +144,7 @@ def harvest_sea_trade_terms(text: str, facts: dict[str, str] | None = None) -> d
return merged
# 补问举例让销售直接回这两个词。整句只有其中一个才记成贸易条款,长句里的港到港仍给运输分类。
# 补问举例让销售直接回这两个词。整句只有其中一个,或长句里写了其中一个,都记成贸易条款。
_SHORT_SEA_TRADE_REPLY = re.compile(
r"^\s*(门到门|港到港|door\s*to\s*door|door-to-door|d2d)\s*[.。!!??]?\s*$",
re.IGNORECASE,
@@ -196,17 +196,54 @@ def drop_invented_door_trade_terms(text: str, facts: dict[str, str] | None = Non
return merged
def _spoken_door_in_text(text: str) -> str:
"""
长句里写了「门到门」或「港到港」就是贸易条款。
举例行「(如:门到门、港到港)」先丢掉,不能当成已填。
原话没有这两个词时返回空,禁止把模型猜的条款写回去。
同一句里两个词都有时,取靠后的那个。
「Port to Port」仍是运输分类,不在这里改写成贸易条款。
「广州港到巴生港」不含「港到港」这三个连续字,不能误收。
"""
cleaned = _without_clarify_examples(text or "")
found: list[tuple[int, str]] = []
for word in ("门到门", "港到港"):
idx = cleaned.rfind(word)
if idx >= 0:
found.append((idx, word))
door = re.search(r"door\s*[- ]?to\s*[- ]?door", cleaned, re.I)
if door:
found.append((door.start(), "门到门"))
if not found:
return ""
found.sort(key=lambda item: item[0])
return found[-1][1]
def apply_spoken_sea_trade_reply(text: str, facts: dict[str, str] | None = None) -> dict[str, str]:
"""
先丢掉模型冒充的门到门,再收下销售整句回复的门到门或港到港。
先丢掉模型冒充的门到门,再按原话收回贸易条款。
测服 2026-10-03:补问举例是门到门、港到港,销售回「门到门」仍被当成没填,并提示参数提取不全。
原话没出现这两个词时,旧草稿里的门到门继续丢掉。返回新 dict。
整句只回「门到门」或「港到港」要收下。长句里写了其中一个(例如先报货、再回「2」选海运)
也是贸易条款,不能因为原话没有「贸易条款」四个字就丢掉。
原话没出现「门到门」时,旧草稿里的门到门继续丢掉(WO202609200013)。
原话没出现「港到港」时,模型填的港到港同样丢掉。
销售已写「贸易条款:…」标签时以标签为准,不用句中的词覆盖。返回新 dict。
"""
merged = drop_invented_door_trade_terms(text, facts)
if _labeled_value(text or "", _LABELED_TRADE_TERMS):
return merged
spoken = short_sea_trade_reply(text)
if spoken:
merged["贸易条款"] = spoken
return merged
door = _spoken_door_in_text(text)
if door:
merged["贸易条款"] = door
return merged
if str(merged.get("贸易条款") or "").strip() == "港到港":
merged.pop("贸易条款", None)
return merged
@@ -89,6 +89,19 @@ def _first_number(raw: str) -> str:
return hit.group(1) if hit else ""
def dimension_volume_text(raw: str) -> str:
"""
原话里的尺寸式体积,保持销售写法,不在这里相乘。
「1.1*2.1*2.2」「1.1x1.2x3」都算体积原文。出站数字仍交给 volume_cbm_number。
箱型「40HQ*1」中间有字母,不算尺寸。没有尺寸返回空串。
"""
hit = _DIM_PRODUCT.search(raw or "")
if not hit:
return ""
return hit.group(0).strip()
def volume_cbm_number(raw: str) -> str:
"""
体积出站用数字:尺寸式先相乘;否则取第一个数。