机器人显示名与站点标题可配;多段联运列表投影;协同群监听对账;工单附件落存;模板与工单后台补强

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-19 09:41:59 +08:00
co-authored by Cursor
parent 277cdc1c28
commit 2b528758b4
119 changed files with 6760 additions and 3516 deletions
+8 -3
View File
@@ -12,6 +12,8 @@ from __future__ import annotations
import json
import logging
import re
import time
from pathlib import Path
from typing import Any, Optional
from agent.llm.extract_text import extract_air_collab_fields, extract_product_quote
@@ -887,16 +889,19 @@ def apply_air_deal(
if not is_sales(ticket, sender_id):
send_group_text(group_client_of(flow), chat_id, copy.air_sales_only_deal())
return "not_sales"
view = ticket_view(ticket)
no = ticket_no(ticket)
status_now = ticket_status(ticket)
# 与海运一致:同一终态再确认不刷「不能修改」。
if status_now in {"已成交", "未成交"} and outcome == status_now:
return "deal_already_recorded"
stopped = halt_closed_group(flow=flow, ticket=ticket, chat_id=chat_id)
if stopped:
return stopped
from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason
from agent.policy.sea_group_ops import _quote_fee_rows, facts_of_group
view = ticket_view(ticket)
no = ticket_no(ticket)
quote = dict(view.get("quote") or {})
status_now = ticket_status(ticket)
usable_total = str(quote.get("total") or "").strip()
if usable_total in {"", "-"}:
usable_total = ""
+8 -19
View File
@@ -72,9 +72,10 @@ def air_cargo_lines(
dest = (src.get("目的港") or src.get("目的地") or "-").strip() or "-"
cargo = (src.get("品名") or "-").strip() or "-"
pack = (src.get("包装方式") or src.get("包装类型") or "-").strip() or "-"
quote_date = (src.get("报价日期") or "").strip()
if quote_date in {"", "-", "—", "-"}:
quote_date = "-"
from agent.schema.field_validate import is_blank_quote_date
quote_date_raw = (src.get("报价日期") or "").strip()
quote_date = "-" if is_blank_quote_date(quote_date_raw) else quote_date_raw
lines = [
f"工单号:{(work_order_no or '').strip()}",
"运输方式:空运",
@@ -218,22 +219,10 @@ def _fee_rows(quote: dict[str, Any] | None, facts: dict[str, str] | None) -> lis
option = resolve_display_air_option(src, facts)
rows: list[dict[str, Any]] = []
if option:
rows.extend(_named_rows(option.get("feeItems") or option.get("fee_items")))
rows.extend(
_named_rows(
option.get("departureCharges")
or option.get("departure_charges")
or option.get("charges")
)
)
rows.extend(
_named_rows(
option.get("destinationCharges")
or option.get("destination_charges")
or option.get("arrivalCharges")
)
)
rows = _dedupe(rows)
from agent.schema.air_route_options import option_fee_dicts
# 与投影同一套:舱位缺 feeItems 时补整单顶层空运费,再拼起运/目的附加费。
rows = option_fee_dicts(option, src)
if not rows and len(parse_air_options(src)) > 1:
return []
if not rows:
+70 -6
View File
@@ -835,9 +835,16 @@ class AirTextInquiryFlow:
facts = dict(view.get("facts") or {})
quote = dict(view.get("quote") or {})
status = str(view.get("status") or "").strip()
lost_reason = str(view.get("lost_reason") or view.get("lostReason") or "").strip()
tms_quote = dict(view.get("tms_quote") or view.get("tmsQuote") or {})
phase = str(view.get("wait_phase") or "").strip() or self._infer_wait_phase(
status=status, quote=quote
status=status, quote=quote, lost_reason=lost_reason
)
# 主账询价中+舱位备选时 wait_phase 常被收成 tms_miss;续办必须回到选舱。
if self._quote_pending_cabin(quote) or self._quote_pending_cabin(tms_quote):
phase = "wait_air_route"
if not quote and tms_quote:
quote = dict(tms_quote)
allowed: tuple[str, ...] = ()
if phase == "wait_collab":
allowed = (copy.BTN_SKIP_COLLAB,)
@@ -855,6 +862,8 @@ class AirTextInquiryFlow:
allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
elif phase == "wait_lost_reason":
allowed = ()
elif phase == "wait_air_route":
allowed = ()
return FlowSession(
sender_id=sender_id,
thread_id=sender_id,
@@ -863,6 +872,7 @@ class AirTextInquiryFlow:
facts=facts,
work_order_no=no,
quote=quote,
tms_quote=tms_quote or dict(quote),
status=status,
allowed=allowed,
wait_version=1,
@@ -870,17 +880,50 @@ class AirTextInquiryFlow:
collab_chat_id=str(view.get("collab_chat_id") or ""),
)
def _infer_wait_phase(self, *, status: str, quote: dict[str, Any]) -> str:
def _infer_wait_phase(
self, *, status: str, quote: dict[str, Any], lost_reason: str = ""
) -> str:
if status in {"已成交", "已关闭"}:
return "done"
if status == "未成交":
return "wait_lost_reason"
# 原因已记则终态回放;缺原因才继续要原因。
return "wait_lost_reason" if not (lost_reason or "").strip() else "done"
if status == "协商中":
return "wait_deal"
if status == "已报价" and quote:
return "wait_adopt"
return "tms_miss"
def _lost_reason_of(self, sess: FlowSession) -> str:
"""从主账读未成交原因;续办终态回放要用,不能靠进程内字段。"""
no = (sess.work_order_no or "").strip()
if not no:
return ""
getter = getattr(self._ledger, "get_for_agent", None)
if not callable(getter):
return ""
try:
view = getter(work_order_no=no, sender_id=sess.sender_id) or {}
except Exception:
logger.warning("读未成交原因失败 wo=%s", no)
return ""
return str(view.get("lost_reason") or view.get("lostReason") or "").strip()
@staticmethod
def _quote_pending_cabin(quote: dict[str, Any] | None) -> bool:
"""报价快照是否仍停在空运选舱(pending_cabin_pick=1 或未选多舱位)。"""
src = dict(quote or {})
flag = str(src.get("pending_cabin_pick") or src.get("pendingCabinPick") or "").strip()
if flag in {"1", "true", "True", "yes"}:
return True
from agent.schema.air_route_options import parse_air_options
if src.get("selected_air_option") or src.get("selectedAirOption"):
return False
return len(parse_air_options(src)) > 0 and not (
str(src.get("total") or "").strip()
)
def _align_resume_phase(self, sess: FlowSession) -> None:
"""
报价文件已经发给销售,续办必须回到成交跟进。
@@ -971,8 +1014,23 @@ class AirTextInquiryFlow:
if sess.phase == "wait_lost_reason":
reply(copy.deal_ack("未成交", sess.work_order_no))
return "wait_lost_reason"
if sess.phase == "wait_air_route":
quote = dict(sess.tms_quote or sess.quote or {})
return self._offer_air_routes(
sess=sess,
work_order_no=sess.work_order_no,
quote=quote,
reply=reply,
cabin_pick=self._quote_pending_cabin(quote),
)
if sess.phase == "done":
reply(copy.deal_ack(sess.status or "已关闭", sess.work_order_no))
status = (sess.status or "").strip() or "已关闭"
lost = self._lost_reason_of(sess) if status == "未成交" else ""
# 未成交且原因已记:续办只重播「原因已记、工单已关」,禁止再要原因。
if status == "未成交" and lost:
reply(copy.lost_reason_ack(lost, work_order_no=sess.work_order_no))
return "done"
reply(copy.deal_ack(status, sess.work_order_no, lost_reason=lost))
return "done"
reply(copy.inquiry_card(work_order_no=sess.work_order_no, facts=sess.facts))
return sess.phase or "replay"
@@ -1393,9 +1451,13 @@ class AirTextInquiryFlow:
)
options = parse_air_options(quote)
from agent.schema.field_validate import is_blank_quote_date
# 没填报价日期:不按起运地收口,TMS 返回的舱位全部给销售点。1 条也要点。
# 未选定前六态保持询价中,禁止把备选舱位费写进后台「已报价」。
if not str((sess.facts or {}).get("报价日期") or "").strip() and options:
# 「(非必填):」占位文案也当没填,必须进选舱。
if is_blank_quote_date((sess.facts or {}).get("报价日期")) and options:
quote = dict(quote)
quote["airOptions"] = sort_cabin_options(options)
ledger_quote = _ledger_quote_pending_cabin(quote)
@@ -1894,6 +1956,7 @@ class AirTextInquiryFlow:
if not callable(renderer):
reply(copy.quote_template_missing())
return "wait_adopt"
# 私聊采用出单:填好的报价单要进后台附件;激活群发模版另走 persist=False。
result = renderer(
work_order_no=sess.work_order_no,
business_line=sess.business_line or "AIR",
@@ -1901,6 +1964,7 @@ class AirTextInquiryFlow:
quote=dict(sess.quote or {}),
format=fmt,
sales_wecom_id=sess.sender_id,
persist_attachment=True,
) or {}
if not result.get("ok"):
reply(str(result.get("message") or copy.quote_template_missing()))
@@ -2298,7 +2362,7 @@ _FLOW_GUARD = threading.Lock()
def build_ledger() -> Any:
"""生产打 8180;单测 LEDGER_BACKEND=memory。"""
"""生产打 8080;单测 LEDGER_BACKEND=memory。"""
from agent.config import get_settings
backend = (get_settings().ledger_backend or "http").strip().lower()
+73
View File
@@ -0,0 +1,73 @@
"""
企微机器人显示名:出站话术与入站 @ 识别共用。
本文件职责:从后台 reply_code=system.bot_display_name 读显示名;
失败回退默认「智报通AIQ」;过渡期识别仍认旧名「询价小助手」。
禁止:每条群消息同步打主账(走 bot_reply 短缓存);禁止当六态。
"""
from __future__ import annotations
from typing import Tuple
# 后台调用编码:inq_bot_reply.reply_text 只写显示名本身,不要整句。
REPLY_CODE = "system.bot_display_name"
# 主账空/失败时的默认显示名(产品已定新名)。
DEFAULT_BOT_DISPLAY_NAME = "智报通AIQ"
# 企微改名过渡:旧习惯 @ 仍要听得见(含「价/报」两版现网名)。
LEGACY_BOT_DISPLAY_NAMES: Tuple[str, ...] = ("询价小助手", "智价通AIQ", "智报通AIQ")
def bot_display_name() -> str:
"""
当前对外显示名(销售话术里的 @ 名)。
读后台短缓存;空串/停用回退默认。只取首行,防止误配整段话术。
"""
from agent.policy.bot_reply import lookup_raw
raw = (lookup_raw(REPLY_CODE) or "").strip()
if not raw:
return DEFAULT_BOT_DISPLAY_NAME
first = raw.splitlines()[0].strip()
return first or DEFAULT_BOT_DISPLAY_NAME
def bot_mention_names() -> Tuple[str, ...]:
"""
入站识别用的全部名字:当前配置名 + 旧名,去重保序。
销售可见话术只用 bot_display_name();识别必须覆盖过渡期旧 @。
"""
names: list[str] = []
for item in (bot_display_name(), *LEGACY_BOT_DISPLAY_NAMES):
name = (item or "").strip()
if name and name not in names:
names.append(name)
return tuple(names)
def text_mentions_bot(text: str) -> bool:
"""原文是否带当前或旧显示名(含不带 @ 的粘贴)。"""
raw = text or ""
return any(name in raw for name in bot_mention_names())
def strip_bot_mention(text: str) -> str:
"""
去掉 @显示名 与裸显示名(含旧名),便于抽字段/判「仅工单号」、出站 instructionText。
先剥带 @ 的,再剥裸名,避免残留 @;末尾压空白,避免「@机器人 舱位」留下双空格。
锁舱/释放出站前必须剥干净,禁止把「@智价通AIQ」原样带给 TMS。
"""
rest = text or ""
# 长名优先,避免短名先剥留下半截。
names = sorted(bot_mention_names(), key=len, reverse=True)
for name in names:
rest = rest.replace(f"@{name}", "").replace(name, "")
return " ".join(rest.split())
def at_bot() -> str:
"""出站示例用的「@显示名」后缀。"""
return f"@{bot_display_name()}"
+20 -1
View File
@@ -58,11 +58,24 @@ def fill(template: str, values: dict[str, Any] | None = None) -> str:
return _PLACEHOLDER.sub(_repl, template or "")
def lookup_raw(code: str) -> str:
"""
按调用编码取原文模板(不填变量)。
供显示名等短配置读取;空串表示停用/未配。走同一份短缓存。
"""
key = (code or "").strip()
if not key:
return ""
return _lookup(key)
def render(code: str, default: str, **kwargs: Any) -> str:
"""
取已启用回复语并填变量。
停用、库空、主账失败、编码对不上:返回 default(已是拼好的默认句)。
自动注入 {{bot_name}}(企微机器人显示名),调用方可不传。
副作用:缓存过期时 GET 主账;超时/失败吞掉,不抛给销售。
"""
key = (code or "").strip()
@@ -72,7 +85,13 @@ def render(code: str, default: str, **kwargs: Any) -> str:
tpl = _lookup(key)
if not (tpl or "").strip():
return fallback
return fill(tpl, kwargs)
values = dict(kwargs)
if "bot_name" not in values:
# 延迟导入,避免与 bot_name → lookup_raw 循环在模块加载期炸。
from agent.policy.bot_name import bot_display_name
values["bot_name"] = bot_display_name()
return fill(tpl, values)
def _lookup(code: str) -> str:
+25 -4
View File
@@ -204,6 +204,22 @@ def _is_waicai(facts: dict[str, str] | None) -> bool:
return "外采" in blob
def _has_all_line(tokens: list[str], *, line: str) -> bool:
"""
该负责人的「负责线路」是否覆盖本业务线全部线路。
成交通知只推负责人(isLeader),不群发。后台负责线路选项里的
「空运全线 / 海运全线 / 陆运全线」表示这位负责人管整条业务线;
旧数据可能仍写「全线」。只认整词,避免误伤「外采-空运」等。
"""
want = {
"SEA": {"海运全线", "全线"},
"LAND": {"陆运全线", "全线"},
"AIR": {"空运全线", "全线"},
}.get((line or "").strip().upper(), set())
return any(t in want for t in tokens)
def leader_matches(
*,
role_code: str,
@@ -215,7 +231,8 @@ def leader_matches(
"""
员工管理「是负责人 + 负责线路对得上」。
海运:海运全线。陆运:线路类别整词。空运:三字码对 / 目的港 / 全线;外采须工单标明。
海运:海运全线。陆运:陆运全线或线路类别整词。
空运:空运全线 / 三字码对 / 目的港;外采须工单标明。
"""
if not is_leader:
return False
@@ -223,14 +240,18 @@ def leader_matches(
code = (role_code or "").strip().lower()
tokens = _split_routes(routes)
if line == "SEA":
return code == "sea" and "海运全线" in tokens
return code == "sea" and _has_all_line(tokens, line="SEA")
if line == "LAND":
if code != "land":
return False
if _has_all_line(tokens, line="LAND"):
return True
want = str((facts or {}).get("线路类别") or "").strip()
return code == "land" and bool(want) and want in tokens
return bool(want) and want in tokens
if line == "AIR":
if code != "air":
return False
if "全线" in tokens:
if _has_all_line(tokens, line="AIR"):
return True
if "外采-空运" in tokens and _is_waicai(facts):
return True
+4 -1
View File
@@ -203,6 +203,7 @@ def finish_deal_outcome(
quote=quote or {},
business_line=business_line,
)
enqueued = 0
for row in leaders:
uid = str(row.get("wecomId") or "").strip()
if not uid:
@@ -214,7 +215,9 @@ def finish_deal_outcome(
dedupe_key=f"deal-notify:{no}:{uid}",
payload=dict(payload),
)
if not ok:
if ok:
enqueued += 1
else:
logger.warning("成交通知入队失败 wo=%s uid=%s info=%s", no, uid, info)
except Exception:
logger.exception("成交通知入队异常 wo=%s uid=%s", no, uid)
+35 -71
View File
@@ -367,7 +367,12 @@ def land_confirm_card(facts: dict[str, str] | None) -> str:
]
for key in _land_cargo_keys(land_type, src):
label = LAND_DISPLAY.get(key, key)
val = (src.get(key) or "").strip()
if key == "品名":
from agent.schema.land_options import land_cargo_name
val = land_cargo_name(src)
else:
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()}")
@@ -534,7 +539,10 @@ def _air_known_value(facts: dict[str, str], key: str) -> str:
if key == "包装方式":
return (src.get("包装方式") or src.get("包装类型") or "").strip()
if key == "报价日期":
return (src.get("报价日期") or "").strip()
from agent.schema.field_validate import is_blank_quote_date
val = (src.get("报价日期") or "").strip()
return "" if is_blank_quote_date(val) else val
from agent.llm.extract_text import clip_inquiry_value
return clip_inquiry_value(src.get(key) or "")
@@ -650,42 +658,6 @@ def ask_clarify(
if key == "起运港":
continue
val = known(src, key)
# #region agent log
if key == "added_services":
try:
import json as _dbg_json
import time as _dbg_time
from pathlib import Path as _DbgPath
_payload = {
"sessionId": "5ea5b3",
"runId": "post-fix",
"hypothesisId": "H3",
"location": "inquiry_copy.py:ask_clarify",
"message": "clarify display added_services",
"data": {
"raw_facts": str(src.get("added_services") or ""),
"display_val": val,
"label": label,
},
"timestamp": int(_dbg_time.time() * 1000),
}
for _p in (
_DbgPath(__file__).resolve().parents[3] / "debug-5ea5b3.log",
_DbgPath(r"d:/own/友通达询价智能体(新)/debug-5ea5b3.log"),
_DbgPath(r"C:/Users/ytd_extdev_0903/incoming/debug-5ea5b3.log"),
):
try:
_p.parent.mkdir(parents=True, exist_ok=True)
with _p.open("a", encoding="utf-8") as _f:
_f.write(
_dbg_json.dumps(_payload, ensure_ascii=False) + "\n"
)
except Exception:
pass
except Exception:
pass
# #endregion
if val:
lines.append(f"{label}:{val}")
lines.append("")
@@ -802,10 +774,13 @@ def inquiry_field_lines(
dest = (facts.get("目的港") or facts.get("目的地") or "-").strip()
cargo = (facts.get("品名") or "-").strip() or "-"
pack = (facts.get("包装方式") or facts.get("包装类型") or "-").strip() or "-"
from agent.schema.field_validate import is_blank_quote_date
quote_date = (facts.get("报价日期") or "").strip()
if quote_date in {"-", "—", "-"}:
if is_blank_quote_date(quote_date):
quote_date = ""
if (transport_mode or "").strip() == "海运":
mode = (transport_mode or "").strip()
if mode == "海运":
qty = (facts.get("货量") or facts.get("货物数量") or "-").strip() or "-"
load = (facts.get("整柜或拼柜") or "-").strip() or "-"
box = (facts.get("箱型箱量") or "-").strip() or "-"
@@ -824,7 +799,7 @@ def inquiry_field_lines(
f"运输分类:{klass}",
f"报价日期:{quote_date or '-'}",
]
if (transport_mode or "").strip() == "陆运":
if mode == "陆运":
land_type = (facts.get("运输类型") or "").strip()
lines = [
f"工单号:{work_order_no}",
@@ -854,30 +829,6 @@ def inquiry_field_lines(
]
# 建单确认和空运群:有内容才多一行。没填不出现。核对卡的空行不在这里。
added = (facts.get("added_services") or facts.get("增值服务") or "").strip()
# #region agent log
try:
import json as _dbg_json
import time as _dbg_time
from pathlib import Path as _DbgPath
_payload = {
"sessionId": "5ea5b3",
"runId": "pre-fix",
"hypothesisId": "H3",
"location": "inquiry_copy.py:air_confirm_lines",
"message": "display added_services value",
"data": {
"added": added,
"facts_keys": sorted(str(k) for k in (facts or {}).keys()),
},
"timestamp": int(_dbg_time.time() * 1000),
}
_path = _DbgPath(__file__).resolve().parents[3] / "debug-5ea5b3.log"
with _path.open("a", encoding="utf-8") as _f:
_f.write(_dbg_json.dumps(_payload, ensure_ascii=False) + "\n")
except Exception:
pass
# #endregion
if added and added not in {"-", "—", "-"}:
lines.insert(-1, f"增值服务:{added}")
return lines
@@ -1323,18 +1274,21 @@ def collab_hint(work_order_no: str) -> str:
"""
空运价格卡协同说明:紧挨「不需要协同,直接生成报价单」上方。
后台 inquiring.air_collab_hint;库内模板可用 {{ticket_no}}。
无后台模板时用本默认句(已带工单号)。销售侧示例:
…发送“ WO202610060030@询价小助手”,谢谢。
后台 inquiring.air_collab_hint;库内模板可用 {{ticket_no}}、{{bot_name}}。
无后台模板时用本默认句(已带工单号与当前显示名)。
"""
from agent.policy.bot_name import bot_display_name
no = (work_order_no or "").strip()
name = bot_display_name()
return _r(
"inquiring.air_collab_hint",
(
f"若需要咨询内部航线人员,请手动将相关方拉到空运原有协同群,"
f"并在空运协同群中发送“ {no}@询价小助手”,谢谢。"
f"并在空运协同群中发送“ {no}@{name}”,谢谢。"
),
ticket_no=no,
bot_name=name,
)
@@ -2865,10 +2819,18 @@ def deal_ack(
next_followup_at: str = "",
lost_reason: str = "",
followup_hours: float | None = None,
business_line: str = "",
) -> str:
"""成交三态回执。后台 deal.recorded_won / lost.ask_reason / negotiating.recorded。"""
"""
成交三态回执。后台 deal.recorded_won / lost.ask_reason / negotiating.recorded。
多段联运不锁舱:已成交只回短确认,不要后台模板里「空运可锁舱」括号说明。
"""
no = (work_order_no or "").strip()
line = (business_line or "").strip().upper()
if outcome == "已成交":
if line == "MULTI":
return f"工单 {no} 已记录为「已成交」。"
return _r(
"deal.recorded_won",
f"已记录为「已成交」。工单 {no} 已关闭智能体跟进。",
@@ -3408,8 +3370,10 @@ def air_text_quote_for_lock_example(work_order_no: str = "") -> str:
附件报价不拆金额、常锁不成;产品口径改为引导航线改发文字价。
示例必须带计费单位(空运常见 KG),避免航线漏写单位导致文字价无法落库。
"""
from agent.policy.bot_name import at_bot
no = (work_order_no or "").strip() or "工单号"
return f"{no},空运段,KG,空运费 单价CNY99.00,数量100 ,金额CNY9900.00@询价小助手"
return f"{no},空运段,KG,空运费 单价CNY99.00,数量100 ,金额CNY9900.00{at_bot()}"
def air_attachment_quote_lock_tip(*, work_order_no: str) -> str:
+8 -33
View File
@@ -138,39 +138,6 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
sess.work_order_no or "-",
)
sess = None
# #region agent log
try:
import json as _json
import time as _time
from pathlib import Path as _Path
with _Path(r"d:\own\友通达询价智能体(新)\debug-d331ea.log").open("a", encoding="utf-8") as _dbg:
_dbg.write(
_json.dumps(
{
"sessionId": "d331ea",
"runId": "post-fix",
"hypothesisId": "D",
"location": "land_text_flow.py:on_text:sess_keep",
"message": "land sess keep-or-clear",
"data": {
"phase_in": phase,
"kept_sess": sess is not None,
"in_front": phase in _LAND_FRONT,
"sensitive_hold": (
str(getattr(sess, "sensitive_hold", "") or "")[:120] if sess else ""
),
"text": (text or "")[:120],
},
"timestamp": int(_time.time() * 1000),
},
ensure_ascii=False,
)
+ "\n"
)
except Exception:
pass
# #endregion
snap = extract_inquiry_snapshot(
text,
@@ -206,6 +173,14 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
# 还在问运输方式时,重量体积和陆运选项要对照前面几句,不只看「1」。
source = waiting_mode_source(sess, text)
facts = harvest_oral_measures(source, merged)
# 陆运:箱型误入要改写;口语 45HQ/厢车/米车补进车型/数量。
from agent.schema.field_validate import (
harvest_oral_land_vehicle,
promote_land_vehicle_from_container,
)
facts = promote_land_vehicle_from_container(facts)
facts = harvest_oral_land_vehicle(source, facts)
# 选项只从原话+能对上枚举的模型值收;禁止模型「陆运」盖掉已选的国内运输拼车。
facts = harvest_land_options(source, facts)
b_opt = " ".join(
+206 -23
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import json
import logging
import re
from contextlib import contextmanager
from typing import Any, Callable, Iterator, Optional
@@ -18,10 +19,13 @@ from agent.llm.extract_text import extract_collab_fields, extract_product_quote
from agent.policy import inquiry_copy as copy
from agent.policy.multi_segments import Segment, group_brief, includes_air, segments_from_payload
from agent.policy.sea_group_ops import (
_archive_file,
_quote_currency_hint,
apply_group_deal,
filename_is_excel_pdf,
group_client_of,
is_sales,
notify_quote_attachment_saved,
override_group_client,
send_filled_sheet,
send_group_text,
@@ -109,6 +113,128 @@ def sender_mode(flow: Any, sender_id: str) -> str:
return _ROLE_MODE.get(role, "")
_RE_SEG_INDEX = re.compile(
r"(?:段\s*(\d+)|第\s*([一二三四五六七八九]|\d+)\s*段)",
re.I,
)
_CN_INDEX = {
"一": 1,
"二": 2,
"三": 3,
"四": 4,
"五": 5,
"六": 6,
"七": 7,
"八": 8,
"九": 9,
}
_FEE_MODE = (
("陆运费", "LAND"),
("空运费", "AIR"),
("海运费", "SEA"),
# 报价单文件名常写「空运到港报价单」无「费」字。
("陆运", "LAND"),
("空运", "AIR"),
("海运", "SEA"),
)
def resolve_quote_segment(
text: str,
segments: list[Segment],
role_mode: str,
) -> tuple[Optional[Segment], str]:
"""
多段报价归到哪一段。
1) 正文「段1 / 第一段」;2) 正文写了陆运费/空运费/海运费;3) 岗位。
运行证据:林绵勋岗位是 air,发「段1:陆运费」却出了段2空运单。
返回 (段, 选用原因)。
"""
if not segments:
return None, "no_segments"
raw = text or ""
hit = _RE_SEG_INDEX.search(raw)
if hit:
num = hit.group(1) or hit.group(2) or ""
try:
index = int(num) if str(num).isdigit() else _CN_INDEX.get(str(num), 0)
except ValueError:
index = 0
if index > 0:
for seg in segments:
if seg.index == index:
return seg, f"text_index:{index}"
fee_mode = ""
for word, mode in _FEE_MODE:
if word in raw:
fee_mode = mode
break
role = (role_mode or "").strip().upper()
if fee_mode:
for seg in segments:
if seg.mode == fee_mode:
return seg, f"text_fee:{fee_mode}"
if role:
for seg in segments:
if seg.mode == role:
return seg, f"role:{role}"
return None, "unmatched"
def apply_multi_file(
*,
flow: Any,
ticket: object,
chat_id: str,
sender_id: str,
filename: str,
media: Optional[dict[str, Any]] = None,
) -> str:
"""
多段群附件报价:落 inq_attachment,按文件名/岗位归段,不写正文抽价。
运行证据:石畅畅发「…空运到港报价单.xlsx」时,旧逻辑把文件名当文字报价,
回「没有识别到报价」,附件也没进后台。
副作用:写主账附件+分段报价;可能发群确认/成交跟进。Worker 线程。
"""
room = (chat_id or "").strip()
no = ticket_no(ticket)
client = group_client_of(flow)
name = (filename or "").strip()
if not filename_is_excel_pdf(name):
send_group_text(client, room, copy.group_only_excel_pdf())
return "file_type_rejected"
segments = load_segments(ticket)
if not segments:
send_group_text(client, room, f"工单{no}没有可报价的运输段。")
return "multi_no_segments"
role_mode = sender_mode(flow, sender_id)
target, reason = resolve_quote_segment(name, segments, role_mode)
if target is None:
send_group_text(client, room, "没有对上你负责的运输段,请让对应岗位报价。")
return "quote_wrong_role"
_archive_file(flow, no, name, dict(media or {}), sender_id)
notify_quote_attachment_saved(flow, room, no, count=1)
quote = {
"source_label": "产品附件报价",
"file_name": name,
"total": "-",
"raw_text": name,
}
# 附件本身就是报价单:只落段价,不再重发空白模板。
return _save_segment_quote(
flow=flow,
ticket=ticket,
chat_id=room,
segment=target,
segments=segments,
quote=quote,
sheet_fn=None,
skip_sheet=True,
)
def handle_multi_inbound(
*,
flow: Any,
@@ -142,8 +268,9 @@ def handle_multi_inbound(
if only_no:
_send_activation(flow, ticket, room, segments, sender_id=sender_id, sheet_fn=sheet_fn)
return "multi_activated"
mode = sender_mode(flow, sender_id)
target = next((seg for seg in segments if seg.mode == mode), None)
role_mode = sender_mode(flow, sender_id)
target, resolve_reason = resolve_quote_segment(text or "", segments, role_mode)
mode = (target.mode if target is not None else role_mode) or ""
fields = {}
if target is not None:
keys = collab_fields_for_ticket(target.facts, target.mode)
@@ -152,7 +279,8 @@ def handle_multi_inbound(
_save_collab(flow, ticket, target, fields)
ticket = _reload(flow, no) or ticket
segments = load_segments(ticket)
target = next((seg for seg in segments if seg.mode == mode), target)
target, resolve_reason = resolve_quote_segment(text or "", segments, role_mode)
mode = (target.mode if target is not None else role_mode) or mode
# 段报价币种优先;否则用工单顶层 quote(产品未写币种时继承 TMS)
baseline = ""
if target is not None:
@@ -190,10 +318,21 @@ def handle_multi_inbound(
)
def deal_text(*, work_order_no: str, segments: list[Segment], quotes: dict[str, dict[str, Any]]) -> str:
"""整票成交跟进文字。每段各自的价格、有效期,海运陆运再加时效。不加总。"""
def deal_text(
*,
work_order_no: str,
segments: list[Segment],
quotes: dict[str, dict[str, Any]],
sales_name: str = "",
) -> str:
"""
整票成交跟进文字。开头 @本单销售,与海运 group_deal_followup 一致。
每段各自的价格、有效期,海运陆运再加时效。不加总。
"""
no = (work_order_no or "").strip()
lines = [f"询价工单 {no}", copy.DEAL_SUBTITLE]
name = (sales_name or "").strip() or "销售"
lines = [f"@{name}", f"询价工单 {no}", copy.DEAL_SUBTITLE]
for seg in segments:
quote = quotes.get(seg.mode) or {}
lines.append(f"段{seg.index}:{seg.word()}")
@@ -240,6 +379,7 @@ def _save_segment_quote(
segments: list[Segment],
quote: dict[str, Any],
sheet_fn: Optional[SheetFn],
skip_sheet: bool = False,
) -> str:
"""写入这一段报价并发该段报价单。其他段未齐时不发成交跟进。"""
no = ticket_no(ticket)
@@ -254,28 +394,63 @@ def _save_segment_quote(
"source_label": "分段报价",
"deal_version": version,
}
writer = getattr(flow.ledger, "upsert_quote", None)
if callable(writer):
writer(
# 分段报价是产品手工价,必须走 upsertProductQuote。
# 旧代码误走 replaceTmsQuote,主账 inq_quote 不落、六态也不到「已报价」。
product_writer = getattr(flow.ledger, "upsert_product_quote", None)
if callable(product_writer):
product_writer(
work_order_no=no,
quote=payload,
to_status="已报价" if all_ready else "",
# 未齐段时保持询价中;空串会被主账默认成「已报价」。
to_status="已报价" if all_ready else "询价中",
remark="分段报价",
kind="product",
)
else:
writer = getattr(flow.ledger, "upsert_quote", None)
if callable(writer):
writer(
work_order_no=no,
quote=payload,
to_status="已报价" if all_ready else "",
)
ticket = _reload(flow, no) or ticket
_send_sheet(
flow=flow,
ticket=ticket,
chat_id=chat_id,
segment=segment,
quote=quote,
sheet_fn=sheet_fn,
)
if not skip_sheet:
_send_sheet(
flow=flow,
ticket=ticket,
chat_id=chat_id,
segment=segment,
quote=quote,
sheet_fn=sheet_fn,
)
client = group_client_of(flow)
if not all_ready:
send_group_text(client, chat_id, f"段{segment.index}{segment.word()}报价单已发出。其他段报完后一起跟进成交。")
tip = (
f"段{segment.index}{segment.word()}报价附件已收到。其他段报完后一起跟进成交。"
if skip_sheet
else f"段{segment.index}{segment.word()}报价单已发出。其他段报完后一起跟进成交。"
)
send_group_text(client, chat_id, tip)
return "segment_quoted"
text = deal_text(work_order_no=no, segments=segments, quotes=stored)
send_group_text(client, chat_id, text)
# 与海运一致:正文写 @销售姓名,并带 mention,企微才能真正点到人。
from agent.policy.sea_group_ops import resolve_group_mentions
sales_name, _products, _mention_ids = resolve_group_mentions(flow, ticket, None)
view = ticket_view(ticket)
sales_id = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip()
text = deal_text(
work_order_no=no,
segments=segments,
quotes=stored,
sales_name=sales_name,
)
send_group_text(
client,
chat_id,
text,
mention_userids=[sales_id] if sales_id else None,
)
carder = getattr(client, "send_group_card", None)
if callable(carder):
carder(
@@ -446,15 +621,23 @@ def _send_sheet(
quote: dict[str, Any],
sheet_fn: Optional[SheetFn],
) -> None:
"""按这一段的运输方式匹配模板。失败只记日志,不挡住报价归段。"""
"""
按这一段的运输方式匹配模板。失败只记日志,不挡住报价归段。
有价段报价单:发群同时写后台附件;激活空白模版(quote 无金额)不落库。
"""
sender = sheet_fn or send_filled_sheet
sheet_ticket = _sheet_ticket(ticket, segment)
# 段上已有金额/明细 → 正式出单,要进 inq_attachment;空 quote 是激活模版。
persist = _has_money(dict(quote or {}))
try:
sender(
flow=flow,
ticket=_sheet_ticket(ticket, segment),
ticket=sheet_ticket,
quote_override=dict(quote or {}),
business_line=segment.mode,
chat_id=chat_id,
persist_attachment=persist,
)
except Exception:
logger.exception("多段模板发送失败 wo=%s mode=%s", ticket_no(ticket), segment.mode)
+7 -1
View File
@@ -182,12 +182,16 @@ def _shared_cargo_facts(header: str) -> dict[str, str]:
def _cargo_name_before_segments(raw: str) -> str:
"""段前货物句里,去掉件数重量体积包装后剩下的那一个品名。"""
from agent.llm.extract_text import is_clarify_ui_prompt
cleaned = _SHARED_NOISE.sub(" ", raw or "")
parts: list[str] = []
for part in re.split(r"[,,、;;\n]+", cleaned):
token = part.strip(" ::。.")
if not token or re.fullmatch(r"[\d.\s*xX×]+", token):
continue
if is_clarify_ui_prompt(token):
continue
if any(word in token for word in ("到", "飞", "空运", "海运", "陆运", "段")):
continue
parts.append(token)
@@ -606,12 +610,14 @@ def _lacking_indexes(segments: list[Segment]) -> list[int]:
def _facts_from_body(body: str, mode: str) -> dict[str, str]:
"""一段正文里的「字段名:值」。陆运再收运输类型、线路类别和销售侧叫法。"""
from agent.llm.extract_text import clip_inquiry_value
facts = parse_labeled_facts(body)
for line in (body or "").splitlines():
matched = _EXTRA_LABEL.match(line.strip())
if not matched:
continue
key, val = matched.group(1), matched.group(2).strip()
key, val = matched.group(1), clip_inquiry_value(matched.group(2))
if val and not str(facts.get(key) or "").strip():
facts[key] = val
if mode == "LAND":
@@ -322,6 +322,7 @@ def apply_group_adjust(
collab = dict(view.get("collab_facts") or {})
if sess is not None and getattr(sess, "collab_facts", None):
collab.update(dict(sess.collab_facts or {}))
# 调价后再出一版:正式有价单,登记后台附件。
_render_and_send_sheet(
flow=flow,
chat_id=room,
@@ -335,6 +336,7 @@ def apply_group_adjust(
quote=result.quote,
sales_id=sender_id,
business_line=line,
persist_attachment=True,
)
emit_group_deal(flow=flow, ticket=ticket2, sess=sess, quote=result.quote)
logger.info("group_adjust wo=%s applied=%s", no, [x.get("name") for x in result.applied])
+63 -16
View File
@@ -390,7 +390,11 @@ def send_group_text(
content: str,
mention_userids: Optional[list[str]] = None,
) -> None:
"""群文本可带企微 userid,方便客户端真正点到人。旧客户端无此参数就只发正文。"""
"""
群文本可带企微 userid,方便客户端真正点到人。旧客户端无此参数就只发正文。
发完后把该群标热:销售接着回「解散群」时,存档轮询会优先扫到,少等几分钟。
"""
if not client or not chat_id or not content:
return
sender = getattr(client, "send_group", None)
@@ -401,6 +405,13 @@ def send_group_text(
sender(chat_id=chat_id, content=content, mention_userids=mentions)
except TypeError:
sender(chat_id=chat_id, content=content)
try:
from agent.channel.wshoto.rooms import mark_room_hot, watch_collab_room
watch_collab_room(chat_id)
mark_room_hot(chat_id)
except Exception:
logger.exception("发群后登记热窗失败 chat=%s", chat_id)
def hydrate_ticket(flow: Any, ticket: object) -> object:
@@ -572,11 +583,13 @@ def send_brief_and_sheet(
quote_override: Optional[dict[str, Any]] = None,
tms_quote: Optional[dict[str, Any]] = None,
product_quote: Optional[dict[str, Any]] = None,
persist_attachment: bool = False,
) -> str:
"""
群里再发一版摘要 + 填好的海运模板。旧消息不撤。
TMS 和人工报价分段写,无价也发表。
persist_attachment:正式有价出单传 True,登记后台附件;激活/补字段重发保持 False。
"""
ticket = hydrate_ticket(flow, ticket)
view = ticket_view(ticket)
@@ -638,6 +651,7 @@ def send_brief_and_sheet(
sess=sess,
quote_override=quote,
business_line=line,
persist_attachment=persist_attachment,
)
return "brief_and_sheet"
@@ -672,11 +686,14 @@ def send_filled_sheet(
quote_override: Optional[dict[str, Any]] = None,
business_line: str = "",
chat_id: str = "",
persist_attachment: bool = False,
) -> dict[str, Any]:
"""
只发填好的模板文件,不再发文字摘要。
business_line 决定匹配哪套后台模板;空运必须传 AIR,禁止落到海运默认模板。
persist_attachment:True 时主账登记 inq_attachment(正式有价出单);
激活空白模版保持 False,避免后台附件列堆满空白表。
"""
incoming_view = ticket_view(ticket) if ticket is not None else {}
incoming_facts = dict(incoming_view.get("facts") or {})
@@ -720,6 +737,7 @@ def send_filled_sheet(
or ""
),
business_line=line or "SEA",
persist_attachment=persist_attachment,
)
@@ -795,17 +813,21 @@ def _render_and_send_sheet(
quote: dict[str, Any],
sales_id: str,
business_line: str = "SEA",
persist_attachment: bool = False,
) -> dict[str, Any]:
"""
按业务线匹配后台模板、填表、发群文件。
空运必须 business_line=AIR,否则会套上海运默认模板。
persist_attachment:正式有价出单 True(写 inq_attachment);
激活空白模版 False(只发企微,不堆后台附件列)。
"""
renderer = getattr(flow.ledger, "render_quote", None)
client = group_client_of(flow)
if not callable(renderer) or not chat_id:
return {"ok": False, "error": "no_renderer_or_chat"}
line = (business_line or "SEA").strip() or "SEA"
# 激活空白模版:persist=False;产品/分段有价出单:persist=True 写后台附件。
result = renderer(
work_order_no=work_order_no,
business_line=line,
@@ -813,6 +835,7 @@ def _render_and_send_sheet(
quote=quote,
format="xlsx",
sales_wecom_id=sales_id,
persist_attachment=bool(persist_attachment),
) or {}
if not result.get("ok"):
logger.warning("群填表失败 wo=%s line=%s err=%s", work_order_no, line, result.get("error"))
@@ -955,6 +978,7 @@ def apply_product_quote(
quote = merge_quote_fees(base, quote)
_save_product_quote(flow, no, quote, sess)
ticket2 = _reload(flow, chat_id, no) or ticket
# 正式有价出单:Excel 进群的同时登记后台附件,销售在工单列表能看到。
send_brief_and_sheet(
flow=flow,
ticket=ticket2,
@@ -962,6 +986,7 @@ def apply_product_quote(
quote_override=quote,
tms_quote={},
product_quote=quote,
persist_attachment=True,
)
emit_group_deal(flow=flow, ticket=ticket2, sess=sess, quote=quote)
return "product_quote_sent"
@@ -1005,6 +1030,7 @@ def apply_tms_quote_choice(
_clear_pending_quote(no, sess, flow)
_save_product_quote(flow, no, quote, sess)
ticket2 = _reload(flow, chat_id, no) or ticket
# 正式有价出单:登记后台附件(与产品文字报价同一口径)。
send_brief_and_sheet(
flow=flow,
ticket=ticket2,
@@ -1012,6 +1038,7 @@ def apply_tms_quote_choice(
quote_override=quote,
tms_quote=base if keep_tms else {},
product_quote=overlay if keep_tms else quote,
persist_attachment=True,
)
emit_group_deal(flow=flow, ticket=ticket2, sess=sess, quote=quote)
return "product_quote_sent"
@@ -1104,6 +1131,7 @@ def _persist_quote_sheet(
or "SEA"
).strip() or "SEA"
if callable(renderer) and no:
# 确认落库:需要把填好的报价单登记到后台附件。
result = renderer(
work_order_no=no,
business_line=line,
@@ -1121,10 +1149,15 @@ def _persist_quote_sheet(
or view.get("salesWecomId")
or ""
),
persist_attachment=True,
) or {}
if result.get("ok"):
filename = filename_from_render(no, result)
object_key = str(result.get("objectKey") or filename)
object_key = str(result.get("objectKey") or "").strip()
# 主账 render 已写 inq_attachment 时不再重复挂一条「只有文件名」的空壳。
if object_key or result.get("attachmentSaved"):
return
object_key = filename
_archive_file(flow, no, filename, {"media_id": object_key}, sender_id)
@@ -1168,7 +1201,11 @@ def handle_group_file(
def dissolve_group(*, flow: Any, ticket: object, sess: Any = None) -> str:
"""解散应用群,这单协同结束,不改六态。"""
"""
解散应用群,这单协同结束,不改六态。
先发「群已解散」再移人:移完人销售可能已不在群,收不到确认。
"""
view = ticket_view(ticket)
no = ticket_no(ticket)
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "")
@@ -1176,6 +1213,8 @@ def dissolve_group(*, flow: Any, ticket: object, sess: Any = None) -> str:
members = list(view.get("collab_member_ids") or view.get("memberIds") or [])
if sess is not None:
members = list(sess.invited_userids or members)
# 先回话再移人,避免销售被移出后看不到确认。
send_group_text(client, chat_id, copy.group_dissolved(work_order_no=no))
dismiss = getattr(client, "dismiss_group", None)
if callable(dismiss) and chat_id:
dismiss(chat_id=chat_id, userids=members)
@@ -1192,7 +1231,6 @@ def dissolve_group(*, flow: Any, ticket: object, sess: Any = None) -> str:
if sess is not None:
sess.collab_ended = True
flow._save(sess)
send_group_text(client, chat_id, copy.group_dissolved(work_order_no=no))
return "group_dissolved"
@@ -1263,6 +1301,18 @@ def apply_group_deal(
view = ticket_view(ticket)
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "")
client = group_client_of(flow)
line = str(
view.get("business_line")
or view.get("businessLine")
or (sess.business_line if sess else "")
or "SEA"
).strip().upper()
status_now = ticket_status(ticket) or (
str(getattr(sess, "status", "") or "").strip() if sess is not None else ""
)
# 同一终态再确认一次(BOT+存档双通道):只静默,不刷「不能修改工单信息」。
if status_now in {"已成交", "未成交"} and outcome == status_now:
return "deal_already_recorded"
stopped = halt_closed_group(flow=flow, ticket=ticket, sess=sess, chat_id=chat_id)
if stopped:
return stopped
@@ -1270,7 +1320,6 @@ def apply_group_deal(
send_group_text(client, chat_id, copy.group_deal_sales_only())
return "not_sales"
quote = _current_quote(view, sess)
status_now = ticket_status(ticket) or (sess.status if sess else "")
usable_total = str(quote.get("total") or "").strip()
if usable_total in {"", "-"}:
usable_total = ""
@@ -1286,7 +1335,7 @@ def apply_group_deal(
outcome=outcome,
facts=facts_of_group(view, sess),
quote=quote,
business_line=str(view.get("business_line") or (sess.business_line if sess else "SEA") or "SEA"),
business_line=line or "SEA",
sales_name=str(view.get("sales_name") or (getattr(sess, "invited_sales_name", "") if sess else "") or ""),
lost_reason=inline,
)
@@ -1303,17 +1352,15 @@ def apply_group_deal(
sess.allowed = ()
flow._save(sess)
ack_reason = inline if phase == "done" and outcome == "未成交" else ""
send_group_text(
client,
chat_id,
copy.deal_ack(
outcome,
no,
str(result.get("next_followup_at") or ""),
lost_reason=ack_reason,
followup_hours=result.get("negotiate_hours"),
),
ack = copy.deal_ack(
outcome,
no,
str(result.get("next_followup_at") or ""),
lost_reason=ack_reason,
followup_hours=result.get("negotiate_hours"),
business_line=line,
)
send_group_text(client, chat_id, ack)
return phase