"""Normalise raw Feishu attendance into the per-intern monthly snapshot. The snapshot is the immutable, human-readable record of what the intern and leader saw and confirmed — per-day ``days`` (each with an authoritative ``category``) plus a rolled-up ``summary``. Classification is rigorous and sourced from two Feishu surfaces: * ``user_tasks/query`` (打卡结果) → per-side check-in / check-out result (Normal / Late / Early / Lack / NoNeedCheck …) + supplement. * ``user_approvals/query`` (假勤审批, passed in as ``approval_dates``) → **authoritative** leave / business-trip / outing coverage per date, with the leave 假别 (年假/病假/事假…). This overrides the打卡-result supplement inference; the supplement is only a fallback when approvals are unavailable (e.g. scope/transient failure). Per-day ``category`` (exactly one, priority order): leave / trip / out ← approved (or supplement-inferred) absence absent (旷工) ← both check-in AND check-out missing, unexcused lack_in / lack_out ← one side missing, unexcused (缺上班/缺下班卡) late / early ← came late / left early, unexcused normal ← punched, nothing wrong rest (休) ← not required to punch, no absence approval 法定节假日 is NOT separately distinguished: Feishu returns ``NoNeedCheck`` for both public holidays and weekends and exposes no reliable holiday flag here, so both render as ``rest`` (休) — verified correct against prod, and we do not guess holidays from the weekday. """ from __future__ import annotations from datetime import datetime from app.domains.attendance import constants as c from app.domains.attendance.cycle import COMPANY_TZ, CyclePeriod # ── Day categories (exactly one per day; frontend renders by these) ── CAT_NORMAL = "normal" CAT_LATE = "late" CAT_EARLY = "early" CAT_LACK_IN = "lack_in" # 缺上班卡 CAT_LACK_OUT = "lack_out" # 缺下班卡 CAT_ABSENT = "absent" # 全天旷工 (both sides missing, unexcused) CAT_LEAVE = "leave" # 请假 (+ leave_type) CAT_TRIP = "trip" # 出差 CAT_OUT = "out" # 外出 CAT_REST = "rest" # 休息 / 节假日 ABNORMAL_CATS: frozenset[str] = frozenset({ CAT_LATE, CAT_EARLY, CAT_LACK_IN, CAT_LACK_OUT, CAT_ABSENT, }) # Categories that represent the intern actually being on the clock — the # ones that accrue 出勤天数 (attendance days). Partial leave is evaluated # separately using approved duration and actual punches; trip/out, # unexcused 旷工, and 休 do not accrue attendance. WORK_CATS: frozenset[str] = frozenset({ CAT_NORMAL, CAT_LATE, CAT_EARLY, CAT_LACK_IN, CAT_LACK_OUT, }) # 满勤标准: 每日打卡总时长需满 9 小时(含午休)。实习生出勤按出勤时长计, # 单日出勤天数 = min(1.0, 打卡总时长 / 9h),四舍五入到 0.1 天。基准 # 09:00~18:00 含午休 1h,可弹性晚到晚走或早到早走各 2h,均落在同一 9h 口径内。 FULL_DAY_HOURS = 9.0 # Leave measures net working time (the 9h attendance span includes 1h lunch). LEAVE_DAY_SECONDS = 8 * 3600 def _epoch(val: object) -> int | None: """Coerce a Feishu epoch (seconds or ms) to integer seconds, or None. Anything that doesn't cleanly resolve to a positive epoch returns None.""" if val is None or val == "": return None try: n = int(val) # type: ignore[arg-type] except (TypeError, ValueError): return None if n > 10_000_000_000: # milliseconds → seconds n //= 1000 return n if n > 0 else None def _fmt_time(val: object) -> str | None: """Format a Feishu epoch (seconds or ms) into local ``HH:MM``. Best-effort — anything that doesn't cleanly resolve returns None so the calendar just omits the time rather than rendering garbage.""" n = _epoch(val) if n is None: return None try: return datetime.fromtimestamp(n, tz=COMPANY_TZ).strftime("%H:%M") except (ValueError, OSError, OverflowError): return None def _attendance_fraction(first_in_ts: int | None, last_out_ts: int | None) -> float: """Duration-weighted attendance for one on-the-clock day. ``first_in_ts`` / ``last_out_ts`` are the earliest actual check-in and latest actual check-out epochs (seconds) of the day. The fraction is ``min(1.0, span_hours / 9h)`` rounded to 0.1 天. When actual punch epochs are unavailable (Feishu returned only 应到 shift times, no 打卡流水), the span can't be measured — we fall back to a full day (1.0) rather than silently zeroing a real attendance day.""" if first_in_ts is None or last_out_ts is None or last_out_ts <= first_in_ts: return 1.0 span_hours = (last_out_ts - first_in_ts) / 3600.0 return round(min(1.0, span_hours / FULL_DAY_HOURS), 1) def _leave_attendance_fraction( first_in_ts: int | None, last_out_ts: int | None, approval: dict | None, ) -> float: """Count proven work on a partial-leave day without counting leave as work. Keep the usual punch-span / 9h rule, capped by the day's remaining entitlement (1 - approved leave seconds / 8h). A 4h afternoon leave therefore caps 09:26–15:00 at 0.5 days. Unknown duration, incomplete punches, ambiguous overlapping approvals, and multi-day totals retain the conservative 0; never manufacture a full day from missing data. """ if first_in_ts is None or last_out_ts is None or last_out_ts <= first_in_ts: return 0.0 intervals = (approval or {}).get("leave_intervals") or [] if not intervals: return 0.0 bounds: list[tuple[int, int, int]] = [] for interval in intervals: start, end, seconds = ( interval.get("start_ts"), interval.get("end_ts"), interval.get("seconds"), ) if (not isinstance(start, int) or not isinstance(end, int) or not isinstance(seconds, int) or seconds <= 0 or end <= start): return 0.0 bounds.append((start, end, seconds)) bounds = sorted(set(bounds)) if any(right[0] < left[1] for left, right in zip(bounds, bounds[1:])): return 0.0 # no reliable per-date net duration for overlapping approvals remaining = max(0.0, 1.0 - sum(b[2] for b in bounds) / LEAVE_DAY_SECONDS) overlap = sum(max(0, min(last_out_ts, end) - max(first_in_ts, start)) for start, end, _ in bounds) worked = max(0, last_out_ts - first_in_ts - overlap) return round(min(remaining, worked / (FULL_DAY_HOURS * 3600)), 1) def _parse_day(day: object) -> str | None: """Feishu ``day`` is an int ``yyyymmdd`` → ISO ``yyyy-mm-dd``.""" try: return datetime.strptime(str(int(day)), "%Y%m%d").date().isoformat() # type: ignore[arg-type] except (TypeError, ValueError): return None def _eval_side(result: str | None, supplement: str | None, agg: dict, side: str) -> None: """Fold one punch side (check-in or check-out) into the day aggregate. Records raw per-side signals (late/early/lack/remedied) and the supplement-derived absence hints (leave/trip/out) — the day-level excuse decision happens later in :func:`build_person_snapshot`.""" supp = supplement or c.SUPPLEMENT_NONE # Supplement-level absence hints are independent of the result (Feishu # emits NoNeedCheck + supplement=Leave for full-day leave). if supp == c.SUPPLEMENT_LEAVE: agg["supp_leave"] = True elif supp == c.SUPPLEMENT_TRAVEL: agg["supp_trip"] = True elif supp == c.SUPPLEMENT_GOOUT: agg["supp_out"] = True if result == c.RESULT_NORMAL: agg["punched"] = True return if result in c.BENIGN_RESULTS: # NoNeedCheck / SystemCheck / Todo return if result == c.RESULT_LACK: if supp == c.SUPPLEMENT_CARD: agg["remedied"] = True elif supp == c.SUPPLEMENT_CARD_APPLICATION: agg[f"lack_{side}"] = True agg["remedy_pending"] = True elif supp in c.RESOLVED_SUPPLEMENTS: pass # excused by an approved correction (leave/trip/manager fix) else: agg[f"lack_{side}"] = True elif result == c.RESULT_LATE: if supp not in c.RESOLVED_SUPPLEMENTS: agg["late"] = True elif result == c.RESULT_EARLY: if supp not in c.RESOLVED_SUPPLEMENTS: agg["early"] = True def build_person_snapshot( raw_results: list[dict], *, feishu_user_id: str | None, period: CyclePeriod, approval_dates: dict[str, dict] | None = None, ) -> dict: """Build the snapshot dict for ONE intern. ``raw_results`` is the subset of ``user_task_results`` for this person. ``approval_dates`` maps ISO date → ``{"kind": "leave"|"trip"|"out", "name": <假别 or "">}`` from the 假勤审批 overlay (authoritative). Leave entries also retain ``leave_intervals`` with time bounds and a proven single-day duration in seconds. Without approval details the per-day supplement classifies leave, but cannot prove partial attendance. """ approval_dates = approval_dates or {} by_day: dict[str, dict] = {} for res in raw_results: iso = _parse_day(res.get("day")) if iso is None: continue entry = by_day.setdefault(iso, { "date": iso, "weekday": datetime.fromisoformat(iso).weekday() + 1, # 1=Mon..7=Sun "shift_id": res.get("shift_id"), "group_id": res.get("group_id"), "late": False, "early": False, "lack_in": False, "lack_out": False, "remedied": False, "remedy_pending": False, "punched": False, "supp_leave": False, "supp_trip": False, "supp_out": False, "first_in_ts": None, "last_out_ts": None, "segments": [], }) for rec in res.get("records") or []: if not isinstance(rec, dict): continue ci, co = rec.get("check_in_result"), rec.get("check_out_result") ci_supp = rec.get("check_in_result_supplement") co_supp = rec.get("check_out_result_supplement") _eval_side(ci, ci_supp, entry, "in") _eval_side(co, co_supp, entry, "out") ci_rec = rec.get("check_in_record") or {} co_rec = rec.get("check_out_record") or {} ci_ts = _epoch(ci_rec.get("check_time")) co_ts = _epoch(co_rec.get("check_time")) # Earliest actual check-in / latest actual check-out across all # of the day's segments → the day's total clocked span. if ci_ts is not None: entry["first_in_ts"] = ( ci_ts if entry["first_in_ts"] is None else min(entry["first_in_ts"], ci_ts) ) if co_ts is not None: entry["last_out_ts"] = ( co_ts if entry["last_out_ts"] is None else max(entry["last_out_ts"], co_ts) ) ci_time = _fmt_time(ci_rec.get("check_time")) co_time = _fmt_time(co_rec.get("check_time")) if ci_time or co_time: entry["punched"] = True entry["segments"].append({ "check_in_result": ci, "check_out_result": co, "check_in_supplement": ci_supp, "check_out_supplement": co_supp, "check_in_time": ci_time, "check_out_time": co_time, "check_in_scheduled": _fmt_time(rec.get("check_in_shift_time")), "check_out_scheduled": _fmt_time(rec.get("check_out_shift_time")), }) days: list[dict] = [] counts = { "work_days": 0, "normal_days": 0, "abnormal_days": 0, "late_count": 0, "early_count": 0, "lack_in_count": 0, "lack_out_count": 0, "absent_count": 0, "remedy_pending_count": 0, "remedied_count": 0, "leave_days": 0, "trip_days": 0, "out_days": 0, } for iso in sorted(by_day): e = by_day[iso] # Authoritative excuse: approval overlay first, supplement fallback. appr = approval_dates.get(iso) if appr: excuse_kind = appr.get("kind") leave_type = appr.get("name") or None elif e["supp_leave"]: excuse_kind, leave_type = "leave", None elif e["supp_trip"]: excuse_kind, leave_type = "trip", None elif e["supp_out"]: excuse_kind, leave_type = "out", None else: excuse_kind, leave_type = None, None if excuse_kind == "leave": category = CAT_LEAVE elif excuse_kind == "trip": category = CAT_TRIP elif excuse_kind == "out": category = CAT_OUT elif e["lack_in"] and e["lack_out"] and not e["punched"]: category = CAT_ABSENT elif e["lack_in"]: category = CAT_LACK_IN elif e["lack_out"]: category = CAT_LACK_OUT elif e["late"]: category = CAT_LATE elif e["early"]: category = CAT_EARLY elif e["punched"]: category = CAT_NORMAL else: category = CAT_REST is_abnormal = category in ABNORMAL_CATS # 出勤天数 is duration-weighted: a day on the clock accrues # min(1.0, span/9h), not a flat 1.0 (see _attendance_fraction). if category in WORK_CATS: attendance_days = _attendance_fraction(e["first_in_ts"], e["last_out_ts"]) elif category == CAT_LEAVE: attendance_days = _leave_attendance_fraction( e["first_in_ts"], e["last_out_ts"], appr, ) else: attendance_days = 0.0 counts["work_days"] += attendance_days if category == CAT_NORMAL: counts["normal_days"] += 1 elif category == CAT_LATE: counts["late_count"] += 1 elif category == CAT_EARLY: counts["early_count"] += 1 elif category == CAT_LACK_IN: counts["lack_in_count"] += 1 elif category == CAT_LACK_OUT: counts["lack_out_count"] += 1 elif category == CAT_ABSENT: counts["absent_count"] += 1 elif category == CAT_LEAVE: counts["leave_days"] += 1 elif category == CAT_TRIP: counts["trip_days"] += 1 elif category == CAT_OUT: counts["out_days"] += 1 if is_abnormal: counts["abnormal_days"] += 1 if e["remedied"]: counts["remedied_count"] += 1 if e["remedy_pending"] and is_abnormal: counts["remedy_pending_count"] += 1 days.append({ "date": iso, "weekday": e["weekday"], "shift_id": e["shift_id"], "group_id": e["group_id"], "segments": e["segments"], "category": category, "leave_type": leave_type, "leave_intervals": (appr or {}).get("leave_intervals", []), "attendance_days": attendance_days, "is_abnormal": is_abnormal, "remedied": e["remedied"], "remedy_pending": e["remedy_pending"], "had_punch": e["punched"], "issues": [category] if is_abnormal else [], }) summary = dict(counts) # Duration-weighted attendance total — kill float-addition noise # (e.g. 18.400000000000002) by pinning to 0.1 天. summary["work_days"] = round(counts["work_days"], 1) # Backward-compatible aggregate consumed by the denormalised report # column + the飞书 card: total unexcused 缺卡-type days. summary["lack_count"] = ( counts["lack_in_count"] + counts["lack_out_count"] + counts["absent_count"] ) return { "source": "feishu_attendance", "feishu_user_id": feishu_user_id, "period": { "start_date": period.start_date.isoformat(), "end_date": period.end_date.isoformat(), "cycle_key": period.key, }, "summary": summary, "days": days, } def empty_snapshot(*, feishu_user_id: str | None, period: CyclePeriod) -> dict: """Snapshot for a person with no attendance rows. Zeroed summary.""" return build_person_snapshot([], feishu_user_id=feishu_user_id, period=period)