diff --git a/inquiry-agent/agent/channel/h5/air_route_page.py b/inquiry-agent/agent/channel/h5/air_route_page.py index 5e60ccc..e57b951 100644 --- a/inquiry-agent/agent/channel/h5/air_route_page.py +++ b/inquiry-agent/agent/channel/h5/air_route_page.py @@ -13,7 +13,11 @@ from typing import Any from agent.channel.h5.wecom_close import wecom_auto_close_script from agent.schema.air_route_options import ( + option_airline, + option_endpoint_name, option_fee_lines, + option_flight_date, + option_schedule, option_total, parse_air_options, route_codes, @@ -197,7 +201,11 @@ def expired_air_route_page() -> str: def render_air_route_page(payload: dict[str, Any]) -> str: """ 渲染选线路页。payload 来自 token:quote / facts / work_order_no。 + + cabin_pick 为真:没填报价日期,按舱位六项展示(含总价,不含费用明细)。 """ + if payload.get("cabin_pick"): + return _render_cabin_page(payload) quote = dict(payload.get("quote") or {}) facts = dict(payload.get("facts") or {}) wo = html.escape(str(payload.get("work_order_no") or "")) @@ -235,6 +243,73 @@ def render_air_route_ok() -> str: ) +def _render_cabin_page(payload: dict[str, Any]) -> str: + """没填日期的舱位列表。顺序以 token 里已排好的 airOptions 为准。""" + quote = dict(payload.get("quote") or {}) + facts = dict(payload.get("facts") or {}) + wo = html.escape(str(payload.get("work_order_no") or "")) + options = parse_air_options(quote) + rows = [_cabin_card(i, option, quote, facts) for i, option in enumerate(options)] + count = len(options) + body = ( + "
" + "
" + f"

{wo}

" + "

选择舱位

" + f"

共 {count} 条舱位,点选一条后确定。价格是 TMS 返回的总价。

" + "
" + "
" + + "".join(rows) + + "
" + "" + "
" + "" + ) + return _wrap("选择舱位", body) + + +def _cabin_field(label: str, value: str) -> str: + """舱位一项。空值留空,不写横杠,避免把没返回的班期显示成占位符。""" + shown = html.escape(value or "") + return ( + "
  • " + + html.escape(label) + + "" + + shown + + "
  • " + ) + + +def _cabin_card(index: int, option: dict[str, Any], quote: dict[str, Any], facts: dict[str, str]) -> str: + """ + 一张舱位:起运地、目的地、航班日期、班期、航司、总价。 + + 不展示费用明细。班期用 TMS 原文。 + """ + src = dict(option or {}) + fields = ( + ("起运地", option_endpoint_name(src, "origin", facts)), + ("目的地", option_endpoint_name(src, "destination", facts)), + ("航班日期", option_flight_date(src)), + ("班期", option_schedule(src)), + ("航司", option_airline(src)), + ) + field_html = "" + total = html.escape(option_total(src, quote)) + return ( + "" + ) + + def _option_card(index: int, option: dict[str, Any], quote: dict[str, Any], facts: dict[str, str]) -> str: """一张线路卡:航线、TMS 费用、总价。没有费用行就留空说明,不编造。""" src = dict(option or {}) diff --git a/inquiry-agent/agent/channel/h5/air_route_store.py b/inquiry-agent/agent/channel/h5/air_route_store.py index 48620e1..5e40451 100644 --- a/inquiry-agent/agent/channel/h5/air_route_store.py +++ b/inquiry-agent/agent/channel/h5/air_route_store.py @@ -33,15 +33,21 @@ def issue_air_route_token( work_order_no: str, quote: dict[str, Any], facts: dict[str, str] | None = None, + cabin_pick: bool = False, ttl_seconds: int = AIR_ROUTE_TTL_SECONDS, ) -> str: - """签发选线路 token。副作用:写 Redis 或内存。""" + """ + 签发选线路 token。副作用:写 Redis 或内存。 + + cabin_pick=True:没填报价日期,页面按舱位六项展示,选定后不再查价。 + """ payload = json.dumps( { "sender_id": sender_id, "work_order_no": work_order_no, "quote": quote, "facts": dict(facts or {}), + "cabin_pick": bool(cabin_pick), }, ensure_ascii=False, ) diff --git a/inquiry-agent/agent/llm/extract_text.py b/inquiry-agent/agent/llm/extract_text.py index 5909341..39fbbc5 100644 --- a/inquiry-agent/agent/llm/extract_text.py +++ b/inquiry-agent/agent/llm/extract_text.py @@ -46,7 +46,7 @@ _LABEL_KEYS = ( r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|" + _QUOTE_DATE_LABEL_RE + r"|运输分类|车型/数量|车型数量|" - r"贸易条款|货好时间|货源地|提货地址|收货地址|送货地址|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|" + r"贸易条款|货好时间|货源地|提货地址|收货地址|送货地址|增值服务(非必填)|增值服务|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|" r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸" ) _LABEL_LINE = re.compile(rf"^({_LABEL_KEYS})\s*[::]\s*(.+)$") @@ -68,6 +68,8 @@ _OPTIONAL_INQUIRY_LABELS = ( "收货地址", "送货地址", "贸易条款", + "增值服务(非必填)", + "增值服务", "起运港", ) # 图上常见「4. 希望送到…」:编号项不是询价字段,值收到这里为止。 @@ -112,6 +114,7 @@ _CLARIFY_NAMES = frozenset( "送货地址", "通关口岸", "始发站", + "增值服务", } ) # 报价单表头和格子在同一行:只洗这些询价键,不碰提货/收货地址。 @@ -477,6 +480,7 @@ def extract_inquiry_snapshot( if str(facts.get(key) or "").strip(): facts[key] = clip_inquiry_value(str(facts[key])) facts = _drop_fee_place_values(_clip_sheet_inquiry_facts(facts)) + facts = _apply_air_added_services(text, facts, mode) subtype = str(facts.get("运输类型") or "").strip() return { "business_line": mode, @@ -524,6 +528,7 @@ def extract_inquiry_snapshot( if str(facts.get(key) or "").strip(): facts[key] = clip_inquiry_value(str(facts[key])) facts = _drop_fee_place_values(_clip_sheet_inquiry_facts(facts)) + facts = _apply_air_added_services(text, facts, mode) land_subtype = str((b_payload or {}).get("land_subtype") or facts.get("运输类型") or "").strip() if land_subtype and not str(facts.get("运输类型") or "").strip(): facts["运输类型"] = land_subtype @@ -703,6 +708,15 @@ def _clip_sheet_inquiry_facts(facts: dict[str, str]) -> dict[str, str]: return {key: val for key, val in out.items() if str(val or "").strip()} +def _apply_air_added_services(text: str, facts: dict[str, str], mode: str) -> dict[str, str]: + """空运才收增值服务。其他业务线原样返回,避免海运口播里的托盘被记进来。""" + if (mode or "").strip().upper() != "AIR": + return facts + from agent.schema.added_services import apply_air_value_added + + return apply_air_value_added(text, facts) + + def _merge_optional_inquiry_labels(text: str, facts: dict[str, str]) -> dict[str, str]: """把原话里的通关口岸、货好时间、提货/收货地址写进询价事实。有值才覆盖。""" from agent.schema.field_validate import normalize_facts @@ -754,6 +768,9 @@ def _harvest_labels(text: str, labels: tuple[str, ...]) -> dict[str, str]: begin = pos + len(label) end = kept[i + 1][0] if i + 1 < len(kept) else len(raw) chunk = raw[begin:end] + # 增值服务只认冒号后同一行。空行不能把下一行「报价日期(非必填):」收成服务。 + if label.startswith("增值服务"): + chunk = chunk.split("\n", 1)[0] chunk = re.sub(r"^[\s::,,、]+", "", chunk) chunk = re.sub(r"[\s,,、]+$", "", chunk) if chunk: diff --git a/inquiry-agent/agent/policy/air_text_flow.py b/inquiry-agent/agent/policy/air_text_flow.py index 5ce7395..e2cd19e 100644 --- a/inquiry-agent/agent/policy/air_text_flow.py +++ b/inquiry-agent/agent/policy/air_text_flow.py @@ -1,7 +1,9 @@ """ 空运文字询价确定性流程(模型只负责抽字段,本文件只编排与执行)。 -本文件职责:补问到必填齐 → 核对确定后建单 → 查 TMS(一条出卡,多条 H5 选线路)。 +本文件职责:补问到必填齐 → 核对确定后建单 → 查 TMS。 +说了报价日期:一条直接出卡,没写起运地且多条才进选线路。 +没说报价日期:不填当天,查价不带日期,返回的舱位全部进 H5(1 条也要点),选定后不再查价。 对不上机场也不能跳过查价、本地说暂无报价。 调用:handler 在 Worker/inbox 线程;禁止回调线程同步跑。 禁止:直连 TMS;用图状态冒充主账成功;用户文字冒充文件已生成。 @@ -971,6 +973,8 @@ class AirTextInquiryFlow: # 让本句按新询价走,避免正文里的「确认」误伤。 sess = self._detach_ticketed_for_new_inquiry(sess) + added_source = text + prev_added = "" snap = extract_inquiry_snapshot( text, injected_facts=injected_facts, @@ -994,19 +998,26 @@ class AirTextInquiryFlow: facts = dict(snap.get("facts") or {}) if sess and sess.phase in {"clarify", "need_mode", "wait_confirm"}: # 补问轮:旧字段打底;运输方式沿用上一句(飞=空运) + # 增值服务按句累加,本句没提时不能把上一句已记下的服务冲掉。 from agent.llm.extract_text import detect_transport_mode from agent.schema.field_validate import harvest_oral_measures + prev_added = str((sess.facts or {}).get("added_services") or "") merged = dict(sess.facts) merged.update(facts) - source = waiting_mode_source(sess, text) - facts = harvest_oral_measures(source, merged) + added_source = waiting_mode_source(sess, text) + facts = harvest_oral_measures(added_source, merged) mode = mode or (sess.business_line or "").upper() if not mode: mode = detect_transport_mode( f"{sess.immutable_text or ''}\n{text}" ) + if (mode or "").upper() == "AIR": + from agent.schema.added_services import apply_air_value_added + + facts = apply_air_value_added(added_source, facts, previous=prev_added) + if not mode: reply(copy.ask_transport()) self._save( @@ -1268,9 +1279,31 @@ class AirTextInquiryFlow: options_matching_origin, parse_air_options, project_selected_quote, + sort_cabin_options, ) options = parse_air_options(quote) + # 没填报价日期:不按起运地收口,TMS 返回的舱位全部给销售点。1 条也要点。 + if not str((sess.facts or {}).get("报价日期") or "").strip() and options: + quote = dict(quote) + quote["airOptions"] = sort_cabin_options(options) + up = self._ledger.upsert_quote(work_order_no=no, quote=quote, to_status="已报价") + if not up.get("ok"): + reply("主账写入报价失败,不能当作已报价。") + sess.phase = "ledger_fail" + self._save(sess) + return "ledger_fail" + sess.quote = quote + copy.remember_tms_quote(sess, quote) + sess.tms_quote = dict(quote) + sess.status = "已报价" + return self._offer_air_routes( + sess=sess, + work_order_no=no, + quote=quote, + reply=reply, + cabin_pick=True, + ) stated_origin = str( (sess.facts or {}).get("起运港") or (sess.facts or {}).get("起运地") or "" ).strip() @@ -1335,8 +1368,13 @@ class AirTextInquiryFlow: work_order_no: str, quote: dict[str, Any], reply: ReplyFn, + cabin_pick: bool = False, ) -> str: - """多条线路:企微只发入口卡,选线在 H5。""" + """ + 企微只发入口卡,点选在 H5。 + + cabin_pick:没填报价日期,列出全部舱位(含 1 条)。选定后用该条总价出报价,不再查价。 + """ from agent.channel.h5.air_route_store import build_air_route_url, issue_air_route_token from agent.schema.air_route_options import parse_air_options @@ -1345,6 +1383,7 @@ class AirTextInquiryFlow: work_order_no=work_order_no, quote=quote, facts=dict(sess.facts), + cabin_pick=cabin_pick, ) url = build_air_route_url(token) count = len(parse_air_options(quote)) @@ -1353,8 +1392,12 @@ class AirTextInquiryFlow: sess.wait_version += 1 self._save(sess) reply( - copy.air_route_entry_text(work_order_no=work_order_no, count=count), - copy.air_route_entry_card(work_order_no=work_order_no, count=count, url=url), + copy.air_route_entry_text( + work_order_no=work_order_no, count=count, cabin_pick=cabin_pick + ), + copy.air_route_entry_card( + work_order_no=work_order_no, count=count, url=url, cabin_pick=cabin_pick + ), ) return "wait_air_route" diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py index b6401cd..d1bce04 100644 --- a/inquiry-agent/agent/policy/inquiry_copy.py +++ b/inquiry-agent/agent/policy/inquiry_copy.py @@ -57,7 +57,7 @@ def _cabin_copy(code: str, default: str, **kwargs: Any) -> str: return text -# 空运补问:必填 7 项 + 非必填。报价日期默认当天,不进待补充。 +# 空运补问:必填项 + 非必填。报价日期非必填,没说不默认当天,不进待补充。 _AIR_EXAMPLES = { "起运港": "南京", "目的港": "吉隆坡", @@ -72,6 +72,7 @@ _AIR_EXAMPLES = { "提货地址": "东莞仓库", "收货地址": "马尼拉", "贸易条款": "DDP、DAP、CIF、CIP", + "added_services": "托盘、前置仓、带电、卡转、卡转低消、MUST GO、备装、分泡、温控、快件、DG、TC", } _AIR_CLARIFY = tuple( (key, label, _AIR_EXAMPLES[key]) for key, label in AIR_REQUIRED_FIELDS @@ -567,15 +568,17 @@ def air_confirm_card(facts: dict[str, str] | None) -> str: """ 确定查价前的核对卡。没有工单号。 - 起运地(非必填)紧挨在目的地(必填)上面。其余非必填仍在报价日期后面。 + 起运地(非必填)紧挨在目的地(必填)上面。 + 报价日期说了写成「报价日期:日期」;没说写成「报价日期(非必填):」,不填当天。 """ src = dict(facts or {}) lines = ["运输方式(必填):空运"] lines.append(_air_field_line("起运港", _air_known_value(src, "起运港"), required=False)) for key, _label in AIR_REQUIRED_FIELDS: if key == "报价日期": - val = _air_known_value(src, key) or (src.get("报价日期") or "").strip() - lines.append(_air_field_line(key, val, required=True)) + val = _air_known_value(src, key) + # 说了日期就只写日期;没说标明非必填并留空,禁止写成当天。 + lines.append(f"报价日期:{val}" if val else "报价日期(非必填):") continue lines.append(_air_field_line(key, _air_known_value(src, key), required=True)) for key, _label in AIR_OPTIONAL_FIELDS: @@ -616,7 +619,8 @@ def ask_clarify( """ 缺项补问:先回显已识别,再给可复制清单;示例另起一行,如「1.件数(必填):」下一行「(如:10)」。 - 报价日期写进已识别(销售说了用原话,没说用当天),不出现在待补充。 + 空运报价日期:说了就写成「报价日期:日期」,没说写成「报价日期(非必填):」,不进待补充。 + 海运报价日期写进已识别(销售说了用原话,没说用当天),不出现在待补充。 空运非必填一并列出,没填进待补充,不挡查价。 """ src = dict(facts or {}) @@ -635,6 +639,10 @@ def ask_clarify( lines.append(f"起运地:{origin}") for key, label, _example in rows: val = known(src, key) + if not sea and key == "报价日期": + # 空运日期不默认当天。没说也要占一行,方便销售看到这项可以空着。 + lines.append(f"报价日期:{val}" if val else "报价日期(非必填):") + continue if val: lines.append(f"{label}:{val}") if not sea: @@ -758,7 +766,9 @@ def inquiry_field_lines( dest = (facts.get("目的港") or facts.get("目的地") or "-").strip() cargo = (facts.get("品名") or "-").strip() or "-" pack = (facts.get("包装方式") or facts.get("包装类型") or "-").strip() or "-" - quote_date = (facts.get("报价日期") or "-").strip() or "-" + quote_date = (facts.get("报价日期") or "").strip() + if quote_date in {"-", "—", "-"}: + quote_date = "" if (transport_mode or "").strip() == "海运": qty = (facts.get("货量") or facts.get("货物数量") or "-").strip() or "-" load = (facts.get("整柜或拼柜") or "-").strip() or "-" @@ -776,7 +786,7 @@ def inquiry_field_lines( f"箱型箱量:{box}", f"贸易条款:{trade}", f"运输分类:{klass}", - f"报价日期:{quote_date}", + f"报价日期:{quote_date or '-'}", ] if (transport_mode or "").strip() == "陆运": land_type = (facts.get("运输类型") or "").strip() @@ -794,7 +804,7 @@ def inquiry_field_lines( if land_type in _LAND_CUSTOMS_TYPES: lines.append(f"通关口岸:{(facts.get('通关口岸') or '').strip()}") return lines - return [ + lines = [ f"工单号:{work_order_no}", f"运输方式:{transport_mode}", f"起运地:{origin}", @@ -804,8 +814,13 @@ def inquiry_field_lines( f"重量(KG):{_with_unit(str(facts.get('毛重') or ''), 'KGS')}", f"体积(CBM):{_with_unit(str(facts.get('体积') or ''), 'CBM')}", f"包装类型:{pack}", - f"报价日期:{quote_date}", + f"报价日期:{quote_date}" if quote_date else "报价日期(非必填):", ] + # 建单确认和空运群:有内容才多一行。没填不出现。核对卡的空行不在这里。 + added = (facts.get("added_services") or facts.get("增值服务") or "").strip() + if added and added not in {"-", "—", "-"}: + lines.insert(-1, f"增值服务:{added}") + return lines def inquiry_card( @@ -1379,30 +1394,41 @@ def tms_hit_card( return "\n".join(lines) -def air_route_entry_card(*, work_order_no: str, count: int, url: str) -> dict[str, Any]: +def air_route_entry_card( + *, + work_order_no: str, + count: int, + url: str, + cabin_pick: bool = False, +) -> dict[str, Any]: """ - 多条线路入口卡:短文案跳转 H5,避免企微截断。 + 入口卡:短文案跳转 H5,避免企微截断。 + + cabin_pick 为真时按舱位条数原文展示(1 条也发)。 + 说了报价日期的选线路仍至少写 2 条,和原来多线路入口一致。 """ - n = max(int(count or 0), 2) + n = max(int(count or 0), 1 if cabin_pick else 2) + noun = "舱位" if cabin_pick else "线路" fallback = "https://ai.ytd-scm.com/inquiry-form" action_url = (url or "").strip() or fallback return { "msgtype": "template_card", "template_card": { "card_type": "text_notice", - "source": {"desc": "TMS线路报价", "desc_color": 0}, - "main_title": {"title": f"{work_order_no}已查到{n}条线路"}, - "sub_title_text": "点击查看并选择一条线路,选定后回企微看价格明细。", + "source": {"desc": "TMS舱位报价" if cabin_pick else "TMS线路报价", "desc_color": 0}, + "main_title": {"title": f"{work_order_no}已查到{n}条{noun}"}, + "sub_title_text": f"点击查看并选择一条{noun},选定后回企微看价格明细。", "card_action": {"type": 1, "url": action_url}, - "jump_list": [{"type": 1, "title": "查看并选择线路", "url": action_url}], + "jump_list": [{"type": 1, "title": f"查看并选择{noun}", "url": action_url}], }, } -def air_route_entry_text(*, work_order_no: str, count: int) -> str: - """入口卡失败时的文字兜底。""" - n = max(int(count or 0), 2) - return f"{work_order_no}已查到{n}条线路,请点击卡片查看并选择。" +def air_route_entry_text(*, work_order_no: str, count: int, cabin_pick: bool = False) -> str: + """入口卡失败时的文字兜底。没填日期时 1 条舱位也提示去点选。""" + n = max(int(count or 0), 1 if cabin_pick else 2) + noun = "舱位" if cabin_pick else "线路" + return f"{work_order_no}已查到{n}条{noun},请点击卡片查看并选择。" def tms_hit_wecom_payload( diff --git a/inquiry-agent/agent/schema/added_services.py b/inquiry-agent/agent/schema/added_services.py new file mode 100644 index 0000000..61f5154 --- /dev/null +++ b/inquiry-agent/agent/schema/added_services.py @@ -0,0 +1,267 @@ +""" +空运增值服务。 + +本文件职责:把销售原话收成多项 added_services,供确认、工单和 TMS 的 addedServices 使用。 +12 个服务词只要完整出现就记;名单外的必须写在「增值服务」后面才记,并保留原词。 +包装类型/包装方式这一栏里的「托盘」只算包装,不抄进增值服务。 +「增值服务:」同一行为空时,不把下一行收进来,也不沿用上一句已经记下的服务。 +不挡查价。不改六态。海运、陆运不走这里。 +""" + +from __future__ import annotations + +import re + +from agent.schema.field_validate import _PACK_WORDS, _without_clarify_examples + +# 长词在前,避免「卡转低消」再拆出「卡转」。 +_CN_TERMS = ( + "卡转低消", + "前置仓", + "托盘", + "带电", + "卡转", + "备装", + "分泡", + "温控", + "快件", +) +_CN_RE = re.compile("|".join(re.escape(word) for word in _CN_TERMS)) +_MUST_GO_RE = re.compile(r"(?i)MUST\s*GO") +_DG_RE = re.compile(r"(?i)(? list[str]: + """ + 把一栏增值服务拆成多项。 + + 顿号、中文逗号、英文逗号都是分隔。空格不拆,MUST GO 仍是一项。 + 副作用:无。 + """ + text = (raw or "").strip() + if not text: + return [] + out: list[str] = [] + seen: set[str] = set() + for part in _SPLIT_RE.split(text): + token = part.strip().strip("。;; ") + key = token.casefold() + if not token or key in seen: + continue + seen.add(key) + out.append(token) + return out + + +def join_added_services(items: list[str]) -> str: + """多项用顿号连成销售看到的一行。空则空串。""" + return "、".join(split_added_services("、".join(items))) + + +def harvest_added_services(text: str) -> list[str]: + """ + 从当前原话按出现顺序抽出增值服务。 + + 补问示例行「(如:托盘、…)」整行不看,避免清单被当成已填。 + 「不带电」不算带电;「卡转低消」不算再加一项卡转。 + 副作用:无。 + """ + raw = _without_clarify_examples(text or "") + events: list[tuple[int, str]] = [] + # 包装栏里的托盘已在 _canonical_events 排除,这里只收正文里真正的服务词。 + events.extend(_canonical_events(raw)) + for match in _LABEL_RE.finditer(raw): + value = (match.group(1) or "").strip() + if not value or value.startswith("(如") or value.startswith("(如"): + continue + base = match.start(1) + cursor = 0 + for part in _SPLIT_RE.split(value): + piece = part.strip().strip("。;; ") + if not piece: + continue + at = value.find(part, cursor) + if at < 0: + at = cursor + cursor = at + len(part) + extra = _outside_list_text(piece) + if extra: + events.append((base + at + max(piece.find(extra), 0), extra)) + events.sort(key=lambda item: item[0]) + return _dedupe([word for _pos, word in events]) + + +def apply_air_value_added( + text: str, + facts: dict[str, str] | None, + previous: str = "", +) -> dict[str, str]: + """ + 把本句增值服务并进事实,并避免托盘盖住已经说出的其他包装。 + + previous 是上一句已经记下的 added_services。本句没提服务时保留旧值。 + 只应在空运调用。副作用:返回新字典,不改入参。 + """ + merged = dict(facts or {}) + found = harvest_added_services(text) + labeled = _labeled_added_value(text) + # 销售把「增值服务:」留空:这一栏就是空的。 + # 不沿用上一句,也不把包装类型里的托盘、下一行报价日期算进来。 + # 本句没写这一栏、也没提到服务时,才保留已经记下的旧值。 + if labeled == "": + items = found + elif found: + items = _dedupe(split_added_services(previous) + found) + else: + items = _dedupe( + split_added_services(previous) + + split_added_services(str(merged.get("added_services") or "")) + ) + if items: + merged["added_services"] = "、".join(items) + elif not str(merged.get("added_services") or "").strip(): + merged.pop("added_services", None) + _keep_stated_package(text, merged) + return merged + + +def _canonical_events(raw: str) -> list[tuple[int, str]]: + """按原文位置列出 12 个标准词。重叠区间只留更长的那一项。""" + found: list[tuple[int, int, str]] = [] + package_pallets = _package_pallet_spans(raw) + for match in _CN_RE.finditer(raw): + word = match.group(0) + if word == "带电" and match.start() > 0 and raw[match.start() - 1] in _NEG_PREFIX: + continue + if word == "托盘" and _span_inside(match.start(), match.end(), package_pallets): + continue + found.append((match.start(), match.end(), word)) + for match in _MUST_GO_RE.finditer(raw): + found.append((match.start(), match.end(), "MUST GO")) + for match in _DG_RE.finditer(raw): + found.append((match.start(), match.end(), "DG")) + for match in _TC_RE.finditer(raw): + found.append((match.start(), match.end(), "TC")) + found.sort(key=lambda item: (item[0], -(item[1] - item[0]))) + accepted: list[tuple[int, int]] = [] + events: list[tuple[int, str]] = [] + for start, end, word in found: + if any(not (end <= left or start >= right) for left, right in accepted): + continue + accepted.append((start, end)) + events.append((start, word)) + return events + + +def _outside_list_text(piece: str) -> str: + """ + 增值服务标签里、12 个词没有覆盖到的原话。 + + 「托盘」本身会被标准词扫到,这里不重复收。 + 「打木架和带电」只把打木架留下来,带电仍由标准词按出现位置记。 + """ + spans = _canonical_events(piece) + if not spans: + return piece.strip() + chars = list(piece) + for start, word in spans: + # _canonical_events 只有起点。用标准词长度擦掉;MUST GO 用原文长度。 + end = start + _span_len(piece, start, word) + for index in range(start, min(end, len(chars))): + chars[index] = " " + rest = _FILLER.sub("", "".join(chars)) + return rest.strip() + + +def _span_len(piece: str, start: int, word: str) -> int: + """标准词在原文里占几位。MUST GO 中间空格可有可无。""" + if word == "MUST GO": + match = _MUST_GO_RE.match(piece, start) + if match: + return match.end() - match.start() + if word in {"DG", "TC"}: + return 2 + return len(word) + + +def _labeled_added_value(text: str) -> str | None: + """ + 原话里有没有单独的「增值服务」栏。 + + 没写这一栏返回 None。写了但冒号后同一行为空,返回空串。 + 只看同一行,避免空栏把下一行报价日期收进来。 + """ + raw = _without_clarify_examples(text or "") + seen = False + value = "" + for match in _LABEL_RE.finditer(raw): + seen = True + piece = (match.group(1) or "").strip().strip("。;; ") + if not piece or piece.startswith("(如") or piece.startswith("(如"): + continue + value = piece + if not seen: + return None + return value + + +def _package_pallet_spans(raw: str) -> list[tuple[int, int]]: + """「包装类型:托盘」里那个托盘的起止。只算包装,不算增值服务。""" + spans: list[tuple[int, int]] = [] + for match in _PACKAGE_PALLET_RE.finditer(raw or ""): + end = match.end() + spans.append((end - len("托盘"), end)) + return spans + + +def _span_inside(start: int, end: int, spans: list[tuple[int, int]]) -> bool: + """当前词是否落在给定区间里。""" + return any(start >= left and end <= right for left, right in spans) + + +def _keep_stated_package(text: str, facts: dict[str, str]) -> None: + """ + 同一句里还有纸箱、木箱、卡板、散货时,包装类型保持那个词。 + + 包装栏里的托盘不进增值服务,只在没另说包装时由机读记成包装类型。 + """ + raw = _without_clarify_examples(text or "") + if "托盘" not in raw: + return + last = "" + last_pos = -1 + for word in _PACK_WORDS: + if word == "托盘": + continue + pos = raw.rfind(word) + if pos > last_pos: + last_pos = pos + last = word + if last: + facts["包装方式"] = last + + +def _dedupe(items: list[str]) -> list[str]: + """按第一次出现保留,英文忽略大小写。""" + out: list[str] = [] + seen: set[str] = set() + for item in items: + token = (item or "").strip() + key = token.casefold() + if not token or key in seen: + continue + seen.add(key) + out.append(token) + return out diff --git a/inquiry-agent/agent/schema/air_route_options.py b/inquiry-agent/agent/schema/air_route_options.py index 871e41c..94a3466 100644 --- a/inquiry-agent/agent/schema/air_route_options.py +++ b/inquiry-agent/agent/schema/air_route_options.py @@ -255,10 +255,131 @@ def option_total(option: dict[str, Any] | None, quote: dict[str, Any] | None = N return text +# TMS 舱位「班期」可能用的键。页面只展示第一个非空值,例如 D234567,不翻译。 +_SCHEDULE_KEYS = ( + "班期", + "schedule", + "flightSchedule", + "flight_schedule", + "frequency", + "weekFrequency", + "scheduleDays", + "operatingDays", + "flightFrequency", + "departureSchedule", + "scheduleCode", + "flightPeriod", + "frequencyCode", +) + + +def option_flight_date(option: dict[str, Any] | None) -> str: + """ + 舱位上的航班日期。优先 flightDate,其次从起飞时间取日期。 + + 没有则空串。不编当天。 + """ + src = dict(option or {}) + for key in ("flightDate", "flight_date", "departureDate", "departure_date"): + text = str(src.get(key) or "").strip() + if text: + return _date_head(text) + return _date_head(str(src.get("departureTime") or src.get("departure_time") or "")) + + +def _date_head(raw: str) -> str: + """yyyy-MM-dd 开头就取日期,否则原样。空串保持空。""" + text = (raw or "").strip() + if len(text) >= 10 and text[4] == "-" and text[7] == "-": + return text[:10] + return text + + +def option_schedule(option: dict[str, Any] | None) -> str: + """班期按 TMS 原样,例如 D234567。没有就空串,不编。""" + src = dict(option or {}) + for key in _SCHEDULE_KEYS: + text = str(src.get(key) or "").strip() + if text: + return text + return "" + + +def option_airline(option: dict[str, Any] | None) -> str: + """航司:有中文名用中文名,否则用代码。没有就空串。""" + src = dict(option or {}) + for key in ("airlineName", "airline", "airlineCode", "carrier"): + text = str(src.get(key) or "").strip() + if text: + return text + return "" + + +def option_endpoint_name( + option: dict[str, Any] | None, + side: str, + facts: dict[str, str] | None = None, +) -> str: + """ + 舱位起运地或目的地。三字码能对上就用中文,对不上展示代码。 + + side 取 origin / destination。不编城市。 + """ + src = dict(option or {}) + facts = facts or {} + if side == "destination": + code = str(src.get("destinationAirportCode") or src.get("destination") or "") + fallback = str(facts.get("目的港") or facts.get("目的地") or "") + else: + code = str(src.get("originAirportCode") or src.get("origin") or "") + fallback = str(facts.get("起运港") or facts.get("起运地") or "") + return _place_name(code, fallback) + + +def _price_sort_value(option: dict[str, Any]) -> float: + """总价排序用。解析不出的排到同一天最后。""" + raw = option.get("salePrice") + if raw is None or str(raw).strip() == "": + return float("inf") + num = "" + for ch in str(raw).replace(",", ""): + if ch.isdigit() or ch == ".": + num += ch + elif num: + break + try: + return float(num) if num else float("inf") + except ValueError: + return float("inf") + + +def sort_cabin_options(options: list[dict[str, Any]] | None) -> list[dict[str, Any]]: + """ + 没填报价日期时的舱位顺序:航班日期从早到晚,同一天总价从低到高。 + + 没有日期的排在最后。不改入参。 + """ + rows = [dict(item) for item in list(options or [])] + + def _key(item: dict[str, Any]) -> tuple[str, float]: + day = option_flight_date(item) or "9999-99-99" + return (day, _price_sort_value(item)) + + return sorted(rows, key=_key) + + def apply_selected_option(facts: dict[str, str] | None, option: dict[str, Any] | None) -> dict[str, str]: - """选定线路后写回起运港(及能对上的三字码)。改选覆盖。""" + """ + 选定舱位后写回起运港(及能对上的三字码)。改选覆盖。 + + 工单还没有报价日期时,把这条的航班日期写回去,供报价单和锁舱使用。 + 销售已经说了日期则不覆盖。 + """ merged = dict(facts or {}) src = dict(option or {}) + flight_date = option_flight_date(src) + if flight_date and not str(merged.get("报价日期") or "").strip(): + merged["报价日期"] = flight_date code = str(src.get("originAirportCode") or src.get("origin") or "").strip().upper() name = _place_name(code) if name: diff --git a/inquiry-agent/agent/schema/field_validate.py b/inquiry-agent/agent/schema/field_validate.py index 295d3f2..e0619f1 100644 --- a/inquiry-agent/agent/schema/field_validate.py +++ b/inquiry-agent/agent/schema/field_validate.py @@ -1,7 +1,8 @@ """ 字段合同校验:读 inquiry-required-fields-v1,只做作用域/非空/默认值。 -本文件职责:判断首次查价缺项;报价日期未说则填当天,不进入补问。 +本文件职责:判断首次查价缺项。海运、陆运报价日期未说则填当天,不进入补问。 +空运报价日期非必填:没说不填当天、不进补问,确认后不带日期查价。 口语已写 TMS 单位(件/KGS/CBM)时,空着的件数/毛重/体积可机读补上。 禁止:正则猜港口/箱型;禁止用封闭枚举拒绝用户原文;禁止改六态。 """ @@ -37,6 +38,7 @@ AIR_OPTIONAL_FIELDS = ( ("提货地址", "提货地址"), ("收货地址", "收货地址"), ("贸易条款", "贸易条款"), + ("added_services", "增值服务"), ) AIR_DISPLAY = {key: label for key, label in AIR_REQUIRED_FIELDS} AIR_DISPLAY.update({key: label for key, label in AIR_OPTIONAL_FIELDS}) @@ -236,6 +238,9 @@ _ALIASES = { "贸易条款(非必填)": "贸易条款", "起运港(非必填)": "起运港", "起运地(非必填)": "起运港", + "增值服务": "added_services", + "增值服务(非必填)": "added_services", + "addedServices": "added_services", } for _quote_alias in QUOTE_DATE_SYNONYMS: _ALIASES[_quote_alias] = "报价日期" @@ -645,7 +650,11 @@ def shape_facts_for_chosen_mode(facts: dict[str, str] | None, business_line: str def apply_quote_date_default(facts: dict[str, str]) -> dict[str, str]: - """销售未提供报价日期时填当天;已有则不覆盖。""" + """ + 海运、陆运:销售未提供报价日期时填上海当天;已有则不覆盖。 + + 空运不要走这里。空运没说日期必须保持空白,不能填成当天。 + """ merged = dict(facts) if not merged.get("报价日期"): merged["报价日期"] = shanghai_today() @@ -730,13 +739,17 @@ def validate_required_fields( """ 首次查价完整性。 - 返回:ok / implemented / missing(内部键)/ missing_display / facts(含默认报价日期)/ errors。 - 报价日期不进 missing。未识别业务线时 missing 为空、need_transport_mode=True。 + 返回:ok / implemented / missing(内部键)/ missing_display / facts / errors。 + 报价日期不进 missing。海运、陆运没说则 facts 里带上当天;空运没说则保持空白。 + 未识别业务线时 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))) + normalized = peel_trailing_place_clauses(normalize_facts(facts)) line = (business_line or "").strip().upper() + # 只有海运、陆运把空日期补成当天。空运留给销售稍后在舱位里选定航班日期。 + if line in {"SEA", "LAND"}: + normalized = apply_quote_date_default(normalized) if line == "SEA": normalized = project_sea_cargo_qty(normalized) from agent.schema.sea_options import canonicalize_sea_class diff --git a/inquiry-agent/agent/schema/tms_air_query.py b/inquiry-agent/agent/schema/tms_air_query.py index afba3c6..919a5b0 100644 --- a/inquiry-agent/agent/schema/tms_air_query.py +++ b/inquiry-agent/agent/schema/tms_air_query.py @@ -236,9 +236,7 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]: if not pack_raw: missing.append("包装方式") missing_display.append("包装类型") - if not quote_date: - missing.append("报价日期") - missing_display.append("报价日期") + # 报价日期非必填:空着仍可查价,出站不带日期,不能补成当天。 ok = not missing out_facts = dict(src) diff --git a/inquiry-agent/tests/test_air_added_services.py b/inquiry-agent/tests/test_air_added_services.py new file mode 100644 index 0000000..12d45a6 --- /dev/null +++ b/inquiry-agent/tests/test_air_added_services.py @@ -0,0 +1,169 @@ +""" +空运增值服务:多项原词、示例行不当已填、传给 TMS 的是 addedServices 列表。 +""" + +from __future__ import annotations + +import os +import sys +import unittest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +os.environ.setdefault("YTD_ENV", "test") + +from agent.llm.extract_text import extract_inquiry_snapshot +from agent.policy import inquiry_copy as copy +from agent.schema.added_services import harvest_added_services, split_added_services +from agent.schema.tms_air_query import assemble_air_query + + +_EXAMPLE = "(如:托盘、前置仓、带电、卡转、卡转低消、MUST GO、备装、分泡、温控、快件、DG、TC)" + + +class AirAddedServicesTests(unittest.TestCase): + def test_split_enumeration_and_chinese_comma(self) -> None: + """顿号、中文逗号、英文逗号拆成同一组,空格不拆开 MUST GO。""" + self.assertEqual( + split_added_services("托盘、前置仓、带电"), + ["托盘", "前置仓", "带电"], + ) + self.assertEqual( + split_added_services("托盘,前置仓,带电"), + ["托盘", "前置仓", "带电"], + ) + self.assertEqual( + split_added_services("托盘,前置仓,带电"), + ["托盘", "前置仓", "带电"], + ) + self.assertEqual(split_added_services("MUST GO、带电"), ["MUST GO", "带电"]) + + def test_example_line_is_not_filled(self) -> None: + text = "增值服务(非必填):\n" + _EXAMPLE + self.assertEqual(harvest_added_services(text), []) + + def test_labeled_value_keeps_order_and_canonical_english(self) -> None: + dun = harvest_added_services("增值服务(非必填):托盘、前置仓、带电") + comma = harvest_added_services("增值服务(非必填):托盘,前置仓,带电") + self.assertEqual(dun, ["托盘", "前置仓", "带电"]) + self.assertEqual(comma, ["托盘", "前置仓", "带电"]) + spoken = harvest_added_services("这票 must go,要dg和tc") + self.assertEqual(spoken, ["MUST GO", "DG", "TC"]) + + def test_longest_match_and_negation(self) -> None: + self.assertEqual(harvest_added_services("要卡转低消"), ["卡转低消"]) + self.assertNotIn("卡转", harvest_added_services("要卡转低消")) + self.assertEqual(harvest_added_services("不带电"), []) + self.assertEqual(harvest_added_services("带电"), ["带电"]) + + def test_outside_list_only_when_labeled(self) -> None: + """名单外的词必须写在增值服务后面才记;12 个词只要出现就记。""" + self.assertEqual(harvest_added_services("要打木架"), []) + self.assertEqual( + harvest_added_services("增值服务:打木架"), + ["打木架"], + ) + self.assertEqual( + harvest_added_services("包装纸箱,要卡转低消,带电,增值服务:打木架"), + ["卡转低消", "带电", "打木架"], + ) + + def test_extract_stores_added_services_and_keeps_other_package(self) -> None: + snap = extract_inquiry_snapshot( + "空运到吉隆坡,茶叶,10件,100公斤,2方,包装纸箱,要卡转低消,带电,增值服务:打木架", + injected_mode="AIR", + ) + facts = snap["facts"] + self.assertEqual(facts.get("added_services"), "卡转低消、带电、打木架") + self.assertEqual(facts.get("包装方式"), "纸箱") + self.assertEqual(facts.get("是否含电"), "是") + + def test_pallet_does_not_replace_another_package(self) -> None: + snap = extract_inquiry_snapshot( + "空运,包装是纸箱,要托盘", + injected_mode="AIR", + ) + facts = snap["facts"] + self.assertEqual(facts.get("包装方式"), "纸箱") + self.assertEqual(facts.get("added_services"), "托盘") + + def test_blank_service_line_does_not_copy_package_or_next_field(self) -> None: + """包装类型写托盘、增值服务留空时,不能把托盘和下一行报价日期抄进增值服务。""" + text = ( + "运输方式:空运\n" + "起运地:-\n" + "目的地:CRK\n" + "品名:LMM振动测试定位治具\n" + "件数:1件\n" + "重量(KG):1.5KG\n" + "体积(CBM):100CBM\n" + "包装类型:托盘\n" + "增值服务:\n" + "报价日期(非必填):" + ) + self.assertEqual(harvest_added_services(text), []) + snap = extract_inquiry_snapshot(text) + self.assertNotIn("added_services", snap["facts"]) + self.assertEqual(snap["facts"].get("包装方式"), "托盘") + card = copy.air_confirm_card(snap["facts"]) + self.assertIn("包装类型(必填):托盘", card) + self.assertIn("增值服务(非必填):\n", card) + self.assertNotIn("托盘、报价日期", card) + + def test_pallet_alone_is_package_and_service(self) -> None: + snap = extract_inquiry_snapshot( + "空运,不带电,包装托盘,目的地吉隆坡", + injected_mode="AIR", + ) + facts = snap["facts"] + self.assertEqual(facts.get("包装方式"), "托盘") + self.assertEqual(facts.get("added_services"), "托盘") + self.assertNotIn("带电", facts.get("added_services") or "") + self.assertEqual(facts.get("是否含电"), "否") + + def test_confirm_lists_optional_and_ticket_hides_empty(self) -> None: + card = copy.air_confirm_card({"目的港": "吉隆坡", "包装方式": "纸箱"}) + self.assertIn("增值服务(非必填):", card) + clarify = copy.ask_clarify( + facts={"目的港": "吉隆坡"}, + missing_keys=["品名"], + transport_mode="空运", + ) + self.assertIn("增值服务(非必填):\n" + _EXAMPLE, clarify) + lines = copy.inquiry_field_lines( + work_order_no="WO1", + facts={"目的港": "吉隆坡", "包装方式": "纸箱", "added_services": "托盘、带电"}, + transport_mode="空运", + ) + self.assertIn("增值服务:托盘、带电", lines) + empty = copy.inquiry_field_lines( + work_order_no="WO1", + facts={"目的港": "吉隆坡", "包装方式": "纸箱"}, + transport_mode="空运", + ) + self.assertFalse(any(line.startswith("增值服务") for line in empty)) + + def test_assemble_keeps_services_without_blocking(self) -> None: + ready = { + "目的港": "吉隆坡", + "品名": "茶叶", + "件数": "10", + "毛重": "100", + "体积": "2", + "包装方式": "纸箱", + "报价日期": "2026-10-13", + "added_services": "托盘、前置仓、带电", + } + out = assemble_air_query(ready) + self.assertTrue(out["ok"], out) + self.assertEqual(out["facts"]["added_services"], "托盘、前置仓、带电") + bare = dict(ready) + bare.pop("added_services") + self.assertTrue(assemble_air_query(bare)["ok"]) + self.assertNotIn("added_services", assemble_air_query(bare)["facts"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/inquiry-agent/tests/test_air_route_options.py b/inquiry-agent/tests/test_air_route_options.py index 47a2e08..bdb529d 100644 --- a/inquiry-agent/tests/test_air_route_options.py +++ b/inquiry-agent/tests/test_air_route_options.py @@ -87,6 +87,7 @@ class AirRouteOptionsTests(unittest.TestCase): "毛重": "100", "体积": "2", "包装方式": "托盘", + "报价日期": "2026-09-16", "tms_multi": "1", } phase = flow.on_text( @@ -144,6 +145,7 @@ class AirRouteOptionsTests(unittest.TestCase): "毛重": "21", "体积": "1", "包装方式": "散货", + "报价日期": "2026-09-16", } self.assertEqual( flow.on_text(sender_id="u_hgh", text="空运", reply=reply, injected_facts=facts, injected_mode="AIR"), @@ -174,6 +176,7 @@ class AirRouteOptionsTests(unittest.TestCase): "毛重": "100", "体积": "2", "包装方式": "托盘", + "报价日期": "2026-09-16", "tms_multi": "1", } flow.on_text(sender_id="u_zuh", text="空运", reply=reply, injected_facts=facts, injected_mode="AIR") @@ -199,6 +202,7 @@ class AirRouteOptionsTests(unittest.TestCase): "毛重": "100", "体积": "2", "包装方式": "托盘", + "报价日期": "2026-09-16", "tms_multi": "1", } phase = flow.on_text( @@ -413,6 +417,118 @@ class AirRouteOptionsTests(unittest.TestCase): self.assertIn("window.close()", desktop) self.assertNotIn("closeWindow", dead) + def test_blank_date_always_opens_cabin_page_and_writes_flight_date(self) -> None: + """没填日期:1 条舱位也要进 H5;选定后用该条总价,航班日期写回,不再查价。""" + from agent.schema.air_route_options import sort_cabin_options + + later = { + "originAirportCode": "PVG", + "destinationAirportCode": "LAX", + "flightDate": "2026-10-21", + "schedule": "D1", + "airlineName": "东航", + "salePrice": 1000, + "currency": "CNY", + } + earlier = { + "originAirportCode": "PVG", + "destinationAirportCode": "LAX", + "flightDate": "2026-10-20", + "schedule": "D234567", + "airlineName": "南航", + "salePrice": 3200, + "currency": "CNY", + } + ordered = sort_cabin_options([later, earlier]) + self.assertEqual(option_flight_date_of(ordered[0]), "2026-10-20") + + class Ledger(MemoryLedger): + def __init__(self) -> None: + super().__init__() + self.queries = 0 + + def query_tms(self, **kwargs): + self.queries += 1 + out = super().query_tms(**kwargs) + out["quote"]["airOptions"] = [later, earlier] + return out + + ledger = Ledger() + flow = AirTextInquiryFlow(ledger) + replies: list[str] = [] + + def reply(text: str, extra=None) -> None: + replies.append(text) + _ = extra + + facts = { + "起运港": "上海", + "目的港": "洛杉矶", + "品名": "普货", + "件数": "3", + "毛重": "180", + "体积": "2.5", + "包装方式": "纸箱", + } + self.assertEqual( + flow.on_text(sender_id="u_cabin", text="空运", reply=reply, injected_facts=facts, injected_mode="AIR"), + "wait_confirm", + ) + self.assertIn("报价日期(非必填):", replies[-1]) + self.assertNotIn("报价日期:2026", replies[-1]) + self.assertEqual(flow.on_text(sender_id="u_cabin", text="确定", reply=reply), "wait_air_route") + self.assertEqual(ledger.queries, 1) + self.assertTrue(any("条舱位" in x for x in replies)) + self.assertFalse(any("条线路" in x for x in replies)) + self.assertEqual(flow.select_air_route(sender_id="u_cabin", index=0, reply=reply), "wait_collab") + self.assertEqual(ledger.queries, 1) + sess = flow.session_of("u_cabin") + self.assertEqual(sess.facts.get("报价日期"), "2026-10-20") + self.assertIn("3200", str(sess.quote.get("total"))) + + def test_cabin_page_shows_six_fields_and_total_only(self) -> None: + html = render_air_route_page( + { + "cabin_pick": True, + "work_order_no": "WO202610130001", + "facts": {}, + "quote": { + "airOptions": [ + { + "originAirportCode": "PVG", + "destinationAirportCode": "LAX", + "flightDate": "2026-10-20", + "schedule": "D234567", + "airlineName": "南航", + "salePrice": 3200, + "currency": "CNY", + "departureCharges": [ + {"chargeName": "运输费", "currency": "CNY", "unitPrice": 11} + ], + } + ] + }, + } + ) + self.assertIn("选择舱位", html) + self.assertIn("起运地", html) + self.assertIn("目的地", html) + self.assertIn("航班日期", html) + self.assertIn("2026-10-20", html) + self.assertIn("班期", html) + self.assertIn("D234567", html) + self.assertIn("航司", html) + self.assertIn("南航", html) + self.assertIn("3200", html) + self.assertNotIn("运输费", html) + self.assertNotIn("userid", html.lower()) + + +def option_flight_date_of(option: dict) -> str: + from agent.schema.air_route_options import option_flight_date + + return option_flight_date(option) + if __name__ == "__main__": unittest.main() diff --git a/inquiry-agent/tests/test_air_text_inquiry.py b/inquiry-agent/tests/test_air_text_inquiry.py index 63eaab7..eda6b85 100644 --- a/inquiry-agent/tests/test_air_text_inquiry.py +++ b/inquiry-agent/tests/test_air_text_inquiry.py @@ -140,11 +140,16 @@ class AirTextInquiryTests(unittest.TestCase): ), ) - def test_quote_date_defaults_today_not_in_missing(self) -> None: + def test_air_quote_date_stays_blank_and_not_in_missing(self) -> None: + """空运没说报价日期不填当天,也不拿来补问。海运、陆运仍默认当天。""" result = validate_required_fields(facts=COMPLETE_AIR, business_line="AIR") self.assertTrue(result["ok"]) - self.assertTrue(result["facts"]["报价日期"]) + self.assertFalse(result["facts"].get("报价日期")) self.assertNotIn("报价日期", result["missing"]) + sea = validate_required_fields(facts={"起运港": "上海"}, business_line="SEA") + self.assertTrue(sea["facts"].get("报价日期")) + land = validate_required_fields(facts={"运输类型": "国内整车"}, business_line="LAND") + self.assertTrue(land["facts"].get("报价日期")) def test_fly_is_air_and_followup_keeps_mode(self) -> None: phase = self._on_text( @@ -284,7 +289,7 @@ class AirTextInquiryTests(unittest.TestCase): self.assertIn("目的地", last) self.assertIn("件数", last) self.assertIn("如:", last) - self.assertIn("报价日期:", last) + self.assertIn("报价日期(非必填):", last) self.assertNotIn("报价日期:(如", last) def test_clarify_paste_reply_fills_missing_required(self) -> None: diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java index f9de0ec..8f265d1 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryService.java @@ -121,14 +121,15 @@ public class InquiryTmsQueryService { segment.put("cargoReadyDate", first(facts.get("cargoReadyDate"), facts.get("货好时间"))); segment.put("placeOfReceipt", first(facts.get("placeOfReceipt"), facts.get("提货地址"))); segment.put("deliveryAddress", first(facts.get("deliveryAddress"), facts.get("收货地址"))); + // 工单 added_services 在这里收成列表,后面的 mapper 原样放到 TMS addedServices。 + List addedServices = TmsV2RequestMapper.splitAddedServices(first( + facts.get("addedServices"), facts.get("added_services"), facts.get("增值服务"))); + if (!addedServices.isEmpty()) { + segment.put("addedServices", addedServices); + } } - String quoteDate = TmsV2RequestMapper.normalizeQuoteDate(facts.get("报价日期")); - if (quoteDate == null) { - SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd"); - fmt.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai")); - quoteDate = fmt.format(new Date()); - } + String quoteDate = resolveOutboundQuoteDate(line, facts.get("报价日期")); List missing; if ("SEA".equals(line)) { @@ -162,6 +163,10 @@ public class InquiryTmsQueryService { String cacheKey = line + ":" + quoteDate + ":" + origin + ":" + destination + ":" + segment.get("weightKg") + ":" + segment.get("volumeCbm"); + if ("AIR".equals(line) && segment.get("addedServices") != null) { + // 增值服务不同不能共用同一条运价缓存。 + cacheKey += ":" + segment.get("addedServices"); + } if ("LAND".equals(line)) { cacheKey += ":" + segment.get("landTransportType") + ":" + segment.get("routeCategory") + ":" + segment.get("vehicleType") + ":" + segment.get("quantity"); @@ -1324,17 +1329,36 @@ public class InquiryTmsQueryService { return raw == null || String.valueOf(raw).trim().isEmpty(); } + /** + * 查价出站用的报价日期。 + * + * 海运、陆运没说则填上海当天。空运没说保持 null,不传 quoteDate, + * 让 TMS 一次返回各航班日期的舱位。销售说了的日期原样规范化,不用当天盖掉。 + */ + static String resolveOutboundQuoteDate(String line, Object raw) { + String quoteDate = TmsV2RequestMapper.normalizeQuoteDate(raw); + if (quoteDate != null) { + return quoteDate; + } + if ("AIR".equals(line)) { + return null; + } + SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd"); + fmt.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai")); + return fmt.format(new Date()); + } + /** * 空运出站齐套检查。地名栏允许空串,禁止再拿 destination 拦查价。 + * 报价日期非必填,空着也算出站齐套。 */ static List missingAirQuery(String workOrderNo, String quoteDate, Map segment) { List missing = new ArrayList<>(); if (workOrderNo == null || !workOrderNo.matches("WO\\d{12}")) { missing.add("workOrderNo"); } - if (quoteDate == null || quoteDate.isEmpty()) { - missing.add("quoteDate"); - } + // 报价日期非必填。空着不计入 missing;参数留给已有调用方,避免再按缺日期拦截。 + log.debug("air.query dateOptional len={}", quoteDate == null ? 0 : quoteDate.length()); for (String key : new String[] { "cargoName", "quantity", "weightKg", "volumeCbm", "packageType" }) { diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapper.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapper.java index 2fbb6de..3884fd6 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapper.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapper.java @@ -115,6 +115,12 @@ public final class TmsV2RequestMapper { put(target, "cargoReadyDate", first(source, "cargoReadyDate", "货好时间")); put(target, "placeOfReceipt", first(source, "placeOfReceipt", "提货地址")); put(target, "deliveryAddress", first(source, "deliveryAddress", "收货地址")); + // 有增值服务才传列表。空列表不能出站,否则 TMS 会当成带了服务条件。 + List addedServices = splitAddedServices( + first(source, "addedServices", "added_services", "增值服务")); + if (!addedServices.isEmpty()) { + target.put("addedServices", addedServices); + } } /** @@ -163,6 +169,40 @@ public final class TmsV2RequestMapper { put(target, "customsPort", first(source, "customsPort")); } + /** + * 空运增值服务拆成多项。 + *

    + * 工单里是 added_services 一句话,TMS 要 addedServices 列表。 + * 顿号、中文逗号、英文逗号都拆;空格不拆,MUST GO 仍是一项。 + * 已经是列表则逐项原样保留。空项丢掉,不改词。 + */ + public static List splitAddedServices(Object raw) { + List out = new ArrayList<>(); + if (raw instanceof List) { + for (Object item : (List) raw) { + addServiceToken(out, item); + } + return out; + } + String text = textOf(raw); + if (text.isEmpty()) { + return out; + } + for (String part : text.split("[、,,]")) { + addServiceToken(out, part); + } + return out; + } + + /** 去掉空白后并入列表。同一词只留第一次。 */ + private static void addServiceToken(List out, Object raw) { + String token = textOf(raw); + if (token.isEmpty() || out.contains(token)) { + return; + } + out.add(token); + } + private static String textOf(Object raw) { return raw == null ? "" : String.valueOf(raw).trim(); } diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryServiceTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryServiceTest.java index 1c4a040..3e08d8c 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryServiceTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/InquiryTmsQueryServiceTest.java @@ -92,4 +92,27 @@ public class InquiryTmsQueryServiceTest { "WO202609220004", "2026-09-25", segment); org.junit.jupiter.api.Assertions.assertTrue(missing.isEmpty(), String.valueOf(missing)); } + + @Test + public void airQueryAllowsBlankQuoteDate() { + // 空运没填报价日期也算出站齐套,不能再补成当天才放行。 + java.util.Map segment = new java.util.HashMap(); + segment.put("cargoName", "茶叶"); + segment.put("quantity", 1); + segment.put("weightKg", "1.5"); + segment.put("volumeCbm", "100"); + segment.put("packageType", "托盘"); + java.util.List missing = InquiryTmsQueryService.missingAirQuery( + "WO202609220004", null, segment); + org.junit.jupiter.api.Assertions.assertTrue(missing.isEmpty(), String.valueOf(missing)); + org.junit.jupiter.api.Assertions.assertNull( + InquiryTmsQueryService.resolveOutboundQuoteDate("AIR", null)); + org.junit.jupiter.api.Assertions.assertEquals( + "2026-10-08", + InquiryTmsQueryService.resolveOutboundQuoteDate("AIR", "2026-10-08")); + org.junit.jupiter.api.Assertions.assertNotNull( + InquiryTmsQueryService.resolveOutboundQuoteDate("SEA", null)); + org.junit.jupiter.api.Assertions.assertNotNull( + InquiryTmsQueryService.resolveOutboundQuoteDate("LAND", "")); + } } diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapperTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapperTest.java index 375dc6b..3a69ff9 100644 --- a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapperTest.java +++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/tms/TmsV2RequestMapperTest.java @@ -193,6 +193,31 @@ public class TmsV2RequestMapperTest { assertFalse(out.containsKey("tradeTerms")); assertFalse(out.containsKey("贸易条款")); assertNull(out.get("originCode")); + assertFalse(out.containsKey("addedServices")); + } + + @Test + public void airAddedServicesSplitIntoList() { + Map seg = new HashMap<>(); + seg.put("destinationCode", "KUL"); + seg.put("cargoName", "茶叶"); + seg.put("quantity", "10"); + seg.put("weightKg", "100"); + seg.put("volumeCbm", "2"); + seg.put("packageType", "纸箱"); + seg.put("added_services", "托盘、前置仓,带电"); + Map internal = new HashMap<>(); + internal.put("transportMode", "AIR"); + internal.put("quoteDate", "2026-10-13"); + internal.put("segments", Collections.singletonList(seg)); + Map body = TmsV2RequestMapper.quote(internal); + @SuppressWarnings("unchecked") + List> segs = (List>) body.get("segments"); + assertEquals(java.util.Arrays.asList("托盘", "前置仓", "带电"), segs.get(0).get("addedServices")); + assertFalse(segs.get(0).containsKey("added_services")); + assertEquals( + java.util.Arrays.asList("MUST GO", "带电"), + TmsV2RequestMapper.splitAddedServices("MUST GO、带电")); } @Test diff --git a/inquiry-backend/src/views/inquiry/workOrder/demandFields.ts b/inquiry-backend/src/views/inquiry/workOrder/demandFields.ts index 3839d32..5795808 100644 --- a/inquiry-backend/src/views/inquiry/workOrder/demandFields.ts +++ b/inquiry-backend/src/views/inquiry/workOrder/demandFields.ts @@ -177,6 +177,11 @@ export function buildDemandFields(ticket: any): DemandField[] { push(fields, "重量(KG)", pick(facts, "毛重", "重量") || columnText(ticket?.weightKg)); push(fields, "体积(CBM)", pick(facts, "体积") || columnText(ticket?.volumeCbm)); push(fields, "包装类型", pick(facts, "包装方式", "包装类型")); + const added = pick(facts, "added_services", "增值服务"); + if (added) { + // 没填不占一行,和空运群、建单确认一致。 + push(fields, "增值服务", added); + } push(fields, "报价日期", quoteDate); } else { // 多段或未识别:至少把路线和货量列出来,避免整块空白。 diff --git a/inquiry-backend/tests/demandFields.spec.ts b/inquiry-backend/tests/demandFields.spec.ts index 3016625..98b1f20 100644 --- a/inquiry-backend/tests/demandFields.spec.ts +++ b/inquiry-backend/tests/demandFields.spec.ts @@ -21,10 +21,44 @@ test("land FTL shows vehicle not pieces", () => { const map = Object.fromEntries(rows.map((r) => [r.label, r.value])); expect(map["运输方式"]).toBe("陆运"); expect(map["运输类型"]).toBe("国内运输整车"); - expect(map["车型/数量"]).toBe("53尺(16.5厢车)"); - expect(map["始发站"]).toBe("上海"); - expect(map["货物品名"]).toBe("茶叶"); - expect(map["件数"]).toBeUndefined(); + expect(map["车型/数量"]).toBe("53尺(16.5厢车)"); + expect(map["始发站"]).toBe("上海"); + expect(map["货物品名"]).toBe("茶叶"); + expect(map["件数"]).toBeUndefined(); + expect(map["增值服务"]).toBeUndefined(); +}); + +test("air shows added services only when filled", () => { + const filled = buildDemandFields({ + businessLine: "AIR", + bizTypeLabel: "空运", + factsJson: JSON.stringify({ + 起运港: "深圳", + 目的港: "吉隆坡", + 品名: "茶叶", + 件数: "10", + 毛重: "100", + 体积: "2", + 包装方式: "纸箱", + 报价日期: "2026-10-13", + added_services: "托盘、前置仓、带电", + }), + }); + const map = Object.fromEntries(filled.map((r) => [r.label, r.value])); + expect(map["增值服务"]).toBe("托盘、前置仓、带电"); + expect(map["包装类型"]).toBe("纸箱"); + + const empty = buildDemandFields({ + businessLine: "AIR", + bizTypeLabel: "空运", + factsJson: JSON.stringify({ + 目的港: "吉隆坡", + 品名: "茶叶", + 包装方式: "纸箱", + }), + }); + const emptyMap = Object.fromEntries(empty.map((r) => [r.label, r.value])); + expect(emptyMap["增值服务"]).toBeUndefined(); }); test("sea shows container spec", () => { diff --git a/prompt/04-智能体与模型配置.md b/prompt/04-智能体与模型配置.md index e711cb9..37bcda2 100644 --- a/prompt/04-智能体与模型配置.md +++ b/prompt/04-智能体与模型配置.md @@ -40,9 +40,10 @@ - **海运**:起运港、目的港、品名、货量、整柜或拼柜、箱型箱量、报价日期 箱型箱量是一个原始业务字段;结构化 `containerItems` 只由 DeepSeek B 投影。 -- **空运**:起运地、目的地、品名、件数、重量、体积、包装类型、报价日期 +- **空运**:起运地、目的地、品名、件数、重量、体积、包装类型。报价日期非必填 +- **空运非必填增值服务**:销售看到的名称是「增值服务」,工单字段 `added_services`,查价字段 `addedServices`。没填不挡查价、不传给 TMS。填了按多项原词传递;有报价就回报价,没有就回暂无报价。12 个词(托盘、前置仓、带电、卡转、卡转低消、MUST GO、备装、分泡、温控、快件、DG、TC)只要完整出现就记;名单外的必须销售标明增值服务才记。只写在包装类型里的「托盘」不算增值服务。增值服务这一行冒号后是空的,不能把下一行收进来。 - **陆运**按二级类型:国内拼车/整车、跨境集拼/整车、中港(见合同 JSON),各类型均含报价日期 -- **报价日期**:三线查价都要,用来查 TMS 当天/指定日的价;销售没说则系统默认为当天(上海时区),不补问;销售说了用原话,不得用当天盖掉。 +- **报价日期**:海运、陆运查价都要,用来查 TMS 当天/指定日的价;销售没说则系统默认为当天(上海时区),不补问;销售说了用原话,不得用当天盖掉。空运非必填:说了就按这一天直接查价;没说不默认当天、不补问,确认内容写成「报价日期(非必填):」。确认后不带日期查一次价,把 TMS 返回的全部舱位发给销售点选(哪怕只有 1 条)。舱位展示起运地、目的地、航班日期、班期(TMS 原样,如 D234567)、航司、总价。选定后用该条总价出报价,不再查第二次;该条航班日期写回工单,作为报价日期。 协同阶段另有协同必填(贸易条款、HS、含电含磁等),点「协同」必须用 **不可变销售原话** 重跑模型 B,不得用卡片口令覆盖。 diff --git a/prompt/06-Python智能体.md b/prompt/06-Python智能体.md index c50793b..4b94a72 100644 --- a/prompt/06-Python智能体.md +++ b/prompt/06-Python智能体.md @@ -108,7 +108,7 @@ NormalizedInbound ## 出站 - 主账:登录或 Token,所有写操作要幂等键(message_id / request_id / quoteVersion)。 -- 企微:只通过 outbox;失败重试;过期出站丢弃不计业务成功。询价与正式同一 Agent `1000010`;运维 `1000014`,测试**告警**正文加 `[test]`,销售询价确认不加 `[test]`、不加「询价卡片」标题。建单确认按空运询价必填 8 项:起运地、目的地、品名、件数、重量、体积、包装类型、报价日期(另加工程单号、运输方式),末行「工单已创建,正在查询TMS报价。」销售侧标签不得写成毛重、重量(KG)、体积(CBM)。缺项补问先回显已识别,再列待补充并给示例(重量如 100KGS、体积如 2CBM、包装如托盘),待补充行不加圆点;报价日期销售说了写入已识别,没说默认当天,不进待补充。起运地/目的地说了地名即采集完成,不得因对不上三字码再补问。每次出询价确认都新开一单再查 TMS,相同需求不复用旧工单号。包装等字段销售说什么就记什么,禁止白名单把散货等原文挡回去再补问。 +- 企微:只通过 outbox;失败重试;过期出站丢弃不计业务成功。询价与正式同一 Agent `1000010`;运维 `1000014`,测试**告警**正文加 `[test]`,销售询价确认不加 `[test]`、不加「询价卡片」标题。建单确认按空运询价必填项:起运地、目的地、品名、件数、重量、体积、包装类型(报价日期非必填;另加工程单号、运输方式),末行「工单已创建,正在查询TMS报价。」销售侧标签不得写成毛重、重量(KG)、体积(CBM)。缺项补问先回显已识别,再列待补充并给示例(重量如 100KGS、体积如 2CBM、包装如托盘),待补充行不加圆点;空运报价日期销售说了写入已识别并写成「报价日期:日期」;没说写成「报价日期(非必填):」,不默认当天,不进待补充。海运、陆运报价日期没说仍默认当天。起运地/目的地说了地名即采集完成,不得因对不上三字码再补问。每次出询价确认都新开一单再查 TMS,相同需求不复用旧工单号。包装等字段销售说什么就记什么,禁止白名单把散货等原文挡回去再补问。 - 对象存储:S3 兼容;生成文件后登记主账附件再发企微。 ## 测试 diff --git a/prompt/10-TMS对接.md b/prompt/10-TMS对接.md index fcd9671..2119085 100644 --- a/prompt/10-TMS对接.md +++ b/prompt/10-TMS对接.md @@ -15,11 +15,13 @@ 主账按 TMS V2 `/quote/v2/query` 把空运查价参数带全再出站。智能体必须参数齐全才建单、才出询价卡片。 -空运出站必带:`requestId`(UUID)、`workOrderNo`(WOYYYYMMDDNNNN)、`transportMode=AIR`、`quoteDate`(yyyy-MM-dd)、`segments[0]` 的 origin/originCode/destination/destinationCode/cargoName、`quantity`(整数)、`weightKg`/`volumeCbm`(大于 0 的数字)、`packageType`。缺一项:补问,不出卡片,不出站。 +空运出站必带:`requestId`(UUID)、`workOrderNo`(WOYYYYMMDDNNNN)、`transportMode=AIR`、`segments[0]` 的 origin/originCode/destination/destinationCode/cargoName、`quantity`(整数)、`weightKg`/`volumeCbm`(大于 0 的数字)、`packageType`。`quoteDate`(yyyy-MM-dd)仅在销售说了报价日期时带上。缺必带项:补问,不出卡片,不出站。 -- 销售未说报价日期则填当天,不补问该字段 +- 海运、陆运:销售未说报价日期则填当天,不补问该字段 +- 空运:销售未说报价日期则不传 `quoteDate`,不填当天。TMS 一次返回各航班日期舱位后由销售点选,选定舱位的航班日期再写回工单报价日期 - 口语单位在出站前去掉;机场名能对应三字码则带上,销售已写三字码则原样带 - 空运 `packageType` 必须是 TMS 字典码:1=全部(现网航线常无价)、2=托盘/散货/卡板、3=纸箱/木箱。销售卡片仍写原文;禁止把托盘编成 1 +- 空运增值服务:工单字段 `added_services`。有内容才在 `segments[0].addedServices` 传多项原词(顿号、中英文逗号都拆开,空格不拆)。没填不传该字段,不挡查价。TMS 有报价就回报价,没有就回暂无报价。 ## 响应