落地海运私聊文字询价全流程,海运多项费用改走文本明细,避免企微卡片引用条截断。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-15 14:26:09 +08:00
co-authored by Cursor
parent 58b3c2f3e6
commit 0a3c74e3a1
41 changed files with 3702 additions and 48 deletions
+376 -10
View File
@@ -13,7 +13,7 @@ import time
import uuid
from typing import Any, Iterable
from agent.schema.field_validate import AIR_REQUIRED_FIELDS
from agent.schema.field_validate import AIR_REQUIRED_FIELDS, SEA_COLLAB_FIELDS, SEA_REQUIRED_FIELDS
# 空运补问:与合同 8 项一致。报价日期默认当天,不进待补充。
_AIR_EXAMPLES = {
@@ -30,11 +30,27 @@ _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 = "不采用"
@@ -104,6 +120,14 @@ def _air_known_value(facts: dict[str, str], key: str) -> str:
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,
@@ -119,11 +143,14 @@ def ask_clarify(
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 _AIR_CLARIFY:
val = _air_known_value(src, key)
for key, label, _example in rows:
val = known(src, key)
if val:
lines.append(f"{label}:{val}")
lines.append("")
@@ -132,7 +159,7 @@ def ask_clarify(
if "运输方式" in missing_set:
any_missing = True
lines.append("运输方式:(如:空运)")
for key, label, example in _AIR_CLARIFY:
for key, label, example in rows:
if key in missing_set:
any_missing = True
lines.append(f"{label}:(如:{example})")
@@ -155,6 +182,10 @@ def ask_missing(display_names: Iterable[str]) -> str:
"体积(CBM)": "体积",
"包装类型": "包装方式",
"包装方式": "包装方式",
"货物数量": "货量",
"货量": "货量",
"整柜或拼柜": "整柜或拼柜",
"箱型箱量": "箱型箱量",
}
keys: list[str] = []
for name in display_names:
@@ -207,6 +238,21 @@ def inquiry_field_lines(
cargo = (facts.get("品名") or "-").strip() or "-"
pack = (facts.get("包装方式") or facts.get("包装类型") or "-").strip() or "-"
quote_date = (facts.get("报价日期") or "-").strip() or "-"
if (transport_mode or "").strip() == "海运":
qty = (facts.get("货量") or facts.get("货物数量") or "-").strip() or "-"
load = (facts.get("整柜或拼柜") or "-").strip() or "-"
box = (facts.get("箱型箱量") or "-").strip() or "-"
return [
f"工单号:{work_order_no}",
f"运输方式:{transport_mode}",
f"起运港:{origin}",
f"目的港:{dest}",
f"品名:{cargo}",
f"货物数量:{qty}",
f"整柜或拼柜:{load}",
f"箱型箱量:{box}",
f"报价日期:{quote_date}",
]
return [
f"工单号:{work_order_no}",
f"运输方式:{transport_mode}",
@@ -221,14 +267,22 @@ def inquiry_field_lines(
]
def inquiry_card(*, work_order_no: str, facts: dict[str, str], first_or_same: str = "first") -> str:
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)
lines = inquiry_field_lines(
work_order_no=work_order_no, facts=facts, transport_mode=transport_mode
)
lines.append("工单已创建,正在查询TMS报价。")
return "\n".join(lines)
@@ -241,6 +295,10 @@ def canonical_button(raw: str) -> str:
# 新卡 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}:"
):
@@ -328,6 +386,7 @@ def _work_order_in_token(raw: str) -> str:
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}:",
@@ -416,6 +475,51 @@ def quote_area_fee_text(quote: dict[str, Any] | None) -> str:
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:
"""海运完整价格明细文本。不受卡片引用条字数限制。"""
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,
)
)
return "\n".join(lines)
def collab_hint(work_order_no: str) -> str:
"""价格卡协同说明:无「内部协同」前缀,紧挨生成报价单按钮上方。"""
return (
@@ -681,7 +785,230 @@ def skip_collab_ack() -> str:
return "好的,已跳过内部协同。正在按 TMS 标准报价生成报价单草稿…"
def scheme_card(*, work_order_no: str, status: str, facts: dict[str, str], quote: dict[str, Any]) -> str:
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 sea_group_create_fail(*, reason: str = "") -> str:
"""建群失败,按钮保持可点。"""
extra = (reason or "").strip()
if extra:
return f"建群失败:{extra}。请稍后重试。"
return "建群失败,请稍后重试。"
def sea_group_brief(
*,
work_order_no: str,
facts: dict[str, str],
quote: dict[str, Any] | None,
collab_facts: dict[str, str] | None,
sales_name: str,
product_names: list[str],
has_price: bool,
) -> str:
"""
群里自动摘要:@销售 @产品 + 工单号 + 询价字段 + 协同字段 + TMS 报价。
协同没填写 -。无价写明暂无匹配报价。
"""
no = (work_order_no or "").strip()
sales = (sales_name or "").strip() or "销售"
products = [n.strip() for n in (product_names or []) if (n or "").strip()]
at_bits = [f"@{sales}"] + [f"@{n}" for n in products]
lines = [" ".join(at_bits), f"工单号:{no}", "询价字段:"]
lines.extend(
inquiry_field_lines(work_order_no=no, facts=facts, transport_mode="海运")[1:]
)
lines.append("协同补充字段:")
collab = dict(collab_facts or {})
for key in SEA_COLLAB_FIELDS:
val = (collab.get(key) or "").strip() or "-"
lines.append(f"{key}:{val}")
lines.append("TMS报价:")
if has_price and quote:
lines.extend(quote_detail_lines(quote))
else:
lines.append("TMS 暂无匹配报价 / 未查到合适报价")
return "\n".join(lines)
def scheme_card(
*,
work_order_no: str,
status: str,
facts: dict[str, str],
quote: dict[str, Any],
transport_mode: str = "空运",
) -> str:
"""
报价方案文本兜底(模板卡发失败时仍给销售看)。
@@ -692,7 +1019,9 @@ def scheme_card(*, work_order_no: str, status: str, facts: dict[str, str], quote
# 方案卡不展示询价「报价日期」,改展示 TMS 报价有效期
body = "\n".join(
line
for line in inquiry_field_lines(work_order_no=work_order_no, facts=facts)
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)
@@ -718,16 +1047,31 @@ def _clip(raw: str, limit: int) -> str:
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 {}
@@ -735,6 +1079,19 @@ def scheme_horizontal_rows(
origin = (src.get("起运港") or "-").strip() or "-"
dest = (src.get("目的港") or "-").strip() or "-"
cargo = (src.get("品名") or "-").strip() or "-"
mode = (transport_mode or "空运").strip() or "空运"
if mode == "海运":
qty = (src.get("货量") or src.get("货物数量") or "-").strip() or "-"
load = (src.get("整柜或拼柜") or "-").strip() or "-"
box = (src.get("箱型箱量") or "-").strip() or "-"
return [
{"keyname": "工单号", "value": _clip(work_order_no, 26)},
{"keyname": "航线", "value": _clip(f"海运 {origin}→{dest}", 26)},
{"keyname": "货物", "value": _clip(f"{cargo};{qty}", 26)},
{"keyname": "箱型", "value": _clip(f"{load};{box}", 26)},
{"keyname": "时效", "value": _clip(tms_eta_display(q), 26)},
{"keyname": "报价有效期", "value": _clip(tms_valid_until_display(q), 26)},
]
pieces = _with_unit(str(src.get("件数") or ""), "件")
weight = _with_unit(str(src.get("毛重") or ""), "KGS")
volume = _with_unit(str(src.get("体积") or ""), "CBM")
@@ -755,11 +1112,13 @@ def scheme_wecom_payload(
facts: dict[str, str],
quote: dict[str, Any],
status: str = "已报价",
transport_mode: str = "空运",
) -> dict[str, Any]:
"""
一张报价方案卡:标题带工单号、引用条、字段、三个短按钮。
按钮文案必须短:生成Excel / 生成PDF / 不采用。长文案横排会被裁。
海运/空运字段分开,禁止海运卡再出件数毛重体积。
"""
_ = status
src = dict(quote or {})
@@ -772,10 +1131,17 @@ def scheme_wecom_payload(
"main_title": {"title": f"{work_order_no}报价方案"},
"quote_area": {
"type": 0,
"quote_text": quote_area_fee_text(src),
"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
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": [