238 lines
7.3 KiB
Python
238 lines
7.3 KiB
Python
"""
|
||
成交跟进纯规则:工作日顺延、线路对负责人。
|
||
|
||
本文件职责:给私聊/群聊成交共用,不发企微、不写主账。
|
||
也给事件异常算工作日已过小时数(非工作日整天不算;工作日当天 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 个工作日。
|
||
|
||
成交选协商中:顺延 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 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_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
|