""" 调价规则:抽项结构、费用对齐、比例/改到、币种合计、利润点。 本文件职责:只算新价,不发企微、不改六态、不写主账。 模型只负责「是不是调价、调哪几项」;金额必须走这里,禁止模型改价。 调用:私聊/群 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, )