""" 字段合同校验:读 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") # 空运询价必填:起运港改为选填。销售侧名称。内部仍用合同键。 AIR_REQUIRED_FIELDS = ( ("目的港", "目的地"), ("品名", "品名"), ("件数", "件数"), ("毛重", "重量(KG)"), ("体积", "体积(CBM)"), ("包装方式", "包装类型"), ("报价日期", "报价日期"), ) # 空运询价非必填:有值收下;贸易条款只记工单,不挡查价。 AIR_OPTIONAL_FIELDS = ( ("起运港", "起运地"), ("货源地", "货源地"), ("货好时间", "货好时间"), ("提货地址", "提货地址"), ("收货地址", "收货地址"), ("贸易条款", "贸易条款"), ) AIR_DISPLAY = {key: label for key, label in AIR_REQUIRED_FIELDS} AIR_DISPLAY.update({key: label for key, label in AIR_OPTIONAL_FIELDS}) # 内部键仍是起运港(主账/TMS)。销售侧一律写起运地。 AIR_DISPLAY["起运港"] = "起运地" # 海运询价必填 10 项(销售侧含运输方式):内部键「货量」对外叫「货物数量」。 SEA_REQUIRED_FIELDS = ( ("起运港", "起运港"), ("目的港", "目的港"), ("品名", "品名"), ("货量", "货物数量"), ("整柜或拼柜", "整柜或拼柜"), ("箱型箱量", "箱型箱量"), ("贸易条款", "贸易条款"), ("运输分类", "运输分类"), ("报价日期", "报价日期"), ) SEA_DISPLAY = {key: label for key, label in SEA_REQUIRED_FIELDS} # 陆运销售侧名称:卡片写始发站/货物品名;内部仍用合同键起运港/品名。 LAND_DISPLAY = { "运输类型": "运输类型", "线路类别": "线路类别", "运输分类": "运输分类", "起运港": "始发站", "目的港": "目的地", "品名": "货物品名", "毛重": "重量(KG)", "体积": "体积(CBM)", "车型/数量": "车型/数量", "通关口岸": "通关口岸", "报价日期": "报价日期", } # 协同补充字段:拉群后可静默写入,不挡首次查价。内部键仍是 HS编码。 SEA_COLLAB_FIELDS = ("货好时间", "HS编码", "是否含油", "是否含电", "是否含磁") # 空运群只补含电/含磁,摘要不要把海运协同五项搬过去。 AIR_COLLAB_FIELDS = ("是否含电", "是否含磁") # 群摘要对外展示名。内部继续用 HS编码,避免改合同键。 SEA_COLLAB_DISPLAY = { "HS编码": "商品海关编码", } # 陆运摘要标题:海关编码,不要写成海运的商品海关编码。 LAND_COLLAB_DISPLAY = { "HS编码": "海关编码", } # 填写样例允许拉群的类型+线路 → 协同内部键(顺序与规格一致)。 _LAND_COLLAB_BY_PAIR: dict[tuple[str, str], tuple[str, ...]] = { ("国内运输拼车", "国内长途/零担"): ( "客户名称", "包装方式", "货值", "是否为危险品", ), ("国内运输拼车", "国内城配/拖车/打包"): ( "客户名称", "包装方式", "货值", "是否为危险品", ), ("国内运输整车", "国内长途/零担"): ( "客户名称", "货值", "包装方式", "是否为危险品", ), ("国内运输整车", "国内城配/拖车/打包"): ( "客户名称", "货值", "包装方式", "是否为危险品", ), ("跨境集拼", "中港/中亚/中欧"): ( "客户名称", "HS编码", "贸易条款", "包装方式", "是否为危险品", ), ("跨境集拼", "东南亚"): ( "客户名称", "HS编码", "贸易条款", "包装方式", "是否为危险品", ), ("跨境整车", "中港/中亚/中欧"): ( "客户名称", "HS编码", "贸易条款", "货值", "包装方式", "是否为危险品", ), ("跨境整车", "东南亚"): ( "客户名称", "HS编码", "贸易条款", "货值", "包装方式", "是否为危险品", ), ("中港整车", "中港/中亚/中欧"): ( "客户名称", "HS编码", "车型/数量", "贸易条款", "货值", "包装方式", "是否为危险品", ), ("中港零担/集拼", "中港/中亚/中欧"): ( "客户名称", "HS编码", "贸易条款", "包装方式", "是否为危险品", ), } # 意图分类用:海运协同五项 ∪ 贸易条款(陆运跨境仍要)∪ 陆运协同键。 ALL_COLLAB_KEYS = tuple( dict.fromkeys( list(SEA_COLLAB_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 = re.sub(r"(必填)|(非必填)|\(必填\)|\(非必填\)", "", key).strip() key = re.sub(r"(KG)|(CBM)|\(KG\)|\(CBM\)", "", key, flags=re.I).strip() key = _ALIASES.get(key, key) if raw_val is None: continue val = str(raw_val).strip() if not val: continue out[key] = val _fold_land_vehicle_qty(out) return out def _fold_land_vehicle_qty(merged: dict[str, str]) -> None: """ 把拆开的车型、数量收成一个「车型/数量」。 销售侧只认这一项必填;模型若仍拆成两项,这里合并,禁止再分别补问。 不改主账。有「车型/数量」则保留原文,缺的再用车型、数量拼上。 """ combo = (merged.get("车型/数量") or "").strip() vehicle = (merged.get("车型") or "").strip() qty = (merged.get("数量") or "").strip() if not combo: if vehicle and qty: combo = f"{vehicle}/{qty}" elif vehicle: combo = vehicle elif qty: combo = qty elif qty and qty not in combo: combo = f"{combo}/{qty}" if combo: merged["车型/数量"] = combo 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 _quote_date_labeled_value(raw: str) -> str: """ 原话里「报价日期」或同义词后面的值。 询价日期、走货日期、发货时间、提货日期等都算报价日期。 能写成 yyyy-MM-dd 就规范;「明天」这类留原文。 没有这些标签则空串,交给句子里的裸日期。 同一句出现多次,用最后一次,避免前面的货好时间把日期抢走。 """ text = raw or "" labels = ("报价日期",) + QUOTE_DATE_SYNONYMS best_at = -1 best_val = "" for label in labels: pattern = re.compile( rf"(?= best_at: best_at = hit.start() best_val = hit.group(1).strip().strip(",,。;;、") if not best_val: return "" return _harvest_quote_date(best_val) or _excel_serial_date(best_val) or best_val def _excel_serial_date(token: str) -> str: """ Excel 把日期存成序列号时(如 46307),收成 yyyy-MM-dd。 只认整段就是 5 位数字。件数、重量不走这里。 """ from datetime import datetime, timedelta text = (token or "").strip() if text.endswith(".0"): text = text[:-2] if not re.fullmatch(r"\d{5}", text): return "" serial = int(text) # 约 1982–2064。再外的 5 位数不当日期。 if serial < 30000 or serial > 60000: return "" try: return (datetime(1899, 12, 30) + timedelta(days=serial)).date().isoformat() except OverflowError: return "" 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 "" _RE_CLARIFY_EXAMPLE = re.compile(r"^\s*[((]如\s*[::]") def _without_clarify_examples(text: str) -> str: """补问清单里的「(如:托盘、散货)」整行丢掉,避免示例被当成已填。""" return "\n".join( line for line in (text or "").splitlines() if not _RE_CLARIFY_EXAMPLE.match(line) ) def harvest_oral_measures(text: str, facts: dict[str, str] | None = None) -> dict[str, str]: """ 从当前原话机读件数/重量/体积/包装/报价日期。 当前句写了的覆盖旧值:散货替换托盘,9月16日写入报价日期。 补问复制清单的示例行不参与机读。 副作用:无。不改主账。不猜港口。 """ merged = dict(facts or {}) raw = _without_clarify_examples(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) else: # 尺寸式不换算成立方,原文留给报价单;TMS 出站再乘。 from agent.schema.tms_air_query import dimension_volume_text dim = dimension_volume_text(raw) if dim: merged["体积"] = dim pack = _harvest_packaging(raw) if pack: merged["包装方式"] = pack labeled_date = _quote_date_labeled_value(raw) if labeled_date: merged["报价日期"] = labeled_date else: 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 land_collab_fields(land_type: str, route: str) -> tuple[str, ...]: """ 本单陆运协同内部键,顺序与规格一致。 对不上填写样例返回空元组,调用方不得按海运协同五项去列。 海关编码内部仍是 HS编码。副作用:无。 """ from agent.schema.land_options import canonicalize_route, canonicalize_transport_type t = canonicalize_transport_type(land_type) or (land_type or "").strip() r = canonicalize_route(route) or (route or "").strip() return _LAND_COLLAB_BY_PAIR.get((t, r), ()) def collab_fields_for_ticket(facts: dict[str, str] | None, business_line: str) -> tuple[str, ...]: """ 按业务线取出群摘要 / 写入允许的协同键。 LAND 看事实里的运输类型+线路类别;SEA/AIR 用固定名单。 """ line = (business_line or "").strip().upper() if line == "AIR": return AIR_COLLAB_FIELDS if line == "LAND": src = dict(facts or {}) return land_collab_fields( str(src.get("运输类型") or ""), str(src.get("线路类别") or ""), ) return SEA_COLLAB_FIELDS def collab_display_name(key: str, business_line: str) -> str: """协同项对外标题。陆运 HS编码 → 海关编码;海运 → 商品海关编码。""" line = (business_line or "").strip().upper() if line == "LAND": return LAND_COLLAB_DISPLAY.get(key, key) return SEA_COLLAB_DISPLAY.get(key, key) def pick_collab_from_facts( facts: dict[str, str] | None, keys: tuple[str, ...], ) -> dict[str, str]: """ 从私聊事实里取出已有协同键,供进群预填。 只取 keys 里非空值。海关编码/商品海关编码经 normalize 折成 HS编码。 不在 keys 的询价项(如跨境整车车型/数量)不会被取出。 不覆盖调用方已有协同值;本函数只返回候选。 """ allowed = set(keys or ()) if not allowed: return {} src = normalize_facts(facts) out: dict[str, str] = {} for key in keys: val = (src.get(key) or "").strip() if val: out[key] = val return out def air_general_cargo_collab_defaults(facts: dict[str, str] | None) -> dict[str, str]: """ 空运货物品名正好是「普货」时,含电、含磁缺省为否。 只补事实里还没写的项。已经写了「是」或「否」的不在这里改。 品名多一个字(如普货纸箱)不算普货,不默认。 调用方仍须遵守:群里已记下的协同值优先,本函数只给空项。 """ src = normalize_facts(facts) if (src.get("品名") or "").strip() != "普货": return {} out: dict[str, str] = {} for key in AIR_COLLAB_FIELDS: # 摘要占位「-」等于没写,不能挡住默认否。 if (src.get(key) or "").strip() in {"", "-", "—", "-"}: out[key] = "否" return out def harvest_oral_collab(text: str) -> dict[str, str]: """ 群里口语协同:含油/含电/含磁、货好时间、是否为危险品。 只认原话里已经说死的词(不含电、随时可提、普货),不猜贸易条款和海关编码。 「(如:随时可提)」这种补问示例行不算销售说了货好时间。 危险品:否词(不是危险品/非危/普货)先于是词,避免「不是危险品」收成是。 """ raw = _without_clarify_examples(text or "") out: dict[str, str] = {} yes_no = ( ("是否含电", ("不含电", "不带电", "无电"), ("含电", "带电")), ("是否含油", ("不含油", "不带油", "无油"), ("含油", "带油")), ("是否含磁", ("不含磁", "不带磁", "无磁"), ("含磁", "带磁")), ( "是否为危险品", ("不是危险品", "非危险品", "非危", "普货"), ("危险品", "有危", "含危"), ), ) for key, no_words, yes_words in yes_no: if any(w in raw for w in no_words): out[key] = "否" elif any(w in raw for w in yes_words): out[key] = "是" ready = ("随时可提", "货好随时", "随时货好", "货已好", "已经货好") for word in ready: if word in raw: out["货好时间"] = word break return out # 原话里单独出现才收下。举例行「(如:DDP、DAP、CIF、CIP)」整行跳过,避免示例被当成已填。 _TRADE_TERM_CODES = ("DDP", "DAP", "DDU", "CIF", "CIP", "CPT", "CFR", "FOB", "FCA", "EXW") _TRADE_TERM_RE = re.compile( r"(?i)(? dict[str, str]: """ 原话里单独写出的 DAP、DDP、CIF 等记成贸易条款。 只认这一组术语本身,不从「门到门」猜。同一句出现两个不同术语时不选。 补问举例行整行不看。已有「贸易条款」标签值的,由调用方决定不覆盖。 """ kept: list[str] = [] for line in (text or "").splitlines() or [""]: if _TRADE_TERM_EXAMPLE_LINE.match((line or "").strip()): continue kept.append(line) found: list[str] = [] for match in _TRADE_TERM_RE.finditer("\n".join(kept)): code = match.group(1).upper() if code not in found: found.append(code) if len(found) != 1: return {} return {"贸易条款": found[0]} # 箱型原文:40HQ*1、1x40HQ、40尺高柜。货物数量不能收这种串。 _CONTAINER_SPEC = re.compile( r"(?i)(20|40|45)\s*['\"′尺]?\s*(HQ|GP|NOR|RH|HC|高柜|平柜|柜)" ) def looks_like_container_spec(text: str) -> bool: """这串是箱型箱量,不是件数或吨数。20件、20吨不算。""" return bool(_CONTAINER_SPEC.search(text or "")) def project_sea_cargo_qty(facts: dict[str, str] | None) -> dict[str, str]: """ 海运货物数量投影。 抽字段常把「20件」放进件数,又把「40HQ*1」写进货量。 货量只要长得像箱型,就丢掉,改用件数里的数量;件数也是箱型则不搬。 箱型箱量空着时,把这串箱型补到箱型箱量。不限具体件数。 """ merged = dict(facts or {}) qty = (merged.get("货量") or merged.get("货物数量") or "").strip() pieces = (merged.get("件数") or "").strip() if looks_like_container_spec(qty): if not (merged.get("箱型箱量") or "").strip(): merged["箱型箱量"] = qty merged.pop("货量", None) merged.pop("货物数量", None) qty = "" if not qty and pieces and not looks_like_container_spec(pieces): merged["货量"] = pieces return merged # 选定方式后,不属于该方式首次询价的键拿掉。海运件数先投影成货量再删。 _SEA_DROP_AFTER_PROJECT = ("件数", "毛重", "体积", "包装方式", "包装类型") _LAND_DROP_FOREIGN = ("件数", "货量", "货物数量", "整柜或拼柜", "箱型箱量", "贸易条款") _AIR_DROP_FOREIGN = ("货量", "货物数量", "整柜或拼柜", "箱型箱量") def shape_facts_for_chosen_mode(facts: dict[str, str] | None, business_line: str) -> dict[str, str]: """ 运输方式刚定下来时,按该方式的询价字段收口。 海运留下货物数量、整柜或拼柜、箱型箱量、贸易条款、运输分类;件数里的数量先写入货量。 陆运留下运输类型、线路类别、运输分类和重量体积或车型,不留海运箱型、空运件数。 空运留下件数、毛重、体积、包装方式,不留海运箱型。 不猜新值,只删不属于本方式的键。返回新 dict。 """ line = (business_line or "").strip().upper() merged = {str(k): str(v).strip() for k, v in dict(facts or {}).items() if str(v).strip()} if line == "SEA": merged = project_sea_cargo_qty(merged) for key in _SEA_DROP_AFTER_PROJECT: merged.pop(key, None) return merged if line == "LAND": for key in _LAND_DROP_FOREIGN: merged.pop(key, None) return merged if line == "AIR": for key in _AIR_DROP_FOREIGN: merged.pop(key, None) return merged return 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, facts: dict[str, str] | None = None, ) -> list[str]: """当前激活的首次查价内部键列表。中港整车/零担是两个运输类型。""" _ = facts 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 [] _PLACE_KEYS = ("起运港", "目的港") _PACK_IN_PLACE = ("散货", "纸箱", "木箱", "托盘", "卡板", "木托") _PRICE_TAIL = re.compile(r"(的价格|的报价|价格|报价)$") def peel_trailing_place_clauses(facts: dict[str, str] | None) -> dict[str, str]: """ 起运地/目的地只留逗号前的地名。 口语「目的地吉隆坡,普货,散货的价格」会被抽成一整串目的地。 逗号后面的品名、包装、价格不是地名,拆回去;已有品名/包装不覆盖。 """ merged = dict(facts or {}) for key in _PLACE_KEYS: raw = (merged.get(key) or "").strip() parts = [p.strip() for p in re.split(r"[,,、]", raw) if p.strip()] if len(parts) <= 1: continue place = parts[0] for part in parts[1:]: part = _PRICE_TAIL.sub("", part).strip() if not part or part in {"价格", "报价", "查价"}: continue if part in _PACK_IN_PLACE and not (merged.get("包装方式") or "").strip(): merged["包装方式"] = part continue if not (merged.get("品名") or "").strip() and not looks_like_container_spec(part): merged["品名"] = part merged[key] = place return merged 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) if line == "SEA": return SEA_DISPLAY.get(internal_key, internal_key) if line == "LAND": return LAND_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 = peel_trailing_place_clauses(apply_quote_date_default(normalize_facts(facts))) line = (business_line or "").strip().upper() if line == "SEA": normalized = project_sea_cargo_qty(normalized) from agent.schema.sea_options import canonicalize_sea_class klass = canonicalize_sea_class(str(normalized.get("运输分类") or "")) if klass: normalized["运输分类"] = klass else: normalized.pop("运输分类", None) 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": from agent.schema.land_options import scrub_land_placeholder_ports normalized = scrub_land_placeholder_ports(normalized) land_subtype = (land_subtype or "").strip() or str( normalized.get("运输类型") or "" ).strip() 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, normalized) 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, )