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:
@@ -2,3 +2,4 @@ from . import services
|
||||
from . import models
|
||||
from . import controllers
|
||||
from . import reports
|
||||
from . import wizards
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
@@ -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'))
|
||||
|
||||
36
fusion_accounting_reports/tests/test_xlsx_export.py
Normal file
36
fusion_accounting_reports/tests/test_xlsx_export.py
Normal 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')
|
||||
@@ -0,0 +1 @@
|
||||
from . import xlsx_export_wizard
|
||||
|
||||
105
fusion_accounting_reports/wizards/xlsx_export_wizard.py
Normal file
105
fusion_accounting_reports/wizards/xlsx_export_wizard.py
Normal 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',
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user