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

1378 lines
47 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
空运文字询价出站话术(销售可见)。
本文件职责:集中文案;按钮字面量必须与 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,
SEA_COLLAB_DISPLAY,
SEA_COLLAB_FIELDS,
SEA_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
)
# 海运补问:与合同 7 项一致。报价日期默认当天,不进待补充。
_SEA_EXAMPLES = {
"起运港": "上海",
"目的港": "洛杉矶",
"品名": "普货",
"货量": "20吨",
"整柜或拼柜": "整柜",
"箱型箱量": "1x40HQ",
"报价日期": "2026-09-15",
}
_SEA_CLARIFY = tuple(
(key, label, _SEA_EXAMPLES[key]) for key, label in SEA_REQUIRED_FIELDS
)
# 文字按钮(销售点卡或原样回复均可)
BTN_SKIP_COLLAB = "不需要协同,直接生成报价单"
BTN_SKIP_COLLAB_OLD = "不需要协同,直接生成基础报价单"
BTN_SKIP_COLLAB_KEY = "skip_collab"
BTN_SKIP_COLLAB_DONE = "已提交"
BTN_PULL_COLLAB = "拉产品进群协同"
BTN_PULL_COLLAB_KEY = "pull_collab"
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 = "协商中"
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 判断,不要求销售说这些词。
_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 _sea_known_value(facts: dict[str, str], key: str) -> str:
"""海运已识别字段的销售侧展示值。"""
src = facts or {}
if key == "货量":
return (src.get("货量") or 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)
sea = (transport_mode or "").strip() == "海运"
rows = _SEA_CLARIFY if sea else _AIR_CLARIFY
known = _sea_known_value if sea else _air_known_value
lines = ["请确认并补充以下信息:"]
if transport_mode:
lines.append(f"运输方式:{transport_mode}")
for key, label, _example in rows:
val = known(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 rows:
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 "-"
if (transport_mode or "").strip() == "海运":
qty = (facts.get("货量") or facts.get("货物数量") or "-").strip() or "-"
load = (facts.get("整柜或拼柜") or "-").strip() or "-"
box = (facts.get("箱型箱量") or "-").strip() or "-"
return [
f"工单号:{work_order_no}",
f"运输方式:{transport_mode}",
f"起运港:{origin}",
f"目的港:{dest}",
f"品名:{cargo}",
f"货物数量:{qty}",
f"整柜或拼柜:{load}",
f"箱型箱量:{box}",
f"报价日期:{quote_date}",
]
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",
transport_mode: str = "空运",
) -> str:
"""
建单后的询价确认。
无标题、无 [test]。first_or_same 保留兼容,不再对销售展示。
"""
_ = first_or_same
lines = inquiry_field_lines(
work_order_no=work_order_no, facts=facts, transport_mode=transport_mode
)
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_PULL_COLLAB, BTN_PULL_COLLAB_KEY} or text.startswith(
f"{BTN_PULL_COLLAB_KEY}:"
):
return BTN_PULL_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 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):
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 deal_card_version(card_meta: dict[str, Any] | None = None, *, text: str = "") -> int:
"""从成交卡 EventKey 取出 vN;没有则 0(旧私聊卡)。"""
src = card_meta or {}
for raw in (
str(src.get("event_key") or ""),
str(src.get("task_id") or ""),
text or "",
):
hit = re.search(r":v(\d+)\s*$", raw)
if hit:
return int(hit.group(1))
return 0
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_PULL_COLLAB_KEY}:",
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()
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)
# 企微引用条过长会被客户端裁成省略号。海运常有 6~8 项费用,必须走文本明细。
_QUOTE_AREA_SAFE_CHARS = 80
def quote_area_safe_text(quote: dict[str, Any] | None) -> str:
"""
卡片引用条:短报价原样;超长只留合计,避免半截费用名。
完整明细由 sea_quote_detail_text 另发一条文本。空运卡仍用 quote_area_fee_text。
"""
full = quote_area_fee_text(quote)
if len(full) <= _QUOTE_AREA_SAFE_CHARS:
return full
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)
n = len(quote_fee_lines(src))
if n:
return f"总销售价合计:{total}\n共{n}项费用,详见上一条明细"
return f"总销售价合计:{total}"
def sea_quote_detail_text(
*,
work_order_no: str,
quote: dict[str, Any],
first_or_same: str = "first",
history_work_order_no: str = "",
status: str = "已报价",
) -> str:
"""
海运完整价格明细文本:来源、工单号、费用行、合计。
时效、报价有效期只在卡片上出现,这里不写,避免和卡重复。
不受卡片引用条字数限制。first_or_same 只兼容调用方。
"""
_ = first_or_same, history_work_order_no, status
src = dict(quote or {})
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
return "\n".join(
[source, f"{work_order_no}价格明细", quote_area_fee_text(src)]
)
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, 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)
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)}")
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]:
"""
一张价格卡,销售侧顺序:
价格明细
┌ 引用条:空运费 / 其它费用 / 总销售价合计
时效、报价有效期
协同说明(普通文字,贴按钮上方)
按钮
相同询价内容检索对销售隐藏;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)}",
"",
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, replace_name: str = "") -> dict[str, Any]:
"""
点过模板卡后更新原卡:被点按钮改成已提交,整卡置灰不可再点。
必须带企微回调 ResponseCode;无码则不要入出站。
成交卡三个按钮共用这一条,replace_name 默认「已提交」。
"""
return {
"msgtype": "update_template_card",
"response_code": response_code,
"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}"
def tms_miss_hint() -> str:
"""卡片标题下灰字:与现网图一致。"""
return "TMS 标准价库暂无匹配报价"
def tms_miss_body() -> str:
"""卡片中间说明:需求已建单。"""
return "需求已解析并创建工单。"
def tms_miss(*, work_order_no: str) -> str:
"""
卡片发送失败时的短兜底。拉群句另发,不写在这里。
禁止在没打 TMS 前用本句冒充无价。
"""
no = (work_order_no or "").strip()
return f"询价工单{no}已建立,TMS标准价库暂无匹配报价。"
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:
"""
点「复制工单号」后回给销售的正文:只含工单号。
企微模板卡按钮不能写系统剪贴板。整条消息只有工单号,销售长按「复制」不会夹杂其它字。
"""
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"询价工单 {no}",
"desc": tms_miss_hint(),
},
"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:
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 sea_collab_ask() -> str:
"""海运有价卡协同问句。"""
return "是否拉产品人员进群协同?"
def sea_miss_ask() -> str:
"""海运无价卡协同问句。"""
return "未查到合适报价,是否需要拉内部产品人员进群,进行手工协同报价?"
def sea_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(sea_collab_ask())
lines.append(BTN_PULL_COLLAB)
lines.append(BTN_SKIP_COLLAB)
return "\n".join(lines)
def sea_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]:
"""海运有价企微卡:价格明细 + 问句 + 拉群 / 跳过协同。"""
_ = first_or_same, history_work_order_no, status
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)}",
"",
sea_collab_ask(),
]
)
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_safe_text(src),
},
"sub_title_text": meta,
"task_id": card_task_id("quote", work_order_no),
"button_list": [
{
"text": BTN_PULL_COLLAB,
"style": 1,
"key": f"{BTN_PULL_COLLAB_KEY}:{work_order_no}",
},
{
"text": BTN_SKIP_COLLAB,
"style": 1,
"key": f"{BTN_SKIP_COLLAB_KEY}:{work_order_no}",
},
],
},
}
def sea_tms_miss(*, work_order_no: str) -> str:
"""海运无价文字兜底:工单号、暂无匹配、询价中、拉群问句。"""
no = (work_order_no or "").strip()
return "\n".join(
[
f"询价工单 {no}",
"暂无匹配报价",
"工单状态:询价中",
sea_miss_ask(),
BTN_PULL_COLLAB,
]
)
def sea_tms_miss_wecom_payload(*, work_order_no: str, action_url: str = "") -> dict[str, Any]:
"""
海运无价一张卡:工单号、暂无匹配、询价中、拉群问句、一个按钮。
禁止再写「请手动拉人到原有群」。
"""
no = (work_order_no or "").strip()
card: dict[str, Any] = {
"card_type": "button_interaction",
"source": {"desc": "TMS查价结果", "desc_color": 0},
"main_title": {
"title": f"询价工单 {no}",
"desc": "暂无匹配报价",
},
"sub_title_text": "\n".join(
[
"需求已解析并创建工单。",
sea_miss_ask(),
]
),
"horizontal_content_list": [
{"keyname": "工单状态", "value": "询价中"},
{"keyname": "查价结果", "value": "暂无匹配报价"},
],
"task_id": card_task_id("miss", no),
"button_list": [
{
"text": BTN_PULL_COLLAB,
"style": 1,
"key": f"{BTN_PULL_COLLAB_KEY}:{no}",
}
],
}
url = (action_url or "").strip()
if url:
card["card_action"] = {"type": 1, "url": url}
return {"msgtype": "template_card", "template_card": card}
def _role_label(name: str, role: str) -> str:
"""姓名前加岗位,避免「销售甲、乙」被读成两人都是销售。已带岗位前缀的不再重复加。"""
text = (name or "").strip()
if not text:
return role
if text.startswith(role):
return text
return f"{role}{text}"
def sea_group_created(*, work_order_no: str, sales_name: str, product_names: list[str]) -> str:
"""拉群成功后的私聊。已邀请写成「销售某某、产品某某」,岗位不能省略。"""
no = (work_order_no or "").strip()
sales = _role_label(sales_name, "销售")
products = "、".join(
_role_label(n, "产品") for n in (product_names or []) if (n or "").strip()
) or "产品"
return (
f"协同群已创建,群名与工单号一致:{no}。"
f"已邀请:{sales}、{products}。请在群聊中继续确认报价。"
)
def sea_group_exists(*, work_order_no: str) -> str:
"""同一工单已有群,不再新建。"""
no = (work_order_no or "").strip()
return f"协同群已在,群名与工单号一致:{no}。请到该群继续确认报价。"
def sea_group_no_staff() -> str:
"""没有可拉的产品人员,不建空群。"""
return "没有可拉的产品人员。请先在后台「员工管理」维护本线路的海运产品岗后再试。"
def group_sheet_send_fail() -> str:
"""模板已填好但没发进群。"""
return "报价单已生成,发到群里失败,请稍后重试。"
def sea_group_create_fail(*, reason: str = "") -> str:
"""建群失败,按钮保持可点。"""
extra = (reason or "").strip()
if extra:
return f"建群失败:{extra}。请稍后重试。"
return "建群失败,请稍后重试。"
def sea_group_brief(
*,
work_order_no: str,
facts: dict[str, str],
quote: dict[str, Any] | None,
collab_facts: dict[str, str] | None,
sales_name: str,
product_names: list[str],
has_price: bool,
) -> str:
"""
群里自动摘要:@销售 @产品 + 工单号 + 询价字段 + 协同字段 + TMS 报价。
三段之间空一行,方便群里扫。协同没填写 -。
HS编码对外写成商品海关编码。无价只写「TMS 暂无匹配报价」。
"""
no = (work_order_no or "").strip()
sales = (sales_name or "").strip() or "销售"
products = [n.strip() for n in (product_names or []) if (n or "").strip()]
at_bits = [f"@{sales}"] + [f"@{n}" for n in products]
lines = [" ".join(at_bits), f"工单号:{no}", "询价字段:"]
lines.extend(
inquiry_field_lines(work_order_no=no, facts=facts, transport_mode="海运")[1:]
)
lines.append("")
lines.append("协同补充字段:")
collab = dict(collab_facts or {})
for key in SEA_COLLAB_FIELDS:
label = SEA_COLLAB_DISPLAY.get(key, key)
val = (collab.get(key) or collab.get(label) or "").strip() or "-"
lines.append(f"{label}:{val}")
lines.append("")
lines.append("TMS报价:")
if has_price and quote:
lines.extend(quote_detail_lines(quote))
else:
lines.append("TMS 暂无匹配报价")
return "\n".join(lines)
def scheme_card(
*,
work_order_no: str,
status: str,
facts: dict[str, str],
quote: dict[str, Any],
transport_mode: str = "空运",
) -> 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, transport_mode=transport_mode
)
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_transport_mode(business_line: str = "", fallback: str = "空运") -> str:
"""会话业务线 → 方案卡运输方式。海运不得再写成空运。"""
raw = (business_line or "").strip()
line = raw.upper()
if line == "SEA" or raw == "海" or "海运" in raw:
return "海运"
if line == "LAND" or raw == "陆" or "陆运" in raw:
return "陆运"
if line == "AIR" or raw == "空" or "空运" in raw:
return "空运"
return (fallback or "空运").strip() or "空运"
def scheme_horizontal_rows(
*,
work_order_no: str,
facts: dict[str, str],
quote: dict[str, Any],
transport_mode: str = "空运",
) -> 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 "-"
mode = (transport_mode or "空运").strip() or "空运"
if mode == "海运":
qty = (src.get("货量") or src.get("货物数量") or "-").strip() or "-"
load = (src.get("整柜或拼柜") or "-").strip() or "-"
box = (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};{qty}", 26)},
{"keyname": "箱型", "value": _clip(f"{load};{box}", 26)},
{"keyname": "时效", "value": _clip(tms_eta_display(q), 26)},
{"keyname": "报价有效期", "value": _clip(tms_valid_until_display(q), 26)},
]
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 = "已报价",
transport_mode: 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_safe_text(src)
if (transport_mode or "").strip() == "海运"
else quote_area_fee_text(src)
),
},
"horizontal_content_list": scheme_horizontal_rows(
work_order_no=work_order_no,
facts=facts,
quote=src,
transport_mode=transport_mode,
),
"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})"
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,
card_version: int = 0,
) -> dict[str, Any]:
"""
成交跟进卡:对齐现网 button_interaction。
顶部来源红字;标题询价工单号;副标题跟进成交;三个按钮。
字段只要总销售价、时效、有效期。
"""
src = dict(quote or {})
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
no = (work_order_no or "").strip()
ver = int(card_version or 0)
suffix = f":v{ver}" if ver > 0 else ""
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}{suffix}",
},
{
"text": BTN_LOST,
"style": 1,
"key": f"{BTN_LOST_KEY}:{no}{suffix}",
},
{
"text": BTN_NEGOTIATE,
"style": 1,
"key": f"{BTN_NEGOTIATE_KEY}:{no}{suffix}",
},
],
},
}
def ticket_locked(*, work_order_no: str, status: str) -> str:
"""已成交/未成交/已关闭后,群里改字段或再报价。"""
no = (work_order_no or "").strip() or "该工单"
st = (status or "").strip() or "已关闭"
return f"工单{no}{st},不能修改工单信息"
def ticket_locked_file(*, work_order_no: str, status: str) -> str:
"""终态工单再发 Excel/PDF。"""
no = (work_order_no or "").strip() or "该工单"
st = (status or "").strip() or "已关闭"
return f"工单{no}{st},不能再上传附件"
def group_only_excel_pdf() -> str:
return "群里只收 Excel 和 PDF,其它文件不会存入工单"
def group_quote_not_recognized() -> str:
return "没有识别到报价,请产品再发一次价格或 Excel/PDF"
def group_fields_not_recognized() -> str:
return "听到了。请直接说贸易条款、货好时间、商品海关编码、是否含油/含电/含磁。"
def group_deal_stale(*, work_order_no: str) -> str:
"""点了上一版成交卡。"""
no = (work_order_no or "").strip()
return f"这张成交确认已过期。工单{no}已有更新的报价,请点击群里最新一条成交跟进。"
def group_deal_sales_only() -> str:
return "只有负责该工单的销售可以确认成交状态。"
def group_dissolved(*, work_order_no: str) -> str:
no = (work_order_no or "").strip()
return f"群已解散。工单{no}协同已结束。"
def group_cannot_recreate(*, work_order_no: str) -> str:
"""解散后再点拉群。"""
no = (work_order_no or "").strip() or "该工单"
return f"工单{no}协同已结束,不能再拉群。"
def group_deal_mention(*, sales_name: str, work_order_no: str) -> str:
name = (sales_name or "").strip() or "销售"
return f"@{name}\n{deal_card(work_order_no=work_order_no)}"
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} 跟进周期已顺延。"