Compare commits
3 Commits
a90a349fbc
...
e14ad21689
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e14ad21689 | ||
|
|
0a9ed635e8 | ||
|
|
a93162cb70 |
167
fusion_accounting/PHASE_2_PLAN.md
Normal file
167
fusion_accounting/PHASE_2_PLAN.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# Phase 2 — Fusion Accounting Reports Implementation Plan
|
||||
|
||||
**Module:** `fusion_accounting_reports`
|
||||
**Branch:** `fusion_accounting/phase-2-reports`
|
||||
**Pre-phase tag:** `fusion_accounting/pre-phase-2`
|
||||
**Estimated tasks:** 46
|
||||
**Reference:** `/Users/gurpreet/Github/RePackaged-Odoo/accounting/account_reports/`
|
||||
|
||||
## Goal
|
||||
|
||||
Replace Odoo Enterprise's `account_reports` module with a Fusion-native financial reports engine. CORE scope: P&L (income statement), balance sheet, trial balance, general ledger with drill-down. AI augmentation: anomaly detection (variance vs prior period) + AI-generated commentary. Coexists with Enterprise (Enterprise wins by default; Fusion menu shows when Enterprise absent).
|
||||
|
||||
## Architecture (HYBRID engine)
|
||||
|
||||
```
|
||||
fusion.report.engine (AbstractModel) ← shared primitives
|
||||
├── compute_pnl(period, comparison=None)
|
||||
├── compute_balance_sheet(date_to, comparison=None)
|
||||
├── compute_trial_balance(period)
|
||||
├── compute_gl(period, account_ids=None)
|
||||
├── drill_down(report_type, line_id, period)
|
||||
└── _walk_account_hierarchy(root_account_ids)
|
||||
|
||||
services/ ← pure-Python
|
||||
├── date_periods.py → fiscal-period math, comparison-period derivation
|
||||
├── account_hierarchy.py → recursive account tree walk + roll-ups
|
||||
├── totaling.py → balance/credit/debit aggregation rules
|
||||
├── currency_conversion.py → multi-currency revaluation at report date
|
||||
├── anomaly_detection.py → variance vs prior-period statistical flags
|
||||
└── commentary_generator.py → LLM prompt + parse for narrative
|
||||
|
||||
models/
|
||||
├── fusion_report.py → report definition (metadata, line specs)
|
||||
├── fusion_report_engine.py → AbstractModel orchestrator
|
||||
├── fusion_report_pnl.py → P&L definition + execute
|
||||
├── fusion_report_balance_sheet.py
|
||||
├── fusion_report_trial_balance.py
|
||||
├── fusion_report_general_ledger.py
|
||||
├── fusion_report_anomaly.py → persisted flagged variances
|
||||
├── fusion_report_commentary.py → cached AI narratives
|
||||
└── fusion_unreconciled_gl_mv.py → MV for fast GL listing on large DBs
|
||||
|
||||
controllers/bank_rec_controller.py ← 8 JSON-RPC endpoints
|
||||
├── /fusion/reports/run → execute one report
|
||||
├── /fusion/reports/drill_down → drill into a report line
|
||||
├── /fusion/reports/get_anomalies → list flagged variances
|
||||
├── /fusion/reports/get_commentary → fetch / regenerate narrative
|
||||
├── /fusion/reports/compare_periods → side-by-side comparison
|
||||
├── /fusion/reports/export_pdf → PDF export
|
||||
├── /fusion/reports/export_xlsx → XLSX export
|
||||
└── /fusion/reports/list_available → list all report types
|
||||
|
||||
static/src/
|
||||
├── scss/ ← report-specific design tokens
|
||||
├── services/reports_service.js ← reactive state + RPC wrappers
|
||||
├── views/reports_viewer/ ← top-level OWL controller
|
||||
└── components/ ← report_table, drill_down_dialog,
|
||||
period_filter, ai_commentary_panel,
|
||||
anomaly_strip
|
||||
```
|
||||
|
||||
## Coexistence
|
||||
|
||||
Same pattern as Phase 1: `group_fusion_show_when_enterprise_absent` from `fusion_accounting_core`. Reports menu only visible when `account_reports` is NOT installed. Engine + AI tools always available.
|
||||
|
||||
## Tasks (46 total)
|
||||
|
||||
### Group 1: Foundation (tasks 1-2)
|
||||
1. Safety net (tag pre-phase-2, branch phase-2-reports) — **DONE**
|
||||
2. Plan doc + module skeleton
|
||||
|
||||
### Group 2: Engine primitives — TDD layered (tasks 3-8)
|
||||
3. `services/date_periods.py` (fiscal periods, comparison derivation)
|
||||
4. `services/currency_conversion.py` + `services/account_hierarchy.py` + `services/totaling.py`
|
||||
5. `models/fusion_report.py` (report definition model)
|
||||
6. `services/line_resolver.py` (compute report rows from definition)
|
||||
7. `services/drill_down_resolver.py`
|
||||
8. `models/fusion_report_engine.py` (5-method API: compute_pnl, compute_balance_sheet, compute_trial_balance, compute_gl, drill_down)
|
||||
|
||||
### Group 3: Per-report models (tasks 9-12)
|
||||
9. P&L (income statement)
|
||||
10. Balance sheet
|
||||
11. Trial balance
|
||||
12. General ledger
|
||||
|
||||
### Group 4: AI features (tasks 13-17)
|
||||
13. Anomaly detection service (variance vs prior period)
|
||||
14. AI commentary service
|
||||
15. Commentary prompt + LLMProvider integration
|
||||
16. `fusion.report.commentary` persisted model
|
||||
17. `fusion.report.anomaly` persisted model
|
||||
|
||||
### Group 5: Backend wiring (tasks 18-20)
|
||||
18. JSON-RPC controller (8 endpoints)
|
||||
19. ReportsAdapter `_via_fusion` paths
|
||||
20. 5 new AI tools
|
||||
|
||||
### Group 6: Tests + perf (tasks 21-25)
|
||||
21. Property-based tests (totals balance invariant)
|
||||
22. Integration tests — P&L correctness vs known fixtures
|
||||
23. Integration tests — balance sheet + trial balance
|
||||
24. Materialized view for GL
|
||||
25. Cron jobs (anomaly scan + commentary refresh)
|
||||
|
||||
### Group 7: Frontend (tasks 26-33)
|
||||
26. SCSS tokens + main report stylesheet
|
||||
27. `reports_service.js`
|
||||
28. `report_viewer` component (top-level)
|
||||
29. `report_table` component (rows, totals, drill chevrons)
|
||||
30. `drill_down_dialog`
|
||||
31. `period_filter` (date range + comparison toggle)
|
||||
32. `ai_commentary_panel` (Fusion-only)
|
||||
33. `anomaly_strip` (Fusion-only)
|
||||
|
||||
### Group 8: Export + wizards (tasks 34-36)
|
||||
34. PDF export (QWeb template per report)
|
||||
35. XLSX export wizard
|
||||
36. Period selection + comparison wizard
|
||||
|
||||
### Group 9: Migration + coexistence (tasks 37-39)
|
||||
37. Migration wizard inheritance (cache existing definitions)
|
||||
38. Menu + window actions with coexistence group filter
|
||||
39. Coexistence test
|
||||
|
||||
### Group 10: Final tests + polish (tasks 40-46)
|
||||
40. 5 OWL tour tests
|
||||
41. Performance benchmarks
|
||||
42. Optimize if benchmarks fail (conditional)
|
||||
43. Local LLM compat test for commentary
|
||||
44. Update meta-module manifest
|
||||
45. CLAUDE.md, UPGRADE_NOTES.md, README.md
|
||||
46. End-to-end smoke + tag phase-2-complete + push
|
||||
|
||||
## Performance Targets (P95)
|
||||
|
||||
- `engine.compute_pnl` (1 year, 500 accounts): <2s
|
||||
- `engine.compute_balance_sheet`: <2s
|
||||
- `engine.compute_trial_balance`: <1s
|
||||
- `engine.compute_gl` (1 month, all accounts): <3s
|
||||
- `engine.drill_down` (1 line): <500ms
|
||||
- Controller `run` endpoint: <2.5s
|
||||
|
||||
## V19 Conventions (from Phase 1 lessons)
|
||||
|
||||
- `models.Constraint` not `_sql_constraints`
|
||||
- No `@api.depends('id')` on stored compute fields
|
||||
- `@route(type='jsonrpc')` not `type='json'`
|
||||
- `ir.cron` has no `numbercall` field
|
||||
- `res.groups.user_ids` not `users`
|
||||
- `ir.ui.menu.group_ids` not `groups_id`
|
||||
- `res.users.all_group_ids` for searches
|
||||
- `models.Constraint` for unique-keys
|
||||
- Prefer `env.flush_all()` before MV REFRESH
|
||||
|
||||
## Test Targets
|
||||
|
||||
Match Phase 1's test pyramid:
|
||||
- Unit (services pure-Python)
|
||||
- Integration (engine end-to-end with factories)
|
||||
- Property-based (Hypothesis, totals balance invariant)
|
||||
- Controller (HttpCase JSON-RPC)
|
||||
- MV correctness
|
||||
- Performance benchmarks (tagged 'benchmark')
|
||||
- OWL tours (tagged 'tour')
|
||||
- Local LLM smoke (tagged 'local_llm', skips when no LLM)
|
||||
|
||||
Phase 1 final: 157 tests passing. Phase 2 target: ~120-150 additional.
|
||||
1
fusion_accounting_reports/__init__.py
Normal file
1
fusion_accounting_reports/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import services
|
||||
43
fusion_accounting_reports/__manifest__.py
Normal file
43
fusion_accounting_reports/__manifest__.py
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
'name': 'Fusion Accounting Reports',
|
||||
'version': '19.0.1.0.0',
|
||||
'category': 'Accounting/Accounting',
|
||||
'summary': 'AI-augmented financial reports (P&L, balance sheet, trial balance, GL).',
|
||||
'description': """
|
||||
Fusion Accounting Reports
|
||||
=========================
|
||||
|
||||
A Fusion-native replacement for Odoo Enterprise's account_reports module.
|
||||
|
||||
CORE scope (Phase 2):
|
||||
- Income Statement (P&L)
|
||||
- Balance Sheet
|
||||
- Trial Balance
|
||||
- General Ledger (with drill-down)
|
||||
|
||||
AI augmentation:
|
||||
- Anomaly detection (variance vs prior period)
|
||||
- AI commentary (LLM-generated narrative)
|
||||
|
||||
Coexists with Enterprise: when account_reports is installed, the Fusion
|
||||
menu hides; the engine and AI tools remain available for the chat.
|
||||
""",
|
||||
'author': 'Fusion Accounting',
|
||||
'license': 'LGPL-3',
|
||||
'depends': [
|
||||
'fusion_accounting_core',
|
||||
'fusion_accounting_ai',
|
||||
'account',
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'auto_install': False,
|
||||
'application': False,
|
||||
'icon': '/fusion_accounting_reports/static/description/icon.png',
|
||||
}
|
||||
0
fusion_accounting_reports/controllers/__init__.py
Normal file
0
fusion_accounting_reports/controllers/__init__.py
Normal file
0
fusion_accounting_reports/models/__init__.py
Normal file
0
fusion_accounting_reports/models/__init__.py
Normal file
0
fusion_accounting_reports/reports/__init__.py
Normal file
0
fusion_accounting_reports/reports/__init__.py
Normal file
1
fusion_accounting_reports/security/ir.model.access.csv
Normal file
1
fusion_accounting_reports/security/ir.model.access.csv
Normal file
@@ -0,0 +1 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
|
4
fusion_accounting_reports/services/__init__.py
Normal file
4
fusion_accounting_reports/services/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from . import date_periods
|
||||
from . import account_hierarchy
|
||||
from . import totaling
|
||||
from . import currency_conversion
|
||||
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
|
||||
66
fusion_accounting_reports/services/currency_conversion.py
Normal file
66
fusion_accounting_reports/services/currency_conversion.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Multi-currency conversion for financial reports.
|
||||
|
||||
Converts move-line amounts to the report's display currency at the
|
||||
report end-date. Pure-Python - caller provides exchange rates as a
|
||||
dict {(source_code, target_code, date): rate}."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversionRate:
|
||||
source: str
|
||||
target: str
|
||||
rate: float
|
||||
rate_date: date
|
||||
|
||||
|
||||
def convert_amount(amount: float, *, source_currency: str, target_currency: str,
|
||||
rate_date: date, rates: dict) -> float:
|
||||
"""Convert `amount` from source to target at the given date.
|
||||
|
||||
`rates` is a dict keyed by (source, target, date) -> rate.
|
||||
If source == target, returns amount unchanged."""
|
||||
if source_currency == target_currency:
|
||||
return amount
|
||||
key = (source_currency, target_currency, rate_date)
|
||||
if key in rates:
|
||||
return amount * rates[key]
|
||||
inv_key = (target_currency, source_currency, rate_date)
|
||||
if inv_key in rates:
|
||||
inv = rates[inv_key]
|
||||
if inv != 0:
|
||||
return amount / inv
|
||||
candidates = [
|
||||
(d, r) for (s, t, d), r in rates.items()
|
||||
if s == source_currency and t == target_currency and d <= rate_date
|
||||
]
|
||||
if candidates:
|
||||
candidates.sort(key=lambda x: x[0], reverse=True)
|
||||
return amount * candidates[0][1]
|
||||
raise ValueError(
|
||||
f"No exchange rate available for {source_currency}->{target_currency} on or before {rate_date}"
|
||||
)
|
||||
|
||||
|
||||
def fetch_rates(env, *, target_currency_id: int, as_of: date,
|
||||
source_currency_ids: list[int] | None = None) -> dict:
|
||||
"""Fetch all relevant rates from res.currency.rate as of a given date.
|
||||
|
||||
Returns the dict-of-rates structure consumed by convert_amount.
|
||||
Pulls only rates where source != target and date <= as_of."""
|
||||
Rate = env['res.currency.rate'].sudo()
|
||||
target = env['res.currency'].browse(target_currency_id)
|
||||
domain = [
|
||||
('name', '<=', as_of),
|
||||
('currency_id', '!=', target.id),
|
||||
]
|
||||
if source_currency_ids:
|
||||
domain.append(('currency_id', 'in', source_currency_ids))
|
||||
rates_recs = Rate.search(domain)
|
||||
|
||||
out = {}
|
||||
for r in rates_recs:
|
||||
out[(r.currency_id.name, target.name, r.name)] = (1.0 / r.rate) if r.rate else 0.0
|
||||
return out
|
||||
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
|
||||
BIN
fusion_accounting_reports/static/description/icon.png
Normal file
BIN
fusion_accounting_reports/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
2
fusion_accounting_reports/tests/__init__.py
Normal file
2
fusion_accounting_reports/tests/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import test_services_unit
|
||||
from . import test_currency_conversion
|
||||
53
fusion_accounting_reports/tests/test_currency_conversion.py
Normal file
53
fusion_accounting_reports/tests/test_currency_conversion.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Unit tests for currency_conversion service."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from odoo.addons.fusion_accounting_reports.services.currency_conversion import (
|
||||
convert_amount, fetch_rates,
|
||||
)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestCurrencyConversion(TransactionCase):
|
||||
|
||||
def test_same_currency_returns_unchanged(self):
|
||||
result = convert_amount(100, source_currency='USD',
|
||||
target_currency='USD',
|
||||
rate_date=date(2026, 4, 19), rates={})
|
||||
self.assertEqual(result, 100)
|
||||
|
||||
def test_direct_rate(self):
|
||||
rates = {('USD', 'CAD', date(2026, 4, 19)): 1.35}
|
||||
result = convert_amount(100, source_currency='USD',
|
||||
target_currency='CAD',
|
||||
rate_date=date(2026, 4, 19), rates=rates)
|
||||
self.assertEqual(result, 135)
|
||||
|
||||
def test_inverse_rate(self):
|
||||
rates = {('CAD', 'USD', date(2026, 4, 19)): 0.74}
|
||||
result = convert_amount(100, source_currency='USD',
|
||||
target_currency='CAD',
|
||||
rate_date=date(2026, 4, 19), rates=rates)
|
||||
self.assertAlmostEqual(result, 100 / 0.74, places=2)
|
||||
|
||||
def test_falls_back_to_most_recent_rate(self):
|
||||
rates = {
|
||||
('USD', 'CAD', date(2026, 1, 1)): 1.30,
|
||||
('USD', 'CAD', date(2026, 3, 1)): 1.32,
|
||||
}
|
||||
result = convert_amount(100, source_currency='USD',
|
||||
target_currency='CAD',
|
||||
rate_date=date(2026, 4, 19), rates=rates)
|
||||
self.assertEqual(result, 132)
|
||||
|
||||
def test_raises_when_no_rate(self):
|
||||
with self.assertRaises(ValueError):
|
||||
convert_amount(100, source_currency='EUR',
|
||||
target_currency='CAD',
|
||||
rate_date=date(2026, 4, 19), rates={})
|
||||
|
||||
def test_fetch_rates_from_env(self):
|
||||
cad = self.env.ref('base.CAD')
|
||||
rates = fetch_rates(self.env, target_currency_id=cad.id, as_of=date(2026, 4, 19))
|
||||
self.assertIsInstance(rates, dict)
|
||||
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))
|
||||
0
fusion_accounting_reports/wizards/__init__.py
Normal file
0
fusion_accounting_reports/wizards/__init__.py
Normal file
Reference in New Issue
Block a user