销售侧汇总统一叫费用合计,并认准空运费的金额。

企微跳转改为账号密码登录,报价卡上的费用按原金额改回;航线按单价数量金额报价时不再记成海运费。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-24 11:15:33 +08:00
co-authored by Cursor
parent 84207bb4e5
commit 4dfd756bcf
24 changed files with 778 additions and 150 deletions
+308 -41
View File
@@ -28,7 +28,7 @@ _TARGET = re.compile(
)
_SPLIT = re.compile(r"[,,;;。!!??\n]|和(?=\S)")
_FEE_TOKEN = re.compile(r"([\u4e00-\u9fffA-Za-z0-9]{1,16}费)")
_TOTAL_WORDS = ("合计", "总价", "汇总价", "总销售价", "总成本价")
_TOTAL_WORDS = ("费用合计", "合计", "总价", "汇总价", "总销售价", "总成本价")
_QUOTE_WORD = "报价"
@@ -90,12 +90,17 @@ def _fee_key(name: str) -> str:
return raw.strip()
_TOTAL_ALIASES = frozenset({"费用合计", "总销售价", "总成本价", "汇总价", "总价"})
def fee_name_same(left: str, right: str) -> bool:
"""费用名对齐:海运费=海运费用;操作费 ≠ 码头操作费。"""
"""费用名对齐:海运费=海运费用;操作费 ≠ 码头操作费。汇总行的旧称呼也算同一行。"""
a = _fee_key(left)
b = _fee_key(right)
if not a or not b:
return False
if a in _TOTAL_ALIASES and b in _TOTAL_ALIASES:
return True
if a == b:
return True
if a == b + "用" or a == b + "费用" or b == a + "用" or b == a + "费用":
@@ -126,6 +131,240 @@ def match_fee_names(spoken: str, rows: list[dict[str, Any]]) -> list[str]:
return hits
# 海运卡按三段展示。小计是卡上的一行,不在扁平 fee_lines 里。
_SEA_BUCKETS = (
("polCostItems", "始发港费用小计"),
("oceanCostItems", "海运费小计"),
("podCostItems", "目的港费用小计"),
)
def _bucket_items(quote: dict[str, Any], key: str) -> list[dict[str, Any]]:
"""读海运分段。主账有时给列表,有时给 JSON 字符串。"""
raw = quote.get(key)
if raw in (None, ""):
raw = quote.get("fee_rows_" + key)
if isinstance(raw, str) and raw.strip():
try:
raw = json.loads(raw)
except json.JSONDecodeError:
return []
if not isinstance(raw, list):
return []
return [dict(row) for row in raw if isinstance(row, dict)]
def _item_fee_name(item: dict[str, Any]) -> str:
return str(item.get("name") or item.get("label") or item.get("feeName") or "").strip()
def _same_money(left: Any, right: Any) -> bool:
"""两条金额是否同一笔,用来把同名费用配回原来那一行。"""
a, _ = parse_money(left)
b, _ = parse_money(right)
if a is None or b is None:
return False
return abs(a - b) < 0.0001
def _plain_amount(amount: str) -> str:
"""写回分段时去掉币种前缀,避免小计被展示成 CNY CNY。"""
text = str(amount or "").strip()
for prefix in ("USD", "CNY", "EUR", "HKD", "JPY", "GBP", "RMB"):
if text.upper().startswith(prefix):
return text[len(prefix) :].strip()
return text
def _store_bucket(quote: dict[str, Any], key: str, items: list[dict[str, Any]]) -> None:
"""分段和 fee_rows_ 键一起改,卡和模板读的是这两处。"""
if key in quote or quote.get(key) not in (None, ""):
quote[key] = items
alt = "fee_rows_" + key
if alt in quote or quote.get(alt) not in (None, ""):
quote[alt] = json.dumps(items, ensure_ascii=False)
def adjustable_rows(quote: dict[str, Any] | None) -> list[dict[str, Any]]:
"""
调价能对上的行:扁平明细,加上海运分段里的费目和小计。
小计只用来对「始发港费用小计」这类话,不写进 fee_lines。
"""
src = dict(quote or {})
rows = [dict(x) for x in quote_fee_rows(src)]
for row in rows:
row["_origin"] = str(row.get("amount") or "")
header = str(src.get("currency") or src.get("currencyCode") or "").strip()
for key, sub_label in _SEA_BUCKETS:
used: set[int] = set()
for item in _bucket_items(src, key):
name = _item_fee_name(item)
amount = str(item.get("amount") or item.get("value") or item.get("salePrice") or "").strip()
if not name or not amount:
continue
idx = next(
(
i
for i, row in enumerate(rows)
if i not in used
and not row.get("_subtotal_key")
and not row.get("_is_total")
and fee_name_same(name, str(row.get("name") or ""))
and _same_money(row.get("_origin"), amount)
),
-1,
)
if idx < 0:
rows.append(
{
"name": name,
"amount": amount,
"currency": item.get("currency") or header,
"_bucket": key,
"_origin": amount,
}
)
used.add(len(rows) - 1)
else:
rows[idx]["_bucket"] = key
used.add(idx)
subtotal = str(src.get("fee_total_" + key) or "").strip()
if subtotal and subtotal != "-":
rows.append(
{
"name": sub_label,
"amount": subtotal,
"currency": header,
"_subtotal_key": "fee_total_" + key,
"_bucket": key,
}
)
_attach_land_legs(src, rows)
total = str(src.get("total") or "").strip()
if total and total != "-":
rows.append(
{
"name": total_price_label(
str(src.get("business_line") or ""),
quote=src,
),
"amount": total,
"currency": header,
"_is_total": True,
}
)
return rows
def _attach_land_legs(quote: dict[str, Any], rows: list[dict[str, Any]]) -> None:
"""陆运卡按段展示。同名费用每段一行,改价要写回路段成本。"""
from agent.schema.land_route_options import land_leg_fee_blocks
used: set[int] = set()
for index, (_title, fees, _eta) in enumerate(land_leg_fee_blocks(quote)):
for fee in fees:
name = str(fee.get("name") or "").strip()
amount = str(fee.get("amount") or "").strip()
if not name or not amount:
continue
idx = next(
(
i
for i, row in enumerate(rows)
if i not in used
and not row.get("_subtotal_key")
and not row.get("_is_total")
and fee_name_same(name, str(row.get("name") or ""))
and str(row.get("amount") or "") == amount
),
-1,
)
if idx < 0:
rows.append(
{
"name": name,
"amount": amount,
"currency": fee.get("currency") or quote.get("currency") or "",
"_land_leg": index,
}
)
used.add(len(rows) - 1)
else:
rows[idx]["_land_leg"] = index
used.add(idx)
def _write_adjusted_buckets(quote: dict[str, Any], rows: list[dict[str, Any]]) -> None:
"""把改过的费目和小计写回海运分段,价格卡才看得到。"""
for key, _label in _SEA_BUCKETS:
items = _bucket_items(quote, key)
changed = False
used: set[int] = set()
for item in items:
name = _item_fee_name(item)
origin = str(item.get("amount") or item.get("value") or item.get("salePrice") or "")
hit_i = next(
(
i
for i, row in enumerate(rows)
if i not in used
and row.get("_bucket") == key
and not row.get("_subtotal_key")
and fee_name_same(name, str(row.get("name") or ""))
and _same_money(row.get("_origin"), origin)
),
-1,
)
if hit_i < 0:
continue
used.add(hit_i)
plain = _plain_amount(str(rows[hit_i].get("amount") or ""))
if not plain:
continue
item["amount"] = plain
if item.get("name"):
item["name"] = name
for field_name in ("salePrice", "value", "unit_price"):
if item.get(field_name) not in (None, ""):
item[field_name] = plain
changed = True
if changed:
_store_bucket(quote, key, items)
for row in rows:
sub_key = str(row.get("_subtotal_key") or "")
if sub_key:
quote[sub_key] = _plain_amount(str(row.get("amount") or ""))
_write_land_legs(quote, rows)
def _write_land_legs(quote: dict[str, Any], rows: list[dict[str, Any]]) -> None:
"""路段成本改掉,下次出卡才不会又按旧成本显示。"""
from agent.schema.land_route_options import _selected_legs
legs = _selected_legs(quote)
if not legs:
return
for row in rows:
index = row.get("_land_leg")
if index is None or not isinstance(index, int) or index >= len(legs):
continue
plain = _plain_amount(str(row.get("amount") or ""))
if not plain:
continue
leg = legs[index]
leg["costPrice"] = plain
leg["cost_price"] = plain
if leg.get("salePrice") not in (None, ""):
leg["salePrice"] = plain
selected = quote.get("selected_land_route")
if isinstance(selected, dict):
selected["legs"] = legs
quote["selected_land_route"] = selected
if quote.get("landSegments") or quote.get("land_segments"):
quote["landSegments"] = legs
def quote_fee_rows(quote: dict[str, Any] | None) -> list[dict[str, Any]]:
"""抽出费用行。TMS 常把 fee_lines 存成 JSON 字符串。"""
src = dict(quote or {})
@@ -377,21 +616,13 @@ def total_price_label(
quote: dict[str, Any] | None = None,
) -> str:
"""
销售可见总价名称。
销售在企微看到的汇总行名称,一律叫费用合计。
空运一律总销售价。海运/陆运:TMS、产品报价是总成本价,销售调价后才是总销售价。
禁止写「合计」。Excel/PDF 栏目名不走这里。
不区分空运/海运/陆运,也不区分 TMS 还是销售调价。
后台成本价、销售价不走这里。Excel/PDF 报价单模板也不走这里。
"""
src = dict(quote or {})
raw = f"{business_line or ''} {transport_mode or ''} {src.get('business_line') or ''}"
key = raw.upper()
if key.strip() in {"AIR"} or "AIR" in key or "空运" in raw:
return "总销售价"
source = str(src.get("source") or "").strip().upper()
label = str(src.get("source_label") or src.get("sourceLabel") or "").replace(" ", "")
if source == "SALES_ADJUST" or "销售调价" in label:
return "总销售价"
return "总成本价"
_ = business_line, transport_mode, quote
return "费用合计"
def _mark_sales_adjust(quote: dict[str, Any]) -> None:
@@ -545,10 +776,10 @@ def apply_adjust(
明细只有一项且币种与总价一致:改总价或改明细都一起调。
多项同币种改合计:只改合计数字。多币种:只改点名的那一格。
副作用:无。原 quote 不被修改。
副作用:无。原 quote 不被修改。海运小计按卡上的「始发港费用小计」改。
"""
src = dict(quote or {})
rows = [dict(x) for x in quote_fee_rows(src)]
rows = adjustable_rows(src)
if not extract.is_adjust:
return ApplyResult(ok=False, code="not_adjust", quote=src)
header = str(src.get("currency") or "").strip()
@@ -567,7 +798,12 @@ def apply_adjust(
currency_hint = bool(multi)
total_amt, _ = parse_money(src.get("total"))
new_total = total_amt
single_same_ccy = _single_line_same_currency(rows, total_ccy or header) is not None
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,
) is not None
subtotal_only: dict[str, float] = {}
touched_buckets: set[str] = set()
for item in extract.items:
spoken = (item.fee_name or "").strip()
@@ -581,34 +817,65 @@ def apply_adjust(
if not hits:
unmatched.append(spoken)
continue
idx = next(i for i, r in enumerate(rows) if str(r.get("name") or "") == hits[0])
row = dict(rows[idx])
old_amt, prefix = parse_money(row.get("amount"))
if old_amt is None:
indexes = [i for i, r in enumerate(rows) if str(r.get("name") or "") == hits[0]]
if not indexes:
unmatched.append(spoken)
continue
line_ccy = row_currency(row, header) or prefix
if item.kind == "target":
new_amt = float(item.value)
how = f"改到 {item.value:g}"
elif item.direction == "down":
new_amt = old_amt * (1 - float(item.value) / 100.0)
how = f"下调 {item.value:g}%"
else:
new_amt = old_amt * (1 + float(item.value) / 100.0)
how = f"上调 {item.value:g}%"
_apply_amount(row, format_money(new_amt, sample=str(row.get("amount") or ""), currency=line_ccy or prefix))
rows[idx] = row
applied.append({"name": hits[0], "how": how, "amount": row["amount"]})
if (not multi) and new_total is not None:
if single_same_ccy:
new_total = new_amt
any_applied = False
for idx in indexes:
row = dict(rows[idx])
old_amt, prefix = parse_money(row.get("amount"))
if old_amt is None:
continue
line_ccy = row_currency(row, header) or prefix
if item.kind == "target":
new_amt = float(item.value)
how = f"改到 {item.value:g}"
elif item.direction == "down":
new_amt = old_amt * (1 - float(item.value) / 100.0)
how = f"下调 {item.value:g}%"
else:
new_total = new_total + (new_amt - old_amt)
new_amt = old_amt * (1 + float(item.value) / 100.0)
how = f"上调 {item.value:g}%"
_apply_amount(row, format_money(new_amt, sample=str(row.get("amount") or ""), currency=line_ccy or prefix))
rows[idx] = row
if not any_applied:
applied.append({"name": hits[0], "how": how, "amount": row["amount"]})
any_applied = True
bucket = str(row.get("_bucket") or "")
if row.get("_is_total"):
subtotal_only["__total__"] = new_amt
continue
if row.get("_subtotal_key"):
subtotal_only[bucket] = new_amt - old_amt
continue
if bucket:
touched_buckets.add(bucket)
if (not multi) and new_total is not None:
if single_same_ccy:
new_total = new_amt
else:
new_total = new_total + (new_amt - old_amt)
if not any_applied:
unmatched.append(spoken)
if (not multi) and new_total is not None:
if "__total__" in subtotal_only:
new_total = subtotal_only.pop("__total__")
for bucket, delta in subtotal_only.items():
if bucket not in touched_buckets:
new_total = new_total + delta
out = dict(src)
out["fee_rows"] = rows
out["fee_lines"] = rows
visible = []
for row in rows:
if row.get("_subtotal_key") or row.get("_is_total"):
continue
clean = {k: v for k, v in row.items() if not str(k).startswith("_")}
visible.append(clean)
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:
out["total"] = format_money(new_total, sample=str(src.get("total") or ""), currency=total_ccy)
if not applied: