机器人显示名与站点标题可配;多段联运列表投影;协同群监听对账;工单附件落存;模板与工单后台补强

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-19 09:41:59 +08:00
co-authored by Cursor
parent 277cdc1c28
commit 2b528758b4
119 changed files with 6760 additions and 3516 deletions
+70 -6
View File
@@ -835,9 +835,16 @@ class AirTextInquiryFlow:
facts = dict(view.get("facts") or {})
quote = dict(view.get("quote") or {})
status = str(view.get("status") or "").strip()
lost_reason = str(view.get("lost_reason") or view.get("lostReason") or "").strip()
tms_quote = dict(view.get("tms_quote") or view.get("tmsQuote") or {})
phase = str(view.get("wait_phase") or "").strip() or self._infer_wait_phase(
status=status, quote=quote
status=status, quote=quote, lost_reason=lost_reason
)
# 主账询价中+舱位备选时 wait_phase 常被收成 tms_miss;续办必须回到选舱。
if self._quote_pending_cabin(quote) or self._quote_pending_cabin(tms_quote):
phase = "wait_air_route"
if not quote and tms_quote:
quote = dict(tms_quote)
allowed: tuple[str, ...] = ()
if phase == "wait_collab":
allowed = (copy.BTN_SKIP_COLLAB,)
@@ -855,6 +862,8 @@ class AirTextInquiryFlow:
allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
elif phase == "wait_lost_reason":
allowed = ()
elif phase == "wait_air_route":
allowed = ()
return FlowSession(
sender_id=sender_id,
thread_id=sender_id,
@@ -863,6 +872,7 @@ class AirTextInquiryFlow:
facts=facts,
work_order_no=no,
quote=quote,
tms_quote=tms_quote or dict(quote),
status=status,
allowed=allowed,
wait_version=1,
@@ -870,17 +880,50 @@ class AirTextInquiryFlow:
collab_chat_id=str(view.get("collab_chat_id") or ""),
)
def _infer_wait_phase(self, *, status: str, quote: dict[str, Any]) -> str:
def _infer_wait_phase(
self, *, status: str, quote: dict[str, Any], lost_reason: str = ""
) -> str:
if status in {"已成交", "已关闭"}:
return "done"
if status == "未成交":
return "wait_lost_reason"
# 原因已记则终态回放;缺原因才继续要原因。
return "wait_lost_reason" if not (lost_reason or "").strip() else "done"
if status == "协商中":
return "wait_deal"
if status == "已报价" and quote:
return "wait_adopt"
return "tms_miss"
def _lost_reason_of(self, sess: FlowSession) -> str:
"""从主账读未成交原因;续办终态回放要用,不能靠进程内字段。"""
no = (sess.work_order_no or "").strip()
if not no:
return ""
getter = getattr(self._ledger, "get_for_agent", None)
if not callable(getter):
return ""
try:
view = getter(work_order_no=no, sender_id=sess.sender_id) or {}
except Exception:
logger.warning("读未成交原因失败 wo=%s", no)
return ""
return str(view.get("lost_reason") or view.get("lostReason") or "").strip()
@staticmethod
def _quote_pending_cabin(quote: dict[str, Any] | None) -> bool:
"""报价快照是否仍停在空运选舱(pending_cabin_pick=1 或未选多舱位)。"""
src = dict(quote or {})
flag = str(src.get("pending_cabin_pick") or src.get("pendingCabinPick") or "").strip()
if flag in {"1", "true", "True", "yes"}:
return True
from agent.schema.air_route_options import parse_air_options
if src.get("selected_air_option") or src.get("selectedAirOption"):
return False
return len(parse_air_options(src)) > 0 and not (
str(src.get("total") or "").strip()
)
def _align_resume_phase(self, sess: FlowSession) -> None:
"""
报价文件已经发给销售,续办必须回到成交跟进。
@@ -971,8 +1014,23 @@ class AirTextInquiryFlow:
if sess.phase == "wait_lost_reason":
reply(copy.deal_ack("未成交", sess.work_order_no))
return "wait_lost_reason"
if sess.phase == "wait_air_route":
quote = dict(sess.tms_quote or sess.quote or {})
return self._offer_air_routes(
sess=sess,
work_order_no=sess.work_order_no,
quote=quote,
reply=reply,
cabin_pick=self._quote_pending_cabin(quote),
)
if sess.phase == "done":
reply(copy.deal_ack(sess.status or "已关闭", sess.work_order_no))
status = (sess.status or "").strip() or "已关闭"
lost = self._lost_reason_of(sess) if status == "未成交" else ""
# 未成交且原因已记:续办只重播「原因已记、工单已关」,禁止再要原因。
if status == "未成交" and lost:
reply(copy.lost_reason_ack(lost, work_order_no=sess.work_order_no))
return "done"
reply(copy.deal_ack(status, sess.work_order_no, lost_reason=lost))
return "done"
reply(copy.inquiry_card(work_order_no=sess.work_order_no, facts=sess.facts))
return sess.phase or "replay"
@@ -1393,9 +1451,13 @@ class AirTextInquiryFlow:
)
options = parse_air_options(quote)
from agent.schema.field_validate import is_blank_quote_date
# 没填报价日期:不按起运地收口,TMS 返回的舱位全部给销售点。1 条也要点。
# 未选定前六态保持询价中,禁止把备选舱位费写进后台「已报价」。
if not str((sess.facts or {}).get("报价日期") or "").strip() and options:
# 「(非必填):」占位文案也当没填,必须进选舱。
if is_blank_quote_date((sess.facts or {}).get("报价日期")) and options:
quote = dict(quote)
quote["airOptions"] = sort_cabin_options(options)
ledger_quote = _ledger_quote_pending_cabin(quote)
@@ -1894,6 +1956,7 @@ class AirTextInquiryFlow:
if not callable(renderer):
reply(copy.quote_template_missing())
return "wait_adopt"
# 私聊采用出单:填好的报价单要进后台附件;激活群发模版另走 persist=False。
result = renderer(
work_order_no=sess.work_order_no,
business_line=sess.business_line or "AIR",
@@ -1901,6 +1964,7 @@ class AirTextInquiryFlow:
quote=dict(sess.quote or {}),
format=fmt,
sales_wecom_id=sess.sender_id,
persist_attachment=True,
) or {}
if not result.get("ok"):
reply(str(result.get("message") or copy.quote_template_missing()))
@@ -2298,7 +2362,7 @@ _FLOW_GUARD = threading.Lock()
def build_ledger() -> Any:
"""生产打 8180;单测 LEDGER_BACKEND=memory。"""
"""生产打 8080;单测 LEDGER_BACKEND=memory。"""
from agent.config import get_settings
backend = (get_settings().ledger_backend or "http").strip().lower()