From 7d7bd93345247858f14ba371850d51c7030fc693 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 16:16:36 -0400 Subject: [PATCH] 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 --- fusion_accounting_reports/__init__.py | 1 + fusion_accounting_reports/__manifest__.py | 6 +- .../controllers/reports_controller.py | 12 +- .../security/ir.model.access.csv | 1 + fusion_accounting_reports/tests/__init__.py | 1 + .../tests/test_reports_controller.py | 10 +- .../tests/test_xlsx_export.py | 36 ++++++ fusion_accounting_reports/wizards/__init__.py | 1 + .../wizards/xlsx_export_wizard.py | 105 ++++++++++++++++++ .../wizards/xlsx_export_wizard_views.xml | 34 ++++++ 10 files changed, 202 insertions(+), 5 deletions(-) create mode 100644 fusion_accounting_reports/tests/test_xlsx_export.py create mode 100644 fusion_accounting_reports/wizards/xlsx_export_wizard.py create mode 100644 fusion_accounting_reports/wizards/xlsx_export_wizard_views.xml diff --git a/fusion_accounting_reports/__init__.py b/fusion_accounting_reports/__init__.py index 36233572..42f7d4cc 100644 --- a/fusion_accounting_reports/__init__.py +++ b/fusion_accounting_reports/__init__.py @@ -2,3 +2,4 @@ from . import services from . import models from . import controllers from . import reports +from . import wizards diff --git a/fusion_accounting_reports/__manifest__.py b/fusion_accounting_reports/__manifest__.py index 119c298d..f5bfaed1 100644 --- a/fusion_accounting_reports/__manifest__.py +++ b/fusion_accounting_reports/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Reports', - 'version': '19.0.1.0.30', + 'version': '19.0.1.0.31', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented financial reports (P&L, balance sheet, trial balance, GL).', 'description': """ @@ -37,7 +37,11 @@ menu hides; the engine and AI tools remain available for the chat. 'data/report_general_ledger.xml', 'data/cron.xml', 'reports/report_pdf_template.xml', + 'wizards/xlsx_export_wizard_views.xml', ], + 'external_dependencies': { + 'python': ['xlsxwriter'], + }, 'assets': { 'web.assets_backend': [ 'fusion_accounting_reports/static/src/scss/_variables.scss', diff --git a/fusion_accounting_reports/controllers/reports_controller.py b/fusion_accounting_reports/controllers/reports_controller.py index fbd278f2..cc020a46 100644 --- a/fusion_accounting_reports/controllers/reports_controller.py +++ b/fusion_accounting_reports/controllers/reports_controller.py @@ -234,7 +234,15 @@ class FusionReportsController(http.Controller): @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, } diff --git a/fusion_accounting_reports/security/ir.model.access.csv b/fusion_accounting_reports/security/ir.model.access.csv index 83c075b2..750413ec 100644 --- a/fusion_accounting_reports/security/ir.model.access.csv +++ b/fusion_accounting_reports/security/ir.model.access.csv @@ -3,3 +3,4 @@ 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 diff --git a/fusion_accounting_reports/tests/__init__.py b/fusion_accounting_reports/tests/__init__.py index 78b7a9a6..cffbaa01 100644 --- a/fusion_accounting_reports/tests/__init__.py +++ b/fusion_accounting_reports/tests/__init__.py @@ -19,3 +19,4 @@ 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 diff --git a/fusion_accounting_reports/tests/test_reports_controller.py b/fusion_accounting_reports/tests/test_reports_controller.py index 49023b61..54a0d54e 100644 --- a/fusion_accounting_reports/tests/test_reports_controller.py +++ b/fusion_accounting_reports/tests/test_reports_controller.py @@ -111,10 +111,16 @@ class TestReportsController(HttpCase): 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')) diff --git a/fusion_accounting_reports/tests/test_xlsx_export.py b/fusion_accounting_reports/tests/test_xlsx_export.py new file mode 100644 index 00000000..3fbab2f1 --- /dev/null +++ b/fusion_accounting_reports/tests/test_xlsx_export.py @@ -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') diff --git a/fusion_accounting_reports/wizards/__init__.py b/fusion_accounting_reports/wizards/__init__.py index e69de29b..99bf00b3 100644 --- a/fusion_accounting_reports/wizards/__init__.py +++ b/fusion_accounting_reports/wizards/__init__.py @@ -0,0 +1 @@ +from . import xlsx_export_wizard diff --git a/fusion_accounting_reports/wizards/xlsx_export_wizard.py b/fusion_accounting_reports/wizards/xlsx_export_wizard.py new file mode 100644 index 00000000..3fb03b29 --- /dev/null +++ b/fusion_accounting_reports/wizards/xlsx_export_wizard.py @@ -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', + } diff --git a/fusion_accounting_reports/wizards/xlsx_export_wizard_views.xml b/fusion_accounting_reports/wizards/xlsx_export_wizard_views.xml new file mode 100644 index 00000000..40a10b4d --- /dev/null +++ b/fusion_accounting_reports/wizards/xlsx_export_wizard_views.xml @@ -0,0 +1,34 @@ + + + + fusion.xlsx.export.wizard.form + fusion.xlsx.export.wizard + +
+ + + + + + + + + + + +
+
+ +
+
+ + + Export Report (XLSX) + fusion.xlsx.export.wizard + form + new + +