Files
inquiry_robot/inquiry-agent/agent/policy/air_text_flow.py
T
jillion886andCursor d9029dec89 落地空运群内协同与锁舱出站。
群里按 BOT 通道激活当前工单、航线报价和成交跟进;没有 TMS 报价编号时用工单字段补齐仍调锁舱,避免被本地规则拦住。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 17:25:42 +08:00

1061 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
空运文字询价确定性流程(模型只负责抽字段,本文件只编排与执行)。
本文件职责:补问到询价必填齐全 → 建单出卡片 → 必须打主账查 TMS(有价/暂无报价)。
对不上机场也不能跳过查价、本地说暂无报价。
调用:handler 在 Worker/inbox 线程;禁止回调线程同步跑。
禁止:直连 TMS;用图状态冒充主账成功;用户文字冒充文件已生成。
"""
from __future__ import annotations
import logging
import threading
import uuid
from dataclasses import dataclass, field
from typing import Any, Callable, Optional
from agent.ledger.http_ledger import HttpLedger
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.tms_air_query import assemble_air_query
logger = logging.getLogger(__name__)
# 可返回 outbox id;查价前要等询价确认真正发出。
ReplyFn = Callable[..., Optional[str]]
@dataclass
class FlowSession:
"""一单会话书签(运行账,不是六态真相)。"""
sender_id: str
thread_id: str
wait_version: int = 0
phase: str = "idle"
business_line: str = ""
facts: dict[str, str] = field(default_factory=dict)
work_order_no: str = ""
first_or_same: str = "first"
history_work_order_no: str = ""
quote: dict[str, Any] = field(default_factory=dict)
status: str = ""
allowed: tuple[str, ...] = ()
immutable_text: str = ""
collab_chat_id: str = ""
collab_facts: dict[str, str] = field(default_factory=dict)
invited_userids: tuple[str, ...] = ()
invited_sales_name: str = ""
invited_product_names: tuple[str, ...] = ()
deal_version: int = 0
collab_ended: bool = False
pending_product_quote: dict[str, Any] = field(default_factory=dict)
class AirTextInquiryFlow:
"""
空运文字询价 Owner。海运走 SeaTextInquiryFlow,不要再往本文件堆海运。陆运仍补问二级。
同一 sender 新开询价会覆盖「当前书签」(旧单留在主账)。
价格卡按工单号另存一份当时会话:点哪张卡就生成哪张卡当时的报价单。
"""
def __init__(self, ledger: Any = None, group_client: Any = None) -> None:
self._ledger = ledger or build_ledger()
self._lock = threading.Lock()
self._by_sender: dict[str, FlowSession] = {}
self._by_ticket: dict[str, FlowSession] = {}
self._group_client = group_client
@property
def ledger(self) -> Any:
return self._ledger
@ledger.setter
def ledger(self, value: Any) -> None:
self._ledger = value
def group_client(self) -> Any:
"""空运群 BOT 出站;单测注入假客户端。"""
return self._group_client
def session_of(self, sender_id: str) -> Optional[FlowSession]:
with self._lock:
return self._by_sender.get(sender_id)
def session_by_work_order(self, work_order_no: str) -> Optional[FlowSession]:
"""用工单号找回书签,不校验 sender。群成交卡校验用。"""
no = (work_order_no or "").strip()
if not no:
return None
with self._lock:
return self._by_ticket.get(no)
def session_by_ticket(self, sender_id: str, work_order_no: str) -> Optional[FlowSession]:
"""
只查进程内该书签,不读主账。
续办选空运/海运流程用:海运已跳过协同时必须找回 wait_adopt,
不能因为主账把已报价海运一律收成 wait_collab 就套空运协同卡。
"""
no = (work_order_no or "").strip().upper()
if not (sender_id or "").strip() or not no:
return None
with self._lock:
hit = self._by_ticket.get(no)
if hit and hit.sender_id == sender_id:
return hit
for key, sess in self._by_ticket.items():
if (key or "").upper() == no and sess.sender_id == sender_id:
return sess
return None
def session_for_action(
self,
sender_id: str,
card_meta: Optional[dict[str, Any]] = None,
*,
text: str = "",
) -> Optional[FlowSession]:
"""
点卡:用工单号找回出卡当时的会话;对不上才用该销售当前书签。
必须校验 sender_id,禁止用别人的工单号串会话。
"""
no = copy.work_order_from_card_meta(card_meta, text=text)
with self._lock:
if no:
hit = self._by_ticket.get(no)
if hit and hit.sender_id == sender_id:
return hit
elif not no:
return self._by_sender.get(sender_id)
if no:
# 卡上有工单号就只动这一单;进程刚重启时从主账收回,禁止落到别人/最新单。
return self._load_ticket_session(sender_id, no)
return None
def _save(self, sess: FlowSession) -> None:
with self._lock:
self._by_sender[sess.sender_id] = sess
no = (sess.work_order_no or "").strip()
if no:
self._by_ticket[no] = sess
def _load_ticket_session(self, sender_id: str, work_order_no: str) -> Optional[FlowSession]:
"""
点卡时进程内没有该书签:只读主账收回这一单。
找不到或不是本人:返回 None,调用方丢弃,绝不改成当前最新单。
"""
getter = getattr(self._ledger, "get_for_agent", None)
if not callable(getter):
logger.warning("card 点到 %s 但无主账可读", work_order_no)
return None
view = getter(work_order_no=work_order_no, sender_id=sender_id) or {}
if not view.get("found") or view.get("owned") is False:
logger.warning(
"card 点到 %s 主账未收回 found=%s owned=%s",
work_order_no,
view.get("found"),
view.get("owned"),
)
return None
sess = self._session_from_ledger(sender_id=sender_id, view=view)
self._save(sess)
logger.info(
"card 从主账收回 wo=%s phase=%s",
sess.work_order_no,
sess.phase,
)
return sess
def resume_historical(
self,
*,
sender_id: str,
work_order_no: str,
reply: ReplyFn,
) -> str:
"""
按工单号回到当时停点并重出对应卡片。
先用进程内该书签;没有再读主账推断节点。不重跑 TMS、不新建工单。
"""
no = (work_order_no or "").strip().upper()
if not no:
reply("请告诉我要继续的工单号,例如 WO202609140034。")
return "need_work_order"
with self._lock:
sess = self._by_ticket.get(no)
if sess and sess.sender_id == sender_id:
keep = sess
else:
keep = None
if keep:
self._align_resume_phase(keep)
return self._replay_wait(keep, reply, source="session")
getter = getattr(self._ledger, "get_for_agent", None)
if not callable(getter):
reply(f"找不到工单 {no},请核对工单号后再试。")
return "ticket_missing"
view = getter(work_order_no=no, sender_id=sender_id) or {}
if not view.get("found"):
reply(f"找不到工单 {no},请核对工单号后再试。")
return "ticket_missing"
if view.get("owned") is False:
reply("这张工单不属于当前账号,不能继续。")
return "ticket_forbidden"
sess = self._session_from_ledger(sender_id=sender_id, view=view)
self._align_resume_phase(sess)
self._save(sess)
return self._replay_wait(sess, reply, source="ledger")
def _session_from_ledger(self, *, sender_id: str, view: dict[str, Any]) -> FlowSession:
"""主账快照收成运行书签。wait_phase 由主账推断,不是六态本身。"""
no = str(view.get("work_order_no") or "").strip()
facts = dict(view.get("facts") or {})
quote = dict(view.get("quote") or {})
status = str(view.get("status") or "").strip()
phase = str(view.get("wait_phase") or "").strip() or self._infer_wait_phase(
status=status, quote=quote
)
allowed: tuple[str, ...] = ()
if phase == "wait_collab":
allowed = (copy.BTN_SKIP_COLLAB,)
elif phase == "wait_adopt":
allowed = (copy.BTN_ADOPT_EXCEL, copy.BTN_ADOPT_PDF, copy.BTN_REJECT)
elif phase == "wait_deal":
allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
return FlowSession(
sender_id=sender_id,
thread_id=sender_id,
phase=phase,
business_line=str(view.get("business_line") or "AIR"),
facts=facts,
work_order_no=no,
quote=quote,
status=status,
allowed=allowed,
wait_version=1,
)
def _infer_wait_phase(self, *, status: str, quote: dict[str, Any]) -> str:
if status in {"已成交", "未成交", "协商中", "已关闭"}:
return "done"
if status == "已报价" and quote:
return "wait_adopt"
return "tms_miss"
def _align_resume_phase(self, sess: FlowSession) -> None:
"""
报价文件已经发给销售,续办必须回到成交跟进。
主账六态仍是「已报价」,不能据此重放到协同卡或生成报价单。
Redis 就绪键由出文件时写入;主账 wait_phase=wait_deal 也认。
"""
if sess.phase not in {"wait_collab", "wait_adopt", "wait_file"}:
return
issued = bool((sess.quote or {}).get("quoteFileIssued"))
if issued or self._quote_file_already_out(sess.work_order_no):
logger.info("resume 报价文件已出,回到成交跟进 wo=%s was=%s", sess.work_order_no, sess.phase)
self._promote_wait_deal(sess)
def _replay_wait(self, sess: FlowSession, reply: ReplyFn, *, source: str) -> str:
"""按书签重出当时节点,不改六态。"""
logger.info(
"resume replay wo=%s phase=%s source=%s",
sess.work_order_no,
sess.phase,
source,
)
if sess.phase == "wait_adopt":
mode = copy.scheme_transport_mode(sess.business_line)
reply(
copy.scheme_card(
work_order_no=sess.work_order_no,
status=sess.status or "已报价",
facts=sess.facts,
quote=sess.quote,
transport_mode=mode,
),
copy.scheme_wecom_payload(
work_order_no=sess.work_order_no,
facts=sess.facts,
quote=sess.quote,
status=sess.status or "已报价",
transport_mode=mode,
),
)
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 "已报价",
),
)
return "wait_collab"
if sess.phase == "tms_miss":
self._emit_tms_miss(reply, sess.work_order_no)
return "tms_miss"
if sess.phase == "wait_file":
self._refresh_wait_file(sess)
if sess.phase == "wait_file":
reply("报价单正在生成,请稍候。")
return "wait_file"
if sess.phase == "wait_deal":
# 成交卡已经绑在报价文件那条出站上。这里再发一张独立卡,
# 会在文件还在上传时被另一条出站线程抢走,销售又先看到成交跟进。
return "wait_deal"
if sess.phase == "done":
reply(copy.deal_ack(sess.status or "已关闭", sess.work_order_no))
return "done"
reply(copy.inquiry_card(work_order_no=sess.work_order_no, facts=sess.facts))
return sess.phase or "replay"
def match_button(self, text: str, sess: Optional[FlowSession]) -> str:
"""精确匹配当前允许的按钮文案 / 企微 EventKey;不是按钮返回空。"""
raw = (text or "").strip()
if not sess or not raw:
return ""
canonical = copy.canonical_button(raw)
if canonical and canonical in sess.allowed:
return canonical
for btn in sess.allowed:
if raw == btn or raw == f"【{btn}】":
return btn
return ""
def on_text(
self,
*,
sender_id: str,
text: str,
reply: ReplyFn,
injected_facts: Optional[dict[str, Any]] = None,
injected_mode: str = "",
b_result: Optional[dict[str, Any]] = None,
allow_b: bool = False,
chat_fn: Optional[Any] = None,
inbound_raw: Optional[dict[str, Any]] = None,
) -> str:
"""
处理一句私聊文字。返回阶段名供单测断言。
先认按钮(系统交互),再抽字段(模型/注入/B)。
点价格卡必须按卡片工单号找回当时会话,不能默认最新一单。
"""
if not (sender_id or "").strip():
return "reject_no_sender"
sess = self.session_for_action(sender_id, inbound_raw, text=text)
btn = self.match_button(text, sess)
if btn:
return self.on_button(
sender_id=sender_id,
action=btn,
reply=reply,
card_meta=inbound_raw,
)
snap = extract_inquiry_snapshot(
text,
injected_facts=injected_facts,
injected_mode=injected_mode,
b_result=b_result,
allow_b=allow_b,
chat_fn=chat_fn,
)
mode = (snap.get("business_line") or "").upper()
facts = dict(snap.get("facts") or {})
if sess and sess.phase in {"clarify", "need_mode"}:
# 补问轮:旧字段打底;运输方式沿用上一句(飞=空运)
from agent.llm.extract_text import detect_transport_mode
from agent.schema.field_validate import harvest_oral_measures
merged = dict(sess.facts)
merged.update(facts)
facts = harvest_oral_measures(text, merged)
mode = mode or (sess.business_line or "").upper()
if not mode:
mode = detect_transport_mode(
f"{sess.immutable_text or ''}\n{text}"
)
if not mode:
reply(copy.ASK_TRANSPORT)
self._save(
FlowSession(
sender_id=sender_id,
thread_id=sender_id,
phase="need_mode",
business_line="",
facts=facts,
immutable_text=sess.immutable_text if sess and sess.immutable_text else text,
)
)
return "need_mode"
if mode != "AIR":
if mode == "LAND":
reply(copy.ASK_LAND_SUBTYPE)
return "need_land_subtype"
if mode == "SEA":
# 海运交给海运 Owner,空运不再回「本轮先开通空运」
from agent.policy.sea_text_flow import get_sea_text_flow
return get_sea_text_flow().on_text(
sender_id=sender_id,
text=text,
reply=reply,
injected_facts=facts,
injected_mode=mode,
b_result=b_result,
allow_b=allow_b,
chat_fn=chat_fn,
inbound_raw=inbound_raw,
)
reply("请补充运输方式:空运、海运或陆运。")
return "need_mode"
check = validate_required_fields(facts=facts, business_line="AIR")
facts = dict(check["facts"])
tms_ready = assemble_air_query(facts)
facts = dict(tms_ready["facts"])
if not check["ok"] or not tms_ready["ok"]:
# 询价必填或 TMS 查价参数不齐:回显已识别 + 待补充示例,不出询价确认
keys = list(check["missing"] or [])
for key in tms_ready.get("missing") or []:
if key not in keys:
keys.append(key)
reply(
copy.ask_clarify(
facts=facts,
missing_keys=keys,
transport_mode="空运",
)
)
self._save(
FlowSession(
sender_id=sender_id,
thread_id=sender_id,
phase="clarify",
business_line="AIR",
facts=facts,
immutable_text=sess.immutable_text if sess and sess.immutable_text else text,
wait_version=(sess.wait_version + 1) if sess else 1,
)
)
return "clarify"
return self._create_and_quote(
sender_id=sender_id,
facts=facts,
text=text,
reply=reply,
)
def _create_and_quote(
self,
*,
sender_id: str,
facts: dict[str, str],
text: str,
reply: ReplyFn,
) -> str:
# 先落已确认字段,避免建单失败后补问轮把毛重/体积弄丢
self._save(
FlowSession(
sender_id=sender_id,
thread_id=sender_id,
phase="quoting",
business_line="AIR",
facts=facts,
immutable_text=text,
)
)
created = self._ledger.create_ticket(
sender_id=sender_id,
business_line="AIR",
facts=facts,
# 每次出询价确认都新开一单;不用内容指纹,避免相同需求复用旧工单号
idempotency_key=f"create:{sender_id}:{uuid.uuid4().hex}",
)
no = str(created.get("work_order_no") or "").strip()
if not no:
reply("建单失败,请稍后把刚才那条再发一次。重量和体积已经记下。")
return "ledger_fail"
first, history_wo = self._resolve_similar(created, facts=facts, current_no=no)
out_id = reply(copy.inquiry_card(work_order_no=no, facts=facts, first_or_same=first)) or ""
# 询价确认必须先到销售手机,再打 TMS。否则查得快时暂无报价卡会抢先发出。
self._wait_inquiry_sent(str(out_id), work_order_no=no)
tms = self._ledger.query_tms(work_order_no=no, facts=facts)
sess = FlowSession(
sender_id=sender_id,
thread_id=sender_id,
business_line="AIR",
facts=facts,
work_order_no=no,
first_or_same=first,
history_work_order_no=history_wo,
immutable_text=text,
wait_version=1,
)
if not tms.get("ok"):
# 没拿到 TMS 回包(超时/熔断/主账未出站)不能冒充「暂无报价」
if tms.get("classification") == "TMS_QUERY_INCOMPLETE":
logger.warning("tms.query 未出站 wo=%s class=%s", no, tms.get("classification"))
reply("TMS 查询失败,请稍后重试。工单已创建:" + no)
sess.phase = "tms_tech"
self._save(sess)
return "tms_tech"
if not tms.get("has_price"):
# ok=true 才是 TMS 已查过、确认没价
self._emit_tms_miss(reply, no)
sess.phase = "tms_miss"
sess.status = "询价中"
sess.allowed = ()
self._save(sess)
return "tms_miss"
quote = tms["quote"]
up = self._ledger.upsert_quote(work_order_no=no, quote=quote, to_status="已报价")
if not up.get("ok"):
reply("主账写入报价失败,不能当作已报价。")
sess.phase = "ledger_fail"
self._save(sess)
return "ledger_fail"
sess.quote = quote
sess.status = "已报价"
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="已报价",
),
)
return "wait_collab"
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="询价确认")
def _wait_outbound_sent(self, out_id: str, *, work_order_no: str, what: str) -> None:
"""
等指定出站真正发出。只等这一条,不锁其它工单。
单测 reply 不回 id 则跳过。超时仍继续,避免卡死 inbox。
"""
raw = (out_id or "").strip()
if not raw:
return
from agent.channel.queue import get_message_store
store = get_message_store()
waiter = getattr(store, "wait_outbound_done", None)
if not callable(waiter):
return
ok = waiter(raw, timeout_sec=30.0)
if not ok:
logger.warning(
"%s出站未在时限内完成 wo=%s out=%s,仍继续",
what,
work_order_no,
raw,
)
def _emit_tms_miss(self, reply: ReplyFn, work_order_no: str) -> None:
"""
TMS 已查过且确认没价:文字兜底 + 暂无报价卡片。
禁止在没打 query_tms 前回这个。
"""
from agent.config import get_settings
reply(
copy.tms_miss(work_order_no=work_order_no),
copy.tms_miss_wecom_payload(
work_order_no=work_order_no,
action_url=(get_settings().public_base_url or "").strip(),
),
)
# 卡片发出后再单独发拉群句,不塞进卡、不当卡片失败兜底。
reply(copy.tms_miss_followup(work_order_no=work_order_no))
def _resolve_similar(
self,
created: dict[str, Any],
*,
facts: dict[str, str],
current_no: str,
) -> tuple[str, str]:
"""
全库相同需求:建单结果已带则用;否则再问主账。检索失败当首次,不影响出卡。
"""
history = str(created.get("similar_work_order_no") or "").strip()
first = str(created.get("first_or_same") or "first")
if first == "same" and history and history != current_no:
return "same", history
finder = getattr(self._ledger, "find_similar_open", None)
if not callable(finder):
return "first", ""
try:
similar = finder(
facts=facts, business_line="AIR", exclude_work_order_no=current_no
) or {}
except TypeError:
similar = finder(facts=facts, business_line="AIR") or {}
except Exception:
logger.warning("find_similar 失败,按首次询价展示 current=%s", current_no)
return "first", ""
history = str(similar.get("work_order_no") or "").strip()
if similar.get("found") and history and history != current_no:
return "same", history
return "first", ""
def on_button(
self,
*,
sender_id: str,
action: str,
reply: ReplyFn,
card_meta: Optional[dict[str, Any]] = None,
) -> str:
"""处理已允许的按钮;错版本/未允许/已点过直接丢弃。"""
sess = self.session_for_action(sender_id, card_meta)
card_wo = copy.work_order_from_card_meta(card_meta)
if sess and sess.phase == "wait_file":
self._refresh_wait_file(sess)
# 成交卡已经发到销售手机,HTTP 进程可能还停在 wait_file,不能丢弃
if (
sess
and action in {copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE}
and sess.phase == "wait_file"
):
self._promote_wait_deal(sess)
if not sess or action not in sess.allowed:
logger.info(
"card 丢弃 sender=%s action=%s card_wo=%s sess_wo=%s phase=%s",
sender_id,
action,
card_wo or "-",
(sess.work_order_no if sess else "-"),
(sess.phase if sess else "-"),
)
return "card_discard"
if card_wo and sess.work_order_no and card_wo != sess.work_order_no:
logger.warning(
"card 工单不一致 card_wo=%s sess_wo=%s,拒绝串单",
card_wo,
sess.work_order_no,
)
return "card_discard"
if action == copy.BTN_SKIP_COLLAB:
# 先摘掉按钮,连点第二次不再进生成
sess.allowed = tuple(x for x in sess.allowed if x != copy.BTN_SKIP_COLLAB)
self._save(sess)
return self._skip_collab(sess, reply, card_meta=card_meta)
if action == copy.BTN_REJECT:
self._grey_clicked_card(reply, card_meta)
sess.phase = "wait_adjust"
sess.allowed = ()
sess.wait_version += 1
self._save(sess)
reply(copy.reject_ack())
return "wait_adjust"
if action == copy.BTN_ADOPT_EXCEL:
return self._adopt_file(sess, reply, kind="Excel", card_meta=card_meta)
if action == copy.BTN_ADOPT_PDF:
return self._adopt_file(sess, reply, kind="PDF", card_meta=card_meta)
if action == copy.BTN_DEAL:
return self._close(sess, reply, "已成交", card_meta=card_meta)
if action == copy.BTN_LOST:
return self._close(sess, reply, "未成交", card_meta=card_meta)
if action == copy.BTN_NEGOTIATE:
return self._close(sess, reply, "协商中", card_meta=card_meta)
return "card_unknown"
def _skip_collab(
self,
sess: FlowSession,
reply: ReplyFn,
card_meta: Optional[dict[str, Any]] = None,
) -> str:
code = str((card_meta or {}).get("response_code") or "").strip()
if code:
reply(
copy.BTN_SKIP_COLLAB_DONE,
copy.skip_collab_card_update(response_code=code),
)
reply(copy.skip_collab_ack())
sess.phase = "wait_adopt"
sess.allowed = (copy.BTN_ADOPT_EXCEL, copy.BTN_ADOPT_PDF, copy.BTN_REJECT)
sess.wait_version += 1
self._save(sess)
mode = copy.scheme_transport_mode(sess.business_line)
reply(
copy.scheme_card(
work_order_no=sess.work_order_no,
status=sess.status or "已报价",
facts=sess.facts,
quote=sess.quote,
transport_mode=mode,
),
copy.scheme_wecom_payload(
work_order_no=sess.work_order_no,
facts=sess.facts,
quote=sess.quote,
status=sess.status or "已报价",
transport_mode=mode,
),
)
return "wait_adopt"
def _grey_clicked_card(
self,
reply: ReplyFn,
card_meta: Optional[dict[str, Any]],
*,
replace_name: str = "",
) -> None:
"""
点过模板卡后整卡置灰,三个按钮都不可再点。
企微 update_template_card 一次更新后整卡按钮失效;无 ResponseCode 则不更新。
"""
code = str((card_meta or {}).get("response_code") or "").strip()
if not code:
return
reply(
copy.BTN_SKIP_COLLAB_DONE,
copy.skip_collab_card_update(response_code=code, replace_name=replace_name),
)
def _adopt_file(
self,
sess: FlowSession,
reply: ReplyFn,
*,
kind: str,
card_meta: Optional[dict[str, Any]] = None,
) -> str:
"""
点生成 Excel/PDF:按询价需求匹配后台报价模板再出单。
关键词未命中走默认模板;双无则只回话术,不进成交卡。
Excel 当场发文件;PDF 主账只填 xlsx,转 PDF 进 LibreOffice 单槽。
"""
self._grey_clicked_card(reply, card_meta)
if kind == "Excel":
reply(copy.adopt_excel_ack())
else:
reply(copy.adopt_pdf_ack())
fmt = "xlsx" if kind == "Excel" else "pdf"
renderer = getattr(self._ledger, "render_quote", None)
if not callable(renderer):
reply(copy.quote_template_missing())
return "wait_adopt"
result = renderer(
work_order_no=sess.work_order_no,
business_line=sess.business_line or "AIR",
facts=dict(sess.facts or {}),
quote=dict(sess.quote or {}),
format=fmt,
sales_wecom_id=sess.sender_id,
) or {}
if not result.get("ok"):
reply(str(result.get("message") or copy.quote_template_missing()))
return "wait_adopt"
filename = str(result.get("fileName") or f"{sess.work_order_no}_quote.{fmt}")
file_extra = self._quote_file_payload(result, filename=filename)
if kind == "PDF" and result.get("needsPdf"):
queued = self._enqueue_quote_pdf(
result,
work_order_no=sess.work_order_no,
sender_id=sess.sender_id,
filename=filename if filename.lower().endswith(".pdf") else f"{sess.work_order_no}_quote.pdf",
quote=dict(sess.quote or {}),
facts=dict(sess.facts or {}),
)
if not queued:
fallback = str(result.get("fileName") or f"{sess.work_order_no}_quote.xlsx")
if not fallback.lower().endswith(".xlsx"):
fallback = f"{sess.work_order_no}_quote.xlsx"
reply("PDF 转换排队失败,已按后台模板发送 Excel 报价单。")
return self._emit_file_then_deal(
sess,
reply,
kind="Excel",
filename=fallback,
file_extra=file_extra,
)
# 已入队:等 Worker 出文件后再出成交卡,这里不提前发卡
sess.phase = "wait_file"
sess.allowed = ()
sess.wait_version += 1
self._save(sess)
return "wait_file"
return self._emit_file_then_deal(
sess, reply, kind=kind, filename=filename, file_extra=file_extra
)
def _emit_file_then_deal(
self,
sess: FlowSession,
reply: ReplyFn,
*,
kind: str,
filename: str,
file_extra: dict[str, Any],
) -> str:
"""
报价文件与成交跟进走同一条出站:先传文件,成功后再发卡。
两张消息分两条出站时,另一条消费线程会在上传期间把成交卡发出去。
单测 reply 不回 id 则不等待。
"""
extra = dict(file_extra or {})
extra["followup_card"] = copy.deal_wecom_payload(
work_order_no=sess.work_order_no,
quote=sess.quote,
facts=sess.facts,
).get("template_card") or {}
reply(
"\n".join(
[
copy.file_ready(
kind=kind, work_order_no=sess.work_order_no, filename=filename
),
copy.deal_card(
work_order_no=sess.work_order_no,
status=sess.status or "已报价",
quote=sess.quote,
facts=sess.facts,
),
]
),
extra,
)
from agent.redis_coord.quote_file import mark_quote_file_ready
mark_quote_file_ready(sess.work_order_no)
sess.phase = "wait_deal"
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
sess.wait_version += 1
self._save(sess)
return "wait_deal"
def _emit_deal(self, reply: ReplyFn, sess: FlowSession) -> None:
"""
报价单发出后出成交跟进卡:文字兜底 + 企微模板卡。
字段以报价当时的 quote 为准,不编历史报价/工单状态/来源行。
"""
reply(
copy.deal_card(
work_order_no=sess.work_order_no,
status=sess.status or "已报价",
quote=sess.quote,
facts=sess.facts,
),
copy.deal_wecom_payload(
work_order_no=sess.work_order_no,
quote=sess.quote,
facts=sess.facts,
),
)
def _quote_file_payload(self, result: dict[str, Any], *, filename: str) -> dict[str, Any]:
"""出站文件载荷:优先本机路径,否则主账回的 Base64。"""
return {
"msgtype": "file",
"filename": filename,
"filepath": str(result.get("filePath") or ""),
"file_b64": str(result.get("fileBase64") or ""),
}
def _enqueue_quote_pdf(
self,
result: dict[str, Any],
*,
work_order_no: str,
sender_id: str,
filename: str,
quote: Optional[dict[str, Any]] = None,
facts: Optional[dict[str, str]] = None,
) -> bool:
"""
把主账已填好的 xlsx 交给 Worker LibreOffice 单槽转 PDF。
失败返回 False,调用方改发 Excel,不在 inbox 线程跑 soffice。
"""
raw_b64 = str(result.get("fileBase64") or "").strip()
if not raw_b64:
return False
try:
import base64
import tempfile
from pathlib import Path
from agent.jobs.pdf_job import enqueue_pdf_job
data = base64.b64decode(raw_b64)
work_dir = Path(tempfile.gettempdir()) / "inquiry_quote"
work_dir.mkdir(parents=True, exist_ok=True)
xlsx_path = work_dir / f"{work_order_no}_quote.xlsx"
xlsx_path.write_bytes(data)
queued = enqueue_pdf_job(
xlsx_path=str(xlsx_path),
inquiry_no=work_order_no,
quote_version=1,
idempotency_key=f"pdf:{work_order_no}:{uuid.uuid4().hex[:8]}",
touser=sender_id,
filename=filename,
quote=quote,
facts=facts,
)
return bool(queued.accepted)
except Exception:
logger.exception("报价 PDF 入队失败 wo=%s", work_order_no)
return False
def _close(
self,
sess: FlowSession,
reply: ReplyFn,
status: str,
card_meta: Optional[dict[str, Any]] = None,
) -> str:
"""点成交/未成交/协商中:先整卡置灰,再入主账。"""
self._grey_clicked_card(reply, card_meta, replace_name=status)
result = self._ledger.transition(work_order_no=sess.work_order_no, to_status=status)
if not result.get("ok"):
reply("主账状态更新失败,成交结果未入账。")
return "ledger_fail"
sess.status = status
sess.phase = "done"
sess.allowed = ()
self._save(sess)
reply(copy.deal_ack(status, sess.work_order_no))
return "done"
def mark_wait_deal(self, sender_id: str, work_order_no: str) -> None:
"""
Worker 出完报价文件后:先写 Redis,再切本进程书签。
HTTP 与 Worker 不是同一份内存。只改本进程会让销售一直看到「请稍候」。
找不到本进程书签也要写 Redis,让收消息那边能切到成交跟进。
"""
no = (work_order_no or "").strip()
if not no:
return
from agent.redis_coord.quote_file import mark_quote_file_ready
mark_quote_file_ready(no)
if not sender_id:
return
with self._lock:
sess = self._by_sender.get(sender_id)
if sess is None or sess.work_order_no != no:
sess = self._by_ticket.get(no)
if sess is None:
return
self._promote_wait_deal(sess)
def _promote_wait_deal(self, sess: FlowSession) -> None:
"""书签切到成交跟进,允许点成交/未成交/协商中。"""
sess.phase = "wait_deal"
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
sess.wait_version += 1
self._save(sess)
def _refresh_wait_file(self, sess: FlowSession) -> None:
"""
wait_file 时看文件是否已经发出。
Worker 写 Redis;本进程重启后也可看出站成交卡/文件去重键。
"""
if sess.phase != "wait_file":
return
no = (sess.work_order_no or "").strip()
if not no:
return
if self._quote_file_already_out(no):
self._promote_wait_deal(sess)
def _quote_file_already_out(self, work_order_no: str) -> bool:
"""文件或成交卡是否已经入过出站。"""
from agent.redis_coord.quote_file import is_quote_file_ready
if is_quote_file_ready(work_order_no):
return True
try:
from agent.channel.queue import get_message_store
store = get_message_store()
probe = getattr(store, "has_outbound_dedupe", None)
if not callable(probe):
return False
return bool(
probe(f"deal-card:{work_order_no}:") or probe(f"pdf-file:{work_order_no}:")
)
except Exception:
logger.exception("判断报价文件是否已出站失败 wo=%s", work_order_no)
return False
_FLOW: Optional[AirTextInquiryFlow] = None
_FLOW_GUARD = threading.Lock()
def build_ledger() -> Any:
"""生产打 8180;单测 LEDGER_BACKEND=memory。"""
from agent.config import get_settings
backend = (get_settings().ledger_backend or "http").strip().lower()
if backend == "memory":
return MemoryLedger()
return HttpLedger()
def get_air_text_flow() -> AirTextInquiryFlow:
"""进程内一份流程(与 inbox 线程共用);测试可 new 独立实例。"""
global _FLOW
with _FLOW_GUARD:
if _FLOW is None:
_FLOW = AirTextInquiryFlow()
return _FLOW
def reset_air_text_flow_for_test() -> AirTextInquiryFlow:
"""单测重置全局实例(强制内存账本)。"""
global _FLOW
with _FLOW_GUARD:
_FLOW = AirTextInquiryFlow(MemoryLedger())
return _FLOW