补齐成交跟进:按线路推成交通知、收下未成交原因、协商中按工作日再问。
成交走询价消息服务;陆运/海运私聊下一句能记下原因;协商中回执写明顺延三个工作日和下次 9:00。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -22,6 +22,9 @@ WECOM_CALLBACK_TOKEN=
|
||||
WECOM_ENCODING_AES_KEY=
|
||||
WECOM_CALLBACK_REPLY_TEXT=询价智能体接通成功。已收到:{content}
|
||||
WECOM_OPS_AGENT_ID=1000014
|
||||
# 现网「询价消息服务」= 运维应用 1000014(与 WECOM_OPERATIONS_* 同一套)。空则只成交不推。
|
||||
WECOM_NOTIFY_AGENT_ID=1000014
|
||||
WECOM_NOTIFY_SECRET=
|
||||
# 会话存档席:后台员工管理里「询价机器人」的企微 userid,每个协同群必拉。可逗号分隔多个。
|
||||
WECOM_ARCHIVE_SEAT_USER_IDS=
|
||||
# 微盛会话存档(Worker 按存档席拉群消息)。游标只写 REDIS_KEY_PREFIX,禁止推现网官方 seq。
|
||||
|
||||
@@ -43,6 +43,15 @@ class ChannelRuntime:
|
||||
secret=settings.wecom_secret,
|
||||
agent_id=agent_id,
|
||||
)
|
||||
notify_id = int(getattr(settings, "wecom_notify_agent_id", "") or "0") or 0
|
||||
notify_secret = str(getattr(settings, "wecom_notify_secret", "") or "").strip()
|
||||
self._notify_client = None
|
||||
if notify_id and notify_secret:
|
||||
self._notify_client = WeComAppClient(
|
||||
corp_id=settings.wecom_corp_id,
|
||||
secret=notify_secret,
|
||||
agent_id=notify_id,
|
||||
)
|
||||
|
||||
@property
|
||||
def store(self):
|
||||
@@ -105,10 +114,20 @@ class ChannelRuntime:
|
||||
logger.warning("outbox 过期丢弃 id=%s", item.id)
|
||||
self._store.complete_outbound(item.id)
|
||||
continue
|
||||
result = self._client.send_outbound(
|
||||
payload = getattr(item, "payload", None) or {}
|
||||
client = self._client
|
||||
if payload.get("wecom_app") == "notify":
|
||||
if self._notify_client is None:
|
||||
logger.error("询价消息服务未配置,成交通知丢弃 id=%s", item.id)
|
||||
self._store.complete_outbound(
|
||||
item.id, error="notify_app_missing", permanent=True
|
||||
)
|
||||
continue
|
||||
client = self._notify_client
|
||||
result = client.send_outbound(
|
||||
touser=item.touser,
|
||||
content=item.content,
|
||||
payload=getattr(item, "payload", None) or {},
|
||||
payload=payload,
|
||||
)
|
||||
if result.ok:
|
||||
self._store.complete_outbound(item.id)
|
||||
|
||||
@@ -60,6 +60,9 @@ class Settings(BaseSettings):
|
||||
wecom_corp_id: str = Field(default="", alias="WECOM_CORP_ID")
|
||||
wecom_agent_id: str = Field(default="1000010", alias="WECOM_AGENT_ID")
|
||||
wecom_secret: str = Field(default="", alias="WECOM_SECRET")
|
||||
# 现网「询价消息服务」:只推成交通知,不走询价小助手 1000010。
|
||||
wecom_notify_agent_id: str = Field(default="", alias="WECOM_NOTIFY_AGENT_ID")
|
||||
wecom_notify_secret: str = Field(default="", alias="WECOM_NOTIFY_SECRET")
|
||||
wecom_archive_seat_user_ids: str = Field(
|
||||
default="",
|
||||
alias="WECOM_ARCHIVE_SEAT_USER_IDS",
|
||||
|
||||
@@ -27,7 +27,12 @@ from agent.policy.air_group_ops import (
|
||||
resolve_cabin_ticket,
|
||||
)
|
||||
from agent.policy.air_text_flow import get_air_text_flow
|
||||
from agent.policy.sea_group_ops import group_client_of, send_group_text, ticket_view
|
||||
from agent.policy.sea_group_ops import (
|
||||
capture_group_lost_reason,
|
||||
group_client_of,
|
||||
send_group_text,
|
||||
ticket_view,
|
||||
)
|
||||
from agent.routing.air_group_intent import (
|
||||
INTENT_ACTIVATE,
|
||||
INTENT_CONFIRM_TMS,
|
||||
@@ -233,6 +238,14 @@ def _handle_bound(
|
||||
injected_quote: Optional[dict[str, Any]],
|
||||
) -> str:
|
||||
chat_id = message.chat_id
|
||||
lost = capture_group_lost_reason(
|
||||
flow=engine,
|
||||
ticket=ticket,
|
||||
sender_id=message.sender_id,
|
||||
text=message.content or "",
|
||||
)
|
||||
if lost:
|
||||
return lost
|
||||
facts = dict(ticket_view(ticket).get("collab_facts") or {})
|
||||
if intent == INTENT_PICK_OPTION or (
|
||||
intent == INTENT_OTHER and str(facts.get("__pending_air_lock") or "") == "1"
|
||||
|
||||
@@ -17,6 +17,7 @@ from agent.policy.sea_group_ops import (
|
||||
apply_group_deal,
|
||||
apply_product_quote,
|
||||
apply_tms_quote_choice,
|
||||
capture_group_lost_reason,
|
||||
dissolve_group,
|
||||
group_client_of,
|
||||
handle_group_file,
|
||||
@@ -138,6 +139,16 @@ def handle_group_inbound(
|
||||
classify_group_text,
|
||||
)
|
||||
|
||||
lost = capture_group_lost_reason(
|
||||
flow=engine,
|
||||
ticket=ticket,
|
||||
sender_id=message.sender_id,
|
||||
text=message.content or "",
|
||||
sess=sess,
|
||||
)
|
||||
if lost:
|
||||
return lost
|
||||
|
||||
intent = classify_group_text(message.content or "", injected=injected_intent)
|
||||
logger.info(
|
||||
"group_inbound 文字 chat=%s sender=%s intent=%s chars=%s",
|
||||
|
||||
@@ -198,6 +198,8 @@ def handle_text_other(message: InboundMessage, *, store=None) -> str:
|
||||
sess = engine.session_for_action(
|
||||
message.sender_id, message.raw, text=message.content or ""
|
||||
)
|
||||
if sess and sess.phase == "wait_lost_reason":
|
||||
return handle_text_inquiry(message, store=msg_store)
|
||||
btn = engine.match_button(message.content or "", sess)
|
||||
if btn:
|
||||
return handle_text_inquiry(message, store=msg_store)
|
||||
|
||||
@@ -111,6 +111,7 @@ class WorkerRuntime:
|
||||
threading.Thread(target=self._recognition_loop, name="recog-loop", daemon=True),
|
||||
threading.Thread(target=self._archive_loop, name="archive-loop", daemon=True),
|
||||
threading.Thread(target=self._wshoto_poll_loop, name="wshoto-poll", daemon=True),
|
||||
threading.Thread(target=self._deal_followup_loop, name="deal-followup", daemon=True),
|
||||
threading.Thread(target=self._pdf_loop, name="pdf-loop", daemon=True),
|
||||
]
|
||||
for t in self._threads:
|
||||
@@ -273,6 +274,22 @@ class WorkerRuntime:
|
||||
wait = 1.0 if has_hot_room() else interval
|
||||
wait_wshoto_poll(wait)
|
||||
|
||||
def _deal_followup_loop(self) -> None:
|
||||
"""
|
||||
HTTP 槽轮询协商再问。独立线程,不占回调、不与 soffice 串队。
|
||||
"""
|
||||
from agent.jobs.deal_followup_poll import poll_due_negotiate
|
||||
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
self.http_jobs.run(
|
||||
lambda: poll_due_negotiate(settings=self.settings),
|
||||
label="deal-followup",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("协商再问轮询失败")
|
||||
self._stop.wait(60.0)
|
||||
|
||||
def _pdf_loop(self) -> None:
|
||||
"""仅 LibreOffice 单槽消费 PDF Stream。"""
|
||||
if not self.redis:
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
协商中到期再问:Worker HTTP 槽轮询,回到当初那个会话。
|
||||
|
||||
本文件职责:认领主账到期单、发成交跟进。不在回调线程跑。
|
||||
禁止:群解散后改发私聊;占 LibreOffice 槽;把六态写进 Redis。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.config import Settings, get_settings
|
||||
from agent.policy import inquiry_copy as copy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def poll_due_negotiate(*, ledger: Any = None, settings: Optional[Settings] = None) -> int:
|
||||
"""
|
||||
认领到期协商中工单并再问一次。
|
||||
|
||||
返回发出条数。主账已清空 next_followup_at,本轮失败也不再自动补发。
|
||||
"""
|
||||
cfg = settings or get_settings()
|
||||
book = ledger
|
||||
if book is None:
|
||||
if str(getattr(cfg, "ledger_backend", "http") or "http").lower() == "memory":
|
||||
return 0
|
||||
from agent.ledger.http_ledger import HttpLedger
|
||||
|
||||
book = HttpLedger()
|
||||
claimer = getattr(book, "claim_due_negotiate", None)
|
||||
if not callable(claimer):
|
||||
return 0
|
||||
rows = claimer() or []
|
||||
sent = 0
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
try:
|
||||
if _reask_one(row, settings=cfg):
|
||||
sent += 1
|
||||
except Exception:
|
||||
logger.exception("协商再问失败 wo=%s", row.get("work_order_no") or row.get("workOrderNo"))
|
||||
return sent
|
||||
|
||||
|
||||
def _reask_one(row: dict[str, Any], *, settings: Settings) -> bool:
|
||||
no = str(row.get("work_order_no") or row.get("workOrderNo") or "").strip()
|
||||
if not no:
|
||||
return False
|
||||
chat_id = str(row.get("collab_chat_id") or row.get("collabChatId") or "").strip()
|
||||
sales = str(row.get("sales_wecom_id") or row.get("salesWecomId") or "").strip()
|
||||
name = str(row.get("sales_name") or row.get("salesName") or "").strip() or "销售"
|
||||
facts = dict(row.get("facts") or {})
|
||||
quote = dict(row.get("quote") or {})
|
||||
line = str(row.get("business_line") or row.get("businessLine") or "").strip().upper()
|
||||
if chat_id:
|
||||
ok = _send_group_reask(
|
||||
chat_id=chat_id,
|
||||
work_order_no=no,
|
||||
sales_name=name,
|
||||
facts=facts,
|
||||
quote=quote,
|
||||
business_line=line,
|
||||
settings=settings,
|
||||
)
|
||||
if not ok:
|
||||
logger.warning("协商再问原群失败且不补发私聊 wo=%s chat=%s", no, chat_id)
|
||||
return ok
|
||||
if not sales:
|
||||
logger.warning("协商再问无销售 userid wo=%s", no)
|
||||
return False
|
||||
from agent.channel.queue import get_message_store
|
||||
|
||||
text = copy.deal_card(work_order_no=no, quote=quote, facts=facts)
|
||||
payload = copy.deal_wecom_payload(work_order_no=no, quote=quote, facts=facts)
|
||||
ok, info = get_message_store().enqueue_outbound(
|
||||
touser=sales,
|
||||
content=text,
|
||||
dedupe_key=f"deal-reask:{no}",
|
||||
payload=payload,
|
||||
)
|
||||
if not ok:
|
||||
logger.warning("协商再问私聊入队失败 wo=%s info=%s", no, info)
|
||||
return bool(ok)
|
||||
|
||||
|
||||
def _send_group_reask(
|
||||
*,
|
||||
chat_id: str,
|
||||
work_order_no: str,
|
||||
sales_name: str,
|
||||
facts: dict[str, str],
|
||||
quote: dict[str, Any],
|
||||
business_line: str,
|
||||
settings: Settings,
|
||||
) -> bool:
|
||||
text = copy.group_deal_followup(sales_name=sales_name, work_order_no=work_order_no)
|
||||
if business_line == "AIR":
|
||||
from agent.channel.aibot.reply import AibotReplyClient
|
||||
|
||||
out = AibotReplyClient().send_group(chat_id=chat_id, content=text)
|
||||
return bool(out.get("ok"))
|
||||
from agent.channel.outbox.sender import WeComAppClient
|
||||
|
||||
client = WeComAppClient(
|
||||
corp_id=settings.wecom_corp_id,
|
||||
secret=settings.wecom_secret,
|
||||
agent_id=int(settings.wecom_agent_id or "0") or 0,
|
||||
)
|
||||
out = client.send_group(chat_id=chat_id, content=text)
|
||||
if not out.get("ok"):
|
||||
return False
|
||||
carder = getattr(client, "send_group_card", None)
|
||||
if callable(carder):
|
||||
payload = copy.deal_wecom_payload(work_order_no=work_order_no, quote=quote, facts=facts)
|
||||
carder(chat_id=chat_id, template_card=payload.get("template_card") or {})
|
||||
return True
|
||||
@@ -193,15 +193,46 @@ class HttpLedger:
|
||||
out.setdefault("needsPdf", str(format).lower() == "pdf")
|
||||
return out
|
||||
|
||||
def transition(self, *, work_order_no: str, to_status: str) -> dict[str, Any]:
|
||||
out = self._post(
|
||||
"/inquiry/agent/ticket/transition",
|
||||
{"workOrderNo": work_order_no, "toStatus": to_status},
|
||||
)
|
||||
def transition(
|
||||
self,
|
||||
*,
|
||||
work_order_no: str,
|
||||
to_status: str,
|
||||
remark: str = "",
|
||||
lost_reason: str = "",
|
||||
next_followup_at: str = "",
|
||||
deal_notify_summary: str = "",
|
||||
) -> dict[str, Any]:
|
||||
body: dict[str, Any] = {"workOrderNo": work_order_no, "toStatus": to_status}
|
||||
if remark:
|
||||
body["remark"] = remark
|
||||
if lost_reason:
|
||||
body["lostReason"] = lost_reason
|
||||
if next_followup_at:
|
||||
body["nextFollowupAt"] = next_followup_at
|
||||
if deal_notify_summary:
|
||||
body["dealNotifySummary"] = deal_notify_summary
|
||||
out = self._post("/inquiry/agent/ticket/transition", body)
|
||||
if not out.get("ok") and out.get("status"):
|
||||
out["ok"] = True
|
||||
return out if out.get("ok") else {"ok": False, "error": out.get("error") or "transition_failed"}
|
||||
|
||||
def patch_lost_reason(self, *, work_order_no: str, lost_reason: str) -> dict[str, Any]:
|
||||
"""未成交后记下原因。不改六态。"""
|
||||
out = self._post(
|
||||
"/inquiry/agent/ticket/patchLostReason",
|
||||
{"workOrderNo": work_order_no, "lostReason": lost_reason},
|
||||
)
|
||||
return out if out.get("ok") else {"ok": False, "error": out.get("error") or "patch_lost_reason_failed"}
|
||||
|
||||
def claim_due_negotiate(self) -> list[dict[str, Any]]:
|
||||
"""认领到期协商再问,主账会清空再问时刻防重发。"""
|
||||
out = self._post("/inquiry/agent/ticket/claimDueNegotiate", {})
|
||||
rows = out.get("tickets") or out.get("result") or []
|
||||
if isinstance(rows, list):
|
||||
return [dict(x) for x in rows if isinstance(x, dict)]
|
||||
return []
|
||||
|
||||
def match_sea_staff(self, *, origin: str, destination: str) -> list[dict[str, Any]]:
|
||||
"""主账按线路返回启用的海运产品岗。"""
|
||||
out = self._post(
|
||||
|
||||
@@ -41,6 +41,9 @@ class MemoryTicket:
|
||||
collab_ended: bool = False
|
||||
attachments: list[dict[str, Any]] = field(default_factory=list)
|
||||
quote_file_ready: bool = False
|
||||
lost_reason: str = ""
|
||||
next_followup_at: str = ""
|
||||
deal_notify_summary: str = ""
|
||||
|
||||
|
||||
class MemoryLedger:
|
||||
@@ -207,14 +210,70 @@ class MemoryLedger:
|
||||
"status": ticket.status,
|
||||
}
|
||||
|
||||
def transition(self, *, work_order_no: str, to_status: str) -> dict[str, Any]:
|
||||
def transition(
|
||||
self,
|
||||
*,
|
||||
work_order_no: str,
|
||||
to_status: str,
|
||||
remark: str = "",
|
||||
lost_reason: str = "",
|
||||
next_followup_at: str = "",
|
||||
deal_notify_summary: str = "",
|
||||
) -> dict[str, Any]:
|
||||
_ = remark
|
||||
with self._lock:
|
||||
ticket = self._tickets.get(work_order_no)
|
||||
if ticket is None:
|
||||
return {"ok": False, "error": "ticket_not_found"}
|
||||
ticket.status = to_status
|
||||
if to_status == "未成交":
|
||||
ticket.next_followup_at = ""
|
||||
if lost_reason:
|
||||
ticket.lost_reason = lost_reason
|
||||
elif to_status == "已成交":
|
||||
ticket.next_followup_at = ""
|
||||
if deal_notify_summary:
|
||||
ticket.deal_notify_summary = deal_notify_summary
|
||||
elif to_status == "协商中":
|
||||
ticket.next_followup_at = next_followup_at
|
||||
return {"ok": True, "status": ticket.status}
|
||||
|
||||
def patch_lost_reason(self, *, work_order_no: str, lost_reason: str) -> dict[str, Any]:
|
||||
reason = (lost_reason or "").strip()
|
||||
with self._lock:
|
||||
ticket = self._tickets.get(work_order_no)
|
||||
if ticket is None:
|
||||
return {"ok": False, "error": "ticket_not_found"}
|
||||
if ticket.status != "未成交":
|
||||
return {"ok": False, "error": "not_lost", "status": ticket.status}
|
||||
if not reason:
|
||||
return {"ok": False, "error": "reason_blank"}
|
||||
ticket.lost_reason = reason
|
||||
return {"ok": True, "work_order_no": work_order_no, "lost_reason": reason}
|
||||
|
||||
def claim_due_negotiate(self) -> list[dict[str, Any]]:
|
||||
now = datetime.now(_SHANGHAI).strftime("%Y-%m-%d %H:%M:%S")
|
||||
hits: list[dict[str, Any]] = []
|
||||
with self._lock:
|
||||
for ticket in self._tickets.values():
|
||||
due = (ticket.next_followup_at or "").strip()
|
||||
if ticket.status != "协商中" or not due or due > now:
|
||||
continue
|
||||
ticket.next_followup_at = ""
|
||||
hits.append(
|
||||
{
|
||||
"work_order_no": ticket.work_order_no,
|
||||
"sales_wecom_id": ticket.sales_wecom_id,
|
||||
"sales_name": "",
|
||||
"business_line": ticket.business_line,
|
||||
"collab_chat_id": ticket.collab_chat_id,
|
||||
"status": ticket.status,
|
||||
"facts": dict(ticket.facts),
|
||||
"quote": dict(ticket.quote),
|
||||
}
|
||||
)
|
||||
return hits
|
||||
|
||||
def get_ticket(self, *, work_order_no: str) -> MemoryTicket | None:
|
||||
with self._lock:
|
||||
return self._tickets.get(work_order_no)
|
||||
@@ -282,8 +341,12 @@ class MemoryLedger:
|
||||
status = ticket.status
|
||||
quote = dict(ticket.quote or {})
|
||||
wait_phase = "done"
|
||||
if status in {"已成交", "未成交", "协商中", "已关闭"}:
|
||||
if status in {"已成交", "已关闭"}:
|
||||
wait_phase = "done"
|
||||
elif status == "未成交":
|
||||
wait_phase = "wait_lost_reason" if not (ticket.lost_reason or "").strip() else "done"
|
||||
elif status == "协商中":
|
||||
wait_phase = "wait_deal"
|
||||
elif status == "已报价" and quote:
|
||||
# 文件已发给销售 → 成交跟进;否则海运协同卡、空运方案卡
|
||||
if ticket.quote_file_ready or quote.get("quoteFileIssued"):
|
||||
@@ -306,6 +369,10 @@ class MemoryLedger:
|
||||
"wait_phase": wait_phase,
|
||||
"collab_chat_id": ticket.collab_chat_id,
|
||||
"collab_facts": dict(ticket.collab_facts),
|
||||
"lost_reason": ticket.lost_reason,
|
||||
"lostReason": ticket.lost_reason,
|
||||
"deal_notify_summary": ticket.deal_notify_summary,
|
||||
"next_followup_at": ticket.next_followup_at,
|
||||
}
|
||||
|
||||
def upsert_staff(self, row: dict[str, Any]) -> None:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
成交跟进:工作日顺延、线路对负责人、未成交收下原因(不打外网)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import date, datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from agent.policy.deal_followup import (
|
||||
SHANGHAI,
|
||||
air_route_keys,
|
||||
leader_matches,
|
||||
next_followup_at,
|
||||
shift_workdays,
|
||||
)
|
||||
|
||||
|
||||
class TestWorkdays(unittest.TestCase):
|
||||
def test_skip_weekend(self) -> None:
|
||||
# 2026-09-18 周五,顺延 3 个工作日 → 21、22、23,第四个工作日 24
|
||||
self.assertEqual(shift_workdays(date(2026, 9, 18), 3), date(2026, 9, 23))
|
||||
self.assertEqual(shift_workdays(date(2026, 9, 18), 4), date(2026, 9, 24))
|
||||
|
||||
def test_skip_national_day(self) -> None:
|
||||
# 2026-09-30 周三,国庆休 → 第四个工作日落到节后
|
||||
fourth = shift_workdays(date(2026, 9, 30), 4)
|
||||
self.assertGreaterEqual(fourth, date(2026, 10, 9))
|
||||
self.assertNotEqual(fourth.weekday(), 5)
|
||||
self.assertNotEqual(fourth.weekday(), 6)
|
||||
|
||||
def test_next_followup_is_9am(self) -> None:
|
||||
when = next_followup_at(datetime(2026, 9, 18, 15, 30, tzinfo=SHANGHAI))
|
||||
self.assertEqual(when.hour, 9)
|
||||
self.assertEqual(when.minute, 0)
|
||||
self.assertEqual(when.tzinfo, SHANGHAI)
|
||||
self.assertEqual(when.date(), date(2026, 9, 24))
|
||||
|
||||
|
||||
class TestAirRouteKeys(unittest.TestCase):
|
||||
def test_pair_and_dest(self) -> None:
|
||||
keys = air_route_keys({"起运港": "SZX", "目的港": "KUL"})
|
||||
self.assertIn("SZX-KUL", keys)
|
||||
self.assertIn("KUL", keys)
|
||||
|
||||
def test_chinese_city_projects_iata(self) -> None:
|
||||
keys = air_route_keys({"起运港": "深圳", "目的港": "吉隆坡"})
|
||||
self.assertIn("SZX-KUL", keys)
|
||||
self.assertIn("KUL", keys)
|
||||
|
||||
|
||||
class TestLeaderMatch(unittest.TestCase):
|
||||
def test_sea_all_line(self) -> None:
|
||||
self.assertTrue(
|
||||
leader_matches(
|
||||
role_code="sea",
|
||||
routes="海运全线",
|
||||
is_leader=True,
|
||||
business_line="SEA",
|
||||
facts={},
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
leader_matches(
|
||||
role_code="sea",
|
||||
routes="海运全线",
|
||||
is_leader=False,
|
||||
business_line="SEA",
|
||||
facts={},
|
||||
)
|
||||
)
|
||||
|
||||
def test_land_route_category(self) -> None:
|
||||
self.assertTrue(
|
||||
leader_matches(
|
||||
role_code="land",
|
||||
routes="国内长途/零担,东南亚",
|
||||
is_leader=True,
|
||||
business_line="LAND",
|
||||
facts={"线路类别": "国内长途/零担"},
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
leader_matches(
|
||||
role_code="land",
|
||||
routes="东南亚",
|
||||
is_leader=True,
|
||||
business_line="LAND",
|
||||
facts={"线路类别": "国内长途/零担"},
|
||||
)
|
||||
)
|
||||
|
||||
def test_air_pair_dest_and_all(self) -> None:
|
||||
facts = {"起运港": "SZX", "目的港": "KUL"}
|
||||
self.assertTrue(
|
||||
leader_matches(
|
||||
role_code="air",
|
||||
routes="SZX-KUL",
|
||||
is_leader=True,
|
||||
business_line="AIR",
|
||||
facts=facts,
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
leader_matches(
|
||||
role_code="air",
|
||||
routes="KUL",
|
||||
is_leader=True,
|
||||
business_line="AIR",
|
||||
facts=facts,
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
leader_matches(
|
||||
role_code="air",
|
||||
routes="全线",
|
||||
is_leader=True,
|
||||
business_line="AIR",
|
||||
facts=facts,
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
leader_matches(
|
||||
role_code="air",
|
||||
routes="外采-空运",
|
||||
is_leader=True,
|
||||
business_line="AIR",
|
||||
facts=facts,
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
leader_matches(
|
||||
role_code="air",
|
||||
routes="外采-空运",
|
||||
is_leader=True,
|
||||
business_line="AIR",
|
||||
facts={**facts, "外采": "1"},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,247 @@
|
||||
"""
|
||||
成交三态:入账、推负责人、收下未成交原因、协商中不停会话。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from agent.ledger.memory_ledger import MemoryLedger
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.policy.air_text_flow import AirTextInquiryFlow, FlowSession
|
||||
from agent.policy.deal_outcome import capture_lost_reason, finish_deal_outcome
|
||||
from agent.policy.sea_text_flow import SeaTextInquiryFlow
|
||||
|
||||
|
||||
class TestDealOutcome(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.ledger = MemoryLedger()
|
||||
created = self.ledger.create_ticket(
|
||||
sender_id="sales1",
|
||||
business_line="SEA",
|
||||
facts={"起运港": "上海", "目的港": "洛杉矶", "品名": "衣服"},
|
||||
)
|
||||
self.wo = str(created.get("work_order_no") or "")
|
||||
self.ledger.transition(work_order_no=self.wo, to_status="已报价")
|
||||
self.ledger.upsert_staff(
|
||||
{
|
||||
"name": "海运负责人",
|
||||
"wecomId": "lead-sea",
|
||||
"roleCode": "sea",
|
||||
"routes": "海运全线",
|
||||
"isLeader": 1,
|
||||
"status": "active",
|
||||
}
|
||||
)
|
||||
|
||||
def test_deal_notifies_leaders(self) -> None:
|
||||
queued: list[dict] = []
|
||||
|
||||
def enqueue(*, touser, content, dedupe_key, payload):
|
||||
queued.append(
|
||||
{
|
||||
"touser": touser,
|
||||
"content": content,
|
||||
"dedupe_key": dedupe_key,
|
||||
"payload": payload,
|
||||
}
|
||||
)
|
||||
return True, "ok"
|
||||
|
||||
out = finish_deal_outcome(
|
||||
ledger=self.ledger,
|
||||
work_order_no=self.wo,
|
||||
outcome="已成交",
|
||||
facts={"起运港": "上海", "目的港": "洛杉矶"},
|
||||
quote={"total": "1000", "currency": "USD"},
|
||||
business_line="SEA",
|
||||
sales_name="吴吉林",
|
||||
enqueue=enqueue,
|
||||
notify_ready=True,
|
||||
)
|
||||
self.assertTrue(out.get("ok"))
|
||||
self.assertEqual(out.get("phase"), "done")
|
||||
self.assertIn("海运负责人", str(out.get("notify_summary") or ""))
|
||||
self.assertEqual(queued[0]["touser"], "lead-sea")
|
||||
self.assertEqual(queued[0]["payload"].get("wecom_app"), "notify")
|
||||
ticket = self.ledger.get_ticket(work_order_no=self.wo)
|
||||
self.assertEqual(ticket.status, "已成交")
|
||||
self.assertEqual(ticket.next_followup_at, "")
|
||||
|
||||
def test_deal_notify_card_omits_sales_label(self) -> None:
|
||||
"""成交通知不要「销售」二字:标题副标和正文都不写。"""
|
||||
text = copy.deal_notify_card(
|
||||
work_order_no="WO202609180008",
|
||||
sales_name="吴吉林",
|
||||
facts={"起运地": "泉州", "目的地": "南京", "线路类别": "国内城配/拖车/打包"},
|
||||
quote={},
|
||||
business_line="LAND",
|
||||
)
|
||||
self.assertNotIn("销售", text)
|
||||
payload = copy.deal_notify_wecom_payload(
|
||||
work_order_no="WO202609180008",
|
||||
sales_name="吴吉林",
|
||||
facts={"起运地": "泉州", "目的地": "南京", "线路类别": "国内城配/拖车/打包"},
|
||||
quote={},
|
||||
business_line="LAND",
|
||||
)
|
||||
title = (payload.get("template_card") or {}).get("main_title") or {}
|
||||
blob = str(title.get("title") or "") + str(title.get("desc") or "")
|
||||
self.assertNotIn("销售", blob)
|
||||
self.assertNotIn("desc", title)
|
||||
|
||||
def test_deal_without_leader_still_closes(self) -> None:
|
||||
self.ledger.upsert_staff(
|
||||
{
|
||||
"name": "不是负责人",
|
||||
"wecomId": "lead-sea",
|
||||
"roleCode": "sea",
|
||||
"routes": "海运全线",
|
||||
"isLeader": 0,
|
||||
"status": "active",
|
||||
}
|
||||
)
|
||||
out = finish_deal_outcome(
|
||||
ledger=self.ledger,
|
||||
work_order_no=self.wo,
|
||||
outcome="已成交",
|
||||
facts={},
|
||||
quote={},
|
||||
business_line="SEA",
|
||||
notify_ready=True,
|
||||
)
|
||||
self.assertEqual(out.get("notify_summary"), "无匹配负责人")
|
||||
self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).status, "已成交")
|
||||
|
||||
def test_lost_waits_for_reason(self) -> None:
|
||||
out = finish_deal_outcome(
|
||||
ledger=self.ledger,
|
||||
work_order_no=self.wo,
|
||||
outcome="未成交",
|
||||
facts={},
|
||||
quote={},
|
||||
business_line="SEA",
|
||||
)
|
||||
self.assertEqual(out.get("phase"), "wait_lost_reason")
|
||||
ticket = self.ledger.get_ticket(work_order_no=self.wo)
|
||||
self.assertEqual(ticket.status, "未成交")
|
||||
self.assertEqual(ticket.lost_reason, "")
|
||||
view = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1")
|
||||
self.assertEqual(view.get("wait_phase"), "wait_lost_reason")
|
||||
captured = capture_lost_reason(
|
||||
ledger=self.ledger, work_order_no=self.wo, reason="客人比价了"
|
||||
)
|
||||
self.assertTrue(captured.get("ok"))
|
||||
ticket = self.ledger.get_ticket(work_order_no=self.wo)
|
||||
self.assertEqual(ticket.lost_reason, "客人比价了")
|
||||
view = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1")
|
||||
self.assertEqual(view.get("wait_phase"), "done")
|
||||
|
||||
def test_lost_reason_ack_repeats_user_text(self) -> None:
|
||||
self.assertEqual(copy.lost_reason_ack("客户不要了"), "已记下未成交原因:客户不要了。")
|
||||
self.assertEqual(copy.lost_reason_ack("价格高。"), "已记下未成交原因:价格高。")
|
||||
|
||||
def test_negotiate_ack_names_followup_day(self) -> None:
|
||||
text = copy.deal_ack("协商中", "WO202609190007", "2026-09-24 09:00:00")
|
||||
self.assertEqual(
|
||||
text,
|
||||
"已记录为「协商中」。已顺延三个工作日,9月24日的9:00再次发起成交状态询问。",
|
||||
)
|
||||
self.assertNotIn("跟进周期已顺延", text)
|
||||
|
||||
def test_negotiate_keeps_wait_deal(self) -> None:
|
||||
out = finish_deal_outcome(
|
||||
ledger=self.ledger,
|
||||
work_order_no=self.wo,
|
||||
outcome="协商中",
|
||||
facts={},
|
||||
quote={},
|
||||
business_line="SEA",
|
||||
)
|
||||
self.assertEqual(out.get("phase"), "wait_deal")
|
||||
ticket = self.ledger.get_ticket(work_order_no=self.wo)
|
||||
self.assertEqual(ticket.status, "协商中")
|
||||
self.assertTrue(ticket.next_followup_at)
|
||||
view = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1")
|
||||
self.assertEqual(view.get("wait_phase"), "wait_deal")
|
||||
again = finish_deal_outcome(
|
||||
ledger=self.ledger,
|
||||
work_order_no=self.wo,
|
||||
outcome="协商中",
|
||||
facts={},
|
||||
quote={},
|
||||
business_line="SEA",
|
||||
)
|
||||
self.assertEqual(again.get("phase"), "wait_deal")
|
||||
self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).status, "协商中")
|
||||
self.assertTrue(again.get("next_followup_at"))
|
||||
|
||||
def test_private_next_text_is_lost_reason(self) -> None:
|
||||
flow = AirTextInquiryFlow(self.ledger)
|
||||
sess = FlowSession(
|
||||
sender_id="sales1",
|
||||
thread_id="sales1",
|
||||
phase="wait_deal",
|
||||
business_line="SEA",
|
||||
work_order_no=self.wo,
|
||||
allowed=(copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE),
|
||||
)
|
||||
flow._save(sess)
|
||||
replies: list[str] = []
|
||||
phase = flow.on_button(
|
||||
sender_id="sales1",
|
||||
action=copy.BTN_LOST,
|
||||
reply=lambda t, extra=None: replies.append(t),
|
||||
)
|
||||
self.assertEqual(phase, "wait_lost_reason")
|
||||
phase = flow.on_text(
|
||||
sender_id="sales1",
|
||||
text="价格高",
|
||||
reply=lambda t, extra=None: replies.append(t),
|
||||
)
|
||||
self.assertEqual(phase, "done")
|
||||
self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).lost_reason, "价格高")
|
||||
self.assertTrue(any(t == "已记下未成交原因:价格高。" for t in replies))
|
||||
|
||||
def test_sea_private_next_text_is_lost_reason(self) -> None:
|
||||
flow = SeaTextInquiryFlow(self.ledger)
|
||||
flow._save(
|
||||
FlowSession(
|
||||
sender_id="sales1",
|
||||
thread_id="sales1",
|
||||
phase="wait_deal",
|
||||
business_line="SEA",
|
||||
work_order_no=self.wo,
|
||||
allowed=(copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE),
|
||||
)
|
||||
)
|
||||
replies: list[str] = []
|
||||
phase = flow.on_button(
|
||||
sender_id="sales1",
|
||||
action=copy.BTN_LOST,
|
||||
reply=lambda t, extra=None: replies.append(t),
|
||||
)
|
||||
self.assertEqual(phase, "wait_lost_reason")
|
||||
phase = flow.on_text(
|
||||
sender_id="sales1",
|
||||
text="客人比价了",
|
||||
reply=lambda t, extra=None: replies.append(t),
|
||||
)
|
||||
self.assertEqual(phase, "done")
|
||||
self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).lost_reason, "客人比价了")
|
||||
|
||||
def test_claim_due_negotiate_clears_schedule(self) -> None:
|
||||
finish_deal_outcome(
|
||||
ledger=self.ledger,
|
||||
work_order_no=self.wo,
|
||||
outcome="协商中",
|
||||
facts={},
|
||||
quote={},
|
||||
business_line="SEA",
|
||||
)
|
||||
ticket = self.ledger.get_ticket(work_order_no=self.wo)
|
||||
ticket.next_followup_at = "2020-01-01 09:00:00"
|
||||
rows = self.ledger.claim_due_negotiate()
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0]["work_order_no"], self.wo)
|
||||
self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).next_followup_at, "")
|
||||
@@ -26,6 +26,7 @@ os.environ.setdefault("LEDGER_BACKEND", "memory")
|
||||
|
||||
from agent.ledger.memory_ledger import MemoryLedger
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.policy.air_text_flow import FlowSession
|
||||
from agent.policy.land_text_flow import LandTextInquiryFlow
|
||||
|
||||
|
||||
@@ -623,5 +624,46 @@ class LandTextPullTests(unittest.TestCase):
|
||||
self.assertTrue(is_product(ticket, "u1", flow=self.flow))
|
||||
|
||||
|
||||
class LandLostReasonTests(unittest.TestCase):
|
||||
"""陆运私聊未成交后,下一句任意文字要记下原因,不能当新询价丢掉。"""
|
||||
|
||||
def test_land_private_next_text_is_lost_reason(self) -> None:
|
||||
ledger = MemoryLedger()
|
||||
created = ledger.create_ticket(
|
||||
sender_id="WuJiLin",
|
||||
business_line="LAND",
|
||||
facts={"起运地": "泉州", "目的地": "南京", "线路类别": "国内城配/拖车/打包"},
|
||||
)
|
||||
wo = str(created.get("work_order_no") or "")
|
||||
ledger.transition(work_order_no=wo, to_status="已报价")
|
||||
flow = LandTextInquiryFlow(ledger)
|
||||
flow._save(
|
||||
FlowSession(
|
||||
sender_id="WuJiLin",
|
||||
thread_id="WuJiLin",
|
||||
phase="wait_deal",
|
||||
business_line="LAND",
|
||||
work_order_no=wo,
|
||||
allowed=(copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE),
|
||||
)
|
||||
)
|
||||
replies: list[str] = []
|
||||
phase = flow.on_button(
|
||||
sender_id="WuJiLin",
|
||||
action=copy.BTN_LOST,
|
||||
reply=lambda t, extra=None: replies.append(t),
|
||||
)
|
||||
self.assertEqual(phase, "wait_lost_reason")
|
||||
self.assertEqual(ledger.get_ticket(work_order_no=wo).status, "未成交")
|
||||
phase = flow.on_text(
|
||||
sender_id="WuJiLin",
|
||||
text="价格高",
|
||||
reply=lambda t, extra=None: replies.append(t),
|
||||
)
|
||||
self.assertEqual(phase, "done")
|
||||
self.assertEqual(ledger.get_ticket(work_order_no=wo).lost_reason, "价格高")
|
||||
self.assertTrue(any(t == "已记下未成交原因:价格高。" for t in replies))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1522,9 +1522,36 @@ class SeaGroupOpsTests(unittest.TestCase):
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "done")
|
||||
self.assertEqual(phase, "wait_lost_reason")
|
||||
self.assertEqual(self.ledger.get_ticket(work_order_no=wo).status, "未成交")
|
||||
|
||||
def test_sales_next_text_is_lost_reason(self) -> None:
|
||||
wo, chat = self._open_group()
|
||||
handle_group_collab(
|
||||
InboundMessage(
|
||||
sender_id="sales1",
|
||||
message_id="lost-1",
|
||||
content="未成交",
|
||||
chat_id=chat,
|
||||
chat_type="group",
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
phase = handle_group_collab(
|
||||
InboundMessage(
|
||||
sender_id="sales1",
|
||||
message_id="lost-2",
|
||||
content="客人比价了",
|
||||
chat_id=chat,
|
||||
chat_type="group",
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "done")
|
||||
self.assertEqual(self.ledger.get_ticket(work_order_no=wo).lost_reason, "客人比价了")
|
||||
|
||||
def test_sales_can_close_deal_by_text(self) -> None:
|
||||
"""群里销售说成交,要入账并回话,不能装没听见。相近说法也认。"""
|
||||
wo, chat = self._open_group()
|
||||
|
||||
+29
@@ -122,6 +122,10 @@ public class InquiryAgentController {
|
||||
Object to = body == null ? null : body.get("toStatus");
|
||||
req.setWorkOrderNo(no == null ? "" : String.valueOf(no));
|
||||
req.setToStatus(to == null ? "" : String.valueOf(to));
|
||||
req.setRemark(firstStr(body, "remark"));
|
||||
req.setLostReason(firstStr(body, "lostReason", "lost_reason"));
|
||||
req.setNextFollowupAt(firstStr(body, "nextFollowupAt", "next_followup_at"));
|
||||
req.setDealNotifySummary(firstStr(body, "dealNotifySummary", "deal_notify_summary"));
|
||||
try {
|
||||
org.jeecg.modules.inquiry.entity.InquiryTicket ticket =
|
||||
workOrderService.transition(req, "agent");
|
||||
@@ -135,6 +139,31 @@ public class InquiryAgentController {
|
||||
}
|
||||
}
|
||||
|
||||
@ApiOperation("智能体补未成交原因(不改六态)")
|
||||
@PostMapping("/ticket/patchLostReason")
|
||||
public Result<Map<String, Object>> patchLostReason(@RequestBody Map<String, Object> body,
|
||||
HttpServletRequest http) {
|
||||
if (!authed(http)) {
|
||||
return Result.error(401, "unauthorized");
|
||||
}
|
||||
return Result.OK(workOrderService.patchLostReason(
|
||||
firstStr(body, "workOrderNo", "work_order_no"),
|
||||
firstStr(body, "lostReason", "lost_reason"),
|
||||
"agent"));
|
||||
}
|
||||
|
||||
@ApiOperation("认领到期协商再问(Worker HTTP 槽)")
|
||||
@PostMapping("/ticket/claimDueNegotiate")
|
||||
public Result<Map<String, Object>> claimDueNegotiate(HttpServletRequest http) {
|
||||
if (!authed(http)) {
|
||||
return Result.error(401, "unauthorized");
|
||||
}
|
||||
Map<String, Object> out = new java.util.LinkedHashMap<>();
|
||||
out.put("ok", true);
|
||||
out.put("tickets", workOrderService.claimDueNegotiate());
|
||||
return Result.OK(out);
|
||||
}
|
||||
|
||||
@ApiOperation("智能体续办读单(只读,校验发起人)")
|
||||
@PostMapping("/ticket/get")
|
||||
public Result<Map<String, Object>> get(@RequestBody Map<String, Object> body,
|
||||
|
||||
+3
-1
@@ -30,6 +30,7 @@ public final class TicketStatuses {
|
||||
/**
|
||||
* from -> 允许的 to 集合。
|
||||
* 终态(已成交/未成交/已关闭)默认不可再流转,除非显式「关闭」从非终态进入已关闭。
|
||||
* 协商中允许再进协商中:只顺延再问,不重开询价。
|
||||
*/
|
||||
private static final Map<String, Set<String>> ALLOWED;
|
||||
|
||||
@@ -37,7 +38,8 @@ public final class TicketStatuses {
|
||||
Map<String, Set<String>> m = new HashMap<>();
|
||||
m.put(INQUIRING, setOf(QUOTED, NEGOTIATING, CLOSED));
|
||||
m.put(QUOTED, setOf(NEGOTIATING, DEAL, LOST, CLOSED));
|
||||
m.put(NEGOTIATING, setOf(QUOTED, DEAL, LOST, CLOSED));
|
||||
// 协商中可再选协商中:顺延下一轮再问,次数不限。
|
||||
m.put(NEGOTIATING, setOf(QUOTED, DEAL, LOST, CLOSED, NEGOTIATING));
|
||||
m.put(DEAL, Collections.emptySet());
|
||||
m.put(LOST, Collections.emptySet());
|
||||
m.put(CLOSED, Collections.emptySet());
|
||||
|
||||
+9
@@ -20,4 +20,13 @@ public class TicketTransitionRequest {
|
||||
|
||||
@ApiModelProperty("备注/关单原因")
|
||||
private String remark;
|
||||
|
||||
@ApiModelProperty("未成交原因(仅未成交)")
|
||||
private String lostReason;
|
||||
|
||||
@ApiModelProperty("协商中下次再问时间")
|
||||
private String nextFollowupAt;
|
||||
|
||||
@ApiModelProperty("成交通知结果摘要")
|
||||
private String dealNotifySummary;
|
||||
}
|
||||
|
||||
+22
@@ -115,6 +115,28 @@ public class InquiryTicket implements Serializable {
|
||||
@ApiModelProperty("关单原因")
|
||||
private String closeReason;
|
||||
|
||||
/**
|
||||
* 未成交原因:销售下一句任意文字;没回则空着。
|
||||
* 不是 closeReason,后台要单独展示。
|
||||
*/
|
||||
@ApiModelProperty("未成交原因")
|
||||
private String lostReason;
|
||||
|
||||
/**
|
||||
* 协商中下次再问时刻(第四个工作日 09:00)。
|
||||
* 中途改成交/未成交或再问发出后清空。
|
||||
*/
|
||||
@ApiModelProperty("协商中下次再问时间")
|
||||
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date nextFollowupAt;
|
||||
|
||||
/**
|
||||
* 成交通知结果:已推哪些负责人 / 无匹配负责人 / 通知未发出。
|
||||
*/
|
||||
@ApiModelProperty("成交通知结果")
|
||||
private String dealNotifySummary;
|
||||
|
||||
@ApiModelProperty("业务版本")
|
||||
private Integer version;
|
||||
|
||||
|
||||
+11
@@ -109,4 +109,15 @@ public interface IInquiryWorkOrderService extends IService<InquiryTicket> {
|
||||
* 空运群激活/切换当前工单。一单一群,未结案才能绑。不改六态。
|
||||
*/
|
||||
Map<String, Object> activateAirGroup(Map<String, Object> body);
|
||||
|
||||
/**
|
||||
* 未成交后补原因。已成交/其它六态拒绝。不改六态。
|
||||
*/
|
||||
Map<String, Object> patchLostReason(String workOrderNo, String lostReason, String operator);
|
||||
|
||||
/**
|
||||
* 认领已到期的协商中再问:清空 nextFollowupAt,避免 Worker 重复发。
|
||||
* 只改到期行,不加全局锁。
|
||||
*/
|
||||
java.util.List<java.util.Map<String, Object>> claimDueNegotiate();
|
||||
}
|
||||
|
||||
+3
@@ -174,6 +174,9 @@ public class InquiryStaffRoleServiceImpl extends ServiceImpl<InquiryStaffRoleMap
|
||||
one.put("wecomId", row.getWecomId());
|
||||
one.put("roleCode", row.getRoleCode());
|
||||
one.put("status", row.getStatus());
|
||||
// 成交跟进按「是负责人 + 负责线路」匹配,缺这两项智能体对不上人。
|
||||
one.put("isLeader", row.getIsLeader());
|
||||
one.put("routes", StringUtils.defaultString(row.getRoutes()));
|
||||
out.add(one);
|
||||
}
|
||||
return out;
|
||||
|
||||
+155
-3
@@ -209,11 +209,27 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
if (TicketStatuses.CLOSED.equals(to) && StringUtils.isNotBlank(req.getRemark())) {
|
||||
ticket.setCloseReason(req.getRemark());
|
||||
}
|
||||
if (TicketStatuses.LOST.equals(to)) {
|
||||
ticket.setNextFollowupAt(null);
|
||||
if (StringUtils.isNotBlank(req.getLostReason())) {
|
||||
ticket.setLostReason(StringUtils.left(req.getLostReason().trim(), 500));
|
||||
}
|
||||
} else if (TicketStatuses.DEAL.equals(to)) {
|
||||
ticket.setNextFollowupAt(null);
|
||||
if (StringUtils.isNotBlank(req.getDealNotifySummary())) {
|
||||
ticket.setDealNotifySummary(StringUtils.left(req.getDealNotifySummary().trim(), 500));
|
||||
}
|
||||
} else if (TicketStatuses.NEGOTIATING.equals(to)) {
|
||||
Date follow = parseDateTime(req.getNextFollowupAt());
|
||||
ticket.setNextFollowupAt(follow);
|
||||
}
|
||||
ticket.setVersion(ticket.getVersion() == null ? 2 : ticket.getVersion() + 1);
|
||||
ticket.setUpdateBy(operator);
|
||||
ticket.setUpdateTime(new Date());
|
||||
updateById(ticket);
|
||||
appendLog(ticket, from, to, "transition", operator, req.getRemark());
|
||||
String remark = StringUtils.defaultIfBlank(req.getRemark(),
|
||||
StringUtils.defaultIfBlank(req.getDealNotifySummary(), req.getLostReason()));
|
||||
appendLog(ticket, from, to, "transition", operator, remark);
|
||||
return ticket;
|
||||
}
|
||||
|
||||
@@ -569,9 +585,13 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
}
|
||||
String status = StringUtils.defaultString(ticket.getStatus());
|
||||
String waitPhase;
|
||||
if (TicketStatuses.DEAL.equals(status) || TicketStatuses.LOST.equals(status)
|
||||
|| TicketStatuses.NEGOTIATING.equals(status) || TicketStatuses.CLOSED.equals(status)) {
|
||||
if (TicketStatuses.DEAL.equals(status) || TicketStatuses.CLOSED.equals(status)) {
|
||||
waitPhase = "done";
|
||||
} else if (TicketStatuses.LOST.equals(status)) {
|
||||
waitPhase = StringUtils.isBlank(ticket.getLostReason()) ? "wait_lost_reason" : "done";
|
||||
} else if (TicketStatuses.NEGOTIATING.equals(status)) {
|
||||
// 协商中会话不能结束:还要再问成交,也可中途改成交/未成交。
|
||||
waitPhase = "wait_deal";
|
||||
} else if (TicketStatuses.QUOTED.equals(status) && !quote.isEmpty()) {
|
||||
// 文件已发出:成交跟进。六态仍是已报价,不能退回协同/出方案卡
|
||||
if (quoteFileAlreadyIssued(no, quote)) {
|
||||
@@ -592,6 +612,12 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
out.put("facts", facts);
|
||||
out.put("quote", quote);
|
||||
out.put("wait_phase", waitPhase);
|
||||
out.put("lost_reason", StringUtils.defaultString(ticket.getLostReason()));
|
||||
out.put("lostReason", StringUtils.defaultString(ticket.getLostReason()));
|
||||
out.put("deal_notify_summary", StringUtils.defaultString(ticket.getDealNotifySummary()));
|
||||
if (ticket.getNextFollowupAt() != null) {
|
||||
out.put("next_followup_at", new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(ticket.getNextFollowupAt()));
|
||||
}
|
||||
out.put("collab_chat_id", StringUtils.defaultString(ticket.getCollabChatId()));
|
||||
Map<String, String> collabFacts = new LinkedHashMap<>();
|
||||
if (StringUtils.isNotBlank(ticket.getCollabFactsJson())) {
|
||||
@@ -773,6 +799,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
out.put("business_line", StringUtils.defaultString(ticket.getBusinessLine()));
|
||||
out.put("facts", parseFactsJson(ticket.getFactsJson()));
|
||||
out.put("quote", latestQuotePayload(ticket.getWorkOrderNo()));
|
||||
out.put("lost_reason", StringUtils.defaultString(ticket.getLostReason()));
|
||||
out.put("lostReason", StringUtils.defaultString(ticket.getLostReason()));
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -1027,6 +1055,130 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
.last("LIMIT 1"), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 未成交后补原因。不改六态;已有原因可覆盖。
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> patchLostReason(String workOrderNo, String lostReason, String operator) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
String no = StringUtils.trimToEmpty(workOrderNo);
|
||||
String reason = StringUtils.left(StringUtils.trimToEmpty(lostReason), 500);
|
||||
out.put("ok", false);
|
||||
if (StringUtils.isBlank(no) || StringUtils.isBlank(reason)) {
|
||||
out.put("error", "workOrderNo_or_reason_blank");
|
||||
return out;
|
||||
}
|
||||
InquiryTicket ticket = findByWorkOrderNo(no);
|
||||
if (ticket == null) {
|
||||
out.put("error", "ticket_not_found");
|
||||
return out;
|
||||
}
|
||||
if (!TicketStatuses.LOST.equals(ticket.getStatus())) {
|
||||
out.put("error", "not_lost");
|
||||
out.put("status", ticket.getStatus());
|
||||
return out;
|
||||
}
|
||||
ticket.setLostReason(reason);
|
||||
ticket.setUpdateBy(StringUtils.defaultIfBlank(operator, "agent"));
|
||||
ticket.setUpdateTime(new Date());
|
||||
updateById(ticket);
|
||||
appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "lost_reason", operator, reason);
|
||||
out.put("ok", true);
|
||||
out.put("work_order_no", no);
|
||||
out.put("lost_reason", reason);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 认领到期协商再问:状态仍是协商中且 next_followup_at 已到。
|
||||
* 清空再问时刻,Worker 只发这一轮;销售再选协商中才会排下一轮。
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public List<Map<String, Object>> claimDueNegotiate() {
|
||||
Date now = new Date();
|
||||
List<InquiryTicket> due = list(new LambdaQueryWrapper<InquiryTicket>()
|
||||
.eq(InquiryTicket::getStatus, TicketStatuses.NEGOTIATING)
|
||||
.isNotNull(InquiryTicket::getNextFollowupAt)
|
||||
.le(InquiryTicket::getNextFollowupAt, now)
|
||||
.last("LIMIT 50"));
|
||||
List<Map<String, Object>> out = new ArrayList<>();
|
||||
if (due == null || due.isEmpty()) {
|
||||
return out;
|
||||
}
|
||||
for (InquiryTicket ticket : due) {
|
||||
ticket.setNextFollowupAt(null);
|
||||
ticket.setUpdateBy("agent");
|
||||
ticket.setUpdateTime(now);
|
||||
updateById(ticket);
|
||||
appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "negotiate_followup", "agent", "协商再问已发出");
|
||||
out.add(toDueNegotiateRow(ticket));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Worker 再问需要的最小快照:会话通道、字段、报价。
|
||||
*/
|
||||
private Map<String, Object> toDueNegotiateRow(InquiryTicket ticket) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("work_order_no", ticket.getWorkOrderNo());
|
||||
row.put("workOrderNo", ticket.getWorkOrderNo());
|
||||
row.put("sales_wecom_id", StringUtils.defaultString(ticket.getSalesWecomId()));
|
||||
row.put("salesWecomId", StringUtils.defaultString(ticket.getSalesWecomId()));
|
||||
row.put("sales_name", StringUtils.defaultString(ticket.getSalesName()));
|
||||
row.put("business_line", StringUtils.defaultString(ticket.getBusinessLine()));
|
||||
row.put("collab_chat_id", StringUtils.defaultString(ticket.getCollabChatId()));
|
||||
row.put("status", ticket.getStatus());
|
||||
Map<String, String> facts = new LinkedHashMap<>();
|
||||
if (StringUtils.isNotBlank(ticket.getFactsJson())) {
|
||||
JSONObject stored = JSON.parseObject(ticket.getFactsJson());
|
||||
if (stored != null) {
|
||||
for (String key : stored.keySet()) {
|
||||
if (stored.get(key) != null) {
|
||||
facts.put(key, String.valueOf(stored.get(key)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (StringUtils.isNotBlank(ticket.getOrigin())) {
|
||||
facts.putIfAbsent("起运港", ticket.getOrigin());
|
||||
}
|
||||
if (StringUtils.isNotBlank(ticket.getDestination())) {
|
||||
facts.putIfAbsent("目的港", ticket.getDestination());
|
||||
}
|
||||
row.put("facts", facts);
|
||||
Map<String, Object> quote = new LinkedHashMap<>();
|
||||
List<InquiryQuote> quotes = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
|
||||
.eq(InquiryQuote::getWorkOrderNo, ticket.getWorkOrderNo())
|
||||
.orderByDesc(InquiryQuote::getCreateTime)
|
||||
.last("LIMIT 1"));
|
||||
if (quotes != null && !quotes.isEmpty() && StringUtils.isNotBlank(quotes.get(0).getPayloadJson())) {
|
||||
JSONObject parsed = JSON.parseObject(quotes.get(0).getPayloadJson());
|
||||
if (parsed != null) {
|
||||
quote.putAll(parsed);
|
||||
}
|
||||
}
|
||||
row.put("quote", quote);
|
||||
return row;
|
||||
}
|
||||
|
||||
private static Date parseDateTime(String raw) {
|
||||
if (StringUtils.isBlank(raw)) {
|
||||
return null;
|
||||
}
|
||||
String text = raw.trim().replace("T", " ");
|
||||
if (text.length() >= 19) {
|
||||
text = text.substring(0, 19);
|
||||
}
|
||||
try {
|
||||
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(text);
|
||||
} catch (Exception e) {
|
||||
return parseDate(raw);
|
||||
}
|
||||
}
|
||||
|
||||
private void appendLog(InquiryTicket ticket, String from, String to, String action, String operator, String remark) {
|
||||
InquiryTicketStatusLog log = new InquiryTicketStatusLog();
|
||||
log.setTicketId(ticket.getId());
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
-- 成交跟进:未成交原因 / 协商再问时刻 / 成交通知摘要。
|
||||
-- 仅改 inquiry_robot;幂等需手工确认列是否已存在。
|
||||
-- 库:inquiry_robot
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
ALTER TABLE `inq_ticket`
|
||||
ADD COLUMN `lost_reason` varchar(500) NULL COMMENT '未成交原因,不是关单原因' AFTER `close_reason`;
|
||||
|
||||
ALTER TABLE `inq_ticket`
|
||||
ADD COLUMN `next_followup_at` datetime NULL COMMENT '协商中下次再问(工作日 09:00)' AFTER `lost_reason`;
|
||||
|
||||
ALTER TABLE `inq_ticket`
|
||||
ADD COLUMN `deal_notify_summary` varchar(500) NULL COMMENT '成交通知:已推负责人/无匹配/未发出' AFTER `next_followup_at`;
|
||||
@@ -48,6 +48,9 @@ type TicketRow = {
|
||||
volumeCbm: string;
|
||||
goodsName: string;
|
||||
updateTime: string;
|
||||
lostReason: string;
|
||||
nextFollowupAt: string;
|
||||
dealNotifySummary: string;
|
||||
};
|
||||
|
||||
const rows = ref<TicketRow[]>([]);
|
||||
@@ -265,6 +268,9 @@ function mapTicket(t: any, quotes: any[] = [], attachments: any[] = [], timeline
|
||||
volumeCbm: fmtMeasure(t.volumeCbm, "CBM"),
|
||||
goodsName: dash(t.goodsName),
|
||||
updateTime: fmtTime(t.updateTime || t.createTime),
|
||||
lostReason: dash(t.lostReason || t.lost_reason),
|
||||
nextFollowupAt: fmtTime(t.nextFollowupAt || t.next_followup_at),
|
||||
dealNotifySummary: dash(t.dealNotifySummary || t.deal_notify_summary),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -630,6 +636,18 @@ function refresh() {
|
||||
<div class="ticket-detail-item"><label>事件异常</label><span>{{ current.eventException }}</span></div>
|
||||
<div class="ticket-detail-item"><label>系统异常</label><span>{{ current.systemException }}</span></div>
|
||||
<div class="ticket-detail-item"><label>系统异常原因</label><span>{{ current.systemExceptionReason }}</span></div>
|
||||
<div v-if="current.status === '已成交'" class="ticket-detail-item">
|
||||
<label>成交时间</label><span>{{ current.updateTime }}</span>
|
||||
</div>
|
||||
<div v-if="current.status === '已成交'" class="ticket-detail-item">
|
||||
<label>成交通知</label><span>{{ current.dealNotifySummary }}</span>
|
||||
</div>
|
||||
<div v-if="current.status === '未成交'" class="ticket-detail-item">
|
||||
<label>未成交原因</label><span>{{ current.lostReason }}</span>
|
||||
</div>
|
||||
<div v-if="current.status === '协商中'" class="ticket-detail-item">
|
||||
<label>下次再问</label><span>{{ current.nextFollowupAt }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user