机器人显示名与站点标题可配;多段联运列表投影;协同群监听对账;工单附件落存;模板与工单后台补强
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 = ""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()}"
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user