From e720cfe901ecfbe5826854c374ab075b54e6d93d Mon Sep 17 00:00:00 2001 From: Camille Spiritus Date: Wed, 23 Feb 2022 10:40:20 +0000 Subject: [PATCH] [IMP][l10n_ch] account: allow for QR bills to be printed in batch The only option to print a QR Invoice was to go on the invoice page and to click on the Print QR Button. This PR allows for a user to print multiple QR codes, selected from the Invoices view. The download occurs normally if all invoices are valid and QR-printable. A wizard opens when the whole invoice selection isn't valid. It allows to download the invoices as a single pdf or to see a list of the ones that could not be printed in the QR format. The wizard's text depends on the number of QR-valid, ISR-valid and classic (non QR, non ISR) invoices that the user tried to print. All invoices for which a print is asked will be printed with a QR bill if it is possible. If not, this will check if printing in the ISR format is possible. Lastly, if none is possible, the classic invoice will be printed. The behaviour allowed for the removal of the QR PRINT and ISR PRINT buttons on the single invoice view, for a behaviour closer to the original guidelines. Bills can't be QR printed. Corrected QR-Bill CSS. task-2726507 closes odoo/odoo#82341 Signed-off-by: Laurent Smet --- addons/account/models/res_bank.py | 4 +- .../account_qr_code_sepa/models/res_bank.py | 2 +- addons/l10n_ch/__manifest__.py | 2 + addons/l10n_ch/models/__init__.py | 1 - addons/l10n_ch/models/account_invoice.py | 83 ++++++++------- addons/l10n_ch/models/ir_actions_report.py | 44 +++++++- addons/l10n_ch/models/mail_template.py | 54 ---------- addons/l10n_ch/models/res_bank.py | 27 +++-- addons/l10n_ch/report/swissqr_report.py | 3 +- addons/l10n_ch/report/swissqr_report.xml | 1 - addons/l10n_ch/security/ir.model.access.csv | 2 + .../static/src/scss/report_swissqr.scss | 8 ++ addons/l10n_ch/tests/__init__.py | 1 + .../l10n_ch/tests/test_l10n_ch_isr_print.py | 7 +- addons/l10n_ch/tests/test_l10n_ch_qr_print.py | 77 ++++++++++++++ addons/l10n_ch/tests/test_swissqr.py | 6 +- addons/l10n_ch/views/account_invoice_view.xml | 65 +++--------- .../l10n_ch/views/qr_invoice_wizard_view.xml | 38 +++++++ addons/l10n_ch/wizard/__init__.py | 1 + addons/l10n_ch/wizard/qr_invoice_wizard.py | 100 ++++++++++++++++++ 20 files changed, 360 insertions(+), 166 deletions(-) delete mode 100644 addons/l10n_ch/models/mail_template.py create mode 100644 addons/l10n_ch/security/ir.model.access.csv create mode 100644 addons/l10n_ch/tests/test_l10n_ch_qr_print.py create mode 100644 addons/l10n_ch/views/qr_invoice_wizard_view.xml create mode 100644 addons/l10n_ch/wizard/qr_invoice_wizard.py diff --git a/addons/account/models/res_bank.py b/addons/account/models/res_bank.py index bb01198badd..df3835cdd54 100644 --- a/addons/account/models/res_bank.py +++ b/addons/account/models/res_bank.py @@ -151,7 +151,7 @@ class ResPartnerBank(models.Model): return [(code, name) for (code, name, sequence) in all_available] - def _eligible_for_qr_code(self, qr_method, debtor_partner, currency): + def _eligible_for_qr_code(self, qr_method, debtor_partner, currency, raises_error=True): """ Tells whether or not the criteria to apply QR-generation method qr_method are met for a payment on this account, in the given currency, by debtor_partner. This does not impeach generation errors, @@ -167,4 +167,4 @@ class ResPartnerBank(models.Model): Returns None if no error was found, or a string describing the first error encountered so that it can be reported to the user. """ - return None \ No newline at end of file + return None diff --git a/addons/account_qr_code_sepa/models/res_bank.py b/addons/account_qr_code_sepa/models/res_bank.py index 0927b429c1d..29ed6473708 100644 --- a/addons/account_qr_code_sepa/models/res_bank.py +++ b/addons/account_qr_code_sepa/models/res_bank.py @@ -38,7 +38,7 @@ class ResPartnerBank(models.Model): } return super()._get_qr_code_generation_params(qr_method, amount, currency, debtor_partner, free_communication, structured_communication) - def _eligible_for_qr_code(self, qr_method, debtor_partner, currency): + def _eligible_for_qr_code(self, qr_method, debtor_partner, currency, raises_error=True): if qr_method == 'sct_qr': # Some countries share the same IBAN country code diff --git a/addons/l10n_ch/__manifest__.py b/addons/l10n_ch/__manifest__.py index 0d9b256e0f9..4de7e40085e 100644 --- a/addons/l10n_ch/__manifest__.py +++ b/addons/l10n_ch/__manifest__.py @@ -36,6 +36,7 @@ Here is how it works: 'depends': ['account', 'l10n_multilang', 'base_iban', 'l10n_din5008'], 'data': [ + 'security/ir.model.access.csv', 'data/l10n_ch_chart_data.xml', 'data/account.account.template.csv', 'data/l10n_ch_chart_post_data.xml', @@ -50,6 +51,7 @@ Here is how it works: 'views/account_invoice_view.xml', 'views/res_config_settings_views.xml', 'views/setup_wizard_views.xml', + 'views/qr_invoice_wizard_view.xml' ], 'demo': [ diff --git a/addons/l10n_ch/models/__init__.py b/addons/l10n_ch/models/__init__.py index 9ee1b1e289a..b39307615c9 100644 --- a/addons/l10n_ch/models/__init__.py +++ b/addons/l10n_ch/models/__init__.py @@ -4,7 +4,6 @@ from . import res_config_settings from . import account_invoice from . import account_journal -from . import mail_template from . import res_bank from . import res_company from . import account_bank_statement diff --git a/addons/l10n_ch/models/account_invoice.py b/addons/l10n_ch/models/account_invoice.py index 06dc59b8a62..1cc742ac7ae 100644 --- a/addons/l10n_ch/models/account_invoice.py +++ b/addons/l10n_ch/models/account_invoice.py @@ -1,18 +1,24 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. - import re from odoo import models, fields, api, _ -from odoo.exceptions import ValidationError, UserError +from odoo.exceptions import UserError from odoo.tools.float_utils import float_split_str from odoo.tools.misc import mod10r - l10n_ch_ISR_NUMBER_LENGTH = 27 l10n_ch_ISR_ID_NUM_LENGTH = 6 class AccountMove(models.Model): + # NOTE + # The ISR system is kept and taken into account up to September 2022. + # After that, the transition to the QR system will be completed and the ISR system won't exist anymore. + # This means that Odoo v16 shouldn't support the ISR system and all the references to it should be cleaned up by then. + # In the versions leading to that change, + # although the functions related to the ISR are still taken into account and still exist, + # the QR billing is always preferred. + _inherit = 'account.move' l10n_ch_isr_subscription = fields.Char(compute='_compute_l10n_ch_isr_subscription', help='ISR subscription number identifying your company or your bank to generate ISR.') @@ -29,6 +35,14 @@ class AccountMove(models.Model): l10n_ch_currency_name = fields.Char(related='currency_id.name', readonly=True, string="Currency Name", help="The name of this invoice's currency") #This field is used in the "invisible" condition field of the 'Print ISR' button. l10n_ch_isr_needs_fixing = fields.Boolean(compute="_compute_l10n_ch_isr_needs_fixing", help="Used to show a warning banner when the vendor bill needs a correct ISR payment reference. ") + l10n_ch_is_qr_valid = fields.Boolean(compute='_compute_l10n_ch_qr_is_valid', help="Determines whether an invoice can be printed as a QR or not") + + @api.depends('partner_id', 'currency_id') + def _compute_l10n_ch_qr_is_valid(self): + for move in self: + move.l10n_ch_is_qr_valid = move.move_type == 'out_invoice' \ + and move.partner_bank_id._eligible_for_qr_code('ch_qr', move.partner_id, move.currency_id, raises_error=False) + @api.depends('partner_bank_id.l10n_ch_isr_subscription_eur', 'partner_bank_id.l10n_ch_isr_subscription_chf') def _compute_l10n_ch_isr_subscription(self): """ Computes the ISR subscription identifying your company or the bank that allows to generate ISR. And formats it accordingly""" @@ -278,42 +292,11 @@ class AccountMove(models.Model): """ return float_split_str(self.amount_residual, 2) - def isr_print(self): - """ Triggered by the 'Print ISR' button. - """ - self.ensure_one() - if self.l10n_ch_isr_valid: - self.l10n_ch_isr_sent = True - return self.env.ref('l10n_ch.l10n_ch_isr_report').report_action(self) - else: - raise ValidationError(_("""You cannot generate an ISR yet.\n - For this, you need to :\n - - set a valid postal account number (or an IBAN referencing one) for your company\n - - define its bank\n - - associate this bank with a postal reference for the currency used in this invoice\n - - fill the 'bank account' field of the invoice with the postal to be used to receive the related payment. A default account will be automatically set for all invoices created after you defined a postal account for your company.""")) - - def print_ch_qr_bill(self): - """ Triggered by the 'Print QR-bill' button. - """ - self.ensure_one() - - if not self.partner_bank_id: - raise UserError(_("QR-Bill can not be generated on paid invoices. If the invoice is not fully paid, please make sure Recipient Bank field is not empty and try again.")) - - if not self.partner_bank_id._eligible_for_qr_code('ch_qr', self.partner_id, self.currency_id): - raise UserError(_("Cannot generate the QR-bill. Please check you have configured the address of your company and debtor. If you are using a QR-IBAN, also check the invoice's payment reference is a QR reference.")) - - self.l10n_ch_isr_sent = True - return self.env.ref('l10n_ch.l10n_ch_qr_report').report_action(self) - def action_invoice_sent(self): # OVERRIDE rslt = super(AccountMove, self).action_invoice_sent() - - if self.l10n_ch_isr_valid: + if self.l10n_ch_isr_valid or self.l10n_ch_is_qr_valid: rslt['context']['l10n_ch_mark_isr_as_sent'] = True - return rslt @api.returns('mail.message', lambda value: value.id) @@ -346,5 +329,33 @@ class AccountMove(models.Model): while i > 0: spaced_qrr_ref = qrr_ref[max(i-5, 0) : i] + ' ' + spaced_qrr_ref i -= 5 - return spaced_qrr_ref + + def l10n_ch_action_print_qr(self): + ''' + Checks that all invoices can be printed in the QR format. + If so, launches the printing action. + Else, triggers the l10n_ch wizard that will display the informations. + ''' + if any(x.move_type != 'out_invoice' for x in self): + raise UserError(_("Only customers invoices can be QR-printed.")) + if False in self.mapped('l10n_ch_is_qr_valid'): + return { + 'name': (_("Some invoices could not be printed in the QR format")), + 'type': 'ir.actions.act_window', + 'res_model': 'l10n_ch.qr_invoice.wizard', + 'view_type': 'form', + 'view_mode': 'form', + 'target': 'new', + 'context': {'active_ids': self.ids}, + } + return self.env.ref('account.account_invoices').report_action(self) + + def _l10n_ch_dispatch_invoices_to_print(self): + qr_invs = self.filtered('l10n_ch_is_qr_valid') + isr_invs = self.filtered('l10n_ch_isr_valid') + return { + 'qr': qr_invs, + 'isr': isr_invs, + 'classic': self - qr_invs - isr_invs, + } diff --git a/addons/l10n_ch/models/ir_actions_report.py b/addons/l10n_ch/models/ir_actions_report.py index 78439c16f89..ac27a31a4a3 100644 --- a/addons/l10n_ch/models/ir_actions_report.py +++ b/addons/l10n_ch/models/ir_actions_report.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. - +import io from odoo import api, models - +from odoo.tools.pdf import OdooPdfFileReader, OdooPdfFileWriter from pathlib import Path -from reportlab.graphics.shapes import Image as ReportLabImage +from reportlab.graphics.shapes import Image as ReportLabImage from reportlab.lib.units import mm CH_QR_CROSS_SIZE_RATIO = 0.1522 # Ratio between the side length of the Swiss QR-code cross image and the QR-code's @@ -26,3 +26,41 @@ class IrActionsReport(models.Model): cross_path = Path(__file__).absolute().parent / CH_QR_CROSS_FILE qr_cross = ReportLabImage((width/2 - cross_width/2) / mm, (height/2 - cross_height/2) / mm, cross_width / mm, cross_height / mm, cross_path.as_posix()) barcode_drawing.add(qr_cross) + + def _render_qweb_pdf_prepare_streams(self, data, res_ids=None): + # OVERRIDE + res = super()._render_qweb_pdf_prepare_streams(data, res_ids) + if res_ids and self.report_name in ('account.report_invoice_with_payments', 'account.report_invoice'): + invoices = self.env[self.model].browse(res_ids) + # Determine which invoices need a QR/ISR. + qr_inv_ids = [] + isr_inv_ids = [] + for invoice in invoices: + if invoice.company_id.country_code != 'CH': + continue + if invoice.l10n_ch_is_qr_valid: + qr_inv_ids.append(invoice.id) + elif invoice.l10n_ch_isr_valid: + isr_inv_ids.append(invoice.id) + # Render the additional reports. + streams_to_append = {} + if qr_inv_ids: + qr_res = self.env.ref('l10n_ch.l10n_ch_qr_report')._render_qweb_pdf_prepare_streams(data, res_ids=qr_inv_ids) + for invoice_id, stream in qr_res.items(): + streams_to_append[invoice_id] = stream + if isr_inv_ids: + isr_res = self.env.ref('l10n_ch.l10n_ch_isr_report')._render_qweb_pdf_prepare_streams(data, res_ids=qr_inv_ids) + for invoice_id, stream in isr_res.items(): + streams_to_append[invoice_id] = stream + # Add to results + for invoice_id, additional_stream in streams_to_append.items(): + invoice_stream = res[invoice_id]['stream'] + writer = OdooPdfFileWriter() + writer.appendPagesFromReader(OdooPdfFileReader(invoice_stream, strict=False)) + writer.appendPagesFromReader(OdooPdfFileReader(additional_stream['stream'], strict=False)) + new_pdf_stream = io.BytesIO() + writer.write(new_pdf_stream) + res[invoice_id]['stream'] = new_pdf_stream + invoice_stream.close() + additional_stream['stream'].close() + return res diff --git a/addons/l10n_ch/models/mail_template.py b/addons/l10n_ch/models/mail_template.py deleted file mode 100644 index 7d50d1eb5e2..00000000000 --- a/addons/l10n_ch/models/mail_template.py +++ /dev/null @@ -1,54 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -import base64 - -from odoo import api, models - - -class MailTemplate(models.Model): - _inherit = 'mail.template' - - def generate_email(self, res_ids, fields): - """ Method overridden in order to add an attachment containing the ISR - to the draft message when opening the 'send by mail' wizard on an invoice. - This attachment generation will only occur if all the required data are - present on the invoice. Otherwise, no ISR attachment will be created, and - the mail will only contain the invoice (as defined in the mother method). - """ - result = super(MailTemplate, self).generate_email(res_ids, fields) - if self.model != 'account.move': - return result - - multi_mode = True - if isinstance(res_ids, int): - res_ids = [res_ids] - multi_mode = False - - if self.model == 'account.move': - for record in self.env[self.model].browse(res_ids): - inv_print_name = self._render_field('report_name', record.ids, compute_lang=True)[record.id] - new_attachments = [] - - if record.l10n_ch_isr_valid: - # We add an attachment containing the ISR - isr_report_name = 'ISR-' + inv_print_name + '.pdf' - isr_pdf = self.env.ref('l10n_ch.l10n_ch_isr_report')._render_qweb_pdf(record.ids)[0] - isr_pdf = base64.b64encode(isr_pdf) - new_attachments.append((isr_report_name, isr_pdf)) - - if record.partner_bank_id._eligible_for_qr_code('ch_qr', record.partner_id, record.currency_id): - # We add an attachment containing the QR-bill - qr_report_name = 'QR-bill-' + inv_print_name + '.pdf' - qr_pdf = self.env.ref('l10n_ch.l10n_ch_qr_report')._render_qweb_pdf(record.ids)[0] - qr_pdf = base64.b64encode(qr_pdf) - new_attachments.append((qr_report_name, qr_pdf)) - - record_dict = multi_mode and result[record.id] or result - attachments_list = record_dict.get('attachments', False) - if attachments_list: - attachments_list.extend(new_attachments) - else: - record_dict['attachments'] = new_attachments - - return result diff --git a/addons/l10n_ch/models/res_bank.py b/addons/l10n_ch/models/res_bank.py index 81ec17034ef..1d1fffce6a2 100644 --- a/addons/l10n_ch/models/res_bank.py +++ b/addons/l10n_ch/models/res_bank.py @@ -6,10 +6,9 @@ import re from odoo import api, fields, models, _ from odoo.addons.base.models.res_bank import sanitize_account_number from odoo.addons.base_iban.models.res_partner_bank import normalize_iban, pretty_iban, validate_iban -from odoo.exceptions import ValidationError +from odoo.exceptions import ValidationError, UserError from odoo.tools.misc import mod10r - ISR_SUBSCRIPTION_CODE = {'CHF': '01', 'EUR': '03'} CLEARING = "09000" _re_postal = re.compile('^[0-9]{2}-[0-9]{1,6}-[0-9]$') @@ -332,15 +331,23 @@ class ResPartnerBank(models.Model): and re.match('\d+$', reference) \ and reference == mod10r(reference[:-1]) - def _eligible_for_qr_code(self, qr_method, debtor_partner, currency): + def _eligible_for_qr_code(self, qr_method, debtor_partner, currency, raises_error=True): if qr_method == 'ch_qr': - - return self.acc_type == 'iban' and \ - self.partner_id.country_id.code == 'CH' and \ - (not debtor_partner or debtor_partner.country_id.code == 'CH') \ - and currency.name in ('EUR', 'CHF') - - return super()._eligible_for_qr_code(qr_method, debtor_partner, currency) + error_messages = [_("The QR code could not be generated for the following reason(s):")] + if self.acc_type != 'iban': + error_messages.append(_("The account type isn't QR-IBAN.")) + if self.partner_id.country_id.code != 'CH': + error_messages.append(_("Your company isn't located in Switzerland.")) + if not debtor_partner or debtor_partner.country_id.code != 'CH': + error_messages.append(_("The debtor partner's address isn't located in Switzerland.")) + if currency.id not in (self.env.ref('base.EUR').id, self.env.ref('base.CHF').id): + error_messages.append(_("The currency isn't EUR nor CHF. \r\n")) + if len(error_messages) != 1: + if raises_error: + raise UserError(' '.join(error_messages)) + return False + return True + return super()._eligible_for_qr_code(qr_method, debtor_partner, currency, raises_error) def _check_for_qr_code_errors(self, qr_method, amount, currency, debtor_partner, free_communication, structured_communication): def _partner_fields_set(partner): diff --git a/addons/l10n_ch/report/swissqr_report.py b/addons/l10n_ch/report/swissqr_report.py index 178777cb035..c10e3959a12 100644 --- a/addons/l10n_ch/report/swissqr_report.py +++ b/addons/l10n_ch/report/swissqr_report.py @@ -1,5 +1,4 @@ # -*- coding:utf-8 -*- - from odoo import api, models class ReportSwissQR(models.AbstractModel): @@ -19,4 +18,4 @@ class ReportSwissQR(models.AbstractModel): 'doc_model': 'account.move', 'docs': docs, 'qr_code_urls': qr_code_urls, - } \ No newline at end of file + } diff --git a/addons/l10n_ch/report/swissqr_report.xml b/addons/l10n_ch/report/swissqr_report.xml index ed18b919b5a..1a813117efe 100644 --- a/addons/l10n_ch/report/swissqr_report.xml +++ b/addons/l10n_ch/report/swissqr_report.xml @@ -10,7 +10,6 @@ l10n_ch.qr_report_main 'QR-bill-%s' % object.name - 'QR-bill-' + object.name + '.pdf'