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

必填核对后再建单;多条线路走 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
+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}