落地私聊空运询价与TMS报价回填,按后台模板出单。

查到价后把币种、单位、数量、单价、备注写入报价单,避免销售只看到光秃秃的金额。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-14 20:25:35 +08:00
co-authored by Cursor
parent cea3a6e51e
commit 99da1faf30
78 changed files with 8838 additions and 321 deletions
+255 -38
View File
@@ -1,19 +1,265 @@
"""
字段合同校验入口壳。
字段合同校验:读 inquiry-required-fields-v1,只做作用域/非空/默认值。
本文件职责:暴露 validate_required_fields_shell;正式规则读 contracts JSON 后再实现。
禁止:在未 require_formal_contract 时假装已激活字段;禁止用正则替代模型语义。
本文件职责:判断首次查价缺项;报价日期未说则填当天,不进入补问。
口语已写 TMS 单位(件/KGS/CBM)时,空着的件数/毛重/体积可机读补上。
禁止:正则猜港口/箱型;禁止用封闭枚举拒绝用户原文;禁止改六态。
"""
from __future__ import annotations
import logging
import re
from typing import Any
from zoneinfo import ZoneInfo
from agent.schema.contracts_loader import load_contract, require_formal_contract
from agent.schema.contracts_loader import require_formal_contract
logger = logging.getLogger(__name__)
_SHANGHAI = ZoneInfo("Asia/Shanghai")
# 空运询价必填 8 项:销售侧名称。内部仍用合同键。
AIR_REQUIRED_FIELDS = (
("起运港", "起运地"),
("目的港", "目的地"),
("品名", "品名"),
("件数", "件数"),
("毛重", "重量"),
("体积", "体积"),
("包装方式", "包装类型"),
("报价日期", "报价日期"),
)
AIR_DISPLAY = {key: label for key, label in AIR_REQUIRED_FIELDS}
# 口语里已经带上 TMS 文档规定的单位时,补进空字段。
# 只认「数字+单位」,不猜港口,不把长宽高换算成立方。
_RE_PIECES = re.compile(r"(?<![\d.])(\d+)\s*(件|pcs)\b", re.I)
_RE_WEIGHT = re.compile(r"(?<![\d.])(\d+(?:\.\d+)?)\s*(kgs|kg|公斤|千克)\b", re.I)
_RE_VOLUME = re.compile(
r"(?<![\d.])(\d+(?:\.\d+)?)\s*(cbm|立方米|立方|m3|m³)\b",
re.I,
)
_RE_VOLUME_FANG = re.compile(r"(?<![\d.])(\d+(?:\.\d+)?)\s*方(?!向)")
_RE_WEIGHT_WORD = re.compile(r"(?:毛重|重量)\s*[::]?\s*(\d+(?:\.\d+)?)")
_RE_VOLUME_WORD = re.compile(r"体积\s*[::]?\s*(\d+(?:\.\d+)?)")
# 销售原话里的包装词:当前句出现则覆盖旧值,散货也要收下
_PACK_WORDS = ("纸箱", "木箱", "托盘", "卡板", "散货")
# 报价日期:9月15日 / 2026-09-15;未写年用上海当年
_RE_DATE_YMD = re.compile(
r"(20\d{2})\s*[-/.年]\s*(\d{1,2})\s*[-/.月]\s*(\d{1,2})\s*[日号]?"
)
_RE_DATE_MD = re.compile(r"(?<!\d)(\d{1,2})\s*月\s*(\d{1,2})\s*[日号]?")
# 展示名 / 别名 → 内部键
_ALIASES = {
"起运地": "起运港",
"目的地": "目的港",
"包装类型": "包装方式",
"货物数量": "货量",
"始发站": "起运港",
"货物品名": "品名",
"重量(KG)": "毛重",
"重量": "毛重",
"体积(CBM)": "体积",
"车型/数量": "车型数量",
}
def shanghai_today() -> str:
"""报价日期系统默认:Asia/Shanghai 当天。"""
from datetime import datetime
return datetime.now(_SHANGHAI).date().isoformat()
def normalize_facts(facts: dict[str, Any] | None) -> dict[str, str]:
"""
把展示名/别名折成合同内部键,去掉首尾空白。
空值丢弃;不解释语义。
"""
out: dict[str, str] = {}
for raw_key, raw_val in (facts or {}).items():
key = str(raw_key).strip()
key = _ALIASES.get(key, key)
if raw_val is None:
continue
val = str(raw_val).strip()
if not val:
continue
out[key] = val
return out
def _harvest_packaging(raw: str) -> str:
"""当前原话里最后出现的包装词;没有则空串。"""
last = ""
last_pos = -1
for word in _PACK_WORDS:
pos = raw.rfind(word)
if pos > last_pos:
last_pos = pos
last = word
return last
def _harvest_quote_date(raw: str) -> str:
"""当前原话里的报价日期,规范成 yyyy-MM-dd;没有则空串。"""
from datetime import datetime
hit = _RE_DATE_YMD.search(raw)
if hit:
year, month, day = int(hit.group(1)), int(hit.group(2)), int(hit.group(3))
else:
hit = _RE_DATE_MD.search(raw)
if not hit:
return ""
year = datetime.now(_SHANGHAI).year
month, day = int(hit.group(1)), int(hit.group(2))
try:
return datetime(year, month, day).date().isoformat()
except ValueError:
return ""
def harvest_oral_measures(text: str, facts: dict[str, str] | None = None) -> dict[str, str]:
"""
从当前原话机读件数/重量/体积/包装/报价日期。
当前句写了的覆盖旧值:散货替换托盘,9月16日写入报价日期。
副作用:无。不改主账。不猜港口。
"""
merged = dict(facts or {})
raw = text or ""
hit = _RE_PIECES.search(raw)
if hit:
merged["件数"] = f"{hit.group(1)}{hit.group(2)}"
hit = _RE_WEIGHT.search(raw)
if hit:
# 原样留下用户单位:1.5KG 不要改成 1.5KGS
merged["毛重"] = f"{hit.group(1)}{hit.group(2)}"
else:
hit = _RE_WEIGHT_WORD.search(raw)
if hit:
merged["毛重"] = hit.group(1)
hit = _RE_VOLUME.search(raw)
if hit:
merged["体积"] = f"{hit.group(1)}{hit.group(2)}"
else:
hit = _RE_VOLUME_FANG.search(raw) or _RE_VOLUME_WORD.search(raw)
if hit:
merged["体积"] = hit.group(1)
pack = _harvest_packaging(raw)
if pack:
merged["包装方式"] = pack
quote_date = _harvest_quote_date(raw)
if quote_date:
merged["报价日期"] = quote_date
from agent.schema.tms_air_query import harvest_named_air_route
return harvest_named_air_route(raw, merged)
def apply_quote_date_default(facts: dict[str, str]) -> dict[str, str]:
"""销售未提供报价日期时填当天;已有则不覆盖。"""
merged = dict(facts)
if not merged.get("报价日期"):
merged["报价日期"] = shanghai_today()
logger.info("报价日期缺省为当天 date=%s", merged["报价日期"])
return merged
def _pricing_required_keys(contract: dict[str, Any], business_line: str, land_subtype: str) -> list[str]:
"""当前激活的首次查价内部键列表。"""
line = (business_line or "").strip().upper()
pricing = contract.get("pricing_required") or {}
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":
land = pricing.get("LAND") or {}
block = land.get(land_subtype) or {}
if not block:
return []
return list(block.get("always") or [])
return []
def display_name(internal_key: str, business_line: str = "AIR") -> str:
"""补问时给销售看的名字。"""
line = (business_line or "").upper()
if line == "AIR":
return AIR_DISPLAY.get(internal_key, internal_key)
return internal_key
def validate_required_fields(
*,
facts: dict[str, Any],
business_line: str = "",
land_subtype: str = "",
require_formal: bool = True,
) -> dict[str, Any]:
"""
首次查价完整性。
返回:ok / implemented / missing(内部键)/ missing_display / facts(含默认报价日期)/ errors。
报价日期不进 missing。未识别业务线时 missing 为空、need_transport_mode=True。
"""
_ = require_formal
contract = require_formal_contract("inquiry-required-fields-v1")
normalized = apply_quote_date_default(normalize_facts(facts))
line = (business_line or "").strip().upper()
if line not in {"SEA", "AIR", "LAND"}:
return {
"ok": False,
"implemented": True,
"need_transport_mode": True,
"missing": [],
"missing_display": [],
"facts": normalized,
"errors": [],
"business_line": line,
}
if line == "LAND" and not (land_subtype or "").strip():
return {
"ok": False,
"implemented": True,
"need_transport_mode": False,
"need_land_subtype": True,
"missing": [],
"missing_display": [],
"facts": normalized,
"errors": [],
"business_line": line,
}
required = _pricing_required_keys(contract, line, land_subtype)
missing = [k for k in required if k != "报价日期" and not normalized.get(k)]
missing_display = [display_name(k, line) for k in missing]
ok = len(missing) == 0
logger.info(
"schema.validate line=%s land=%s ok=%s missing=%s",
line,
land_subtype or "-",
ok,
missing,
)
return {
"ok": ok,
"implemented": True,
"need_transport_mode": False,
"need_land_subtype": False,
"missing": missing,
"missing_display": missing_display,
"facts": normalized,
"errors": [],
"business_line": line,
"land_subtype": land_subtype,
}
def validate_required_fields_shell(
*,
@@ -21,38 +267,9 @@ def validate_required_fields_shell(
business_line: str = "",
require_formal: bool = True,
) -> dict[str, Any]:
"""
字段校验空壳。
返回结构固定:ok / implemented / missing / errors。
implemented=False 表示规则引擎尚未接入(框架阶段预期)。
"""
stem = "inquiry-required-fields-v1"
try:
contract = (
require_formal_contract(stem) if require_formal else load_contract(stem)
)
except (FileNotFoundError, RuntimeError) as exc:
return {
"ok": False,
"implemented": False,
"missing": [],
"errors": [str(exc)],
"business_line": business_line,
}
is_ph = bool(contract.get("_meta", {}).get("is_placeholder"))
logger.info(
"schema.validate_shell line=%s placeholder=%s facts_keys=%s(未跑激活规则)",
business_line or "-",
is_ph,
sorted(facts.keys()),
"""兼容骨架入口:现已接入正式激活规则。"""
return validate_required_fields(
facts=facts,
business_line=business_line,
require_formal=require_formal,
)
return {
"ok": False,
"implemented": False,
"missing": [],
"errors": ["field_activation_rules_not_implemented"],
"business_line": business_line,
"contract_placeholder": is_ph,
}