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

3464 lines
125 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 精确匹配。
销售可见固定句经 bot_reply 按调用编码读后台;停用/失败回退本文件默认句。
禁止:在本文件直接 request 主账;禁止把密钥写进话术。
"""
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_OPTIONAL_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
from agent.schema.quote_adjust import total_price_label
def _r(code: str, default: str, **kwargs: Any) -> str:
"""
按后台调用编码取回复语。
停用、库空、主账失败返回 default。本函数不打主账,只调 bot_reply 短缓存。
"""
from agent.policy.bot_reply import render
return render(code, default, **kwargs)
# 空运补问:必填 7 项 + 非必填。报价日期默认当天,不进待补充。
_AIR_EXAMPLES = {
"起运港": "南京",
"目的港": "吉隆坡",
"品名": "茶叶",
"件数": "10",
"毛重": "100",
"体积": "2",
"包装方式": "托盘、散货",
"报价日期": "2026-09-15",
"货源地": "东莞",
"货好时间": "随时可提",
"提货地址": "东莞仓库",
"收货地址": "马尼拉",
"贸易条款": "DDP、DAP、CIF、CIP",
}
_AIR_CLARIFY = tuple(
(key, label, _AIR_EXAMPLES[key]) for key, label in AIR_REQUIRED_FIELDS
)
_AIR_OPTIONAL_CLARIFY = tuple(
(key, label, _AIR_EXAMPLES.get(key, "")) for key, label in AIR_OPTIONAL_FIELDS
)
AIR_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。"
# 核对卡已取消时效,不再对销售发送此句;常量仅保留避免旧引用报错。
AIR_CONFIRM_EXPIRED = "刚才的空运核对已过期,请把空运需求再发一次,出核对卡后再回复确定。"
# 海运私聊与空运同一句。核对卡没有工单号,回复确定后才建单查价。
SEA_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。"
# 核对卡已取消时效,不再对销售发送此句。
SEA_CONFIRM_EXPIRED = "刚才的海运核对已过期,请把海运需求再发一次,出核对卡后再回复确定。"
# 海运补问:与合同 10 项一致。报价日期默认当天,不进待补充。
_SEA_EXAMPLES = {
"起运港": "上海",
"目的港": "洛杉矶",
"品名": "普货",
"货量": "20吨",
"整柜或拼柜": "整柜",
"箱型箱量": "1x40HQ",
# 销售看到的举例是门到门、港到港,不是 FOB。举例行不参与已填判断。
"贸易条款": "门到门、港到港",
"运输分类": "Port to Port",
"报价日期": "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 = "不需要协同,直接生成基础报价单"
# 海运/陆运已有 TMS 报价时价格明细卡用短文案;空运仍用上面长文案。点卡 EventKey 不变。
BTN_SKIP_COLLAB_TMS = "直接生成报价单"
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|VT)\d{12}", re.IGNORECASE)
# 仅给无网 stub / 工单号回填用,正式续办意图由 DeepSeek A 判断,不要求销售说这些词。
_CONTINUE_HINT = (
"继续",
"接着",
"回到",
"再看",
"再出",
"找回",
"重开",
"那单",
"这单",
"历史工单",
"咨询工单",
"刚才那",
"上次那",
)
ASK_TRANSPORT = (
"请回复数字或文字,告知我询价的运输方式:\n"
"1.陆运\n"
"2.海运\n"
"3.空运"
)
def ask_transport() -> str:
"""未识别运输方式。后台 inquiring.ask_transport。"""
return _r("inquiring.ask_transport", ASK_TRANSPORT)
def first_chat() -> str:
"""销售首次单聊。后台 inquiring.first_chat。"""
return _r("inquiring.first_chat", FIRST_CHAT)
def image_recognizing() -> str:
"""正在识别图片。后台 inquiring.image_recognizing。"""
return _r("inquiring.image_recognizing", IMAGE_RECOGNIZING)
def image_tech_fail() -> str:
"""图片技术失败。后台 inquiring.image_tech_fail。"""
return _r("inquiring.image_tech_fail", IMAGE_TECH_FAIL)
def attach_recognizing() -> str:
"""正在识别附件。后台 inquiring.attach_recognizing。"""
return _r("inquiring.attach_recognizing", ATTACH_RECOGNIZING)
def attach_tech_fail() -> str:
"""附件技术失败。后台 inquiring.attach_tech_fail。"""
return _r("inquiring.attach_tech_fail", ATTACH_TECH_FAIL)
def attach_h5_expired() -> str:
"""H5 上传过期。后台 inquiring.attach_h5_expired。"""
return _r("inquiring.attach_h5_expired", ATTACH_H5_EXPIRED)
def attach_h5_ok() -> str:
"""H5 上传成功。后台 inquiring.attach_h5_ok。"""
return _r("inquiring.attach_h5_ok", ATTACH_H5_OK)
def continue_need_wo() -> str:
"""续办但未提供工单号。后台 inquiring.continue_need_wo。"""
return _r("inquiring.continue_need_wo", "请告诉我要继续的工单号,例如 WO202609140034。")
def continue_ticket_not_found(work_order_no: str) -> str:
"""续办找不到工单。后台 inquiring.continue_not_found(工单号后有空格)。"""
no = (work_order_no or "").strip()
return _r(
"inquiring.continue_not_found",
f"找不到工单 {no},请核对工单号后再试。",
ticket_no=no,
)
def quote_generating() -> str:
"""报价单生成中。后台 inquiring.quote_generating。"""
return _r("inquiring.quote_generating", "报价单正在生成,请稍候。")
# 待补充清单:请销售整段复制,只在冒号后填值;示例行不算已填。
CLARIFY_PASTE_HINT = "请复制粘贴以下内容,通过回复数字或文字方式,告知我以下字段值:"
_PENDING_UNITS = {}
HANDOFF_ALREADY_COLLAB = "该工单已拉产品进群协同。"
def already_collab() -> str:
"""该工单已拉过产品进群。后台 transfer.already_collab。"""
return _r("transfer.already_collab", HANDOFF_ALREADY_COLLAB)
HANDOFF_CLARIFY_REASON = "参数提取不全(连续 3 次反问仍不完整)"
HANDOFF_INTENT_REASON = "已识别您希望转人工处理"
CLARIFY_LIMIT_HINT = "连续 3 次反问仍不完整将触发转人工"
# 私聊图片询价:先回识别中;看不清/失败与补问拼成一条,销售侧不加 [test]。
IMAGE_RECOGNIZING = "正在识别图片"
IMAGE_UNREADABLE = "图片没看清询价信息,请换一张更清楚的,或直接打字。"
IMAGE_TECH_FAIL = "图片暂时没认出来,请再发一张或直接打字。"
# 私聊附件询价:H5 或发图开始认;看不清/失败与补问拼成一条。
ATTACH_RECOGNIZING = "正在识别附件"
ATTACH_UNREADABLE = "附件没看清询价信息,请换一份更清楚的 Excel / PDF(或把图片发到会话),或直接打字。"
ATTACH_TECH_FAIL = "附件暂时没认出来,请再传一次或直接打字。"
ATTACH_H5_REJECT_TYPE = "只接受 Excel、PDF;图片请发到当前企微会话。"
ATTACH_H5_NEED_FILE = "请选择要上传的 Excel 或 PDF。"
ATTACH_H5_EXPIRED = "已过期,请回企微重新说附件询价。"
ATTACH_H5_OK = "已收到,请回企微查看识别结果。"
def attachment_upload_card(url: str) -> dict[str, Any]:
"""
附件上传入口模板卡(text_notice 跳转 H5)。
必须跟现网同一卡型:企微 news_notice 强制要 card_image.url,缺了会 42044,
出站再回退成一句「上传询价附件」,销售侧就看不到卡片。
文案对齐规格:Excel/PDF 走 H5,图片发当前会话。url 必须已含 token、不含 userid。
"""
jump = (url or "").strip()
fallback = "https://ai.ytd-scm.com/inquiry-form"
action_url = jump or fallback
card: dict[str, Any] = {
"card_type": "text_notice",
"source": {"desc": "附件询价", "desc_color": 0},
"main_title": {
"title": "上传询价附件",
"desc": "附件入口支持 Excel、PDF;图片请直接在企微聊天窗口发送",
},
"sub_title_text": (
"Excel、PDF 请点击下方入口上传;图片请直接在当前企微会话发送。"
"字段完整时自动进入建单与查价;缺失字段会在企微中补问"
),
"jump_list": [{"type": 1, "title": "上传询价附件", "url": action_url}],
"card_action": {"type": 1, "url": action_url},
}
return {"msgtype": "template_card", "template_card": card}
def image_fail_then_clarify(reason: str, clarify: str) -> str:
"""
看不清或技术失败时,说明 + 文字询价补问写在同一条回复。
禁止只回说明就把人丢在那里。两端空白去掉,中间空一行。
"""
head = (reason or "").strip()
body = (clarify or "").strip()
if head and body:
return f"{head}\n\n{body}"
return head or body
# 旧一句只问运输类型;销售要一次看到类型、线路、分类。残留调用也走完整清单。
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:
"""没有对上线路的陆运产品,不建空群。后台 inquiring.land_no_staff。"""
return _r(
"inquiring.land_no_staff",
"没有可拉的产品人员。请先在后台「员工管理」维护本线路的陆运产品岗后再试。",
)
def land_group_pair_rejected() -> str:
"""类型+线路不在填写样例里,禁止拉群。"""
return "拉群失败,请排查填写内容,并重新发起工单。"
def _land_optional_keys(land_type: str) -> tuple[str, ...]:
"""当前类型询价非必填内部键。国内没有通关口岸。"""
if land_type in _LAND_CUSTOMS_TYPES:
return ("通关口岸",)
return ()
def _pending_field_block(
index: int,
label: str,
example: str = "",
*,
optional: bool = False,
unit: str = "",
) -> str:
"""
待补充一项:编号 + 字段名 + 必填/单位,示例另起一行。
销售侧如:
1.件数(必填):
(如:10)
2.重量(KG)(必填):
(如:100)
复制回去时只认冒号后面的值;下一行「(如:…)」不算已填。
"""
mark = "(非必填)" if optional else "(必填)"
extra = f"({unit})" if (unit or "").strip() else ""
head = f"{index}.{label}{extra}{mark}:"
shown = (example or "").strip()
if shown:
return f"{head}\n(如:{shown})"
return head
def _land_field_line(key: str, value: str, *, required: bool, example: str = "") -> str:
"""已识别一行:名称(必填/非必填):值。待补充走 _pending_field_block。"""
label = LAND_DISPLAY.get(key, key)
mark = "必填" if required else "非必填"
if value:
return f"{label}({mark}):{value}"
_ = example
return f"{label}({mark}):"
def clarify_progress_line(retry: int) -> str:
"""补问次数提醒:参数提取不全(1/3)。"""
n = int(retry or 0)
if n < 1:
n = 1
if n > 3:
n = 3
return f"参数提取不全({n}/3)。{CLARIFY_LIMIT_HINT}"
def with_clarify_progress(body: str, retry: int) -> str:
"""把次数提醒顶在补问正文前面。第一次补问(retry=0)不打(1/3)。"""
text = (body or "").strip()
n = int(retry or 0)
if n < 1:
return text
head = clarify_progress_line(n)
if not text:
return head
return head + "\n" + text
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()
optional = _land_optional_keys(land_type)
lines = ["请确认并补充以下信息:", "运输方式:陆运"]
if land_type:
lines.append(f"运输类型:{land_type}")
for key in ("线路类别", "运输分类"):
val = (src.get(key) or "").strip()
if val:
lines.append(_land_field_line(key, val, required=True))
for key in _land_cargo_keys(land_type, src):
val = (src.get(key) or "").strip()
if val:
lines.append(_land_field_line(key, val, required=True))
for key in optional:
val = (src.get(key) or "").strip()
if val:
lines.append(_land_field_line(key, val, required=False))
pending = list(missing)
for key in optional:
if not (src.get(key) or "").strip() and key not in pending:
pending.append(key)
if not pending:
return ""
lines.append("")
lines.append(CLARIFY_PASTE_HINT)
seq = 0
for key in pending:
seq += 1
required = key not in optional
example = _LAND_EXAMPLES.get(key, "")
label = LAND_DISPLAY.get(key, key)
lines.append(
_pending_field_block(
seq,
label,
example,
optional=not required,
unit=_PENDING_UNITS.get(key, ""),
)
)
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()
from agent.llm.extract_text import clip_inquiry_value
return clip_inquiry_value(src.get(key) or "")
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 _air_field_line(key: str, value: str, *, required: bool, example: str = "") -> str:
"""空运核对卡一行:名称(必填/非必填):值。待补充走 _pending_field_block。"""
label = AIR_DISPLAY_LABEL(key)
mark = "必填" if required else "非必填"
text = (value or "").strip()
if text:
return f"{label}({mark}):{text}"
_ = example
return f"{label}({mark}):"
def AIR_DISPLAY_LABEL(key: str) -> str:
"""核对/补问对外名。空运起运港对外写起运地,内部键不变。"""
return display_name(key, "AIR")
def air_confirm_card(facts: dict[str, str] | None) -> str:
"""
确定查价前的核对卡。没有工单号。
起运地(非必填)紧挨在目的地(必填)上面。其余非必填仍在报价日期后面。
"""
src = dict(facts or {})
lines = ["运输方式(必填):空运"]
lines.append(_air_field_line("起运港", _air_known_value(src, "起运港"), required=False))
for key, _label in AIR_REQUIRED_FIELDS:
if key == "报价日期":
val = _air_known_value(src, key) or (src.get("报价日期") or "").strip()
lines.append(_air_field_line(key, val, required=True))
continue
lines.append(_air_field_line(key, _air_known_value(src, key), required=True))
for key, _label in AIR_OPTIONAL_FIELDS:
if key == "起运港":
continue
lines.append(_air_field_line(key, _air_known_value(src, key), required=False))
lines.append("")
lines.append(AIR_CONFIRM_TAIL)
return "\n".join(lines)
def sea_confirm_card(facts: dict[str, str] | None) -> str:
"""
海运确定查价前的核对卡。没有工单号。
销售侧必填都列出(货物数量对应内部键货量)。报价日期没说则用已经填上的当天。
协同字段不在这张卡上,不挡首次查价。
"""
src = dict(facts or {})
lines = ["运输方式(必填):海运"]
for key, label in SEA_REQUIRED_FIELDS:
if key == "货量":
val = (src.get("货量") or src.get("货物数量") or "").strip()
else:
val = (src.get(key) or "").strip()
lines.append(f"{label}(必填):{val}")
lines.append("")
lines.append(SEA_CONFIRM_TAIL)
return "\n".join(lines)
def ask_clarify(
*,
facts: dict[str, str] | None = None,
missing_keys: Iterable[str] = (),
transport_mode: str = "空运",
) -> str:
"""
缺项补问:先回显已识别,再给可复制清单;示例另起一行,如「1.件数(必填):」下一行「(如:10)」。
报价日期写进已识别(销售说了用原话,没说用当天),不出现在待补充。
空运非必填一并列出,没填进待补充,不挡查价。
"""
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}")
if not sea:
origin = known(src, "起运港")
if origin:
lines.append(f"起运地:{origin}")
for key, label, _example in rows:
val = known(src, key)
if val:
lines.append(f"{label}:{val}")
if not sea:
for key, label, _example in _AIR_OPTIONAL_CLARIFY:
if key == "起运港":
continue
val = known(src, key)
if val:
lines.append(f"{label}:{val}")
lines.append("")
lines.append(CLARIFY_PASTE_HINT)
any_missing = False
seq = 0
if "运输方式" in missing_set:
any_missing = True
seq += 1
lines.append(_pending_field_block(seq, "运输方式", "空运"))
if not sea and not known(src, "起运港"):
any_missing = True
seq += 1
lines.append(_pending_field_block(seq, "起运地", _AIR_EXAMPLES["起运港"], optional=True))
for key, label, example in rows:
if key in missing_set:
any_missing = True
seq += 1
if sea and key == "运输分类":
lines.append(_pending_field_block(seq, "运输分类", "Port to Port、其他"))
else:
lines.append(
_pending_field_block(
seq,
label,
example,
unit=_PENDING_UNITS.get(key, ""),
)
)
if not sea:
optional_pending = [
(key, label, example)
for key, label, example in _AIR_OPTIONAL_CLARIFY
if key != "起运港" and not known(src, key)
]
for key, label, example in optional_pending:
any_missing = True
seq += 1
lines.append(_pending_field_block(seq, label, example, optional=True))
if not any_missing:
return ""
return "\n".join(lines)
def ask_missing(display_names: Iterable[str]) -> str:
"""兼容旧调用:仅有展示名时仍走补问,但没有已识别回显。"""
alias = {
"起运地": "起运港",
"目的地": "目的港",
"起运地(机场名或三字码)": "起运港",
"目的地(机场名或三字码)": "目的港",
"毛重": "毛重",
"重量": "毛重",
"重量(KG)": "毛重",
"重量(KG)": "毛重",
"体积": "体积",
"体积(CBM)": "体积",
"体积(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]:
"""
询价确认字段行(销售可见)。
起运/目的展示销售原文:说南京就南京,图上/原话是 SZX 就展示 SZX。
originCode 只给出站 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 "-"
trade = (facts.get("贸易条款") or "-").strip() or "-"
klass = (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"贸易条款:{trade}",
f"运输分类:{klass}",
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"重量(KG):{_with_unit(str(facts.get('毛重') or ''), 'KGS')}",
f"体积(CBM):{_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_TMS,
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/VT + 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 _total_label(
quote: dict[str, Any] | None = None,
business_line: str = "",
transport_mode: str = "",
) -> str:
"""销售侧汇总行。企微卡片、文字明细、成交通知都叫费用合计。"""
src = dict(quote or {})
return total_price_label(
business_line or str(src.get("business_line") or ""),
transport_mode,
quote=src,
)
def _manual_quote_lines(
manual: dict[str, Any],
*,
business_line: str = "",
transport_mode: str = "",
) -> list[str]:
"""人工报价段:费用行 + 总销售价,不写合计。"""
src = dict(manual or {})
lines = [f"{name}:{amount}" for name, amount in quote_fee_lines(src)]
header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip()
total = money_text(str(src.get("total") or "-").strip() or "-", header_ccy)
if total and total != "-":
lines.append(f"{_total_label(src, business_line, transport_mode)}:{total}")
elif not lines:
lines.append(f"{_total_label(src, business_line, transport_mode)}:{total or '-'}")
return lines
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
_SEA_FEE_BUCKETS = (
("polCostItems", "始发港费用", "始发港费用小计"),
("oceanCostItems", "海运费", "海运费小计"),
("podCostItems", "目的港费用", "目的港费用小计"),
)
def _parse_fee_bucket(quote: dict[str, Any], key: str) -> list[dict[str, Any]]:
"""读 polCostItems / fee_rows_polCostItems,字符串 JSON 也认。"""
raw = quote.get(key) or quote.get("fee_rows_" + key)
if isinstance(raw, str) and raw.strip():
try:
raw = json.loads(raw)
except json.JSONDecodeError:
return []
if not isinstance(raw, list):
return []
return [row for row in raw if isinstance(row, dict)]
def sea_bucket_fee_lines(quote: dict[str, Any] | None) -> list[str]:
"""
海运三段正文。有任一段桶才用;否则返回空列表,调用方回退扁平 fee_lines。
费目按该条币种金额原样;小计用 fee_total_* + 表头结算币种。本地不折算。
"""
src = dict(quote or {})
header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip()
sections: list[str] = []
any_bucket = False
for key, title, subtotal_label in _SEA_FEE_BUCKETS:
rows = _parse_fee_bucket(src, key)
subtotal = str(src.get("fee_total_" + key) or "").strip()
if not rows and not subtotal:
continue
any_bucket = True
sections.append(title)
for row in rows:
name = str(row.get("name") or row.get("label") or row.get("feeName") or "").strip()
amount = str(row.get("amount") or row.get("value") or "").strip()
line_ccy = str(row.get("currency") or row.get("currencyCode") or header_ccy).strip()
if name and amount and amount != "-":
sections.append(f"{name}:{money_text(amount, line_ccy)}")
if subtotal and subtotal != "-":
sections.append(f"{subtotal_label}:{money_text(subtotal, header_ccy)}")
sections.append("")
if not any_bucket:
return []
while sections and sections[-1] == "":
sections.pop()
return sections
def _land_grouped_fee_lines(quote: dict[str, Any] | None) -> list[str]:
"""陆运:每段先写始发→目的,再写该段 TMS 费用名。"""
from agent.schema.land_route_options import land_leg_fee_blocks
src = dict(quote or {})
header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip()
lines: list[str] = []
for title, fees, eta in land_leg_fee_blocks(src):
lines.append(title)
for fee in fees:
name = str(fee.get("name") or "").strip()
amount = str(fee.get("amount") or "").strip()
line_ccy = str(fee.get("currency") or fee.get("currencyCode") or header_ccy).strip()
if name and amount and amount != "-":
lines.append(f"{name}:{money_text(amount, line_ccy)}")
if eta:
lines.append(f"时效:{eta}")
return lines
def _is_land_surface(*, quote: dict[str, Any] | None = None, business_line: str = "", transport_mode: str = "") -> bool:
"""陆运销售面:有 landSegments / 陆运业务线就按段出明细。"""
src = dict(quote or {})
if src.get("landSegments") or src.get("land_segments") or src.get("selected_land_route"):
return True
raw_line = (business_line or "").strip()
if raw_line and scheme_transport_mode(raw_line, fallback="") == "陆运":
return True
mode = (transport_mode or "").strip()
return bool(mode) and scheme_transport_mode(mode, fallback="") == "陆运"
def quote_area_fee_text(
quote: dict[str, Any] | None,
business_line: str = "",
transport_mode: str = "",
) -> 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)
bucket_lines = sea_bucket_fee_lines(src)
land_lines = _land_grouped_fee_lines(src) if _is_land_surface(
quote=src, business_line=business_line, transport_mode=transport_mode
) else []
if bucket_lines:
fee_lines = list(bucket_lines)
elif land_lines:
fee_lines = list(land_lines)
else:
fee_lines = [f"{name}:{amount}" for name, amount in quote_fee_lines(src)]
fee_lines.append(f"{_total_label(src, business_line, transport_mode)}:{total}")
return "\n".join(fee_lines)
def quote_area_safe_text(
quote: dict[str, Any] | None,
business_line: str = "",
transport_mode: 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)
n = len(quote_fee_lines(src))
if _is_land_surface(quote=src, business_line=business_line, transport_mode=transport_mode):
from agent.schema.land_route_options import land_leg_fee_blocks
block_n = sum(len(fees) for _title, fees, _eta in land_leg_fee_blocks(src))
if block_n:
n = block_n
label = _total_label(src, business_line, transport_mode)
if n:
return f"{label}:{total}\n共{n}项费用,详见上一条明细"
return f"{label}:{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 = "已报价",
business_line: str = "SEA",
) -> str:
"""
完整价格明细文本:来源、工单号、费用行、总价。海运/陆运/空运共用。
陆运分段时效跟在各段费用后面,用 TMS 原文;报价有效期写在总成本价下面。
海运时效、报价有效期仍只在按钮卡副标题。
空运:业务确认没有时效,总销售价下面只留报价有效期。
不受卡片引用条字数限制。first_or_same 只兼容调用方。
"""
_ = first_or_same, history_work_order_no, status
src = dict(quote or {})
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
lines = [source, f"{work_order_no}价格明细"]
line = (business_line or "").upper()
if line == "AIR":
from agent.schema.air_route_options import route_codes
route = route_codes(None, {}, src)
if route:
lines.append(route)
if line == "LAND":
from agent.schema.land_route_options import land_leg_fee_blocks
# 只有一段时,费用前的「始发 → 目的」已经是这条线路。
# 再写横杠全程(上海-谅山…)就是同一条路出现两次。多段仍保留全程,方便看中转。
blocks = land_leg_fee_blocks(src)
land_line = str(src.get("route_line") or "").strip()
if land_line and len(blocks) != 1:
lines.append(land_line)
lines.append(quote_area_fee_text(src, business_line=business_line or "SEA"))
# 空运、陆运的价格明细正文都要带有效期。海运仍放在按钮卡副标题,避免和原口径打架。
if line in {"AIR", "LAND"}:
lines.append(f"报价有效期:{tms_valid_until_display(src)}")
return "\n".join(lines)
def collab_hint(work_order_no: str) -> str:
"""空运价格卡协同说明:请把相关方拉进原有协同群,并 @ 询价小助手。紧挨生成报价单按钮上方。"""
return (
f"请手动将相关方拉到空运原有协同群,并在空运协同群中发送"
f"“ {work_order_no}@询价小助手”,谢谢。"
)
def _strip_leading_dash(raw: str) -> str:
"""去掉时效前导 '-' / '—',避免销售看到「-3-5天」或「-;3-5天」。"""
text = (raw or "").strip()
while text.startswith(("-", "—", "–")):
rest = text[1:].lstrip(" \t;;,,")
if not rest:
return ""
text = rest
return text
def tms_eta_display(quote: dict[str, Any] | None) -> str:
"""时效只取 TMS 回的 eta / 预计送达;没有就「-」。禁止本地编。前导横杠会剥掉。"""
src = dict(quote or {})
eta = _strip_leading_dash(str(src.get("eta") or ""))
if eta:
return eta
dated = _strip_leading_dash(
str(src.get("eta_date") or src.get("estimated_delivery") or "")
)
return dated or "-"
def tms_valid_until_display(quote: dict[str, Any] | None) -> str:
"""报价有效期只取 TMS 原文,没有就 "-"。"""
src = dict(quote or {})
raw = str(
src.get("valid_until")
or src.get("validUntil")
or src.get("quoteValidUntil")
or ""
).strip()
return raw if raw else "-"
def similar_inquiry_line(*, first_or_same: str, history_work_order_no: str = "") -> str:
"""
相同询价内容检索文案(主账仍会算,销售侧价格卡已隐藏,不再调用)。
"""
no = (history_work_order_no or "").strip()
if first_or_same == "same" and no:
return f"相同询价内容检索:引用历史工单号{no}"
return "相同询价内容检索:首次询价"
def quote_detail_lines(
quote: dict[str, Any] | None,
*,
first_or_same: str = "first",
history_work_order_no: str = "",
status: str = "已报价",
business_line: str = "",
transport_mode: 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)
bucket_lines = sea_bucket_fee_lines(src) if (business_line or "").upper() == "SEA" or (
transport_mode or ""
).strip() == "海运" else []
land_lines = _land_grouped_fee_lines(src) if _is_land_surface(
quote=src, business_line=business_line, transport_mode=transport_mode
) else []
if bucket_lines:
lines = list(bucket_lines)
elif land_lines:
lines = list(land_lines)
else:
lines = [f"{name}:{amount}" for name, amount in quote_fee_lines(src)]
lines.append(f"{_total_label(src, business_line, transport_mode)}:{total}")
if not _is_air_surface(business_line=business_line, transport_mode=transport_mode):
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}价格明细"]
from agent.schema.air_route_options import route_codes
route = route_codes(None, {}, src) or str(src.get("route_line") or "").strip()
if route:
lines.append(route)
lines.extend(
quote_detail_lines(
src,
first_or_same=first_or_same,
history_work_order_no=history_work_order_no,
status=status,
business_line="AIR",
)
)
lines.append("")
lines.append(collab_hint(work_order_no))
lines.append(BTN_SKIP_COLLAB)
return "\n".join(lines)
def air_route_entry_card(*, work_order_no: str, count: int, url: str) -> dict[str, Any]:
"""
多条线路入口卡:短文案跳转 H5,避免企微截断。
"""
n = max(int(count or 0), 2)
fallback = "https://ai.ytd-scm.com/inquiry-form"
action_url = (url or "").strip() or fallback
return {
"msgtype": "template_card",
"template_card": {
"card_type": "text_notice",
"source": {"desc": "TMS线路报价", "desc_color": 0},
"main_title": {"title": f"{work_order_no}已查到{n}条线路"},
"sub_title_text": "点击查看并选择一条线路,选定后回企微看价格明细。",
"card_action": {"type": 1, "url": action_url},
"jump_list": [{"type": 1, "title": "查看并选择线路", "url": action_url}],
},
}
def air_route_entry_text(*, work_order_no: str, count: int) -> str:
"""入口卡失败时的文字兜底。"""
n = max(int(count or 0), 2)
return f"{work_order_no}已查到{n}条线路,请点击卡片查看并选择。"
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_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_safe_text(src, business_line="AIR"),
},
"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:
"""请手动拉人进群并发送工单号。后台 inquiring.pull_group_manual。"""
no = (work_order_no or "").strip()
return _r(
"inquiring.pull_group_manual",
f"请手动拉相关人员和智能体到原有群,并在群聊发送工单编号{no}",
ticket_no=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:
"""销售跳过内部协同。后台 inquiring.skip_collab_ack。"""
return _r(
"inquiring.skip_collab_ack",
"好的,已跳过内部协同。正在按 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 = "已报价",
business_line: str = "SEA",
) -> 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,
business_line=business_line or "SEA",
)
)
lines.append("")
lines.append(sea_collab_ask())
lines.append(BTN_PULL_COLLAB)
lines.append(BTN_SKIP_COLLAB_TMS)
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 = "已报价",
business_line: str = "SEA",
) -> 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, business_line=business_line or "SEA"),
},
"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_TMS,
"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}
MULTI_CONFIRM_TAIL = "请回复「确定」创建工单,内容有误时,请重新发内容。"
MULTI_SAME_MODE = "同一运输方式出现了两段,请拆开再发。"
# 核对卡已取消时效,不再对销售发送此句。
MULTI_CONFIRM_EXPIRED = "刚才的多段核对已过期,请把多段需求再发一次,出核对后再回复确定。"
def multi_air_manual_hint(*, work_order_no: str) -> str:
"""含空运的多段:不自动拉群。与 inquiring.pull_group_manual 同一条后台。"""
return tms_miss_quote_text(work_order_no=work_order_no)
def multi_pull_text(*, work_order_no: str) -> str:
"""不含空运:建单后的拉群文字兜底。不写 TMS、不写暂无报价。"""
no = (work_order_no or "").strip()
return "\n".join(
[
f"询价工单 {no}",
"工单已创建。",
"是否拉产品人员进群协同?",
BTN_PULL_COLLAB,
]
)
def multi_pull_wecom_payload(*, work_order_no: str) -> dict[str, Any]:
"""不含空运:一张卡、一个按钮,点下去才按各段线路建群。"""
no = (work_order_no or "").strip()
return {
"msgtype": "template_card",
"template_card": {
"card_type": "button_interaction",
"source": {"desc": "多段联运", "desc_color": 0},
"main_title": {"title": f"询价工单 {no}", "desc": "工单已创建。"},
"sub_title_text": "是否拉产品人员进群协同?",
"task_id": card_task_id("multi_pull", no or "multi"),
"button_list": [
{
"text": BTN_PULL_COLLAB,
"style": 1,
"key": f"{BTN_PULL_COLLAB_KEY}:{no}",
}
],
},
}
def handoff_air_hint(*, work_order_no: str) -> str:
"""空运转人工:只发文字,不建群。后台 transfer.air_hint。"""
no = (work_order_no or "").strip()
if no:
default = f"触发转人工,请手动把航线和智能体拉进原有空运群,并发送工单号 {no}。"
return _r("transfer.air_hint", default, ticket_no=no)
return _r(
"transfer.air_hint",
"触发转人工,请手动把航线和智能体拉进原有空运群,并发送工单号。",
ticket_no="",
)
def handoff_offer_text(*, reason: str, transport_mode: str) -> str:
"""海运/陆运转人工卡片的文字兜底(点卡或原样回复按钮均可)。"""
why = (reason or "").strip() or HANDOFF_INTENT_REASON
mode = (transport_mode or "").strip()
lines = [why]
if mode:
lines.append(f"运输方式:{mode}")
lines.append("是否拉产品人员进群协同?")
lines.append(BTN_PULL_COLLAB)
return "\n".join(lines)
def handoff_offer_wecom_payload(*, reason: str, task_token: str) -> dict[str, Any]:
"""海运/陆运一张卡、一个按钮:拉产品进群协同。"""
token = (task_token or "handoff").strip() or "handoff"
why = (reason or "").strip() or HANDOFF_INTENT_REASON
return {
"msgtype": "template_card",
"template_card": {
"card_type": "button_interaction",
"source": {"desc": "转人工", "desc_color": 0},
"main_title": {"title": why, "desc": "是否拉产品人员进群协同?"},
"task_id": card_task_id("handoff", token),
"button_list": [
{
"text": BTN_PULL_COLLAB,
"style": 1,
"key": f"{BTN_PULL_COLLAB_KEY}:{token}",
}
],
},
}
def handoff_group_brief(
*,
work_order_no: str,
facts: dict[str, str] | None,
business_line: str,
product_names: list[str] | None = None,
sales_name: str = "",
airline_names: list[str] | None = None,
) -> str:
"""
转人工群摘要:@人 +「工单号转人工」+ 已整理询价;未整理与协同全部为 -。
不写 TMS、不写模板。
"""
from agent.policy.handoff_human import collab_keys_for_handoff, transport_label
from agent.schema.field_validate import collab_display_name
no = (work_order_no or "").strip()
line = (business_line or "").strip().upper()
mode = transport_label(line) or "海运"
names: list[str] = []
if line == "AIR":
if (sales_name or "").strip():
names.append(sales_name.strip())
for n in airline_names or []:
if (n or "").strip() and n.strip() not in names:
names.append(n.strip())
else:
for n in product_names or []:
if (n or "").strip() and n.strip() not in names:
names.append(n.strip())
at = " ".join(f"@{n}" for n in names)
src = dict(facts or {})
field_lines = inquiry_field_lines(work_order_no=no, facts=src, transport_mode=mode)
body = field_lines[1:] if field_lines else []
lines: list[str] = []
if at:
lines.append(at)
lines.append(f"{no}转人工")
lines.append("询价字段:")
lines.extend(body)
lines.append("")
lines.append("协同补充字段:")
for key in collab_keys_for_handoff(src, line):
label = collab_display_name(key, line)
lines.append(f"{label}:-")
return "\n".join(lines)
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:
"""拉群成功后的私聊。后台 inquiring.group_created。已邀请写成「销售某某、产品某某」。"""
no = (work_order_no or "").strip()
sales_raw = (sales_name or "").strip()
sales = _role_label(sales_raw, "销售")
name_bits = [n.strip() for n in (product_names or []) if (n or "").strip()]
products = "、".join(_role_label(n, "产品") for n in name_bits) or "产品"
default = (
f"协同群已创建,群名与工单号一致:{no}。"
f"已邀请:{sales}、{products}。请在群聊中继续确认报价。"
)
sales_var = sales_raw[2:] if sales_raw.startswith("销售") else sales_raw
prod_var = "、".join(
(n[2:] if n.startswith("产品") else n) for n in name_bits
) or "产品"
return _r(
"inquiring.group_created",
default,
ticket_no=no,
sales_name=sales_var,
product_names=prod_var,
)
def sea_group_exists(*, work_order_no: str) -> str:
"""同一工单已有群,不再新建。后台 inquiring.group_exists。"""
no = (work_order_no or "").strip()
return _r(
"inquiring.group_exists",
f"协同群已在,群名与工单号一致:{no}。请到该群继续确认报价。",
ticket_no=no,
)
def sea_group_no_staff() -> str:
"""没有可拉的产品人员,不建空群。后台 inquiring.sea_no_staff。"""
return _r(
"inquiring.sea_no_staff",
"没有可拉的产品人员。请先在后台「员工管理」维护本线路的海运产品岗后再试。",
)
def group_sheet_send_fail() -> str:
"""模板已填好但没发进群。"""
return "报价单已生成,发到群里失败,请稍后重试。"
def sea_group_create_fail(*, reason: str = "") -> str:
"""建群失败,按钮保持可点。后台 inquiring.group_create_fail(无原因短句)。"""
extra = (reason or "").strip()
default = f"建群失败:{extra}。请稍后重试。" if extra else "建群失败,请稍后重试。"
return _r("inquiring.group_create_fail", default, reason=extra)
def quote_is_product(quote: dict[str, Any] | None) -> bool:
"""来源是产品/航线人工,不能写进 TMS 报价段。"""
src = dict(quote or {})
code = str(src.get("source") or "").strip().upper()
if code == "PRODUCT":
return True
if code in {"TMS", "SALES_ADJUST"}:
return False
label = str(src.get("source_label") or src.get("sourceLabel") or src.get("source") or "")
return "产品" in label or "航线" in label
def quote_is_sales_adjust(quote: dict[str, Any] | None) -> bool:
"""销售调过价的这一版,禁止再写进 TMS 报价段。"""
src = dict(quote or {})
code = str(src.get("source") or "").strip().upper()
if code == "SALES_ADJUST":
return True
if code in {"TMS", "PRODUCT"}:
return False
label = str(src.get("source_label") or src.get("sourceLabel") or "").replace(" ", "")
return "销售调价" in label
def quote_is_tms(quote: dict[str, Any] | None) -> bool:
"""
只有 TMS 原价能进 TMS 段。
产品报价、销售调价即使金额还在,也不能冒充 TMS。
"""
src = dict(quote or {})
if not src:
return False
if quote_is_product(src) or quote_is_sales_adjust(src):
return False
code = str(src.get("source") or "").strip().upper()
if code == "TMS":
return True
label = str(src.get("source_label") or src.get("sourceLabel") or src.get("source") or "")
return "TMS" in label.upper() or "标准报价" in label or not label.strip()
def remember_tms_quote(sess: Any, quote: dict[str, Any] | None) -> None:
"""
书签里单独记住 TMS 原价。调价只改 sess.quote,不准覆盖这一份。
副作用:写 sess.tms_quote。调用方在 TMS 命中或调价前调用。
"""
if sess is None:
return
src = dict(quote or {})
if not quote_is_tms(src):
return
sess.tms_quote = dict(src)
def resolve_tms_quote(sess: Any = None, ticket: Any = None) -> dict[str, Any]:
"""
拉群/群摘要用的 TMS 原价:书签 tms_quote → 主账 tms_quote → 当前价若仍是 TMS。
调后最新一条是销售调价,不能拿来当 TMS。
"""
candidates: list[dict[str, Any]] = []
if sess is not None:
candidates.append(dict(getattr(sess, "tms_quote", None) or {}))
view = dict(ticket or {}) if isinstance(ticket, dict) else {}
if view:
candidates.append(dict(view.get("tms_quote") or view.get("tmsQuote") or {}))
elif ticket is not None:
candidates.append(dict(getattr(ticket, "tms_quote", None) or {}))
if sess is not None:
candidates.append(dict(getattr(sess, "quote", None) or {}))
if view:
candidates.append(dict(view.get("quote") or {}))
for item in candidates:
if quote_is_tms(item) and (item.get("total") or item.get("fee_rows") or item.get("fee_lines")):
return item
return {}
def _land_quote_is_selected(quote: dict[str, Any] | None) -> bool:
"""已收成一条陆运线路:有选定段,或只剩一条且写了线路名。"""
src = dict(quote or {})
selected = src.get("selected_land_route")
if isinstance(selected, dict) and list(selected.get("legs") or []):
return True
if not str(src.get("route_line") or "").strip():
return False
from agent.schema.land_route_options import needs_route_pick
return not needs_route_pick(src)
def land_group_tms_quote(
tms: dict[str, Any] | None,
current: dict[str, Any] | None,
) -> dict[str, Any]:
"""
群摘要陆运 TMS 段用已选线路,不用还没选的整包。
选定前书签里的 TMS 原价含全部备选费用,总成本价和时效都还没汇总。
当前价已是选定投影时用当前价。当前价已是人工报价或调价时,仍用传入的 TMS 价。
"""
raw = dict(tms or {})
cur = dict(current or {})
if not quote_is_tms(cur) or not _land_quote_is_selected(cur):
return raw
from agent.schema.land_route_options import needs_route_pick
if _land_quote_is_selected(raw) and not needs_route_pick(raw):
return raw
return cur
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 暂无匹配报价」并提示用文字或 Excel 另报;产品报的价单独放人工报价,销售调价另起一段。
禁止把调后价写进 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, manual, adjust = _split_brief_quotes(
quote=quote,
tms_quote=tms_quote,
product_quote=product_quote,
has_price=has_price,
)
if mode == "陆运":
# 拉群时书签仍可能是整包。当前价已选定时换成那一条,和私聊明细对齐。
tms = land_group_tms_quote(tms, quote)
lines.append("")
lines.append("TMS报价:")
if tms and (tms.get("total") or quote_fee_lines(tms)):
if mode == "陆运":
# 与私聊价格明细同一套:线路名、分段费用、各段时效、总成本价、有效期。
# 不用 quote_detail_lines,那会再补一行合并时效,也会在未选定时摊开全部费用。
# 群里已有「TMS报价:」,不要再叠「TMS标准报价」和「工单号价格明细」。
detail = sea_quote_detail_text(
work_order_no=no,
quote=tms,
business_line="LAND",
).splitlines()
source = str(tms.get("source_label") or "TMS标准报价").replace(" ", "")
title = f"{no}价格明细"
lines.extend(ln for ln in detail if ln.strip() not in {source, title, "TMS标准报价"})
else:
lines.extend(quote_detail_lines(tms, transport_mode=mode))
lines.append(group_tms_confirm_hint(business_line="LAND" if mode == "陆运" else "SEA"))
else:
lines.append("TMS 暂无匹配报价")
lines.append(group_no_tms_quote_hint(business_line="LAND" if mode == "陆运" else "SEA"))
if manual and (quote_fee_lines(manual) or str(manual.get("total") or "").strip() not in {"", "-"}):
lines.append("")
lines.append("人工报价:")
lines.extend(_manual_quote_lines(manual, transport_mode=mode))
if adjust and (quote_fee_lines(adjust) or str(adjust.get("total") or "").strip() not in {"", "-"}):
lines.append("")
lines.append("销售调价:")
lines.extend(_manual_quote_lines(adjust, transport_mode=mode))
return "\n".join(lines)
def _split_brief_quotes(
*,
quote: dict[str, Any] | None,
tms_quote: dict[str, Any] | None,
product_quote: dict[str, Any] | None,
has_price: bool,
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
"""
群摘要三段价:TMS 原价 / 人工 / 销售调价。调后价不准掉进 TMS。
tms_quote 显式传入(含空字典)时不再用当前 quote 兜底。
"""
src = dict(quote or {})
tms = dict(tms_quote) if tms_quote is not None else {}
manual = dict(product_quote or {})
if tms_quote is None:
if has_price and src and quote_is_tms(src):
tms = src
elif quote_is_product(src):
manual = manual or src
if quote_is_sales_adjust(tms) or quote_is_product(tms):
tms = {}
if quote_is_sales_adjust(manual):
manual = {}
adjust = src if quote_is_sales_adjust(src) else {}
if quote_is_sales_adjust(product_quote) and not adjust:
adjust = dict(product_quote or {})
return tms, manual, adjust
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, transport_mode=transport_mode),
]
if not _is_air_surface(transport_mode=transport_mode):
lines.append(f"时效:{eta or '-'}")
lines.extend(
[
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 _air_scheme_route(quote: dict[str, Any], facts: dict[str, str]) -> str:
"""方案卡航线:空运ZUH→中转→CRK。没有三字码才退回地名。"""
from agent.schema.air_route_options import scheme_route_codes
return scheme_route_codes(quote, facts)
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 _is_air_surface(*, business_line: str = "", transport_mode: str = "") -> bool:
"""
空运销售面:业务确认没有时效,展示层一律不写。
海运/陆运仍保留时效。两边都空时不算空运,避免误藏海运/陆运卡。
"""
raw_line = (business_line or "").strip()
if raw_line and scheme_transport_mode(raw_line, fallback="") == "空运":
return True
mode = (transport_mode or "").strip()
return bool(mode) and scheme_transport_mode(mode, fallback="") == "空运"
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(_air_scheme_route(q, src), 26)},
{"keyname": "货物", "value": _clip(f"{cargo};{pieces};{weight};{volume}", 26)},
{"keyname": "包装", "value": _clip(pack, 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, transport_mode=transport_mode),
},
"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:
"""销售选择不采用。后台 quoted.reject_ack。"""
return _r(
"quoted.reject_ack",
"请问如何调整报价单,以满足您的需求?请说明要调哪一项费用,例如「提货费加 10%」。",
)
def adjust_confirm(applied: list[dict[str, Any]]) -> str:
"""识别成功确认。applied 每项含 name、how。"""
bits = [f"{x.get('name')}在当前报价基础上{x.get('how')}" for x in applied if x.get("name")]
body = ";".join(bits) or "已识别调价指令"
return f"已识别调价指令:{body}。正在刷新报价"
def adjust_ledger_remark(applied: list[dict[str, Any]]) -> str:
"""
主账状态流转备注:每次销售调价一条,记下当时改了哪几项。
后台「状态流转与备注」原样展示;不含利润点。
"""
bits = [f"{x.get('name')}{x.get('how')}" for x in applied if x.get("name")]
return "销售调价:" + (";".join(bits) if bits else "已改价")
def adjust_need_fee() -> str:
return "请说明要调哪一项费用明细,例如「提货费加 10%」。只说报价或总价,系统不会改价。"
def adjust_need_lines() -> str:
return "请分别对明细价格进行调价,系统不分摊合计。"
def adjust_currency_hint() -> str:
return "请分别对不同币种进行调价,系统不做汇率换算。"
def adjust_unmatched(missing: list[str], available: list[str]) -> str:
miss = "、".join(x for x in missing if x) or "这些费用"
have = "、".join(available) if available else "(当前没有费用明细)"
return f"卡上没有{miss}。当前能调的费用:{have}"
def adjust_ambiguous(name: str, hits: list[str]) -> str:
return f"「{name}」对上了多项({'、'.join(hits)}),请说明要调哪一项。"
def adjust_ineligible(reason: str) -> str:
if reason == "already_collab":
return "这张单已经拉群协同,请到群里发送调价指令。"
if reason == "closed":
return "这张单已经结案,不能再按这套调价。"
return "当前工单还没有可调的报价,不能按这套调价。"
def adjust_only_sales(*, air_room: bool = False) -> str:
"""
群里不是这张单的销售在发调价。价格不改。
空运群称呼航线;海运、陆运称呼产品。两句用句号分开,避免连成一句。
"""
who = "航线" if air_room else "产品"
return f"只有这张单的销售能调价。{who}请继续报价,价格未改。"
def adjust_group_body(quote: dict[str, Any] | None, business_line: str = "") -> str:
"""
群里调后全量明细,利润点不写给销售。
海运必须带三段标题与小计(始发港/海运/目的港),禁止只摊平 fee_lines 把小计弄丢。
"""
src = dict(quote or {})
text = quote_area_fee_text(src, business_line=business_line)
return "调价后价格:\n" + text
def adopt_excel_ack() -> str:
"""采用并生成 Excel。后台 quoted.adopt_excel。"""
return _r("quoted.adopt_excel", "已确认采用该方案,正在生成 Excel 最终报价单…")
def adopt_pdf_ack() -> str:
"""采用并生成 PDF。后台 quoted.adopt_pdf。"""
return _r("quoted.adopt_pdf", "已确认采用该方案,正在生成PDF最终报价单…")
QUOTE_TEMPLATE_MISSING = "生成报价单失败,请联系IT运维维护后台报价模板。"
def quote_template_missing() -> str:
"""关键词与默认模板都未维护。后台 quoted.template_missing。"""
return _r("quoted.template_missing", QUOTE_TEMPLATE_MISSING)
def file_ready(*, kind: str, work_order_no: str, filename: str) -> str:
"""最终报价单生成完成。后台 quoted.file_ready。"""
return _r(
"quoted.file_ready",
f"已生成{kind}最终报价单:{filename}(工单 {work_order_no})",
kind=kind,
filename=filename,
ticket_no=work_order_no,
)
# 未成交原因说明跟在未成交后,不跟在协商中后。验收脚本认这个开关。
DEAL_LOST_REASON_BEFORE_NEGOTIATE = True
DEAL_HINT = "咨询成交状态:请输入成交 / 未成交 (未成交需补充原因)/ 协商中。"
DEAL_SUBTITLE = "报价单已发送,请跟进成交状态"
def deal_horizontal_rows(
*,
quote: dict[str, Any] | None,
facts: dict[str, str] | None = None,
business_line: str = "",
transport_mode: str = "",
) -> list[dict[str, str]]:
"""
成交跟进卡字段:费用合计、报价有效期;海运/陆运再加时效。
空运业务确认没有时效,这里不写。
不要历史报价 / 工单状态 / 来源。来源只留卡片顶部红字。
"""
_ = facts
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)
rows = [
{"keyname": _total_label(src, business_line, transport_mode), "value": _clip(total, 26)},
]
if not _is_air_surface(business_line=business_line, transport_mode=transport_mode):
rows.append({"keyname": "时效", "value": _clip(tms_eta_display(src), 26)})
rows.append({"keyname": "报价有效期", "value": _clip(tms_valid_until_display(src), 26)})
return rows
def deal_card(
*,
work_order_no: str,
status: str = "",
summary: str = "",
quote: dict[str, Any] | None = None,
facts: dict[str, str] | None = None,
business_line: str = "",
) -> str:
"""
成交跟进文字兜底(模板卡发失败时仍给销售看)。
与卡片同一套字段;status / summary 只兼容旧调用,不再展示。
"""
_ = status, summary
no = (work_order_no or "").strip()
rows = deal_horizontal_rows(quote=quote, facts=facts, business_line=business_line)
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,
business_line: str = "",
) -> 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, business_line=business_line
),
"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:
"""非产品人员在群里报价。后台 inquiring.group_only_product。"""
return _r(
"inquiring.group_only_product",
"只有产品能报价格,请产品在群里发价格或 Excel/PDF",
)
_GROUP_QUOTE_TAIL = (
"亦可通过“纯文字”告知新报价,或直接将新报价excel表发给智能体。\n"
"(注:附件报价单仅作为文件存储于后台,智能体不读取且不将报价内容写进系统)"
)
def group_tms_confirm_hint(*, business_line: str = "") -> str:
"""
有 TMS 价时写在报价有效期下方:谁可以确认沿用,以及怎么另报。
空运喊航线人员,海运/陆运喊产品人员。附件只当存档,不读进报价。
"""
line = (business_line or "").strip().upper()
who = "航线人员" if line == "AIR" else "产品人员"
return f"请{who}回复“确认报价”确定采用TMS报价。\n{_GROUP_QUOTE_TAIL}"
def group_no_tms_quote_hint(*, business_line: str = "") -> str:
"""
TMS 无价时写在「暂无匹配」下方:谁可以用文字或 Excel 另报。
空运喊航线人员,海运/陆运喊产品人员。附件只当存档,不读进报价。
"""
line = (business_line or "").strip().upper()
who = "航线人员" if line == "AIR" else "产品人员"
return (
f"{who}可通过“纯文字”告知新报价,或直接将新报价excel表发给智能体。\n"
"(注:附件报价单仅作为文件存储于后台,智能体不读取且不将报价内容写进系统)"
)
def quote_attachment_saved_app(work_order_no: str, count: int = 1) -> str:
"""
群里告诉大家:模板或产品附件已进后台。
后台 inquiring.group_file_saved。默认句带工单号和份数;后台改成短句则原样用。
"""
no = (work_order_no or "").strip() or "WO"
n = count if count > 0 else 1
return _r(
"inquiring.group_file_saved",
f"工单{no}的{n}个附件已保存到后台工单",
ticket_no=no,
count=n,
)
def group_file_saved(*, work_order_no: str = "", count: int = 1) -> str:
"""群附件已保存。与 quote_attachment_saved_app 同一条后台编码。"""
return quote_attachment_saved_app(work_order_no, count)
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_confirm_need_tms() -> str:
return "本单还没有可沿用的 TMS 报价,请直接回复协同报价或上传报价附件。"
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,
next_followup_at: str = "",
lost_reason: str = "",
) -> str:
"""成交三态回执。后台 deal.recorded_won / lost.ask_reason / negotiating.recorded。"""
no = (work_order_no or "").strip()
if outcome == "已成交":
return _r(
"deal.recorded_won",
f"已记录为「已成交」。工单 {no} 已关闭智能体跟进。",
ticket_no=no,
)
if outcome == "未成交":
reason = (lost_reason or "").strip()
if reason:
return f"已记录为「未成交」。{lost_reason_ack(reason, work_order_no=no)}"
return _r("lost.ask_reason", "已记录为「未成交」。请补充未成交原因。", ticket_no=no)
when = _followup_month_day(next_followup_at) or "第四个工作日"
return _r(
"negotiating.recorded",
f"已记录为「协商中」。已顺延三个工作日,{when}的9:00再次发起成交状态询问。",
followup_when=when,
ticket_no=no,
)
def _followup_month_day(raw: str) -> str:
"""把主账再问时刻收成「9月24日」。"""
from datetime import datetime
text = (raw or "").strip()
if not text:
return ""
for fmt, n in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d", 10)):
try:
dt = datetime.strptime(text[:n], fmt)
return f"{dt.month}月{dt.day}日"
except ValueError:
continue
return ""
def lost_reason_ack(reason: str = "", work_order_no: str = "") -> str:
"""
收下未成交原因后的回执。后台 lost.reason_ack。
后台模板里的 {{lost_reason}}、{{ticket_no}} 都在这里填。
不传工单号时占位留空串,避免群里再露出 {{ticket_no}}。
"""
text = (reason or "").strip()
no = (work_order_no or "").strip()
if text.endswith(("。", ".", "!", "!", "?", "?")):
default = f"已记下未成交原因:{text}"
else:
default = f"已记下未成交原因:{text}。" if text else "已记下未成交原因。"
return _r("lost.reason_ack", default, lost_reason=text, ticket_no=no)
def deal_notify_card(
*,
work_order_no: str,
sales_name: str,
facts: dict[str, str] | None,
quote: dict[str, Any] | None,
business_line: str,
) -> str:
"""成交通知文字兜底:方案卡压缩。不写销售,避免标题出现「销售 销售」。"""
no = (work_order_no or "").strip()
_ = sales_name
mode = scheme_transport_mode(business_line)
rows = scheme_horizontal_rows(
work_order_no=no,
facts=dict(facts or {}),
quote=dict(quote or {}),
transport_mode=mode,
)
lines = [f"成交通知 工单 {no}"]
lines.extend(f"{row['keyname']}:{row['value']}" for row in rows)
return "\n".join(lines)
def site_card_url() -> str:
"""
整张卡片点击后打开的后台地址。
事件异常、系统异常、成交通知都走这里,点开是同一个网站。
不用企业微信授权页,也不要用 work.weixin.qq.com。
"""
from agent.config import get_settings
base = str(getattr(get_settings(), "public_base_url", "") or "").strip().rstrip("/")
return base or "https://ai.ytd-scm.com"
def deal_notify_wecom_payload(
*,
work_order_no: str,
sales_name: str,
facts: dict[str, str] | None,
quote: dict[str, Any] | None,
business_line: str,
) -> dict[str, Any]:
"""
成交通知卡:走询价消息服务,不走询价小助手。
字段按报价方案卡压缩,避免超过 6 格。标题不写销售。
"""
no = (work_order_no or "").strip()
_ = sales_name
mode = scheme_transport_mode(business_line)
rows = scheme_horizontal_rows(
work_order_no=no,
facts=dict(facts or {}),
quote=dict(quote or {}),
transport_mode=mode,
)
action_url = site_card_url()
return {
"msgtype": "template_card",
"wecom_app": "notify",
"template_card": {
"card_type": "text_notice",
"source": {"desc": "成交通知", "desc_color": 0},
"main_title": {
"title": f"成交通知 工单 {no}",
},
"horizontal_content_list": rows,
"card_action": {"type": 1, "url": action_url},
},
}
def air_need_work_order() -> str:
"""空运群还没有当前单,且这句话里没有工单号。"""
return "请先 @我 并发送要跟的工单号,例如 WO202609160001。"
def air_lock_need_work_order() -> str:
"""锁舱/释放必须自带工单号。后台 inquiring.air_lock_need_wo。"""
return _r(
"inquiring.air_lock_need_wo",
"锁舱或释放舱位请带上工单号,例如 WO202609160001锁舱。",
)
def air_ticket_not_found(work_order_no: str) -> str:
"""空运群找不到工单。后台 inquiring.air_ticket_not_found。"""
no = (work_order_no or "").strip()
return _r("inquiring.air_ticket_not_found", f"找不到工单{no},请核对工单号后再试。", ticket_no=no)
def air_ticket_not_air(work_order_no: str) -> str:
"""工单不是空运单。后台 inquiring.air_ticket_not_air。"""
no = (work_order_no or "").strip()
return _r(
"inquiring.air_ticket_not_air",
f"工单{no}不是空运单,不能在空运群里跟。",
ticket_no=no,
)
def air_ticket_closed(work_order_no: str, status: str) -> str:
"""空运工单已是终态仍要激活。后台 closed.air_cannot_activate。"""
no = (work_order_no or "").strip()
st = (status or "").strip() or "已关闭"
return _r(
"closed.air_cannot_activate",
f"工单{no}已是「{st}」,不能再激活。",
ticket_no=no,
status=st,
)
def air_ticket_occupied(work_order_no: str) -> str:
"""空运工单已在另一个群。后台 inquiring.air_ticket_occupied。"""
no = (work_order_no or "").strip()
return _r(
"inquiring.air_ticket_occupied",
f"工单{no}已经在另一个群跟了,请到那个群继续。",
ticket_no=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报价。
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 {})
from agent.schema.field_validate import air_general_cargo_collab_defaults
# 品名是普货时,摘要里不能再把没写的含电/含磁显示成横杠。
for key, val in air_general_cargo_collab_defaults(facts).items():
current = (collab.get(key) or "").strip()
if current in {"", "-", "—", "-"}:
collab[key] = val
for key in AIR_COLLAB_FIELDS:
val = (collab.get(key) or "").strip()
if val in {"", "-", "—", "-"}:
val = "-"
lines.append(f"{key}:{val}")
tms, manual, adjust = _split_brief_quotes(
quote=quote,
tms_quote=tms_quote,
product_quote=product_quote,
has_price=has_price,
)
if tms:
from agent.schema.air_route_options import project_group_air_quote
tms = project_group_air_quote(tms, facts)
lines.append("")
lines.append("TMS报价:")
if tms and (tms.get("total") or quote_fee_lines(tms)):
lines.extend(quote_detail_lines(tms, business_line="AIR"))
lines.append(group_tms_confirm_hint(business_line="AIR"))
else:
lines.append("TMS 暂无匹配报价")
lines.append(group_no_tms_quote_hint(business_line="AIR"))
if manual and (quote_fee_lines(manual) or str(manual.get("total") or "").strip() not in {"", "-"}):
lines.append("")
lines.append("人工报价:")
lines.extend(_manual_quote_lines(manual, business_line="AIR"))
if adjust and (quote_fee_lines(adjust) or str(adjust.get("total") or "").strip() not in {"", "-"}):
lines.append("")
lines.append("销售调价:")
lines.extend(_manual_quote_lines(adjust, business_line="AIR"))
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 quote_is_tms(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:
"""只有航线能锁舱或释放。后台 inquiring.air_airline_only_cabin。"""
return _r(
"inquiring.air_airline_only_cabin",
"只有岗位为「航线·空运」的人能锁舱或释放舱位。",
)
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:
return air_lock_need_quote(work_order_no)
def air_lock_need_params(missing=None) -> str:
"""
舱位编号、航司、航班号没齐时,只请航线补全。
这时还没锁舱,不能说 TMS 锁舱失败,也不能通知 IT。
缺哪项就点哪项;三项都缺时是产品指定的那一句。
"""
labels = [str(x).strip() for x in (missing or []) if str(x).strip()]
if not labels:
labels = ["舱位编号", "航司", "航班号"]
joined = "、".join(labels)
return (
f"锁舱参数不完整,缺少:{joined}。"
"请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。"
)
def air_lock_need_quote(work_order_no: str) -> str:
"""没有任何报价时的锁舱拒绝。后台 inquiring.air_lock_need_quote。"""
no = (work_order_no or "").strip()
return _r(
"inquiring.air_lock_need_quote",
f"工单{no}还没有报价,不能锁舱。",
ticket_no=no,
)
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:
"""空运锁舱成功。后台 inquiring.air_lock_ok。"""
default = (
f"工单{work_order_no} 已通知TMS执行空运锁舱,执行成功。\n"
f"操作人:{operator}\n"
f"舱位选项:{option_no}\n"
f"执行编号:{exec_no}\n"
f"报价编号:{quote_no}"
)
return _r(
"inquiring.air_lock_ok",
default,
ticket_no=work_order_no,
operator=operator,
option_no=option_no,
exec_no=exec_no,
quote_no=quote_no,
)
# 多段里有空运段时,航线锁舱的失败原因。产品暂不支持,不当 TMS 故障。
MULTI_LOCK_UNSUPPORTED = "多段询价,暂时不支持锁舱"
# 后台锁舱失败模板末尾常有「已通知IT…请稍等」。多段这条要整句拿掉。
_IT_NOTICE_LINE = re.compile(r"已通知\s*IT[^\n。]*。?")
def multi_air_lock_unsupported(*, work_order_no: str, operator: str) -> str:
"""
多段含空运、航线锁舱时的群回复。
抬头、操作人、失败原因仍走锁舱失败模板,只去掉通知 IT 那句。
不打主账、不标系统异常。
"""
text = air_lock_fail(
work_order_no=work_order_no,
operator=operator,
reason=MULTI_LOCK_UNSUPPORTED,
)
kept: list[str] = []
for line in text.splitlines():
cleaned = _IT_NOTICE_LINE.sub("", line).strip()
if cleaned:
kept.append(cleaned)
return "\n".join(kept).strip()
def air_lock_fail(*, work_order_no: str, operator: str, reason: str) -> str:
"""空运锁舱失败。后台 inquiring.air_lock_fail。"""
why = (reason or "").strip() or "TMS未返回原因"
default = (
f"工单{work_order_no} 空运锁舱失败。\n"
f"操作人:{operator}\n"
f"锁舱失败的原因:{why}\n"
"已通知IT运维排查,请稍等。"
)
return _r(
"inquiring.air_lock_fail",
default,
ticket_no=work_order_no,
operator=operator,
reason=why,
)
def air_cabin_fail_reason(*, tms_reason: str = "", flight_date: str = "", today: str = "") -> str:
"""
锁舱/释放失败原因:原样回 TMS 的 failReason。
禁止改写成「报价选项不存在」或「航班已起飞」。
flight_date / today 只为兼容旧调用,不再用来改写原文。
"""
_ = flight_date
_ = today
return (tms_reason or "").strip()
def air_release_ok(
*,
work_order_no: str,
operator: str,
option_no: str,
exec_no: str,
quote_no: str,
) -> str:
"""空运释放成功。后台 inquiring.air_release_ok。"""
default = (
f"工单{work_order_no} 已通知TMS执行空运释放舱位成功\n"
f"操作人:{operator}\n"
f"舱位选项:{option_no}\n"
f"执行编号:{exec_no}\n"
f"报价编号:{quote_no}"
)
return _r(
"inquiring.air_release_ok",
default,
ticket_no=work_order_no,
operator=operator,
option_no=option_no,
exec_no=exec_no,
quote_no=quote_no,
)
def air_release_fail(*, work_order_no: str, operator: str, reason: str) -> str:
"""空运释放失败。后台 inquiring.air_release_fail。"""
why = (reason or "").strip() or "TMS未返回原因"
default = (
f"工单{work_order_no} 空运释放舱位失败。\n"
f"操作人:{operator}\n"
f"释放舱位失败的原因:{why}\n"
"已通知IT运维排查,请稍等。"
)
return _r(
"inquiring.air_release_fail",
default,
ticket_no=work_order_no,
operator=operator,
reason=why,
)
def air_release_deal_blocked(work_order_no: str) -> str:
"""已成交不能释放舱位。后台 inquiring.air_release_deal_blocked。"""
no = (work_order_no or "").strip()
return _r(
"inquiring.air_release_deal_blocked",
f"释放舱位失败,工单{no}已成交。",
ticket_no=no,
)
def air_release_need_lock(work_order_no: str) -> str:
"""没有有效锁舱不能释放。后台 inquiring.air_release_need_lock。"""
no = (work_order_no or "").strip()
return _r(
"inquiring.air_release_need_lock",
f"工单{no}还没有有效锁舱,不能释放。",
ticket_no=no,
)
# —— 系统异常(UC024):销售/群兜底与恢复;告警卡只给有权限的人 ——
TYPE_LLM = "大模型异常"
TYPE_TMS = "TMS异常"
TYPE_GROUP = "建群异常"
STEP_LLM_NO_TICKET = "llm_no_ticket"
STEP_LLM = "llm"
STEP_TMS_QUOTE = "tms_quote"
STEP_TMS_CABIN = "tms_cabin"
STEP_TMS_LOCK = "tms_lock"
STEP_TMS_RELEASE = "tms_release"
STEP_GROUP = "group"
SYS_EXC_LLM_FALLBACK = "大模型服务异常,IT排查中..."
SYS_EXC_TMS_FALLBACK = "TMS接口异常,IT运维排查中..."
SYS_EXC_RECOVERY_TMS = "TMS已恢复正常使用,感谢您的耐心等待。"
SYS_EXC_RECOVERY_LLM = "大模型已恢复正常使用,感谢您的耐心等待。"
SYS_EXC_RECOVERY_CABIN = "空运舱位接口已恢复正常使用,感谢您的耐心等待。"
SYS_EXC_RECOVERY_GROUP = "建群已恢复正常,感谢您的耐心等待。"
IMPACT_LLM_NO_TICKET = "尚未建单,不落后台工单"
IMPACT_LLM = "当前识别暂停,已有字段与工单状态保持不变"
IMPACT_TMS_QUOTE = "当前询价流程暂停,识别字段与原业务节点保持不变"
IMPACT_TMS_CABIN = "当前锁舱/释放暂停,工单状态保持不变"
IMPACT_TMS_LOCK = "锁舱失败,工单状态保持不变"
IMPACT_TMS_RELEASE = "释放舱位失败,工单状态保持不变"
IMPACT_GROUP = "当前建群/加人暂停,已建群不新建第二个群"
def system_exception_fallback(exception_type: str, *, names: list[str] | None = None) -> str:
"""判定系统异常后,给卡住的会话的兜底话术。后台 sys.llm_fail / sys.tms_fail / sys.group_add_fail。"""
kind = (exception_type or "").strip()
if kind == TYPE_LLM:
return _r("sys.llm_fail", SYS_EXC_LLM_FALLBACK)
if kind == TYPE_GROUP:
people = [str(x).strip() for x in (names or []) if str(x).strip()]
joined = "、".join(people)
if joined:
default = f"无法添加 {joined} 同事,请联系管理员检查企业微信配置。"
else:
default = "无法添加同事,请联系管理员检查企业微信配置。"
text = _r("sys.group_add_fail", default, staff_name=joined)
if not joined:
text = re.sub(r"无法添加\s+同事", "无法添加同事", text)
return text
return _r("sys.tms_fail", SYS_EXC_TMS_FALLBACK)
def system_exception_recovery(step: str) -> str:
"""后台点重试成功后,先发这一句,再补没做完的事。"""
key = (step or "").strip()
if key == STEP_LLM or key == STEP_LLM_NO_TICKET:
return _r("sys.llm_recovery", SYS_EXC_RECOVERY_LLM)
if key in {STEP_TMS_CABIN, STEP_TMS_LOCK, STEP_TMS_RELEASE}:
return _r("sys.cabin_recovery", SYS_EXC_RECOVERY_CABIN)
if key == STEP_GROUP:
return _r("sys.group_recovery", SYS_EXC_RECOVERY_GROUP)
return _r("sys.tms_recovery", SYS_EXC_RECOVERY_TMS)
def system_exception_impact(step: str) -> str:
"""告警卡「影响范围」,按卡住的步骤。"""
key = (step or "").strip()
if key == STEP_LLM_NO_TICKET:
return IMPACT_LLM_NO_TICKET
if key == STEP_LLM:
return IMPACT_LLM
if key == STEP_TMS_LOCK:
return IMPACT_TMS_LOCK
if key == STEP_TMS_RELEASE:
return IMPACT_TMS_RELEASE
if key == STEP_TMS_CABIN:
return IMPACT_TMS_CABIN
if key == STEP_GROUP:
return IMPACT_GROUP
return IMPACT_TMS_QUOTE
def system_exception_alert_payload(
*,
work_order_no: str,
exception_type: str,
service: str,
error_code: str,
step: str,
occurred_at: str,
test_prefix: bool = False,
) -> dict[str, Any]:
"""
系统异常通知卡:走询价消息服务,不走询价小助手。
五行:异常类型、接口/服务、报错原因、影响范围、发生时间。
标题不加 [test]:运维要看的就是这张卡本身,test_prefix 仅保留兼容入参。
"""
no = (work_order_no or "").strip() or "无"
_ = test_prefix
title = "系统异常通知"
rows = [
{"keyname": "异常类型", "value": (exception_type or "").strip() or "-"},
{"keyname": "接口/服务", "value": (service or "").strip() or "-"},
{"keyname": "报错原因", "value": (error_code or "").strip() or "-"},
{"keyname": "影响范围", "value": system_exception_impact(step)},
{"keyname": "发生时间", "value": (occurred_at or "").strip() or "-"},
]
action_url = site_card_url()
return {
"msgtype": "template_card",
"wecom_app": "notify",
"template_card": {
"card_type": "text_notice",
"source": {"desc": "询价机器人系统告警", "desc_color": 0},
"main_title": {
"title": title,
"desc": f"关联工单 {no}",
},
"horizontal_content_list": rows,
"card_action": {"type": 1, "url": action_url},
},
}
def system_exception_alert_text(
*,
work_order_no: str,
exception_type: str,
service: str,
error_code: str,
step: str,
occurred_at: str,
test_prefix: bool = False,
) -> str:
"""告警卡纯文本兜底(通道降级时仍能看懂)。标题同样不加 [test]。"""
no = (work_order_no or "").strip() or "无"
_ = test_prefix
head = "系统异常通知"
lines = [
"询价机器人系统告警",
head,
f"关联工单 {no}",
f"异常类型:{(exception_type or '').strip() or '-'}",
f"接口/服务:{(service or '').strip() or '-'}",
f"报错原因:{(error_code or '').strip() or '-'}",
f"影响范围:{system_exception_impact(step)}",
f"发生时间:{(occurred_at or '').strip() or '-'}",
]
return "\n".join(lines)
def event_exception_session_text(
*,
exception_type: str,
work_order_no: str,
names: list[str] | None,
hours: float,
mention: bool,
test_prefix: bool = False,
) -> str:
"""
事件异常会话两行话术。
mention=True 时姓名前加 @,多人空格拼接。小时数用后台规则值。
测服也不加 [test]:这是销售/航线/产品会话,不是运维告警。
"""
from agent.policy.event_exception import format_timeout_hours
_ = test_prefix
no = (work_order_no or "").strip() or "-"
kind = (exception_type or "").strip() or "事件异常"
hour_text = format_timeout_hours(hours)
people = [str(n).strip() for n in (names or []) if str(n).strip()]
if mention:
tagged = " ".join(f"@{n}" for n in people) if people else "相关人员"
else:
tagged = " ".join(people) if people else "您"
body = (
f"关联询价工单 {no}。{tagged}已超过 {hour_text} 工作小时 无响应,"
f"事件异常已记录工单,请及时处理。"
)
return f"{kind}\n{body}"
def event_exception_alert_payload(
*,
card_title: str,
work_order_no: str,
hours: float,
test_prefix: bool = False,
) -> dict[str, Any]:
"""
事件异常通知卡:走询价消息服务,不走询价小助手。
对齐现网「询价机器人事件告警」:标题用响应异常,正文用「您已超过」。
测服标题也不加 [test];test_prefix 仅保留兼容入参。
"""
from agent.policy.event_exception import format_timeout_hours
_ = test_prefix
no = (work_order_no or "").strip() or "-"
title = (card_title or "").strip() or "事件响应异常"
hour_text = format_timeout_hours(hours)
action_url = site_card_url()
return {
"msgtype": "template_card",
"wecom_app": "notify",
"template_card": {
"card_type": "text_notice",
"source": {"desc": "询价机器人事件告警", "desc_color": 0},
"main_title": {
"title": title,
"desc": f"关联询价工单 {no}",
},
"sub_title_text": f"您已超过 {hour_text} 工作小时无响应,事件异常已记录工单,请及时处理。",
"card_action": {"type": 1, "url": action_url},
},
}
def event_exception_alert_text(
*,
card_title: str,
work_order_no: str,
hours: float,
test_prefix: bool = False,
) -> str:
"""应用卡片纯文本兜底。"""
payload = event_exception_alert_payload(
card_title=card_title,
work_order_no=work_order_no,
hours=hours,
test_prefix=test_prefix,
)
card = payload.get("template_card") or {}
main = card.get("main_title") or {}
lines = [
str((card.get("source") or {}).get("desc") or "询价机器人事件告警"),
str(main.get("title") or ""),
str(main.get("desc") or ""),
str(card.get("sub_title_text") or ""),
]
return "\n".join(x for x in lines if x)