Compare commits

...

3 Commits

Author SHA1 Message Date
gsinghpal
1ebff01d35 feat(fusion_accounting_reports): seed 3 partner-grouped reports
Adds Aged Receivable, Aged Payable, and Partner Ledger as fusion.report
records using the new compute_partner_grouped engine method.

REPORT_TYPES is extended with aged_receivable / aged_payable /
partner_ledger so each report has a unique report_type. The HTTP
controller dispatches these to engine.compute_partner_grouped with
the appropriate account_type via PARTNER_GROUPED_ACCOUNT_TYPE.

Output includes per-partner aging buckets: current, 1-30, 31-60,
61-90, 90+ days.

Westin total: 4 + 4 + 3 = 11 of Enterprise's 22 standard reports.

Made-with: Cursor
2026-04-19 23:55:45 -04:00
gsinghpal
ff6d21a561 feat(fusion_accounting_reports): partner-grouped engine method
Adds engine.compute_partner_grouped(period, account_type=...) that
returns per-partner aggregations with aging buckets (current/1-30/
31-60/61-90/90+). SQL-direct for performance — single GROUP BY query
with conditional sum per bucket.

Foundation for the 3 partner-grouped reports landing in commit 3:
Aged Receivable, Aged Payable, Partner Ledger.

Made-with: Cursor
2026-04-19 23:54:32 -04:00
gsinghpal
6896c71b79 feat(fusion_accounting_reports): seed 4 more standard reports
Adds Cash Flow Statement, Executive Summary, Tax Summary, and Annual
Statements as fusion.report records with line_specs. All work with the
existing engine's bucket-sum pattern — no engine changes needed.

Westin total: 4 + 4 = 8 of Enterprise's 22 standard reports now in
fusion_accounting_reports. Partner-grouped reports (Aged AR/AP,
Partner Ledger) need an engine extension — in commit 2.

Made-with: Cursor
2026-04-19 23:53:16 -04:00
12 changed files with 300 additions and 2 deletions

View File

@@ -36,6 +36,13 @@ menu hides; the engine and AI tools remain available for the chat.
'data/report_balance_sheet.xml',
'data/report_trial_balance.xml',
'data/report_general_ledger.xml',
'data/report_cash_flow.xml',
'data/report_executive_summary.xml',
'data/report_tax_report.xml',
'data/report_annual_statements.xml',
'data/report_aged_receivable.xml',
'data/report_aged_payable.xml',
'data/report_partner_ledger.xml',
'data/cron.xml',
'reports/report_pdf_template.xml',
'wizards/xlsx_export_wizard_views.xml',

View File

