Files
inquiry_robot/inquiry-agent/agent/schema/quote_adjust.py
T
jillion886andCursor 4dfd756bcf 销售侧汇总统一叫费用合计,并认准空运费的金额。
企微跳转改为账号密码登录,报价卡上的费用按原金额改回;航线按单价数量金额报价时不再记成海运费。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 11:15:33 +08:00

921 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
调价规则:抽项结构、费用对齐、比例/改到、币种合计、利润点。
本文件职责:只算新价,不发企微、不改六态、不写主账。
模型只负责「是不是调价、调哪几项」;金额必须走这里,禁止模型改价。
调用:私聊/群 Worker 槽;禁止回调线程同步跑。
"""
from __future__ import annotations
import json
import logging
import re
from dataclasses import dataclass, field
from typing import Any, Optional
logger = logging.getLogger(__name__)
_CLOSED = frozenset({"已成交", "未成交", "已关闭", "转人工"})
_QUOTED = frozenset({"已报价", "协商中"})
_PERCENT_UP = re.compile(
r"(上调|上涨|上升|上浮|提高|加|涨|升)\s*(\d+(?:\.\d+)?)\s*%"
)
_PERCENT_DOWN = re.compile(r"(下调|下降|降低|减|降)\s*(\d+(?:\.\d+)?)\s*%")
_TARGET = re.compile(
r"(提升到|减少到|上调到|下调到|加到|减到|调到|改为|改成)\s*(\d+(?:\.\d+)?)"
)
_SPLIT = re.compile(r"[,,;;。!!??\n]|和(?=\S)")
_FEE_TOKEN = re.compile(r"([\u4e00-\u9fffA-Za-z0-9]{1,16}费)")
_TOTAL_WORDS = ("费用合计", "合计", "总价", "汇总价", "总销售价", "总成本价")
_QUOTE_WORD = "报价"
@dataclass
class AdjustItem:
"""一条抽出来的调价项。kind=percent 时 value 是比例;kind=target 时 value 是金额。"""
fee_name: str
kind: str
direction: str
value: float
@dataclass
class AdjustExtract:
"""一句话抽完的调价意图。wants_total=只动合计数字,不改明细。"""
is_adjust: bool
items: list[AdjustItem] = field(default_factory=list)
wants_total: bool = False
@dataclass
class ApplyResult:
"""规则算完的结果。quote 是新副本,入参原报价不被改。"""
ok: bool
code: str
quote: dict[str, Any]
applied: list[dict[str, Any]] = field(default_factory=list)
unmatched: list[str] = field(default_factory=list)
ambiguous: list[str] = field(default_factory=list)
currency_hint: bool = False
lines_hint: bool = False
profit_point: Optional[float] = None
def private_session_can_adjust(status: str, has_quote: bool, collab_chat_id: str) -> str:
"""
私聊能不能按未协同套走。空串=可以。
already_collab / closed / no_quote 给话术用。
"""
if (collab_chat_id or "").strip():
return "already_collab"
st = (status or "").strip()
if st in _CLOSED:
return "closed"
if st not in _QUOTED or not has_quote:
return "no_quote"
return ""
def _fee_key(name: str) -> str:
raw = (name or "").replace(" ", "").replace("(", "(").replace(")", ")")
for sep in ("/", "/"):
if sep in raw:
raw = raw.split(sep, 1)[0]
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 + "费用":
return True
if a + "费" == b or b + "费" == a:
return True
if len(a) >= 3 and len(a) == len(b) and a[:-1] == b[:-1] and (a.endswith("费") or b.endswith("费")):
return True
return False
def match_fee_names(spoken: str, rows: list[dict[str, Any]]) -> list[str]:
"""对上当前明细里的费用名;0/1/多条都原样返回。"""
hits: list[str] = []
for row in rows:
name = str(row.get("name") or "").strip()
if name and fee_name_same(spoken, name) and name not in hits:
hits.append(name)
if hits:
return hits
spoken_key = _fee_key(spoken)
if spoken_key:
for row in rows:
name = str(row.get("name") or "").strip()
key = _fee_key(name)
if key and (spoken_key in key or key in spoken_key) and name not in hits:
hits.append(name)
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 {})
raw = src.get("fee_rows") or src.get("fee_lines") or src.get("feeItems") or []
if isinstance(raw, str) and raw.strip():
try:
raw = json.loads(raw)
except json.JSONDecodeError:
raw = []
if not isinstance(raw, list):
return []
rows: list[dict[str, Any]] = []
for item in raw:
if not isinstance(item, dict):
continue
name = str(item.get("name") or item.get("label") or "").strip()
amount = str(item.get("amount") or item.get("value") or item.get("salePrice") or "").strip()
if name and amount:
row = dict(item)
row["name"] = name
row["amount"] = amount
rows.append(row)
return rows
def parse_money(raw: Any) -> tuple[Optional[float], str]:
"""抽出金额和币种前缀。读不了返回 (None, '')。"""
text = str(raw if raw not in (None, "") else "").strip()
if not text or text == "-":
return None, ""
ccy = ""
upper = text.upper().replace(",", "")
for prefix in ("USD", "CNY", "EUR", "HKD", "JPY", "GBP", "RMB"):
if upper.startswith(prefix):
ccy = "CNY" if prefix == "RMB" else prefix
text = text[len(prefix) :].strip()
break
cleaned = text.replace(",", "")
try:
return float(cleaned), ccy
except ValueError:
return None, ccy
def format_money(value: float, *, sample: str = "", currency: str = "") -> str:
"""金额写法跟原行对齐:有小数留两位,整数也写两位方便对账。"""
body = f"{value:.2f}"
code = (currency or "").strip().upper()
if code == "RMB":
code = "CNY"
sample_text = str(sample or "")
if code and (sample_text.upper().startswith(code) or sample_text.upper().startswith("RMB")):
return f"{code} {body}"
return body
def row_currency(row: dict[str, Any] | None, fallback: str = "") -> str:
src = dict(row or {})
code = str(src.get("currency") or src.get("currencyCode") or fallback or "").strip().upper()
if code == "RMB":
return "CNY"
if code:
return code
_, prefix = parse_money(src.get("amount"))
return prefix or (fallback or "").strip().upper().replace("RMB", "CNY")
def _apply_amount(row: dict[str, Any], amount: str) -> None:
row["amount"] = amount
row["unit_price"] = amount
for key in ("salePrice", "price", "value"):
if row.get(key) not in (None, ""):
row[key] = amount
def effective_quote(tms: dict[str, Any] | None, overlay: dict[str, Any] | None) -> dict[str, Any]:
"""
人工盖 TMS:同名用人工,没有的项保留 TMS,人工新增的补上。
合计先沿用 TMS,再按同币种差额加减。不做汇率。
"""
base = dict(tms or {})
rows = [dict(x) for x in quote_fee_rows(base)]
incoming = dict(overlay or {})
for item in quote_fee_rows(incoming):
name = str(item.get("name") or "").strip()
if not name:
continue
idx = next((i for i, r in enumerate(rows) if fee_name_same(name, str(r.get("name") or ""))), -1)
if idx < 0:
rows.append(dict(item))
continue
old = dict(rows[idx])
_apply_amount(old, str(item.get("amount") or ""))
if item.get("currency"):
old["currency"] = item.get("currency")
rows[idx] = old
out = dict(base)
out["fee_rows"] = rows
out["fee_lines"] = rows
header = str(out.get("currency") or incoming.get("currency") or "").strip()
base_total, _ = parse_money(base.get("total"))
if base_total is not None:
total_ccy = row_currency({"currency": header, "amount": base.get("total")}, header)
old_rows = quote_fee_rows(base)
delta = 0.0
for new_row in rows:
new_amt, _ = parse_money(new_row.get("amount"))
if new_amt is None:
continue
line_ccy = row_currency(new_row, total_ccy)
if line_ccy and total_ccy and line_ccy != total_ccy:
continue
idx = next(
(i for i, r in enumerate(old_rows) if fee_name_same(str(new_row.get("name") or ""), str(r.get("name") or ""))),
-1,
)
if idx < 0:
delta += new_amt
continue
old_amt, _ = parse_money(old_rows[idx].get("amount"))
if old_amt is None:
continue
delta += new_amt - old_amt
out["total"] = format_money(base_total + delta, sample=str(base.get("total") or ""), currency=header)
return out
def extract_adjust(
text: str,
*,
fee_names: list[str] | None = None,
injected: AdjustExtract | dict[str, Any] | None = None,
allow_model: Optional[bool] = None,
chat_fn: Any = None,
) -> AdjustExtract:
"""
从一句话抽出调价项。单测可注入;正式优先 DeepSeek B。
本地回退只给无网/模型失败用,不是正式识别。
allow_model=False 时禁止打模型。
"""
if injected is not None:
if isinstance(injected, AdjustExtract):
return injected
items = []
for raw in injected.get("items") or []:
if not isinstance(raw, dict):
continue
items.append(
AdjustItem(
fee_name=str(raw.get("fee_name") or "").strip(),
kind=str(raw.get("kind") or "percent"),
direction=str(raw.get("direction") or "up"),
value=float(raw.get("value") or 0),
)
)
return AdjustExtract(
is_adjust=bool(injected.get("is_adjust")),
items=items,
wants_total=bool(injected.get("wants_total")),
)
raw = (text or "").strip()
if not raw:
return AdjustExtract(is_adjust=False)
names = [str(x).strip() for x in (fee_names or []) if str(x).strip()]
use_model = allow_model
if use_model is None:
try:
from agent.config import get_settings
settings = get_settings()
use_model = bool(
getattr(settings, "llm_allow_network", False)
and getattr(settings, "llm_data_usage_confirmed", False)
)
except Exception:
use_model = False
if use_model or chat_fn is not None:
try:
from agent.llm.mode_extract_adjust import invoke_extract_adjust
modeled = invoke_extract_adjust(
raw,
fee_names=names,
allow_network=bool(use_model),
chat_fn=chat_fn,
)
if modeled is not None:
if modeled.wants_total and not modeled.items:
op = _item_from_chunk(raw, "")
if op:
modeled.items = [op]
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]
if total_hit and has_op and not fee_in_text:
op = _item_from_chunk(raw, "")
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: list[AdjustItem] = []
for part in _SPLIT.split(raw) or [raw]:
chunk = (part or "").strip()
if not chunk:
continue
spoken = ""
for name in names_sorted:
if name and name in chunk:
spoken = name
break
if not spoken:
found = _FEE_TOKEN.findall(chunk)
spoken = found[-1] if found else ""
if not spoken:
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)
def _item_from_chunk(chunk: str, fee_name: str) -> Optional[AdjustItem]:
up = _PERCENT_UP.search(chunk)
down = _PERCENT_DOWN.search(chunk)
target = _TARGET.search(chunk)
if target:
return AdjustItem(fee_name=fee_name, kind="target", direction="set", value=float(target.group(2)))
if up:
return AdjustItem(fee_name=fee_name, kind="percent", direction="up", value=float(up.group(2)))
if down:
return AdjustItem(fee_name=fee_name, kind="percent", direction="down", value=float(down.group(2)))
return None
def total_price_label(
business_line: str = "",
transport_mode: str = "",
quote: dict[str, Any] | None = None,
) -> str:
"""
销售在企微看到的汇总行名称,一律叫费用合计。
不区分空运/海运/陆运,也不区分 TMS 还是销售调价。
后台成本价、销售价不走这里。Excel/PDF 报价单模板也不走这里。
"""
_ = business_line, transport_mode, quote
return "费用合计"
def _mark_sales_adjust(quote: dict[str, Any]) -> None:
"""算价成功后这版就是销售调价,卡片和后台都按销售价展示。"""
quote["source"] = "SALES_ADJUST"
quote["source_label"] = "销售调价"
def compute_profit(*, x: Optional[float], baseline: str) -> Optional[float]:
"""
只在销售调价后算一个利润点。
基准=调价前生效价:有手工报价用手工,没有才用 TMS。
"""
if x is None:
return None
base, _ = parse_money(baseline)
if base is None or abs(base) < 1e-9:
return None
return (x - base) / base * 100.0
def _profit_baseline(
src: dict[str, Any],
*,
tms_cost: str,
tms_sale: str,
business_line: str,
) -> str:
"""第一次调价锁住基准;之后连续调仍对这一档。"""
stored = str(src.get("profit_baseline") or "").strip()
if stored:
return stored
current = str(src.get("total") or "").strip()
if current:
return current
line = (business_line or "").upper()
if line == "AIR":
return str(tms_sale or "").strip()
return str(tms_cost or tms_sale or "").strip()
def _attach_profit(
out: dict[str, Any],
src: dict[str, Any],
*,
x: Optional[float],
tms_cost: str,
tms_sale: str,
business_line: str,
) -> Optional[float]:
"""写入利润点和锁定基准。原报价没有利润点,销售没调过就不算。"""
baseline = _profit_baseline(src, tms_cost=tms_cost, tms_sale=tms_sale, business_line=business_line)
profit = compute_profit(x=x, baseline=baseline)
if profit is not None:
out["profit_point"] = profit
if baseline:
out.setdefault("profit_baseline", baseline)
line = (business_line or str(src.get("business_line") or "")).upper()
if line:
out["business_line"] = line
return profit
def _single_line_same_currency(rows: list[dict[str, Any]], total_ccy: str) -> Optional[int]:
"""
价格明细只有一项,且该项币种与总价一致:总价和明细绑在一起。
只看出项数和币种,不看出价是否相等。多项或币种不同:不算绑定。
"""
if len(rows) != 1:
return None
line_ccy = row_currency(rows[0], "")
if line_ccy and total_ccy and line_ccy != total_ccy:
return None
return 0
def _apply_total_only(
src: dict[str, Any],
extract: AdjustExtract,
*,
multi: bool,
total_ccy: str,
business_line: str,
tms_cost: str,
tms_sale: str,
) -> ApplyResult:
"""
改合计。多项或明细币种与总价不同:只改合计数字。
明细只有一项且币种与总价一致:明细跟着改,不再提醒分摊。
"""
item = next((x for x in extract.items if x is not None), None)
old_total, prefix = parse_money(src.get("total"))
if item is None or old_total is None:
return ApplyResult(ok=False, code="need_lines", quote=src, lines_hint=not multi, currency_hint=multi)
if item.kind == "target":
new_amt = float(item.value)
how = f"改到 {item.value:g}"
elif item.direction == "down":
new_amt = old_total * (1 - float(item.value) / 100.0)
how = f"下调 {item.value:g}%"
else:
new_amt = old_total * (1 + float(item.value) / 100.0)
how = f"上调 {item.value:g}%"
out = dict(src)
rows = [dict(x) for x in quote_fee_rows(src)]
mirrored = _single_line_same_currency(rows, total_ccy or prefix)
label = total_price_label(business_line, quote={"source": "SALES_ADJUST"})
applied = [{"name": label, "how": how, "amount": format_money(new_amt, sample=str(src.get("total") or ""), currency=total_ccy or prefix)}]
if mirrored is not None:
row = dict(rows[mirrored])
line_ccy = row_currency(row, total_ccy) or prefix
_apply_amount(row, format_money(new_amt, sample=str(row.get("amount") or ""), currency=line_ccy))
rows[mirrored] = row
applied.append({"name": str(row.get("name") or "明细"), "how": how, "amount": row["amount"]})
out["fee_rows"] = rows
out["fee_lines"] = list(rows)
out["total"] = format_money(new_amt, sample=str(src.get("total") or ""), currency=total_ccy or prefix)
profit = _attach_profit(
out,
src,
x=new_amt,
tms_cost=tms_cost or str(src.get("tms_baseline_cost") or ""),
tms_sale=tms_sale or str(src.get("tms_baseline_sale") or ""),
business_line=business_line,
)
_mark_sales_adjust(out)
return ApplyResult(
ok=True,
code="applied",
quote=out,
applied=applied,
currency_hint=multi,
lines_hint=bool((not multi) and mirrored is None),
profit_point=profit,
)
def apply_adjust(
quote: dict[str, Any] | None,
extract: AdjustExtract,
*,
business_line: str = "",
tms_cost: str = "",
tms_sale: str = "",
) -> ApplyResult:
"""
按抽项改当前生效报价副本。
明细只有一项且币种与总价一致:改总价或改明细都一起调。
多项同币种改合计:只改合计数字。多币种:只改点名的那一格。
副作用:无。原 quote 不被修改。海运小计按卡上的「始发港费用小计」改。
"""
src = dict(quote or {})
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()
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)}
if total_ccy:
currencies.add(total_ccy)
multi = len(currencies) > 1
if extract.wants_total:
return _apply_total_only(src, extract, multi=multi, total_ccy=total_ccy, business_line=business_line, tms_cost=tms_cost, tms_sale=tms_sale)
if not extract.items:
return ApplyResult(ok=False, code="need_fee", quote=src)
applied: list[dict[str, Any]] = []
unmatched: list[str] = []
ambiguous: list[str] = []
currency_hint = bool(multi)
total_amt, _ = parse_money(src.get("total"))
new_total = total_amt
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()
if not spoken:
unmatched.append(spoken or "未点名费用")
continue
hits = match_fee_names(spoken, rows)
if len(hits) > 1:
ambiguous.append(spoken)
continue
if not hits:
unmatched.append(spoken)
continue
indexes = [i for i, r in enumerate(rows) if str(r.get("name") or "") == hits[0]]
if not indexes:
unmatched.append(spoken)
continue
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_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)
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:
code = "ambiguous" if ambiguous else "none_matched"
return ApplyResult(
ok=False,
code=code,
quote=src,
unmatched=unmatched,
ambiguous=ambiguous,
)
sale_now, _ = parse_money(out.get("total"))
profit = _attach_profit(
out,
src,
x=sale_now,
tms_cost=tms_cost or str(src.get("tms_baseline_cost") or ""),
tms_sale=tms_sale or str(src.get("tms_baseline_sale") or ""),
business_line=business_line,
)
if tms_cost and "tms_baseline_cost" not in out:
out["tms_baseline_cost"] = tms_cost
if tms_sale and "tms_baseline_sale" not in out:
out["tms_baseline_sale"] = tms_sale
_mark_sales_adjust(out)
logger.info(
"quote_adjust applied=%s unmatched=%s hint=%s profit=%s",
[x.get("name") for x in applied],
unmatched,
currency_hint,
profit,
)
return ApplyResult(
ok=True,
code="applied",
quote=out,
applied=applied,
unmatched=unmatched,
ambiguous=ambiguous,
currency_hint=currency_hint,
profit_point=profit,
)