落地私聊空运询价与TMS报价回填,按后台模板出单。
查到价后把币种、单位、数量、单价、备注写入报价单,避免销售只看到光秃秃的金额。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,794 @@
|
||||
"""
|
||||
空运文字询价确定性流程(模型只负责抽字段,本文件只编排与执行)。
|
||||
|
||||
本文件职责:补问到询价必填齐全 → 建单出卡片 → 必须打主账查 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.config import get_settings
|
||||
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__)
|
||||
|
||||
ReplyFn = Callable[..., None]
|
||||
|
||||
|
||||
@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_deal":
|
||||
summary = (
|
||||
f"{sess.facts.get('起运港', '')} → {sess.facts.get('目的港', '')} "
|
||||
f"{sess.facts.get('品名', '')}"
|
||||
)
|
||||
reply(
|
||||
copy.deal_card(
|
||||
work_order_no=sess.work_order_no,
|
||||
status=sess.status or "已报价",
|
||||
summary=summary,
|
||||
)
|
||||
)
|
||||
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)
|
||||
reply(copy.inquiry_card(work_order_no=no, facts=facts, first_or_same=first))
|
||||
|
||||
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 = "询价中"
|
||||
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 _emit_tms_miss(self, reply: ReplyFn, work_order_no: str) -> None:
|
||||
"""
|
||||
TMS 已查过且确认没价:文字兜底 + 暂无报价卡片。
|
||||
禁止在没打 query_tms 前回这个。
|
||||
"""
|
||||
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(),
|
||||
),
|
||||
)
|
||||
|
||||
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 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, "已成交")
|
||||
if action == copy.BTN_LOST:
|
||||
return self._close(sess, reply, "未成交")
|
||||
if action == copy.BTN_NEGOTIATE:
|
||||
return self._close(sess, reply, "协商中")
|
||||
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]]) -> None:
|
||||
"""点过方案卡后把原卡按钮改成已提交;无 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),
|
||||
)
|
||||
|
||||
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",
|
||||
)
|
||||
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 报价单。")
|
||||
reply(
|
||||
copy.file_ready(kind="Excel", work_order_no=sess.work_order_no, filename=fallback),
|
||||
file_extra,
|
||||
)
|
||||
# 已入队:Worker 出文件;此处不冒充 soffice 已成功
|
||||
else:
|
||||
reply(
|
||||
copy.file_ready(kind=kind, work_order_no=sess.work_order_no, filename=filename),
|
||||
file_extra,
|
||||
)
|
||||
summary = f"{sess.facts.get('起运港', '')} → {sess.facts.get('目的港', '')} {sess.facts.get('品名', '')}"
|
||||
sess.phase = "wait_deal"
|
||||
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
|
||||
sess.wait_version += 1
|
||||
self._save(sess)
|
||||
reply(
|
||||
copy.deal_card(
|
||||
work_order_no=sess.work_order_no,
|
||||
status=sess.status or "已报价",
|
||||
summary=summary,
|
||||
)
|
||||
)
|
||||
return "wait_deal"
|
||||
|
||||
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,
|
||||
) -> 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,
|
||||
)
|
||||
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) -> str:
|
||||
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"
|
||||
|
||||
|
||||
_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
|
||||
@@ -0,0 +1,797 @@
|
||||
"""
|
||||
空运文字询价出站话术(销售可见)。
|
||||
|
||||
本文件职责:集中文案;按钮字面量必须与 handler 精确匹配。
|
||||
禁止:在本文件调主账/TMS;禁止把密钥写进话术。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Iterable
|
||||
|
||||
from agent.schema.field_validate import AIR_REQUIRED_FIELDS
|
||||
|
||||
# 空运补问:与合同 8 项一致。报价日期默认当天,不进待补充。
|
||||
_AIR_EXAMPLES = {
|
||||
"起运港": "南京",
|
||||
"目的港": "吉隆坡",
|
||||
"品名": "茶叶",
|
||||
"件数": "10件",
|
||||
"毛重": "100KGS",
|
||||
"体积": "2CBM",
|
||||
"包装方式": "托盘",
|
||||
"报价日期": "2026-09-15",
|
||||
}
|
||||
_AIR_CLARIFY = tuple(
|
||||
(key, label, _AIR_EXAMPLES[key]) for key, label in AIR_REQUIRED_FIELDS
|
||||
)
|
||||
|
||||
# 文字按钮(销售点卡或原样回复均可)
|
||||
BTN_SKIP_COLLAB = "不需要协同,直接生成报价单"
|
||||
BTN_SKIP_COLLAB_OLD = "不需要协同,直接生成基础报价单"
|
||||
BTN_SKIP_COLLAB_KEY = "skip_collab"
|
||||
BTN_SKIP_COLLAB_DONE = "已提交"
|
||||
BTN_ADOPT_EXCEL = "采用,生成Excel报价单"
|
||||
BTN_ADOPT_PDF = "采用,生成PDF报价单"
|
||||
BTN_REJECT = "不采用"
|
||||
# 企微一张卡三个按钮只能横排,长文案会被裁。卡片用短文案才能看全。
|
||||
BTN_ADOPT_EXCEL_CARD = "生成Excel"
|
||||
BTN_ADOPT_PDF_CARD = "生成PDF"
|
||||
BTN_ADOPT_EXCEL_KEY = "adopt_excel"
|
||||
BTN_ADOPT_PDF_KEY = "adopt_pdf"
|
||||
BTN_REJECT_KEY = "reject"
|
||||
BTN_DEAL = "成交"
|
||||
BTN_LOST = "未成交"
|
||||
BTN_NEGOTIATE = "协商中"
|
||||
|
||||
_WO_NO = re.compile(r"WO\d{12}", re.IGNORECASE)
|
||||
# 仅给无网 stub / 工单号回填用,正式续办意图由 DeepSeek A 判断,不要求销售说这些词。
|
||||
_CONTINUE_HINT = (
|
||||
"继续",
|
||||
"接着",
|
||||
"回到",
|
||||
"再看",
|
||||
"再出",
|
||||
"找回",
|
||||
"重开",
|
||||
"那单",
|
||||
"这单",
|
||||
"历史工单",
|
||||
"咨询工单",
|
||||
"刚才那",
|
||||
"上次那",
|
||||
)
|
||||
|
||||
|
||||
ASK_TRANSPORT = "请补充运输方式:海运 / 空运 / 陆运。"
|
||||
ASK_LAND_SUBTYPE = "请补充陆运类型:国内运输拼车 / 国内运输整车 / 跨境集拼 / 跨境整车 / 中港。"
|
||||
FIRST_CHAT = "你好,我是询价机器人。请直接描述客户需求,例如「上海到洛杉矶空运,3 件 180kg,下周三前到」,我会解析需求并查询 TMS 标准报价。"
|
||||
ROUTE_MODEL_FAILED = "刚才没听清,请再说一次:是新询价,还是要接着办哪张工单?"
|
||||
|
||||
|
||||
def _air_known_value(facts: dict[str, str], key: str) -> str:
|
||||
"""已识别字段的销售侧展示值;没有则空串。"""
|
||||
src = facts or {}
|
||||
if key == "起运港":
|
||||
return (src.get("起运港") or "").strip()
|
||||
if key == "目的港":
|
||||
return (src.get("目的港") or "").strip()
|
||||
if key == "品名":
|
||||
return (src.get("品名") or "").strip()
|
||||
if key == "件数":
|
||||
raw = (src.get("件数") or "").strip()
|
||||
return _keep_or_add_unit(raw, ("件", "PCS"), "件") if raw else ""
|
||||
if key == "毛重":
|
||||
raw = (src.get("毛重") or "").strip()
|
||||
return _keep_or_add_unit(raw, ("KGS", "KG", "公斤", "千克"), "") if raw else ""
|
||||
if key == "体积":
|
||||
raw = (src.get("体积") or "").strip()
|
||||
return _keep_or_add_unit(raw, ("CBM", "M3", "M³", "立方米", "立方", "方"), "") if raw else ""
|
||||
if key == "包装方式":
|
||||
return (src.get("包装方式") or src.get("包装类型") or "").strip()
|
||||
if key == "报价日期":
|
||||
return (src.get("报价日期") or "").strip()
|
||||
return (src.get(key) or "").strip()
|
||||
|
||||
|
||||
def ask_clarify(
|
||||
*,
|
||||
facts: dict[str, str] | None = None,
|
||||
missing_keys: Iterable[str] = (),
|
||||
transport_mode: str = "空运",
|
||||
) -> str:
|
||||
"""
|
||||
缺项补问:先回显已识别,再列待补充并给示例。
|
||||
|
||||
报价日期写进已识别(销售说了用原话,没说用当天),不出现在待补充。
|
||||
"""
|
||||
src = dict(facts or {})
|
||||
skip = {"报价日期"}
|
||||
missing = [k for k in missing_keys if k and k not in skip]
|
||||
missing_set = set(missing)
|
||||
lines = ["请确认并补充以下信息:"]
|
||||
if transport_mode:
|
||||
lines.append(f"运输方式:{transport_mode}")
|
||||
for key, label, _example in _AIR_CLARIFY:
|
||||
val = _air_known_value(src, key)
|
||||
if val:
|
||||
lines.append(f"{label}:{val}")
|
||||
lines.append("")
|
||||
lines.append("待补充:")
|
||||
any_missing = False
|
||||
if "运输方式" in missing_set:
|
||||
any_missing = True
|
||||
lines.append("运输方式:(如:空运)")
|
||||
for key, label, example in _AIR_CLARIFY:
|
||||
if key in missing_set:
|
||||
any_missing = True
|
||||
lines.append(f"{label}:(如:{example})")
|
||||
if not any_missing:
|
||||
return ""
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def ask_missing(display_names: Iterable[str]) -> str:
|
||||
"""兼容旧调用:仅有展示名时仍走补问,但没有已识别回显。"""
|
||||
alias = {
|
||||
"起运地": "起运港",
|
||||
"目的地": "目的港",
|
||||
"起运地(机场名或三字码)": "起运港",
|
||||
"目的地(机场名或三字码)": "目的港",
|
||||
"毛重": "毛重",
|
||||
"重量": "毛重",
|
||||
"重量(KG)": "毛重",
|
||||
"体积": "体积",
|
||||
"体积(CBM)": "体积",
|
||||
"包装类型": "包装方式",
|
||||
"包装方式": "包装方式",
|
||||
}
|
||||
keys: list[str] = []
|
||||
for name in display_names:
|
||||
key = alias.get(name, name)
|
||||
if key and key not in keys and key != "报价日期":
|
||||
keys.append(key)
|
||||
return ask_clarify(facts={}, missing_keys=keys, transport_mode="空运")
|
||||
|
||||
|
||||
def _keep_or_add_unit(raw: str, known: tuple[str, ...], default: str) -> str:
|
||||
"""
|
||||
销售侧单位原样回显。
|
||||
|
||||
写了 1.5KG 就回 1.5KG,禁止改成 1.5KGS。没写单位才补 default(可空)。
|
||||
"""
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return "-"
|
||||
upper = text.upper()
|
||||
for unit in known:
|
||||
if upper.endswith(unit.upper()):
|
||||
return text
|
||||
if default and not any(ch.isalpha() or ch in "立方件" for ch in text):
|
||||
return f"{text}{default}"
|
||||
return text
|
||||
|
||||
|
||||
def _with_unit(raw: str, suffix: str) -> str:
|
||||
"""兼容旧调用:已带单位则原样。重量不要走这里补 KGS。"""
|
||||
if suffix.upper() == "KGS":
|
||||
return _keep_or_add_unit(raw, ("KGS", "KG", "公斤", "千克"), "")
|
||||
if suffix.upper() == "CBM":
|
||||
return _keep_or_add_unit(raw, ("CBM", "M3", "M³", "立方米", "立方", "方"), "")
|
||||
return _keep_or_add_unit(raw, (suffix,), suffix)
|
||||
|
||||
|
||||
def inquiry_field_lines(
|
||||
*,
|
||||
work_order_no: str,
|
||||
facts: dict[str, str],
|
||||
transport_mode: str = "空运",
|
||||
) -> list[str]:
|
||||
"""
|
||||
询价确认字段行(销售可见)。
|
||||
|
||||
起运/目的展示销售原文(南京/北京),三字码只给出站 TMS,不对销售展示。
|
||||
"""
|
||||
origin = (facts.get("起运港") or "-").strip()
|
||||
dest = (facts.get("目的港") or "-").strip()
|
||||
cargo = (facts.get("品名") or "-").strip() or "-"
|
||||
pack = (facts.get("包装方式") or facts.get("包装类型") or "-").strip() or "-"
|
||||
quote_date = (facts.get("报价日期") or "-").strip() or "-"
|
||||
return [
|
||||
f"工单号:{work_order_no}",
|
||||
f"运输方式:{transport_mode}",
|
||||
f"起运地:{origin}",
|
||||
f"目的地:{dest}",
|
||||
f"品名:{cargo}",
|
||||
f"件数:{_with_unit(str(facts.get('件数') or ''), '件')}",
|
||||
f"重量:{_with_unit(str(facts.get('毛重') or ''), 'KGS')}",
|
||||
f"体积:{_with_unit(str(facts.get('体积') or ''), 'CBM')}",
|
||||
f"包装类型:{pack}",
|
||||
f"报价日期:{quote_date}",
|
||||
]
|
||||
|
||||
|
||||
def inquiry_card(*, work_order_no: str, facts: dict[str, str], first_or_same: str = "first") -> str:
|
||||
"""
|
||||
建单后的询价确认。
|
||||
|
||||
无标题、无 [test]。first_or_same 保留兼容,不再对销售展示。
|
||||
"""
|
||||
_ = first_or_same
|
||||
lines = inquiry_field_lines(work_order_no=work_order_no, facts=facts)
|
||||
lines.append("工单已创建,正在查询TMS报价。")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def canonical_button(raw: str) -> str:
|
||||
"""把销售点卡 / 原文 / 企微 EventKey 收成标准按钮文案;对不上返回空。"""
|
||||
text = (raw or "").strip().strip("【】")
|
||||
if text in {BTN_SKIP_COLLAB, BTN_SKIP_COLLAB_OLD, BTN_SKIP_COLLAB_KEY}:
|
||||
return BTN_SKIP_COLLAB
|
||||
# 新卡 EventKey 带工单号:skip_collab:WO…,避免点旧卡落到最新单
|
||||
if text.startswith(f"{BTN_SKIP_COLLAB_KEY}:"):
|
||||
return BTN_SKIP_COLLAB
|
||||
if text in {BTN_ADOPT_EXCEL, BTN_ADOPT_EXCEL_CARD, BTN_ADOPT_EXCEL_KEY} or text.startswith(
|
||||
f"{BTN_ADOPT_EXCEL_KEY}:"
|
||||
):
|
||||
return BTN_ADOPT_EXCEL
|
||||
if text in {BTN_ADOPT_PDF, BTN_ADOPT_PDF_CARD, BTN_ADOPT_PDF_KEY} or text.startswith(
|
||||
f"{BTN_ADOPT_PDF_KEY}:"
|
||||
):
|
||||
return BTN_ADOPT_PDF
|
||||
if text in {BTN_REJECT_KEY} or text.startswith(f"{BTN_REJECT_KEY}:"):
|
||||
return BTN_REJECT
|
||||
if text == BTN_SKIP_COLLAB_DONE:
|
||||
return ""
|
||||
for btn in (BTN_ADOPT_EXCEL, BTN_ADOPT_PDF, BTN_REJECT, BTN_DEAL, BTN_LOST, BTN_NEGOTIATE):
|
||||
if text == btn:
|
||||
return btn
|
||||
return ""
|
||||
|
||||
|
||||
def extract_work_order_no(text: str) -> str:
|
||||
"""从任意原话里抽出 WO + 12 位数字;没有则空。不判断意图。"""
|
||||
hit = _WO_NO.search(text or "")
|
||||
return hit.group(0).upper() if hit else ""
|
||||
|
||||
|
||||
def card_task_id(kind: str, work_order_no: str) -> str:
|
||||
"""
|
||||
每次出卡一个新 task_id。企微 42014:同一 task_id 不能再发,会整卡失败只剩文字。
|
||||
|
||||
工单号仍嵌在前缀里,点卡回调靠 work_order_from_card_meta 找回。
|
||||
"""
|
||||
stamp = f"{int(time.time() * 1000):x}{uuid.uuid4().hex[:4]}"
|
||||
return f"{kind}-{work_order_no}-{stamp}"
|
||||
|
||||
|
||||
def looks_like_continue_ticket(text: str) -> bool:
|
||||
"""
|
||||
无网夹具用的弱提示。正式环境必须由大模型判续办意图。
|
||||
禁止把本函数当成生产唯一路由。
|
||||
"""
|
||||
raw = (text or "").strip()
|
||||
if not raw:
|
||||
return False
|
||||
return any(token in raw for token in _CONTINUE_HINT)
|
||||
|
||||
|
||||
def work_order_from_card_meta(
|
||||
card_meta: dict[str, Any] | None = None,
|
||||
*,
|
||||
text: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
从企微点卡回调用找回「点的是哪一张卡当时的工单」。
|
||||
|
||||
优先 TaskId / EventKey 里的工单号(quote-WO…、scheme-adopt_excel-WO…)。
|
||||
对不上返回空:调用方按该销售当前最新书签处理。
|
||||
"""
|
||||
src = card_meta or {}
|
||||
for raw in (
|
||||
str(src.get("task_id") or "").strip(),
|
||||
str(src.get("event_key") or "").strip(),
|
||||
(text or "").strip(),
|
||||
):
|
||||
no = _work_order_in_token(raw)
|
||||
if no:
|
||||
return no
|
||||
return ""
|
||||
|
||||
|
||||
def _work_order_in_token(raw: str) -> str:
|
||||
"""从 TaskId / EventKey 里抽出 WO 工单号;没有则空。"""
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
for prefix in (
|
||||
f"{BTN_SKIP_COLLAB_KEY}:",
|
||||
f"{BTN_ADOPT_EXCEL_KEY}:",
|
||||
f"{BTN_ADOPT_PDF_KEY}:",
|
||||
f"{BTN_REJECT_KEY}:",
|
||||
):
|
||||
if text.startswith(prefix):
|
||||
rest = text[len(prefix) :].strip()
|
||||
if rest.upper().startswith("WO"):
|
||||
return extract_work_order_no(rest) or rest.split("-", 1)[0]
|
||||
# 卡片 task_id 会带时间后缀(scheme-WO…-abc),只取 WO+12 位。
|
||||
return extract_work_order_no(text)
|
||||
|
||||
|
||||
_CURRENCY_PREFIX = re.compile(r"^[A-Za-z]{3}\b")
|
||||
|
||||
|
||||
def money_text(amount: Any, currency: str = "") -> str:
|
||||
"""
|
||||
销售可见金额:TMS 有币种就写成「CNY 14669.60」。
|
||||
|
||||
金额已带三字码前缀则原样。没有币种不编造 CNY/USD。
|
||||
"""
|
||||
raw = str(amount if amount not in (None, "") else "").strip()
|
||||
if not raw or raw == "-":
|
||||
return raw or "-"
|
||||
if _CURRENCY_PREFIX.match(raw):
|
||||
return raw
|
||||
code = str(currency or "").strip().upper()
|
||||
if code == "RMB":
|
||||
code = "CNY"
|
||||
if not code:
|
||||
return raw
|
||||
return f"{code} {raw}"
|
||||
|
||||
|
||||
def quote_fee_lines(quote: dict[str, Any] | None) -> list[tuple[str, str]]:
|
||||
"""
|
||||
价格明细行:优先 TMS fee_lines,否则用运费/其它/合计兜底。
|
||||
|
||||
返回 (费用名, 金额) ,空金额不展示。金额带 TMS 币种,不编造。
|
||||
"""
|
||||
src = dict(quote or {})
|
||||
header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip()
|
||||
raw = src.get("fee_lines")
|
||||
parsed: Any = raw
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
parsed = None
|
||||
lines: list[tuple[str, str]] = []
|
||||
if isinstance(parsed, list):
|
||||
for item in parsed:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = str(item.get("name") or item.get("label") or "").strip()
|
||||
amount = str(item.get("amount") or item.get("value") or "").strip()
|
||||
line_ccy = str(item.get("currency") or item.get("currencyCode") or header_ccy).strip()
|
||||
if name and amount and amount != "-":
|
||||
lines.append((name, money_text(amount, line_ccy)))
|
||||
if lines:
|
||||
return lines
|
||||
freight = str(src.get("freight") or "").strip()
|
||||
other = str(src.get("other_fee") or "").strip()
|
||||
if freight and freight != "-":
|
||||
lines.append(("空运费", money_text(freight, header_ccy)))
|
||||
if other and other != "-":
|
||||
lines.append(("其他附加费", money_text(other, header_ccy)))
|
||||
return lines
|
||||
|
||||
|
||||
def quote_area_fee_text(quote: dict[str, Any] | None) -> str:
|
||||
"""
|
||||
卡片引用条正文:费用行 + 总销售价合计。
|
||||
|
||||
必须走 quote_text。塞进 quote_area.title 会被企微整段藏掉。
|
||||
"""
|
||||
src = dict(quote or {})
|
||||
header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip()
|
||||
total = money_text(str(src.get("total") or "-").strip() or "-", header_ccy)
|
||||
fee_lines = [f"{name}:{amount}" for name, amount in quote_fee_lines(src)]
|
||||
fee_lines.append(f"总销售价合计:{total}")
|
||||
return "\n".join(fee_lines)
|
||||
|
||||
|
||||
def collab_hint(work_order_no: str) -> str:
|
||||
"""价格卡协同说明:无「内部协同」前缀,紧挨生成报价单按钮上方。"""
|
||||
return (
|
||||
f"若需要咨询内部航线人员,请手动拉新人与智能体到原有群,"
|
||||
f"并在群聊发送工单编号{work_order_no}。"
|
||||
)
|
||||
|
||||
|
||||
def _strip_leading_dash(raw: str) -> str:
|
||||
"""去掉时效前导 '-' / '—',避免销售看到「-3-5天」或「-;3-5天」。"""
|
||||
text = (raw or "").strip()
|
||||
while text.startswith(("-", "—", "–")):
|
||||
rest = text[1:].lstrip(" \t;;,,")
|
||||
if not rest:
|
||||
return ""
|
||||
text = rest
|
||||
return text
|
||||
|
||||
|
||||
def tms_eta_display(quote: dict[str, Any] | None) -> str:
|
||||
"""时效只取 TMS 回的 eta / 预计送达;没有就「-」。禁止本地编。前导横杠会剥掉。"""
|
||||
src = dict(quote or {})
|
||||
eta = _strip_leading_dash(str(src.get("eta") or ""))
|
||||
if eta:
|
||||
return eta
|
||||
dated = _strip_leading_dash(
|
||||
str(src.get("eta_date") or src.get("estimated_delivery") or "")
|
||||
)
|
||||
return dated or "-"
|
||||
|
||||
|
||||
def tms_valid_until_display(quote: dict[str, Any] | None) -> str:
|
||||
"""报价有效期只取 TMS 原文,没有就 "-"。"""
|
||||
src = dict(quote or {})
|
||||
raw = str(
|
||||
src.get("valid_until")
|
||||
or src.get("validUntil")
|
||||
or src.get("quoteValidUntil")
|
||||
or ""
|
||||
).strip()
|
||||
return raw if raw else "-"
|
||||
|
||||
|
||||
def similar_inquiry_line(*, first_or_same: str, history_work_order_no: str = "") -> str:
|
||||
"""
|
||||
相同询价内容检索:首次询价,或引用全库最新一条一模一样的历史工单号。
|
||||
"""
|
||||
no = (history_work_order_no or "").strip()
|
||||
if first_or_same == "same" and no:
|
||||
return f"相同询价内容检索:引用历史工单号{no}"
|
||||
return "相同询价内容检索:首次询价"
|
||||
|
||||
|
||||
def quote_detail_lines(
|
||||
quote: dict[str, Any] | None,
|
||||
*,
|
||||
first_or_same: str = "first",
|
||||
history_work_order_no: str = "",
|
||||
status: str = "已报价",
|
||||
) -> list[str]:
|
||||
"""
|
||||
价格明细正文:TMS 费用、合计、时效、有效期、相同检索。
|
||||
|
||||
给文本兜底和企微引用条共用,禁止在这里编时效/有效期。价格卡不再展示工单状态。
|
||||
"""
|
||||
_ = status
|
||||
src = dict(quote or {})
|
||||
header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip()
|
||||
total = money_text(str(src.get("total") or "-").strip() or "-", header_ccy)
|
||||
lines = [f"{name}:{amount}" for name, amount in quote_fee_lines(src)]
|
||||
lines.append(f"总销售价合计:{total}")
|
||||
lines.append(f"时效:{tms_eta_display(src)}")
|
||||
lines.append(f"报价有效期:{tms_valid_until_display(src)}")
|
||||
lines.append(
|
||||
similar_inquiry_line(
|
||||
first_or_same=first_or_same, history_work_order_no=history_work_order_no
|
||||
)
|
||||
)
|
||||
return lines
|
||||
|
||||
|
||||
def tms_hit_card(
|
||||
*,
|
||||
work_order_no: str,
|
||||
quote: dict[str, Any],
|
||||
first_or_same: str = "first",
|
||||
history_work_order_no: str = "",
|
||||
status: str = "已报价",
|
||||
) -> str:
|
||||
"""
|
||||
有价后的价格明细(文本兜底,模板卡发失败时仍给销售看)。
|
||||
|
||||
顺序:来源 → 价格明细 → 报价正文 → 协同说明 → 按钮。
|
||||
"""
|
||||
src = dict(quote or {})
|
||||
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
|
||||
lines = [source, f"{work_order_no}价格明细"]
|
||||
lines.extend(
|
||||
quote_detail_lines(
|
||||
src,
|
||||
first_or_same=first_or_same,
|
||||
history_work_order_no=history_work_order_no,
|
||||
status=status,
|
||||
)
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(collab_hint(work_order_no))
|
||||
lines.append(BTN_SKIP_COLLAB)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def tms_hit_wecom_payload(
|
||||
*,
|
||||
work_order_no: str,
|
||||
quote: dict[str, Any],
|
||||
first_or_same: str = "first",
|
||||
history_work_order_no: str = "",
|
||||
status: str = "已报价",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
一张价格卡,销售侧顺序:
|
||||
|
||||
价格明细
|
||||
┌ 引用条:空运费 / 其它费用 / 总销售价合计
|
||||
时效、报价有效期、相同询价内容检索
|
||||
协同说明(普通文字,贴按钮上方)
|
||||
按钮
|
||||
|
||||
企微 quote_area.title 只能很短,超长会被整段藏掉。报价必须放 quote_text。
|
||||
不用 horizontal:它会排到副标题后面,把费用挤到按钮前。
|
||||
"""
|
||||
src = dict(quote or {})
|
||||
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
|
||||
meta = "\n".join(
|
||||
[
|
||||
f"时效:{tms_eta_display(src)}",
|
||||
f"报价有效期:{tms_valid_until_display(src)}",
|
||||
similar_inquiry_line(
|
||||
first_or_same=first_or_same, history_work_order_no=history_work_order_no
|
||||
),
|
||||
"",
|
||||
collab_hint(work_order_no),
|
||||
]
|
||||
)
|
||||
return {
|
||||
"msgtype": "template_card",
|
||||
"template_card": {
|
||||
"card_type": "button_interaction",
|
||||
"source": {"desc": source, "desc_color": 0},
|
||||
"main_title": {"title": f"{work_order_no}价格明细"},
|
||||
"quote_area": {
|
||||
"type": 0,
|
||||
"quote_text": quote_area_fee_text(src),
|
||||
},
|
||||
"sub_title_text": meta,
|
||||
"task_id": card_task_id("quote", work_order_no),
|
||||
"button_list": [
|
||||
{
|
||||
"text": BTN_SKIP_COLLAB,
|
||||
"style": 1,
|
||||
# EventKey 也带工单号:TaskId 偶发缺失时仍能找回当时那一单
|
||||
"key": f"{BTN_SKIP_COLLAB_KEY}:{work_order_no}",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def skip_collab_card_update(*, response_code: str) -> dict[str, Any]:
|
||||
"""
|
||||
点过「生成报价单」后更新原卡:按钮改成已提交并保持置灰。
|
||||
|
||||
必须带企微回调 ResponseCode;无码则不要入出站。
|
||||
"""
|
||||
return {
|
||||
"msgtype": "update_template_card",
|
||||
"response_code": response_code,
|
||||
"button": {"replace_name": BTN_SKIP_COLLAB_DONE},
|
||||
}
|
||||
|
||||
|
||||
def tms_miss_quote_text(*, work_order_no: str) -> str:
|
||||
"""引用条正文:只放「请务必在群聊发送工单编号」。"""
|
||||
return f"请务必在群聊发送工单编号{work_order_no}"
|
||||
|
||||
|
||||
def tms_miss_hint() -> str:
|
||||
"""卡片正文:无价说明 + 拉群提示,不单独再写标题下灰字。"""
|
||||
return "TMS标准价库暂无匹配报价,请手动拉新人与智能体到原有群"
|
||||
|
||||
|
||||
def tms_miss(*, work_order_no: str) -> str:
|
||||
"""
|
||||
TMS 已经查过、确认没标准价时的文字兜底。
|
||||
禁止在没打 TMS 前用本句冒充无价。
|
||||
"""
|
||||
return (
|
||||
f"TMS暂无报价。{tms_miss_hint()},"
|
||||
f"{tms_miss_quote_text(work_order_no=work_order_no)}"
|
||||
)
|
||||
|
||||
|
||||
def tms_miss_wecom_payload(*, work_order_no: str, action_url: str = "") -> dict[str, Any]:
|
||||
"""
|
||||
TMS 已查过、确认没价:一张 text_notice 卡。
|
||||
|
||||
顺序:来源 → 标题(仅工单号+暂无报价)→ 引用条(工单编号)→ 拉群说明。
|
||||
禁止:标题下灰字、工单状态、查价结果/来源行、按钮。
|
||||
企微 text_notice 必须带可信域名 card_action,否则整卡发失败只剩文字。
|
||||
"""
|
||||
card: dict[str, Any] = {
|
||||
"card_type": "text_notice",
|
||||
"source": {"desc": "TMS查价结果", "desc_color": 0},
|
||||
"main_title": {"title": f"{work_order_no}暂无报价"},
|
||||
"quote_area": {
|
||||
"type": 0,
|
||||
"quote_text": tms_miss_quote_text(work_order_no=work_order_no),
|
||||
},
|
||||
"sub_title_text": tms_miss_hint(),
|
||||
"task_id": card_task_id("miss", work_order_no),
|
||||
}
|
||||
url = (action_url or "").strip()
|
||||
if url:
|
||||
card["card_action"] = {"type": 1, "url": url}
|
||||
return {"msgtype": "template_card", "template_card": card}
|
||||
|
||||
|
||||
def tms_contract_gap(*, work_order_no: str) -> str:
|
||||
"""已废弃:不再因字典对不上拦查价;保留以免旧单测/引用报错。"""
|
||||
return tms_miss(work_order_no=work_order_no)
|
||||
|
||||
|
||||
def tms_need_more(*, work_order_no: str, hint: str = "") -> str:
|
||||
"""已废弃:TMS 回 1003 按合同收成暂无报价。保留以免旧引用报错。"""
|
||||
_ = hint
|
||||
return tms_miss(work_order_no=work_order_no)
|
||||
|
||||
|
||||
def skip_collab_ack() -> str:
|
||||
return "好的,已跳过内部协同。正在按 TMS 标准报价生成报价单草稿…"
|
||||
|
||||
|
||||
def scheme_card(*, work_order_no: str, status: str, facts: dict[str, str], quote: dict[str, Any]) -> str:
|
||||
"""
|
||||
报价方案文本兜底(模板卡发失败时仍给销售看)。
|
||||
|
||||
与卡片同一套字段,禁止漏项。不再展示工单状态。
|
||||
"""
|
||||
_ = status
|
||||
src = dict(quote or {})
|
||||
# 方案卡不展示询价「报价日期」,改展示 TMS 报价有效期
|
||||
body = "\n".join(
|
||||
line
|
||||
for line in inquiry_field_lines(work_order_no=work_order_no, facts=facts)
|
||||
if not line.startswith("报价日期:")
|
||||
)
|
||||
eta = tms_eta_display(src)
|
||||
valid = tms_valid_until_display(src)
|
||||
lines = [
|
||||
f"【{work_order_no}报价方案】",
|
||||
body,
|
||||
"价格明细",
|
||||
quote_area_fee_text(src),
|
||||
f"时效:{eta or '-'}",
|
||||
f"报价有效期:{valid}",
|
||||
"请确认是否采用该报价方案:",
|
||||
f"【{BTN_ADOPT_EXCEL_CARD}】",
|
||||
f"【{BTN_ADOPT_PDF_CARD}】",
|
||||
f"【{BTN_REJECT}】",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _clip(raw: str, limit: int) -> str:
|
||||
"""企微列表格有字数建议;超长才裁,避免空白。"""
|
||||
text = (raw or "").strip() or "-"
|
||||
return text if len(text) <= limit else text[: limit - 1] + "…"
|
||||
|
||||
|
||||
def scheme_horizontal_rows(
|
||||
*,
|
||||
work_order_no: str,
|
||||
facts: dict[str, str],
|
||||
quote: dict[str, Any],
|
||||
) -> list[dict[str, str]]:
|
||||
"""
|
||||
卡片字段:一行一项,每格独立字数,不会像副标题整段截成 8CB…。
|
||||
|
||||
企微 horizontal 最多 6 条。运输方式写进航线行,避免丢字段。
|
||||
不用 sub_title 堆字段(它会排到列表前面,且超长必截断)。
|
||||
"""
|
||||
src = facts or {}
|
||||
q = dict(quote or {})
|
||||
origin = (src.get("起运港") or "-").strip() or "-"
|
||||
dest = (src.get("目的港") or "-").strip() or "-"
|
||||
cargo = (src.get("品名") or "-").strip() or "-"
|
||||
pieces = _with_unit(str(src.get("件数") or ""), "件")
|
||||
weight = _with_unit(str(src.get("毛重") or ""), "KGS")
|
||||
volume = _with_unit(str(src.get("体积") or ""), "CBM")
|
||||
pack = (src.get("包装方式") or src.get("包装类型") or "-").strip() or "-"
|
||||
return [
|
||||
{"keyname": "工单号", "value": _clip(work_order_no, 26)},
|
||||
{"keyname": "航线", "value": _clip(f"空运 {origin}→{dest}", 26)},
|
||||
{"keyname": "货物", "value": _clip(f"{cargo};{pieces};{weight};{volume}", 26)},
|
||||
{"keyname": "包装", "value": _clip(pack, 26)},
|
||||
{"keyname": "时效", "value": _clip(tms_eta_display(q), 26)},
|
||||
{"keyname": "报价有效期", "value": _clip(tms_valid_until_display(q), 26)},
|
||||
]
|
||||
|
||||
|
||||
def scheme_wecom_payload(
|
||||
*,
|
||||
work_order_no: str,
|
||||
facts: dict[str, str],
|
||||
quote: dict[str, Any],
|
||||
status: str = "已报价",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
一张报价方案卡:标题带工单号、引用条、字段、三个短按钮。
|
||||
|
||||
按钮文案必须短:生成Excel / 生成PDF / 不采用。长文案横排会被裁。
|
||||
"""
|
||||
_ = status
|
||||
src = dict(quote or {})
|
||||
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
|
||||
return {
|
||||
"msgtype": "template_card",
|
||||
"template_card": {
|
||||
"card_type": "button_interaction",
|
||||
"source": {"desc": source, "desc_color": 0},
|
||||
"main_title": {"title": f"{work_order_no}报价方案"},
|
||||
"quote_area": {
|
||||
"type": 0,
|
||||
"quote_text": quote_area_fee_text(src),
|
||||
},
|
||||
"horizontal_content_list": scheme_horizontal_rows(
|
||||
work_order_no=work_order_no, facts=facts, quote=src
|
||||
),
|
||||
"task_id": card_task_id("scheme", work_order_no),
|
||||
"button_list": [
|
||||
{
|
||||
"text": BTN_ADOPT_EXCEL_CARD,
|
||||
"style": 1,
|
||||
"key": f"{BTN_ADOPT_EXCEL_KEY}:{work_order_no}",
|
||||
},
|
||||
{
|
||||
"text": BTN_ADOPT_PDF_CARD,
|
||||
"style": 1,
|
||||
"key": f"{BTN_ADOPT_PDF_KEY}:{work_order_no}",
|
||||
},
|
||||
{
|
||||
"text": BTN_REJECT,
|
||||
"style": 2,
|
||||
"key": f"{BTN_REJECT_KEY}:{work_order_no}",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def reject_ack() -> str:
|
||||
return "请问如何调整报价单,以满足您的需求?您可使用「报价加 10%」等调价指令。"
|
||||
|
||||
|
||||
def adopt_excel_ack() -> str:
|
||||
return "已确认采用该方案,正在生成 Excel 最终报价单…"
|
||||
|
||||
|
||||
def adopt_pdf_ack() -> str:
|
||||
return "已确认采用该方案,正在生成PDF最终报价单…"
|
||||
|
||||
|
||||
QUOTE_TEMPLATE_MISSING = "生成报价单失败,请联系IT运维维护后台报价模板。"
|
||||
|
||||
|
||||
def quote_template_missing() -> str:
|
||||
"""关键词与默认模板都未维护时的销售提示(产品口径,勿改字)。"""
|
||||
return QUOTE_TEMPLATE_MISSING
|
||||
|
||||
|
||||
def file_ready(*, kind: str, work_order_no: str, filename: str) -> str:
|
||||
return f"已生成{kind}最终报价单:{filename}(工单 {work_order_no})"
|
||||
|
||||
|
||||
def deal_card(*, work_order_no: str, status: str, summary: str) -> str:
|
||||
return (
|
||||
"【咨询成交状态】\n"
|
||||
f"工单编号:{work_order_no}\n"
|
||||
f"工单状态:{status}\n"
|
||||
f"询价内容:{summary}\n"
|
||||
"请立即确认当前与客户沟通的成交状态:\n"
|
||||
f"【{BTN_DEAL}】【{BTN_LOST}】【{BTN_NEGOTIATE}】"
|
||||
)
|
||||
|
||||
|
||||
def deal_ack(outcome: str, work_order_no: str) -> str:
|
||||
if outcome == "已成交":
|
||||
return f"已记录为「已成交」。工单 {work_order_no} 已关闭智能体跟进。"
|
||||
if outcome == "未成交":
|
||||
return f"已记录为「未成交」。请补充未成交原因,工单 {work_order_no} 将关闭智能体跟进。"
|
||||
return f"已记录为「协商中」。工单 {work_order_no} 跟进周期已顺延。"
|
||||
Reference in New Issue
Block a user