让成交卡等报价文件真正发出后再出现,并收紧 PDF 顶部留白,避免抬头被裁或空一截。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -15,7 +15,6 @@ 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
|
||||
@@ -25,7 +24,8 @@ from agent.schema.tms_air_query import assemble_air_query
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ReplyFn = Callable[..., None]
|
||||
# 可返回 outbox id;查价前要等询价确认真正发出。
|
||||
ReplyFn = Callable[..., Optional[str]]
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -249,18 +249,13 @@ class AirTextInquiryFlow:
|
||||
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":
|
||||
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,
|
||||
)
|
||||
)
|
||||
self._emit_deal(reply, sess)
|
||||
return "wait_deal"
|
||||
if sess.phase == "done":
|
||||
reply(copy.deal_ack(sess.status or "已关闭", sess.work_order_no))
|
||||
@@ -426,7 +421,9 @@ class AirTextInquiryFlow:
|
||||
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))
|
||||
out_id = reply(copy.inquiry_card(work_order_no=no, facts=facts, first_or_same=first)) or ""
|
||||
# 询价确认必须先到销售手机,再打 TMS。否则查得快时暂无报价卡会抢先发出。
|
||||
self._wait_inquiry_sent(str(out_id), work_order_no=no)
|
||||
|
||||
tms = self._ledger.query_tms(work_order_no=no, facts=facts)
|
||||
sess = FlowSession(
|
||||
@@ -453,6 +450,7 @@ class AirTextInquiryFlow:
|
||||
self._emit_tms_miss(reply, no)
|
||||
sess.phase = "tms_miss"
|
||||
sess.status = "询价中"
|
||||
sess.allowed = ()
|
||||
self._save(sess)
|
||||
return "tms_miss"
|
||||
|
||||
@@ -487,11 +485,41 @@ class AirTextInquiryFlow:
|
||||
)
|
||||
return "wait_collab"
|
||||
|
||||
def _wait_inquiry_sent(self, out_id: str, *, work_order_no: str) -> None:
|
||||
"""等询价确认出站完成后再查价。"""
|
||||
self._wait_outbound_sent(out_id, work_order_no=work_order_no, what="询价确认")
|
||||
|
||||
def _wait_outbound_sent(self, out_id: str, *, work_order_no: str, what: str) -> None:
|
||||
"""
|
||||
等指定出站真正发出。只等这一条,不锁其它工单。
|
||||
|
||||
单测 reply 不回 id 则跳过。超时仍继续,避免卡死 inbox。
|
||||
"""
|
||||
raw = (out_id or "").strip()
|
||||
if not raw:
|
||||
return
|
||||
from agent.channel.queue import get_message_store
|
||||
|
||||
store = get_message_store()
|
||||
waiter = getattr(store, "wait_outbound_done", None)
|
||||
if not callable(waiter):
|
||||
return
|
||||
ok = waiter(raw, timeout_sec=30.0)
|
||||
if not ok:
|
||||
logger.warning(
|
||||
"%s出站未在时限内完成 wo=%s out=%s,仍继续",
|
||||
what,
|
||||
work_order_no,
|
||||
raw,
|
||||
)
|
||||
|
||||
def _emit_tms_miss(self, reply: ReplyFn, work_order_no: str) -> None:
|
||||
"""
|
||||
TMS 已查过且确认没价:文字兜底 + 暂无报价卡片。
|
||||
禁止在没打 query_tms 前回这个。
|
||||
"""
|
||||
from agent.config import get_settings
|
||||
|
||||
reply(
|
||||
copy.tms_miss(work_order_no=work_order_no),
|
||||
copy.tms_miss_wecom_payload(
|
||||
@@ -499,6 +527,8 @@ class AirTextInquiryFlow:
|
||||
action_url=(get_settings().public_base_url or "").strip(),
|
||||
),
|
||||
)
|
||||
# 卡片发出后再单独发拉群句,不塞进卡、不当卡片失败兜底。
|
||||
reply(copy.tms_miss_followup(work_order_no=work_order_no))
|
||||
|
||||
def _resolve_similar(
|
||||
self,
|
||||
@@ -542,6 +572,15 @@ class AirTextInquiryFlow:
|
||||
"""处理已允许的按钮;错版本/未允许/已点过直接丢弃。"""
|
||||
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",
|
||||
@@ -577,11 +616,11 @@ class AirTextInquiryFlow:
|
||||
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, "已成交")
|
||||
return self._close(sess, reply, "已成交", card_meta=card_meta)
|
||||
if action == copy.BTN_LOST:
|
||||
return self._close(sess, reply, "未成交")
|
||||
return self._close(sess, reply, "未成交", card_meta=card_meta)
|
||||
if action == copy.BTN_NEGOTIATE:
|
||||
return self._close(sess, reply, "协商中")
|
||||
return self._close(sess, reply, "协商中", card_meta=card_meta)
|
||||
return "card_unknown"
|
||||
|
||||
def _skip_collab(
|
||||
@@ -617,14 +656,24 @@ class AirTextInquiryFlow:
|
||||
)
|
||||
return "wait_adopt"
|
||||
|
||||
def _grey_clicked_card(self, reply: ReplyFn, card_meta: Optional[dict[str, Any]]) -> None:
|
||||
"""点过方案卡后把原卡按钮改成已提交;无 ResponseCode 则不更新。"""
|
||||
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),
|
||||
copy.skip_collab_card_update(response_code=code, replace_name=replace_name),
|
||||
)
|
||||
|
||||
def _adopt_file(
|
||||
@@ -670,35 +719,75 @@ class AirTextInquiryFlow:
|
||||
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 报价单。")
|
||||
reply(
|
||||
copy.file_ready(kind="Excel", work_order_no=sess.work_order_no, filename=fallback),
|
||||
file_extra,
|
||||
return self._emit_file_then_deal(
|
||||
sess,
|
||||
reply,
|
||||
kind="Excel",
|
||||
filename=fallback,
|
||||
file_extra=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('品名', '')}"
|
||||
# 已入队:等 Worker 出文件后再出成交卡,这里不提前发卡
|
||||
sess.phase = "wait_file"
|
||||
sess.allowed = ()
|
||||
sess.wait_version += 1
|
||||
self._save(sess)
|
||||
return "wait_file"
|
||||
return self._emit_file_then_deal(
|
||||
sess, reply, kind=kind, filename=filename, file_extra=file_extra
|
||||
)
|
||||
|
||||
def _emit_file_then_deal(
|
||||
self,
|
||||
sess: FlowSession,
|
||||
reply: ReplyFn,
|
||||
*,
|
||||
kind: str,
|
||||
filename: str,
|
||||
file_extra: dict[str, Any],
|
||||
) -> str:
|
||||
"""
|
||||
先发报价文件,等这条出站真正发出,再出成交跟进卡。
|
||||
|
||||
避免卡片比文件先到销售手机。单测 reply 不回 id 则不等待。
|
||||
"""
|
||||
out_id = reply(
|
||||
copy.file_ready(kind=kind, work_order_no=sess.work_order_no, filename=filename),
|
||||
file_extra,
|
||||
)
|
||||
self._wait_outbound_sent(str(out_id or ""), work_order_no=sess.work_order_no, what="报价文件")
|
||||
sess.phase = "wait_deal"
|
||||
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
|
||||
sess.wait_version += 1
|
||||
self._save(sess)
|
||||
self._emit_deal(reply, sess)
|
||||
return "wait_deal"
|
||||
|
||||
def _emit_deal(self, reply: ReplyFn, sess: FlowSession) -> None:
|
||||
"""
|
||||
报价单发出后出成交跟进卡:文字兜底 + 企微模板卡。
|
||||
字段以报价当时的 quote 为准,不编历史报价/工单状态/来源行。
|
||||
"""
|
||||
reply(
|
||||
copy.deal_card(
|
||||
work_order_no=sess.work_order_no,
|
||||
status=sess.status or "已报价",
|
||||
summary=summary,
|
||||
)
|
||||
quote=sess.quote,
|
||||
facts=sess.facts,
|
||||
),
|
||||
copy.deal_wecom_payload(
|
||||
work_order_no=sess.work_order_no,
|
||||
quote=sess.quote,
|
||||
facts=sess.facts,
|
||||
),
|
||||
)
|
||||
return "wait_deal"
|
||||
|
||||
def _quote_file_payload(self, result: dict[str, Any], *, filename: str) -> dict[str, Any]:
|
||||
"""出站文件载荷:优先本机路径,否则主账回的 Base64。"""
|
||||
@@ -716,6 +805,8 @@ class AirTextInquiryFlow:
|
||||
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。
|
||||
@@ -744,13 +835,23 @@ class AirTextInquiryFlow:
|
||||
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) -> str:
|
||||
def _close(
|
||||
self,
|
||||
sess: FlowSession,
|
||||
reply: ReplyFn,
|
||||
status: str,
|
||||
card_meta: Optional[dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""点成交/未成交/协商中:先整卡置灰,再入主账。"""
|
||||
self._grey_clicked_card(reply, card_meta, replace_name=status)
|
||||
result = self._ledger.transition(work_order_no=sess.work_order_no, to_status=status)
|
||||
if not result.get("ok"):
|
||||
reply("主账状态更新失败,成交结果未入账。")
|
||||
@@ -762,6 +863,70 @@ class AirTextInquiryFlow:
|
||||
reply(copy.deal_ack(status, sess.work_order_no))
|
||||
return "done"
|
||||
|
||||
def mark_wait_deal(self, sender_id: str, work_order_no: str) -> None:
|
||||
"""
|
||||
Worker 出完报价文件后:先写 Redis,再切本进程书签。
|
||||
|
||||
HTTP 与 Worker 不是同一份内存。只改本进程会让销售一直看到「请稍候」。
|
||||
找不到本进程书签也要写 Redis,让收消息那边能切到成交跟进。
|
||||
"""
|
||||
no = (work_order_no or "").strip()
|
||||
if not no:
|
||||
return
|
||||
from agent.redis_coord.quote_file import mark_quote_file_ready
|
||||
|
||||
mark_quote_file_ready(no)
|
||||
if not sender_id:
|
||||
return
|
||||
with self._lock:
|
||||
sess = self._by_sender.get(sender_id)
|
||||
if sess is None or sess.work_order_no != no:
|
||||
sess = self._by_ticket.get(no)
|
||||
if sess is None:
|
||||
return
|
||||
self._promote_wait_deal(sess)
|
||||
|
||||
def _promote_wait_deal(self, sess: FlowSession) -> None:
|
||||
"""书签切到成交跟进,允许点成交/未成交/协商中。"""
|
||||
sess.phase = "wait_deal"
|
||||
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
|
||||
sess.wait_version += 1
|
||||
self._save(sess)
|
||||
|
||||
def _refresh_wait_file(self, sess: FlowSession) -> None:
|
||||
"""
|
||||
wait_file 时看文件是否已经发出。
|
||||
|
||||
Worker 写 Redis;本进程重启后也可看出站成交卡/文件去重键。
|
||||
"""
|
||||
if sess.phase != "wait_file":
|
||||
return
|
||||
no = (sess.work_order_no or "").strip()
|
||||
if not no:
|
||||
return
|
||||
if self._quote_file_already_out(no):
|
||||
self._promote_wait_deal(sess)
|
||||
|
||||
def _quote_file_already_out(self, work_order_no: str) -> bool:
|
||||
"""文件或成交卡是否已经入过出站。"""
|
||||
from agent.redis_coord.quote_file import is_quote_file_ready
|
||||
|
||||
if is_quote_file_ready(work_order_no):
|
||||
return True
|
||||
try:
|
||||
from agent.channel.queue import get_message_store
|
||||
|
||||
store = get_message_store()
|
||||
probe = getattr(store, "has_outbound_dedupe", None)
|
||||
if not callable(probe):
|
||||
return False
|
||||
return bool(
|
||||
probe(f"deal-card:{work_order_no}:") or probe(f"pdf-file:{work_order_no}:")
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("判断报价文件是否已出站失败 wo=%s", work_order_no)
|
||||
return False
|
||||
|
||||
|
||||
_FLOW: Optional[AirTextInquiryFlow] = None
|
||||
_FLOW_GUARD = threading.Lock()
|
||||
|
||||
@@ -47,6 +47,12 @@ BTN_REJECT_KEY = "reject"
|
||||
BTN_DEAL = "成交"
|
||||
BTN_LOST = "未成交"
|
||||
BTN_NEGOTIATE = "协商中"
|
||||
BTN_DEAL_KEY = "deal"
|
||||
BTN_LOST_KEY = "lost"
|
||||
BTN_NEGOTIATE_KEY = "negotiate"
|
||||
# 旧卡 EventKey 兼容;新暂无报价卡按现网图:无按钮。
|
||||
BTN_COPY_TICKET = "复制工单号"
|
||||
BTN_COPY_TICKET_KEY = "copy_ticket"
|
||||
|
||||
_WO_NO = re.compile(r"WO\d{12}", re.IGNORECASE)
|
||||
# 仅给无网 stub / 工单号回填用,正式续办意图由 DeepSeek A 判断,不要求销售说这些词。
|
||||
@@ -245,6 +251,18 @@ def canonical_button(raw: str) -> str:
|
||||
return BTN_ADOPT_PDF
|
||||
if text in {BTN_REJECT_KEY} or text.startswith(f"{BTN_REJECT_KEY}:"):
|
||||
return BTN_REJECT
|
||||
if text in {BTN_COPY_TICKET, BTN_COPY_TICKET_KEY} or text.startswith(
|
||||
f"{BTN_COPY_TICKET_KEY}:"
|
||||
):
|
||||
return BTN_COPY_TICKET
|
||||
if text in {BTN_DEAL, BTN_DEAL_KEY} or text.startswith(f"{BTN_DEAL_KEY}:"):
|
||||
return BTN_DEAL
|
||||
if text in {BTN_LOST, BTN_LOST_KEY} or text.startswith(f"{BTN_LOST_KEY}:"):
|
||||
return BTN_LOST
|
||||
if text in {BTN_NEGOTIATE, BTN_NEGOTIATE_KEY} or text.startswith(
|
||||
f"{BTN_NEGOTIATE_KEY}:"
|
||||
):
|
||||
return BTN_NEGOTIATE
|
||||
if text == BTN_SKIP_COLLAB_DONE:
|
||||
return ""
|
||||
for btn in (BTN_ADOPT_EXCEL, BTN_ADOPT_PDF, BTN_REJECT, BTN_DEAL, BTN_LOST, BTN_NEGOTIATE):
|
||||
@@ -313,6 +331,10 @@ def _work_order_in_token(raw: str) -> str:
|
||||
f"{BTN_ADOPT_EXCEL_KEY}:",
|
||||
f"{BTN_ADOPT_PDF_KEY}:",
|
||||
f"{BTN_REJECT_KEY}:",
|
||||
f"{BTN_COPY_TICKET_KEY}:",
|
||||
f"{BTN_DEAL_KEY}:",
|
||||
f"{BTN_LOST_KEY}:",
|
||||
f"{BTN_NEGOTIATE_KEY}:",
|
||||
):
|
||||
if text.startswith(prefix):
|
||||
rest = text[len(prefix) :].strip()
|
||||
@@ -439,7 +461,7 @@ def tms_valid_until_display(quote: dict[str, Any] | None) -> str:
|
||||
|
||||
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:
|
||||
@@ -455,11 +477,12 @@ def quote_detail_lines(
|
||||
status: str = "已报价",
|
||||
) -> list[str]:
|
||||
"""
|
||||
价格明细正文:TMS 费用、合计、时效、有效期、相同检索。
|
||||
价格明细正文:TMS 费用、合计、时效、有效期。
|
||||
|
||||
给文本兜底和企微引用条共用,禁止在这里编时效/有效期。价格卡不再展示工单状态。
|
||||
给文本兜底和企微引用条共用,禁止在这里编时效/有效期。
|
||||
价格卡不展示工单状态,也不展示相同询价内容检索。
|
||||
"""
|
||||
_ = status
|
||||
_ = status, first_or_same, history_work_order_no
|
||||
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)
|
||||
@@ -467,11 +490,6 @@ def quote_detail_lines(
|
||||
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
|
||||
|
||||
|
||||
@@ -518,22 +536,21 @@ def tms_hit_wecom_payload(
|
||||
|
||||
价格明细
|
||||
┌ 引用条:空运费 / 其它费用 / 总销售价合计
|
||||
时效、报价有效期、相同询价内容检索
|
||||
时效、报价有效期
|
||||
协同说明(普通文字,贴按钮上方)
|
||||
按钮
|
||||
|
||||
相同询价内容检索对销售隐藏;first_or_same 只兼容调用方,不展示。
|
||||
企微 quote_area.title 只能很短,超长会被整段藏掉。报价必须放 quote_text。
|
||||
不用 horizontal:它会排到副标题后面,把费用挤到按钮前。
|
||||
"""
|
||||
_ = first_or_same, history_work_order_no
|
||||
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),
|
||||
]
|
||||
@@ -562,58 +579,86 @@ def tms_hit_wecom_payload(
|
||||
}
|
||||
|
||||
|
||||
def skip_collab_card_update(*, response_code: str) -> dict[str, Any]:
|
||||
def skip_collab_card_update(*, response_code: str, replace_name: str = "") -> dict[str, Any]:
|
||||
"""
|
||||
点过「生成报价单」后更新原卡:按钮改成已提交并保持置灰。
|
||||
点过模板卡后更新原卡:被点按钮改成已提交,整卡置灰不可再点。
|
||||
|
||||
必须带企微回调 ResponseCode;无码则不要入出站。
|
||||
成交卡三个按钮共用这一条,replace_name 默认「已提交」。
|
||||
"""
|
||||
return {
|
||||
"msgtype": "update_template_card",
|
||||
"response_code": response_code,
|
||||
"button": {"replace_name": BTN_SKIP_COLLAB_DONE},
|
||||
"button": {"replace_name": (replace_name or BTN_SKIP_COLLAB_DONE).strip() or BTN_SKIP_COLLAB_DONE},
|
||||
}
|
||||
|
||||
|
||||
def tms_miss_quote_text(*, work_order_no: str) -> str:
|
||||
"""引用条正文:只放「请务必在群聊发送工单编号」。"""
|
||||
return f"请务必在群聊发送工单编号{work_order_no}"
|
||||
"""文字兜底里的拉群句,与现网图一致。"""
|
||||
return f"请手动拉相关人员和智能体到原有群,并在群聊发送工单编号{work_order_no}"
|
||||
|
||||
|
||||
def tms_miss_hint() -> str:
|
||||
"""卡片正文:无价说明 + 拉群提示,不单独再写标题下灰字。"""
|
||||
return "TMS标准价库暂无匹配报价,请手动拉新人与智能体到原有群"
|
||||
"""卡片标题下灰字:与现网图一致。"""
|
||||
return "TMS 标准价库暂无匹配报价"
|
||||
|
||||
|
||||
def tms_miss_body() -> str:
|
||||
"""卡片中间说明:需求已建单。"""
|
||||
return "需求已解析并创建工单。"
|
||||
|
||||
|
||||
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)}"
|
||||
)
|
||||
no = (work_order_no or "").strip()
|
||||
return f"询价工单{no}已建立,TMS标准价库暂无匹配报价。"
|
||||
|
||||
|
||||
def tms_miss_wecom_payload(*, work_order_no: str, action_url: str = "") -> dict[str, Any]:
|
||||
def tms_miss_followup(*, work_order_no: str) -> str:
|
||||
"""出卡后再发的独立一句:拉人进群并带上工单号。"""
|
||||
return tms_miss_quote_text(work_order_no=work_order_no)
|
||||
|
||||
|
||||
def copy_ticket_text(*, work_order_no: str) -> str:
|
||||
"""
|
||||
TMS 已查过、确认没价:一张 text_notice 卡。
|
||||
点「复制工单号」后回给销售的正文:只含工单号。
|
||||
|
||||
顺序:来源 → 标题(仅工单号+暂无报价)→ 引用条(工单编号)→ 拉群说明。
|
||||
禁止:标题下灰字、工单状态、查价结果/来源行、按钮。
|
||||
企微 text_notice 必须带可信域名 card_action,否则整卡发失败只剩文字。
|
||||
企微模板卡按钮不能写系统剪贴板。整条消息只有工单号,销售长按「复制」不会夹杂其它字。
|
||||
"""
|
||||
return (work_order_no or "").strip()
|
||||
|
||||
|
||||
def tms_miss_wecom_payload(
|
||||
*,
|
||||
work_order_no: str,
|
||||
action_url: str = "",
|
||||
copy_url: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
TMS 已查过、确认没价:一张文本通知卡(对齐现网图)。
|
||||
|
||||
询价工单号 / 灰字无价说明 / 建单说明 / 工单状态与查价结果。
|
||||
不要「来源」行、不要按钮。企微 text_notice 必须带可信域名 card_action。
|
||||
copy_url 忽略:不再跳复制页。
|
||||
"""
|
||||
_ = copy_url
|
||||
no = (work_order_no or "").strip()
|
||||
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),
|
||||
"main_title": {
|
||||
"title": f"询价工单 {no}",
|
||||
"desc": tms_miss_hint(),
|
||||
},
|
||||
"sub_title_text": tms_miss_hint(),
|
||||
"task_id": card_task_id("miss", work_order_no),
|
||||
"sub_title_text": tms_miss_body(),
|
||||
"horizontal_content_list": [
|
||||
{"keyname": "工单状态", "value": "询价中"},
|
||||
{"keyname": "查价结果", "value": "暂无匹配报价"},
|
||||
],
|
||||
"task_id": card_task_id("miss", no),
|
||||
}
|
||||
url = (action_url or "").strip()
|
||||
if url:
|
||||
@@ -778,15 +823,102 @@ 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}】"
|
||||
)
|
||||
DEAL_HINT = "咨询成交状态:请选择成交 / 未成交 / 协商中(未成交需补充原因)。"
|
||||
DEAL_SUBTITLE = "报价单已发送,请跟进成交状态"
|
||||
|
||||
|
||||
def deal_horizontal_rows(
|
||||
*,
|
||||
quote: dict[str, Any] | None,
|
||||
facts: dict[str, str] | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
"""
|
||||
成交跟进卡字段:总销售价合计、时效、报价有效期。
|
||||
|
||||
不要历史报价 / 工单状态 / 来源。来源只留卡片顶部红字。
|
||||
"""
|
||||
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)
|
||||
return [
|
||||
{"keyname": "总销售价合计", "value": _clip(total, 26)},
|
||||
{"keyname": "时效", "value": _clip(tms_eta_display(src), 26)},
|
||||
{"keyname": "报价有效期", "value": _clip(tms_valid_until_display(src), 26)},
|
||||
]
|
||||
|
||||
|
||||
def deal_card(
|
||||
*,
|
||||
work_order_no: str,
|
||||
status: str = "",
|
||||
summary: str = "",
|
||||
quote: dict[str, Any] | None = None,
|
||||
facts: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
成交跟进文字兜底(模板卡发失败时仍给销售看)。
|
||||
|
||||
与卡片同一套字段;status / summary 只兼容旧调用,不再展示。
|
||||
"""
|
||||
_ = status, summary
|
||||
no = (work_order_no or "").strip()
|
||||
rows = deal_horizontal_rows(quote=quote, facts=facts)
|
||||
lines = [
|
||||
f"询价工单 {no}",
|
||||
DEAL_SUBTITLE,
|
||||
DEAL_HINT,
|
||||
]
|
||||
lines.extend(f"{row['keyname']}:{row['value']}" for row in rows)
|
||||
lines.append(f"【{BTN_DEAL}】【{BTN_LOST}】【{BTN_NEGOTIATE}】")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def deal_wecom_payload(
|
||||
*,
|
||||
work_order_no: str,
|
||||
quote: dict[str, Any] | None = None,
|
||||
facts: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
成交跟进卡:对齐现网 button_interaction。
|
||||
|
||||
顶部来源红字;标题询价工单号;副标题跟进成交;三个按钮。
|
||||
字段只要总销售价、时效、有效期。
|
||||
"""
|
||||
src = dict(quote or {})
|
||||
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
|
||||
no = (work_order_no or "").strip()
|
||||
return {
|
||||
"msgtype": "template_card",
|
||||
"template_card": {
|
||||
"card_type": "button_interaction",
|
||||
"source": {"desc": source, "desc_color": 0},
|
||||
"main_title": {
|
||||
"title": f"询价工单 {no}",
|
||||
"desc": DEAL_SUBTITLE,
|
||||
},
|
||||
"sub_title_text": DEAL_HINT,
|
||||
"horizontal_content_list": deal_horizontal_rows(quote=src, facts=facts),
|
||||
"task_id": card_task_id("deal", no),
|
||||
"button_list": [
|
||||
{
|
||||
"text": BTN_DEAL,
|
||||
"style": 1,
|
||||
"key": f"{BTN_DEAL_KEY}:{no}",
|
||||
},
|
||||
{
|
||||
"text": BTN_LOST,
|
||||
"style": 1,
|
||||
"key": f"{BTN_LOST_KEY}:{no}",
|
||||
},
|
||||
{
|
||||
"text": BTN_NEGOTIATE,
|
||||
"style": 1,
|
||||
"key": f"{BTN_NEGOTIATE_KEY}:{no}",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def deal_ack(outcome: str, work_order_no: str) -> str:
|
||||
|
||||
Reference in New Issue
Block a user