"""Unit tests for attendance cycle arithmetic + Feishu fact normalisation. Pure functions — no DB, no network.""" from __future__ import annotations import uuid from datetime import date, datetime import pytest from app.core.exceptions import ValidationError from app.domains.attendance import constants as c from app.domains.attendance.cycle import ( COMPANY_TZ, current_period, latest_completed_period, parse_cycle_key, period_ending_in, ) from app.domains.attendance.facts import build_person_snapshot from app.domains.attendance.feishu_attendance import ApprovalSpan from app.domains.attendance.models import AttendanceCycle from app.domains.attendance.service import AttendanceService # ── Cycle arithmetic ──────────────────────────────────────────────── def test_period_21_to_20(): p = period_ending_in(2026, 6, start_day=21) assert p.start_date == date(2026, 5, 21) assert p.end_date == date(2026, 6, 20) assert p.key == "2026-06" def test_period_january_wraps_year(): p = period_ending_in(2026, 1, start_day=21) assert p.start_date == date(2025, 12, 21) assert p.end_date == date(2026, 1, 20) assert p.key == "2026-01" def test_period_start_day_1_is_calendar_month(): p = period_ending_in(2026, 2, start_day=1) assert p.start_date == date(2026, 2, 1) assert p.end_date == date(2026, 2, 28) def test_current_period_before_and_after_start_day(): # On the 25th → inside the cycle that ends next month. p = current_period(date(2026, 5, 25), start_day=21) assert p.start_date == date(2026, 5, 21) assert p.end_date == date(2026, 6, 20) # On the 5th → inside the cycle that ends this month. p2 = current_period(date(2026, 6, 5), start_day=21) assert p2.start_date == date(2026, 5, 21) assert p2.end_date == date(2026, 6, 20) def test_latest_completed_period(): # Mid-cycle (June 5) → latest completed is the May cycle (4.21–5.20). p = latest_completed_period(date(2026, 6, 5), start_day=21) assert p.start_date == date(2026, 4, 21) assert p.end_date == date(2026, 5, 20) # Right after a cycle closed (June 21) → that just-closed cycle. p2 = latest_completed_period(date(2026, 6, 21), start_day=21) assert p2.end_date == date(2026, 6, 20) def test_parse_cycle_key_roundtrip(): p = period_ending_in(2026, 6, 21) assert parse_cycle_key(p.key, 21).start_date == p.start_date assert parse_cycle_key(p.key, 21).end_date == p.end_date @pytest.mark.asyncio async def test_existing_cycle_does_not_move_when_start_day_changes(db_session): old_period = period_ending_in(2026, 6, start_day=21) cycle = AttendanceCycle( cycle_key=old_period.key, start_date=old_period.start_date, end_date=old_period.end_date, intern_department="Original Intern Department", ) db_session.add(cycle) await db_session.commit() svc = AttendanceService(db_session) changed_period = period_ending_in(2026, 6, start_day=1) with pytest.raises(ValidationError, match="不能用新的周期配置覆盖历史记录"): await svc._find_or_create_cycle( changed_period, department="New Intern Department", actor_user_id=uuid.uuid4(), ) await db_session.refresh(cycle) assert (cycle.start_date, cycle.end_date) == ( old_period.start_date, old_period.end_date, ) assert cycle.intern_department == "Original Intern Department" # ── Fact normalisation ────────────────────────────────────────────── def _period(): return period_ending_in(2026, 6, 21) def _result(day: int, ci: str, co: str, ci_supp="None", co_supp="None") -> dict: return { "user_id": "fs-1", "day": day, "shift_id": "shift-1", "group_id": "grp-1", "records": [{ "check_in_result": ci, "check_out_result": co, "check_in_result_supplement": ci_supp, "check_out_result_supplement": co_supp, }], } def _ts(day: int, hhmm: str) -> int: """Epoch seconds for ``HH:MM`` on the given yyyymmdd, in COMPANY_TZ.""" d = datetime.strptime(str(day), "%Y%m%d").date() h, m = (int(x) for x in hhmm.split(":")) return int(datetime(d.year, d.month, d.day, h, m, tzinfo=COMPANY_TZ).timestamp()) def _result_punched( day: int, ci: str, co: str, in_at: str, out_at: str, ci_supp="None", co_supp="None", ) -> dict: """A day result carrying actual punch epochs (打卡流水), so the duration-weighted 出勤天数 can be computed.""" return { "user_id": "fs-1", "day": day, "shift_id": "shift-1", "group_id": "grp-1", "records": [{ "check_in_result": ci, "check_out_result": co, "check_in_result_supplement": ci_supp, "check_out_result_supplement": co_supp, "check_in_record": {"check_time": _ts(day, in_at)}, "check_out_record": {"check_time": _ts(day, out_at)}, }], } def test_short_day_is_duration_weighted(): # 许晴 2026-06-05: 15:15→18:38 (~3.38h) / 9h ≈ 0.4 天, not a full day. snap = build_person_snapshot( [_result_punched(20260605, "Normal", "Normal", "15:15", "18:38")], feishu_user_id="fs-1", period=_period(), ) assert snap["days"][0]["attendance_days"] == 0.4 assert snap["summary"]["work_days"] == 0.4 def test_full_span_caps_at_one_day(): # 09:25→18:43 spans ~9.3h → capped at 1.0 (overtime doesn't inflate). snap = build_person_snapshot( [_result_punched(20260601, "Normal", "Normal", "09:25", "18:43")], feishu_user_id="fs-1", period=_period(), ) assert snap["days"][0]["attendance_days"] == 1.0 assert snap["summary"]["work_days"] == 1.0 def test_work_days_sums_fractions_across_month(): snap = build_person_snapshot( [ _result_punched(20260601, "Normal", "Normal", "09:25", "18:43"), # 1.0 _result_punched(20260605, "Normal", "Normal", "15:15", "18:38"), # 0.4 ], feishu_user_id="fs-1", period=_period(), ) assert snap["summary"]["work_days"] == 1.4 def test_missing_punch_epoch_falls_back_to_full_day(): # No 打卡流水 epochs (pull carried only 应到) → can't measure span → # a real attendance day still counts as a full 1.0, not 0. snap = build_person_snapshot( [_result(20260602, "Normal", "Normal")], feishu_user_id="fs-1", period=_period(), ) assert snap["days"][0]["attendance_days"] == 1.0 assert snap["summary"]["work_days"] == 1.0 def test_leave_day_accrues_no_attendance(): snap = build_person_snapshot( [_result(20260607, c.RESULT_LACK, c.RESULT_LACK, ci_supp=c.SUPPLEMENT_LEAVE, co_supp=c.SUPPLEMENT_LEAVE)], feishu_user_id="fs-1", period=_period(), ) assert snap["days"][0]["attendance_days"] == 0.0 assert snap["summary"]["work_days"] == 0 def _leave_span(day, start, end, seconds): iso = datetime.strptime(str(day), "%Y%m%d").date().isoformat() return ApprovalSpan( "leave", iso, iso, "Intern leave", start_ts=_ts(day, start), end_ts=_ts(day, end), leave_seconds=seconds, ) @pytest.mark.parametrize( "in_at,out_at,start,end,seconds,expected", [ ("09:26", "15:00", "15:00", "19:00", 14400, 0.5), ("15:00", "19:00", "10:00", "15:00", 14400, 0.4), ("13:00", "15:00", "15:00", "19:00", 14400, 0.2), ("10:00", "19:00", "10:00", "19:00", 28800, 0.0), ("15:10", "18:30", "15:00", "19:00", 14400, 0.0), ("09:26", "15:00", "15:00", "19:00", None, 0.0), ], ) def test_leave_counts_only_proven_work_outside_approved_hours( in_at, out_at, start, end, seconds, expected, ): period = period_ending_in(2026, 8, 21) spans = [_leave_span(20260721, start, end, seconds)] snap = build_person_snapshot( [_result_punched(20260721, "Normal", "NoNeedCheck", in_at, out_at)], feishu_user_id="fs-1", period=period, approval_dates=AttendanceService._spans_to_overlay(spans, period), ) assert snap["days"][0]["category"] == "leave" assert snap["days"][0]["attendance_days"] == expected assert snap["summary"]["work_days"] == expected assert snap["summary"]["abnormal_days"] == 0 def test_partial_leave_month_total_and_duplicate_approvals(): period = period_ending_in(2026, 8, 21) half = _leave_span(20260721, "15:00", "19:00", 14400) full = _leave_span(20260722, "10:00", "19:00", 28800) snap = build_person_snapshot( [ _result_punched(20260721, "Normal", "NoNeedCheck", "09:26", "15:00"), _result(20260722, "NoNeedCheck", "NoNeedCheck"), _result_punched(20260723, "Normal", "Normal", "09:00", "18:00"), ], feishu_user_id="fs-1", period=period, approval_dates=AttendanceService._spans_to_overlay([half, half, full], period), ) assert [d["attendance_days"] for d in snap["days"]] == [0.5, 0.0, 1.0] assert snap["summary"]["work_days"] == 1.5 def test_partial_leave_missing_punches_does_not_default_to_full_day(): period = period_ending_in(2026, 8, 21) snap = build_person_snapshot( [_result(20260721, "Normal", "NoNeedCheck")], feishu_user_id="fs-1", period=period, approval_dates=AttendanceService._spans_to_overlay( [_leave_span(20260721, "15:00", "19:00", 14400)], period, ), ) assert snap["summary"]["work_days"] == 0 def test_multiday_leave_total_is_not_misused_as_daily_duration(): period = period_ending_in(2026, 8, 21) span = ApprovalSpan( "leave", "2026-07-20", "2026-07-22", "Intern leave", start_ts=_ts(20260720, "15:00"), end_ts=_ts(20260722, "19:00"), leave_seconds=20 * 3600, ) overlay = AttendanceService._spans_to_overlay([span], period) assert set(overlay) == {"2026-07-21", "2026-07-22"} snap = build_person_snapshot( [_result_punched(20260721, "Normal", "NoNeedCheck", "09:26", "15:00")], feishu_user_id="fs-1", period=period, approval_dates=overlay, ) assert snap["summary"]["work_days"] == 0 def test_two_distinct_half_day_leaves_do_not_accrue_work(): period = period_ending_in(2026, 8, 21) spans = [ _leave_span(20260721, "10:00", "15:00", 14400), _leave_span(20260721, "15:00", "19:00", 14400), ] snap = build_person_snapshot( [_result_punched(20260721, "Normal", "NoNeedCheck", "09:26", "19:00")], feishu_user_id="fs-1", period=period, approval_dates=AttendanceService._spans_to_overlay(spans, period), ) assert snap["summary"]["work_days"] == 0 def test_normal_day_no_issues(): snap = build_person_snapshot( [_result(20260601, "Normal", "Normal")], feishu_user_id="fs-1", period=_period(), ) s = snap["summary"] assert s["work_days"] == 1 assert s["normal_days"] == 1 assert s["abnormal_days"] == 0 assert snap["days"][0]["is_abnormal"] is False def test_lack_unremedied_is_abnormal(): snap = build_person_snapshot( [_result(20260602, c.RESULT_LACK, "Normal")], feishu_user_id="fs-1", period=_period(), ) s = snap["summary"] assert s["lack_in_count"] == 1 # check-in missing, check-out Normal assert s["lack_count"] == 1 assert s["abnormal_days"] == 1 assert snap["days"][0]["category"] == "lack_in" def test_lack_remedied_by_card_replacement_not_abnormal(): snap = build_person_snapshot( [_result(20260603, c.RESULT_LACK, "Normal", ci_supp=c.SUPPLEMENT_CARD)], feishu_user_id="fs-1", period=_period(), ) s = snap["summary"] assert s["lack_count"] == 0 assert s["remedied_count"] == 1 assert s["abnormal_days"] == 0 def test_card_application_pending_surfaced(): snap = build_person_snapshot( [_result(20260604, c.RESULT_LACK, "Normal", ci_supp=c.SUPPLEMENT_CARD_APPLICATION)], feishu_user_id="fs-1", period=_period(), ) assert snap["summary"]["remedy_pending_count"] == 1 assert snap["summary"]["abnormal_days"] == 1 assert snap["days"][0]["category"] == "lack_in" assert snap["days"][0]["remedy_pending"] is True def test_late_and_early_counted(): snap = build_person_snapshot( [ _result(20260605, c.RESULT_LATE, "Normal"), _result(20260606, "Normal", c.RESULT_EARLY), ], feishu_user_id="fs-1", period=_period(), ) s = snap["summary"] assert s["late_count"] == 1 assert s["early_count"] == 1 assert s["abnormal_days"] == 2 def test_leave_supplement_excuses_lack(): snap = build_person_snapshot( [_result(20260607, c.RESULT_LACK, c.RESULT_LACK, ci_supp=c.SUPPLEMENT_LEAVE, co_supp=c.SUPPLEMENT_LEAVE)], feishu_user_id="fs-1", period=_period(), ) assert snap["summary"]["lack_count"] == 0 assert snap["summary"]["abnormal_days"] == 0 def test_empty_results_zeroed(): snap = build_person_snapshot([], feishu_user_id=None, period=_period()) assert snap["summary"]["work_days"] == 0 assert snap["days"] == [] assert snap["feishu_user_id"] is None def test_lack_out_when_only_checkout_missing(): snap = build_person_snapshot( [_result(20260608, "Normal", c.RESULT_LACK)], feishu_user_id="fs-1", period=_period(), ) assert snap["days"][0]["category"] == "lack_out" assert snap["summary"]["lack_out_count"] == 1 assert snap["summary"]["lack_in_count"] == 0 def test_absent_full_day_both_sides_missing(): snap = build_person_snapshot( [_result(20260609, c.RESULT_LACK, c.RESULT_LACK)], feishu_user_id="fs-1", period=_period(), ) assert snap["days"][0]["category"] == "absent" assert snap["summary"]["absent_count"] == 1 assert snap["summary"]["abnormal_days"] == 1 assert snap["summary"]["work_days"] == 0 # 旷工 is not attendance def test_approval_overlay_marks_leave_with_type(): # Feishu returns NoNeedCheck for a leave day; the 假勤审批 overlay makes # it authoritative 请假 + 假别. snap = build_person_snapshot( [_result(20260610, "NoNeedCheck", "NoNeedCheck")], feishu_user_id="fs-1", period=_period(), approval_dates={"2026-06-10": {"kind": "leave", "name": "年假"}}, ) d = snap["days"][0] assert d["category"] == "leave" assert d["leave_type"] == "年假" assert snap["summary"]["leave_days"] == 1 def test_approval_overlay_excuses_lack(): # An approved leave covering a day with a missing punch → excused # (category leave, not 缺卡). snap = build_person_snapshot( [_result(20260612, c.RESULT_LACK, c.RESULT_LACK)], feishu_user_id="fs-1", period=_period(), approval_dates={"2026-06-12": {"kind": "trip", "name": ""}}, ) assert snap["days"][0]["category"] == "trip" assert snap["summary"]["absent_count"] == 0 assert snap["summary"]["trip_days"] == 1