# Part of Odoo. See LICENSE file for full copyright and licensing details. import base64 import io from PyPDF2 import PdfFileWriter, PdfFileReader from PyPDF2.generic import NameObject, createStringObject from odoo import models from odoo.tools import format_amount, format_date, format_datetime, pdf class IrActionsReport(models.Model): _inherit = 'ir.actions.report' 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 self._get_report(report_ref).report_name != 'sale.report_saleorder': return result orders = self.env['sale.order'].browse(res_ids) for order in orders: initial_stream = result[order.id]['stream'] if initial_stream: order_template = order.sale_order_template_id header_record = order_template if order_template.sale_header else order.company_id footer_record = order_template if order_template.sale_footer else order.company_id has_header = bool(header_record.sale_header) has_footer = bool(footer_record.sale_footer) included_product_docs = self.env['product.document'] doc_line_id_mapping = {} for line in order.order_line: product_product_docs = line.product_id.product_document_ids product_template_docs = line.product_template_id.product_document_ids doc_to_include = ( product_product_docs.filtered(lambda d: d.attached_on == 'inside') or product_template_docs.filtered(lambda d: d.attached_on == 'inside') ) included_product_docs = included_product_docs | doc_to_include doc_line_id_mapping.update({doc.id: line.id for doc in doc_to_include}) if (not has_header and not included_product_docs and not has_footer): continue writer = PdfFileWriter() if has_header: self._add_pages_to_writer(writer, base64.b64decode(header_record.sale_header)) if included_product_docs: for doc in included_product_docs: self._add_pages_to_writer( writer, base64.b64decode(doc.datas), doc_line_id_mapping[doc.id] ) self._add_pages_to_writer(writer, initial_stream.getvalue()) if has_footer: self._add_pages_to_writer(writer, base64.b64decode(footer_record.sale_footer)) form_fields = self._get_form_fields_mapping(order, doc_line_id_mapping) pdf.fill_form_fields_pdf(writer, form_fields=form_fields) with io.BytesIO() as _buffer: writer.write(_buffer) stream = io.BytesIO(_buffer.getvalue()) result[order.id].update({'stream': stream}) return result def _add_pages_to_writer(self, writer, document, sol_id=None): prefix = f'{sol_id}_' if sol_id else '' reader = PdfFileReader(io.BytesIO(document), strict=False) sol_field_names = self._get_sol_form_fields_names() for page_id in range(0, reader.getNumPages()): page = reader.getPage(page_id) if sol_id and page.get('/Annots'): # Prefix all form fields in the document with the sale order line id. # This is necessary to avoid conflicts between fields with the same name. for j in range(0, len(page['/Annots'])): reader_annot = page['/Annots'][j].getObject() if reader_annot.get('/T') in sol_field_names: reader_annot.update({ NameObject("/T"): createStringObject(prefix + reader_annot.get('/T')) }) writer.addPage(page) def _get_sol_form_fields_names(self): """ List of specific pdf fields name for an order line that needs to be renamed in the pdf. Override this method to add new fields to the list. """ return ['description', 'quantity', 'uom', 'price_unit', 'discount', 'product_sale_price', 'taxes', 'tax_excl_price', 'tax_incl_price'] def _get_form_fields_mapping(self, order, doc_line_id_mapping=None): """ Dictionary mapping specific pdf fields name to Odoo fields data for a sale order. Override this method to add new fields to the mapping. :param recordset order: sale.order record :rtype: dict :return: mapping of fields name to Odoo fields data Note: order.ensure_one() """ order.ensure_one() env = self.with_context(use_babel=True).env tz = order.partner_id.tz or self.env.user.tz or 'UTC' lang_code = order.partner_id.lang or self.env.user.lang form_fields_mapping = { 'name': order.name, 'partner_id__name': order.partner_id.name, 'user_id__name': order.user_id.name, 'amount_untaxed': format_amount(env, order.amount_untaxed, order.currency_id), 'amount_total': format_amount(env, order.amount_total, order.currency_id), 'delivery_date': format_datetime(env, order.commitment_date, tz=tz), 'validity_date': format_date(env, order.validity_date, lang_code=lang_code), 'client_order_ref': order.client_order_ref or '', } # Adding fields from each line, prefixed by the line_id to avoid conflicts lines_with_doc_ids = set(doc_line_id_mapping.values()) for line in order.order_line.filtered(lambda sol: sol.id in lines_with_doc_ids): form_fields_mapping.update(self._get_sol_form_fields_mapping(line)) return form_fields_mapping def _get_sol_form_fields_mapping(self, line): """ Dictionary mapping specific pdf fields name to Odoo fields data for a sale order line. Fields name are prefixed by the line id to avoid conflict between files. Override this method to add new fields to the mapping. :param recordset line: sale.order.line record :rtype: dict :return: mapping of prefixed fields name to Odoo fields data Note: line.ensure_one() """ line.ensure_one() env = self.with_context(use_babel=True).env return { f'{line.id}_description': line.name, f'{line.id}_quantity': line.product_uom_qty, f'{line.id}_uom': line.product_uom.name, f'{line.id}_price_unit': format_amount(env, line.price_unit, line.currency_id), f'{line.id}_discount': line.discount, f'{line.id}_product_sale_price': format_amount( env, line.product_id.lst_price, line.product_id.currency_id ), f'{line.id}_taxes': ', '.join(tax.name for tax in line.tax_id), f'{line.id}_tax_excl_price': format_amount(env, line.price_subtotal, line.currency_id), f'{line.id}_tax_incl_price': format_amount(env, line.price_total, line.currency_id), }