feat(fusion_accounting_reports): pure-Python services for date+account+totaling
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
This commit is contained in:
@@ -0,0 +1 @@
|
||||
from . import services
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from . import date_periods
|
||||
from . import account_hierarchy
|
||||
from . import totaling
|
||||
|
||||
62
fusion_accounting_reports/services/account_hierarchy.py
Normal file
62
fusion_accounting_reports/services/account_hierarchy.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Account hierarchy walker.
|
||||
|
||||
Given a flat list of accounts with parent_id pointers, build a tree and
|
||||
provide a recursive walker that yields (account, depth, ancestors) tuples.
|
||||
Used by report line resolvers to render group sub-totals."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Iterator
|
||||
|
||||
|
||||
@dataclass
|
||||
class AccountNode:
|
||||
id: int
|
||||
code: str
|
||||
name: str
|
||||
account_type: str
|
||||
parent_id: int | None
|
||||
children: list['AccountNode'] = field(default_factory=list)
|
||||
|
||||
|
||||
def build_tree(accounts: list[dict]) -> list[AccountNode]:
|
||||
"""Build a forest from a flat list of account dicts.
|
||||
|
||||
Each dict must have keys: id, code, name, account_type, parent_id (nullable)."""
|
||||
nodes: dict[int, AccountNode] = {}
|
||||
for acc in accounts:
|
||||
nodes[acc['id']] = AccountNode(
|
||||
id=acc['id'], code=acc['code'], name=acc['name'],
|
||||
account_type=acc['account_type'],
|
||||
parent_id=acc.get('parent_id'),
|
||||
)
|
||||
roots: list[AccountNode] = []
|
||||
for node in nodes.values():
|
||||
if node.parent_id and node.parent_id in nodes:
|
||||
nodes[node.parent_id].children.append(node)
|
||||
else:
|
||||
roots.append(node)
|
||||
for node in nodes.values():
|
||||
node.children.sort(key=lambda n: n.code)
|
||||
roots.sort(key=lambda n: n.code)
|
||||
return roots
|
||||
|
||||
|
||||
def walk(roots: list[AccountNode], *, max_depth: int = 10) -> Iterator[tuple[AccountNode, int, list[AccountNode]]]:
|
||||
"""Depth-first walk yielding (node, depth, ancestors)."""
|
||||
def _walk(node: AccountNode, depth: int, ancestors: list[AccountNode]):
|
||||
yield (node, depth, ancestors)
|
||||
if depth < max_depth:
|
||||
for child in node.children:
|
||||
yield from _walk(child, depth + 1, ancestors + [node])
|
||||
for root in roots:
|
||||
yield from _walk(root, 0, [])
|
||||
|
||||
|
||||
def filter_by_account_type(roots: list[AccountNode], type_prefix: str) -> list[AccountNode]:
|
||||
"""Return all nodes whose account_type starts with type_prefix
|
||||
(e.g. 'asset_' returns asset_receivable, asset_cash, etc.)."""
|
||||
matches: list[AccountNode] = []
|
||||
for node, _depth, _ancestors in walk(roots):
|
||||
if node.account_type.startswith(type_prefix):
|
||||
matches.append(node)
|
||||
return matches
|
||||
103
fusion_accounting_reports/services/date_periods.py
Normal file
103
fusion_accounting_reports/services/date_periods.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""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}")
|
||||
49
fusion_accounting_reports/services/totaling.py
Normal file
49
fusion_accounting_reports/services/totaling.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Move-line aggregation primitives for report totaling.
|
||||
|
||||
Pure-Python helpers - callers pass dicts with debit/credit/balance/currency keys,
|
||||
no Odoo recordsets needed. Keeps the math testable without an ORM."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class TotalLine:
|
||||
debit: float = 0.0
|
||||
credit: float = 0.0
|
||||
balance: float = 0.0
|
||||
debit_currency: float = 0.0
|
||||
credit_currency: float = 0.0
|
||||
balance_currency: float = 0.0
|
||||
line_count: int = 0
|
||||
|
||||
|
||||
def aggregate(move_lines: list[dict]) -> TotalLine:
|
||||
"""Aggregate a list of move-line dicts into a TotalLine.
|
||||
|
||||
Each dict must have: debit, credit, balance (signed). Optional:
|
||||
debit_currency, credit_currency, balance_currency."""
|
||||
out = TotalLine()
|
||||
for ml in move_lines:
|
||||
out.debit += ml.get('debit', 0.0)
|
||||
out.credit += ml.get('credit', 0.0)
|
||||
out.balance += ml.get('balance', 0.0)
|
||||
out.debit_currency += ml.get('debit_currency', 0.0)
|
||||
out.credit_currency += ml.get('credit_currency', 0.0)
|
||||
out.balance_currency += ml.get('balance_currency', 0.0)
|
||||
out.line_count += 1
|
||||
return out
|
||||
|
||||
|
||||
def aggregate_per_account(move_lines: list[dict]) -> dict[int, TotalLine]:
|
||||
"""Group + aggregate by account_id. Returns {account_id: TotalLine}."""
|
||||
grouped: dict[int, list[dict]] = {}
|
||||
for ml in move_lines:
|
||||
acct = ml['account_id']
|
||||
grouped.setdefault(acct, []).append(ml)
|
||||
return {acct: aggregate(lines) for acct, lines in grouped.items()}
|
||||
|
||||
|
||||
def is_balanced(move_lines: list[dict], *, tolerance: float = 0.005) -> bool:
|
||||
"""True if total debits == total credits (within tolerance for rounding)."""
|
||||
agg = aggregate(move_lines)
|
||||
return abs(agg.debit - agg.credit) <= tolerance
|
||||
@@ -0,0 +1 @@
|
||||
from . import test_services_unit
|
||||
|
||||
142
fusion_accounting_reports/tests/test_services_unit.py
Normal file
142
fusion_accounting_reports/tests/test_services_unit.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""Unit tests for date_periods, account_hierarchy, totaling services."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from odoo.addons.fusion_accounting_reports.services.date_periods import (
|
||||
Period, fiscal_year_bounds, month_bounds, quarter_bounds, comparison_period,
|
||||
)
|
||||
from odoo.addons.fusion_accounting_reports.services.account_hierarchy import (
|
||||
build_tree, walk, filter_by_account_type,
|
||||
)
|
||||
from odoo.addons.fusion_accounting_reports.services.totaling import (
|
||||
aggregate, aggregate_per_account, is_balanced,
|
||||
)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestDatePeriods(TransactionCase):
|
||||
|
||||
def test_fiscal_year_calendar_default(self):
|
||||
period = fiscal_year_bounds(date(2026, 6, 15))
|
||||
self.assertEqual(period.date_from, date(2026, 1, 1))
|
||||
self.assertEqual(period.date_to, date(2026, 12, 31))
|
||||
|
||||
def test_fiscal_year_april_start(self):
|
||||
period = fiscal_year_bounds(date(2026, 6, 15), fy_start_month=4)
|
||||
self.assertEqual(period.date_from, date(2026, 4, 1))
|
||||
self.assertEqual(period.date_to, date(2027, 3, 31))
|
||||
|
||||
def test_fiscal_year_before_start_returns_prior(self):
|
||||
period = fiscal_year_bounds(date(2026, 2, 15), fy_start_month=4)
|
||||
self.assertEqual(period.date_from, date(2025, 4, 1))
|
||||
self.assertEqual(period.date_to, date(2026, 3, 31))
|
||||
|
||||
def test_month_bounds(self):
|
||||
period = month_bounds(date(2026, 4, 19))
|
||||
self.assertEqual(period.date_from, date(2026, 4, 1))
|
||||
self.assertEqual(period.date_to, date(2026, 4, 30))
|
||||
|
||||
def test_month_bounds_december(self):
|
||||
period = month_bounds(date(2026, 12, 19))
|
||||
self.assertEqual(period.date_from, date(2026, 12, 1))
|
||||
self.assertEqual(period.date_to, date(2026, 12, 31))
|
||||
|
||||
def test_quarter_bounds_q2(self):
|
||||
period = quarter_bounds(date(2026, 5, 15))
|
||||
self.assertEqual(period.date_from, date(2026, 4, 1))
|
||||
self.assertEqual(period.date_to, date(2026, 6, 30))
|
||||
|
||||
def test_comparison_previous_year(self):
|
||||
period = Period(date(2026, 1, 1), date(2026, 12, 31), 'FY 2026')
|
||||
comp = comparison_period(period, 'previous_year')
|
||||
self.assertEqual(comp.date_from, date(2025, 1, 1))
|
||||
self.assertEqual(comp.date_to, date(2025, 12, 31))
|
||||
|
||||
def test_comparison_previous_period_same_length(self):
|
||||
period = Period(date(2026, 4, 1), date(2026, 4, 30), 'Apr 2026')
|
||||
comp = comparison_period(period, 'previous_period')
|
||||
self.assertEqual(comp.date_to, date(2026, 3, 31))
|
||||
self.assertEqual(comp.days, period.days)
|
||||
|
||||
def test_period_validates_bounds(self):
|
||||
with self.assertRaises(ValueError):
|
||||
Period(date(2026, 12, 31), date(2026, 1, 1), 'invalid')
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestAccountHierarchy(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.flat = [
|
||||
{'id': 1, 'code': '1', 'name': 'Assets', 'account_type': 'asset_root', 'parent_id': None},
|
||||
{'id': 2, 'code': '11', 'name': 'Cash', 'account_type': 'asset_cash', 'parent_id': 1},
|
||||
{'id': 3, 'code': '12', 'name': 'AR', 'account_type': 'asset_receivable', 'parent_id': 1},
|
||||
{'id': 4, 'code': '2', 'name': 'Liabilities', 'account_type': 'liability_root', 'parent_id': None},
|
||||
{'id': 5, 'code': '21', 'name': 'AP', 'account_type': 'liability_payable', 'parent_id': 4},
|
||||
]
|
||||
|
||||
def test_build_tree_returns_two_roots(self):
|
||||
roots = build_tree(self.flat)
|
||||
self.assertEqual(len(roots), 2)
|
||||
|
||||
def test_walk_yields_all_nodes(self):
|
||||
roots = build_tree(self.flat)
|
||||
ids = [n.id for n, _, _ in walk(roots)]
|
||||
self.assertEqual(set(ids), {1, 2, 3, 4, 5})
|
||||
|
||||
def test_walk_depth_correct(self):
|
||||
roots = build_tree(self.flat)
|
||||
depths = {n.id: depth for n, depth, _ in walk(roots)}
|
||||
self.assertEqual(depths[1], 0)
|
||||
self.assertEqual(depths[2], 1)
|
||||
self.assertEqual(depths[3], 1)
|
||||
|
||||
def test_filter_by_type_prefix(self):
|
||||
roots = build_tree(self.flat)
|
||||
assets = filter_by_account_type(roots, 'asset_')
|
||||
self.assertEqual(len(assets), 3)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestTotaling(TransactionCase):
|
||||
|
||||
def test_aggregate_empty(self):
|
||||
result = aggregate([])
|
||||
self.assertEqual(result.debit, 0.0)
|
||||
self.assertEqual(result.line_count, 0)
|
||||
|
||||
def test_aggregate_simple(self):
|
||||
lines = [
|
||||
{'debit': 100, 'credit': 0, 'balance': 100, 'account_id': 1},
|
||||
{'debit': 0, 'credit': 50, 'balance': -50, 'account_id': 1},
|
||||
]
|
||||
result = aggregate(lines)
|
||||
self.assertEqual(result.debit, 100)
|
||||
self.assertEqual(result.credit, 50)
|
||||
self.assertEqual(result.balance, 50)
|
||||
|
||||
def test_aggregate_per_account_groups_correctly(self):
|
||||
lines = [
|
||||
{'debit': 100, 'credit': 0, 'balance': 100, 'account_id': 1},
|
||||
{'debit': 50, 'credit': 0, 'balance': 50, 'account_id': 1},
|
||||
{'debit': 0, 'credit': 25, 'balance': -25, 'account_id': 2},
|
||||
]
|
||||
result = aggregate_per_account(lines)
|
||||
self.assertEqual(result[1].debit, 150)
|
||||
self.assertEqual(result[2].credit, 25)
|
||||
|
||||
def test_is_balanced_true(self):
|
||||
lines = [
|
||||
{'debit': 100, 'credit': 0, 'balance': 100},
|
||||
{'debit': 0, 'credit': 100, 'balance': -100},
|
||||
]
|
||||
self.assertTrue(is_balanced(lines))
|
||||
|
||||
def test_is_balanced_false(self):
|
||||
lines = [
|
||||
{'debit': 100, 'credit': 0, 'balance': 100},
|
||||
{'debit': 0, 'credit': 50, 'balance': -50},
|
||||
]
|
||||
self.assertFalse(is_balanced(lines))
|
||||
Reference in New Issue
Block a user