Files
inquiry_robot/inquiry-agent/agent/policy/deal_followup.py
T

342 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
成交跟进纯规则:工作日顺延、线路对负责人。
本文件职责:给私聊/群聊成交共用,不发企微、不写主账。
也给事件异常算工作日已过小时数(非工作日整天不算;工作日当天 0–24 都算)。
禁止:在回调线程算完还同步推负责人;把六态写进本文件。
"""
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),
# 国庆只休到 10 月 7 日。10 月 8 日周四正常上班,不能算假期。
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),
# 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),
# 国庆调休:9 月 20 日周日、10 月 10 日周六上班。
date(2026, 9, 20),
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 个工作日。
仍给单测与节假日校验用;协商再问已改走 add_workday_hours。
"""
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
# 协商中再问:主账 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)
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:
"""
起止之间折合多少工作日小时。
非工作日整天不算;工作日当天 0–24 都算,不切上午晚上。
起点落在休息日:从下一个工作日 00:00 起算。
副作用:无。可在 Worker 巡检调用,禁止放进企微回调线程做 IO。
"""
a = start.astimezone(SHANGHAI) if start.tzinfo else start.replace(tzinfo=SHANGHAI)
b = now.astimezone(SHANGHAI) if now.tzinfo else now.replace(tzinfo=SHANGHAI)
if b <= a:
return 0.0
total = 0.0
cur = a
while cur.date() < b.date():
nxt = datetime.combine(cur.date() + timedelta(days=1), time(0, 0), tzinfo=SHANGHAI)
if is_workday(cur.date()):
total += (nxt - cur).total_seconds() / 3600.0
cur = nxt
if is_workday(b.date()):
total += (b - cur).total_seconds() / 3600.0
return total
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_od_codes(facts: dict[str, str] | None) -> tuple[str, str]:
"""从工单 facts 取空运起运/目的三字码(兼容港/地/投影字段)。"""
src = dict(facts or {})
origin = resolve_airport_code(
str(
src.get("起运港")
or src.get("起运地")
or src.get("originCode")
or src.get("origin_code")
or ""
)
)
dest = resolve_airport_code(
str(
src.get("目的港")
or src.get("目的地")
or src.get("destinationCode")
or src.get("destination_code")
or ""
)
)
return origin, dest
def air_route_keys(facts: dict[str, str] | None) -> set[str]:
"""本单可对上的空运线路名:三字码对 + 目的港。"""
origin, dest = _air_od_codes(facts)
keys: set[str] = set()
if dest:
keys.add(dest)
if origin and dest:
keys.add(f"{origin}-{dest}")
return keys
def air_staff_route_matches(*, routes: str, facts: dict[str, str] | None) -> bool:
"""
空运群 @ 航线:按员工「负责线路」是否对上本单。
与成交负责人推送同口径(全线 / 外采 / 三字码对),但不要求 isLeader。
仅有目的港、无起运时:后台常配 CGO-KUL,允许 token 以 -目的 结尾命中。
"""
tokens = _split_routes(routes)
if not tokens:
return False
if _has_all_line(tokens, line="AIR"):
return True
if any("外采-空运" in t or t == "外采-空运" for t in tokens) and _is_waicai(facts):
return True
origin, dest = _air_od_codes(facts)
keys = air_route_keys(facts)
if any(t in keys for t in tokens):
return True
# 只有目的港:KUL 应对上 CGO-KUL / SZX-KUL 等配线
if dest and not origin:
suffix = f"-{dest}"
if any(t == dest or t.endswith(suffix) for t in tokens):
return True
return False
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 _has_all_line(tokens: list[str], *, line: str) -> bool:
"""
该负责人的「负责线路」是否覆盖本业务线全部线路。
成交通知只推负责人(isLeader),不群发。后台负责线路选项里的
「空运全线 / 海运全线 / 陆运全线」表示这位负责人管整条业务线;
旧数据可能仍写「全线」。只认整词,避免误伤「外采-空运」等。
"""
want = {
"SEA": {"海运全线", "全线"},
"LAND": {"陆运全线", "全线"},
"AIR": {"空运全线", "全线"},
}.get((line or "").strip().upper(), set())
return any(t in want for t in tokens)
def leader_matches(
*,
role_code: str,
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 _has_all_line(tokens, line="SEA")
if line == "LAND":
if code != "land":
return False
if _has_all_line(tokens, line="LAND"):
return True
want = str((facts or {}).get("线路类别") or "").strip()
return bool(want) and want in tokens
if line == "AIR":
if code != "air":
return False
return air_staff_route_matches(routes=routes, facts=facts)
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