补齐成交跟进:按线路推成交通知、收下未成交原因、协商中按工作日再问。
成交走询价消息服务;陆运/海运私聊下一句能记下原因;协商中回执写明顺延三个工作日和下次 9:00。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -539,12 +539,28 @@ def apply_air_deal(
|
||||
copy.ticket_locked(work_order_no=ticket_no(ticket), status=ticket_status(ticket)),
|
||||
)
|
||||
return "ticket_locked"
|
||||
result = flow.ledger.transition(work_order_no=ticket_no(ticket), to_status=outcome) or {}
|
||||
from agent.policy.deal_outcome import finish_deal_outcome
|
||||
from agent.policy.sea_group_ops import facts_of_group
|
||||
|
||||
view = ticket_view(ticket)
|
||||
result = finish_deal_outcome(
|
||||
ledger=flow.ledger,
|
||||
work_order_no=ticket_no(ticket),
|
||||
outcome=outcome,
|
||||
facts=facts_of_group(view),
|
||||
quote=dict(view.get("quote") or {}),
|
||||
business_line="AIR",
|
||||
sales_name=str(view.get("sales_name") or ""),
|
||||
)
|
||||
if not result.get("ok"):
|
||||
send_group_text(group_client_of(flow), chat_id, "主账状态更新失败,成交结果未入账。")
|
||||
return "ledger_fail"
|
||||
send_group_text(group_client_of(flow), chat_id, copy.deal_ack(outcome, ticket_no(ticket)))
|
||||
return "done"
|
||||
send_group_text(
|
||||
group_client_of(flow),
|
||||
chat_id,
|
||||
copy.deal_ack(outcome, ticket_no(ticket), str(result.get("next_followup_at") or "")),
|
||||
)
|
||||
return str(result.get("phase") or "done")
|
||||
|
||||
|
||||
def apply_air_lock(
|
||||
|
||||
@@ -322,6 +322,8 @@ class AirTextInquiryFlow:
|
||||
allowed = (copy.BTN_ADOPT_EXCEL, copy.BTN_ADOPT_PDF, copy.BTN_REJECT)
|
||||
elif phase == "wait_deal":
|
||||
allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
|
||||
elif phase == "wait_lost_reason":
|
||||
allowed = ()
|
||||
return FlowSession(
|
||||
sender_id=sender_id,
|
||||
thread_id=sender_id,
|
||||
@@ -333,11 +335,17 @@ class AirTextInquiryFlow:
|
||||
status=status,
|
||||
allowed=allowed,
|
||||
wait_version=1,
|
||||
invited_sales_name=str(view.get("sales_name") or view.get("salesName") or ""),
|
||||
collab_chat_id=str(view.get("collab_chat_id") or ""),
|
||||
)
|
||||
|
||||
def _infer_wait_phase(self, *, status: str, quote: dict[str, Any]) -> str:
|
||||
if status in {"已成交", "未成交", "协商中", "已关闭"}:
|
||||
if status in {"已成交", "已关闭"}:
|
||||
return "done"
|
||||
if status == "未成交":
|
||||
return "wait_lost_reason"
|
||||
if status == "协商中":
|
||||
return "wait_deal"
|
||||
if status == "已报价" and quote:
|
||||
return "wait_adopt"
|
||||
return "tms_miss"
|
||||
@@ -413,6 +421,9 @@ class AirTextInquiryFlow:
|
||||
# 成交卡已经绑在报价文件那条出站上。这里再发一张独立卡,
|
||||
# 会在文件还在上传时被另一条出站线程抢走,销售又先看到成交跟进。
|
||||
return "wait_deal"
|
||||
if sess.phase == "wait_lost_reason":
|
||||
reply(copy.deal_ack("未成交", sess.work_order_no))
|
||||
return "wait_lost_reason"
|
||||
if sess.phase == "done":
|
||||
reply(copy.deal_ack(sess.status or "已关闭", sess.work_order_no))
|
||||
return "done"
|
||||
@@ -462,6 +473,13 @@ class AirTextInquiryFlow:
|
||||
reply=reply,
|
||||
card_meta=inbound_raw,
|
||||
)
|
||||
# 未成交后下一句任意文字即原因;完整新询价(注入字段)则空着原因开新单。
|
||||
if (
|
||||
sess
|
||||
and sess.phase == "wait_lost_reason"
|
||||
and not (injected_facts and injected_mode)
|
||||
):
|
||||
return self._capture_lost_reason(sess, text, reply)
|
||||
|
||||
snap = extract_inquiry_snapshot(
|
||||
text,
|
||||
@@ -1056,15 +1074,48 @@ class AirTextInquiryFlow:
|
||||
) -> str:
|
||||
"""点成交/未成交/协商中:先整卡置灰,再入主账。"""
|
||||
self._grey_clicked_card(reply, card_meta, replace_name=status)
|
||||
result = self._ledger.transition(work_order_no=sess.work_order_no, to_status=status)
|
||||
from agent.policy.deal_outcome import finish_deal_outcome
|
||||
|
||||
result = finish_deal_outcome(
|
||||
ledger=self._ledger,
|
||||
work_order_no=sess.work_order_no,
|
||||
outcome=status,
|
||||
facts=sess.facts,
|
||||
quote=sess.quote,
|
||||
business_line=sess.business_line,
|
||||
sales_name=sess.invited_sales_name,
|
||||
)
|
||||
if not result.get("ok"):
|
||||
reply("主账状态更新失败,成交结果未入账。")
|
||||
return "ledger_fail"
|
||||
phase = str(result.get("phase") or "done")
|
||||
sess.status = status
|
||||
sess.phase = phase
|
||||
if phase == "wait_deal":
|
||||
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
|
||||
else:
|
||||
sess.allowed = ()
|
||||
sess.wait_version += 1
|
||||
self._save(sess)
|
||||
reply(copy.deal_ack(status, sess.work_order_no, str(result.get("next_followup_at") or "")))
|
||||
return phase
|
||||
|
||||
def _capture_lost_reason(self, sess: FlowSession, text: str, reply: ReplyFn) -> str:
|
||||
"""私聊未成交后收下原因,不再追问。"""
|
||||
from agent.policy.deal_outcome import capture_lost_reason
|
||||
|
||||
result = capture_lost_reason(
|
||||
ledger=self._ledger,
|
||||
work_order_no=sess.work_order_no,
|
||||
reason=text,
|
||||
)
|
||||
if not result.get("ok"):
|
||||
reply("未成交原因没有记下,请再发一句。")
|
||||
return "wait_lost_reason"
|
||||
sess.phase = "done"
|
||||
sess.allowed = ()
|
||||
self._save(sess)
|
||||
reply(copy.deal_ack(status, sess.work_order_no))
|
||||
reply(copy.lost_reason_ack(text))
|
||||
return "done"
|
||||
|
||||
def mark_wait_deal(self, sender_id: str, work_order_no: str) -> None:
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"""
|
||||
成交跟进纯规则:工作日顺延、线路对负责人。
|
||||
|
||||
本文件职责:给私聊/群聊成交共用,不发企微、不写主账。
|
||||
禁止:在回调线程算完还同步推负责人;把六态写进本文件。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta, time
|
||||
from typing import Any, Iterable
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from agent.schema.tms_air_query import resolve_airport_code
|
||||
|
||||
SHANGHAI = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
# 法定休息日(不含调休上班)。调休日放进 MAKEUP_WORKDAYS,周末也算工作日。
|
||||
_HOLIDAYS = frozenset(
|
||||
{
|
||||
# 2026
|
||||
date(2026, 1, 1),
|
||||
date(2026, 1, 2),
|
||||
date(2026, 1, 3),
|
||||
date(2026, 2, 15),
|
||||
date(2026, 2, 16),
|
||||
date(2026, 2, 17),
|
||||
date(2026, 2, 18),
|
||||
date(2026, 2, 19),
|
||||
date(2026, 2, 20),
|
||||
date(2026, 2, 21),
|
||||
date(2026, 2, 22),
|
||||
date(2026, 2, 23),
|
||||
date(2026, 4, 4),
|
||||
date(2026, 4, 5),
|
||||
date(2026, 4, 6),
|
||||
date(2026, 5, 1),
|
||||
date(2026, 5, 2),
|
||||
date(2026, 5, 3),
|
||||
date(2026, 5, 4),
|
||||
date(2026, 5, 5),
|
||||
date(2026, 6, 19),
|
||||
date(2026, 6, 20),
|
||||
date(2026, 6, 21),
|
||||
date(2026, 9, 25),
|
||||
date(2026, 9, 26),
|
||||
date(2026, 9, 27),
|
||||
date(2026, 10, 1),
|
||||
date(2026, 10, 2),
|
||||
date(2026, 10, 3),
|
||||
date(2026, 10, 4),
|
||||
date(2026, 10, 5),
|
||||
date(2026, 10, 6),
|
||||
date(2026, 10, 7),
|
||||
date(2026, 10, 8),
|
||||
# 2027 元旦预留
|
||||
date(2027, 1, 1),
|
||||
date(2027, 1, 2),
|
||||
date(2027, 1, 3),
|
||||
}
|
||||
)
|
||||
|
||||
# 周末调休上班:算工作日。
|
||||
_MAKEUP_WORKDAYS = frozenset(
|
||||
{
|
||||
date(2026, 1, 4),
|
||||
date(2026, 2, 14),
|
||||
date(2026, 2, 28),
|
||||
date(2026, 5, 9),
|
||||
date(2026, 10, 10),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def is_workday(day: date) -> bool:
|
||||
"""周六日默认休息;法定假休息;调休上班算工作日。"""
|
||||
if day in _HOLIDAYS:
|
||||
return False
|
||||
if day in _MAKEUP_WORKDAYS:
|
||||
return True
|
||||
return day.weekday() < 5
|
||||
|
||||
|
||||
def shift_workdays(start: date, count: int) -> date:
|
||||
"""
|
||||
从 start 当天之后数 count 个工作日。
|
||||
|
||||
成交选协商中:顺延 3 个工作日后,第四个工作日再问,故 count=4。
|
||||
"""
|
||||
if count < 1:
|
||||
return start
|
||||
cur = start
|
||||
seen = 0
|
||||
while seen < count:
|
||||
cur = cur + timedelta(days=1)
|
||||
if is_workday(cur):
|
||||
seen += 1
|
||||
return cur
|
||||
|
||||
|
||||
def next_followup_at(now: datetime | None = None) -> datetime:
|
||||
"""第四个工作日 Asia/Shanghai 09:00。"""
|
||||
base = now.astimezone(SHANGHAI) if now else datetime.now(SHANGHAI)
|
||||
day = shift_workdays(base.date(), 4)
|
||||
return datetime.combine(day, time(9, 0), tzinfo=SHANGHAI)
|
||||
|
||||
|
||||
def _split_routes(raw: str) -> list[str]:
|
||||
out: list[str] = []
|
||||
for part in (raw or "").replace(",", ",").replace("、", ",").split(","):
|
||||
t = part.strip()
|
||||
if t:
|
||||
out.append(t)
|
||||
return out
|
||||
|
||||
|
||||
def air_route_keys(facts: dict[str, str] | None) -> set[str]:
|
||||
"""本单可对上的空运线路名:三字码对 + 目的港。"""
|
||||
src = dict(facts or {})
|
||||
origin = resolve_airport_code(str(src.get("起运港") or src.get("originCode") or ""))
|
||||
dest = resolve_airport_code(str(src.get("目的港") or src.get("destinationCode") or ""))
|
||||
keys: set[str] = set()
|
||||
if dest:
|
||||
keys.add(dest)
|
||||
if origin and dest:
|
||||
keys.add(f"{origin}-{dest}")
|
||||
return keys
|
||||
|
||||
|
||||
def _is_waicai(facts: dict[str, str] | None) -> bool:
|
||||
src = dict(facts or {})
|
||||
for key in ("外采", "是否外采", "采购方式"):
|
||||
val = str(src.get(key) or "").strip().lower()
|
||||
if val in {"1", "是", "true", "yes", "外采"}:
|
||||
return True
|
||||
blob = "".join(str(v) for v in src.values())
|
||||
return "外采" in blob
|
||||
|
||||
|
||||
def leader_matches(
|
||||
*,
|
||||
role_code: str,
|
||||
routes: str,
|
||||
is_leader: bool,
|
||||
business_line: str,
|
||||
facts: dict[str, str] | None,
|
||||
) -> bool:
|
||||
"""
|
||||
员工管理「是负责人 + 负责线路对得上」。
|
||||
|
||||
海运:海运全线。陆运:线路类别整词。空运:三字码对 / 目的港 / 全线;外采须工单标明。
|
||||
"""
|
||||
if not is_leader:
|
||||
return False
|
||||
line = (business_line or "").strip().upper()
|
||||
code = (role_code or "").strip().lower()
|
||||
tokens = _split_routes(routes)
|
||||
if line == "SEA":
|
||||
return code == "sea" and "海运全线" in tokens
|
||||
if line == "LAND":
|
||||
want = str((facts or {}).get("线路类别") or "").strip()
|
||||
return code == "land" and bool(want) and want in tokens
|
||||
if line == "AIR":
|
||||
if code != "air":
|
||||
return False
|
||||
if "全线" in tokens:
|
||||
return True
|
||||
if "外采-空运" in tokens and _is_waicai(facts):
|
||||
return True
|
||||
keys = air_route_keys(facts)
|
||||
return any(t in keys for t in tokens)
|
||||
return False
|
||||
|
||||
|
||||
def pick_leaders(
|
||||
staff: Iterable[dict[str, Any]],
|
||||
*,
|
||||
business_line: str,
|
||||
facts: dict[str, str] | None,
|
||||
) -> list[dict[str, str]]:
|
||||
"""从员工行筛出该推的负责人(启用且有企微 id)。"""
|
||||
out: list[dict[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
for row in staff:
|
||||
if str(row.get("status") or "active").lower() != "active":
|
||||
continue
|
||||
wecom = str(row.get("wecomId") or row.get("wecom_id") or "").strip()
|
||||
if not wecom or wecom in seen:
|
||||
continue
|
||||
leader_flag = row.get("isLeader")
|
||||
if leader_flag is None:
|
||||
leader_flag = row.get("is_leader")
|
||||
is_leader = leader_flag in {True, 1, "1", "true", "True"}
|
||||
if not leader_matches(
|
||||
role_code=str(row.get("roleCode") or row.get("role_code") or ""),
|
||||
routes=str(row.get("routes") or ""),
|
||||
is_leader=bool(is_leader),
|
||||
business_line=business_line,
|
||||
facts=facts,
|
||||
):
|
||||
continue
|
||||
seen.add(wecom)
|
||||
out.append(
|
||||
{
|
||||
"name": str(row.get("name") or "").strip(),
|
||||
"wecomId": wecom,
|
||||
"roleCode": str(row.get("roleCode") or row.get("role_code") or ""),
|
||||
}
|
||||
)
|
||||
return out
|
||||
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
成交三态落地:改主账六态、推负责人、收下未成交原因、排协商再问。
|
||||
|
||||
本文件职责:私聊/群聊共用。不在回调线程等 LLM。推送只入 outbox。
|
||||
禁止:把图成功当主账成功;成交失败还去推负责人;未成交推负责人。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from agent.config import get_settings
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.policy.deal_followup import next_followup_at, pick_leaders
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EnqueueFn = Callable[..., tuple[bool, str]]
|
||||
|
||||
|
||||
def notify_app_ready() -> bool:
|
||||
"""询价消息服务已配 AgentId+Secret 才推。空则只成交不推。"""
|
||||
settings = get_settings()
|
||||
return bool(
|
||||
str(getattr(settings, "wecom_notify_agent_id", "") or "").strip()
|
||||
and str(getattr(settings, "wecom_notify_secret", "") or "").strip()
|
||||
)
|
||||
|
||||
|
||||
def _role_of(business_line: str) -> str:
|
||||
line = (business_line or "").strip().upper()
|
||||
if line == "SEA":
|
||||
return "sea"
|
||||
if line == "LAND":
|
||||
return "land"
|
||||
return "air"
|
||||
|
||||
|
||||
def _enqueue_default(
|
||||
*,
|
||||
touser: str,
|
||||
content: str,
|
||||
dedupe_key: str,
|
||||
payload: dict[str, Any],
|
||||
) -> tuple[bool, str]:
|
||||
from agent.channel.queue import get_message_store
|
||||
|
||||
return get_message_store().enqueue_outbound(
|
||||
touser=touser,
|
||||
content=content,
|
||||
dedupe_key=dedupe_key,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
def finish_deal_outcome(
|
||||
*,
|
||||
ledger: Any,
|
||||
work_order_no: str,
|
||||
outcome: str,
|
||||
facts: dict[str, str] | None,
|
||||
quote: dict[str, Any] | None,
|
||||
business_line: str,
|
||||
sales_name: str = "",
|
||||
enqueue: Optional[EnqueueFn] = None,
|
||||
notify_ready: Optional[bool] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
先入主账,再按结果推人或排再问。
|
||||
|
||||
返回 ok / phase / notify_summary。phase:
|
||||
已成交 done;未成交 wait_lost_reason;协商中 wait_deal。
|
||||
"""
|
||||
no = (work_order_no or "").strip()
|
||||
status = (outcome or "").strip()
|
||||
extra: dict[str, Any] = {}
|
||||
summary = ""
|
||||
if status == "协商中":
|
||||
extra["next_followup_at"] = next_followup_at().strftime("%Y-%m-%d %H:%M:%S")
|
||||
if status == "已成交":
|
||||
staff: list[dict[str, Any]] = []
|
||||
lister = getattr(ledger, "list_staff_by_role", None)
|
||||
if callable(lister):
|
||||
try:
|
||||
staff = list(lister(role_code=_role_of(business_line)) or [])
|
||||
except Exception:
|
||||
logger.exception("成交匹配负责人失败 wo=%s", no)
|
||||
leaders = pick_leaders(staff, business_line=business_line, facts=facts)
|
||||
ready = notify_app_ready() if notify_ready is None else bool(notify_ready)
|
||||
if not leaders:
|
||||
summary = "无匹配负责人"
|
||||
elif not ready:
|
||||
summary = "通知未发出"
|
||||
else:
|
||||
names = [str(x.get("name") or x.get("wecomId") or "").strip() for x in leaders]
|
||||
summary = "已推:" + "、".join(n for n in names if n)
|
||||
sender = enqueue or _enqueue_default
|
||||
payload = copy.deal_notify_wecom_payload(
|
||||
work_order_no=no,
|
||||
sales_name=sales_name,
|
||||
facts=facts or {},
|
||||
quote=quote or {},
|
||||
business_line=business_line,
|
||||
)
|
||||
text = copy.deal_notify_card(
|
||||
work_order_no=no,
|
||||
sales_name=sales_name,
|
||||
facts=facts or {},
|
||||
quote=quote or {},
|
||||
business_line=business_line,
|
||||
)
|
||||
for row in leaders:
|
||||
uid = str(row.get("wecomId") or "").strip()
|
||||
if not uid:
|
||||
continue
|
||||
try:
|
||||
ok, info = sender(
|
||||
touser=uid,
|
||||
content=text,
|
||||
dedupe_key=f"deal-notify:{no}:{uid}",
|
||||
payload=dict(payload),
|
||||
)
|
||||
if not ok:
|
||||
logger.warning("成交通知入队失败 wo=%s uid=%s info=%s", no, uid, info)
|
||||
except Exception:
|
||||
logger.exception("成交通知入队异常 wo=%s uid=%s", no, uid)
|
||||
extra["deal_notify_summary"] = summary
|
||||
extra["remark"] = summary
|
||||
writer = getattr(ledger, "transition", None)
|
||||
if not callable(writer):
|
||||
return {"ok": False, "error": "no_transition", "phase": "ledger_fail"}
|
||||
result = writer(work_order_no=no, to_status=status, **extra) or {}
|
||||
if not result.get("ok"):
|
||||
return {"ok": False, "error": result.get("error") or "transition_failed", "phase": "ledger_fail"}
|
||||
if status == "未成交":
|
||||
phase = "wait_lost_reason"
|
||||
elif status == "协商中":
|
||||
phase = "wait_deal"
|
||||
else:
|
||||
phase = "done"
|
||||
return {
|
||||
"ok": True,
|
||||
"phase": phase,
|
||||
"notify_summary": summary,
|
||||
"status": status,
|
||||
"next_followup_at": str(extra.get("next_followup_at") or ""),
|
||||
}
|
||||
|
||||
|
||||
def capture_lost_reason(*, ledger: Any, work_order_no: str, reason: str) -> dict[str, Any]:
|
||||
"""把销售下一句写进未成交原因。"""
|
||||
text = (reason or "").strip()
|
||||
no = (work_order_no or "").strip()
|
||||
if not no or not text:
|
||||
return {"ok": False, "error": "blank"}
|
||||
patcher = getattr(ledger, "patch_lost_reason", None)
|
||||
if not callable(patcher):
|
||||
return {"ok": False, "error": "no_patch"}
|
||||
return patcher(work_order_no=no, lost_reason=text) or {"ok": False}
|
||||
@@ -1732,12 +1732,104 @@ 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:
|
||||
def deal_ack(outcome: str, work_order_no: str, next_followup_at: str = "") -> str:
|
||||
"""成交三态回执。协商中写明顺延三个工作日和下次再问的月日 9:00。"""
|
||||
if outcome == "已成交":
|
||||
return f"已记录为「已成交」。工单 {work_order_no} 已关闭智能体跟进。"
|
||||
if outcome == "未成交":
|
||||
return "已记录为「未成交」。请补充未成交原因。"
|
||||
return f"已记录为「协商中」。工单 {work_order_no} 跟进周期已顺延。"
|
||||
when = _followup_month_day(next_followup_at)
|
||||
if when:
|
||||
return f"已记录为「协商中」。已顺延三个工作日,{when}的9:00再次发起成交状态询问。"
|
||||
return "已记录为「协商中」。已顺延三个工作日,第四个工作日早上9:00再次发起成交状态询问。"
|
||||
|
||||
|
||||
def _followup_month_day(raw: str) -> str:
|
||||
"""把主账再问时刻收成「9月24日」。"""
|
||||
from datetime import datetime
|
||||
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
for fmt, n in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d", 10)):
|
||||
try:
|
||||
dt = datetime.strptime(text[:n], fmt)
|
||||
return f"{dt.month}月{dt.day}日"
|
||||
except ValueError:
|
||||
continue
|
||||
return ""
|
||||
|
||||
|
||||
def lost_reason_ack(reason: str = "") -> str:
|
||||
"""收下未成交原因后回显原句,不再追问。"""
|
||||
text = (reason or "").strip()
|
||||
if text.endswith(("。", ".", "!", "!", "?", "?")):
|
||||
return f"已记下未成交原因:{text}"
|
||||
return f"已记下未成交原因:{text}。" if text else "已记下未成交原因。"
|
||||
|
||||
|
||||
def deal_notify_card(
|
||||
*,
|
||||
work_order_no: str,
|
||||
sales_name: str,
|
||||
facts: dict[str, str] | None,
|
||||
quote: dict[str, Any] | None,
|
||||
business_line: str,
|
||||
) -> str:
|
||||
"""成交通知文字兜底:方案卡压缩。不写销售,避免标题出现「销售 销售」。"""
|
||||
no = (work_order_no or "").strip()
|
||||
_ = sales_name
|
||||
mode = scheme_transport_mode(business_line)
|
||||
rows = scheme_horizontal_rows(
|
||||
work_order_no=no,
|
||||
facts=dict(facts or {}),
|
||||
quote=dict(quote or {}),
|
||||
transport_mode=mode,
|
||||
)
|
||||
lines = [f"成交通知 工单 {no}"]
|
||||
lines.extend(f"{row['keyname']}:{row['value']}" for row in rows)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def deal_notify_wecom_payload(
|
||||
*,
|
||||
work_order_no: str,
|
||||
sales_name: str,
|
||||
facts: dict[str, str] | None,
|
||||
quote: dict[str, Any] | None,
|
||||
business_line: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
成交通知卡:走询价消息服务,不走询价小助手。
|
||||
|
||||
字段按报价方案卡压缩,避免超过 6 格。标题不写销售。
|
||||
"""
|
||||
no = (work_order_no or "").strip()
|
||||
_ = sales_name
|
||||
mode = scheme_transport_mode(business_line)
|
||||
rows = scheme_horizontal_rows(
|
||||
work_order_no=no,
|
||||
facts=dict(facts or {}),
|
||||
quote=dict(quote or {}),
|
||||
transport_mode=mode,
|
||||
)
|
||||
from agent.config import get_settings
|
||||
|
||||
base = str(getattr(get_settings(), "public_base_url", "") or "").strip().rstrip("/")
|
||||
action_url = base or "https://work.weixin.qq.com"
|
||||
return {
|
||||
"msgtype": "template_card",
|
||||
"wecom_app": "notify",
|
||||
"template_card": {
|
||||
"card_type": "text_notice",
|
||||
"source": {"desc": "成交通知", "desc_color": 0},
|
||||
"main_title": {
|
||||
"title": f"成交通知 工单 {no}",
|
||||
},
|
||||
"horizontal_content_list": rows,
|
||||
"card_action": {"type": 1, "url": action_url},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def air_need_work_order() -> str:
|
||||
|
||||
@@ -91,6 +91,13 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
|
||||
reply=reply,
|
||||
sess=sess,
|
||||
)
|
||||
# 未成交后下一句任意文字即原因。不能走下面的 new_over_old,否则会当新询价丢掉。
|
||||
if (
|
||||
sess
|
||||
and sess.phase == "wait_lost_reason"
|
||||
and not (injected_facts and injected_mode)
|
||||
):
|
||||
return self._capture_lost_reason(sess, text, reply)
|
||||
if sess and phase not in _LAND_FRONT:
|
||||
# 查价后半段书签还在时,销售又发新陆运:只走陆运前半段。
|
||||
# 禁止再 super 去海运 on_text,海运会把 LAND 转回来,递归到死、一句都不回。
|
||||
|
||||
@@ -64,6 +64,7 @@ def ticket_view(ticket: object) -> dict[str, Any]:
|
||||
view.get("productNames") or view.get("product_names") or [],
|
||||
)
|
||||
view.setdefault("sales_name", view.get("salesName") or view.get("sales_name") or "")
|
||||
view.setdefault("lost_reason", view.get("lostReason") or view.get("lost_reason") or "")
|
||||
facts = dict(view.get("collab_facts") or view.get("collabFacts") or {})
|
||||
view.setdefault("collab_facts", facts)
|
||||
flag = str(facts.get("__ended") or view.get("collab_ended") or view.get("collabEnded") or "").strip().lower()
|
||||
@@ -89,6 +90,7 @@ def ticket_view(ticket: object) -> dict[str, Any]:
|
||||
"product_ids": list(getattr(ticket, "product_ids", []) or []),
|
||||
"product_names": list(getattr(ticket, "product_names", []) or []),
|
||||
"sales_name": str(getattr(ticket, "sales_name", "") or ""),
|
||||
"lost_reason": str(getattr(ticket, "lost_reason", "") or ""),
|
||||
}
|
||||
|
||||
|
||||
@@ -987,18 +989,75 @@ def apply_group_deal(
|
||||
if not (quote.get("total") or _quote_fee_rows(quote) or status_now in {"已报价", "协商中"}):
|
||||
send_group_text(client, chat_id, copy.group_deal_need_quote())
|
||||
return "deal_need_quote"
|
||||
writer = getattr(flow.ledger, "transition", None)
|
||||
if callable(writer):
|
||||
result = writer(work_order_no=no, to_status=outcome) or {}
|
||||
if not result.get("ok"):
|
||||
send_group_text(client, chat_id, "主账状态更新失败,成交结果未入账。")
|
||||
return "ledger_fail"
|
||||
from agent.policy.deal_outcome import finish_deal_outcome
|
||||
|
||||
result = finish_deal_outcome(
|
||||
ledger=flow.ledger,
|
||||
work_order_no=no,
|
||||
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"),
|
||||
sales_name=str(view.get("sales_name") or (getattr(sess, "invited_sales_name", "") if sess else "") or ""),
|
||||
)
|
||||
if not result.get("ok"):
|
||||
send_group_text(client, chat_id, "主账状态更新失败,成交结果未入账。")
|
||||
return "ledger_fail"
|
||||
phase = str(result.get("phase") or "done")
|
||||
if sess is not None:
|
||||
sess.status = outcome
|
||||
sess.phase = phase
|
||||
if phase == "wait_deal":
|
||||
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
|
||||
else:
|
||||
sess.allowed = ()
|
||||
flow._save(sess)
|
||||
send_group_text(client, chat_id, copy.deal_ack(outcome, no, str(result.get("next_followup_at") or "")))
|
||||
return phase
|
||||
|
||||
|
||||
def facts_of_group(view: dict[str, Any], sess: Any = None) -> dict[str, str]:
|
||||
"""主账字段打底,书签覆盖;给负责人匹配和成交通知卡用。"""
|
||||
facts = dict(view.get("facts") or {})
|
||||
if sess is not None and getattr(sess, "facts", None):
|
||||
facts.update(dict(sess.facts or {}))
|
||||
return facts
|
||||
|
||||
|
||||
def capture_group_lost_reason(
|
||||
*,
|
||||
flow: Any,
|
||||
ticket: object,
|
||||
sender_id: str,
|
||||
text: str,
|
||||
sess: Any = None,
|
||||
) -> str:
|
||||
"""
|
||||
群里未成交后,本单销售下一句任意文字即原因。
|
||||
空运群是否要 @ 由入站通道先拦,这里不再判。
|
||||
"""
|
||||
ticket = hydrate_ticket(flow, ticket)
|
||||
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 "")
|
||||
client = group_client_of(flow)
|
||||
if ticket_status(ticket) != "未成交":
|
||||
return ""
|
||||
if str(view.get("lost_reason") or "").strip():
|
||||
return ""
|
||||
if not is_sales(ticket, sender_id):
|
||||
return ""
|
||||
from agent.policy.deal_outcome import capture_lost_reason
|
||||
|
||||
result = capture_lost_reason(ledger=flow.ledger, work_order_no=no, reason=text)
|
||||
if not result.get("ok"):
|
||||
send_group_text(client, chat_id, "未成交原因没有记下,请再发一句。")
|
||||
return "wait_lost_reason"
|
||||
if sess is not None:
|
||||
sess.phase = "done"
|
||||
sess.allowed = ()
|
||||
flow._save(sess)
|
||||
send_group_text(client, chat_id, copy.deal_ack(outcome, no))
|
||||
send_group_text(client, chat_id, copy.lost_reason_ack(text))
|
||||
return "done"
|
||||
|
||||
|
||||
|
||||
@@ -85,6 +85,13 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
reply=reply,
|
||||
card_meta=inbound_raw,
|
||||
)
|
||||
# 未成交后下一句任意文字即原因,不能先抽字段当成新询价。
|
||||
if (
|
||||
sess
|
||||
and sess.phase == "wait_lost_reason"
|
||||
and not (injected_facts and injected_mode)
|
||||
):
|
||||
return self._capture_lost_reason(sess, text, reply)
|
||||
|
||||
snap = extract_inquiry_snapshot(
|
||||
text,
|
||||
|
||||
Reference in New Issue
Block a user