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

1895 lines
66 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_COLLAB_FIELDS,
AIR_REQUIRED_FIELDS,
LAND_DISPLAY,
SEA_COLLAB_FIELDS,
SEA_REQUIRED_FIELDS,
collab_display_name,
collab_fields_for_ticket,
display_name,
)
from agent.schema.land_options import option_prompt as _land_option_prompt
# 空运补问:与合同 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 = _land_option_prompt(current={})
def land_sample_image_payload() -> dict[str, Any]:
"""
填写样例表图片出站。
走企微 image,不走文件气泡。路径随发布包,出站线程再读字节。
禁止在回调线程上传。
"""
from agent.schema.land_options import sample_image_path
path = sample_image_path()
return {
"msgtype": "image",
"filename": "填写样例.png",
"filepath": str(path),
}
def land_sample_image_fallback() -> str:
"""图片失败时回文字样例,避免销售什么都看不到。"""
from agent.schema.land_options import sample_fallback_text
return sample_fallback_text()
FIRST_CHAT = "你好,我是询价机器人。请直接描述客户需求,例如「上海到洛杉矶空运,3 件 180kg,下周三前到」,我会解析需求并查询 TMS 标准报价。"
ROUTE_MODEL_FAILED = "刚才没听清,请再说一次:是新询价,还是要接着办哪张工单?"
LAND_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。"
LAND_CONFIRM_EXPIRED = "刚才的陆运核对已过期,请把陆运需求再发一次,出核对卡后再回复确定。"
LAND_TMS_RETRY = "工单 {wo} 上次查价没成功。回复「确定」按原需求再查一次,不用重新填。"
LAND_TMS_RETRYING = "正在重新查询TMS报价。工单:{wo}"
_LAND_LCL_KEYS = ("起运港", "目的港", "品名", "体积", "毛重", "报价日期")
_LAND_FTL_KEYS = ("起运港", "目的港", "品名", "车型/数量", "报价日期")
_LAND_HK_FULL_KEYS = ("起运港", "目的港", "品名", "报价日期")
_LAND_FTL_TYPES = frozenset({"国内运输整车", "跨境整车", "中港零担/集拼"})
_LAND_CUSTOMS_TYPES = frozenset({"跨境集拼", "跨境整车", "中港整车", "中港零担/集拼"})
_LAND_EXAMPLES = {
"起运港": "广州",
"目的港": "深圳",
"品名": "衣服",
"体积": "100CBM",
"毛重": "50kg",
"车型/数量": "9米6/1",
"通关口岸": "皇岗",
}
def _land_cargo_keys(land_type: str, facts: dict[str, str] | None = None) -> tuple[str, ...]:
"""当前运输类型要展示的货物内部键。中港整车不要车型/数量。"""
_ = facts
if land_type == "中港整车":
return _LAND_HK_FULL_KEYS
if land_type in _LAND_FTL_TYPES:
return _LAND_FTL_KEYS
return _LAND_LCL_KEYS
def land_confirm_card(facts: dict[str, str] | None) -> str:
"""
确定查价前的核对卡。没有工单号。
行列出运输类型/线路/分类和该类型货物字段。
国内不出现通关口岸;跨境/中港通关口岸非必填,没填留空行。
不再单独列出中港装载方式。
"""
src = dict(facts or {})
land_type = (src.get("运输类型") or "").strip()
lines = [
f"运输类型(必填):{land_type}",
f"线路类别(必填):{(src.get('线路类别') or '').strip()}",
f"运输分类(必填):{(src.get('运输分类') or '').strip()}",
]
for key in _land_cargo_keys(land_type, src):
label = LAND_DISPLAY.get(key, key)
val = (src.get(key) or "").strip()
lines.append(f"{label}(必填):{val}")
if land_type in _LAND_CUSTOMS_TYPES:
lines.append(f"通关口岸(非必填):{(src.get('通关口岸') or '').strip()}")
lines.append("")
lines.append(LAND_CONFIRM_TAIL)
return "\n".join(lines)
def land_group_no_staff() -> str:
"""没有对上线路的陆运产品,不建空群。"""
return "没有可拉的产品人员。请先在后台「员工管理」维护本线路的陆运产品岗后再试。"
def land_group_pair_rejected() -> str:
"""类型+线路不在填写样例里,禁止拉群。"""
return "拉群失败,请排查填写内容,并重新发起工单。"
def ask_land_fields(
*,
facts: dict[str, str] | None = None,
missing_keys: Iterable[str] = (),
land_subtype: str = "",
) -> str:
"""
陆运货物缺项补问。不出核对卡、不出现确定句。
报价日期不进待补充。
"""
src = dict(facts or {})
skip = {"报价日期", "运输类型", "线路类别", "运输分类"}
missing = [k for k in missing_keys if k and k not in skip]
land_type = (land_subtype or src.get("运输类型") or "").strip()
lines = ["请确认并补充以下信息:", "运输方式:陆运"]
if land_type:
lines.append(f"运输类型:{land_type}")
for key in _land_cargo_keys(land_type, src):
val = (src.get(key) or "").strip()
if val:
lines.append(f"{LAND_DISPLAY.get(key, key)}:{val}")
lines.append("")
lines.append("待补充:")
if not missing:
return ""
for key in missing:
label = display_name(key, "LAND")
example = _LAND_EXAMPLES.get(key, "")
if example:
lines.append(f"{label}:(如:{example})")
else:
lines.append(f"{label}:")
return "\n".join(lines)
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 facts.get("起运地") or "-").strip()
dest = (facts.get("目的港") or 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}",
]
if (transport_mode or "").strip() == "陆运":
land_type = (facts.get("运输类型") or "").strip()
lines = [
f"工单号:{work_order_no}",
f"运输方式:{transport_mode}",
f"运输类型:{land_type or '-'}",
f"线路类别:{(facts.get('线路类别') or '-').strip() or '-'}",
f"运输分类:{(facts.get('运输分类') or '-').strip() or '-'}",
]
for key in _land_cargo_keys(land_type, facts):
label = LAND_DISPLAY.get(key, key)
val = (facts.get(key) or "-").strip() or "-"
lines.append(f"{label}:{val}")
if land_type in _LAND_CUSTOMS_TYPES:
lines.append(f"通关口岸:{(facts.get('通关口岸') or '').strip()}")
return lines
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") or src.get("fee_rows")
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 quote_is_product(quote: dict[str, Any] | None) -> bool:
"""来源是产品/航线人工,不能写进 TMS 报价段。"""
label = str((quote or {}).get("source_label") or (quote or {}).get("source") or "")
return "产品" in label or "航线" in label
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,
tms_quote: dict[str, Any] | None = None,
product_quote: dict[str, Any] | None = None,
transport_mode: str = "海运",
) -> str:
"""
群里自动摘要:询价 + 协同 + TMS 报价 + 人工报价。
TMS 无价只写「TMS 暂无匹配报价」,产品报的价单独放人工报价,不能混进 TMS。
陆运传入 transport_mode=陆运,始发站/货物字段跟核对卡同一套。
"""
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]
mode = (transport_mode or "海运").strip() or "海运"
lines = [" ".join(at_bits), f"工单号:{no}", "询价字段:"]
lines.extend(
inquiry_field_lines(work_order_no=no, facts=facts, transport_mode=mode)[1:]
)
lines.append("")
lines.append("协同补充字段:")
collab = dict(collab_facts or {})
line = "LAND" if mode == "陆运" else "SEA"
collab_keys = collab_fields_for_ticket(facts, line) or SEA_COLLAB_FIELDS
for key in collab_keys:
label = collab_display_name(key, line)
val = (collab.get(key) or collab.get(label) or "").strip() or "-"
lines.append(f"{label}:{val}")
tms = dict(tms_quote) if tms_quote is not None else {}
manual = dict(product_quote or {})
if tms_quote is None:
src = dict(quote or {})
if has_price and src and not quote_is_product(src):
tms = src
elif quote_is_product(src):
manual = manual or src
lines.append("")
lines.append("TMS报价:")
if tms and (tms.get("total") or quote_fee_lines(tms)):
lines.extend(quote_detail_lines(tms))
lines.append(group_tms_confirm_hint())
else:
lines.append("TMS 暂无匹配报价")
if manual and (quote_fee_lines(manual) or str(manual.get("total") or "").strip() not in {"", "-"}):
lines.append("")
lines.append("人工报价:")
fee_lines = quote_fee_lines(manual)
if fee_lines:
lines.extend(f"{name}:{amount}" for name, amount in fee_lines)
else:
header_ccy = str(manual.get("currency") or "").strip()
lines.append(f"合计:{money_text(str(manual.get('total') or '-'), header_ccy)}")
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)},
]
if mode == "陆运":
return [
{"keyname": "工单号", "value": _clip(work_order_no, 26)},
{"keyname": "线路", "value": _clip(f"陆运 {origin}→{dest}", 26)},
{"keyname": "类型", "value": _clip(_land_scheme_type_line(src), 26)},
{"keyname": "货物", "value": _clip(_land_scheme_goods_line(src), 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 _land_scheme_type_line(facts: dict[str, str]) -> str:
"""方案卡「类型」格:运输类型、线路类别、运输分类。"""
src = facts or {}
land_type = (src.get("运输类型") or "-").strip() or "-"
bits = [land_type]
route = (src.get("线路类别") or "-").strip() or "-"
klass = (src.get("运输分类") or "-").strip() or "-"
bits.extend([route, klass])
return ";".join(bits)
def _land_scheme_goods_line(facts: dict[str, str]) -> str:
"""方案卡「货物」格:品名 + 当前类型的体积重量或车型/数量。"""
src = facts or {}
land_type = (src.get("运输类型") or "").strip()
bits: list[str] = [(src.get("品名") or "-").strip() or "-"]
for key in _land_cargo_keys(land_type, src):
if key in {"起运港", "目的港", "品名", "报价日期"}:
continue
val = (src.get(key) or "").strip()
if val:
bits.append(val)
if land_type in _LAND_CUSTOMS_TYPES:
customs = (src.get("通关口岸") or "").strip()
if customs:
bits.append(customs)
return ";".join(bits)
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_only_product_can_quote() -> str:
return "只有产品能报价格,请产品在群里发价格或 Excel/PDF"
def group_tms_confirm_hint() -> str:
"""报价有效期下方:有 TMS 价时告诉产品还可以确认沿用。"""
return "有TMS报价时可回复“确认报价”沿用该价;也可直接回复协同报价或上传报价附件。"
def quote_attachment_saved_app(work_order_no: str, count: int = 1) -> str:
"""群里告诉大家:模板或产品附件已进后台。"""
no = (work_order_no or "").strip() or "WO"
n = count if count > 0 else 1
return f"工单{no}的{n}个附件已保存到后台工单"
def group_confirm_need_tms() -> str:
return "本单还没有可沿用的 TMS 报价,请直接回复协同报价或上传报价附件。"
def group_ask_tms_or_product() -> str:
return (
"本单已有 TMS 报价。\n"
"1. 要在 TMS 基础上改,请回复:在TMS基础上改(或直接回 1 / 第一个)\n"
"2. 不要 TMS 报价、只用你刚说的价格,请回复:不用TMS报价(或直接回 2 / 第二个)"
)
def group_tms_choice_unclear() -> str:
return "还没确定怎么用 TMS 报价。请回复:在TMS基础上改 / 不用TMS报价,也可以直接回 1 或 2。"
def group_need_price_before_choice() -> str:
return "请产品先发价格,再说在TMS基础上改或不用TMS报价"
def group_file_saved() -> str:
return "附件已保存后台"
def group_quote_not_recognized() -> str:
return "没有识别到报价,请产品再发一次价格或 Excel/PDF"
def group_fields_not_recognized(
keys: tuple[str, ...] | None = None,
business_line: str = "",
) -> str:
"""没认出协同字段。陆运列出本单可补项;海运保持原六项口。"""
if keys:
line = (business_line or "").strip().upper() or "SEA"
names = [collab_display_name(k, line) for k in keys]
return "听到了。请直接说" + "、".join(names) + "。"
return "听到了。请直接说贸易条款、货好时间、商品海关编码、是否含油/含电/含磁。"
def group_deal_stale(*, work_order_no: str) -> str:
"""点了上一版成交卡。"""
no = (work_order_no or "").strip()
return f"这张成交确认已过期。工单{no}已有更新的报价,请点击群里最新一条成交跟进。"
def group_deal_need_quote() -> str:
return "还没有可确认的报价,请先完成报价再确认成交状态。"
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_followup(*, sales_name: str, work_order_no: str) -> str:
"""
群里文字成交跟进。只提示输入,不含报价合计、不含点选按钮。
私聊成交卡仍走 deal_card,不改这里。
"""
name = (sales_name or "").strip() or "销售"
no = (work_order_no or "").strip()
return (
f"@{name}\n"
f"询价工单 {no}\n"
"报价单已发送,请确认成交状态\n"
"咨询成交状态:请输入成交 / 未成交 / 协商中(未成交需补充原因)"
)
def group_deal_mention(*, sales_name: str, work_order_no: str) -> str:
return group_deal_followup(sales_name=sales_name, 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 "已记录为「未成交」。请补充未成交原因。"
return f"已记录为「协商中」。工单 {work_order_no} 跟进周期已顺延。"
def air_need_work_order() -> str:
"""空运群还没有当前单,且这句话里没有工单号。"""
return "请先 @我 并发送要跟的工单号,例如 WO202609160001。"
def air_lock_need_work_order() -> str:
"""锁舱/释放必须自带工单号,不猜当前单。"""
return "锁舱或释放舱位请带上工单号,例如 WO202609160001锁舱。"
def air_ticket_not_found(work_order_no: str) -> str:
no = (work_order_no or "").strip()
return f"找不到工单{no},请核对工单号后再试。"
def air_ticket_not_air(work_order_no: str) -> str:
no = (work_order_no or "").strip()
return f"工单{no}不是空运单,不能在空运群里跟。"
def air_ticket_closed(work_order_no: str, status: str) -> str:
no = (work_order_no or "").strip()
st = (status or "").strip() or "已关闭"
return f"工单{no}已是「{st}」,不能再激活。"
def air_ticket_occupied(work_order_no: str) -> str:
no = (work_order_no or "").strip()
return f"工单{no}已经在另一个群跟了,请到那个群继续。"
def air_group_brief(
*,
work_order_no: str,
facts: dict[str, str],
quote: dict[str, Any] | None,
collab_facts: dict[str, str] | None,
has_price: bool,
tms_quote: dict[str, Any] | None = None,
product_quote: dict[str, Any] | None = None,
sales_name: str = "",
airline_names: list[str] | None = None,
) -> str:
"""
空运群激活摘要:与海运进群同一套结构,字段用空运。
先 @ 销售和航线,再工单号 + 询价字段 + 协同补充(含电/含磁)+ TMS报价。
不写「当前工单/询价要点/现在等到哪一步」。
"""
no = (work_order_no or "").strip()
at_bits: list[str] = []
sales = (sales_name or "").strip()
if sales:
at_bits.append(f"@{sales}")
for name in airline_names or []:
token = f"@{(name or '').strip()}"
if (name or "").strip() and token not in at_bits:
at_bits.append(token)
lines = ([(" ".join(at_bits))] if at_bits else []) + [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 AIR_COLLAB_FIELDS:
val = (collab.get(key) or "").strip() or "-"
lines.append(f"{key}:{val}")
tms = dict(tms_quote) if tms_quote is not None else {}
manual = dict(product_quote or {})
if tms_quote is None:
src = dict(quote or {})
if has_price and src and not quote_is_product(src):
tms = src
elif quote_is_product(src):
manual = manual or src
lines.append("")
lines.append("TMS报价:")
if tms and (tms.get("total") or quote_fee_lines(tms)):
lines.extend(quote_detail_lines(tms))
lines.append(group_tms_confirm_hint())
else:
lines.append("TMS 暂无匹配报价")
if manual and (quote_fee_lines(manual) or str(manual.get("total") or "").strip() not in {"", "-"}):
lines.append("")
lines.append("人工报价:")
fee_lines = quote_fee_lines(manual)
if fee_lines:
lines.extend(f"{name}:{amount}" for name, amount in fee_lines)
else:
header_ccy = str(manual.get("currency") or "").strip()
lines.append(f"合计:{money_text(str(manual.get('total') or '-'), header_ccy)}")
return "\n".join(lines)
def air_activate_summary(
*,
work_order_no: str,
facts: dict,
status: str = "",
wait_hint: str = "",
quote: dict[str, Any] | None = None,
collab_facts: dict[str, str] | None = None,
) -> str:
"""兼容旧名:激活成功改走海运同款群摘要。"""
_ = status, wait_hint
src = dict(quote or {})
return air_group_brief(
work_order_no=work_order_no,
facts=dict(facts or {}),
quote=src,
collab_facts=collab_facts,
has_price=bool(src.get("total") or src.get("quoteId")),
tms_quote=src if src and not quote_is_product(src) else {},
product_quote=src if quote_is_product(src) else {},
)
def air_wait_hint(*, status: str, has_quote: bool, pending_lock: bool = False) -> str:
"""激活摘要里的「现在等到哪一步」。"""
if pending_lock:
return "等待航线选择要锁的舱位选项"
st = (status or "").strip()
if st == "协商中":
return "协商中,可继续改价或确认成交"
if has_quote or st == "已报价":
return "已有报价,可补含电/含磁、航线改价、锁舱或确认成交"
return "等待航线报价或锁舱(须先有 TMS 报价)"
def air_airline_only_quote() -> str:
return "只有岗位为「航线·空运」的人能报价格。"
def air_airline_only_cabin() -> str:
return "只有岗位为「航线·空运」的人能锁舱或释放舱位。"
def air_sales_only_deal() -> str:
return "只有负责该工单的销售可以确认成交状态。"
def air_fields_not_recognized() -> str:
return "听到了。空运群里可补充是否含电、是否含磁。"
def air_quote_not_recognized() -> str:
return "没有识别到报价,请航线再发一次价格或 Excel/PDF。"
def air_fields_denied() -> str:
return "只有这张单的销售或航线可以补含电/含磁。"
def air_no_tms_quote_to_lock(work_order_no: str) -> str:
no = (work_order_no or "").strip()
return f"工单{no}还没有可锁的 TMS 报价,不能锁舱。"
def air_ask_lock_option(options: list) -> str:
lines = ["这张单有多条舱位选项,请回复要锁哪一条,例如 AIR-OPT-01:"]
for item in options:
no = str(item.get("optionNo") or item.get("option_no") or "").strip()
airline = str(item.get("airlineName") or item.get("airlineCode") or "").strip()
if no and airline:
lines.append(f"{no} {airline}")
elif no:
lines.append(no)
return "\n".join(lines)
def air_lock_ok(
*,
work_order_no: str,
operator: str,
option_no: str,
exec_no: str,
quote_no: str,
) -> str:
return (
f"工单{work_order_no} 已通知TMS执行空运锁舱,执行成功。\n"
f"操作人:{operator}\n"
f"舱位选项:{option_no}\n"
f"执行编号:{exec_no}\n"
f"报价编号:{quote_no}"
)
def air_lock_fail(*, work_order_no: str, operator: str, reason: str) -> str:
why = (reason or "").strip() or "TMS未返回原因"
return (
f"工单{work_order_no} 空运锁舱失败。\n"
f"操作人:{operator}\n"
f"锁舱失败的原因:{why}\n"
"已通知IT运维排查,请稍等。"
)
def air_release_ok(
*,
work_order_no: str,
operator: str,
option_no: str,
exec_no: str,
quote_no: str,
) -> str:
return (
f"工单{work_order_no} 已通知TMS执行空运释放舱位成功\n"
f"操作人:{operator}\n"
f"舱位选项:{option_no}\n"
f"执行编号:{exec_no}\n"
f"报价编号:{quote_no}"
)
def air_release_fail(*, work_order_no: str, operator: str, reason: str) -> str:
why = (reason or "").strip() or "TMS未返回原因"
return (
f"工单{work_order_no} 空运释放舱位失败\n"
f"操作人:{operator}\n"
f"释放舱位失败的原因:{why}"
)
def air_release_deal_blocked(work_order_no: str) -> str:
no = (work_order_no or "").strip()
return f"释放舱位失败,工单{no}已成交。"
def air_release_need_lock(work_order_no: str) -> str:
no = (work_order_no or "").strip()
return f"工单{no}还没有有效锁舱,不能释放。"