diff --git a/fusion_reports_templates/__init__.py b/fusion_reports_templates/__init__.py index 0650744f..91c5580f 100644 --- a/fusion_reports_templates/__init__.py +++ b/fusion_reports_templates/__init__.py @@ -1 +1,2 @@ +from . import controllers from . import models diff --git a/fusion_reports_templates/__manifest__.py b/fusion_reports_templates/__manifest__.py index 7207558d..12d68440 100644 --- a/fusion_reports_templates/__manifest__.py +++ b/fusion_reports_templates/__manifest__.py @@ -3,7 +3,7 @@ # License OPL-1 (Odoo Proprietary License v1.0) { 'name': 'Fusion Reports — Templates', - 'version': '19.0.1.0.0', + 'version': '19.0.1.2.0', 'category': 'Tools/Reports', 'summary': 'Branded PDF templates for Quotation, Sales Order, Invoice, Delivery, Purchase Order, and Payment Receipt.', 'description': """ diff --git a/fusion_reports_templates/controllers/__init__.py b/fusion_reports_templates/controllers/__init__.py new file mode 100644 index 00000000..8c3feb6f --- /dev/null +++ b/fusion_reports_templates/controllers/__init__.py @@ -0,0 +1 @@ +from . import portal diff --git a/fusion_reports_templates/controllers/portal.py b/fusion_reports_templates/controllers/portal.py new file mode 100644 index 00000000..ee5b5138 --- /dev/null +++ b/fusion_reports_templates/controllers/portal.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +"""Swap stock report refs for their Fusion-branded equivalents in customer/vendor +portal routes. + +The portal "View Details" link and the portal Download button in +`sale/account/purchase/controllers/portal.py` hard-code the stock report +(`sale.action_report_saleorder`, `account.account_invoices`, ...). All those +routes funnel through `CustomerPortal._show_report` in +`portal/controllers/portal.py`, so a single override on that method is enough +to redirect every customer-facing render to the Fusion templates that +`mail_template_override.xml` already uses for outbound email. +""" +from odoo.addons.portal.controllers import portal + + +_PORTAL_REPORT_OVERRIDES = { + # Sale: quotation / order + 'sale.action_report_saleorder': 'fusion_reports_templates.action_report_fr_sale_portrait', + # Account: customer invoice + credit note (with-payment and without-payment variants) + 'account.account_invoices': 'fusion_reports_templates.action_report_fr_invoice_portrait', + 'account.account_invoices_without_payment': 'fusion_reports_templates.action_report_fr_invoice_portrait', + # Purchase: PO + RFQ (vendor portal) + 'purchase.action_report_purchase_order': 'fusion_reports_templates.action_report_fr_purchase_portrait', + 'purchase.report_purchase_quotation': 'fusion_reports_templates.action_report_fr_purchase_portrait', +} + + +class CustomerPortal(portal.CustomerPortal): + + def _show_report(self, model, report_type, report_ref, download=False): + report_ref = _PORTAL_REPORT_OVERRIDES.get(report_ref, report_ref) + return super()._show_report( + model=model, + report_type=report_type, + report_ref=report_ref, + download=download, + ) diff --git a/fusion_reports_templates/models/ir_actions_report.py b/fusion_reports_templates/models/ir_actions_report.py index 16be4d7b..def1de0a 100644 --- a/fusion_reports_templates/models/ir_actions_report.py +++ b/fusion_reports_templates/models/ir_actions_report.py @@ -1,15 +1,28 @@ # -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) -"""Sort the Print-menu report bindings by `sequence`. +"""Two orthogonal jobs: -Odoo 19 returns `ir.actions.report` bindings in raw insertion order, which -pushes third-party reports to the bottom even when they are the primary -customer-facing document. Adding a sequence field and sorting inside -`ir.actions.actions._get_bindings` restores the expected ordering. +1. Sequence the Print-menu report bindings so Fusion reports float to the + top — Odoo 19 returns `ir.actions.report` bindings in raw insertion + order, which buries third-party reports. + +2. Bridge `sale_pdf_quote_builder` to our Fusion sale-order reports. + Upstream gates its header/footer PDF merge on + `report_name == 'sale.report_saleorder'`, so the merge silently no-ops + when the user prints with our Portrait/Landscape variants. We replay + the same merge here for those report names. """ +import io + from odoo import api, fields, models -from odoo.tools import frozendict +from odoo.tools import frozendict, pdf, str2bool + + +_FUSION_SALE_REPORTS = frozenset({ + 'fusion_reports_templates.report_fr_sale_portrait', + 'fusion_reports_templates.report_fr_sale_landscape', +}) class IrActionsReport(models.Model): @@ -21,6 +34,72 @@ class IrActionsReport(models.Model): '(lower = higher in the list).', ) + def _render_qweb_pdf_prepare_streams(self, report_ref, data, res_ids=None): + result = super()._render_qweb_pdf_prepare_streams(report_ref, data, res_ids=res_ids) + if not res_ids: + return result + report = self._get_report(report_ref) + if report.report_name not in _FUSION_SALE_REPORTS: + return result + # quotation_document_ids is added by sale_pdf_quote_builder; bail if + # that module isn't installed (nothing to merge). + if 'quotation_document_ids' not in self.env['sale.order']._fields: + return result + try: + from odoo.tools.pdf import PdfFileWriter + except ImportError: + return result + + always_include = str2bool( + self.env['ir.config_parameter'].sudo().get_param( + 'sale.always_include_selected_documents' + ) + ) + orders = self.env['sale.order'].browse(res_ids) + for order in orders: + initial_stream = result.get(order.id, {}).get('stream') + if not initial_stream: + continue + if order.state == 'sale' and not always_include: + continue + quotation_documents = order.quotation_document_ids + headers = quotation_documents.filtered(lambda d: d.document_type == 'header') + footers = quotation_documents - headers + has_product_document = any( + line.product_document_ids for line in order.order_line + ) + if not headers and not has_product_document and not footers: + continue + + form_fields_values_mapping = {} + writer = PdfFileWriter() + self_with_order_context = self.with_context( + use_babel=True, lang=order._get_lang() or self.env.user.lang + ) + for header in headers: + prefix = f'quotation_document_id_{header.id}__' + self_with_order_context._update_mapping_and_add_pages_to_writer( + writer, header, form_fields_values_mapping, prefix, order + ) + if has_product_document: + for line in order.order_line: + for doc in line.product_document_ids: + prefix = f'sol_id_{line.id}_product_document_id_{doc.id}__' + self_with_order_context._update_mapping_and_add_pages_to_writer( + writer, doc, form_fields_values_mapping, prefix, order, line + ) + self._add_pages_to_writer(writer, initial_stream.getvalue()) + for footer in footers: + prefix = f'quotation_document_id_{footer.id}__' + self_with_order_context._update_mapping_and_add_pages_to_writer( + writer, footer, form_fields_values_mapping, prefix, order + ) + pdf.fill_form_fields_pdf(writer, form_fields=form_fields_values_mapping) + buffer = io.BytesIO() + writer.write(buffer) + result[order.id]['stream'] = io.BytesIO(buffer.getvalue()) + return result + class IrActionsActions(models.Model): _inherit = 'ir.actions.actions'