@@ -18,7 +18,16 @@ from ..services.date_periods import Period
_logger = logging.getLogger(__name__)
REPORT_TYPES = {'pnl', 'balance_sheet', 'trial_balance', 'general_ledger'}
REPORT_TYPES = {
'pnl', 'balance_sheet', 'trial_balance', 'general_ledger',
'aged_receivable', 'aged_payable', 'partner_ledger',
}
PARTNER_GROUPED_ACCOUNT_TYPE = {
'aged_receivable': 'asset_receivable',
'aged_payable': 'liability_payable',
'partner_ledger': 'asset_receivable',
}
def _parse_date(value):
@@ -76,6 +85,14 @@ class FusionReportsController(http.Controller):
if report_type == 'trial_balance':
period = _build_period(date_from, date_to)
return engine.compute_trial_balance(period, company_id=company_id)
if report_type in PARTNER_GROUPED_ACCOUNT_TYPE:
period = _build_period(date_from, date_to)
return engine.compute_partner_grouped(
period,
account_type=PARTNER_GROUPED_ACCOUNT_TYPE[report_type],
comparison=comparison,
company_id=company_id,
)
# general_ledger
period = _build_period(date_from, date_to)
return engine.compute_gl(period, company_id=company_id)

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="report_aged_payable" model="fusion.report">
<field name="name">Aged Payable</field>
<field name="code">aged_payable</field>
<field name="report_type">aged_payable</field>
<field name="sequence">36</field>
<field name="description">Per-vendor outstanding payables, bucketed by aging.</field>
<field name="line_specs" eval="[
{'label': 'Aged Payable', 'account_type_for_grouping': 'liability_payable'}
]"/>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="report_aged_receivable" model="fusion.report">
<field name="name">Aged Receivable</field>
<field name="code">aged_receivable</field>
<field name="report_type">aged_receivable</field>
<field name="sequence">35</field>
<field name="description">Per-customer outstanding receivables, bucketed by aging.</field>
<field name="line_specs" eval="[
{'label': 'Aged Receivable', 'account_type_for_grouping': 'asset_receivable'}
]"/>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="report_annual_statements" model="fusion.report">
<field name="name">Annual Statements</field>
<field name="code">annual_statements</field>
<field name="report_type">pnl</field>
<field name="sequence">11</field>
<field name="default_comparison_mode">previous_year</field>
<field name="description">Year-over-year P&amp;L comparison for annual reporting.</field>
<field name="line_specs" eval="[
{'label': 'Revenue', 'account_type_prefix': 'income', 'sign': -1, 'level': 0},
{'label': 'Cost of Goods Sold', 'account_type_prefix': 'expense_direct_cost', 'sign': -1, 'level': 1},
{'label': 'Gross Profit', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0},
{'label': 'Operating Expenses', 'account_type_prefix': 'expense', 'sign': -1, 'level': 1},
{'label': 'OPERATING INCOME', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0}
]"/>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="report_cash_flow" model="fusion.report">
<field name="name">Cash Flow Statement</field>
<field name="code">cash_flow</field>
<field name="report_type">pnl</field>
<field name="sequence">15</field>
<field name="default_comparison_mode">previous_year</field>
<field name="description">Cash flow by activity (operating, investing, financing).</field>
<field name="line_specs" eval="[
{'label': 'Operating Activities', 'level': 0},
{'label': 'Net Income (from operations)', 'account_type_prefix': 'income', 'sign': -1, 'level': 1},
{'label': 'Depreciation Add-back', 'account_type_prefix': 'expense_depreciation', 'sign': 1, 'level': 1},
{'label': 'Operating Cash Flow', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0},
{'label': 'Investing Activities', 'level': 0},
{'label': 'Fixed Asset Purchases', 'account_type_prefix': 'asset_fixed', 'sign': -1, 'level': 1},
{'label': 'Investing Cash Flow', 'compute': 'subtotal', 'above': 1, 'sign': 1, 'level': 0},
{'label': 'Financing Activities', 'level': 0},
{'label': 'Liabilities (long-term)', 'account_type_prefix': 'liability_non_current', 'sign': 1, 'level': 1},
{'label': 'Equity', 'account_type_prefix': 'equity', 'sign': 1, 'level': 1},
{'label': 'Financing Cash Flow', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0},
{'label': 'NET CHANGE IN CASH', 'compute': 'subtotal', 'above': 3, 'sign': 1, 'level': 0}
]"/>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="report_executive_summary" model="fusion.report">
<field name="name">Executive Summary</field>
<field name="code">executive_summary</field>
<field name="report_type">pnl</field>
<field name="sequence">5</field>
<field name="default_comparison_mode">previous_year</field>
<field name="description">Top-level KPI summary: revenue, expenses, net income, key balance positions.</field>
<field name="line_specs" eval="[
{'label': 'PROFIT &amp; LOSS', 'level': 0},
{'label': 'Revenue', 'account_type_prefix': 'income', 'sign': -1, 'level': 1},
{'label': 'Expenses', 'account_type_prefix': 'expense', 'sign': -1, 'level': 1},
{'label': 'Net Income', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0},
{'label': 'BALANCE POSITIONS', 'level': 0},
{'label': 'Cash &amp; Bank', 'account_type_prefix': 'asset_cash', 'sign': 1, 'level': 1},
{'label': 'Receivables', 'account_type_prefix': 'asset_receivable', 'sign': 1, 'level': 1},
{'label': 'Payables', 'account_type_prefix': 'liability_payable', 'sign': -1, 'level': 1},
{'label': 'Net Working Position', 'compute': 'subtotal', 'above': 3, 'sign': 1, 'level': 0}
]"/>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="report_partner_ledger" model="fusion.report">
<field name="name">Partner Ledger</field>
<field name="code">partner_ledger</field>
<field name="report_type">partner_ledger</field>
<field name="sequence">40</field>
<field name="description">Per-partner ledger combining receivable and payable activity.</field>
<field name="line_specs" eval="[
{'label': 'Partner Ledger', 'account_type_for_grouping': 'asset_receivable'}
]"/>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="report_tax_summary" model="fusion.report">
<field name="name">Tax Summary</field>
<field name="code">tax_summary</field>
<field name="report_type">trial_balance</field>
<field name="sequence">25</field>
<field name="description">Tax liability + asset positions. v1: aggregate-level only; per-tax-code breakdown is Phase 2.5.</field>
<field name="line_specs" eval="[
{'label': 'Tax Asset (recoverable)', 'account_type_prefix': 'asset_current', 'sign': 1, 'level': 0},
{'label': 'Tax Liability (collected)', 'account_type_prefix': 'liability_current', 'sign': -1, 'level': 0},
{'label': 'NET TAX POSITION', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0}
]"/>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -13,6 +13,9 @@ REPORT_TYPES = [
('balance_sheet', 'Balance Sheet'),
('trial_balance', 'Trial Balance'),
('general_ledger', 'General Ledger'),
('aged_receivable', 'Aged Receivable'),
('aged_payable', 'Aged Payable'),
('partner_ledger', 'Partner Ledger'),
]

View File

