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