[ADD] sale_pdf_quote_builder:
When enable, this new feature adds the possibility to set a PDF header, footer and some product documents to the quotation report. These PDF can contains forms that'll then be filled using Odoo database. This replace the previous sale quotation builder feature. task-3249142 closes odoo/odoo#133773 Related: odoo/upgrade#5168 Related: odoo/documentation#5863 Signed-off-by: Victor Feyens (vfe) <vfe@odoo.com> Co-authored-by: Victor Feyens <vfe@odoo.com> Co-authored-by: Morgane Demesmaeker <edm@odoo.com>
This commit is contained in:
committed by
Demesmaeker
co-authored by
Morgane Demesmaeker
parent
693b62f38e
commit
6d1a78286a
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<template id="report_saleorder_document" inherit_id="sale.report_saleorder">
|
||||
<template id="report_saleorder_document" inherit_id="sale.report_saleorder_raw">
|
||||
<xpath expr='//t[@t-call="sale.report_saleorder_document"]' position="attributes">
|
||||
<attribute name="t-if" add="doc.company_id.country_code != 'BR'" separator=" and "/>
|
||||
</xpath>
|
||||
|
||||
@@ -183,6 +183,14 @@
|
||||
<field name="description_sale">160x80cm, with large legs.</field>
|
||||
</record>
|
||||
|
||||
<record id="product_product_4_product_template_document" model="product.document">
|
||||
<field name="name">Customizable Desk Document.pdf</field>
|
||||
<field name="datas" type="base64" file="product/static/demo/customizable_desk_document.pdf"/>
|
||||
<field name="mimetype">application/pdf</field>
|
||||
<field name="res_model">product.template</field>
|
||||
<field name="res_id" ref="product.product_product_4_product_template"/>
|
||||
</record>
|
||||
|
||||
<!-- the product template attribute lines have to be defined before creating the variants -->
|
||||
<record id="product_4_attribute_1_product_template_attribute_line" model="product.template.attribute.line">
|
||||
<field name="product_tmpl_id" ref="product_product_4_product_template"/>
|
||||
@@ -516,6 +524,14 @@
|
||||
<field name="image_1920" type="base64" file="product/static/img/product_product_25-image.png"/>
|
||||
</record>
|
||||
|
||||
<record id="product_product_25_document" model="product.document">
|
||||
<field name="name">Acoustic Bloc Screens Document.pdf</field>
|
||||
<field name="datas" type="base64" file="product/static/demo/acoustic_bloc_screen_document.pdf"/>
|
||||
<field name="mimetype">application/pdf</field>
|
||||
<field name="res_model">product.product</field>
|
||||
<field name="res_id" ref="product.product_product_25"/>
|
||||
</record>
|
||||
|
||||
<record id="product_product_27" model="product.product">
|
||||
<field name="name">Drawer</field>
|
||||
<field name="categ_id" ref="product_category_5"/>
|
||||
@@ -558,6 +574,14 @@
|
||||
<field name="image_1920" type="base64" file="product/static/img/product_product_46-image.jpg"/>
|
||||
</record>
|
||||
|
||||
<record id="consu_delivery_02_document" model="product.document">
|
||||
<field name="name">Large Meeting Table Document.pdf</field>
|
||||
<field name="datas" type="base64" file="product/static/demo/large_meeting_table_document.pdf"/>
|
||||
<field name="mimetype">application/pdf</field>
|
||||
<field name="res_model">product.product</field>
|
||||
<field name="res_id" ref="product.consu_delivery_02"/>
|
||||
</record>
|
||||
|
||||
<record id="consu_delivery_01" model="product.product">
|
||||
<field name="name">Three-Seat Sofa</field>
|
||||
<field name="categ_id" ref="product_category_5"/>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -125,7 +125,12 @@ class CustomerPortal(payment_portal.PaymentPortal):
|
||||
return request.redirect('/my')
|
||||
|
||||
if report_type in ('html', 'pdf', 'text'):
|
||||
return self._show_report(model=order_sudo, report_type=report_type, report_ref='sale.action_report_saleorder', download=download)
|
||||
return self._show_report(
|
||||
model=order_sudo,
|
||||
report_type=report_type,
|
||||
report_ref='sale.action_report_saleorder',
|
||||
download=download,
|
||||
)
|
||||
|
||||
if request.env.user.share and access_token:
|
||||
# If a public/portal user accesses the order with the access token
|
||||
|
||||
@@ -194,8 +194,7 @@
|
||||
</t>
|
||||
</template>
|
||||
|
||||
|
||||
<template id="report_saleorder">
|
||||
<template id="report_saleorder_raw">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="doc">
|
||||
<t t-call="sale.report_saleorder_document" t-lang="doc.partner_id.lang"/>
|
||||
@@ -203,6 +202,10 @@
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<template id="report_saleorder">
|
||||
<t t-call="sale.report_saleorder_raw"/>
|
||||
</template>
|
||||
|
||||
<template id="report_saleorder_pro_forma">
|
||||
<t t-call="web.html_container">
|
||||
<t t-set="is_pro_forma" t-value="True"/>
|
||||
|
||||
@@ -76,6 +76,7 @@ class ResConfigSettings(models.TransientModel):
|
||||
module_sale_amazon = fields.Boolean("Amazon Sync")
|
||||
module_sale_loyalty = fields.Boolean("Coupons & Loyalty")
|
||||
module_sale_margin = fields.Boolean("Margins")
|
||||
module_sale_pdf_quote_builder = fields.Boolean("PDF Quote builder")
|
||||
|
||||
#=== ONCHANGE METHODS ===#
|
||||
|
||||
|
||||
@@ -107,6 +107,9 @@
|
||||
<setting id="order_warnings" string="Sale Warnings" help="Get warnings in orders for products or customers">
|
||||
<field name="group_warning_sale"/>
|
||||
</setting>
|
||||
<setting id="sale_pdf_quote_builder" string="PDF Quote builder" help="Make your quote attractive by adding header pages, product descriptions and footer pages to your quote.">
|
||||
<field name="module_sale_pdf_quote_builder"/>
|
||||
</setting>
|
||||
<setting id="no_edit_order" help="No longer edit orders once confirmed">
|
||||
<field name="group_auto_done_setting"/>
|
||||
</setting>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models, api
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import models
|
||||
from . import wizards
|
||||
@@ -0,0 +1,17 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
{
|
||||
'name': "Sales PDF Quotation Builder",
|
||||
'category': 'Sales/Sales',
|
||||
'description': "Build nice quotations",
|
||||
'depends': ['sale_management'],
|
||||
'data': [
|
||||
'report/ir_actions_report.xml',
|
||||
'views/sale_order_template_views.xml',
|
||||
'wizards/res_config_settings_views.xml',
|
||||
],
|
||||
'demo': [
|
||||
'data/sale_pdf_quote_builder_demo.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="base.main_company" model="res.company">
|
||||
<field name="sale_header"
|
||||
type="base64"
|
||||
file="sale_pdf_quote_builder/static/quote_builder_pdf/quote_header_pages.pdf"/>
|
||||
<field name="sale_footer"
|
||||
type="base64"
|
||||
file="sale_pdf_quote_builder/static/quote_builder_pdf/quote_footer_pages.pdf"/>
|
||||
</record>
|
||||
|
||||
<record id="product.product_product_4_product_template_document" model="product.document">
|
||||
<field name="attached_on">inside</field>
|
||||
</record>
|
||||
|
||||
<record id="product.product_product_25_document" model="product.document">
|
||||
<field name="attached_on">inside</field>
|
||||
</record>
|
||||
|
||||
<record id="product.consu_delivery_02_document" model="product.document">
|
||||
<field name="attached_on">inside</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,6 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import ir_actions_report
|
||||
from . import product_document
|
||||
from . import res_company
|
||||
from . import sale_order_template
|
||||
@@ -0,0 +1,93 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import io
|
||||
from zlib import error as zlib_error
|
||||
try:
|
||||
from PyPDF2.errors import PdfStreamError, PdfReadError
|
||||
except ImportError:
|
||||
from PyPDF2.utils import PdfStreamError, PdfReadError
|
||||
|
||||
from odoo import _, models
|
||||
from odoo.tools import format_amount, format_date, 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
|
||||
record = order_template or order.company_id
|
||||
header = record.sale_header
|
||||
footer = record.sale_footer
|
||||
|
||||
included_product_docs = self.env['product.document']
|
||||
for line in order.order_line:
|
||||
documents = (
|
||||
line.product_id.product_document_ids
|
||||
or line.product_template_id.product_document_ids
|
||||
)
|
||||
doc_to_include = documents.filtered(lambda d: d.attached_on == 'inside')
|
||||
included_product_docs = included_product_docs | doc_to_include
|
||||
|
||||
if not header and not included_product_docs and not footer:
|
||||
continue
|
||||
|
||||
IrBinary = self.env['ir.binary']
|
||||
pdf_data = []
|
||||
if header:
|
||||
header_stream = IrBinary._record_to_stream(record, 'sale_header').read()
|
||||
pdf_data.append(header_stream)
|
||||
for included_doc in included_product_docs:
|
||||
doc_stream = IrBinary._record_to_stream(included_doc, 'datas').read()
|
||||
pdf_data.append(doc_stream)
|
||||
pdf_data.append((initial_stream).getvalue())
|
||||
if footer:
|
||||
footer_stream = IrBinary._record_to_stream(record, 'sale_footer').read()
|
||||
pdf_data.append(footer_stream)
|
||||
|
||||
try:
|
||||
form_fields = self._get_form_fields_mapping(order)
|
||||
stream = io.BytesIO(pdf.merge_pdf(pdf_data, form_fields))
|
||||
result[order.id].update({'stream': stream})
|
||||
except (ValueError, PdfStreamError, PdfReadError, TypeError, zlib_error,
|
||||
NotImplementedError):
|
||||
record._message_log(body=_(
|
||||
"There was an error when trying to merge headers and footers to the "
|
||||
"original PDF.\n Please make sure the source file are valid."
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
def _get_form_fields_mapping(self, order):
|
||||
""" Dictionary mapping specific pdf fields name to Odoo fields data.
|
||||
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()
|
||||
tz = order.partner_id.tz or self.env.user.tz or 'UTC'
|
||||
env = self.with_context(use_babel=True).env
|
||||
currency_id = order.currency_id
|
||||
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, currency_id),
|
||||
'amount_total': format_amount(env, order.amount_total, currency_id),
|
||||
'commitment_date': format_date(env, order.commitment_date, tz=tz),
|
||||
'validity_date': format_date(env, order.validity_date, tz=tz),
|
||||
'client_order_ref': order.client_order_ref or '',
|
||||
}
|
||||
return form_fields_mapping
|
||||
@@ -0,0 +1,30 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import _, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class ProductDocument(models.Model):
|
||||
_inherit = 'product.document'
|
||||
|
||||
attached_on = fields.Selection(
|
||||
selection_add=[('inside', "Inside quote")],
|
||||
help="Allows you to share the document with your customers within a sale.\n"
|
||||
"Leave it empty if you don't want to share this document with sales customer.\n"
|
||||
"Quotation: the document will be sent to and accessible by customers at any time.\n"
|
||||
"e.g. this option can be useful to share Product description files.\n"
|
||||
"Confirmed order: the document will be sent to and accessible by customers.\n"
|
||||
"e.g. this option can be useful to share User Manual or digital content bought"
|
||||
" on ecommerce. \n"
|
||||
"Inside quote: The document will be included in the pdf of the quotation between the "
|
||||
"header pages and the quote table. ",
|
||||
)
|
||||
|
||||
def write(self, vals):
|
||||
res = super().write(vals)
|
||||
if vals.keys() & {'attached_on', 'mimetype'}:
|
||||
if any(
|
||||
doc.attached_on == 'inside' and not doc.mimetype.endswith('pdf') for doc in self
|
||||
):
|
||||
raise ValidationError(_("Only PDF documents can be attached inside a quote."))
|
||||
return res
|
||||
@@ -0,0 +1,10 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ResCompany(models.Model):
|
||||
_inherit = 'res.company'
|
||||
|
||||
sale_header = fields.Binary(string="Header pages")
|
||||
sale_footer = fields.Binary(string="Footer pages")
|
||||
@@ -0,0 +1,12 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class SaleOrderTemplate(models.Model):
|
||||
_inherit = 'sale.order.template'
|
||||
|
||||
sale_header = fields.Binary(
|
||||
string="Header pages", default=lambda self: self.env.company.sale_header)
|
||||
sale_footer = fields.Binary(
|
||||
string="Footer pages", default=lambda self: self.env.company.sale_footer)
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="action_report_saleorder_raw" model="ir.actions.report">
|
||||
<field name="name">Quotation / Order</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">sale.report_saleorder_raw</field>
|
||||
<field name="report_file">sale.report_saleorder_raw</field>
|
||||
<field name="print_report_name">(object.state in ('draft', 'sent') and 'Quotation - %s' % (object.name)) or 'Order - %s' % (object.name)</field>
|
||||
<field name="binding_model_id" ref="sale.model_sale_order"/>
|
||||
<field name="binding_type">report</field>
|
||||
</record>
|
||||
|
||||
<record id="sale.action_report_saleorder" model="ir.actions.report">
|
||||
<field name="name">PDF Quote</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="sale_order_template_form" model="ir.ui.view">
|
||||
<field name="name">sale.order.template.form</field>
|
||||
<field name="model">sale.order.template</field>
|
||||
<field name="inherit_id" ref="sale_management.sale_order_template_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<notebook position="inside">
|
||||
<page name="pdf_quote" string="PDF Quote Builder">
|
||||
<group>
|
||||
<p class="text-muted" colspan="2">
|
||||
Provide header pages and footer pages to compose an attractive quotation
|
||||
with more information about your company, your products and your services.
|
||||
The pdf of your quotes will be built by putting together header pages,
|
||||
product descriptions, details of the quote and then the footer pages.
|
||||
</p>
|
||||
<group>
|
||||
<field name="sale_header" options="{'accepted_file_extensions': '.pdf'}"/>
|
||||
<field name="sale_footer" options="{'accepted_file_extensions': '.pdf'}"/>
|
||||
</group>
|
||||
<p class="text-muted" colspan="2">
|
||||
Products descriptions are pdf documents you can add directly on products.
|
||||
To do so, go on a product, find the "product documents" button, then add a
|
||||
new pdf document with a visibility set as "Inside Quotes". For each product
|
||||
in the quote, if the product has an "inside quotes" document, this document
|
||||
will be added after header pages and before the quotation details.
|
||||
</p>
|
||||
<p class="text-muted" colspan="2">
|
||||
Some information specific to the quote (customer name, quotation reference, ... )
|
||||
can be injected in these documents using pdf forms.
|
||||
Refer to the documentation to know more about this feature.
|
||||
</p>
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1 @@
|
||||
from . import res_config_settings
|
||||
@@ -0,0 +1,10 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
sale_header = fields.Binary(related='company_id.sale_header', readonly=False)
|
||||
sale_footer = fields.Binary(related='company_id.sale_footer', readonly=False)
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="res_config_settings_view_form" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.inherit.sale.pdf.quote.builder</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="inherit_id" ref="sale_management.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<setting id="sale_pdf_quote_builder" position="replace">
|
||||
<setting id="sale_pdf_quote_builder"
|
||||
string="PDF Quote builder"
|
||||
help="Make your quote attractive by adding header pages, product descriptions and footer pages to your quote."
|
||||
company_dependent="1">
|
||||
<div class="mt16">
|
||||
<label for="sale_header" class="me-2"/>
|
||||
<field name="sale_header" options="{'accepted_file_extensions': '.pdf'}"/>
|
||||
</div>
|
||||
<div class="mt16">
|
||||
<label for="sale_footer" class="me-2"/>
|
||||
<field name="sale_footer" options="{'accepted_file_extensions': '.pdf'}"/>
|
||||
</div>
|
||||
</setting>
|
||||
</setting>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
+17
-2
@@ -30,6 +30,7 @@ try:
|
||||
|
||||
PyPDF2.PdfFileReader = PdfFileReader
|
||||
from PyPDF2 import PdfFileWriter, PdfFileReader
|
||||
PdfFileReader.getFields = PdfFileReader.get_fields
|
||||
PdfFileWriter._addObject = PdfFileWriter._add_object
|
||||
except ImportError:
|
||||
from PyPDF2 import PdfFileWriter, PdfFileReader
|
||||
@@ -76,17 +77,31 @@ class BrandedFileWriter(PdfFileWriter):
|
||||
PdfFileWriter = BrandedFileWriter
|
||||
|
||||
|
||||
def merge_pdf(pdf_data):
|
||||
def merge_pdf(pdf_data, form_fields=None):
|
||||
''' Merge a collection of PDF documents in one.
|
||||
Note that the attachments are not merged.
|
||||
:param list pdf_data: a list of PDF datastrings
|
||||
:param dict form_fields: a dictionary of form fields to update in the merged PDF
|
||||
:return: a unique merged PDF datastring
|
||||
'''
|
||||
form_fields = form_fields or {}
|
||||
writer = PdfFileWriter()
|
||||
for document in pdf_data:
|
||||
reader = PdfFileReader(io.BytesIO(document), strict=False)
|
||||
has_fields = form_fields and bool(reader.getFields())
|
||||
for page in range(0, reader.getNumPages()):
|
||||
writer.addPage(reader.getPage(page))
|
||||
page = reader.getPage(page)
|
||||
writer.addPage(page)
|
||||
if has_fields:
|
||||
try:
|
||||
writer.update_page_form_field_values(page, form_fields)
|
||||
except AttributeError: # This method was renamed in PyPDF2 2.0
|
||||
# This is a known bug on previous version of PyPDF2, fixed in 2.11
|
||||
if not page.get('/Annots'):
|
||||
_logger.info("No fields to update in this page")
|
||||
else:
|
||||
writer.updatePageFormFieldValues(page, form_fields)
|
||||
|
||||
with io.BytesIO() as _buffer:
|
||||
writer.write(_buffer)
|
||||
return _buffer.getvalue()
|
||||
|
||||
Reference in New Issue
Block a user