"""Date period math for financial reports. Pure-Python helpers that compute: - Fiscal year start/end given any reference date + company fiscal year settings - Comparison periods (prior year same period, prior period, etc.) - Period boundaries for monthly / quarterly / yearly reporting NO Odoo imports - all callers pass in primitive types so the same module is unit-testable without an Odoo registry.""" from dataclasses import dataclass from datetime import date, timedelta from typing import Literal PeriodGranularity = Literal['month', 'quarter', 'year', 'custom'] ComparisonMode = Literal['none', 'previous_period', 'previous_year'] @dataclass(frozen=True) class Period: date_from: date date_to: date label: str def __post_init__(self): if self.date_from > self.date_to: raise ValueError(f"date_from ({self.date_from}) > date_to ({self.date_to})") @property def days(self) -> int: return (self.date_to - self.date_from).days + 1 def fiscal_year_bounds(reference_date: date, *, fy_start_month: int = 1, fy_start_day: int = 1) -> Period: """Return the fiscal year period containing `reference_date`. Default: calendar year (Jan 1 - Dec 31). Pass fy_start_month=4, fy_start_day=1 for an April-March fiscal year.""" if reference_date.month < fy_start_month or ( reference_date.month == fy_start_month and reference_date.day < fy_start_day ): start_year = reference_date.year - 1 else: start_year = reference_date.year start = date(start_year, fy_start_month, fy_start_day) next_start = date(start_year + 1, fy_start_month, fy_start_day) end = next_start - timedelta(days=1) return Period(date_from=start, date_to=end, label=f"FY {start_year}") def month_bounds(reference_date: date) -> Period: """Return the calendar month containing `reference_date`.""" start = reference_date.replace(day=1) if reference_date.month == 12: next_start = date(reference_date.year + 1, 1, 1) else: next_start = date(reference_date.year, reference_date.month + 1, 1) return Period( date_from=start, date_to=next_start - timedelta(days=1), label=start.strftime('%B %Y'), ) def quarter_bounds(reference_date: date) -> Period: """Return the calendar quarter containing `reference_date`.""" quarter = (reference_date.month - 1) // 3 + 1 start_month = (quarter - 1) * 3 + 1 start = date(reference_date.year, start_month, 1) end_month = start_month + 2 if end_month == 12: end = date(reference_date.year, 12, 31) else: end = date(reference_date.year, end_month + 1, 1) - timedelta(days=1) return Period(date_from=start, date_to=end, label=f"Q{quarter} {reference_date.year}") def comparison_period(period: Period, mode: ComparisonMode) -> Period | None: """Derive the comparison period for `period` per `mode`. `previous_period`: same length, immediately before `previous_year`: same calendar dates, one year earlier `none`: returns None""" if mode == 'none': return None if mode == 'previous_period': days = period.days new_to = period.date_from - timedelta(days=1) new_from = new_to - timedelta(days=days - 1) return Period(date_from=new_from, date_to=new_to, label=f"{period.label} (previous)") if mode == 'previous_year': try: new_from = period.date_from.replace(year=period.date_from.year - 1) new_to = period.date_to.replace(year=period.date_to.year - 1) except ValueError: new_from = period.date_from.replace(year=period.date_from.year - 1, day=28) new_to = period.date_to.replace(year=period.date_to.year - 1, day=28) return Period(date_from=new_from, date_to=new_to, label=f"{period.label} (prev year)") raise ValueError(f"Unknown comparison mode: {mode}")