Files
inquiry_robot/inquiry-agent/agent/schema/quote_adjust.py
T

1270 lines
46 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 text_has_relative_adjust(text: str) -> bool:
"""
这句话是在按比例改价:加10%、减5%、上调、上浮、加一成。
这种是销售调价,不是航线/产品报一个新金额。不打模型。
"""
raw = text or ""
if _PERCENT_UP.search(raw) or _PERCENT_DOWN.search(raw):
return True
blob = raw.replace(" ", "").replace(" ", "")
return any(phrase in blob for phrase in ("加一成", "减一成", "往上抬", "往下压", "上浮", "下浮"))
def text_has_total_only_adjust(text: str) -> bool:
"""
只动总价/总销售价/费用合计,没有点名某一项费用。
「总销售价改为180」仍是销售调价。点了「空运费」「报关费」则不是。不打模型。
"""
raw = text or ""
if not (_TARGET.search(raw) or _PERCENT_UP.search(raw) or _PERCENT_DOWN.search(raw)):
return False
if not any(word in raw for word in _TOTAL_WORDS) and "报价" not in raw:
return False
real: list[str] = []
for name in _FEE_TOKEN.findall(raw):
if name in {"费用", "费用合计"}:
continue
if any(word in name for word in ("合计", "总价", "汇总")):
continue
real.append(name)
return not real
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()]
names_sorted = sorted(names, key=len, reverse=True)
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]
# 模型常只抽出「费用合计」:本地再扫点名小计/费目,避免「始发港费用小计提升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("调价模型抽取失败,回退本地")
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 = _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()
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:
# 「费用合计提升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)
return 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 _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]:
"""
只在销售调价后算一个利润点。
基准=调价前生效价:有手工报价用手工,没有才用 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 _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,
*,
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)
# 同时说了「始发港费用小计」和「费用合计」时:不能只走合计通道,否则小计不改、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)}
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
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,
) 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
total_touched = True
continue
if row.get("_subtotal_key"):
# 只改小计数字写回 fee_total_*;明细是否另调看有没有点名费目
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:
# 合计是整单改数;小计差额不再叠进合计,避免「合计×1.1 + 小计差额」重复
new_total = subtotal_only.pop("__total__")
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 = []
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 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"
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)
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],
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,
)