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