员工岗位可重新启用;调价认空运航线;会话存档跳号补拉;陆运报价按车型列回填
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 = ("起运港", "目的港")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user