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
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
from . import services
|
||||
from . import models
|
||||
from . import controllers
|
||||
from . import reports
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
'name': 'Fusion Accounting Reports',
|
||||
'version': '19.0.1.0.29',
|
||||
'version': '19.0.1.0.30',
|
||||
'category': 'Accounting/Accounting',
|
||||
'summary': 'AI-augmented financial reports (P&L, balance sheet, trial balance, GL).',
|
||||
'description': """
|
||||
@@ -36,6 +36,7 @@ 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',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
|
||||
@@ -210,9 +210,25 @@ 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')
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from . import report_pdf
|
||||
|
||||
58
fusion_accounting_reports/reports/report_pdf.py
Normal file
58
fusion_accounting_reports/reports/report_pdf.py
Normal 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,
|
||||
}
|
||||
72
fusion_accounting_reports/reports/report_pdf_template.xml
Normal file
72
fusion_accounting_reports/reports/report_pdf_template.xml
Normal 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>
|
||||
@@ -18,3 +18,4 @@ 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
|
||||
|
||||
34
fusion_accounting_reports/tests/test_pdf_export.py
Normal file
34
fusion_accounting_reports/tests/test_pdf_export.py
Normal 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)
|
||||
@@ -101,13 +101,15 @@ 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):
|
||||
result = self._jsonrpc('export_xlsx', {
|
||||
|
||||
Reference in New Issue
Block a user