Compare commits

..

6 Commits

Author SHA1 Message Date
gsinghpal
1c773bb5e4 test(fusion_accounting_reports): coexistence behavior
Mirrors Phase 1's coexistence test pattern. Verifies:

- The coexistence group (group_fusion_show_when_enterprise_absent)
  exists and is referenceable
- The reports engine model (fusion.report.engine) is always
  registered, regardless of Enterprise install state
- The Financial Reports root menu requires the coexistence group
- The Open Report... sub-menu (period picker wizard) is gated too

Uses V19 group_ids attribute with a graceful fallback to groups_id for
older runtime variants.

Tests: 3 new (test_coexistence.py). Net 115 -> 118.
Made-with: Cursor
2026-04-19 16:20:09 -04:00
gsinghpal
5994a1b96b feat(fusion_accounting_reports): menu + window actions with coexistence group filter
Adds views/menu_views.xml with a Financial Reports root menu (sequence
50) and three sub-items: Open Report... (period picker wizard), Export
to XLSX... (xlsx wizard), and Anomalies (list view of fusion.report.anomaly).

Every menu and the root are gated by group_fusion_show_when_enterprise_absent
so the entire Fusion Reports tree disappears when Enterprise's
account_reports module is installed - the engine, AI tools, and exports
remain available; only the UI hides to avoid duplicate menus.

Includes a window action for fusion.report.anomaly (list,form).

Made-with: Cursor
2026-04-19 16:19:24 -04:00
gsinghpal
e17e7f9e4c feat(fusion_accounting_reports): migration wizard bootstrap step verifies report definitions
Inherits fusion.migration.wizard from fusion_accounting_migration and
appends a _reports_bootstrap_step that confirms the 4 CORE report
definitions (pnl, balance_sheet, trial_balance, general_ledger) exist
after migration. Returns a structured result with expected, present, and
missing report types.

Hooked into action_run_migration via super(); failures are logged
(warning) but never raised, so the migration chain remains tolerant of
ordering between sub-modules.

Adds fusion_accounting_migration to manifest depends.

Tests: 1 new (test_migration_round_trip.py). Net 114 -> 115.
Made-with: Cursor
2026-04-19 16:18:39 -04:00
gsinghpal
8de4beb46a feat(fusion_accounting_reports): period picker wizard with common presets
Adds fusion.period.picker.wizard - a guided entry point that lets users
pick a report type and a common period preset (this/last month, quarter,
YTD, last year, or custom range). The wizard uses the existing date_periods
service helpers (month_bounds, quarter_bounds, fiscal_year_bounds) to
pre-fill date_from / date_to via @api.onchange.

action_open_report returns a client action that launches the OWL reports
viewer with default_report_type / default_date_from / default_date_to /
default_comparison in the context.

Tests: 3 new (test_period_picker.py). Net 111 -> 114.
Made-with: Cursor
2026-04-19 16:17:46 -04:00
gsinghpal
7d7bd93345 feat(fusion_accounting_reports): XLSX export wizard
Adds a TransientModel wizard fusion.xlsx.export.wizard that lets users
pick a report type, date range, and comparison mode, then runs the
engine and produces an XLSX via xlsxwriter (in-memory).

The wizard exposes a download field that becomes available after export
finishes. Works on P&L, Balance Sheet, Trial Balance, and General Ledger.
Comparison columns are written when the engine returns a comparison_period
in the result.

Also wires the controller's /fusion/reports/export_xlsx endpoint to drive
the wizard and return base64-encoded XLSX bytes (replaces the not_implemented
placeholder).

Tests: 2 new (test_xlsx_export.py) + 1 controller test updated. Manifest
declares xlsxwriter as an external_dependency.

Made-with: Cursor
2026-04-19 16:16:36 -04:00
gsinghpal
23b988c401 feat(fusion_accounting_reports): PDF export with QWeb template
Adds an AbstractModel report (report_pdf.py) and a single multi-purpose
QWeb template (report_pdf_template.xml) that renders P&L, Balance Sheet,
Trial Balance, and General Ledger results from the engine.