@@ -14,7 +14,7 @@ Internal pipeline (per report run):
"""
import logging
from datetime import date
from datetime import date, timedelta
from odoo import _, api, models
from odoo.exceptions import ValidationError
@@ -118,6 +118,130 @@ class FusionReportEngine(models.AbstractModel):
limit=500,
)
@api.model
def compute_partner_grouped(
self, period: Period, *, account_type: str = 'asset_receivable',
comparison: str = 'none', company_id: int | None = None,
) -> dict:
"""Per-partner aggregation report (Aged Receivable, Aged Payable,
Partner Ledger).
Returns a dict with ``rows`` = list of partner-level aggregates.
Each row has the partner_id, partner_name, total residual, and
aging buckets: current / 1-30 / 31-60 / 61-90 / 90+ days past
``period.date_to``.
SQL-direct for performance: a single GROUP BY query with conditional
sum per bucket. Only un-reconciled, posted lines with non-zero
residual at the as-of date are included.
"""
company_id = company_id or self.env.company.id
accounts = self.env['account.account'].sudo().search([
('account_type', '=', account_type),
('company_ids', 'in', company_id),
])
if not accounts:
return {
'report_type': 'partner_grouped',
'account_type': account_type,
'period': {
'date_from': str(period.date_from),
'date_to': str(period.date_to),
'label': period.label,
},
'rows': [],
'total': 0.0,
'partner_count': 0,
}
as_of = period.date_to
d30 = as_of - timedelta(days=30)
d60 = as_of - timedelta(days=60)
d90 = as_of - timedelta(days=90)
self.env.cr.execute(
"""
SELECT
COALESCE(p.id, 0) AS partner_id,
COALESCE(p.name, '(no partner)') AS partner_name,
SUM(aml.amount_residual) AS total_residual,
SUM(CASE
WHEN aml.date_maturity >= %s
OR aml.date_maturity IS NULL
THEN aml.amount_residual ELSE 0
END) AS bucket_current,
SUM(CASE
WHEN aml.date_maturity < %s
AND aml.date_maturity >= %s
THEN aml.amount_residual ELSE 0
END) AS bucket_1_30,
SUM(CASE
WHEN aml.date_maturity < %s
AND aml.date_maturity >= %s
THEN aml.amount_residual ELSE 0
END) AS bucket_31_60,
SUM(CASE
WHEN aml.date_maturity < %s
AND aml.date_maturity >= %s
THEN aml.amount_residual ELSE 0
END) AS bucket_61_90,
SUM(CASE
WHEN aml.date_maturity < %s
THEN aml.amount_residual ELSE 0
END) AS bucket_90_plus,
COUNT(*) AS line_count
FROM account_move_line aml
LEFT JOIN res_partner p ON p.id = aml.partner_id
WHERE aml.account_id = ANY(%s)
AND aml.parent_state = 'posted'
AND aml.reconciled = false
AND aml.amount_residual != 0
AND aml.company_id = %s
AND aml.date <= %s
GROUP BY p.id, p.name
HAVING SUM(aml.amount_residual) != 0
ORDER BY total_residual DESC
""",
(
as_of,
as_of, d30,
d30, d60,
d60, d90,
d90,
list(accounts.ids), company_id, as_of,
),
)
rows = []
for r in self.env.cr.dictfetchall():
rows.append({
'partner_id': r['partner_id'] or False,
'partner_name': r['partner_name'] or '(no partner)',
'total': float(r['total_residual'] or 0),
'bucket_current': float(r['bucket_current'] or 0),
'bucket_1_30': float(r['bucket_1_30'] or 0),
'bucket_31_60': float(r['bucket_31_60'] or 0),
'bucket_61_90': float(r['bucket_61_90'] or 0),
'bucket_90_plus': float(r['bucket_90_plus'] or 0),
'line_count': r['line_count'],
})
total = sum(r['total'] for r in rows)
return {
'report_type': 'partner_grouped',
'account_type': account_type,
'period': {
'date_from': str(period.date_from),
'date_to': str(period.date_to),
'label': period.label,
},
'company_id': company_id,
'rows': rows,
'total': total,
'partner_count': len(rows),
}
# ============================================================
# PRIVATE HELPERS
# ============================================================

View File

@@ -90,6 +90,23 @@ class TestFusionReportEngine(TransactionCase):
)
self.assertIsInstance(rows, list)
def test_compute_partner_grouped_receivable(self):
period = Period(date(2025, 1, 1), date(2025, 12, 31), 'Test')
result = self.env['fusion.report.engine'].compute_partner_grouped(
period, account_type='asset_receivable',
)
self.assertEqual(result['report_type'], 'partner_grouped')
self.assertEqual(result['account_type'], 'asset_receivable')
self.assertIn('rows', result)
self.assertIn('total', result)
self.assertIn('partner_count', result)
if result['rows']:
for key in (
'partner_name', 'total', 'bucket_current', 'bucket_1_30',
'bucket_31_60', 'bucket_61_90', 'bucket_90_plus',
):
self.assertIn(key, result['rows'][0])
def test_no_report_raises_validation_error(self):
period = Period(date(2026, 1, 1), date(2026, 12, 31), 'Test 2026')
# Inactivate any pre-existing GL definitions so the lookup