停发填写样例图,同群只跟当前工单,重量体积统一为 KG/CBM。 Co-authored-by: Cursor <cursoragent@cursor.com>
1765 lines
67 KiB
Python
1765 lines
67 KiB
Python
"""
|
||
空运文字询价确定性流程(模型只负责抽字段,本文件只编排与执行)。
|
||
|
||
本文件职责:补问到必填齐 → 核对确定后建单 → 查 TMS(一条出卡,多条 H5 选线路)。
|
||
对不上机场也不能跳过查价、本地说暂无报价。
|
||
调用:handler 在 Worker/inbox 线程;禁止回调线程同步跑。
|
||
禁止:直连 TMS;用图状态冒充主账成功;用户文字冒充文件已生成。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import threading
|
||
import time
|
||
import uuid
|
||
from dataclasses import asdict, 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.land_options import looks_like_confirm
|
||
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)
|
||
tms_quote: dict[str, Any] = field(default_factory=dict)
|
||
# 陆运 H5 重选要用的整包线路。选定后 tms_quote 只留已选那条,供群摘要使用。
|
||
land_route_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)
|
||
clarify_missing_sig: str = ""
|
||
clarify_retry: int = 0
|
||
# clarify=3 次反问入口;intent=销售要求人来接管。空=没有待点的转人工卡。
|
||
handoff_offer: str = ""
|
||
handoff_committed: bool = False
|
||
virtual_work_order_no: str = ""
|
||
|
||
|
||
def _session_payload(sess: FlowSession) -> dict[str, Any]:
|
||
"""书签转 JSON。tuple 改 list,方便 HTTP/Worker 共用。"""
|
||
data = asdict(sess)
|
||
data["allowed"] = list(sess.allowed or ())
|
||
data["invited_userids"] = list(sess.invited_userids or ())
|
||
data["invited_product_names"] = list(sess.invited_product_names or ())
|
||
data["facts"] = {str(k): str(v) for k, v in (sess.facts or {}).items()}
|
||
return data
|
||
|
||
|
||
def _session_from_payload(raw: dict[str, Any]) -> FlowSession:
|
||
"""Redis JSON → 书签。缺字段用默认,避免旧键打崩。"""
|
||
return FlowSession(
|
||
sender_id=str(raw.get("sender_id") or ""),
|
||
thread_id=str(raw.get("thread_id") or raw.get("sender_id") or ""),
|
||
wait_version=int(raw.get("wait_version") or 0),
|
||
phase=str(raw.get("phase") or "idle"),
|
||
business_line=str(raw.get("business_line") or ""),
|
||
facts={str(k): str(v) for k, v in dict(raw.get("facts") or {}).items()},
|
||
work_order_no=str(raw.get("work_order_no") or ""),
|
||
first_or_same=str(raw.get("first_or_same") or "first"),
|
||
history_work_order_no=str(raw.get("history_work_order_no") or ""),
|
||
quote=dict(raw.get("quote") or {}),
|
||
tms_quote=dict(raw.get("tms_quote") or raw.get("tmsQuote") or {}),
|
||
land_route_quote=dict(raw.get("land_route_quote") or raw.get("landRouteQuote") or {}),
|
||
status=str(raw.get("status") or ""),
|
||
allowed=tuple(str(x) for x in (raw.get("allowed") or ())),
|
||
immutable_text=str(raw.get("immutable_text") or ""),
|
||
collab_chat_id=str(raw.get("collab_chat_id") or ""),
|
||
collab_facts={str(k): str(v) for k, v in dict(raw.get("collab_facts") or {}).items()},
|
||
invited_userids=tuple(str(x) for x in (raw.get("invited_userids") or ())),
|
||
invited_sales_name=str(raw.get("invited_sales_name") or ""),
|
||
invited_product_names=tuple(str(x) for x in (raw.get("invited_product_names") or ())),
|
||
deal_version=int(raw.get("deal_version") or 0),
|
||
collab_ended=bool(raw.get("collab_ended")),
|
||
pending_product_quote=dict(raw.get("pending_product_quote") or {}),
|
||
clarify_missing_sig=str(raw.get("clarify_missing_sig") or ""),
|
||
clarify_retry=int(raw.get("clarify_retry") or 0),
|
||
handoff_offer=str(raw.get("handoff_offer") or ""),
|
||
handoff_committed=bool(raw.get("handoff_committed")),
|
||
virtual_work_order_no=str(raw.get("virtual_work_order_no") or ""),
|
||
)
|
||
|
||
|
||
class AirTextInquiryFlow:
|
||
"""
|
||
空运文字询价 Owner。海运走 SeaTextInquiryFlow,陆运走 LandTextInquiryFlow。
|
||
|
||
同一 sender 新开询价会覆盖「当前书签」(旧单留在主账)。
|
||
价格卡按工单号另存一份当时会话:点哪张卡就生成哪张卡当时的报价单。
|
||
"""
|
||
|
||
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()
|
||
self._lock = threading.Lock()
|
||
self._by_sender: dict[str, FlowSession] = {}
|
||
self._by_ticket: dict[str, FlowSession] = {}
|
||
self._group_client = group_client
|
||
self._bookmark_store = bookmark_store
|
||
|
||
@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]:
|
||
sid = (sender_id or "").strip()
|
||
with self._lock:
|
||
hit = self._by_sender.get(sid)
|
||
if hit is not None:
|
||
return hit
|
||
loaded = self._load_bookmark(sid)
|
||
if loaded is None:
|
||
return None
|
||
self._save(loaded)
|
||
return loaded
|
||
|
||
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)
|
||
if not no:
|
||
return self.session_of(sender_id)
|
||
with self._lock:
|
||
hit = self._by_ticket.get(no)
|
||
if hit and hit.sender_id == sender_id:
|
||
return hit
|
||
# 卡上有工单号就只动这一单;进程刚重启时从主账收回,禁止落到别人/最新单。
|
||
return self._load_ticket_session(sender_id, no)
|
||
|
||
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
|
||
vt = (sess.virtual_work_order_no or "").strip()
|
||
if vt:
|
||
self._by_ticket[vt] = sess
|
||
self._persist_bookmark(sess)
|
||
|
||
def _handoff_state(self, sess: Optional[FlowSession]) -> dict[str, Any]:
|
||
"""新建书签时带上转人工计数,避免补问一轮就把 3 次清掉。已冻结的票不当新书签底。"""
|
||
from agent.policy.handoff_human import is_handoff_frozen
|
||
|
||
if sess is None or is_handoff_frozen(sess):
|
||
return {}
|
||
return {
|
||
"clarify_missing_sig": sess.clarify_missing_sig,
|
||
"clarify_retry": sess.clarify_retry,
|
||
"handoff_offer": sess.handoff_offer,
|
||
"handoff_committed": sess.handoff_committed,
|
||
"virtual_work_order_no": sess.virtual_work_order_no,
|
||
"collab_chat_id": sess.collab_chat_id,
|
||
"work_order_no": sess.work_order_no,
|
||
"status": sess.status,
|
||
"quote": dict(sess.quote or {}),
|
||
"tms_quote": dict(sess.tms_quote or {}),
|
||
"land_route_quote": dict(sess.land_route_quote or {}),
|
||
"allowed": sess.allowed,
|
||
"invited_userids": sess.invited_userids,
|
||
"invited_sales_name": sess.invited_sales_name,
|
||
"invited_product_names": sess.invited_product_names,
|
||
}
|
||
|
||
def _block_if_handoff_committed(self, sess: Optional[FlowSession], reply: ReplyFn) -> bool:
|
||
"""真正转人工之后这一票不再跟。返回 True 表示已经拦住。"""
|
||
from agent.policy.handoff_human import is_handoff_frozen
|
||
|
||
if sess is None:
|
||
return False
|
||
if is_handoff_frozen(sess):
|
||
no = (sess.work_order_no or sess.virtual_work_order_no or "").strip()
|
||
reply(f"工单 {no} 已转人工,本单不再自动跟进。如需新询价请直接说明新的运输需求。")
|
||
return True
|
||
return False
|
||
|
||
def _detach_frozen_bookmark(
|
||
self, sess: Optional[FlowSession], sender_id: str
|
||
) -> Optional[FlowSession]:
|
||
"""
|
||
真正转人工后丢掉当前私聊书签,好开新询价。
|
||
|
||
旧单仍留在工单号索引,群里带号仍能对上。
|
||
"""
|
||
from agent.policy.handoff_human import is_handoff_frozen
|
||
|
||
if sess is None or not is_handoff_frozen(sess):
|
||
return sess
|
||
logger.info(
|
||
"handoff.detach_frozen sender=%s wo=%s vt=%s",
|
||
sender_id,
|
||
sess.work_order_no or "-",
|
||
sess.virtual_work_order_no or "-",
|
||
)
|
||
self.drop_current_bookmark(sender_id)
|
||
return None
|
||
|
||
def offer_handoff(self, *, sender_id: str, text: str, reply: ReplyFn) -> str:
|
||
"""
|
||
路由判定销售明确要求人来接管。运输方式不明则继续补问运输方式。
|
||
"""
|
||
from agent.llm.extract_text import detect_transport_mode
|
||
from agent.policy.handoff_human import is_closed_for_handoff
|
||
|
||
sess = self.session_of(sender_id)
|
||
if sess and is_closed_for_handoff(sess.status):
|
||
reply("该工单已结束,不能再转人工。")
|
||
return "handoff_closed"
|
||
if sess and self._block_if_handoff_committed(sess, reply):
|
||
return "handoff_done"
|
||
mode = (sess.business_line if sess else "") or detect_transport_mode(text) or ""
|
||
mode = (mode or "").upper()
|
||
if not mode:
|
||
reply(copy.ASK_TRANSPORT)
|
||
self._save(
|
||
FlowSession(
|
||
sender_id=sender_id,
|
||
thread_id=sender_id,
|
||
phase="need_mode",
|
||
facts=dict(sess.facts) if sess else {},
|
||
immutable_text=(sess.immutable_text if sess and sess.immutable_text else text),
|
||
**self._handoff_state(sess),
|
||
)
|
||
)
|
||
return "need_mode"
|
||
if mode == "SEA":
|
||
from agent.policy.sea_text_flow import get_sea_text_flow
|
||
|
||
return get_sea_text_flow()._offer_handoff_now(
|
||
sender_id=sender_id, reason="intent", reply=reply, text=text
|
||
)
|
||
if mode == "LAND":
|
||
from agent.policy.land_text_flow import get_land_text_flow
|
||
|
||
return get_land_text_flow()._offer_handoff_now(
|
||
sender_id=sender_id, reason="intent", reply=reply, text=text
|
||
)
|
||
return self._offer_handoff_now(
|
||
sender_id=sender_id, reason="intent", reply=reply, text=text
|
||
)
|
||
|
||
def _offer_handoff_now(
|
||
self,
|
||
*,
|
||
sender_id: str,
|
||
reason: str,
|
||
reply: ReplyFn,
|
||
text: str = "",
|
||
sess: Optional[FlowSession] = None,
|
||
) -> str:
|
||
"""
|
||
发出转人工入口。reason=clarify|intent。
|
||
点卡/空运群发号之前不冻结。
|
||
"""
|
||
from agent.policy.handoff_human import (
|
||
has_collab_group,
|
||
ticket_no_for_handoff_offer,
|
||
transport_label,
|
||
)
|
||
|
||
sess = sess or self.session_of(sender_id)
|
||
if sess is None:
|
||
sess = FlowSession(
|
||
sender_id=sender_id,
|
||
thread_id=sender_id,
|
||
phase="handoff_offer",
|
||
business_line=self.bookmark_kind.upper() if self.bookmark_kind != "sea" else "SEA",
|
||
immutable_text=text,
|
||
)
|
||
if self.bookmark_kind == "air":
|
||
sess.business_line = "AIR"
|
||
elif self.bookmark_kind == "land":
|
||
sess.business_line = "LAND"
|
||
else:
|
||
sess.business_line = "SEA"
|
||
line = (sess.business_line or "").upper()
|
||
if has_collab_group(sess):
|
||
reply(copy.HANDOFF_ALREADY_COLLAB)
|
||
return "handoff_already"
|
||
why = copy.HANDOFF_CLARIFY_REASON if reason == "clarify" else copy.HANDOFF_INTENT_REASON
|
||
if line == "AIR":
|
||
no = ticket_no_for_handoff_offer(sess)
|
||
reply(copy.handoff_air_hint(work_order_no=no))
|
||
sess.handoff_offer = reason
|
||
sess.phase = "handoff_offer"
|
||
self._save(sess)
|
||
return "handoff_offer"
|
||
token = (sess.work_order_no or "").strip() or sender_id
|
||
reply(
|
||
copy.handoff_offer_text(reason=why, transport_mode=transport_label(line)),
|
||
copy.handoff_offer_wecom_payload(reason=why, task_token=token),
|
||
)
|
||
sess.handoff_offer = reason
|
||
sess.phase = "handoff_offer"
|
||
sess.allowed = (copy.BTN_PULL_COLLAB,)
|
||
self._save(sess)
|
||
return "handoff_offer"
|
||
|
||
def _clarify_or_handoff(
|
||
self,
|
||
*,
|
||
sess: Optional[FlowSession],
|
||
keys: list[str],
|
||
new_sess: FlowSession,
|
||
reply: ReplyFn,
|
||
clarify_text: str,
|
||
) -> str:
|
||
"""缺项:第一次不计数;满 3 次先发(3/3)再出转人工入口,否则照常补问。"""
|
||
from agent.policy.handoff_human import inherit_pre_create_ticket, note_clarify_round
|
||
|
||
if sess is not None:
|
||
new_sess.clarify_missing_sig = sess.clarify_missing_sig
|
||
new_sess.clarify_retry = sess.clarify_retry
|
||
new_sess.handoff_offer = sess.handoff_offer
|
||
new_sess.handoff_committed = sess.handoff_committed
|
||
new_sess.virtual_work_order_no = sess.virtual_work_order_no
|
||
inherit_pre_create_ticket(sess, new_sess)
|
||
decision = note_clarify_round(new_sess, keys)
|
||
reply(copy.with_clarify_progress(clarify_text, new_sess.clarify_retry))
|
||
if decision == "handoff":
|
||
return self._offer_handoff_now(
|
||
sender_id=new_sess.sender_id,
|
||
reason="clarify",
|
||
reply=reply,
|
||
sess=new_sess,
|
||
)
|
||
self._save(new_sess)
|
||
return new_sess.phase or "clarify"
|
||
|
||
def _detach_ticketed_for_new_inquiry(self, sess: Optional[FlowSession]) -> Optional[FlowSession]:
|
||
"""
|
||
已经出过正式工单号:这一句按新一轮收字段,不带上一单。
|
||
|
||
测服 WO202609200012 出号后,下一句海运把旧贸易条款写进 WO202609200013。
|
||
点按钮、未成交原因、调价已在前面处理。虚拟号还在补问,要接着填。
|
||
"""
|
||
if sess is None:
|
||
return None
|
||
from agent.policy.handoff_human import is_virtual_no
|
||
|
||
no = (sess.work_order_no or "").strip()
|
||
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", "wait_air_route"}:
|
||
return sess
|
||
logger.info(
|
||
"%s.new_over_old phase=%s wo=%s",
|
||
self.bookmark_kind,
|
||
phase or "-",
|
||
no,
|
||
)
|
||
return None
|
||
|
||
def drop_current_bookmark(self, sender_id: str) -> Optional[FlowSession]:
|
||
"""
|
||
发图新开:丢掉当前书签,已出号会话仍留在 _by_ticket。
|
||
|
||
未出号的补问/核对草稿从此不再被文字当「当前单」。
|
||
HTTP 与 Worker 必须同时丢掉 Redis 里那份,否则补「托盘」会问运输方式。
|
||
"""
|
||
sid = (sender_id or "").strip()
|
||
if not sid:
|
||
return None
|
||
with self._lock:
|
||
old = self._by_sender.pop(sid, None)
|
||
self._delete_bookmark(sid)
|
||
return old
|
||
|
||
def _persist_bookmark(self, sess: FlowSession) -> None:
|
||
from agent.redis_coord.flow_bookmark import save_flow_bookmark
|
||
|
||
save_flow_bookmark(
|
||
self.bookmark_kind,
|
||
sess.sender_id,
|
||
_session_payload(sess),
|
||
store=self._bookmark_store,
|
||
)
|
||
|
||
def _load_bookmark(self, sender_id: str) -> Optional[FlowSession]:
|
||
from agent.redis_coord.flow_bookmark import load_flow_bookmark
|
||
|
||
raw = load_flow_bookmark(
|
||
self.bookmark_kind,
|
||
sender_id,
|
||
store=self._bookmark_store,
|
||
)
|
||
if not raw:
|
||
return None
|
||
return _session_from_payload(raw)
|
||
|
||
def _delete_bookmark(self, sender_id: str) -> None:
|
||
from agent.redis_coord.flow_bookmark import delete_flow_bookmark
|
||
|
||
delete_flow_bookmark(
|
||
self.bookmark_kind,
|
||
sender_id,
|
||
store=self._bookmark_store,
|
||
)
|
||
|
||
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
|
||
if (str(view.get("business_line") or "")).upper() == "MULTI":
|
||
# 多段联运由 MultiTextInquiryFlow 接卡。这里收回会变成 tms_miss,点拉群没回声。
|
||
logger.info("card 多段工单不走空运收回 wo=%s", work_order_no)
|
||
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)
|
||
elif phase == "wait_lost_reason":
|
||
allowed = ()
|
||
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,
|
||
invited_sales_name=str(view.get("sales_name") or view.get("salesName") or ""),
|
||
collab_chat_id=str(view.get("collab_chat_id") or ""),
|
||
)
|
||
|
||
def _infer_wait_phase(self, *, status: str, quote: dict[str, Any]) -> str:
|
||
if status in {"已成交", "已关闭"}:
|
||
return "done"
|
||
if status == "未成交":
|
||
return "wait_lost_reason"
|
||
if status == "协商中":
|
||
return "wait_deal"
|
||
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":
|
||
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":
|
||
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 == "wait_lost_reason":
|
||
reply(copy.deal_ack("未成交", sess.work_order_no))
|
||
return "wait_lost_reason"
|
||
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)
|
||
sess = self._detach_frozen_bookmark(sess, sender_id)
|
||
btn = self.match_button(text, sess)
|
||
if btn:
|
||
return self.on_button(
|
||
sender_id=sender_id,
|
||
action=btn,
|
||
reply=reply,
|
||
card_meta=inbound_raw,
|
||
)
|
||
# 未成交后下一句任意文字即原因;完整新询价(注入字段)则空着原因开新单。
|
||
if (
|
||
sess
|
||
and sess.phase == "wait_lost_reason"
|
||
and not (injected_facts and injected_mode)
|
||
):
|
||
return self._capture_lost_reason(sess, text, reply)
|
||
if sess:
|
||
from agent.policy.quote_adjust_ops import try_private_adjust
|
||
|
||
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(
|
||
text,
|
||
injected_facts=injected_facts,
|
||
injected_mode=injected_mode,
|
||
b_result=b_result,
|
||
allow_b=allow_b,
|
||
chat_fn=chat_fn,
|
||
)
|
||
from agent.policy.system_exception import handle_extract_tech_fail
|
||
|
||
llm_phase = handle_extract_tech_fail(
|
||
snap=snap,
|
||
ledger=self._ledger,
|
||
reply=reply,
|
||
sender_id=sender_id,
|
||
sess=sess,
|
||
)
|
||
if llm_phase:
|
||
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", "wait_confirm"}:
|
||
# 补问轮:旧字段打底;运输方式沿用上一句(飞=空运)
|
||
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":
|
||
from agent.policy.land_text_flow import get_land_text_flow
|
||
|
||
# 本句已是陆运:丢掉空运补问草稿。
|
||
self.drop_current_bookmark(sender_id)
|
||
return get_land_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,
|
||
)
|
||
if mode == "SEA":
|
||
# 海运交给海运 Owner,空运不再回「本轮先开通空运」
|
||
from agent.policy.sea_text_flow import get_sea_text_flow
|
||
|
||
self.drop_current_bookmark(sender_id)
|
||
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(copy.ASK_TRANSPORT)
|
||
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"]:
|
||
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=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 self._clarify_or_handoff(
|
||
sess=sess,
|
||
keys=keys,
|
||
new_sess=new_sess,
|
||
reply=reply,
|
||
clarify_text=copy.ask_clarify(
|
||
facts=facts,
|
||
missing_keys=keys,
|
||
transport_mode="空运",
|
||
),
|
||
)
|
||
|
||
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,
|
||
)
|
||
|
||
def _create_and_quote(
|
||
self,
|
||
*,
|
||
sender_id: str,
|
||
facts: dict[str, str],
|
||
text: str,
|
||
reply: ReplyFn,
|
||
) -> str:
|
||
# 先落已确认字段,避免建单失败后补问轮把毛重/体积弄丢
|
||
from agent.policy.handoff_human import void_virtual_no
|
||
|
||
old = self.session_of(sender_id)
|
||
if old:
|
||
void_virtual_no(old)
|
||
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)
|
||
|
||
from agent.policy.system_exception import (
|
||
confirm_tms_quote_if_tech,
|
||
query_tms_resilient,
|
||
)
|
||
|
||
tms = query_tms_resilient(self._ledger, 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 confirm_tms_quote_if_tech(
|
||
ledger=self._ledger,
|
||
reply=reply,
|
||
work_order_no=no,
|
||
tms=tms,
|
||
sender_id=sender_id,
|
||
ticket_status="询价中",
|
||
):
|
||
self.drop_current_bookmark(sender_id)
|
||
return "system_exception"
|
||
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"]
|
||
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("主账写入报价失败,不能当作已报价。")
|
||
sess.phase = "ledger_fail"
|
||
self._save(sess)
|
||
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)
|
||
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="询价确认")
|
||
|
||
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)
|
||
from agent.policy.event_exception import clear_wait
|
||
|
||
clear_wait(self._ledger, work_order_no=sess.work_order_no)
|
||
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,
|
||
),
|
||
)
|
||
from agent.policy.event_exception import WAIT_ADOPT, start_wait
|
||
|
||
start_wait(self._ledger, work_order_no=sess.work_order_no, wait_kind=WAIT_ADOPT)
|
||
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}")
|
||
from agent.policy.event_exception import clear_wait
|
||
|
||
clear_wait(self._ledger, work_order_no=sess.work_order_no)
|
||
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,
|
||
business_line=sess.business_line or "AIR",
|
||
).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,
|
||
business_line=sess.business_line or "AIR",
|
||
),
|
||
]
|
||
),
|
||
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)
|
||
from agent.policy.event_exception import WAIT_DEAL, start_wait
|
||
|
||
start_wait(self._ledger, work_order_no=sess.work_order_no, wait_kind=WAIT_DEAL)
|
||
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,
|
||
business_line=sess.business_line or "AIR",
|
||
),
|
||
copy.deal_wecom_payload(
|
||
work_order_no=sess.work_order_no,
|
||
quote=sess.quote,
|
||
facts=sess.facts,
|
||
business_line=sess.business_line or "AIR",
|
||
),
|
||
)
|
||
|
||
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)
|
||
from agent.policy.deal_outcome import finish_deal_outcome
|
||
|
||
result = finish_deal_outcome(
|
||
ledger=self._ledger,
|
||
work_order_no=sess.work_order_no,
|
||
outcome=status,
|
||
facts=sess.facts,
|
||
quote=sess.quote,
|
||
business_line=sess.business_line,
|
||
sales_name=sess.invited_sales_name,
|
||
)
|
||
if not result.get("ok"):
|
||
reply("主账状态更新失败,成交结果未入账。")
|
||
return "ledger_fail"
|
||
phase = str(result.get("phase") or "done")
|
||
sess.status = status
|
||
sess.phase = phase
|
||
if phase == "wait_deal":
|
||
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
|
||
else:
|
||
sess.allowed = ()
|
||
sess.wait_version += 1
|
||
self._save(sess)
|
||
reply(copy.deal_ack(status, sess.work_order_no, str(result.get("next_followup_at") or "")))
|
||
return phase
|
||
|
||
def _capture_lost_reason(self, sess: FlowSession, text: str, reply: ReplyFn) -> str:
|
||
"""私聊未成交后收下原因,不再追问。"""
|
||
from agent.policy.deal_outcome import capture_lost_reason
|
||
|
||
result = capture_lost_reason(
|
||
ledger=self._ledger,
|
||
work_order_no=sess.work_order_no,
|
||
reason=text,
|
||
)
|
||
if not result.get("ok"):
|
||
reply("未成交原因没有记下,请再发一句。")
|
||
return "wait_lost_reason"
|
||
sess.phase = "done"
|
||
sess.allowed = ()
|
||
self._save(sess)
|
||
reply(copy.lost_reason_ack(text))
|
||
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)
|
||
from agent.policy.event_exception import WAIT_DEAL, start_wait
|
||
|
||
start_wait(self._ledger, work_order_no=sess.work_order_no, wait_kind=WAIT_DEAL)
|
||
|
||
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
|