员工岗位可重新启用;调价认空运航线;会话存档跳号补拉;陆运报价按车型列回填

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-28 19:48:47 +08:00
co-authored by Cursor
parent 2b528758b4
commit 56ba8e93b5
62 changed files with 2960 additions and 793 deletions
+17 -10
View File
@@ -146,7 +146,6 @@ _LAND_COLLAB_BY_PAIR: dict[tuple[str, str], tuple[str, ...]] = {
("中港整车", "中港/中亚/中欧"): (
"客户名称",
"HS编码",
"车型/数量",
"贸易条款",
"货值",
"包装方式",
@@ -769,21 +768,29 @@ def _pricing_required_keys(
land_subtype: str,
facts: dict[str, str] | None = None,
) -> list[str]:
"""当前激活的首次查价内部键列表。中港整车/零担是两个运输类型。"""
"""
当前激活的首次查价内部键列表。中港整车/零担是两个运输类型。
合同里偶有「车型数量」等别名键,这里折成 normalize_facts 后的内部键,
避免销售已填「车型/数量」仍被判缺。
"""
_ = facts
line = (business_line or "").strip().upper()
pricing = contract.get("pricing_required") or {}
raw: list[str] = []
if line == "SEA":
return list((pricing.get("SEA") or {}).get("always") or [])
if line == "AIR":
return list((pricing.get("AIR") or {}).get("always") or [])
if line == "LAND":
raw = list((pricing.get("SEA") or {}).get("always") or [])
elif line == "AIR":
raw = list((pricing.get("AIR") or {}).get("always") or [])
# 空运选填键不得进首次查价必填:合同与 AIR_OPTIONAL_FIELDS 对齐,避免再逼销售补起运地。
optional_air = {key for key, _label in AIR_OPTIONAL_FIELDS} | {"报价日期"}
raw = [k for k in raw if k not in optional_air]
elif line == "LAND":
land = pricing.get("LAND") or {}
block = land.get(land_subtype) or {}
if not block:
return []
return list(block.get("always") or [])
return []
if block:
raw = list(block.get("always") or [])
return [_ALIASES.get(k, k) for k in raw]
_PLACE_KEYS = ("起运港", "目的港")
+170 -20
View File
@@ -23,9 +23,15 @@ _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+)?)"
# 「增加到/调整到/涨到 110%」= 改到原价的 110%,不是上调 110%。必须先于绝对金额与相对上调。
_TO_VERBS = (
"增加到|提高到|提升到|减少到|上调到|下调到|调整到|上浮到|上涨到|上升到|"
"涨到|升到|加到|减到|调到|增至|变为|变成|改为|改成|调整为|调整成"
)
_PERCENT_TO = re.compile(rf"({_TO_VERBS})\s*(\d+(?:\.\d+)?)\s*%")
# (?!\d) 防止「增加到110%」回退成「加到11」;(?!\s*%) 把带百分号的留给 _PERCENT_TO。
_TARGET = re.compile(rf"({_TO_VERBS})\s*(\d+(?:\.\d+)?)(?!\d)(?!\s*%)")
_DAO_PERCENT = re.compile(rf"(?:{_TO_VERBS})\s*(\d+(?:\.\d+)?)\s*%")
_SPLIT = re.compile(r"[,,;;。!!??\n]|和(?=\S)")
_FEE_TOKEN = re.compile(r"([\u4e00-\u9fffA-Za-z0-9]{1,16}费)")
_TOTAL_WORDS = ("费用合计", "合计", "总价", "汇总价", "总销售价", "总成本价")
@@ -432,9 +438,74 @@ def row_currency(row: dict[str, Any] | None, fallback: str = "") -> str:
return prefix or (fallback or "").strip().upper().replace("RMB", "CNY")
def _parse_qty(raw: Any) -> Optional[float]:
"""数量/计费重转浮点;读不了返回 None。"""
if raw in (None, ""):
return None
try:
return float(str(raw).replace(",", "").strip())
except ValueError:
return None
def _row_or_quote_qty(row: dict[str, Any], quote: dict[str, Any] | None = None) -> Optional[float]:
"""
费用行数量;行上没有时用工单/舱位计费重兜底。
空运调价常只有 unitPrice+amount,quantity 在 chargeableWeightKg。
"""
qty = _parse_qty(row.get("quantity"))
if qty is not None:
return qty
qty = _parse_qty(row.get("chargeQuantity"))
if qty is not None:
return qty
src = dict(quote or {})
for key in ("chargeableWeightKg", "chargeable_weight_kg", "chargeableWeight"):
qty = _parse_qty(src.get(key))
if qty is not None:
return qty
opt = src.get("selected_air_option") or src.get("selectedAirOption")
if isinstance(opt, str) and opt.strip():
try:
opt = json.loads(opt)
except json.JSONDecodeError:
opt = None
if isinstance(opt, dict):
for key in ("chargeableWeightKg", "chargeable_weight_kg", "chargeableWeight"):
qty = _parse_qty(opt.get(key))
if qty is not None:
return qty
return None
def _apply_amount(row: dict[str, Any], amount: str) -> None:
"""
写入行金额;有数量时单价=金额÷数量,禁止把行合计原样塞进单价。
空运 KG:调价后金额是单价×计费重,展示读 unit_price;无数量(按票等)才单价=金额。
"""
row["amount"] = amount
row["unit_price"] = amount
qty_raw = row.get("quantity")
if qty_raw in (None, ""):
qty_raw = row.get("chargeQuantity")
qty_num: Optional[float] = None
if qty_raw not in (None, ""):
try:
qty_num = float(str(qty_raw).replace(",", "").strip())
except ValueError:
qty_num = None
amt_num, _ = parse_money(amount)
if qty_num is not None and abs(qty_num) > 1e-9 and amt_num is not None:
# 有计费数量:单价从行金额反推,避免 3385.8 被当成「每KG」
unit_plain = format_money(amt_num / qty_num, sample=str(row.get("unit_price") or row.get("unitPrice") or ""))
unit_plain = _plain_amount(unit_plain)
row["unit_price"] = unit_plain
row["unitPrice"] = unit_plain
else:
row["unit_price"] = amount
if row.get("unitPrice") not in (None, ""):
row["unitPrice"] = amount
for key in ("salePrice", "price", "value"):
if row.get(key) not in (None, ""):
row[key] = amount
@@ -518,7 +589,12 @@ 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)):
if not (
_TARGET.search(raw)
or _PERCENT_TO.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
@@ -546,27 +622,30 @@ def extract_adjust(
本地回退只给无网/模型失败用,不是正式识别。
allow_model=False 时禁止打模型。
"""
raw = (text or "").strip()
if injected is not None:
if isinstance(injected, AdjustExtract):
return injected
return _coerce_dao_percent(raw, injected)
items = []
for raw in injected.get("items") or []:
if not isinstance(raw, dict):
for row in injected.get("items") or []:
if not isinstance(row, 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),
fee_name=str(row.get("fee_name") or "").strip(),
kind=str(row.get("kind") or "percent"),
direction=str(row.get("direction") or "up"),
value=float(row.get("value") or 0),
)
)
return AdjustExtract(
is_adjust=bool(injected.get("is_adjust")),
items=items,
wants_total=bool(injected.get("wants_total")),
return _coerce_dao_percent(
raw,
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()]
@@ -617,11 +696,16 @@ def extract_adjust(
items=merged,
wants_total=bool(modeled.wants_total),
)
return modeled
return _coerce_dao_percent(raw, 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))
has_op = bool(
_PERCENT_UP.search(raw)
or _PERCENT_DOWN.search(raw)
or _PERCENT_TO.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, "")
@@ -634,7 +718,9 @@ def extract_adjust(
item = _item_from_chunk(raw, "")
if item:
items.append(item)
return AdjustExtract(is_adjust=bool(items or has_op), items=items)
return _coerce_dao_percent(
raw, AdjustExtract(is_adjust=bool(items or has_op), items=items)
)
def _local_fee_items(raw: str, names_sorted: list[str]) -> list[AdjustItem]:
@@ -664,10 +750,40 @@ def _local_fee_items(raw: str, names_sorted: list[str]) -> list[AdjustItem]:
return items
def _coerce_dao_percent(text: str, extract: AdjustExtract) -> AdjustExtract:
"""
「增加到110%」纠正为 percent/set。
模型常抽成 percent/up;本地旧正则会抽成绝对金额 target。两种都不能当上调。
"""
hit = _DAO_PERCENT.search(text or "")
if not hit or not extract.items:
return extract
val = float(hit.group(1))
fixed: list[AdjustItem] = []
changed = False
for item in extract.items:
same_num = abs(float(item.value) - val) < 1e-9
if item.kind == "percent" and item.direction in {"up", "down"} and same_num:
fixed.append(AdjustItem(item.fee_name, "percent", "set", val))
changed = True
elif item.kind == "target" and item.direction == "set" and same_num:
fixed.append(AdjustItem(item.fee_name, "percent", "set", val))
changed = True
else:
fixed.append(item)
if not changed:
return extract
return AdjustExtract(is_adjust=extract.is_adjust, items=fixed, wants_total=extract.wants_total)
def _item_from_chunk(chunk: str, fee_name: str) -> Optional[AdjustItem]:
to_pct = _PERCENT_TO.search(chunk)
up = _PERCENT_UP.search(chunk)
down = _PERCENT_DOWN.search(chunk)
target = _TARGET.search(chunk)
if to_pct:
return AdjustItem(fee_name=fee_name, kind="percent", direction="set", value=float(to_pct.group(2)))
if target:
return AdjustItem(fee_name=fee_name, kind="target", direction="set", value=float(target.group(2)))
if up:
@@ -1167,6 +1283,10 @@ def _apply_total_only(
if item.kind == "target":
new_amt = float(item.value)
how = f"改到 {item.value:g}"
elif item.kind == "percent" and item.direction == "set":
# 「费用合计增加到110%」= 改到原合计的 110%,不是上调 110%
new_amt = old_total * (float(item.value) / 100.0)
how = f"调到 {item.value:g}%"
elif item.direction == "down":
new_amt = old_total * (1 - float(item.value) / 100.0)
how = f"下调 {item.value:g}%"
@@ -1311,15 +1431,45 @@ def apply_adjust(
if old_amt is None:
continue
line_ccy = row_currency(row, header) or prefix
if item.kind == "target":
old_up, _ = parse_money(row.get("unit_price") or row.get("unitPrice"))
qty_num = _row_or_quote_qty(row, src)
# 有单价且有计费重:比例调价按「单价」缩放,再算行金额=单价×数量。
# 否则会把行合计×110% 后整笔写进单价(15→42633.53)。
scale_by_unit = (
item.kind == "percent"
and old_up is not None
and qty_num is not None
and abs(qty_num) > 1e-9
and abs(old_up - old_amt) > 1e-6
)
if scale_by_unit:
if item.direction == "set":
new_up = old_up * (float(item.value) / 100.0)
how = f"调到 {item.value:g}%"
elif item.direction == "down":
new_up = old_up * (1 - float(item.value) / 100.0)
how = f"下调 {item.value:g}%"
else:
new_up = old_up * (1 + float(item.value) / 100.0)
how = f"上调 {item.value:g}%"
new_amt = new_up * qty_num
if row.get("quantity") in (None, ""):
row["quantity"] = f"{qty_num:.10g}"
elif item.kind == "target":
new_amt = float(item.value)
how = f"改到 {item.value:g}"
elif item.kind == "percent" and item.direction == "set":
# 「运输费增加到110%」= 原价 × 110%,不是上调 110%(×210%)
new_amt = old_amt * (float(item.value) / 100.0)
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}%"
if qty_num is not None and row.get("quantity") in (None, ""):
row["quantity"] = f"{qty_num:.10g}"
_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:
+10 -7
View File
@@ -4,7 +4,7 @@
本文件职责:核对卡通过后、建单查价前,确认当前运输类型的货物字段能拼出查价正文。
带去:运输类型、线路类别、始发/目的/品名/报价日期,以及该类型的体积重量或车型/数量;通关口岸有值才带。
出站类型/线路译成 TMS 用的值,线路键名 routeCategory。工单与卡片仍用销售中文。
中港整车、中港零担/集拼出站类型都只写「中港」。中港整车不带车型/数量。
中港整车、中港零担/集拼出站类型按细类原文传。中港整车带车型/数量;仅中港零担/集拼不带。
禁止:把运输分类放进出站;直连 TMS;改六态。
线程:纯函数。
"""
@@ -20,8 +20,10 @@ from agent.schema.field_validate import apply_quote_date_default, normalize_fact
logger = logging.getLogger(__name__)
LCL_TYPES = frozenset({"国内运输拼车", "跨境集拼"})
FTL_TYPES = frozenset({"国内运输整车", "跨境整车", "中港零担/集拼"})
HK_FTL_TYPE = "中港整车"
FTL_TYPES = frozenset({"国内运输整车", "跨境整车", "中港整车"})
# 仅中港零担/集拼:询价不采车型/数量,出站也不带。
HK_NO_VEHICLE_TYPES = frozenset({"中港零担/集拼"})
HK_FTL_TYPE = "中港整车" # 兼容旧调用名;现与国内/跨境整车同属 FTL_TYPES
CUSTOMS_TYPES = frozenset({"跨境集拼", "跨境整车", "中港整车", "中港零担/集拼"})
_TYPE_ENUM = {
@@ -29,8 +31,8 @@ _TYPE_ENUM = {
"国内运输整车": "DOMESTIC_FTL",
"跨境集拼": "CROSS_BORDER_LTL",
"跨境整车": "CROSS_BORDER_FTL",
"中港整车": "中港",
"中港零担/集拼": "中港",
"中港整车": "中港整车",
"中港零担/集拼": "中港零担/集拼",
}
_ROUTE_ENUM = {
"国内长途/零担": "DOMESTIC_LONG_LTL",
@@ -45,7 +47,7 @@ _TAIL_QTY = re.compile(r"^(.*?)[\s,,]+(\d+)\s*(台|辆)?$")
def tms_land_type_code(raw: str) -> str:
"""销售中文运输类型 → 查价 landTransportType。中港两类都是「中港」。对不上空串。"""
"""销售中文运输类型 → 查价 landTransportType。中港两类按细类原文。对不上空串。"""
text = (raw or "").strip()
if text in _TYPE_ENUM:
return _TYPE_ENUM[text]
@@ -140,7 +142,8 @@ def assemble_land_query(facts: dict[str, Any] | None) -> dict[str, Any]:
missing.append("毛重")
else:
outbound["weightKg"] = wgt
elif land_type == HK_FTL_TYPE:
elif land_type in HK_NO_VEHICLE_TYPES:
# 中港零担/集拼:不要车型/数量,也不改要体积毛重
pass
elif land_type in FTL_TYPES:
combo = (