Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92bdf56a4d | ||
|
|
7ef4a1b1cf |
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -968,24 +968,12 @@ def apply_air_lock(
|
||||
)
|
||||
send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_params(labels))
|
||||
return "wait_lock_params"
|
||||
# 附件/手工报价、运价库无舱位:引导纯文字报价,不算系统异常、不通知 IT。
|
||||
# 仅「航线附件报价」引导纯文字价。有 TMS / 文字手工价失败不能误进这条
|
||||
# (旧逻辑用「无匹配报价」关键字,会把 TMS 锁舱失败也拐进文字报价提示)。
|
||||
src_label = str((quote or {}).get("source_label") or (quote or {}).get("source") or "")
|
||||
is_attach_quote = "航线附件报价" in src_label
|
||||
need_text_quote = (
|
||||
err_code in {"lock_incomplete", "lock_fail"}
|
||||
and (
|
||||
"航线附件报价" in src_label
|
||||
or "附件" in src_label
|
||||
or any(
|
||||
x in reason_text
|
||||
for x in (
|
||||
"手工报价",
|
||||
"无匹配舱位",
|
||||
"无匹配报价",
|
||||
"运价库无匹配",
|
||||
"标准运价库无匹配",
|
||||
)
|
||||
)
|
||||
)
|
||||
is_attach_quote and err_code in {"lock_incomplete", "lock_fail"}
|
||||
)
|
||||
if need_text_quote:
|
||||
send_group_text(
|
||||
@@ -1555,6 +1543,8 @@ def _resolve_lock_params(
|
||||
拼出锁舱三项。
|
||||
|
||||
这一句里写的优先;没写的用上一句暂存的;再没有才用报价上已有的舱位。
|
||||
TMS 已有舱位明细时(selected_air_option / airOptions):句中未点名也自动带出,
|
||||
禁止再追问舱位编号/航司/航班号(WO014:航线确认TMS 后直接「锁舱」仍被补问)。
|
||||
航司写成南航、东方航空这类名字时归一成二字码;归一不了也算已经补充,不再追问。
|
||||
"""
|
||||
said_opt, said_air, said_flight = lock_params_from_text(instruction)
|
||||
@@ -1562,16 +1552,18 @@ def _resolve_lock_params(
|
||||
air = _normalize_airline(airline or said_air or str(facts.get(_PENDING_LOCK_AIRLINE) or ""))
|
||||
flight = _normalize_flight(flight_no or said_flight or str(facts.get(_PENDING_LOCK_FLIGHT) or ""))
|
||||
chosen = _option_row(quote, option)
|
||||
single_auto = False
|
||||
if chosen is None and not option:
|
||||
options = _air_options(quote)
|
||||
if len(options) == 1:
|
||||
chosen = options[0]
|
||||
chosen = _default_cabin_row(quote)
|
||||
if chosen is not None:
|
||||
option = str(chosen.get("optionNo") or chosen.get("option_no") or "").strip()
|
||||
single_auto = True
|
||||
if not _airline_ready(air) and chosen is not None:
|
||||
air = _normalize_airline(
|
||||
str(chosen.get("airlineCode") or chosen.get("airline") or "")
|
||||
str(
|
||||
chosen.get("airlineCode")
|
||||
or chosen.get("airline")
|
||||
or chosen.get("airlineName")
|
||||
or ""
|
||||
)
|
||||
)
|
||||
if not flight and chosen is not None:
|
||||
flight = _normalize_flight(str(chosen.get("flightNo") or chosen.get("flight_no") or ""))
|
||||
@@ -1584,6 +1576,51 @@ def _resolve_lock_params(
|
||||
return option, air, flight
|
||||
|
||||
|
||||
def _selected_cabin_row(quote: dict[str, Any]) -> Optional[dict[str, Any]]:
|
||||
"""报价上已选定的舱位(JSON 字符串或 dict)。"""
|
||||
selected = (quote or {}).get("selected_air_option") or (quote or {}).get("selectedAirOption")
|
||||
if isinstance(selected, str) and selected.strip().startswith("{"):
|
||||
try:
|
||||
selected = json.loads(selected)
|
||||
except json.JSONDecodeError:
|
||||
selected = None
|
||||
if isinstance(selected, dict) and (
|
||||
selected.get("optionNo") or selected.get("option_no") or selected.get("flightNo")
|
||||
):
|
||||
return selected
|
||||
return None
|
||||
|
||||
|
||||
def _default_cabin_row(quote: dict[str, Any]) -> Optional[dict[str, Any]]:
|
||||
"""
|
||||
句中未点名舱位时的默认行。
|
||||
|
||||
优先 selected_air_option;否则单条 airOptions;TMS 多条时取第一条,
|
||||
避免「已有 TMS 舱位仍追问三项」(WO014)。
|
||||
"""
|
||||
selected = _selected_cabin_row(quote)
|
||||
if selected is not None:
|
||||
return selected
|
||||
options = _air_options(quote)
|
||||
if len(options) == 1:
|
||||
return options[0]
|
||||
if len(options) > 1 and _quote_has_tms_cabin(quote):
|
||||
return options[0]
|
||||
return None
|
||||
|
||||
|
||||
def _quote_has_tms_cabin(quote: dict[str, Any]) -> bool:
|
||||
"""本单报价里已有可引用的 TMS 舱位明细。"""
|
||||
src = dict(quote or {})
|
||||
qid = str(src.get("quoteId") or src.get("quote_id") or "").strip()
|
||||
if qid and not qid.upper().startswith("QT-"):
|
||||
return bool(_air_options(src) or _selected_cabin_row(src))
|
||||
label = str(src.get("source_label") or src.get("source") or "").upper()
|
||||
if "TMS" in label:
|
||||
return bool(_air_options(src) or _selected_cabin_row(src))
|
||||
return False
|
||||
|
||||
|
||||
def _option_row(quote: dict[str, Any], option_no: str) -> Optional[dict[str, Any]]:
|
||||
"""按舱位编号找报价里的那一条;对不上返回空。"""
|
||||
want = (option_no or "").strip().upper()
|
||||
@@ -1593,6 +1630,11 @@ def _option_row(quote: dict[str, Any], option_no: str) -> Optional[dict[str, Any
|
||||
no = str(row.get("optionNo") or row.get("option_no") or "").strip().upper()
|
||||
if no == want:
|
||||
return row
|
||||
selected = _selected_cabin_row(quote)
|
||||
if selected is not None:
|
||||
no = str(selected.get("optionNo") or selected.get("option_no") or "").strip().upper()
|
||||
if no == want:
|
||||
return selected
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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(
|
||||
@@ -3321,24 +3347,11 @@ def air_lock_incomplete(*, work_order_no: str, operator: str, reason: str = "")
|
||||
"""
|
||||
锁舱参数不齐(纯手工价、运价库无舱位等):业务补问,不算系统异常,不惊动 IT。
|
||||
|
||||
附件/无舱位场景改引导纯文字报价;有 TMS 舱位时仍可补 AIR-OPT 三项。
|
||||
有 TMS / 非附件报价:仍补舱位三项。附件报价的「请发纯文字」只由
|
||||
air_group_ops 在 source_label=航线附件报价 时走 air_lock_need_text_quote,
|
||||
禁止按「无匹配报价」关键字在这里拐过去(会误伤 TMS 锁舱)。
|
||||
"""
|
||||
why = (reason or "").strip()
|
||||
blob = why
|
||||
if any(
|
||||
x in blob
|
||||
for x in (
|
||||
"手工报价",
|
||||
"附件",
|
||||
"无匹配舱位",
|
||||
"无匹配报价",
|
||||
"运价库无匹配",
|
||||
"标准运价库无匹配",
|
||||
)
|
||||
):
|
||||
return air_lock_need_text_quote(
|
||||
work_order_no=work_order_no, operator=operator, reason=why
|
||||
)
|
||||
lines = [
|
||||
f"工单{work_order_no} 还不能锁舱。",
|
||||
f"操作人:{operator}",
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -1110,8 +1110,57 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
self.assertEqual(phase, "lock_ok")
|
||||
self.assertTrue(any("执行成功" in t for t in self._texts()))
|
||||
|
||||
def test_tms_quote_with_selected_cabin_locks_without_asking_params(self) -> None:
|
||||
"""TMS 已有 selected_air_option:直接锁,不追问舱位/航司/航班(WO014)。"""
|
||||
wo = self._create_air(
|
||||
quote={
|
||||
"total": "183370",
|
||||
"source": "TMS",
|
||||
"source_label": "航线确认TMS",
|
||||
"quoteId": "AIR-2094721692181016578-2094721692701110273",
|
||||
"selected_air_option": {
|
||||
"optionNo": "AIR-OPT01",
|
||||
"airlineCode": "JG",
|
||||
"flightNo": "JG2719",
|
||||
},
|
||||
"airOptions": [
|
||||
{
|
||||
"optionNo": "AIR-OPT01",
|
||||
"airlineCode": "JG",
|
||||
"flightNo": "JG2719",
|
||||
},
|
||||
{
|
||||
"optionNo": "AIR-OPT02",
|
||||
"airlineCode": "RW",
|
||||
"flightNo": "RW633",
|
||||
},
|
||||
],
|
||||
},
|
||||
status="已报价",
|
||||
)
|
||||
calls: list[dict] = []
|
||||
orig = self.ledger.lock_cabin
|
||||
|
||||
def spy(**kwargs):
|
||||
calls.append(kwargs)
|
||||
return orig(**kwargs)
|
||||
|
||||
self.ledger.lock_cabin = spy # type: ignore
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_tms_auto", mid="tmsauto1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="air_lock",
|
||||
)
|
||||
self.assertEqual(phase, "lock_ok")
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertEqual(calls[0].get("option_no"), "AIR-OPT01")
|
||||
self.assertEqual(calls[0].get("airline"), "JG")
|
||||
self.assertEqual(calls[0].get("flight_no"), "JG2719")
|
||||
self.assertFalse(any("锁舱参数不完整" in t for t in self._texts()))
|
||||
|
||||
def test_lock_missing_params_asks_and_does_not_call_tms(self) -> None:
|
||||
"""缺舱位、航司、航班号时只请补,不锁舱、不报 TMS 失败、不通知 IT。"""
|
||||
"""无舱位明细时缺三项才请补,不锁舱、不报 TMS 失败、不通知 IT。"""
|
||||
wo = self._create_air(
|
||||
quote={
|
||||
"total": "CNY 1800",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
+33
-9
@@ -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<String> 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<String> 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<String> missingAirQuery(String workOrderNo, String quoteDate, Map<String, Object> segment) {
|
||||
List<String> 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"
|
||||
}) {
|
||||
|
||||
+40
@@ -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<String> 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"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 空运增值服务拆成多项。
|
||||
* <p>
|
||||
* 工单里是 added_services 一句话,TMS 要 addedServices 列表。
|
||||
* 顿号、中文逗号、英文逗号都拆;空格不拆,MUST GO 仍是一项。
|
||||
* 已经是列表则逐项原样保留。空项丢掉,不改词。
|
||||
*/
|
||||
public static List<String> splitAddedServices(Object raw) {
|
||||
List<String> 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<String> 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();
|
||||
}
|
||||
|
||||
+23
@@ -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<String, Object> segment = new java.util.HashMap<String, Object>();
|
||||
segment.put("cargoName", "茶叶");
|
||||
segment.put("quantity", 1);
|
||||
segment.put("weightKg", "1.5");
|
||||
segment.put("volumeCbm", "100");
|
||||
segment.put("packageType", "托盘");
|
||||
java.util.List<String> 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", ""));
|
||||
}
|
||||
}
|
||||
|
||||
+25
@@ -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<String, Object> 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<String, Object> internal = new HashMap<>();
|
||||
internal.put("transportMode", "AIR");
|
||||
internal.put("quoteDate", "2026-10-13");
|
||||
internal.put("segments", Collections.singletonList(seg));
|
||||
Map<String, Object> body = TmsV2RequestMapper.quote(internal);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> segs = (List<Map<String, Object>>) 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
|
||||
|
||||
@@ -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 {
|
||||
// 多段或未识别:至少把路线和货量列出来,避免整块空白。
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,不得用卡片口令覆盖。
|
||||
|
||||
|
||||
@@ -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 兼容;生成文件后登记主账附件再发企微。
|
||||
|
||||
## 测试
|
||||
|
||||
+4
-2
@@ -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 有报价就回报价,没有就回暂无报价。
|
||||
|
||||
## 响应
|
||||
|
||||
|
||||
Reference in New Issue
Block a user