Files
inquiry_robot/inquiry-agent/agent/policy/air_text_flow.py
T

960 lines
36 KiB
Python

"""
空运文字询价确定性流程(模型只负责抽字段,本文件只编排与执行)。
本文件职责:补问到询价必填齐全 → 建单出卡片 → 必须打主账查 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 = ""
class AirTextInquiryFlow:
"""
空运文字询价 Owner(本轮产品范围);海运/陆运先补问运输方式或陆运二级。
同一 sender 新开询价会覆盖「当前书签」(旧单留在主账)。
价格卡按工单号另存一份当时会话:点哪张卡就生成哪张卡当时的报价单。
"""
def __init__(self, ledger: 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] = {}
@property
def ledger(self) -> Any:
return self._ledger
def session_of(self, sender_id: str) -> Optional[FlowSession]:
with self._lock:
return self._by_sender.get(sender_id)
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:
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._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 _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":
reply(
copy.scheme_card(
work_order_no=sess.work_order_no,
status=sess.status or "已报价",
facts=sess.facts,
quote=sess.quote,
),
copy.scheme_wecom_payload(
work_order_no=sess.work_order_no,
facts=sess.facts,
quote=sess.quote,
status=sess.status or "已报价",
),
)
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":
self._emit_deal(reply, sess)
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"
# 海运本轮只提示先走空运闭环(不半做海运查价)
reply("本轮先开通空运文字询价。请按空运描述需求,或补充为「空运」。")
return "sea_deferred"
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)
reply(
copy.scheme_card(
work_order_no=sess.work_order_no,
status=sess.status or "已报价",
facts=sess.facts,
quote=sess.quote,
),
copy.scheme_wecom_payload(
work_order_no=sess.work_order_no,
facts=sess.facts,
quote=sess.quote,
status=sess.status or "已报价",
),
)
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 则不等待。
"""
out_id = reply(
copy.file_ready(kind=kind, work_order_no=sess.work_order_no, filename=filename),
file_extra,
)
self._wait_outbound_sent(str(out_id or ""), work_order_no=sess.work_order_no, what="报价文件")
sess.phase = "wait_deal"
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
sess.wait_version += 1
self._save(sess)
self._emit_deal(reply, 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