协商中再问按后台超时小时顺延并可强制清跟进时刻;工单与账号支持按筛选导出 Excel。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-13 12:34:03 +08:00
co-authored by Cursor
parent 97f0219dfd
commit 0b2f0b76c4
36 changed files with 867 additions and 64 deletions
@@ -48,6 +48,8 @@ _PENDING_LOCK = "__pending_air_lock"
_PENDING_LOCK_OPTION = "__pending_lock_option"
_PENDING_LOCK_AIRLINE = "__pending_lock_airline"
_PENDING_LOCK_FLIGHT = "__pending_lock_flight"
_LOCK_ID = "__air_lock_id"
_LOCK_OPTION = "__air_lock_option"
_LOCK_QUOTE = "__air_lock_quote"
@@ -861,6 +863,7 @@ def apply_air_deal(
no,
str(result.get("next_followup_at") or ""),
lost_reason=ack_reason,
followup_hours=result.get("negotiate_hours"),
),
)
return phase
@@ -1518,11 +1521,13 @@ def _resolve_lock_params(
air = _normalize_airline(airline or said_air or str(facts.get(_PENDING_LOCK_AIRLINE) or ""))
flight = _normalize_flight(flight_no or said_flight or str(facts.get(_PENDING_LOCK_FLIGHT) or ""))
chosen = _option_row(quote, option)
single_auto = False
if chosen is None and not option:
options = _air_options(quote)
if len(options) == 1:
chosen = options[0]
option = str(chosen.get("optionNo") or chosen.get("option_no") or "").strip()
single_auto = True
if not _airline_ready(air) and chosen is not None:
air = _normalize_airline(
str(chosen.get("airlineCode") or chosen.get("airline") or "")
@@ -2007,6 +2007,7 @@ class AirTextInquiryFlow:
sess.work_order_no,
str(result.get("next_followup_at") or ""),
lost_reason=ack_reason,
followup_hours=result.get("negotiate_hours"),
)
)
return phase
+45 -5
View File
@@ -88,7 +88,7 @@ def shift_workdays(start: date, count: int) -> date:
"""
从 start 当天之后数 count 个工作日。
成交选协商中:顺延 3 个工作日后,第四个工作日再问,故 count=4。
仍给单测与节假日校验用;协商再问已改走 add_workday_hours。
"""
if count < 1:
return start
@@ -101,11 +101,51 @@ def shift_workdays(start: date, count: int) -> date:
return cur
def next_followup_at(now: datetime | None = None) -> datetime:
"""第四个工作日 Asia/Shanghai 09:00。"""
# 协商中再问:主账 timeoutCode;缺配置时默认 48 工作小时。
TIMEOUT_CODE_DEAL_NEGOTIATE = "deal_negotiate"
DEFAULT_NEGOTIATE_HOURS = 48.0
def add_workday_hours(start: datetime, hours: float) -> datetime:
"""
从 start 起累加 hours 个工作小时;非工作日整天跳过。
与 workday_elapsed_hours 互逆:工作日当天 0–24 都算。
起点落在休息日:从下一个工作日 00:00 起算。
hours≤0 时原样返回(调用方应不排期)。
"""
base = start.astimezone(SHANGHAI) if start.tzinfo else start.replace(tzinfo=SHANGHAI)
remaining = float(hours)
if remaining <= 0:
return base
cur = base
if not is_workday(cur.date()):
cur = datetime.combine(cur.date() + timedelta(days=1), time(0, 0), tzinfo=SHANGHAI)
while not is_workday(cur.date()):
cur = datetime.combine(cur.date() + timedelta(days=1), time(0, 0), tzinfo=SHANGHAI)
while remaining > 0:
if not is_workday(cur.date()):
cur = datetime.combine(cur.date() + timedelta(days=1), time(0, 0), tzinfo=SHANGHAI)
continue
day_end = datetime.combine(cur.date() + timedelta(days=1), time(0, 0), tzinfo=SHANGHAI)
room = (day_end - cur).total_seconds() / 3600.0
if remaining <= room + 1e-9:
return cur + timedelta(hours=remaining)
remaining -= room
cur = day_end
return cur
def next_followup_at(now: datetime | None = None, *, hours: float | None = None) -> datetime:
"""
距今 hours 个工作小时后的再问时刻(默认 48)。
非工作日不计入;提醒时刻不再固定 09:00。
hours≤0 时返回起点(调用方应把 0 当作不排再问)。
"""
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)
h = DEFAULT_NEGOTIATE_HOURS if hours is None else float(hours)
return add_workday_hours(base, h)
def workday_elapsed_hours(start: datetime, now: datetime) -> float:
+42 -2
View File
@@ -13,7 +13,12 @@ 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
from agent.policy.deal_followup import (
DEFAULT_NEGOTIATE_HOURS,
TIMEOUT_CODE_DEAL_NEGOTIATE,
next_followup_at,
pick_leaders,
)
logger = logging.getLogger(__name__)
@@ -156,8 +161,14 @@ def finish_deal_outcome(
status = (outcome or "").strip()
extra: dict[str, Any] = {}
summary = ""
negotiate_hours = 0.0
if status == "协商中":
extra["next_followup_at"] = next_followup_at().strftime("%Y-%m-%d %H:%M:%S")
# 小时读后台超时响应 deal_negotiate;0=不排再问;缺配置默认 48
negotiate_hours = _negotiate_followup_hours(ledger)
if negotiate_hours > 0:
extra["next_followup_at"] = next_followup_at(hours=negotiate_hours).strftime(
"%Y-%m-%d %H:%M:%S"
)
if status == "已成交":
staff: list[dict[str, Any]] = []
lister = getattr(ledger, "list_staff_by_role", None)
@@ -242,9 +253,38 @@ def finish_deal_outcome(
"notify_summary": summary,
"status": status,
"next_followup_at": str(extra.get("next_followup_at") or ""),
"negotiate_hours": negotiate_hours,
}
def _negotiate_followup_hours(ledger: Any) -> float:
"""
读主账 deal_negotiate 超时小时;找不到或异常则用默认 48。
允许小数;负数当 0(不排再问)。不在回调线程打主账以外的慢活。
"""
lookup = getattr(ledger, "get_timeout_by_code", None)
if not callable(lookup):
return DEFAULT_NEGOTIATE_HOURS
try:
row = lookup(timeout_code=TIMEOUT_CODE_DEAL_NEGOTIATE) or {}
except Exception:
logger.exception("读协商再问小时失败,用默认 %s", DEFAULT_NEGOTIATE_HOURS)
return DEFAULT_NEGOTIATE_HOURS
if not row.get("found"):
return DEFAULT_NEGOTIATE_HOURS
raw = row.get("timeoutHours")
if raw is None:
raw = row.get("timeout_hours")
try:
hours = float(raw)
except (TypeError, ValueError):
return DEFAULT_NEGOTIATE_HOURS
if hours < 0:
return 0.0
return hours
def _transition_deal_status(
writer: Callable[..., dict[str, Any]],
*,
+22 -5
View File
@@ -2775,6 +2775,7 @@ def deal_ack(
work_order_no: str,
next_followup_at: str = "",
lost_reason: str = "",
followup_hours: float | None = None,
) -> str:
"""成交三态回执。后台 deal.recorded_won / lost.ask_reason / negotiating.recorded。"""
no = (work_order_no or "").strip()
@@ -2789,26 +2790,42 @@ def deal_ack(
if reason:
return f"已记录为「未成交」。{lost_reason_ack(reason, work_order_no=no)}"
return _r("lost.ask_reason", "已记录为「未成交」。请补充未成交原因。", ticket_no=no)
when = _followup_month_day(next_followup_at) or "第四个工作日"
from agent.policy.event_exception import format_timeout_hours
# 小时缺省按后台默认 48;0 表示运营关掉再问,话术仍说明已记录协商中
hours_val = 48.0 if followup_hours is None else float(followup_hours)
hours_text = format_timeout_hours(hours_val if hours_val > 0 else 48.0)
when = _followup_when_text(next_followup_at) or f"{hours_text} 个工作小时后"
return _r(
"negotiating.recorded",
f"已记录为「协商中」。已顺延三个工作日,{when}的9:00再次发起成交状态询问。",
f"已记录为「协商中」。已顺延 {hours_text} 个工作小时,将于 {when} 再次发起成交状态询问。",
followup_when=when,
followup_hours=hours_text,
ticket_no=no,
)
def _followup_month_day(raw: str) -> str:
"""把主账再问时刻收成「9月24日」。"""
"""把主账再问时刻收成「9月24日」(旧模板兼容)。"""
when = _followup_when_text(raw)
if "日" in when:
return when.split("日", 1)[0] + "日"
return when
def _followup_when_text(raw: str) -> str:
"""把主账再问时刻收成「9月24日15:30」。"""
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)):
for fmt, n in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)):
try:
dt = datetime.strptime(text[:n], fmt)
return f"{dt.month}月{dt.day}日"
if n <= 10:
return f"{dt.month}月{dt.day}日"
return f"{dt.month}月{dt.day}日{dt.hour}:{dt.minute:02d}"
except ValueError:
continue
return ""
+7 -1
View File
@@ -1305,7 +1305,13 @@ def apply_group_deal(
send_group_text(
client,
chat_id,
copy.deal_ack(outcome, no, str(result.get("next_followup_at") or ""), lost_reason=ack_reason),
copy.deal_ack(
outcome,
no,
str(result.get("next_followup_at") or ""),
lost_reason=ack_reason,
followup_hours=result.get("negotiate_hours"),
),
)
return phase