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
This commit is contained in:
gsinghpal
2026-04-19 16:16:36 -04:00
parent 23b988c401
commit 7d7bd93345
10 changed files with 202 additions and 5 deletions

View File

@@ -2,3 +2,4 @@ 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.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',

View File

@@ -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,
}

View File

@@ -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
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

View File

@@ -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

View File

@@ -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'))

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 @@
from . import xlsx_export_wizard

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>