空运未填报价日期时不默认当天,查出舱位后由销售点选再写回日期。

包装类型里的托盘不再被抄进增值服务。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-13 18:35:52 +08:00
co-authored by Cursor
parent dda210c219
commit 7ef4a1b1cf
21 changed files with 1068 additions and 58 deletions
@@ -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 = (
"<div class='wrap'>"
"<header class='hero'>"
f"<p class='wo'>{wo}</p>"
"<h1>选择舱位</h1>"
f"<p class='lead'>共 {count} 条舱位,点选一条后确定。价格是 TMS 返回的总价。</p>"
"</header>"
"<form method='post'>"
+ "".join(rows)
+ "<div class='bar'><div class='bar-inner'>"
"<button type='submit'>选定这条</button>"
"</div></div></form></div>"
"<script>(function(){var f=document.querySelector('form');if(!f)return;"
"function sync(){f.querySelectorAll('.opt').forEach(function(el){"
"el.classList.toggle('is-on',!!(el.querySelector('input')||{}).checked);});}"
"f.addEventListener('change',sync);})();</script>"
)
return _wrap("选择舱位", body)
def _cabin_field(label: str, value: str) -> str:
"""舱位一项。空值留空,不写横杠,避免把没返回的班期显示成占位符。"""
shown = html.escape(value or "")
return (
"<li><span class='n'>"
+ html.escape(label)
+ "</span><span class='a'>"
+ shown
+ "</span></li>"
)
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 = "<ul class='fees'>" + "".join(_cabin_field(n, v) for n, v in fields) + "</ul>"
total = html.escape(option_total(src, quote))
return (
"<label class='opt'>"
f"<input type='radio' name='index' value='{index}' required />"
"<div class='head'><span class='tick' aria-hidden='true'></span>"
"<div class='route'><strong>舱位</strong></div></div>"
f"{field_html}"
f"<p class='total'><span>总价</span><b>{total}</b></p>"
"</label>"
)
def _option_card(index: int, option: dict[str, Any], quote: dict[str, Any], facts: dict[str, str]) -> str:
"""一张线路卡:航线、TMS 费用、总价。没有费用行就留空说明,不编造。"""
src = dict(option or {})
@@ -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,
)
+18 -1
View File
@@ -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:
+49 -6
View File
@@ -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"
+46 -20
View File
@@ -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(
@@ -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)(?<![A-Za-z])DG(?![A-Za-z])")
_TC_RE = re.compile(r"(?i)(?<![A-Za-z])TC(?![A-Za-z])")
# 冒号后只吃同一行。\s 会跨行,空着的增值服务会把下一行「报价日期(非必填):」吞进来。
_LABEL_RE = re.compile(
r"增值服务(?:(非必填)|\(非必填\))?[^\S\n]*[::][^\S\n]*([^\n]*)"
)
# 包装类型/包装方式这一栏里的托盘只算包装。确认卡上增值服务留空时不能再抄一份过去。
_PACKAGE_PALLET_RE = re.compile(
r"包装(?:类型|方式)?(?:((?:非)?必填)|\((?:非)?必填\))?[^\S\n]*[::][^\S\n]*托盘"
)
_SPLIT_RE = re.compile(r"[、,,]")
_NEG_PREFIX = frozenset("不无非")
_FILLER = re.compile(r"[、,,\s和及与::]+")
def split_added_services(raw: str) -> 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
+122 -1
View File
@@ -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:
+18 -5
View File
@@ -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
+1 -3
View File
@@ -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)