""" 字段合同校验:读 inquiry-required-fields-v1,只做作用域/非空/默认值。 本文件职责:判断首次查价缺项;报价日期未说则填当天,不进入补问。 口语已写 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 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"(? 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( *, facts: dict[str, Any], business_line: str = "", require_formal: bool = True, ) -> dict[str, Any]: """兼容骨架入口:现已接入正式激活规则。""" return validate_required_fields( facts=facts, business_line=business_line, require_formal=require_formal, )