落地空运群内协同与锁舱出站。

群里按 BOT 通道激活当前工单、航线报价和成交跟进;没有 TMS 报价编号时用工单字段补齐仍调锁舱,避免被本地规则拦住。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 17:25:42 +08:00
co-authored by Cursor
parent 12219cacb1
commit d9029dec89
62 changed files with 6772 additions and 159 deletions
+330 -11
View File
@@ -14,6 +14,7 @@ import uuid
from typing import Any, Iterable
from agent.schema.field_validate import (
AIR_COLLAB_FIELDS,
AIR_REQUIRED_FIELDS,
SEA_COLLAB_DISPLAY,
SEA_COLLAB_FIELDS,
@@ -238,8 +239,8 @@ def inquiry_field_lines(
起运/目的展示销售原文(南京/北京),三字码只给出站 TMS,不对销售展示。
"""
origin = (facts.get("起运港") or "-").strip()
dest = (facts.get("目的港") or "-").strip()
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 "-"
@@ -452,7 +453,7 @@ def quote_fee_lines(quote: dict[str, Any] | None) -> list[tuple[str, str]]:
"""
src = dict(quote or {})
header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip()
raw = src.get("fee_lines")
raw = src.get("fee_lines") or src.get("fee_rows")
parsed: Any = raw
if isinstance(raw, str) and raw.strip():
try:
@@ -988,6 +989,12 @@ def sea_group_create_fail(*, reason: str = "") -> str:
return "建群失败,请稍后重试。"
def quote_is_product(quote: dict[str, Any] | None) -> bool:
"""来源是产品/航线人工,不能写进 TMS 报价段。"""
label = str((quote or {}).get("source_label") or (quote or {}).get("source") or "")
return "产品" in label or "航线" in label
def sea_group_brief(
*,
work_order_no: str,
@@ -997,12 +1004,13 @@ def sea_group_brief(
sales_name: str,
product_names: list[str],
has_price: bool,
tms_quote: dict[str, Any] | None = None,
product_quote: dict[str, Any] | None = None,
) -> str:
"""
群里自动摘要:@销售 @产品 + 工单号 + 询价字段 + 协同字段 + TMS 报价。
群里自动摘要:询价 + 协同 + TMS 报价 + 人工报价。
三段之间空一行,方便群里扫。协同没填写 -。
HS编码对外写成商品海关编码。无价只写「TMS 暂无匹配报价」。
TMS 无价只写「TMS 暂无匹配报价」,产品报的价单独放人工报价,不能混进 TMS。
"""
no = (work_order_no or "").strip()
sales = (sales_name or "").strip() or "销售"
@@ -1019,12 +1027,30 @@ def sea_group_brief(
label = SEA_COLLAB_DISPLAY.get(key, key)
val = (collab.get(key) or collab.get(label) or "").strip() or "-"
lines.append(f"{label}:{val}")
tms = dict(tms_quote) if tms_quote is not None else {}
manual = dict(product_quote or {})
if tms_quote is None:
src = dict(quote or {})
if has_price and src and not quote_is_product(src):
tms = src
elif quote_is_product(src):
manual = manual or src
lines.append("")
lines.append("TMS报价:")
if has_price and quote:
lines.extend(quote_detail_lines(quote))
if tms and (tms.get("total") or quote_fee_lines(tms)):
lines.extend(quote_detail_lines(tms))
lines.append(group_tms_confirm_hint())
else:
lines.append("TMS 暂无匹配报价")
if manual and (quote_fee_lines(manual) or str(manual.get("total") or "").strip() not in {"", "-"}):
lines.append("")
lines.append("人工报价:")
fee_lines = quote_fee_lines(manual)
if fee_lines:
lines.extend(f"{name}:{amount}" for name, amount in fee_lines)
else:
header_ccy = str(manual.get("currency") or "").strip()
lines.append(f"合计:{money_text(str(manual.get('total') or '-'), header_ccy)}")
return "\n".join(lines)
@@ -1335,6 +1361,46 @@ def group_only_excel_pdf() -> str:
return "群里只收 Excel 和 PDF,其它文件不会存入工单"
def group_only_product_can_quote() -> str:
return "只有产品能报价格,请产品在群里发价格或 Excel/PDF"
def group_tms_confirm_hint() -> str:
"""报价有效期下方:有 TMS 价时告诉产品还可以确认沿用。"""
return "有TMS报价时可回复“确认报价”沿用该价;也可直接回复协同报价或上传报价附件。"
def quote_attachment_saved_app(work_order_no: str, count: int = 1) -> str:
"""群里告诉大家:模板或产品附件已进后台。"""
no = (work_order_no or "").strip() or "WO"
n = count if count > 0 else 1
return f"工单{no}的{n}个附件已保存到后台工单"
def group_confirm_need_tms() -> str:
return "本单还没有可沿用的 TMS 报价,请直接回复协同报价或上传报价附件。"
def group_ask_tms_or_product() -> str:
return (
"本单已有 TMS 报价。\n"
"1. 要在 TMS 基础上改,请回复:在TMS基础上改(或直接回 1 / 第一个)\n"
"2. 不要 TMS 报价、只用你刚说的价格,请回复:不用TMS报价(或直接回 2 / 第二个)"
)
def group_tms_choice_unclear() -> str:
return "还没确定怎么用 TMS 报价。请回复:在TMS基础上改 / 不用TMS报价,也可以直接回 1 或 2。"
def group_need_price_before_choice() -> str:
return "请产品先发价格,再说在TMS基础上改或不用TMS报价"
def group_file_saved() -> str:
return "附件已保存后台"
def group_quote_not_recognized() -> str:
return "没有识别到报价,请产品再发一次价格或 Excel/PDF"
@@ -1349,6 +1415,10 @@ def group_deal_stale(*, work_order_no: str) -> str:
return f"这张成交确认已过期。工单{no}已有更新的报价,请点击群里最新一条成交跟进。"
def group_deal_need_quote() -> str:
return "还没有可确认的报价,请先完成报价再确认成交状态。"
def group_deal_sales_only() -> str:
return "只有负责该工单的销售可以确认成交状态。"
@@ -1364,14 +1434,263 @@ def group_cannot_recreate(*, work_order_no: str) -> str:
return f"工单{no}协同已结束,不能再拉群。"
def group_deal_mention(*, sales_name: str, work_order_no: str) -> str:
def group_deal_followup(*, sales_name: str, work_order_no: str) -> str:
"""
群里文字成交跟进。只提示输入,不含报价合计、不含点选按钮。
私聊成交卡仍走 deal_card,不改这里。
"""
name = (sales_name or "").strip() or "销售"
return f"@{name}\n{deal_card(work_order_no=work_order_no)}"
no = (work_order_no or "").strip()
return (
f"@{name}\n"
f"询价工单 {no}\n"
"报价单已发送,请确认成交状态\n"
"咨询成交状态:请输入成交 / 未成交 / 协商中(未成交需补充原因)"
)
def group_deal_mention(*, sales_name: str, work_order_no: str) -> str:
return group_deal_followup(sales_name=sales_name, work_order_no=work_order_no)
def deal_ack(outcome: str, work_order_no: str) -> str:
if outcome == "已成交":
return f"已记录为「已成交」。工单 {work_order_no} 已关闭智能体跟进。"
if outcome == "未成交":
return f"已记录为「未成交」。请补充未成交原因,工单 {work_order_no} 将关闭智能体跟进。"
return "已记录为「未成交」。请补充未成交原因。"
return f"已记录为「协商中」。工单 {work_order_no} 跟进周期已顺延。"
def air_need_work_order() -> str:
"""空运群还没有当前单,且这句话里没有工单号。"""
return "请先 @我 并发送要跟的工单号,例如 WO202609160001。"
def air_lock_need_work_order() -> str:
"""锁舱/释放必须自带工单号,不猜当前单。"""
return "锁舱或释放舱位请带上工单号,例如 WO202609160001锁舱。"
def air_ticket_not_found(work_order_no: str) -> str:
no = (work_order_no or "").strip()
return f"找不到工单{no},请核对工单号后再试。"
def air_ticket_not_air(work_order_no: str) -> str:
no = (work_order_no or "").strip()
return f"工单{no}不是空运单,不能在空运群里跟。"
def air_ticket_closed(work_order_no: str, status: str) -> str:
no = (work_order_no or "").strip()
st = (status or "").strip() or "已关闭"
return f"工单{no}已是「{st}」,不能再激活。"
def air_ticket_occupied(work_order_no: str) -> str:
no = (work_order_no or "").strip()
return f"工单{no}已经在另一个群跟了,请到那个群继续。"
def air_group_brief(
*,
work_order_no: str,
facts: dict[str, str],
quote: dict[str, Any] | None,
collab_facts: dict[str, str] | None,
has_price: bool,
tms_quote: dict[str, Any] | None = None,
product_quote: dict[str, Any] | None = None,
sales_name: str = "",
airline_names: list[str] | None = None,
) -> str:
"""
空运群激活摘要:与海运进群同一套结构,字段用空运。
先 @ 销售和航线,再工单号 + 询价字段 + 协同补充(含电/含磁)+ TMS报价。
不写「当前工单/询价要点/现在等到哪一步」。
"""
no = (work_order_no or "").strip()
at_bits: list[str] = []
sales = (sales_name or "").strip()
if sales:
at_bits.append(f"@{sales}")
for name in airline_names or []:
token = f"@{(name or '').strip()}"
if (name or "").strip() and token not in at_bits:
at_bits.append(token)
lines = ([(" ".join(at_bits))] if at_bits else []) + [f"工单号:{no}", "询价字段:"]
lines.extend(
inquiry_field_lines(work_order_no=no, facts=facts, transport_mode="空运")[1:]
)
lines.append("")
lines.append("协同补充字段:")
collab = dict(collab_facts or {})
for key in AIR_COLLAB_FIELDS:
val = (collab.get(key) or "").strip() or "-"
lines.append(f"{key}:{val}")
tms = dict(tms_quote) if tms_quote is not None else {}
manual = dict(product_quote or {})
if tms_quote is None:
src = dict(quote or {})
if has_price and src and not quote_is_product(src):
tms = src
elif quote_is_product(src):
manual = manual or src
lines.append("")
lines.append("TMS报价:")
if tms and (tms.get("total") or quote_fee_lines(tms)):
lines.extend(quote_detail_lines(tms))
lines.append(group_tms_confirm_hint())
else:
lines.append("TMS 暂无匹配报价")
if manual and (quote_fee_lines(manual) or str(manual.get("total") or "").strip() not in {"", "-"}):
lines.append("")
lines.append("人工报价:")
fee_lines = quote_fee_lines(manual)
if fee_lines:
lines.extend(f"{name}:{amount}" for name, amount in fee_lines)
else:
header_ccy = str(manual.get("currency") or "").strip()
lines.append(f"合计:{money_text(str(manual.get('total') or '-'), header_ccy)}")
return "\n".join(lines)
def air_activate_summary(
*,
work_order_no: str,
facts: dict,
status: str = "",
wait_hint: str = "",
quote: dict[str, Any] | None = None,
collab_facts: dict[str, str] | None = None,
) -> str:
"""兼容旧名:激活成功改走海运同款群摘要。"""
_ = status, wait_hint
src = dict(quote or {})
return air_group_brief(
work_order_no=work_order_no,
facts=dict(facts or {}),
quote=src,
collab_facts=collab_facts,
has_price=bool(src.get("total") or src.get("quoteId")),
tms_quote=src if src and not quote_is_product(src) else {},
product_quote=src if quote_is_product(src) else {},
)
def air_wait_hint(*, status: str, has_quote: bool, pending_lock: bool = False) -> str:
"""激活摘要里的「现在等到哪一步」。"""
if pending_lock:
return "等待航线选择要锁的舱位选项"
st = (status or "").strip()
if st == "协商中":
return "协商中,可继续改价或确认成交"
if has_quote or st == "已报价":
return "已有报价,可补含电/含磁、航线改价、锁舱或确认成交"
return "等待航线报价或锁舱(须先有 TMS 报价)"
def air_airline_only_quote() -> str:
return "只有岗位为「航线·空运」的人能报价格。"
def air_airline_only_cabin() -> str:
return "只有岗位为「航线·空运」的人能锁舱或释放舱位。"
def air_sales_only_deal() -> str:
return "只有负责该工单的销售可以确认成交状态。"
def air_fields_not_recognized() -> str:
return "听到了。空运群里可补充是否含电、是否含磁。"
def air_quote_not_recognized() -> str:
return "没有识别到报价,请航线再发一次价格或 Excel/PDF。"
def air_fields_denied() -> str:
return "只有这张单的销售或航线可以补含电/含磁。"
def air_no_tms_quote_to_lock(work_order_no: str) -> str:
no = (work_order_no or "").strip()
return f"工单{no}还没有可锁的 TMS 报价,不能锁舱。"
def air_ask_lock_option(options: list) -> str:
lines = ["这张单有多条舱位选项,请回复要锁哪一条,例如 AIR-OPT-01:"]
for item in options:
no = str(item.get("optionNo") or item.get("option_no") or "").strip()
airline = str(item.get("airlineName") or item.get("airlineCode") or "").strip()
if no and airline:
lines.append(f"{no} {airline}")
elif no:
lines.append(no)
return "\n".join(lines)
def air_lock_ok(
*,
work_order_no: str,
operator: str,
option_no: str,
exec_no: str,
quote_no: str,
) -> str:
return (
f"工单{work_order_no} 已通知TMS执行空运锁舱,执行成功。\n"
f"操作人:{operator}\n"
f"舱位选项:{option_no}\n"
f"执行编号:{exec_no}\n"
f"报价编号:{quote_no}"
)
def air_lock_fail(*, work_order_no: str, operator: str, reason: str) -> str:
why = (reason or "").strip() or "TMS未返回原因"
return (
f"工单{work_order_no} 空运锁舱失败。\n"
f"操作人:{operator}\n"
f"锁舱失败的原因:{why}\n"
"已通知IT运维排查,请稍等。"
)
def air_release_ok(
*,
work_order_no: str,
operator: str,
option_no: str,
exec_no: str,
quote_no: str,
) -> str:
return (
f"工单{work_order_no} 已通知TMS执行空运释放舱位成功\n"
f"操作人:{operator}\n"
f"舱位选项:{option_no}\n"
f"执行编号:{exec_no}\n"
f"报价编号:{quote_no}"
)
def air_release_fail(*, work_order_no: str, operator: str, reason: str) -> str:
why = (reason or "").strip() or "TMS未返回原因"
return (
f"工单{work_order_no} 空运释放舱位失败\n"
f"操作人:{operator}\n"
f"释放舱位失败的原因:{why}"
)
def air_release_deal_blocked(work_order_no: str) -> str:
no = (work_order_no or "").strip()
return f"释放舱位失败,工单{no}已成交。"
def air_release_need_lock(work_order_no: str) -> str:
no = (work_order_no or "").strip()
return f"工单{no}还没有有效锁舱,不能释放。"