"""Attendance-cycle date arithmetic. A cycle runs from ``start_day`` of one month through ``start_day - 1`` of the next (the default 21 → 20). All boundaries are computed on *local calendar dates* — never UTC ``datetime`` — because a cycle that ends "on the 20th" must end on the 20th in the company's timezone, not slip a day because the worker runs in UTC. Pure functions only (no DB, no app state) so they're trivially testable and reusable from the service, the reminder cron, and tests. """ from __future__ import annotations from dataclasses import dataclass from datetime import date, datetime from zoneinfo import ZoneInfo # Company timezone for "what cycle are we in today" defaulting. The # concrete cycle a report belongs to is always stored as explicit # start/end dates, so this only affects the *suggested* current/previous # cycle — it never reinterprets a persisted cycle. COMPANY_TZ = ZoneInfo("Asia/Shanghai") @dataclass(frozen=True) class CyclePeriod: """An inclusive [start_date, end_date] attendance period.""" start_date: date end_date: date @property def key(self) -> str: """Canonical idempotency key — ``YYYY-MM`` of the end date. 2026-05-21 .. 2026-06-20 → ``"2026-06"`` (the "June cycle"). """ return self.end_date.strftime("%Y-%m") @property def label(self) -> str: return f"{self.start_date.isoformat()} ~ {self.end_date.isoformat()}" def _clamp_start_day(start_day: int) -> int: # Guard against nonsense config. 28 is the highest day-of-month that # exists in every month, so cycles stay well-defined year-round. if start_day < 1: return 1 if start_day > 28: return 28 return start_day def _prev_month(year: int, month: int) -> tuple[int, int]: return (year - 1, 12) if month == 1 else (year, month - 1) def _last_day_of_month(year: int, month: int) -> int: if month == 12: nxt = date(year + 1, 1, 1) else: nxt = date(year, month + 1, 1) from datetime import timedelta return (nxt - timedelta(days=1)).day def period_ending_in(year: int, month: int, start_day: int = 21) -> CyclePeriod: """The cycle whose end date falls in ``year``-``month``. With ``start_day=21`` the period is ``[prev-month-21, this-month-20]``. ``start_day=1`` degenerates to the full calendar month ``[1, last-day-of-month]``. """ sd = _clamp_start_day(start_day) if sd == 1: return CyclePeriod( start_date=date(year, month, 1), end_date=date(year, month, _last_day_of_month(year, month)), ) end_date = date(year, month, sd - 1) py, pm = _prev_month(year, month) start_date = date(py, pm, sd) return CyclePeriod(start_date=start_date, end_date=end_date) def current_period(today: date, start_day: int = 21) -> CyclePeriod: """The cycle that *contains* ``today``.""" sd = _clamp_start_day(start_day) if sd == 1: return period_ending_in(today.year, today.month, sd) # On/after the start day → the cycle ends next month; otherwise it # ends this month. if today.day >= sd: ny, nm = (today.year + 1, 1) if today.month == 12 else (today.year, today.month + 1) return period_ending_in(ny, nm, sd) return period_ending_in(today.year, today.month, sd) def latest_completed_period(today: date, start_day: int = 21) -> CyclePeriod: """The most recent cycle whose end date has already passed. This is the sensible default for "generate the latest reports" — you report on a period only once it's over. """ cur = current_period(today, start_day) if today > cur.end_date: return cur # We're mid-cycle: the latest *completed* one ended the day before # this cycle started. py, pm = (cur.start_date.year, cur.start_date.month) return period_ending_in(py, pm, _clamp_start_day(start_day)) def today_in_company_tz(now: datetime | None = None) -> date: """Current local calendar date in the company timezone. ``now`` is injectable for tests; production passes ``None`` and we read the wall clock. Always returns a tz-correct local *date*. """ if now is None: now = datetime.now(COMPANY_TZ) elif now.tzinfo is None: now = now.replace(tzinfo=COMPANY_TZ) return now.astimezone(COMPANY_TZ).date() def parse_cycle_key(cycle_key: str, start_day: int = 21) -> CyclePeriod: """Inverse of :pyattr:`CyclePeriod.key` — ``"2026-06"`` → the period.""" try: year_s, month_s = cycle_key.split("-", 1) year, month = int(year_s), int(month_s) if not (1 <= month <= 12): raise ValueError except (ValueError, AttributeError) as exc: # noqa: PERF203 raise ValueError(f"invalid cycle_key: {cycle_key!r}") from exc return period_ending_in(year, month, start_day)