Wires the controller's /fusion/reports/export_pdf endpoint to actually
return base64-encoded PDF bytes via _render_qweb_pdf. The template walks
the result['rows'] list and applies indentation/bold based on level and
is_subtotal flags, with optional comparison columns when present.

Tests: 2 new (test_pdf_export.py) + 1 controller test updated to assert
the real PDF response. Net 109 -> 111.

Made-with: Cursor
2026-04-19 16:13:22 -04:00
22 changed files with 670 additions and 9 deletions

View File

@@ -1,3 +1,5 @@
from . import services
from . import models
from . import controllers
from . import reports
from . import wizards

View File

@@ -1,6 +1,6 @@
{
'name': 'Fusion Accounting Reports',
'version': '19.0.1.0.29',
'version': '19.0.1.0.35',
'category': 'Accounting/Accounting',
'summary': 'AI-augmented financial reports (P&L, balance sheet, trial balance, GL).',
'description': """
@@ -27,6 +27,7 @@ menu hides; the engine and AI tools remain available for the chat.
'depends': [
'fusion_accounting_core',
'fusion_accounting_ai',
'fusion_accounting_migration',
'account',
],
'data': [
@@ -36,7 +37,14 @@ menu hides; the engine and AI tools remain available for the chat.
'data/report_trial_balance.xml',
'data/report_general_ledger.xml',
'data/cron.xml',
'reports/report_pdf_template.xml',
'wizards/xlsx_export_wizard_views.xml',
'wizards/period_picker_wizard_views.xml',
'views/menu_views.xml',
],
'external_dependencies': {
'python': ['xlsxwriter'],
},
'assets': {
'web.assets_backend': [
'fusion_accounting_reports/static/src/scss/_variables.scss',

View File

@@ -210,15 +210,39 @@ class FusionReportsController(http.Controller):
@http.route('/fusion/reports/export_pdf', type='jsonrpc', auth='user')
def export_pdf(self, report_type, date_from, date_to,
comparison='none', company_id=None):
Report = request.env['fusion.report']
report_def = Report.search([('report_type', '=', report_type)], limit=1)
if not report_def:
return {'status': 'error', 'message': f'No report definition for {report_type}'}
company_id = int(company_id) if company_id else request.env.company.id
pdf, _ct = request.env['ir.actions.report'].sudo()._render_qweb_pdf(
'fusion_accounting_reports.report_pdf_template',
res_ids=[report_def.id],
data={
'report_type': report_type,
'date_from': date_from, 'date_to': date_to,
'comparison': comparison, 'company_id': company_id,
},
)
import base64
return {
'status': 'not_implemented',
'message': 'PDF export shipping in Task 34',
'status': 'ok',
'pdf_base64': base64.b64encode(pdf).decode('ascii'),
'filename': f'{report_type}_{date_from}_{date_to}.pdf',
}
@http.route('/fusion/reports/export_xlsx', type='jsonrpc', auth='user')
def export_xlsx(self, report_type, date_from, date_to,
comparison='none', company_id=None):
wizard = request.env['fusion.xlsx.export.wizard'].create({
'report_type': report_type,
'date_from': _parse_date(date_from),
'date_to': _parse_date(date_to),
'comparison': comparison,
})
wizard.action_export()
return {
'status': 'not_implemented',
'message': 'XLSX export shipping in Task 35',
'status': 'ok',
'xlsx_base64': wizard.xlsx_file.decode('ascii') if wizard.xlsx_file else '',
'filename': wizard.xlsx_filename,
}

View File

@@ -4,3 +4,4 @@ from . import fusion_report_commentary
from . import fusion_report_anomaly
from . import fusion_account_balance_mv
from . import fusion_reports_cron
from . import fusion_migration_wizard

View File

@@ -0,0 +1,35 @@
"""Reports-specific migration step.
Ensures the 4 CORE report definitions are present after migration."""
import logging
from odoo import models
_logger = logging.getLogger(__name__)
class FusionMigrationWizard(models.TransientModel):
_inherit = "fusion.migration.wizard"
def _reports_bootstrap_step(self):
"""Verify all 4 CORE report definitions exist."""
Report = self.env['fusion.report'].sudo()
expected = ['pnl', 'balance_sheet', 'trial_balance', 'general_ledger']
present = Report.search([('report_type', 'in', expected)]).mapped('report_type')
missing = set(expected) - set(present)
return {
'step': 'reports_bootstrap',
'expected_reports': expected,
'present_reports': list(present),
'missing_reports': list(missing),
}
def action_run_migration(self):
"""Override to add reports-bootstrap step at the end of the chain."""
result = super().action_run_migration() if hasattr(super(), 'action_run_migration') else None
try:
self._reports_bootstrap_step()
except Exception as e:
_logger.warning("reports_bootstrap_step failed: %s", e)
return result

View File

@@ -0,0 +1 @@
from . import report_pdf

View File

@@ -0,0 +1,58 @@
"""QWeb PDF report for fusion financial reports.
Wraps the engine's compute_* methods and feeds the result into a
single multi-purpose template that handles all 4 report types."""
from datetime import datetime
from odoo import api, models
from ..services.date_periods import Period
class FusionReportPdf(models.AbstractModel):
_name = "report.fusion_accounting_reports.report_pdf_template"
_description = "Fusion Financial Report PDF"
@api.model
def _get_report_values(self, docids, data=None):
"""data is expected to be {report_type, date_from, date_to, comparison, company_id}."""
data = data or {}
report_type = data.get('report_type', 'pnl')
company_id = data.get('company_id') or self.env.company.id
date_from = data.get('date_from')
date_to = data.get('date_to')
comparison = data.get('comparison', 'none')
if isinstance(date_from, str):
date_from = datetime.strptime(date_from, '%Y-%m-%d').date()
if isinstance(date_to, str):
date_to = datetime.strptime(date_to, '%Y-%m-%d').date()
engine = self.env['fusion.report.engine']
if report_type == 'pnl':
period = Period(date_from, date_to, f"{date_from} - {date_to}")
result = engine.compute_pnl(period, comparison=comparison, company_id=company_id)
elif report_type == 'balance_sheet':
result = engine.compute_balance_sheet(date_to, comparison=comparison, company_id=company_id)
elif report_type == 'trial_balance':
period = Period(date_from, date_to, f"{date_from} - {date_to}")
result = engine.compute_trial_balance(period, company_id=company_id)
elif report_type == 'general_ledger':
period = Period(date_from, date_to, f"{date_from} - {date_to}")
result = engine.compute_gl(period, company_id=company_id)
else:
result = {'rows': [], 'report_name': 'Unknown', 'period': {}}
company = self.env['res.company'].browse(company_id)
return {
'doc_ids': docids,
'doc_model': 'fusion.report',
'docs': self.env['fusion.report'].browse(docids) if docids else
self.env['fusion.report'].search([('report_type', '=', report_type)], limit=1),
'data': data,
'result': result,
'company_id': company,
'company': company,
'res_company': company,
}

View File

@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="report_pdf_template">
<t t-call="web.html_container">
<t t-call="web.external_layout">
<div class="page">
<h2>
<t t-esc="result.get('report_name', 'Financial Report')"/>
</h2>
<p>
<strong>Period:</strong>
<span t-esc="result.get('period', {}).get('label', '')"/>
</p>
<p t-if="result.get('comparison_period')">
<strong>Compared to:</strong>
<span t-esc="result.get('comparison_period', {}).get('label', '')"/>
</p>
<table class="table table-sm">
<thead>
<tr>
<th>Line</th>
<th class="text-end">Amount</th>
<t t-if="result.get('comparison_period')">
<th class="text-end">Comparison</th>
<th class="text-end">Variance %</th>
</t>
</tr>
</thead>
<tbody>
<tr t-foreach="result.get('rows', [])" t-as="row"
t-attf-style="{{ 'font-weight: bold;' if row.get('is_subtotal') else '' }}">
<td t-attf-style="padding-left: {{ (row.get('level', 0) or 0) * 16 + 8 }}px;">
<span t-esc="row.get('label', '')"/>
</td>
<td class="text-end">
<span t-esc="'{:,.2f}'.format(row.get('amount', 0))"/>
</td>
<t t-if="result.get('comparison_period')">
<td class="text-end">
<t t-if="row.get('amount_comparison') is not None">
<span t-esc="'{:,.2f}'.format(row.get('amount_comparison'))"/>
</t>
</td>
<td class="text-end">
<t t-if="row.get('variance_pct') is not None">
<span t-esc="'{:+.1f}%'.format(row.get('variance_pct'))"/>
</t>
</td>
</t>
</tr>
</tbody>
</table>
<p class="text-muted" style="font-size: 0.75rem;">
Generated by Fusion Accounting Reports
</p>
</div>
</t>
</t>
</template>
<record id="action_report_fusion_financial" model="ir.actions.report">
<field name="name">Fusion Financial Report (PDF)</field>
<field name="model">fusion.report</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_accounting_reports.report_pdf_template</field>
<field name="report_file">fusion_accounting_reports.report_pdf_template</field>
<field name="binding_model_id" ref="model_fusion_report"/>
<field name="binding_view_types">form,list</field>
</record>
</odoo>

View File

@@ -3,3 +3,5 @@ access_fusion_report_user,fusion.report.user,model_fusion_report,base.group_user
access_fusion_report_admin,fusion.report.admin,model_fusion_report,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
access_fusion_report_commentary,fusion.report.commentary,model_fusion_report_commentary,base.group_user,1,1,1,0
access_fusion_report_anomaly,fusion.report.anomaly,model_fusion_report_anomaly,base.group_user,1,1,1,0
access_fusion_xlsx_export_wizard_user,fusion.xlsx.export.wizard.user,model_fusion_xlsx_export_wizard,base.group_user,1,1,1,0
access_fusion_period_picker_wizard_user,fusion.period.picker.wizard.user,model_fusion_period_picker_wizard,base.group_user,1,1,1,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
3 access_fusion_report_admin fusion.report.admin model_fusion_report fusion_accounting_core.group_fusion_accounting_admin 1 1 1 1
4 access_fusion_report_commentary fusion.report.commentary model_fusion_report_commentary base.group_user 1 1 1 0
5 access_fusion_report_anomaly fusion.report.anomaly model_fusion_report_anomaly base.group_user 1 1 1 0
6 access_fusion_xlsx_export_wizard_user fusion.xlsx.export.wizard.user model_fusion_xlsx_export_wizard base.group_user 1 1 1 0
7 access_fusion_period_picker_wizard_user fusion.period.picker.wizard.user model_fusion_period_picker_wizard base.group_user 1 1 1 0

View File

@@ -18,3 +18,8 @@ from . import test_pnl_integration
from . import test_bs_tb_integration
from . import test_account_balance_mv
from . import test_cron
from . import test_pdf_export
from . import test_xlsx_export
from . import test_period_picker
from . import test_migration_round_trip
from . import test_coexistence

View File

@@ -0,0 +1,39 @@
"""Coexistence tests for fusion_accounting_reports.
Mirrors Phase 1's coexistence test pattern: verifies the menu requires
the coexistence group, and the engine model is always available."""
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestReportsCoexistence(TransactionCase):
def setUp(self):
super().setUp()
self.coex_group = self.env.ref(
'fusion_accounting_core.group_fusion_show_when_enterprise_absent',
raise_if_not_found=False,
)
self.assertIsNotNone(self.coex_group, "Coexistence group must exist")
def test_engine_always_available(self):
"""The engine is registered regardless of Enterprise install state."""
self.assertIn('fusion.report.engine', self.env.registry)
def test_menu_gated_by_coexistence_group(self):
menu = self.env.ref('fusion_accounting_reports.menu_fusion_reports_root',
raise_if_not_found=False)
if not menu:
self.skipTest("Menu not loaded")
menu_groups = getattr(menu, 'group_ids', None) or menu.groups_id
self.assertIn(self.coex_group, menu_groups,
"Reports root menu must require the coexistence group")
def test_period_picker_wizard_gated_too(self):
menu = self.env.ref('fusion_accounting_reports.menu_fusion_reports_open',
raise_if_not_found=False)
if not menu:
self.skipTest("Menu not loaded")
menu_groups = getattr(menu, 'group_ids', None) or menu.groups_id
self.assertIn(self.coex_group, menu_groups)

View File

@@ -0,0 +1,15 @@
"""Tests for the reports-bootstrap migration step."""
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestMigrationRoundTrip(TransactionCase):
def test_bootstrap_finds_all_4_reports(self):
wizard = self.env['fusion.migration.wizard'].create({})
result = wizard._reports_bootstrap_step()
self.assertEqual(result['step'], 'reports_bootstrap')
self.assertEqual(set(result['present_reports']),
{'pnl', 'balance_sheet', 'trial_balance', 'general_ledger'})
self.assertEqual(result['missing_reports'], [])

View File

@@ -0,0 +1,34 @@
"""Tests for the PDF export."""
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestPdfExport(TransactionCase):
def test_pdf_render_pnl(self):
report = self.env.ref('fusion_accounting_reports.report_pnl')
pdf, content_type = self.env['ir.actions.report'].sudo()._render_qweb_pdf(
'fusion_accounting_reports.report_pdf_template',
res_ids=[report.id],
data={
'report_type': 'pnl',
'date_from': '2026-01-01', 'date_to': '2026-12-31',
'company_id': self.env.company.id,
},
)
self.assertGreater(len(pdf), 500)
self.assertIn(content_type, ('pdf', 'html'))
def test_pdf_render_balance_sheet(self):
report = self.env.ref('fusion_accounting_reports.report_balance_sheet')
pdf, _ = self.env['ir.actions.report'].sudo()._render_qweb_pdf(
'fusion_accounting_reports.report_pdf_template',
res_ids=[report.id],
data={
'report_type': 'balance_sheet',
'date_from': '2026-01-01', 'date_to': '2026-12-31',
'company_id': self.env.company.id,
},
)
self.assertGreater(len(pdf), 500)

View File

@@ -0,0 +1,36 @@
"""Tests for period picker wizard."""
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestPeriodPickerWizard(TransactionCase):
def test_this_month_preset_fills_dates(self):
wizard = self.env['fusion.period.picker.wizard'].create({
'report_type': 'pnl',
'period_preset': 'this_month',
})
wizard._onchange_period_preset()
self.assertTrue(wizard.date_from)
self.assertTrue(wizard.date_to)
self.assertEqual(wizard.date_from.day, 1)
def test_this_year_preset_uses_ytd(self):
wizard = self.env['fusion.period.picker.wizard'].create({
'report_type': 'pnl',
'period_preset': 'this_year',
})
wizard._onchange_period_preset()
self.assertEqual(wizard.date_from.month, 1)
self.assertEqual(wizard.date_from.day, 1)
def test_action_open_report_returns_client_action(self):
wizard = self.env['fusion.period.picker.wizard'].create({
'report_type': 'pnl',
'period_preset': 'this_year',
})
wizard._onchange_period_preset()
action = wizard.action_open_report()
self.assertEqual(action['type'], 'ir.actions.client')
self.assertEqual(action['tag'], 'fusion_reports')

View File

@@ -101,18 +101,26 @@ class TestReportsController(HttpCase):
self.assertIn('highlights', result)
self.assertIn('concerns', result)
def test_export_pdf_placeholder(self):
def test_export_pdf_returns_pdf(self):
result = self._jsonrpc('export_pdf', {
'report_type': 'pnl',
'date_from': '2026-01-01',
'date_to': '2026-12-31',
})
self.assertEqual(result.get('status'), 'not_implemented')
self.assertEqual(result.get('status'), 'ok')
self.assertIn('pdf_base64', result)
self.assertTrue(result.get('filename', '').endswith('.pdf'))
def test_export_xlsx_placeholder(self):
def test_export_xlsx_returns_xlsx(self):
try:
import xlsxwriter # noqa: F401
except ImportError:
self.skipTest("xlsxwriter not installed")
result = self._jsonrpc('export_xlsx', {
'report_type': 'pnl',
'date_from': '2026-01-01',
'date_to': '2026-12-31',
})
self.assertEqual(result.get('status'), 'not_implemented')
self.assertEqual(result.get('status'), 'ok')
self.assertTrue(result.get('xlsx_base64'))
self.assertTrue(result.get('filename', '').endswith('.xlsx'))

View File

@@ -0,0 +1,36 @@
"""Tests for XLSX export wizard."""
from datetime import date
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestXlsxExport(TransactionCase):
def test_export_pnl_produces_xlsx(self):
try:
import xlsxwriter # noqa: F401
except ImportError:
self.skipTest("xlsxwriter not installed")
wizard = self.env['fusion.xlsx.export.wizard'].create({
'report_type': 'pnl',
'date_from': date(2026, 1, 1),
'date_to': date(2026, 12, 31),
})
wizard.action_export()
self.assertEqual(wizard.state, 'done')
self.assertTrue(wizard.xlsx_file)
self.assertTrue(wizard.xlsx_filename.endswith('.xlsx'))
def test_export_balance_sheet(self):
try:
import xlsxwriter # noqa: F401
except ImportError:
self.skipTest("xlsxwriter not installed")
wizard = self.env['fusion.xlsx.export.wizard'].create({
'report_type': 'balance_sheet',
'date_from': date(2026, 1, 1),
'date_to': date(2026, 12, 31),
})
wizard.action_export()
self.assertEqual(wizard.state, 'done')

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem id="menu_fusion_reports_root"
name="Financial Reports"
sequence="50"
web_icon="fusion_accounting_reports,static/description/icon.png"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<menuitem id="menu_fusion_reports_open"
name="Open Report..."
parent="menu_fusion_reports_root"
action="action_fusion_period_picker_wizard"
sequence="10"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<menuitem id="menu_fusion_reports_xlsx"
name="Export to XLSX..."
parent="menu_fusion_reports_root"
action="action_fusion_xlsx_export_wizard"
sequence="20"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<record id="action_fusion_report_anomaly_list" model="ir.actions.act_window">
<field name="name">Report Anomalies</field>
<field name="res_model">fusion.report.anomaly</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_fusion_reports_anomalies"
name="Anomalies"
parent="menu_fusion_reports_root"
action="action_fusion_report_anomaly_list"
sequence="30"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
</odoo>

View File

@@ -0,0 +1,2 @@
from . import xlsx_export_wizard
from . import period_picker_wizard

View File

@@ -0,0 +1,77 @@
"""Period selection + comparison wizard.
Pre-fills date ranges for common report periods (current month, YTD, etc.)."""
from datetime import timedelta
from odoo import api, fields, models
from ..services.date_periods import (
fiscal_year_bounds, month_bounds, quarter_bounds,
)
class FusionPeriodPickerWizard(models.TransientModel):
_name = "fusion.period.picker.wizard"
_description = "Period Selection Wizard"
report_type = fields.Selection([
('pnl', 'P&L'),
('balance_sheet', 'Balance Sheet'),
('trial_balance', 'Trial Balance'),
('general_ledger', 'General Ledger'),
], required=True, default='pnl')
period_preset = fields.Selection([
('this_month', 'This Month'),
('last_month', 'Last Month'),
('this_quarter', 'This Quarter'),
('last_quarter', 'Last Quarter'),
('this_year', 'This Year (YTD)'),
('last_year', 'Last Year'),
('custom', 'Custom Range'),
], default='this_month', required=True)
date_from = fields.Date()
date_to = fields.Date()
comparison = fields.Selection([
('none', 'No Comparison'),
('previous_period', 'Previous Period'),
('previous_year', 'Previous Year'),
], default='none')
@api.onchange('period_preset')
def _onchange_period_preset(self):
today = fields.Date.today()
if self.period_preset == 'this_month':
p = month_bounds(today)
self.date_from, self.date_to = p.date_from, p.date_to
elif self.period_preset == 'last_month':
p = month_bounds(today.replace(day=1) - timedelta(days=1))
self.date_from, self.date_to = p.date_from, p.date_to
elif self.period_preset == 'this_quarter':
p = quarter_bounds(today)
self.date_from, self.date_to = p.date_from, p.date_to
elif self.period_preset == 'last_quarter':
this_q = quarter_bounds(today)
p = quarter_bounds(this_q.date_from - timedelta(days=1))
self.date_from, self.date_to = p.date_from, p.date_to
elif self.period_preset == 'this_year':
p = fiscal_year_bounds(today)
self.date_from, self.date_to = p.date_from, today
elif self.period_preset == 'last_year':
last_year = today.replace(year=today.year - 1)
p = fiscal_year_bounds(last_year)
self.date_from, self.date_to = p.date_from, p.date_to
def action_open_report(self):
"""Open the fusion reports viewer pre-filled with selected period."""
self.ensure_one()
return {
'type': 'ir.actions.client',
'tag': 'fusion_reports',
'context': {
'default_report_type': self.report_type,
'default_date_from': str(self.date_from),
'default_date_to': str(self.date_to),
'default_comparison': self.comparison,
},
}

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_fusion_period_picker_wizard_form" model="ir.ui.view">
<field name="name">fusion.period.picker.wizard.form</field>
<field name="model">fusion.period.picker.wizard</field>
<field name="arch" type="xml">
<form string="Pick Reporting Period">
<group>
<field name="report_type"/>
<field name="period_preset"/>
<field name="date_from" invisible="period_preset != 'custom'"
required="period_preset == 'custom'"/>
<field name="date_to" invisible="period_preset != 'custom'"
required="period_preset == 'custom'"/>
<field name="comparison"/>
</group>
<footer>
<button name="action_open_report" type="object" string="Open Report"
class="btn-primary"/>
<button special="cancel" string="Cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_fusion_period_picker_wizard" model="ir.actions.act_window">
<field name="name">Open Financial Report</field>
<field name="res_model">fusion.period.picker.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1,105 @@
"""XLSX export wizard for fusion financial reports."""
import base64
import io
from odoo import _, fields, models
from odoo.exceptions import UserError
from ..services.date_periods import Period
class FusionXlsxExportWizard(models.TransientModel):
_name = "fusion.xlsx.export.wizard"
_description = "Export Financial Report to XLSX"
report_type = fields.Selection([
('pnl', 'P&L'),
('balance_sheet', 'Balance Sheet'),
('trial_balance', 'Trial Balance'),
('general_ledger', 'General Ledger'),
], required=True, default='pnl')
date_from = fields.Date(required=True, default=fields.Date.today)
date_to = fields.Date(required=True, default=fields.Date.today)
comparison = fields.Selection([
('none', 'No Comparison'),
('previous_period', 'Previous Period'),
('previous_year', 'Previous Year'),
], default='none')
xlsx_file = fields.Binary(readonly=True)
xlsx_filename = fields.Char(readonly=True)
state = fields.Selection([('draft', 'Draft'), ('done', 'Done')], default='draft')
def action_export(self):
self.ensure_one()
company_id = self.env.company.id
engine = self.env['fusion.report.engine']
if self.report_type == 'pnl':
period = Period(self.date_from, self.date_to, f"{self.date_from} - {self.date_to}")
result = engine.compute_pnl(period, comparison=self.comparison, company_id=company_id)
elif self.report_type == 'balance_sheet':
result = engine.compute_balance_sheet(self.date_to, comparison=self.comparison, company_id=company_id)
elif self.report_type == 'trial_balance':
period = Period(self.date_from, self.date_to, f"{self.date_from} - {self.date_to}")
result = engine.compute_trial_balance(period, company_id=company_id)
else:
period = Period(self.date_from, self.date_to, f"{self.date_from} - {self.date_to}")
result = engine.compute_gl(period, company_id=company_id)
try:
import xlsxwriter
except ImportError:
raise UserError(_(
"xlsxwriter Python package is required for XLSX export. "
"Install with: pip install xlsxwriter"))
buf = io.BytesIO()
wb = xlsxwriter.Workbook(buf, {'in_memory': True})
ws = wb.add_worksheet(self.report_type[:30])
bold = wb.add_format({'bold': True})
money = wb.add_format({'num_format': '#,##0.00'})
money_bold = wb.add_format({'num_format': '#,##0.00', 'bold': True})
ws.write(0, 0, result.get('report_name', 'Report'), bold)
ws.write(1, 0, f"Period: {result.get('period', {}).get('label', '')}")
if result.get('comparison_period'):
ws.write(2, 0, f"Comparison: {result['comparison_period']['label']}")
row_idx = 4
ws.write(row_idx, 0, 'Line', bold)
ws.write(row_idx, 1, 'Amount', bold)
if result.get('comparison_period'):
ws.write(row_idx, 2, 'Comparison', bold)
ws.write(row_idx, 3, 'Variance %', bold)
for row in result.get('rows', []):
row_idx += 1
label = (' ' * (row.get('level', 0) or 0)) + (row.get('label', '') or '')
fmt = bold if row.get('is_subtotal') else None
money_fmt = money_bold if row.get('is_subtotal') else money
ws.write(row_idx, 0, label, fmt)
ws.write(row_idx, 1, row.get('amount', 0), money_fmt)
if result.get('comparison_period'):
if row.get('amount_comparison') is not None:
ws.write(row_idx, 2, row['amount_comparison'], money_fmt)
if row.get('variance_pct') is not None:
ws.write(row_idx, 3, row['variance_pct'] / 100,
wb.add_format({'num_format': '+0.0%;-0.0%;0.0%'}))
ws.set_column(0, 0, 40)
ws.set_column(1, 3, 16)
wb.close()
self.write({
'xlsx_file': base64.b64encode(buf.getvalue()),
'xlsx_filename': f'{self.report_type}_{self.date_from}_{self.date_to}.xlsx',
'state': 'done',
})
return {
'type': 'ir.actions.act_window',
'res_model': self._name,
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
}

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_fusion_xlsx_export_wizard_form" model="ir.ui.view">
<field name="name">fusion.xlsx.export.wizard.form</field>
<field name="model">fusion.xlsx.export.wizard</field>
<field name="arch" type="xml">
<form string="Export to XLSX">
<group invisible="state == 'done'">
<field name="report_type"/>
<field name="date_from"/>
<field name="date_to"/>
<field name="comparison"/>
</group>
<group invisible="state != 'done'">
<field name="xlsx_file" filename="xlsx_filename" readonly="1"/>
<field name="xlsx_filename" invisible="1"/>
</group>
<field name="state" invisible="1"/>
<footer>
<button name="action_export" type="object" string="Export"
class="btn-primary" invisible="state == 'done'"/>
<button special="cancel" string="Close"/>
</footer>
</form>
</field>
</record>
<record id="action_fusion_xlsx_export_wizard" model="ir.actions.act_window">
<field name="name">Export Report (XLSX)</field>
<field name="res_model">fusion.xlsx.export.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>