Three service modules with no Odoo dependencies: - date_periods: fiscal year/month/quarter bounds + comparison derivation - account_hierarchy: parent-child tree walker with type filtering - totaling: move-line aggregation primitives 18 unit tests covering edge cases (December rollover, Feb 29, fiscal- year-before-start, balance check tolerance). Made-with: Cursor
104 lines
3.9 KiB
Python
104 lines
3.9 KiB
Python
"""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}")
|