落地空运单聊询价字段与线路选择。
必填核对后再建单;多条线路走 H5 点选;费用只展示该线路 TMS 回包,不套用整单空运费。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 ""),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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 {}))
|
||||
|
||||
@@ -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="询价确认")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 = {
|
||||
"是否为危险品": "是否为危险品",
|
||||
"通关口岸": "通关口岸",
|
||||
"通关口岸(非必填)": "通关口岸",
|
||||
"货源地": "货源地",
|
||||
"货好时间": "货好时间",
|
||||
"提货地址": "提货地址",
|
||||
"收货地址": "收货地址",
|
||||
"贸易条款": "贸易条款",
|
||||
"货源地(非必填)": "货源地",
|
||||
"货好时间(非必填)": "货好时间",
|
||||
"提货地址(非必填)": "提货地址",
|
||||
"收货地址(非必填)": "收货地址",
|
||||
"贸易条款(非必填)": "贸易条款",
|
||||
"起运港(非必填)": "起运港",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user