落地空运单聊询价字段与线路选择。

必填核对后再建单;多条线路走 H5 点选;费用只展示该线路 TMS 回包,不套用整单空运费。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-22 15:02:32 +08:00
co-authored by Cursor
parent 8afe909230
commit cfbce2fd69
53 changed files with 3470 additions and 372 deletions
@@ -56,6 +56,9 @@ def normalize_aibot_payload(body: dict[str, Any]) -> AibotInboundBody:
raw.setdefault("source", "aibot")
if data.get("response_url"):
raw.setdefault("response_url", data.get("response_url"))
req_id = str(data.get("aibot_req_id") or raw.get("aibot_req_id") or "").strip()
if req_id:
raw["aibot_req_id"] = req_id
return AibotInboundBody(
sender_id=sender,
message_id=mid,
+24 -39
View File
@@ -1,15 +1,15 @@
"""
空运群 BOT 回话。
本文件职责:有即时 `response_url` 就流式回;没有(发文件走存档)就打本机桥 `/send-group`,
由长连接 `aibot_send_msg` 主动往群里说。
禁止:走海运 appchat/send;在回调线程等 LLM。
本文件职责:打本机桥 `/send`(骨架 `/send-group`)。
有这次 @ 的 `aibot_req_id` 时,桥走 `aibot_respond_msg`,群里才会引用原消息;
没有 req_id(典型:会话存档听到附件)才走 `aibot_send_msg` 主动发言。
禁止:走海运 appchat/send;用企微 stream 回复(会带「内容由AI生成」和赞踩);在回调线程等 LLM。
"""
from __future__ import annotations
import logging
import uuid
from typing import Any
import httpx
@@ -23,13 +23,14 @@ class AibotReplyClient:
"""
空运群出站客户端。
@ 带了 response_url:流式只收口这次 @;正文走桥主动发言,和发模板同一条通道。
存档听到附件时没有 url:直接走桥。该群须先被 @ 过,企微才允许主动说。
空运群出站只走桥。带上这次 @ 的 req_id,桥才能引用原消息。
禁止 POST 企微 stream(群里会显示「内容由AI生成」和赞踩)。
"""
def __init__(self, response_url: str = "", timeout_sec: float = 8.0) -> None:
def __init__(self, response_url: str = "", timeout_sec: float = 8.0, callback_req_id: str = "") -> None:
self._url = (response_url or "").strip()
self._timeout = timeout_sec
self._callback_req_id = str(callback_req_id or "").strip()
self.sent: list[dict[str, Any]] = []
def send_group(
@@ -45,41 +46,15 @@ class AibotReplyClient:
if not text:
return {"ok": False, "error": "empty"}
room = str(chat_id or "").strip()
# @ 当场流式回话群里经常看不见长摘要,模板却走主动发言,就会只剩一张表。
# 工单信息必须再走桥,和模板同一条通道;流式只收口这次 @。
if self._url:
self._send_stream(room, text.split("\n", 1)[0][:80])
# 企微 stream 会自带「内容由AI生成」和赞踩,空运群业务话术不要这条通道。
# 工单正文只走桥主动发言(markdown),和发模板同一条通道。
if room:
out = self._send_proactive(room, text)
if out.get("ok") or not self._url:
return out
if self._url:
return self._send_stream(room, text)
return self._send_proactive(room, text)
return {"ok": False, "error": "no_chat_id"}
def _send_stream(self, chat_id: str, text: str) -> dict[str, Any]:
"""@ 当场回话:POST 企微给的 response_url。"""
body = {
"msgtype": "stream",
"stream": {
"id": uuid.uuid4().hex,
"finish": True,
"content": text,
},
}
try:
resp = httpx.post(self._url, json=body, timeout=self._timeout)
ok = 200 <= resp.status_code < 300
if not ok:
logger.warning("aibot 回话 HTTP=%s chat=%s", resp.status_code, chat_id)
return {"ok": ok, "status": resp.status_code}
except Exception:
logger.exception("aibot 回话网络失败 chat=%s", chat_id)
return {"ok": False, "error": "network"}
def _send_proactive(self, chat_id: str, text: str) -> dict[str, Any]:
"""
没有 response_url(典型:会话存档听到附件)时,让桥主动往群里说。
把正文交给桥。有 callback_req_id 时桥引用这次 @;没有则主动发言。
副作用:回环 HTTP 打 8913,不直连企微。桥未连上或该群从未 @ 过,企微不会投递。
"""
@@ -100,14 +75,24 @@ class AibotReplyClient:
# 现网官方桥是 POST /send;本仓库骨架桥是 /send-group。两个都试,先打官方口径。
resp = httpx.post(
f"{base}/send",
json={"chatid": chat_id, "operation": "text", "content": text},
json={
"chatid": chat_id,
"operation": "text",
"content": text,
"aibot_req_id": self._callback_req_id,
},
headers=headers,
timeout=self._timeout,
)
if resp.status_code == 404:
resp = httpx.post(
f"{base}/send-group",
json={"chat_id": chat_id, "content": text, "chat_type": 2},
json={
"chat_id": chat_id,
"content": text,
"chat_type": 2,
"aibot_req_id": self._callback_req_id,
},
headers=headers,
timeout=self._timeout,
)
+47 -1
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
import logging
from typing import Any, Optional
from fastapi import APIRouter, File, HTTPException, Query, UploadFile
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile
from fastapi.responses import HTMLResponse
from pydantic import BaseModel, Field
@@ -112,6 +112,52 @@ async def h5_upload_submit(
return HTMLResponse(render_upload_message("上传成功", copy.ATTACH_H5_OK))
@router.get("/air-routes/{token}", response_class=HTMLResponse)
def h5_air_route_open(token: str) -> HTMLResponse:
"""打开空运选线路页。身份只认 token。"""
from agent.channel.h5.air_route_page import expired_air_route_page, render_air_route_page
from agent.channel.h5.air_route_store import lookup_air_route_token
rec = lookup_air_route_token(token)
if rec is None:
return HTMLResponse(expired_air_route_page(), status_code=410)
return HTMLResponse(render_air_route_page(rec))
@router.post("/air-routes/{token}", response_class=HTMLResponse)
def h5_air_route_pick(token: str, index: int = Form(default=-1)) -> HTMLResponse:
"""
选定线路。请求线程只验 token、入出站队列,不查 TMS、不跑整图。
"""
from agent.channel.h5.air_route_page import (
expired_air_route_page,
render_air_route_ok,
render_air_route_page,
)
from agent.channel.h5.air_route_store import lookup_air_route_token
from agent.channel.queue import get_message_store
from agent.policy.air_text_flow import get_air_text_flow
rec = lookup_air_route_token(token)
if rec is None:
return HTMLResponse(expired_air_route_page(), status_code=410)
sender_id = str(rec.get("sender_id") or "").strip()
if not sender_id:
return HTMLResponse(expired_air_route_page(), status_code=410)
def reply(text: str, extra=None) -> None:
store = get_message_store()
store.enqueue_outbound(touser=sender_id, content=text, payload=dict(extra or {}))
phase = get_air_text_flow().select_air_route(
sender_id=sender_id, index=int(index), reply=reply
)
logger.info("h5.air_route pick sender=%s phase=%s idx=%s", sender_id, phase, index)
if phase in {"route_expired", "route_invalid"}:
return HTMLResponse(render_air_route_page(rec), status_code=400)
return HTMLResponse(render_air_route_ok())
@router.get("/{token}")
def h5_open(token: str) -> dict[str, Any]:
"""
@@ -0,0 +1,282 @@
"""
空运选线路 H5 页。
本文件职责:列出 TMS 线路(航线+费用+总价),提交所选下标。
样式对齐附件询价页:企微内嵌、手机竖屏、卡片点选。
禁止:从 URL 读 userid;禁止本页查 TMS;禁止编造费用行。
"""
from __future__ import annotations
import html
from typing import Any
from agent.schema.air_route_options import (
option_fee_lines,
option_total,
parse_air_options,
route_codes,
route_line,
)
# 与 upload_page 同一套企微蓝、浅底、白卡片。禁止在页面写 userid。
_PAGE_CSS = """
:root {
--bg: #eef3f9;
--card: #ffffff;
--text: #17233d;
--muted: #5b6b7c;
--line: #c5d4ea;
--brand: #1677ff;
--brand-press: #0f62d9;
--ok: #0f8a5f;
--shadow: 0 10px 28px rgba(23, 51, 91, 0.10);
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
}
.wrap {
max-width: 480px;
margin: 0 auto;
padding: 16px 16px calc(96px + env(safe-area-inset-bottom));
}
.hero { margin: 4px 0 16px; }
.wo {
display: inline-block;
font-size: 12px;
color: #2b5aa8;
background: #eef4ff;
border-radius: 999px;
padding: 4px 10px;
margin: 0 0 8px;
word-break: break-all;
}
h1 { font-size: 22px; line-height: 1.3; margin: 0 0 6px; font-weight: 600; }
.lead { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.6; }
.opt {
display: block;
position: relative;
background: var(--card);
border: 1.5px solid transparent;
border-radius: 16px;
padding: 16px 16px 14px;
margin: 0 0 12px;
box-shadow: var(--shadow);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.opt:has(input:checked), .opt.is-on {
border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.16), var(--shadow);
}
.opt input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
pointer-events: none;
}
.head {
display: flex;
align-items: flex-start;
gap: 12px;
}
.tick {
width: 22px;
height: 22px;
margin-top: 2px;
border: 2px solid var(--line);
border-radius: 50%;
flex-shrink: 0;
background: #fff;
}
.opt:has(input:checked) .tick, .opt.is-on .tick {
border-color: var(--brand);
background: var(--brand);
box-shadow: inset 0 0 0 4px #fff;
}
.route { flex: 1; min-width: 0; }
.route strong {
display: block;
font-size: 17px;
line-height: 1.4;
font-weight: 650;
}
.codes { display: block; margin-top: 2px; color: var(--muted); font-size: 12px; letter-spacing: 0.04em; }
.meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.pill {
font-size: 12px;
color: #2b5aa8;
background: #eef4ff;
border-radius: 999px;
padding: 3px 8px;
}
.pill.via { color: #8a5a00; background: #fff6e0; }
.fees { list-style: none; margin: 12px 0 0; padding: 0; }
.fees li {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 8px 0;
border-top: 1px solid #eef2f6;
font-size: 14px;
line-height: 1.5;
}
.fees .n { color: var(--muted); }
.fees .a { font-variant-numeric: tabular-nums; text-align: right; }
.empty {
margin: 12px 0 0;
padding-top: 10px;
border-top: 1px solid #eef2f6;
color: var(--muted);
font-size: 13px;
}
.total {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
margin: 12px 0 0;
padding-top: 10px;
border-top: 1px dashed var(--line);
font-size: 15px;
}
.total b { color: var(--brand); font-size: 18px; font-variant-numeric: tabular-nums; }
.bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
background: rgba(255,255,255,0.96);
box-shadow: 0 -8px 24px rgba(23, 51, 91, 0.08);
}
.bar-inner { max-width: 480px; margin: 0 auto; }
button[type="submit"] {
width: 100%;
min-height: 48px;
border: 0;
border-radius: 10px;
background: var(--brand);
color: #fff;
font-size: 16px;
font-weight: 700;
cursor: pointer;
touch-action: manipulation;
}
button[type="submit"]:active { background: var(--brand-press); }
.msg {
max-width: 480px;
margin: 48px auto;
padding: 0 16px;
text-align: center;
}
.msg h1 { font-size: 20px; margin: 0 0 8px; }
.msg p { margin: 0; color: var(--muted); font-size: 15px; line-height: 1.6; }
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; }
}
"""
def expired_air_route_page() -> str:
"""token 过期页。"""
return _wrap(
"已过期",
"<div class='msg'><h1>链接已过期</h1><p>请回企微重新打开选线路卡片。</p></div>",
)
def render_air_route_page(payload: dict[str, Any]) -> str:
"""
渲染选线路页。payload 来自 token:quote / facts / work_order_no。
"""
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: list[str] = []
for i, option in enumerate(options):
rows.append(_option_card(i, option, quote, facts))
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 render_air_route_ok() -> str:
"""选定成功。"""
return _wrap(
"已选定",
"<div class='msg'><h1>已选定</h1><p>请回企微查看价格明细。</p></div>",
)
def _option_card(index: int, option: dict[str, Any], quote: dict[str, Any], facts: dict[str, str]) -> str:
"""一张线路卡:航线、TMS 费用、总价。没有费用行就留空说明,不编造。"""
src = dict(option or {})
line = html.escape(route_line(src, facts))
codes = html.escape(route_codes(src, facts, quote))
fees = option_fee_lines(src, quote)
if fees:
fee_html = (
"<ul class='fees'>"
+ "".join(
f"<li><span class='n'>{html.escape(name)}</span>"
f"<span class='a'>{html.escape(amount)}</span></li>"
for name, amount in fees
)
+ "</ul>"
)
else:
fee_html = "<p class='empty'>本线路未返回费用明细</p>"
total = html.escape(option_total(src, quote))
transfer = str(src.get("transferAirportCode") or src.get("transfer") or "").strip()
tag = "中转" if transfer else "直达"
tag_cls = "pill via" if transfer else "pill"
flight = html.escape(str(src.get("flightNo") or "").strip())
pills = [f"<span class='{tag_cls}'>{tag}</span>"]
if flight:
pills.append(f"<span class='pill'>航班 {flight}</span>")
codes_html = f"<span class='codes'>{codes}</span>" if codes else ""
return (
"<label class='opt'>"
f"<input type='radio' name='index' value='{index}' required />"
"<div class='head'><span class='tick' aria-hidden='true'></span>"
f"<div class='route'><strong>{line}</strong>{codes_html}"
f"<div class='meta'>{''.join(pills)}</div></div></div>"
f"{fee_html}"
f"<p class='total'><span>总销售价</span><b>{total}</b></p>"
"</label>"
)
def _wrap(title: str, body: str) -> str:
"""整页壳:标题、viewport、共用样式。"""
return (
"<!doctype html><html lang='zh-CN'><head><meta charset='utf-8' />"
"<meta name='viewport' content='width=device-width, initial-scale=1' />"
f"<title>{html.escape(title)}</title>"
f"<style>{_PAGE_CSS}</style></head><body>"
f"{body}</body></html>"
)
@@ -0,0 +1,121 @@
"""
空运选线路 H5 的短命 token。
本文件职责:token 绑定 sender + 工单 + 线路报价;URL 禁止 userid。
优先 Redis;单测走内存。
禁止:在本包查 TMS、改六态。
"""
from __future__ import annotations
import json
import logging
import secrets
import threading
import time
from typing import Any, Optional
from agent.channel.h5.copy_store import inquiry_form_base
from agent.config import get_settings
from agent.redis_coord.keys import TOKEN_COPY_TICKET
logger = logging.getLogger(__name__)
AIR_ROUTE_TTL_SECONDS = 7 * 24 * 60 * 60
_KEY = TOKEN_COPY_TICKET + ":air_route"
_MEM: dict[str, tuple[str, float]] = {}
_MEM_LOCK = threading.Lock()
def issue_air_route_token(
*,
sender_id: str,
work_order_no: str,
quote: dict[str, Any],
facts: dict[str, str] | None = None,
ttl_seconds: int = AIR_ROUTE_TTL_SECONDS,
) -> str:
"""签发选线路 token。副作用:写 Redis 或内存。"""
payload = json.dumps(
{
"sender_id": sender_id,
"work_order_no": work_order_no,
"quote": quote,
"facts": dict(facts or {}),
},
ensure_ascii=False,
)
token = secrets.token_urlsafe(24)
if _redis_set(token, payload, ttl_seconds):
return token
expires = time.time() + ttl_seconds
with _MEM_LOCK:
_MEM[token] = (payload, expires)
logger.info("air route token 走内存 wo=%s", work_order_no)
return token
def lookup_air_route_token(token: str) -> Optional[dict[str, Any]]:
"""读 token。过期或不存在返回 None。"""
raw = (token or "").strip()
if not raw:
return None
text = _redis_get(raw)
if text is None:
with _MEM_LOCK:
hit = _MEM.get(raw)
if not hit:
return None
payload, expires = hit
if expires < time.time():
_MEM.pop(raw, None)
return None
text = payload
try:
data = json.loads(text)
except json.JSONDecodeError:
return None
return data if isinstance(data, dict) else None
def build_air_route_url(token: str) -> str:
"""拼选线路页:{inquiry-form}/air-routes/{token}。"""
raw = (token or "").strip()
if not raw:
return ""
return f"{inquiry_form_base().rstrip('/')}/air-routes/{raw}"
def _redis_set(token: str, payload: str, ttl: int) -> bool:
client = _try_redis()
if client is None:
return False
try:
client.raw.set(client.key(_KEY, token), payload, ex=ttl)
return True
except Exception as exc: # noqa: BLE001
logger.warning("air route token Redis 写入失败:%s", exc)
return False
def _redis_get(token: str) -> Optional[str]:
client = _try_redis()
if client is None:
return None
try:
raw = client.raw.get(client.key(_KEY, token))
except Exception as exc: # noqa: BLE001
logger.warning("air route token Redis 读取失败:%s", exc)
return None
if raw is None:
return None
return raw.decode("utf-8") if isinstance(raw, bytes) else str(raw)
def _try_redis():
try:
from agent.redis_coord import get_redis_runtime
return get_redis_runtime(get_settings()).client
except Exception: # noqa: BLE001
return None
@@ -60,7 +60,8 @@ def _attach_bot_client(engine, message: InboundMessage) -> None:
每次入站换一次 BOT 出站客户端。
测试注入的 FakeBotGroup 不动。
有 response_url 走当场流式回;没有(存档附件)走桥主动发言,马上在群里说。
有 response_url 也不走 stream;空运群正文一律走桥。
把这次入站的 aibot_req_id 带给桥,群里才能引用原 @。
不能复用上一次 @ 的过期 url。
"""
from agent.channel.aibot.reply import AibotReplyClient
@@ -69,7 +70,8 @@ def _attach_bot_client(engine, message: InboundMessage) -> None:
if existing is not None and not isinstance(existing, AibotReplyClient):
return
engine._group_client = AibotReplyClient(
str((message.raw or {}).get("response_url") or "")
str((message.raw or {}).get("response_url") or ""),
callback_req_id=str((message.raw or {}).get("aibot_req_id") or ""),
)
+18
View File
@@ -217,6 +217,24 @@ class HttpLedger:
out["ok"] = True
return out if out.get("ok") else {"ok": False, "error": out.get("error") or "transition_failed"}
def update_facts(self, *, work_order_no: str, facts: dict[str, str]) -> dict[str, Any]:
"""选定线路后写回起运港等询价事实。不改六态。"""
out = self._post(
"/inquiry/agent/ticket/updateFacts",
{"workOrderNo": work_order_no, "facts": facts},
idem=f"update-facts:{work_order_no}:{sorted((facts or {}).items())}",
)
return out if out.get("ok") else {"ok": False, "error": out.get("error") or "update_facts_failed"}
def void_quote_files(self, *, work_order_no: str) -> dict[str, Any]:
"""采用后改选:作废旧报价单。不改六态。"""
out = self._post(
"/inquiry/agent/ticket/voidQuoteFiles",
{"workOrderNo": work_order_no},
idem=f"void-quote:{work_order_no}",
)
return out if out.get("ok") else {"ok": False, "error": out.get("error") or "void_quote_failed"}
def patch_lost_reason(self, *, work_order_no: str, lost_reason: str) -> dict[str, Any]:
"""未成交后记下原因。不改六态。"""
out = self._post(
+49 -1
View File
@@ -192,6 +192,30 @@ class MemoryLedger:
{"name": "其他附加费", "amount": "USD 80"},
],
}
if str(facts.get("tms_multi") or "") == "1":
quote["isMulti"] = "2"
quote["airOptions"] = [
{
"originAirportCode": "ZUH",
"transferAirportCode": "COC",
"destinationAirportCode": "CRK",
"salePrice": 183370,
"currency": "CNY",
"departureCharges": [
{"chargeName": "运输费", "currency": "CNY", "unitPrice": 11}
],
},
{
"originAirportCode": "SZX",
"transferAirportCode": "",
"destinationAirportCode": "CRK",
"salePrice": 193370,
"currency": "CNY",
"departureCharges": [
{"chargeName": "运输费", "currency": "CNY", "unitPrice": 12}
],
},
]
return {
"ok": True,
"classification": "HAS_PRICE",
@@ -220,6 +244,28 @@ class MemoryLedger:
"status": ticket.status,
}
def update_facts(self, *, work_order_no: str, facts: dict[str, str]) -> dict[str, Any]:
"""选定线路后写回起运港等询价事实。"""
with self._lock:
ticket = self._tickets.get(work_order_no)
if ticket is None:
return {"ok": False, "error": "ticket_not_found"}
ticket.facts = dict(facts)
return {"ok": True}
def void_quote_files(self, *, work_order_no: str) -> dict[str, Any]:
"""采用后改选:旧报价单不再当本单有效文件。"""
with self._lock:
ticket = self._tickets.get(work_order_no)
if ticket is None:
return {"ok": False, "error": "ticket_not_found"}
ticket.quote_file_ready = False
if ticket.quote:
ticket.quote = dict(ticket.quote)
ticket.quote["quoteFileIssued"] = False
ticket.quote["quoteFileVoided"] = True
return {"ok": True}
def transition(
self,
*,
@@ -359,7 +405,9 @@ class MemoryLedger:
wait_phase = "wait_deal"
elif status == "已报价" and quote:
# 文件已发给销售 → 成交跟进;否则海运协同卡、空运方案卡
if ticket.quote_file_ready or quote.get("quoteFileIssued"):
if quote.get("quoteFileVoided"):
wait_phase = "wait_adopt"
elif ticket.quote_file_ready or quote.get("quoteFileIssued"):
wait_phase = "wait_deal"
elif (ticket.business_line or "").upper() == "SEA":
wait_phase = "wait_collab"
+14 -2
View File
@@ -23,19 +23,31 @@ _MODE_WORDS = (
_LABEL_LINE = re.compile(
r"^(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|运输分类|车型/数量|车型数量|"
r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|"
r"贸易条款|货好时间|货源地|提货地址|收货地址|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|"
r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸)\s*[::]\s*(.+)$"
)
_LABEL_INLINE = re.compile(
r"(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|运输分类|车型/数量|车型数量|"
r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|"
r"贸易条款|货好时间|货源地|提货地址|收货地址|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|"
r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸)\s*[::]\s*(\S+)"
)
# 陆运核对卡非必填:销售可能写「通关口岸皇岗」或照抄「通关口岸(非必填):皇岗」。长词优先。
_OPTIONAL_INQUIRY_LABELS = (
"通关口岸(非必填)",
"通关口岸",
"货源地(非必填)",
"货好时间(非必填)",
"提货地址(非必填)",
"收货地址(非必填)",
"贸易条款(非必填)",
"起运港(非必填)",
"货源地",
"货好时间",
"提货地址",
"收货地址",
"贸易条款",
"起运港",
)
# 群里协同标签,冒号可省略:「客户名称张三」「货值 10万」。长词优先。
_COLLAB_LABELS = (
+65 -30
View File
@@ -49,6 +49,18 @@ _LOCK_QUOTE = "__air_lock_quote"
_LOCK_EXEC = "__air_lock_exec"
def _ticket_flight_date(ticket: object) -> str:
"""锁舱 flightDate 用工单报价日期,和主账出站一致。"""
view = ticket_view(ticket)
facts = dict(view.get("facts") or {})
return str(
facts.get("报价日期")
or view.get("quoteDate")
or view.get("quote_date")
or ""
).strip()
def is_airline(flow: Any, sender_id: str) -> bool:
"""员工管理岗位=航线·空运且启用。"""
uid = (sender_id or "").strip()
@@ -595,21 +607,13 @@ def apply_air_lock(
if not is_airline(flow, sender_id):
send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin())
return "not_airline"
from agent.policy.system_exception import reply_paused_ticket, ticket_is_paused
if ticket_is_paused(ticket):
reply_paused_ticket(
ticket=ticket,
reply=lambda text: send_group_text(group_client_of(flow), chat_id, text),
)
return "system_exception_paused"
# 锁舱是航线主动再试:即使工单因上次失败标了系统异常,也要再打 TMS,
# 不能只回「TMS接口异常,IT运维排查中」。
no = ticket_no(ticket)
name = operator_name(flow, sender_id)
quote = ticket_view(ticket).get("quote") or {}
options = _air_options(quote)
quote_id = _quote_id(quote) or f"QT-{no}"
if not options:
options = [{"optionNo": "AIR-OPT-01"}]
quote_id = _quote_id(quote)
picked = (option_no or "").strip()
if not picked and len(options) > 1:
facts = dict(ticket_view(ticket).get("collab_facts") or {})
@@ -619,8 +623,10 @@ def apply_air_lock(
)
send_group_text(group_client_of(flow), chat_id, copy.air_ask_lock_option(options))
return "wait_lock_option"
if not picked:
if not picked and options:
picked = str(options[0].get("optionNo") or options[0].get("option_no") or "").strip()
if not picked:
picked = "AIR-OPT-01"
locker = getattr(flow.ledger, "lock_cabin", None)
if not callable(locker):
send_group_text(
@@ -630,7 +636,7 @@ def apply_air_lock(
)
return "lock_unavailable"
from agent.policy.system_exception import (
STEP_TMS_CABIN,
STEP_TMS_LOCK,
TYPE_TMS,
SystemExceptionEvent,
call_with_retries,
@@ -649,21 +655,35 @@ def apply_air_lock(
is_ok=lambda x: bool((x or {}).get("ok")),
)
if not out.get("ok"):
why = copy.air_cabin_fail_reason(
tms_reason=str(out.get("failReason") or out.get("error") or ""),
flight_date=_ticket_flight_date(ticket),
)
send_group_text(
group_client_of(flow),
chat_id,
copy.air_lock_fail(work_order_no=no, operator=name, reason=why),
)
confirm_system_exception(
event=SystemExceptionEvent(
exception_type=TYPE_TMS,
step=STEP_TMS_CABIN,
step=STEP_TMS_LOCK,
reason="空运锁舱接口调用失败",
service="空运锁舱",
error_code=str(out.get("failReason") or out.get("error") or "LOCK_FAIL"),
error_code=why or "LOCK_FAIL",
work_order_no=no,
ticket_status=ticket_status(ticket),
conversation_kind="group",
conversation_target=chat_id,
extra={"option_no": picked, "instruction": instruction, "sender_id": sender_id},
extra={
"option_no": picked,
"instruction": instruction,
"sender_id": sender_id,
"operator_name": name,
"flight_date": _ticket_flight_date(ticket),
},
),
ledger=flow.ledger,
reply=lambda text: send_group_text(group_client_of(flow), chat_id, text),
)
return "system_exception"
exec_no = str(out.get("lockId") or out.get("tmsRequestId") or "").strip()
@@ -690,6 +710,10 @@ def apply_air_lock(
quote_no=quote_no,
),
)
from agent.policy.system_exception import clear_system_exception, ticket_is_paused
if ticket_is_paused(ticket):
clear_system_exception(ledger=flow.ledger, work_order_no=no)
view = ticket_view(ticket)
view["collab_chat_id"] = chat_id
emit_group_deal(flow=flow, ticket=view, quote=dict(view.get("quote") or {}))
@@ -707,14 +731,7 @@ def apply_air_release(
if not is_airline(flow, sender_id):
send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin())
return "not_airline"
from agent.policy.system_exception import reply_paused_ticket, ticket_is_paused
if ticket_is_paused(ticket):
reply_paused_ticket(
ticket=ticket,
reply=lambda text: send_group_text(group_client_of(flow), chat_id, text),
)
return "system_exception_paused"
# 释放同理:暂停单也允许再打 TMS,回话用释放失败原因而不是接口异常兜底。
no = ticket_no(ticket)
if ticket_status(ticket) == "已成交":
send_group_text(group_client_of(flow), chat_id, copy.air_release_deal_blocked(no))
@@ -734,7 +751,7 @@ def apply_air_release(
)
return "release_unavailable"
from agent.policy.system_exception import (
STEP_TMS_CABIN,
STEP_TMS_RELEASE,
TYPE_TMS,
SystemExceptionEvent,
call_with_retries,
@@ -753,21 +770,35 @@ def apply_air_release(
is_ok=lambda x: bool((x or {}).get("ok")),
)
if not out.get("ok"):
why = copy.air_cabin_fail_reason(
tms_reason=str(out.get("failReason") or out.get("error") or ""),
flight_date=_ticket_flight_date(ticket),
)
send_group_text(
group_client_of(flow),
chat_id,
copy.air_release_fail(work_order_no=no, operator=name, reason=why),
)
confirm_system_exception(
event=SystemExceptionEvent(
exception_type=TYPE_TMS,
step=STEP_TMS_CABIN,
step=STEP_TMS_RELEASE,
reason="空运释放舱位接口调用失败",
service="空运舱位",
error_code=str(out.get("failReason") or out.get("error") or "RELEASE_FAIL"),
error_code=why or "RELEASE_FAIL",
work_order_no=no,
ticket_status=ticket_status(ticket),
conversation_kind="group",
conversation_target=chat_id,
extra={"lock_id": lock_id, "instruction": instruction, "sender_id": sender_id},
extra={
"lock_id": lock_id,
"instruction": instruction,
"sender_id": sender_id,
"operator_name": name,
"flight_date": _ticket_flight_date(ticket),
},
),
ledger=flow.ledger,
reply=lambda text: send_group_text(group_client_of(flow), chat_id, text),
)
return "system_exception"
exec_no = str(out.get("releaseId") or out.get("tmsRequestId") or lock_id).strip()
@@ -782,6 +813,10 @@ def apply_air_release(
quote_no=str(facts.get(_LOCK_QUOTE) or ""),
),
)
from agent.policy.system_exception import clear_system_exception, ticket_is_paused
if ticket_is_paused(ticket):
clear_system_exception(ledger=flow.ledger, work_order_no=no)
view = ticket_view(ticket)
view["collab_chat_id"] = chat_id
emit_group_deal(flow=flow, ticket=view, quote=dict(view.get("quote") or {}))
+285 -34
View File
@@ -1,7 +1,7 @@
"""
空运文字询价确定性流程(模型只负责抽字段,本文件只编排与执行)。
本文件职责:补问到询价必填齐全 → 建单出卡片 → 必须打主账查 TMS(有价/暂无报价)。
本文件职责:补问到必填齐 → 核对确定后建单 → 查 TMS(一条出卡,多条 H5 选线路)。
对不上机场也不能跳过查价、本地说暂无报价。
调用:handler 在 Worker/inbox 线程;禁止回调线程同步跑。
禁止:直连 TMS;用图状态冒充主账成功;用户文字冒充文件已生成。
@@ -11,6 +11,7 @@ from __future__ import annotations
import logging
import threading
import time
import uuid
from dataclasses import asdict, dataclass, field
from typing import Any, Callable, Optional
@@ -20,6 +21,7 @@ from agent.ledger.memory_ledger import MemoryLedger
from agent.llm.extract_text import extract_inquiry_snapshot
from agent.policy import inquiry_copy as copy
from agent.schema.field_validate import validate_required_fields
from agent.schema.land_options import looks_like_confirm
from agent.schema.tms_air_query import assemble_air_query
logger = logging.getLogger(__name__)
@@ -114,6 +116,8 @@ class AirTextInquiryFlow:
"""
bookmark_kind = "air"
# 明细文本真正发出后再发卡,避免销售先看到半截引用条。
_detail_before_card_gap_sec = 0.8
def __init__(self, ledger: Any = None, group_client: Any = None, *, bookmark_store: Any = None) -> None:
self._ledger = ledger or build_ledger()
@@ -410,7 +414,7 @@ class AirTextInquiryFlow:
phase = (sess.phase or "").strip()
if not no or is_virtual_no(no):
return sess
if phase in {"clarify", "need_mode", "handoff_offer", "wait_lost_reason"}:
if phase in {"clarify", "need_mode", "handoff_offer", "wait_lost_reason", "wait_air_route"}:
return sess
logger.info(
"%s.new_over_old phase=%s wo=%s",
@@ -621,21 +625,13 @@ class AirTextInquiryFlow:
)
return "wait_adopt"
if sess.phase == "wait_collab":
reply(
copy.tms_hit_card(
work_order_no=sess.work_order_no,
quote=sess.quote,
first_or_same=sess.first_or_same,
history_work_order_no=sess.history_work_order_no,
status=sess.status or "已报价",
),
copy.tms_hit_wecom_payload(
work_order_no=sess.work_order_no,
quote=sess.quote,
first_or_same=sess.first_or_same,
history_work_order_no=sess.history_work_order_no,
status=sess.status or "已报价",
),
self._emit_air_hit(
reply,
work_order_no=sess.work_order_no,
quote=sess.quote,
first_or_same=sess.first_or_same,
history_work_order_no=sess.history_work_order_no,
status=sess.status or "已报价",
)
return "wait_collab"
if sess.phase == "tms_miss":
@@ -716,6 +712,19 @@ class AirTextInquiryFlow:
adjusted = try_private_adjust(self, sess, text, reply)
if adjusted:
return adjusted
if sess and sess.phase == "wait_confirm" and looks_like_confirm(text):
# 确定查价不再抽字段,避免「确定」被模型改成空 facts。
logger.info("air.confirm sender=%s", sender_id)
return self._confirm_or_hold(
sender_id=sender_id,
facts=dict(sess.facts),
text=sess.immutable_text or text,
reply=reply,
sess=sess,
)
if (not sess or sess.phase != "wait_confirm") and looks_like_confirm(text) and not sess:
reply(copy.AIR_CONFIRM_EXPIRED)
return "confirm_expired"
sess = self._detach_ticketed_for_new_inquiry(sess)
snap = extract_inquiry_snapshot(
@@ -739,7 +748,7 @@ class AirTextInquiryFlow:
return llm_phase
mode = (snap.get("business_line") or "").upper()
facts = dict(snap.get("facts") or {})
if sess and sess.phase in {"clarify", "need_mode"}:
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
@@ -833,9 +842,78 @@ class AirTextInquiryFlow:
),
)
return self._create_and_quote(
return self._hold_for_confirm(
sender_id=sender_id,
facts=facts,
text=sess.immutable_text if sess and sess.immutable_text else text,
reply=reply,
sess=sess,
)
def _hold_for_confirm(
self,
*,
sender_id: str,
facts: dict[str, str],
text: str,
reply: ReplyFn,
sess: Optional[FlowSession],
) -> str:
"""必填已齐:出核对卡,等确定再建单。"""
reply(copy.air_confirm_card(facts))
self._save(
FlowSession(
sender_id=sender_id,
thread_id=sender_id,
phase="wait_confirm",
business_line="AIR",
facts=facts,
immutable_text=text,
wait_version=(sess.wait_version + 1) if sess else 1,
)
)
return "wait_confirm"
def _confirm_or_hold(
self,
*,
sender_id: str,
facts: dict[str, str],
text: str,
reply: ReplyFn,
sess: FlowSession,
) -> str:
"""确定意图:字段仍齐则建单查价。"""
check = validate_required_fields(facts=facts, business_line="AIR")
tms_ready = assemble_air_query(check["facts"])
if not check["ok"] or not tms_ready.get("ok"):
keys = list(check["missing"] or [])
for key in tms_ready.get("missing") or []:
if key not in keys:
keys.append(key)
new_sess = FlowSession(
sender_id=sender_id,
thread_id=sender_id,
phase="clarify",
business_line="AIR",
facts=dict(tms_ready.get("facts") or facts),
immutable_text=sess.immutable_text or text,
wait_version=sess.wait_version + 1,
)
return self._clarify_or_handoff(
sess=sess,
keys=keys,
new_sess=new_sess,
reply=reply,
clarify_text=copy.ask_clarify(
facts=new_sess.facts,
missing_keys=keys,
transport_mode="空运",
),
)
return self._create_and_quote(
sender_id=sender_id,
facts=dict(tms_ready["facts"]),
text=text,
reply=reply,
)
@@ -925,6 +1003,15 @@ class AirTextInquiryFlow:
return "tms_miss"
quote = tms["quote"]
from agent.schema.air_route_options import (
needs_route_pick,
parse_air_options,
project_selected_quote,
)
options = parse_air_options(quote)
if options and not needs_route_pick(quote):
quote = project_selected_quote(quote, options[0])
up = self._ledger.upsert_quote(work_order_no=no, quote=quote, to_status="已报价")
if not up.get("ok"):
reply("主账写入报价失败,不能当作已报价。")
@@ -933,29 +1020,193 @@ class AirTextInquiryFlow:
return "ledger_fail"
sess.quote = quote
copy.remember_tms_quote(sess, quote)
sess.tms_quote = dict(quote)
sess.status = "已报价"
if needs_route_pick(quote):
return self._offer_air_routes(
sess=sess,
work_order_no=no,
quote=quote,
reply=reply,
)
sess.phase = "wait_collab"
sess.allowed = (copy.BTN_SKIP_COLLAB,)
sess.wait_version += 1
self._save(sess)
reply(
copy.tms_hit_card(
work_order_no=no,
quote=quote,
first_or_same=first,
history_work_order_no=history_wo,
status="已报价",
),
copy.tms_hit_wecom_payload(
work_order_no=no,
quote=quote,
first_or_same=first,
history_work_order_no=history_wo,
status="已报价",
),
self._emit_air_hit(
reply,
work_order_no=no,
quote=quote,
first_or_same=first,
history_work_order_no=history_wo,
status="已报价",
)
return "wait_collab"
def _offer_air_routes(
self,
*,
sess: FlowSession,
work_order_no: str,
quote: dict[str, Any],
reply: ReplyFn,
) -> str:
"""多条线路:企微只发入口卡,选线在 H5。"""
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
token = issue_air_route_token(
sender_id=sess.sender_id,
work_order_no=work_order_no,
quote=quote,
facts=dict(sess.facts),
)
url = build_air_route_url(token)
count = len(parse_air_options(quote))
sess.phase = "wait_air_route"
sess.allowed = ()
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),
)
return "wait_air_route"
def select_air_route(
self,
*,
sender_id: str,
index: int,
reply: ReplyFn,
) -> str:
"""
H5 选定一条线路:写回起运港,出价格明细卡。
已采用过则作废旧报价单后再出新卡。
"""
sess = self.session_of(sender_id)
if sess is None or not (sess.work_order_no or "").strip():
reply("线路选择已过期,请回企微重新询价。")
return "route_expired"
from agent.schema.air_route_options import (
apply_selected_option,
parse_air_options,
project_selected_quote,
)
raw_quote = dict(sess.tms_quote or sess.quote or {})
options = parse_air_options(raw_quote)
if index < 0 or index >= len(options):
reply("没有这条线路,请重新打开页面选择。")
return "route_invalid"
option = options[index]
facts = apply_selected_option(dict(sess.facts), option)
sess.facts = facts
quote = project_selected_quote(raw_quote, option)
prev_phase = (sess.phase or "").strip()
already_adopted = prev_phase in {"wait_deal", "wait_file", "wait_adopt"}
if already_adopted:
clearer = getattr(self._ledger, "void_quote_files", None)
if callable(clearer):
clearer(work_order_no=sess.work_order_no)
from agent.redis_coord.quote_file import clear_quote_file_ready
clear_quote_file_ready(sess.work_order_no)
patcher = getattr(self._ledger, "update_facts", None)
if callable(patcher):
patcher(work_order_no=sess.work_order_no, facts=facts)
self._ledger.upsert_quote(work_order_no=sess.work_order_no, quote=quote, to_status="已报价")
sess.quote = quote
copy.remember_tms_quote(sess, quote)
sess.status = "已报价"
sess.wait_version += 1
if already_adopted:
# 旧报价单已作废,直接出方案卡让销售重新采用。
sess.phase = "wait_adopt"
sess.allowed = (copy.BTN_ADOPT_EXCEL, copy.BTN_ADOPT_PDF, copy.BTN_REJECT)
self._save(sess)
mode = copy.scheme_transport_mode(sess.business_line)
reply(
copy.scheme_card(
work_order_no=sess.work_order_no,
status="已报价",
facts=facts,
quote=quote,
transport_mode=mode,
),
copy.scheme_wecom_payload(
work_order_no=sess.work_order_no,
facts=facts,
quote=quote,
status="已报价",
transport_mode=mode,
),
)
return "wait_adopt"
sess.phase = "wait_collab"
sess.allowed = (copy.BTN_SKIP_COLLAB,)
self._save(sess)
self._emit_air_hit(
reply,
work_order_no=sess.work_order_no,
quote=quote,
first_or_same=sess.first_or_same,
history_work_order_no=sess.history_work_order_no,
status="已报价",
)
return "wait_collab"
def _emit_air_hit(
self,
reply: ReplyFn,
*,
work_order_no: str,
quote: dict[str, Any],
first_or_same: str = "first",
history_work_order_no: str = "",
status: str = "已报价",
) -> None:
"""
先发完整价格明细文本,等它真正发出,再发带按钮的卡。
企微引用条超长会截成省略号。海运已走这条;空运多项费用同样不能塞进卡。
只等这一条出站,不锁其它工单。
"""
src = dict(quote or {})
out_id = reply(
copy.sea_quote_detail_text(
work_order_no=work_order_no,
quote=src,
first_or_same=first_or_same,
history_work_order_no=history_work_order_no,
status=status,
business_line="AIR",
)
) or ""
self._wait_outbound_sent(
str(out_id), work_order_no=work_order_no, what="价格明细文本"
)
gap = float(getattr(self, "_detail_before_card_gap_sec", 0.8) or 0)
if out_id and gap > 0:
time.sleep(gap)
reply(
copy.tms_hit_card(
work_order_no=work_order_no,
quote=src,
first_or_same=first_or_same,
history_work_order_no=history_work_order_no,
status=status,
),
copy.tms_hit_wecom_payload(
work_order_no=work_order_no,
quote=src,
first_or_same=first_or_same,
history_work_order_no=history_work_order_no,
status=status,
),
)
def _wait_inquiry_sent(self, out_id: str, *, work_order_no: str) -> None:
"""等询价确认出站完成后再查价。"""
self._wait_outbound_sent(out_id, work_order_no=work_order_no, what="询价确认")
+136 -21
View File
@@ -15,6 +15,7 @@ from typing import Any, Iterable
from agent.schema.field_validate import (
AIR_COLLAB_FIELDS,
AIR_OPTIONAL_FIELDS,
AIR_REQUIRED_FIELDS,
LAND_DISPLAY,
SEA_COLLAB_FIELDS,
@@ -27,7 +28,7 @@ from agent.schema.land_options import option_prompt as _land_option_prompt
from agent.schema.quote_adjust import total_price_label
from agent.schema.sea_options import sea_class_prompt
# 空运补问:与合同 8 项一致。报价日期默认当天,不进待补充。
# 空运补问:必填 7 项 + 非必填。报价日期默认当天,不进待补充。
_AIR_EXAMPLES = {
"起运港": "南京",
"目的港": "吉隆坡",
@@ -37,10 +38,20 @@ _AIR_EXAMPLES = {
"体积": "2CBM",
"包装方式": "托盘",
"报价日期": "2026-09-15",
"货源地": "东莞",
"货好时间": "随时可提",
"提货地址": "东莞仓库",
"收货地址": "马尼拉",
"贸易条款": "FOB",
}
_AIR_CLARIFY = tuple(
(key, label, _AIR_EXAMPLES[key]) for key, label in AIR_REQUIRED_FIELDS
)
_AIR_OPTIONAL_CLARIFY = tuple(
(key, label, _AIR_EXAMPLES.get(key, "")) for key, label in AIR_OPTIONAL_FIELDS
)
AIR_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。"
AIR_CONFIRM_EXPIRED = "刚才的空运核对已过期,请把空运需求再发一次,出核对卡后再回复确定。"
# 海运补问:与合同 10 项一致。报价日期默认当天,不进待补充。
_SEA_EXAMPLES = {
@@ -373,6 +384,46 @@ def _sea_known_value(facts: dict[str, str], key: str) -> str:
return (src.get(key) or "").strip()
def _air_field_line(key: str, value: str, *, required: bool, example: str = "") -> str:
"""空运销售侧一行:名称(必填/非必填):值或示例。"""
label = AIR_DISPLAY_LABEL(key)
mark = "必填" if required else "非必填"
text = (value or "").strip()
if text:
return f"{label}({mark}):{text}"
if example:
return f"{label}({mark}):(如:{example})"
return f"{label}({mark}):"
def AIR_DISPLAY_LABEL(key: str) -> str:
"""核对/补问对外名。起运港按产品口径写起运港。"""
if key == "起运港":
return "起运港"
return display_name(key, "AIR")
def air_confirm_card(facts: dict[str, str] | None) -> str:
"""
确定查价前的核对卡。没有工单号。
必填 7 项 + 6 个非必填都列出;空着的非必填留空行。
"""
src = dict(facts or {})
lines = ["运输方式(必填):空运"]
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))
continue
lines.append(_air_field_line(key, _air_known_value(src, key), required=True))
for key, _label in AIR_OPTIONAL_FIELDS:
lines.append(_air_field_line(key, _air_known_value(src, key), required=False))
lines.append("")
lines.append(AIR_CONFIRM_TAIL)
return "\n".join(lines)
def ask_clarify(
*,
facts: dict[str, str] | None = None,
@@ -383,6 +434,7 @@ def ask_clarify(
缺项补问:先回显已识别,再列待补充并给示例。
报价日期写进已识别(销售说了用原话,没说用当天),不出现在待补充。
空运非必填一并列出,没填进待补充,不挡查价。
"""
src = dict(facts or {})
skip = {"报价日期"}
@@ -398,6 +450,16 @@ def ask_clarify(
val = known(src, key)
if val:
lines.append(f"{label}:{val}")
if not sea:
origin = known(src, "起运港")
if origin:
lines.append(f"起运地:{origin}")
for key, label, _example in _AIR_OPTIONAL_CLARIFY:
if key == "起运港":
continue
val = known(src, key)
if val:
lines.append(f"{label}:{val}")
lines.append("")
lines.append("待补充:")
any_missing = False
@@ -411,6 +473,16 @@ def ask_clarify(
lines.append(sea_class_prompt())
else:
lines.append(f"{label}:(如:{example})")
if not sea:
optional_pending = [
(key, label, example)
for key, label, example in _AIR_OPTIONAL_CLARIFY
if not known(src, key)
]
for key, label, example in optional_pending:
any_missing = True
shown = "起运港" if key == "起运港" else label
lines.append(f"{shown}(非必填):(如:{example})")
if not any_missing:
return ""
return "\n".join(lines)
@@ -861,18 +933,24 @@ def quote_area_safe_text(
transport_mode: str = "",
) -> str:
"""
卡片引用条:短报价原样;超长只留总价名称,避免半截费用名。
卡片引用条:空运有费用行就只留总价,禁止再塞费用。
完整明细由 sea_quote_detail_text 另发一条文本。空运卡仍用 quote_area_fee_text。
三项短费用也会被企微裁成「人工费:CNY 123...」。完整明细走文本。
海运/陆运仍按字数:短的原样,超长同样只留总价。
"""
full = quote_area_fee_text(quote, business_line=business_line, transport_mode=transport_mode)
if len(full) <= _QUOTE_AREA_SAFE_CHARS:
return full
src = dict(quote or {})
header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip()
total = money_text(str(src.get("total") or "-").strip() or "-", header_ccy)
n = len(quote_fee_lines(src))
label = _total_label(src, business_line, transport_mode)
line = (business_line or "").upper()
mode = (transport_mode or "").strip()
is_air = line == "AIR" or mode == "空运"
if is_air and n:
return f"{label}:{total}\n共{n}项费用,详见上一条明细"
full = quote_area_fee_text(quote, business_line=business_line, transport_mode=transport_mode)
if len(full) <= _QUOTE_AREA_SAFE_CHARS:
return full
if n:
return f"{label}:{total}\n共{n}项费用,详见上一条明细"
return f"{label}:{total}"
@@ -888,7 +966,7 @@ def sea_quote_detail_text(
business_line: str = "SEA",
) -> str:
"""
海运/陆运完整价格明细文本:来源、工单号、费用行、总销售价。
完整价格明细文本:来源、工单号、费用行、总价。海运/陆运/空运共用。
时效、报价有效期只在卡片上出现,这里不写,避免和卡重复。
不受卡片引用条字数限制。first_or_same 只兼容调用方。
@@ -896,13 +974,15 @@ def sea_quote_detail_text(
_ = first_or_same, history_work_order_no, status
src = dict(quote or {})
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
return "\n".join(
[
source,
f"{work_order_no}价格明细",
quote_area_fee_text(src, business_line=business_line or "SEA"),
]
)
lines = [source, f"{work_order_no}价格明细"]
if (business_line or "").upper() == "AIR":
from agent.schema.air_route_options import route_codes
route = route_codes(None, {}, src)
if route:
lines.append(route)
lines.append(quote_area_fee_text(src, business_line=business_line or "SEA"))
return "\n".join(lines)
def collab_hint(work_order_no: str) -> str:
@@ -1007,6 +1087,11 @@ def tms_hit_card(
src = dict(quote or {})
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
lines = [source, f"{work_order_no}价格明细"]
from agent.schema.air_route_options import route_codes
route = route_codes(None, {}, src) or str(src.get("route_line") or "").strip()
if route:
lines.append(route)
lines.extend(
quote_detail_lines(
src,
@@ -1022,6 +1107,32 @@ def tms_hit_card(
return "\n".join(lines)
def air_route_entry_card(*, work_order_no: str, count: int, url: str) -> dict[str, Any]:
"""
多条线路入口卡:短文案跳转 H5,避免企微截断。
"""
n = max(int(count or 0), 2)
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": "点击查看并选择一条线路,选定后回企微看价格明细。",
"card_action": {"type": 1, "url": action_url},
"jump_list": [{"type": 1, "title": "查看并选择线路", "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 tms_hit_wecom_payload(
*,
work_order_no: str,
@@ -1062,7 +1173,7 @@ def tms_hit_wecom_payload(
"main_title": {"title": f"{work_order_no}价格明细"},
"quote_area": {
"type": 0,
"quote_text": quote_area_fee_text(src, business_line="AIR"),
"quote_text": quote_area_safe_text(src, business_line="AIR"),
},
"sub_title_text": meta,
"task_id": card_task_id("quote", work_order_no),
@@ -1675,6 +1786,13 @@ def _clip(raw: str, limit: int) -> str:
return text if len(text) <= limit else text[: limit - 1] + "…"
def _air_scheme_route(quote: dict[str, Any], facts: dict[str, str]) -> str:
"""方案卡航线:空运ZUH→中转→CRK。没有三字码才退回地名。"""
from agent.schema.air_route_options import scheme_route_codes
return scheme_route_codes(quote, facts)
def scheme_transport_mode(business_line: str = "", fallback: str = "空运") -> str:
"""会话业务线 → 方案卡运输方式。海运不得再写成空运。"""
raw = (business_line or "").strip()
@@ -1736,7 +1854,7 @@ def scheme_horizontal_rows(
pack = (src.get("包装方式") or src.get("包装类型") or "-").strip() or "-"
return [
{"keyname": "工单号", "value": _clip(work_order_no, 26)},
{"keyname": "航线", "value": _clip(f"空运 {origin}→{dest}", 26)},
{"keyname": "航线", "value": _clip(_air_scheme_route(q, src), 26)},
{"keyname": "货物", "value": _clip(f"{cargo};{pieces};{weight};{volume}", 26)},
{"keyname": "包装", "value": _clip(pack, 26)},
{"keyname": "时效", "value": _clip(tms_eta_display(q), 26)},
@@ -1798,11 +1916,8 @@ def scheme_wecom_payload(
"main_title": {"title": f"{work_order_no}报价方案"},
"quote_area": {
"type": 0,
"quote_text": (
quote_area_safe_text(src, transport_mode=transport_mode)
if (transport_mode or "").strip() in {"海运", "陆运"}
else quote_area_fee_text(src, transport_mode=transport_mode)
),
# 空运/海运/陆运引用条一律只留总价,费用在上一条明细。
"quote_text": quote_area_safe_text(src, transport_mode=transport_mode),
},
"horizontal_content_list": scheme_horizontal_rows(
work_order_no=work_order_no,
@@ -107,6 +107,17 @@ def _emit_private_card(flow: Any, sess: Any, reply) -> None:
)
return
sess.allowed = (copy.BTN_SKIP_COLLAB,)
emit_air = getattr(flow, "_emit_air_hit", None)
if callable(emit_air):
emit_air(
reply,
work_order_no=no,
quote=quote,
first_or_same=first,
history_work_order_no=history,
status="已报价",
)
return
reply(
copy.tms_hit_card(
work_order_no=no,
@@ -95,6 +95,12 @@ def ticket_view(ticket: object) -> dict[str, Any]:
"product_names": list(getattr(ticket, "product_names", []) or []),
"sales_name": str(getattr(ticket, "sales_name", "") or ""),
"lost_reason": str(getattr(ticket, "lost_reason", "") or ""),
"system_exception": str(getattr(ticket, "system_exception", "") or ""),
"systemException": str(getattr(ticket, "system_exception", "") or ""),
"system_exception_reason": str(getattr(ticket, "system_exception_reason", "") or ""),
"systemExceptionReason": str(getattr(ticket, "system_exception_reason", "") or ""),
"system_exception_step": str(getattr(ticket, "system_exception_step", "") or ""),
"system_exception_payload": dict(getattr(ticket, "system_exception_payload", {}) or {}),
}
@@ -418,6 +424,16 @@ def hydrate_ticket(flow: Any, ticket: object) -> object:
merged["collab_facts"] = collab
if view.get("collab_chat_id"):
merged["collab_chat_id"] = view.get("collab_chat_id")
for key in (
"system_exception",
"systemException",
"system_exception_reason",
"systemExceptionReason",
"system_exception_step",
"system_exception_payload",
):
if full_view.get(key) and not merged.get(key):
merged[key] = full_view.get(key)
return merged
+30 -23
View File
@@ -27,6 +27,8 @@ STEP_LLM_NO_TICKET = copy.STEP_LLM_NO_TICKET
STEP_LLM = copy.STEP_LLM
STEP_TMS_QUOTE = copy.STEP_TMS_QUOTE
STEP_TMS_CABIN = copy.STEP_TMS_CABIN
STEP_TMS_LOCK = copy.STEP_TMS_LOCK
STEP_TMS_RELEASE = copy.STEP_TMS_RELEASE
STEP_GROUP = copy.STEP_GROUP
MAX_AUTO_RETRIES = 3
@@ -140,15 +142,6 @@ def _now_text(now: datetime | None) -> str:
return stamp.strftime("%Y-%m-%d %H:%M:%S")
def _test_prefix() -> bool:
try:
from agent.config import get_settings
return (get_settings().ytd_env or "").strip().lower() == "test"
except Exception: # noqa: BLE001
return False
def notify_app_ready() -> bool:
"""询价消息服务已配才推告警。"""
from agent.policy.deal_outcome import notify_app_ready as deal_ready
@@ -244,7 +237,6 @@ def confirm_system_exception(
users = _list_notify_users(ledger) if ledger is not None else []
if ready and users:
occurred = _now_text(now)
test_prefix = _test_prefix()
payload = copy.system_exception_alert_payload(
work_order_no=no,
exception_type=event.exception_type,
@@ -252,7 +244,6 @@ def confirm_system_exception(
error_code=event.error_code,
step=event.step,
occurred_at=occurred,
test_prefix=test_prefix,
)
text = copy.system_exception_alert_text(
work_order_no=no,
@@ -261,7 +252,6 @@ def confirm_system_exception(
error_code=event.error_code,
step=event.step,
occurred_at=occurred,
test_prefix=test_prefix,
)
sender = enqueue or _enqueue_default
for row in users:
@@ -626,7 +616,7 @@ def run_admin_retry(work_order_no: str, *, ledger: Any = None) -> dict[str, Any]
reply("请把刚才那句话再发一次。")
return {"ok": True, "step": step}
if step == STEP_TMS_CABIN:
if step in {STEP_TMS_CABIN, STEP_TMS_LOCK, STEP_TMS_RELEASE}:
return _resume_cabin_step(
ledger=book,
work_order_no=no,
@@ -634,6 +624,7 @@ def run_admin_retry(work_order_no: str, *, ledger: Any = None) -> dict[str, Any]
target=target,
status=status,
reply=reply,
step=step,
)
return {"ok": False, "error": "unknown_step", "step": step}
@@ -716,10 +707,14 @@ def _resume_tms_quote(
copy.remember_tms_quote(sess, quote)
flow._save(sess)
if biz == "AIR":
reply(
copy.tms_hit_card(work_order_no=no, quote=quote, status="已报价"),
copy.tms_hit_wecom_payload(work_order_no=no, quote=quote, status="已报价"),
)
emit_air = getattr(flow, "_emit_air_hit", None)
if callable(emit_air):
emit_air(reply, work_order_no=no, quote=quote, status="已报价")
else:
reply(
copy.tms_hit_card(work_order_no=no, quote=quote, status="已报价"),
copy.tms_hit_wecom_payload(work_order_no=no, quote=quote, status="已报价"),
)
return
emit = getattr(flow, "_emit_sea_hit", None)
if callable(emit):
@@ -742,13 +737,17 @@ def _resume_cabin_step(
target: str,
status: str,
reply: ReplyFn,
step: str = "",
) -> dict[str, Any]:
"""重试空运锁舱或释放:payload 里有 lock_id 就是释放。"""
no = (work_order_no or "").strip()
lock_id = str(extra.get("lock_id") or "").strip()
option_no = str(extra.get("option_no") or "").strip() or "AIR-OPT-01"
option_no = str(extra.get("option_no") or "").strip()
instruction = str(extra.get("instruction") or "")
sender = str(extra.get("sender_id") or target or "").strip()
cabin_step = STEP_TMS_RELEASE if lock_id else STEP_TMS_LOCK
if (step or "").strip() in {STEP_TMS_LOCK, STEP_TMS_RELEASE}:
cabin_step = (step or "").strip()
if lock_id:
releaser = getattr(ledger, "release_cabin", None)
out = call_with_retries(
@@ -782,10 +781,19 @@ def _resume_cabin_step(
service = "空运锁舱"
reason = "空运锁舱接口调用失败"
if not (out or {}).get("ok"):
why = copy.air_cabin_fail_reason(
tms_reason=str((out or {}).get("failReason") or (out or {}).get("error") or ""),
flight_date=str(extra.get("flight_date") or ""),
)
operator = str(extra.get("operator_name") or "同事").strip() or "同事"
if cabin_step == STEP_TMS_RELEASE or lock_id:
reply(copy.air_release_fail(work_order_no=no, operator=operator, reason=why))
else:
reply(copy.air_lock_fail(work_order_no=no, operator=operator, reason=why))
confirm_system_exception(
event=SystemExceptionEvent(
exception_type=TYPE_TMS,
step=STEP_TMS_CABIN,
step=cabin_step,
reason=reason,
service=service,
error_code=str((out or {}).get("failReason") or (out or {}).get("error") or "CABIN_FAIL"),
@@ -796,10 +804,9 @@ def _resume_cabin_step(
extra=dict(extra),
),
ledger=ledger,
reply=reply,
)
return {"ok": False, "error": "still_fail", "step": STEP_TMS_CABIN}
reply(copy.system_exception_recovery(STEP_TMS_CABIN))
return {"ok": False, "error": "still_fail", "step": cabin_step}
reply(copy.system_exception_recovery(cabin_step))
clear_system_exception(ledger=ledger, work_order_no=no)
if not lock_id:
exec_no = str((out or {}).get("lockId") or (out or {}).get("tmsRequestId") or "").strip()
@@ -813,4 +820,4 @@ def _resume_cabin_step(
reply(copy.air_lock_ok(work_order_no=no, operator="同事", option_no=option_no, exec_no=exec_no, quote_no=""))
else:
reply(copy.air_release_ok(work_order_no=no, operator="同事", option_no="", exec_no=str((out or {}).get("releaseId") or lock_id), quote_no=""))
return {"ok": True, "step": STEP_TMS_CABIN}
return {"ok": True, "step": cabin_step}
@@ -30,6 +30,19 @@ def mark_quote_file_ready(work_order_no: str) -> None:
logger.exception("写报价文件就绪键失败 wo=%s", no)
def clear_quote_file_ready(work_order_no: str) -> None:
"""采用后改选:清掉就绪键,避免续办再进成交跟进。"""
no = (work_order_no or "").strip()
if not no:
return
try:
client = get_redis_runtime().client
client.raw.delete(client.key(QUOTE_FILE_READY, no))
logger.info("报价文件就绪键已清 wo=%s", no)
except Exception:
logger.exception("清报价文件就绪键失败 wo=%s", no)
def is_quote_file_ready(work_order_no: str) -> bool:
"""HTTP 读:文件是否已经发出。读失败当未完成,继续请稍候。"""
no = (work_order_no or "").strip()
@@ -0,0 +1,250 @@
"""
空运 TMS 线路报价:从 airOptions 拆出可选线路。
本文件职责:判断一条还是多条、拼航线(含中转)、抽出该线路费用。
选定后把起运港写回 facts。中转只用于展示,不写进报价单模板。
禁止:猜没回的机场名;把贸易条款当线路;改六态。
"""
from __future__ import annotations
import json
import logging
from typing import Any
from agent.schema.tms_air_query import _AIRPORTS, resolve_airport_code
logger = logging.getLogger(__name__)
# 三字码 → 销售侧地名;同码多个中文时取较短的常用名。
_CODE_NAME: dict[str, str] = {}
for name, code in _AIRPORTS.items():
prev = _CODE_NAME.get(code)
if prev is None or len(name) < len(prev):
_CODE_NAME[code] = name
def parse_air_options(quote: dict[str, Any] | None) -> list[dict[str, Any]]:
"""把 quote.airOptions(list 或 JSON 串)收成字典列表。"""
src = dict(quote or {})
raw = src.get("airOptions") or src.get("air_options")
if isinstance(raw, str) and raw.strip():
try:
raw = json.loads(raw)
except json.JSONDecodeError:
logger.warning("air.routes airOptions 不是 JSON")
return []
if not isinstance(raw, list):
return []
out: list[dict[str, Any]] = []
for item in raw:
if isinstance(item, dict):
out.append(dict(item))
return out
def needs_route_pick(quote: dict[str, Any] | None) -> bool:
"""多于一条线路才进 H5 选。"""
return len(parse_air_options(quote)) > 1
def _place_name(code: str, fallback: str = "") -> str:
"""三字码能对上就用中文,否则用原文或 fallback。"""
raw = (code or "").strip()
if not raw:
return (fallback or "").strip()
mapped = _CODE_NAME.get(raw.upper())
if mapped:
return mapped
if resolve_airport_code(raw):
return _CODE_NAME.get(resolve_airport_code(raw), raw)
return raw
def route_line(option: dict[str, Any] | None, facts: dict[str, str] | None = None) -> str:
"""
销售侧航线:有中转写成 起运-中转-目的。
对不上字典就展示三字码,禁止编城市。
"""
src = dict(option or {})
facts = facts or {}
origin = _place_name(
str(src.get("originAirportCode") or src.get("origin") or ""),
str(facts.get("起运港") or ""),
)
dest = _place_name(
str(src.get("destinationAirportCode") or src.get("destination") or ""),
str(facts.get("目的港") or ""),
)
transfer = _place_name(str(src.get("transferAirportCode") or src.get("transfer") or ""))
bits = [x for x in (origin, transfer, dest) if x]
return "-".join(bits)
def _iata_code(raw: str, fallback: str = "") -> str:
"""只要三字码。已是代码就用;中文能投影就投影。禁止编假码。"""
text = (raw or "").strip()
if len(text) == 3 and text.isalpha():
return text.upper()
mapped = resolve_airport_code(text) or resolve_airport_code(fallback)
return (mapped or "").upper()
def route_codes(
option: dict[str, Any] | None,
facts: dict[str, str] | None = None,
quote: dict[str, Any] | None = None,
) -> str:
"""
价格明细/方案卡用的航线三字码:有中转写成 ZUH-CAN-CRK。
对不上就不写假中转。
"""
src = dict(option or {})
if not src:
q = dict(quote or {})
src = dict(q.get("selected_air_option") or {})
if not src:
opts = parse_air_options(q)
src = dict(opts[0]) if opts else {}
facts = facts or {}
origin = _iata_code(
str(src.get("originAirportCode") or src.get("origin") or facts.get("originCode") or ""),
str(facts.get("起运港") or ""),
)
dest = _iata_code(
str(src.get("destinationAirportCode") or src.get("destination") or facts.get("destinationCode") or ""),
str(facts.get("目的港") or ""),
)
transfer = _iata_code(
str(
src.get("transferAirportCode")
or src.get("transfer")
or (quote or {}).get("transferAirportCode")
or ""
)
)
bits = [x for x in (origin, transfer, dest) if x]
return "-".join(bits)
def scheme_route_codes(
quote: dict[str, Any] | None,
facts: dict[str, str] | None = None,
) -> str:
"""方案卡航线:空运ZUH→中转→CRK。没有代码则退回地名。"""
codes = route_codes(None, facts, quote)
if codes:
return "空运" + codes.replace("-", "→")
facts = facts or {}
origin = (facts.get("起运港") or "-").strip() or "-"
dest = (facts.get("目的港") or "-").strip() or "-"
return f"空运{origin}→{dest}"
def _fee_row(item: dict[str, Any]) -> tuple[str, str] | None:
"""一条 TMS 费用。有名称才算;金额空则用单价。禁止编费目。"""
name = str(
item.get("chargeName") or item.get("feeName") or item.get("name") or ""
).strip()
if not name:
return None
ccy = str(item.get("currency") or "").strip()
amount = item.get("amount")
if amount is None or str(amount).strip() == "":
amount = item.get("unitPrice") or item.get("unit_price")
text = str(amount or "").strip()
if ccy and text and ccy not in text:
text = f"{ccy} {text}"
return (name, text or "-")
def _rows_from_list(raw: Any) -> list[tuple[str, str]]:
"""把 TMS 费用数组收成展示行。不是列表就当没有。"""
if isinstance(raw, str) and raw.strip():
try:
raw = json.loads(raw)
except json.JSONDecodeError:
return []
if not isinstance(raw, list):
return []
out: list[tuple[str, str]] = []
for item in raw:
if isinstance(item, dict):
row = _fee_row(item)
if row:
out.append(row)
return out
def option_fee_lines(option: dict[str, Any] | None, quote: dict[str, Any] | None = None) -> list[tuple[str, str]]:
"""
该线路 TMS 回了哪些费用就列哪些。
只读本条 departureCharges / feeItems。多条线路时,整单 fee_lines 是最低价方案,
禁止套到没回费用的线路。仅一条线路时,整单 fee_lines 才算这条的明细。
"""
src = dict(option or {})
rows = _rows_from_list(src.get("departureCharges") or src.get("departure_charges"))
rows.extend(_rows_from_list(src.get("feeItems") or src.get("fee_items")))
if rows:
return rows
q = dict(quote or {})
options = parse_air_options(q)
if len(options) > 1:
return []
return _rows_from_list(q.get("fee_lines") or q.get("fee_rows") or q.get("feeItems"))
def option_total(option: dict[str, Any] | None, quote: dict[str, Any] | None = None) -> str:
"""该线路总销售价。"""
src = dict(option or {})
q = dict(quote or {})
raw = src.get("salePrice")
if raw is None or str(raw).strip() == "":
raw = q.get("total") or q.get("salePrice")
ccy = str(src.get("currency") or q.get("currency") or "").strip()
text = str(raw or "").strip() or "-"
if ccy and text != "-" and ccy not in text:
return f"{ccy} {text}"
return text
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 {})
code = str(src.get("originAirportCode") or src.get("origin") or "").strip().upper()
name = _place_name(code)
if name:
merged["起运港"] = name
if code and len(code) == 3 and code.isalpha():
merged["originCode"] = code
dest_code = str(src.get("destinationAirportCode") or "").strip().upper()
dest_name = _place_name(dest_code, str(merged.get("目的港") or ""))
if dest_name:
merged["目的港"] = dest_name
if dest_code and len(dest_code) == 3 and dest_code.isalpha():
merged["destinationCode"] = dest_code
return merged
def project_selected_quote(quote: dict[str, Any] | None, option: dict[str, Any] | None) -> dict[str, Any]:
"""把选中线路投影成价格卡用的 quote。"""
base = dict(quote or {})
src = dict(option or {})
lines = [{"name": n, "amount": a} for n, a in option_fee_lines(src, base)]
# 必须覆盖整单 fee_lines:空列表也要写回去,否则会残留别的线路空运费。
base["fee_lines"] = lines
if not lines:
base["freight"] = "-"
base["other_fee"] = "-"
total = src.get("salePrice")
if total is not None and str(total).strip() != "":
base["total"] = str(total).strip()
if src.get("currency"):
base["currency"] = src.get("currency")
if src.get("validUntil"):
base["valid_until"] = src.get("validUntil")
base["selected_air_option"] = src
base["route_line"] = route_line(src, {})
return base
+24 -2
View File
@@ -19,9 +19,8 @@ logger = logging.getLogger(__name__)
_SHANGHAI = ZoneInfo("Asia/Shanghai")
# 空运询价必填 8 项:销售侧名称。内部仍用合同键。
# 空运询价必填:起运港改为选填。销售侧名称。内部仍用合同键。
AIR_REQUIRED_FIELDS = (
("起运港", "起运地"),
("目的港", "目的地"),
("品名", "品名"),
("件数", "件数"),
@@ -30,7 +29,19 @@ AIR_REQUIRED_FIELDS = (
("包装方式", "包装类型"),
("报价日期", "报价日期"),
)
# 空运询价非必填:有值收下;贸易条款只记工单,不挡查价。
AIR_OPTIONAL_FIELDS = (
("起运港", "起运港"),
("货源地", "货源地"),
("货好时间", "货好时间"),
("提货地址", "提货地址"),
("收货地址", "收货地址"),
("贸易条款", "贸易条款"),
)
AIR_DISPLAY = {key: label for key, label in AIR_REQUIRED_FIELDS}
AIR_DISPLAY.update({key: label for key, label in AIR_OPTIONAL_FIELDS})
# 补问回显仍用「起运地」,与历史卡片一致。
AIR_DISPLAY["起运港"] = "起运地"
# 海运询价必填 10 项(销售侧含运输方式):内部键「货量」对外叫「货物数量」。
SEA_REQUIRED_FIELDS = (
@@ -193,6 +204,17 @@ _ALIASES = {
"是否为危险品": "是否为危险品",
"通关口岸": "通关口岸",
"通关口岸(非必填)": "通关口岸",
"货源地": "货源地",
"货好时间": "货好时间",
"提货地址": "提货地址",
"收货地址": "收货地址",
"贸易条款": "贸易条款",
"货源地(非必填)": "货源地",
"货好时间(非必填)": "货好时间",
"提货地址(非必填)": "提货地址",
"收货地址(非必填)": "收货地址",
"贸易条款(非必填)": "贸易条款",
"起运港(非必填)": "起运港",
}
+31 -7
View File
@@ -2,6 +2,8 @@
空运查价参数:对照 TMS V2 /quote/v2/query 是否带全。
本文件职责:询价卡片出现前,确认销售侧空运必填能拼出查价正文。
起运港非必填。货源地/货好时间/提货地址/收货地址有值才带出站键。
贸易条款只留中文键给工单,禁止投影成 transportTerms。
三字码能投影就带;对不上不拦建单、不拦查价。
包装:销售原文非空即收下,禁止白名单拦补问。
禁止:猜没写过的港口;因「对不上字典」跳过 TMS 或对销售说教;改六态。
@@ -35,8 +37,7 @@ _AIRPORTS = {
"北京": "PEK",
}
# TMS air_packaging_type 实测只认 1/2/3。1=全部,这条航线无价;
# 2/3 能出与现网相同的标准价。托盘走 2,纸箱/木箱走 3。
# 历史字典:只给对照,空运出站不再编成 1/2/3。
_PACKS = {
"全部": "1",
"托盘": "2",
@@ -170,10 +171,7 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]:
missing: list[str] = []
missing_display: list[str] = []
# 销售侧必填只看 8 项是否非空。三字码是出站投影,对不上不拿来再补问起运地/目的地。
if not origin:
missing.append("起运港")
missing_display.append("起运地")
# 起运港改为非必填:空着仍可查价。三字码是出站投影,对不上不拿来再补问。
if not dest:
missing.append("目的港")
missing_display.append("目的地")
@@ -204,6 +202,8 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]:
out_facts["originCode"] = origin_code
else:
out_facts.pop("originCode", None)
else:
out_facts.pop("originCode", None)
if dest:
out_facts["目的港"] = dest
if dest_code:
@@ -220,10 +220,34 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]:
if str(src.get("体积") or "").strip():
out_facts["体积"] = str(src.get("体积")).strip()
if pack_raw:
# 包装出站跟销售原文:输入托盘就传托盘,禁止编成 2。
out_facts["包装方式"] = pack_raw
out_facts["packageType"] = pack_code
out_facts["packageType"] = pack_raw
if quote_date:
out_facts["报价日期"] = quote_date
# 非必填有值才投影出站键;贸易条款禁止写成 TMS 条款字段。
source_place = str(src.get("货源地") or "").strip()
ready = str(src.get("货好时间") or "").strip()
pickup = str(src.get("提货地址") or "").strip()
delivery = str(src.get("收货地址") or "").strip()
trade = str(src.get("贸易条款") or "").strip()
if source_place:
out_facts["货源地"] = source_place
out_facts["cargoSource"] = source_place
if ready:
out_facts["货好时间"] = ready
out_facts["cargoReadyDate"] = ready
if pickup:
out_facts["提货地址"] = pickup
out_facts["placeOfReceipt"] = pickup
if delivery:
out_facts["收货地址"] = delivery
out_facts["deliveryAddress"] = delivery
if trade:
out_facts["贸易条款"] = trade
out_facts.pop("tradeTerms", None)
out_facts.pop("transportTerms", None)
out_facts.pop("trade_terms", None)
logger.info(
"tms.air.assemble ok=%s origin=%s dest=%s pack=%s missing=%s",