diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index 0552636c84f..3923e14eeef 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -4076,12 +4076,29 @@ class AccountMove(models.Model): 'checkbox_download': False, } - def _generate_pdf_and_send_invoice(self, template, from_cron=True): + def _generate_pdf_and_send_invoice(self, template, from_cron=True, allow_fallback_pdf=True): + """ Generate the pdf for the current invoices and send them by mail using the send & print wizard. + + :param from_cron: Flag indicating if the method is called from a cron. In that case, we avoid raising any + error. + :param allow_fallback_pdf: In case of error when generating the documents for invoices, generate a + proforma PDF report instead. + """ composer_vals = self._get_pdf_and_send_invoice_vals(template) composer = self.env['account.move.send'].create(composer_vals) # from_cron=True to log errors in chatter instead of raise - composer.action_send_and_print(from_cron=from_cron) + composer.action_send_and_print(from_cron=from_cron, allow_fallback_pdf=allow_fallback_pdf) + + def _get_invoice_pdf_report_filename(self): + """ Get the filename of the generated PDF invoice report. """ + self.ensure_one() + return f"{self.name.replace('/', '_')}.pdf" + + def _get_invoice_proforma_pdf_report_filename(self): + """ Get the filename of the generated proforma PDF invoice report. """ + self.ensure_one() + return f"{self.name.replace('/', '_')}_proforma.pdf" # ------------------------------------------------------------------------- # TOOLING @@ -4300,7 +4317,7 @@ class AccountMove(models.Model): def _get_mail_thread_data_attachments(self): res = super()._get_mail_thread_data_attachments() # else, attachments with 'res_field' get excluded - return res | self.env['account.move.send']._get_linked_attachments(self) + return res | self.env['account.move.send']._get_invoice_extra_attachments(self) # ------------------------------------------------------------------------- # TOOLING diff --git a/addons/account/static/src/components/mail_attachments/mail_attachments.js b/addons/account/static/src/components/mail_attachments/mail_attachments.js index 6ad085ea87b..a8d46ac5c56 100644 --- a/addons/account/static/src/components/mail_attachments/mail_attachments.js +++ b/addons/account/static/src/components/mail_attachments/mail_attachments.js @@ -47,6 +47,7 @@ export class MailAttachments extends Component { name: file.filename, mimetype: file.mimetype, placeholder: false, + manual: true, }); } this.props.record.update({ [this.props.name]: this.getValue().concat(extraFiles) }); diff --git a/addons/account/tests/test_account_move_send.py b/addons/account/tests/test_account_move_send.py index 8e34866fa97..7b3f7493b0a 100644 --- a/addons/account/tests/test_account_move_send.py +++ b/addons/account/tests/test_account_move_send.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from ast import literal_eval - from datetime import date +from unittest.mock import patch +from odoo import Command from odoo.addons.account.tests.common import AccountTestInvoicingCommon from odoo.addons.mail.tests.common import MailCommon from odoo.tests.common import Form, users, warmup @@ -13,7 +13,7 @@ from odoo.tools import formataddr, mute_logger @tagged('post_install_l10n', 'post_install', '-at_install') -class AccountMoveSendBase(AccountTestInvoicingCommon, MailCommon): +class TestAccountComposerPerformance(AccountTestInvoicingCommon, MailCommon): @classmethod def setUpClass(cls): @@ -131,11 +131,6 @@ class AccountMoveSendBase(AccountTestInvoicingCommon, MailCommon): self.patch(self.env.registry, 'ready', True) self.flush_tracking() - -@tagged('post_install_l10n', 'post_install', '-at_install') -class TestAccountComposerPerformance(AccountMoveSendBase): - """ Test performance of custom composer for moves. """ - def test_assert_initial_values(self): """ Test initial values to ease understanding of results and notifications """ for move in self.test_account_moves: @@ -239,7 +234,7 @@ class TestAccountComposerPerformance(AccountMoveSendBase): with self.mock_mail_gateway(mail_unlink_sent=False), \ self.mock_mail_app(): - composer.action_send_and_print() + composer.action_send_and_print(allow_fallback_pdf=True) self.env.cr.flush() # force tracking message self.assertEqual(len(self._new_msgs), 2, 'Should produce 2 messages: one for posting template, one for tracking') @@ -254,7 +249,7 @@ class TestAccountComposerPerformance(AccountMoveSendBase): ) # print: template-based message - self.assertEqual(len(print_msg.attachment_ids), 3) # + factur-x.xml in tests + self.assertEqual(len(print_msg.attachment_ids), 3) self.assertNotIn(self.attachments, print_msg.attachment_ids, 'Attachments should be duplicated, not just linked') self.assertEqual(print_msg.author_id, self.user_account_other.partner_id, @@ -301,7 +296,6 @@ class TestAccountComposerPerformance(AccountMoveSendBase): ) # composer configuration - self.assertEqual(len(composer.mail_attachments_widget), 3) self.assertIn(f'TemplateBody for {test_move.name}', composer.mail_body) self.assertEqual(composer.move_ids, test_move) self.assertTrue(composer.checkbox_send_mail) @@ -342,7 +336,7 @@ class TestAccountComposerPerformance(AccountMoveSendBase): ) # print: template-based message - self.assertEqual(len(print_msg.attachment_ids), 3) # + factur-x.xml in tests + self.assertEqual(len(print_msg.attachment_ids), 3) self.assertNotIn(self.attachments, print_msg.attachment_ids, 'Attachments should be duplicated, not just linked') self.assertEqual(print_msg.author_id, self.user_account_other.partner_id, @@ -389,7 +383,6 @@ class TestAccountComposerPerformance(AccountMoveSendBase): ) # composer configuration - self.assertEqual(len(composer.mail_attachments_widget), 3) self.assertIn(f'SpanishBody for {test_move.name}', composer.mail_body, 'Should be translated, based on template') self.assertEqual(composer.move_ids, test_move) @@ -435,3 +428,295 @@ class TestAccountComposerPerformance(AccountMoveSendBase): author=self.user_account_other.partner_id, content='access_token=' ) + +@tagged('post_install_l10n', 'post_install', '-at_install') +class TestAccountMoveSendCommon(AccountTestInvoicingCommon): + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + cls.partner_a.email = "partner_a@tsointsoin" + cls.partner_b.email = "partner_b@tsointsoin" + + def _assert_mail_attachments_widget(self, wizard, expected_values_list): + self.assertEqual(len(wizard.mail_attachments_widget), len(expected_values_list)) + for values, expected_values in zip(wizard.mail_attachments_widget, expected_values_list): + try: + int(values['id']) + check_id_needed = True + except ValueError: + check_id_needed = False + self.assertDictEqual( + {k: v for k, v in values.items() if not check_id_needed and k != 'id'}, + {k: v for k, v in expected_values.items() if not check_id_needed and k != 'id'}, + ) + + def create_send_and_print(self, invoices, **kwargs): + template = self.env.ref(invoices._get_mail_template()) + return self.env['account.move.send']\ + .with_context(active_model='account.move', active_ids=invoices.ids)\ + .create({ + 'mail_template_id': template.id, + **kwargs, + }) + + def _get_mail_message(self, move): + return self.env['mail.message'].search([('model', '=', move._name), ('res_id', '=', move.id)], limit=1) + +@tagged('post_install_l10n', 'post_install', '-at_install') +class TestAccountMoveSend(TestAccountMoveSendCommon): + + def test_send_mail_warning_message(self): + invoice = self.init_invoice("out_invoice", amounts=[1000], post=True) + self.partner_a.email = None + wizard = self.create_send_and_print(invoice) + self.assertTrue(wizard.send_mail_warning_message) + + # Fix the partner. + self.partner_a.email = "turlututu@tsointsoin" + wizard.invalidate_recordset(fnames=['send_mail_warning_message']) + self.assertFalse(wizard.send_mail_warning_message) + + def test_invoice_single(self): + invoice = self.init_invoice("out_invoice", amounts=[1000], post=True) + wizard = self.create_send_and_print(invoice) + self.assertRecordValues(wizard, [{ + 'mode': 'invoice_single', + 'enable_download': True, + 'checkbox_download': True, + 'enable_send_mail': True, + 'display_mail_composer': True, + 'send_mail_readonly': False, + 'checkbox_send_mail': True, + 'mail_lang': 'en_US', + 'mail_partner_ids': wizard.move_ids.partner_id.ids, + }]) + self.assertTrue(wizard.mail_subject) + self.assertTrue(wizard.mail_body) + self._assert_mail_attachments_widget(wizard, [{ + 'mimetype': 'application/pdf', + 'name': 'INV_2019_00001.pdf', + 'placeholder': True, + }]) + + # Process. + results = wizard.action_send_and_print() + self.assertEqual(results['type'], 'ir.actions.act_url') + self.assertRecordValues(wizard, [{'mode': 'done'}]) + + # The PDF has been successfully generated. + pdf_report = invoice.invoice_pdf_report_id + self.assertTrue(pdf_report) + invoice_attachments = self.env['ir.attachment'].search([ + ('res_model', '=', invoice._name), + ('res_id', '=', invoice.id), + ('res_field', '=', 'invoice_pdf_report_file'), + ]) + self.assertEqual(len(invoice_attachments), 1) + + # Send it again. The PDF must not be created again. + wizard = self.create_send_and_print(invoice) + results = wizard.action_send_and_print() + self.assertEqual(results['type'], 'ir.actions.act_url') + self.assertRecordValues(wizard, [{'mode': 'done'}]) + self.assertRecordValues(invoice, [{'invoice_pdf_report_id': pdf_report.id}]) + invoice_attachments = self.env['ir.attachment'].search([ + ('res_model', '=', invoice._name), + ('res_id', '=', invoice.id), + ('res_field', '=', 'invoice_pdf_report_file'), + ]) + self.assertEqual(len(invoice_attachments), 1) + + def test_invoice_multi(self): + invoice1 = self.init_invoice("out_invoice", partner=self.partner_a, amounts=[1000], post=True) + invoice2 = self.init_invoice("out_invoice", partner=self.partner_b, amounts=[1000], post=True) + + wizard = self.create_send_and_print(invoice1 + invoice2) + wizard_values = { + 'mode': 'invoice_multi', + 'enable_download': False, + 'checkbox_download': False, + 'enable_send_mail': True, + 'display_mail_composer': False, + 'send_mail_readonly': False, + 'checkbox_send_mail': True, + 'mail_lang': 'en_US', + 'mail_partner_ids': [], + 'mail_subject': False, + 'mail_body': False, + 'mail_attachments_widget': False, + } + self.assertRecordValues(wizard, [wizard_values]) + + # Process. + results = wizard.action_send_and_print() + self.assertEqual(results['type'], 'ir.actions.act_window_close') + self.assertRecordValues(wizard, [{'mode': 'invoice_multi'}]) + + # Awaiting the CRON. + self.assertFalse(invoice1.invoice_pdf_report_id) + self.assertFalse(invoice2.invoice_pdf_report_id) + + # Run the CRON. + wizard.action_send_and_print(from_cron=True) + self.assertTrue(invoice1.invoice_pdf_report_id) + invoice_attachments = self.env['ir.attachment'].search([ + ('res_model', '=', invoice1._name), + ('res_id', '=', invoice1.id), + ('res_field', '=', 'invoice_pdf_report_file'), + ]) + self.assertEqual(len(invoice_attachments), 1) + self.assertTrue(invoice2.invoice_pdf_report_id) + invoice_attachments = self.env['ir.attachment'].search([ + ('res_model', '=', invoice2._name), + ('res_id', '=', invoice2.id), + ('res_field', '=', 'invoice_pdf_report_file'), + ]) + self.assertEqual(len(invoice_attachments), 1) + + # Mix already sent invoice with a new one. + invoice3 = self.init_invoice("out_invoice", partner=self.partner_b, amounts=[1000], post=True) + wizard = self.create_send_and_print(invoice1 + invoice2 + invoice3) + self.assertRecordValues(wizard, [wizard_values]) + wizard.action_send_and_print(from_cron=True) + invoice_attachments = self.env['ir.attachment'].search([ + ('res_model', '=', invoice1._name), + ('res_id', '=', invoice1.id), + ('res_field', '=', 'invoice_pdf_report_file'), + ]) + self.assertEqual(len(invoice_attachments), 1) + invoice_attachments = self.env['ir.attachment'].search([ + ('res_model', '=', invoice2._name), + ('res_id', '=', invoice2.id), + ('res_field', '=', 'invoice_pdf_report_file'), + ]) + self.assertEqual(len(invoice_attachments), 1) + self.assertTrue(invoice1.invoice_pdf_report_id) + invoice_attachments = self.env['ir.attachment'].search([ + ('res_model', '=', invoice3._name), + ('res_id', '=', invoice3.id), + ('res_field', '=', 'invoice_pdf_report_file'), + ]) + self.assertEqual(len(invoice_attachments), 1) + + def test_invoice_mail_attachments_widget(self): + invoice = self.init_invoice("out_invoice", amounts=[1000], post=True) + + # Add a new attachment on the mail_template. + template = self.env.ref(invoice._get_mail_template()) + extra_attachment = self.env['ir.attachment'].create({'name': "extra_attachment", 'raw': b'bar'}) + template.attachment_ids = [Command.link(extra_attachment.id)] + + wizard = self.create_send_and_print(invoice) + pdf_report_values = { + 'mimetype': 'application/pdf', + 'name': 'INV_2019_00001.pdf', + 'placeholder': True, + } + extra_attachment_values = { + 'mimetype': 'application/octet-stream', + 'name': extra_attachment.name, + 'placeholder': False, + 'mail_template_id': template.id, + } + self._assert_mail_attachments_widget(wizard, [pdf_report_values, extra_attachment_values]) + + # Add a new attachment manually. + manual_attachment = self.env['ir.attachment'].create({'name': "manual_attachment", 'raw': b'foo'}) + manual_attachment_values = { + 'id': manual_attachment.id, + 'name': manual_attachment.name, + 'mimetype': manual_attachment.mimetype, + 'placeholder': False, + 'manual': True, + } + wizard.mail_attachments_widget = wizard.mail_attachments_widget + [manual_attachment_values] + + # Add an attachment to a new mail_template and change it. + new_mail_template = wizard.mail_template_id.copy() + extra_attachment2 = self.env['ir.attachment'].create({'name': "extra_attachment2", 'raw': b'bar'}) + new_mail_template.attachment_ids = [Command.set(extra_attachment2.ids)] + extra_attachment2_values = { + 'mimetype': 'application/octet-stream', + 'name': extra_attachment2.name, + 'placeholder': False, + 'mail_template_id': new_mail_template.id, + } + + wizard.mail_template_id = new_mail_template + self._assert_mail_attachments_widget(wizard, [ + pdf_report_values, + extra_attachment2_values, + manual_attachment_values, + ]) + + # Send. + wizard.action_send_and_print() + message = self._get_mail_message(invoice) + self.assertRecordValues(message, [{ + 'attachment_ids': [ + manual_attachment.id, + invoice.invoice_pdf_report_id.id, + extra_attachment2.id, + ], + }]) + + # Resend. + wizard = self.create_send_and_print(invoice) + pdf_report_values['id'] = invoice.invoice_pdf_report_id.id + self._assert_mail_attachments_widget(wizard, [ + pdf_report_values, + extra_attachment_values, + ]) + + # Switch the template. + wizard.mail_template_id = new_mail_template + self._assert_mail_attachments_widget(wizard, [ + pdf_report_values, + extra_attachment2_values, + ]) + + # Send. + wizard.action_send_and_print() + message = self._get_mail_message(invoice) + self.assertRecordValues(message, [{ + 'attachment_ids': [ + invoice.invoice_pdf_report_id.id, + extra_attachment2.id, + ], + }]) + + def test_proforma_pdf(self): + invoice = self.init_invoice("out_invoice", amounts=[1000], post=True) + wizard = self.create_send_and_print(invoice) + + def _hook_invoice_document_before_pdf_report_render(self, invoice, invoice_data): + invoice_data['error'] = 'test_proforma_pdf' + + # Process. + with patch.object(type(wizard), '_hook_invoice_document_before_pdf_report_render', _hook_invoice_document_before_pdf_report_render): + results = wizard.action_send_and_print(allow_fallback_pdf=True) + + self.assertEqual(results['type'], 'ir.actions.act_url') + self.assertRecordValues(wizard, [{'mode': 'done'}]) + + # The PDF is not generated but a proforma. + self.assertFalse(invoice.invoice_pdf_report_id) + + def test_error_but_continue(self): + invoice = self.init_invoice("out_invoice", amounts=[1000], post=True) + wizard = self.create_send_and_print(invoice) + + def _hook_invoice_document_before_pdf_report_render(self, invoice, invoice_data): + invoice_data['error'] = 'prout' + invoice_data['error_but_continue'] = True + + # Process. + with patch.object(type(wizard), '_hook_invoice_document_before_pdf_report_render', _hook_invoice_document_before_pdf_report_render): + results = wizard.action_send_and_print(allow_fallback_pdf=True) + + self.assertEqual(results['type'], 'ir.actions.act_url') + self.assertRecordValues(wizard, [{'mode': 'done'}]) + + # The PDF is generated even in case of error. + self.assertTrue(invoice.invoice_pdf_report_id) diff --git a/addons/account/views/report_invoice.xml b/addons/account/views/report_invoice.xml index d9ea609a5e6..c8c9602445d 100644 --- a/addons/account/views/report_invoice.xml +++ b/addons/account/views/report_invoice.xml @@ -51,6 +51,7 @@

+ PROFORMA Invoice Draft Invoice Cancelled Invoice diff --git a/addons/account/wizard/account_move_send.py b/addons/account/wizard/account_move_send.py index ece637ad895..f980135edf6 100644 --- a/addons/account/wizard/account_move_send.py +++ b/addons/account/wizard/account_move_send.py @@ -95,45 +95,66 @@ class AccountMoveSend(models.Model): .with_context(lang=lang)\ ._render_field(field, move.ids, **kwargs)[move._origin.id] - @api.model - def _get_default_mode_from_moves(self, moves): - if all(x.is_invoice(include_receipts=True) for x in moves): - return 'invoice_single' if len(moves) == 1 else 'invoice_multi' - return None + def _get_available_field_values_in_multi(self, move): + self.ensure_one() + return { + 'move_ids': [Command.set(move.ids)], + 'mail_template_id': self.mail_template_id.id, + 'checkbox_send_mail': self.checkbox_send_mail, + } + + def _get_placeholder_mail_attachments_data(self, move): + """ Returns all the placeholder data. + Should be extended to add placeholder based on the checkboxes. + + :param: move: The current move. + :returns: A list of dictionary for each placeholder. + * id: str: The (fake) id of the attachment, this is needed in rendering in t-key. + * name: str: The name of the attachment. + * mimetype: str: The mimetype of the attachment. + * placeholder bool: Should be true to prevent download / deletion. + """ + if move.invoice_pdf_report_id: + return [] + + filename = move._get_invoice_pdf_report_filename() + return [{ + 'id': f'placeholder_{filename}', + 'name': filename, + 'mimetype': 'application/pdf', + 'placeholder': True, + }] @api.model - def _get_default_lang(self, mail_template, move): - if mail_template: - return mail_template._render_lang([move.id]).get(move.id) - else: - return get_lang(self.env).code + def _get_invoice_extra_attachments(self, move): + return move.invoice_pdf_report_id @api.model - def _get_default_mail_partners(self, mail_template, lang, move): - partners = self.env['res.partner'].with_company(move.company_id) - if mail_template.email_to: - for mail_data in tools.email_split(mail_template.email_to): - partners |= partners.find_or_create(mail_data) - if mail_template.email_cc: - for mail_data in tools.email_split(mail_template.email_cc): - partners |= partners.find_or_create(mail_data) - if mail_template.partner_to: - partner_to = self._get_mail_default_field_value_from_template(mail_template, lang, move, 'partner_to') - partner_ids = [int(pid) for pid in partner_to.split(',') if pid] - partners |= self.env['res.partner'].sudo().browse(partner_ids).exists() - return partners + def _get_invoice_extra_attachments_data(self, move): + return [ + { + 'id': attachment.id, + 'name': attachment.name, + 'mimetype': attachment.mimetype, + 'placeholder': False, + } + for attachment in self._get_invoice_extra_attachments(move) + ] @api.model - def _get_default_mail_body(self, mail_template, lang, move): - return self._get_mail_default_field_value_from_template(mail_template, lang, move, 'body_html', options={'post_process': True}) - - @api.model - def _get_default_mail_subject(self, mail_template, lang, move): - return self._get_mail_default_field_value_from_template(mail_template, lang, move, 'subject') - - @api.model - def _get_default_email_from(self, mail_template, lang, move): - return self._get_mail_default_field_value_from_template(mail_template, lang, move, 'email_from') + def _get_mail_template_attachments_data(self, mail_template): + """ Returns all the placeholder data and mail template data + """ + return [ + { + 'id': attachment.id, + 'name': attachment.name, + 'mimetype': attachment.mimetype, + 'placeholder': False, + 'mail_template_id': mail_template.id, + } + for attachment in mail_template.attachment_ids + ] # ------------------------------------------------------------------------- # COMPUTE METHODS @@ -149,10 +170,10 @@ class AccountMoveSend(models.Model): @api.depends('move_ids') def _compute_mode(self): for wizard in self: - if wizard.move_ids: - wizard.mode = wizard._get_default_mode_from_moves(wizard.move_ids) - else: - wizard.mode = 'done' + mode = 'done' + if wizard.move_ids and all(x.is_invoice(include_receipts=True) for x in wizard.move_ids): + mode = 'invoice_single' if len(wizard.move_ids) == 1 else 'invoice_multi' + wizard.mode = mode @api.depends('move_ids') def _compute_enable_download(self): @@ -162,7 +183,7 @@ class AccountMoveSend(models.Model): @api.depends('enable_download') def _compute_checkbox_download(self): for wizard in self: - wizard.checkbox_download = wizard.company_id.invoice_is_download + wizard.checkbox_download = wizard.enable_download and wizard.company_id.invoice_is_download @api.depends('move_ids') def _compute_send_mail_extra_fields(self): @@ -196,16 +217,31 @@ class AccountMoveSend(models.Model): @api.depends('mail_template_id') def _compute_mail_lang(self): for wizard in self: - if wizard.mode == 'invoice_single': - wizard.mail_lang = wizard._get_default_lang(wizard.mail_template_id, wizard.move_ids) + if wizard.mail_template_id and wizard.mode == 'invoice_single': + move = wizard.move_ids + wizard.mail_lang = wizard.mail_template_id._render_lang([move.id]).get(move.id) else: - wizard.mail_lang = False + wizard.mail_lang = get_lang(self.env).code @api.depends('mail_template_id', 'mail_lang') def _compute_mail_partner_ids(self): for wizard in self: if wizard.mail_template_id and wizard.mode == 'invoice_single': - wizard.mail_partner_ids = wizard._get_default_mail_partners(wizard.mail_template_id, wizard.mail_lang, wizard.move_ids) + move = wizard.move_ids + partners = self.env['res.partner'].with_company(move.company_id) + mail_template = wizard.mail_template_id + if mail_template.email_to: + for mail_data in tools.email_split(mail_template.email_to): + partners |= partners.find_or_create(mail_data) + if mail_template.email_cc: + for mail_data in tools.email_split(mail_template.email_cc): + partners |= partners.find_or_create(mail_data) + if mail_template.partner_to: + partner_to = wizard._get_mail_default_field_value_from_template(mail_template, wizard.mail_lang, move, + 'partner_to') + partner_ids = [int(pid) for pid in partner_to.split(',') if pid] + partners |= self.env['res.partner'].sudo().browse(partner_ids).exists() + wizard.mail_partner_ids = partners else: wizard.mail_partner_ids = [] @@ -213,7 +249,12 @@ class AccountMoveSend(models.Model): def _compute_mail_subject(self): for wizard in self: if wizard.mail_template_id and wizard.mode == 'invoice_single': - wizard.mail_subject = wizard._get_default_mail_subject(wizard.mail_template_id, wizard.mail_lang, wizard.move_ids) + wizard.mail_subject = wizard._get_mail_default_field_value_from_template( + wizard.mail_template_id, + wizard.mail_lang, + wizard.move_ids, + 'subject', + ) else: wizard.mail_subject = None @@ -221,112 +262,147 @@ class AccountMoveSend(models.Model): def _compute_mail_body(self): for wizard in self: if wizard.mail_template_id and wizard.mode == 'invoice_single': - wizard.mail_body = wizard._get_default_mail_body(wizard.mail_template_id, wizard.mail_lang, wizard.move_ids) + wizard.mail_body = wizard._get_mail_default_field_value_from_template( + wizard.mail_template_id, + wizard.mail_lang, + wizard.move_ids, + 'body_html', + options={'post_process': True}, + ) else: wizard.mail_body = None @api.depends('mail_template_id', 'mail_lang') def _compute_mail_attachments_widget(self): for wizard in self: - if wizard.mail_attachments_widget: # When checkboxes are ticked in UI - wizard.mail_attachments_widget = wizard._get_placeholder_mail_attachments_data(wizard.move_ids)\ - + wizard._get_selected_attachments_data() - elif wizard.mode == 'invoice_single': - wizard.mail_attachments_widget = wizard\ - ._get_default_mail_attachments_data(wizard.mail_template_id, wizard.move_ids) + if wizard.mode == 'invoice_single': + manual_attachments_data = [x for x in wizard.mail_attachments_widget or [] if x.get('manual')] + wizard.mail_attachments_widget = \ + wizard._get_placeholder_mail_attachments_data(wizard.move_ids) \ + + wizard._get_invoice_extra_attachments_data(wizard.move_ids) \ + + wizard._get_mail_template_attachments_data(wizard.mail_template_id) \ + + manual_attachments_data else: wizard.mail_attachments_widget = [] - # ------------------------------------------------------------------------- - # ATTACHMENTS - # ------------------------------------------------------------------------- - - @api.model - def _get_linked_attachments(self, move): - """ Returns all the attachments ids linked to the move to be sent in the email. - Should be extended to add attachments based on the checkboxes. - """ - return move.invoice_pdf_report_id - - def _get_mail_attachments_ids(self, mail_template, move): - """ Returns all the attachments ids linked to the move to be sent in the email - AND the attachments selected by the user or the one from the template (invoice multi) - """ - self.ensure_one() - if self.mode == 'invoice_single': - return self._get_selected_attachments_ids() + self._get_linked_attachments(move).ids - else: - return mail_template.attachment_ids.ids + self._get_linked_attachments(move).ids - - def _get_selected_attachments_ids(self): - """ Returns all the attachments ids selected by the user (invoice single) - """ - return [vals['id'] for vals in self._get_selected_attachments_data()] - - def _get_selected_attachments_data(self): - """ Returns all the attachments data selected by the user (invoice single) - """ - return list(filter(lambda x: not x.get('placeholder'), self.mail_attachments_widget)) - - def _get_placeholder_mail_attachments_data(self, move): - """ Returns all the placeholder data. - Should be extended to add placeholder based on the checkboxes. - - :returns: A list of dictionary for each placeholder. - * id: str: The (fake) id of the attachment, this is needed in rendering in t-key. - * name: str: The name of the attachment. - * mimetype: str: The mimetype of the attachment. - * placeholder bool: should be true to prevent download / deletion - """ - self.ensure_one() - filename = self._get_invoice_pdf_report_filename(move) - return [{ - 'id': f'placeholder_{filename}', - 'name': filename, - 'mimetype': 'application/pdf', - 'placeholder': True, - }] - - def _get_default_mail_attachments_data(self, mail_template, move): - """ Returns all the placeholder data and mail template data - """ - results = [] - - for attachment in mail_template.attachment_ids: - results.append({ - 'id': attachment.id, - 'name': attachment.name, - 'mimetype': attachment.mimetype, - 'placeholder': False, - }) - - return self._get_placeholder_mail_attachments_data(move) + results - # ------------------------------------------------------------------------- # BUSINESS ACTIONS # ------------------------------------------------------------------------- - @api.model - def _get_invoice_pdf_report_filename(self, move): - return f"{move.name.replace('/', '_')}.pdf" + def _need_invoice_document(self, invoice): + """ Determine if we need to generate the documents for the invoice passed as parameter. - @api.model - def _need_document(self, move): - return not move.invoice_pdf_report_id and move.state == 'posted' + :param invoice: An account.move record. + :return: True if the PDF / electronic documents must be generated, False otherwise. + """ + self.ensure_one() + return self.mode == 'invoice_single' and not invoice.invoice_pdf_report_id and invoice.state == 'posted' - @api.model - def _download(self, move): - """ Download the PDF. """ - return { - 'type': 'ir.actions.act_url', - 'url': f'/web/content/{move.invoice_pdf_report_id.id}?download=true', - 'close': True, # close the wizard + def _hook_invoice_document_before_pdf_report_render(self, invoice, invoice_data): + """ Hook allowing to add some extra data for the invoice passed as parameter before the rendering of the pdf + report. + + :param invoice: An account.move record. + :param invoice_data: The collected data for the invoice so far. + """ + self.ensure_one() + + def _prepare_invoice_pdf_report(self, invoice, invoice_data): + """ Prepare the pdf report for the invoice passed as parameter. + + :param invoice: An account.move record. + :param invoice_data: The collected data for the invoice so far. + """ + self.ensure_one() + + if invoice.invoice_pdf_report_id: + return + + content, _report_format = self.env['ir.actions.report']._render('account.account_invoices', invoice.ids) + + invoice_data['pdf_attachment_values'] = { + 'raw': content, + 'name': invoice._get_invoice_pdf_report_filename(), + 'mimetype': 'application/pdf', + 'res_model': invoice._name, + 'res_id': invoice.id, + 'res_field': 'invoice_pdf_report_file', # Binary field } - @api.model - def _send_mail(self, mail_template, move, **kwargs): - """ Send the journal entries passed as parameter by mail. """ + def _prepare_invoice_proforma_pdf_report(self, invoice, invoice_data): + """ Prepare the proforma pdf report for the invoice passed as parameter. + + :param invoice: An account.move record. + :param invoice_data: The collected data for the invoice so far. + """ + self.ensure_one() + + content, _report_format = self.env['ir.actions.report']._render('account.account_invoices', invoice.ids, data={'proforma': True}) + + invoice_data['proforma_pdf_attachment_values'] = { + 'raw': content, + 'name': invoice._get_invoice_proforma_pdf_report_filename(), + 'mimetype': 'application/pdf', + 'res_model': invoice._name, + 'res_id': invoice.id, + } + + def _hook_invoice_document_after_pdf_report_render(self, invoice, invoice_data): + """ Hook allowing to add some extra data for the invoice passed as parameter after the rendering of the + (proforma) pdf report. + + :param invoice: An account.move record. + :param invoice_data: The collected data for the invoice so far. + """ + self.ensure_one() + + def _link_invoice_documents(self, invoice, invoice_data): + """ Create the attachments containing the pdf/electronic documents for the invoice passed as parameter. + + :param invoice: An account.move record. + :param invoice_data: The collected data for the invoice so far. + """ + # create an attachment that will become 'invoice_pdf_report_file' + # note: Binary is used for security reason + invoice.message_main_attachment_id = self.env['ir.attachment'].create(invoice_data['pdf_attachment_values']) + invoice.invalidate_recordset(fnames=['invoice_pdf_report_id', 'invoice_pdf_report_file']) + self.env.add_to_compute(invoice._fields['is_move_sent'], invoice) + + def _hook_if_errors(self, moves_data, from_cron=False, allow_fallback_pdf=False): + """ Process errors found so far when generating the documents. + + :param from_cron: Flag indicating if the method is called from a cron. In that case, we avoid raising any + error. + :param allow_fallback_pdf: In case of error when generating the documents for invoices, generate a + proforma PDF report instead. + """ + allow_raising = not from_cron and not allow_fallback_pdf + for move, move_data in moves_data.items(): + error = move_data['error'] + if allow_raising: + raise UserError(error) + + move.with_context(no_new_invoice=True).message_post(body=error) + + def _hook_if_success(self, moves_data, from_cron=False, allow_fallback_pdf=False): + """ Process successful documents. + + :param from_cron: Flag indicating if the method is called from a cron. In that case, we avoid raising any + error. + :param allow_fallback_pdf: In case of error when generating the documents for invoices, generate a + proforma PDF report instead. + """ + send_mail = self.enable_send_mail and self.checkbox_send_mail + + # Send mail. + if send_mail: + self._send_mails(moves_data) + + def _send_mail(self, move, **kwargs): + """ Send the journal entry passed as parameter by mail. """ + self.ensure_one() partner_ids = kwargs.get('partner_ids', []) + mail_template = self.mail_template_id move\ .with_context( @@ -345,135 +421,158 @@ class AccountMoveSend(models.Model): }, ) - def _send_mails(self, moves): + def _get_mail_params(self, move): + self.ensure_one() + + if self.mode != 'invoice_single': + return + + # We must ensure the newly created PDF are added. At this point, the PDF has been generated but not added + # to 'mail_attachments_widget'. + attachment_ids = list(set([ + x['id'] + for x in self.mail_attachments_widget + self._get_invoice_extra_attachments_data(self.move_ids) + if not x.get('placeholder') + ])) + + return { + 'body': self.mail_body, + 'subject': self.mail_subject, + 'partner_ids': self.mail_partner_ids.ids, + 'attachment_ids': attachment_ids, + } + + def _send_mails(self, moves_data): + self.ensure_one() subtype = self.env.ref('mail.mt_comment') mail_template = self.mail_template_id - for move in moves: - attachment_ids = self._get_mail_attachments_ids(mail_template, move) - mail_lang = self.mail_lang or self._get_default_lang(mail_template, move) - partners = self.mail_partner_ids or self._get_default_mail_partners(mail_template, mail_lang, move) - body = self.mail_body or self._get_default_mail_body(mail_template, mail_lang, move) - subject = self.mail_subject or self._get_default_mail_subject(mail_template, mail_lang, move) - email_from = self._get_default_email_from(mail_template, mail_lang, move) + for move, move_data in moves_data.items(): + form = move_data['_form'] + mail_params = form._get_mail_params(move) + if not mail_params: + continue + + if move_data.get('proforma_pdf_attachment'): + mail_params['attachment_ids'].append(move_data['proforma_pdf_attachment'].id) + + mail_lang = form.mail_lang + email_from = form._get_mail_default_field_value_from_template(mail_template, mail_lang, move, 'email_from') model_description = move.with_context(lang=mail_lang).type_name - self._send_mail( - mail_template, + form._send_mail( move, - body=body, - subject=subject, - email_from=email_from, subtype_id=subtype.id, - partner_ids=partners.ids, - attachment_ids=attachment_ids, model_description=model_description, + email_from=email_from, + **mail_params, ) - def _generate_documents_success_hook(self, moves, from_cron): - send_mail = self.enable_send_mail and self.checkbox_send_mail - - # Send mail. - if send_mail: - self._send_mails(moves) - - return moves - - def _generate_documents_failed_hook(self, errors, from_cron): - for move, error in errors: - if from_cron: - move\ - .with_context(no_new_invoice=True)\ - .message_post(body=error) - else: - raise UserError(error) - - def _link_document(self, invoice, prepared_data): - """ Link prepared_data to the record - """ - # create an attachment that will become 'invoice_pdf_report_file' - # note: Binary is used for security reason - self.env['ir.attachment'].create(prepared_data['pdf_attachment_values']) - invoice.invalidate_model(fnames=['invoice_pdf_report_id', 'invoice_pdf_report_file']) - self.env.add_to_compute(invoice._fields['is_move_sent'], invoice) - - def _postprocess_document(self, invoice, prepared_data): - """ Postprocess prepared_data before it gets linked to the record - """ + def _call_web_service(self, invoices_data): + # TO OVERRIDE self.ensure_one() - def _render_document(self, invoice, prepared_data): - """ Extend prepared_data with the rendered documents + def _generate_invoice_documents(self, invoices_data, allow_fallback_pdf=False): + """ Generate the invoice PDF and electronic documents. + + :param allow_fallback_pdf: In case of error when generating the documents for invoices, generate a + proforma PDF report instead. + :param invoices_data: The collected data for invoices so far. """ - self.ensure_one() + for invoice, invoice_data in invoices_data.items(): + form = invoice_data['_form'] + if form._need_invoice_document(invoice): + form._hook_invoice_document_before_pdf_report_render(invoice, invoice_data) + invoice_data['blocking_error'] = invoice_data.get('error') \ + and not (allow_fallback_pdf and invoice_data.get('error_but_continue')) + if invoice_data['blocking_error']: + continue - content, _report_format = self.env['ir.actions.report']._render('account.account_invoices', invoice.ids) + invoices_data_web_service = { + invoice: invoice_data + for invoice, invoice_data in invoices_data.items() + if not invoice_data.get('error') + } + if invoices_data_web_service: + self._call_web_service(invoices_data_web_service) - prepared_data['pdf_attachment_values'] = { - 'raw': content, - 'name': self._get_invoice_pdf_report_filename(invoice), - 'mimetype': 'application/pdf', - 'res_model': invoice._name, - 'res_id': invoice.id, - 'res_field': 'invoice_pdf_report_file', # Binary field + invoices_data_pdf = { + invoice: invoice_data + for invoice, invoice_data in invoices_data.items() + if not invoice_data.get('error') or not invoice_data.get('blocking_error', True) + } + for invoice, invoice_data in invoices_data_pdf.items(): + form = invoice_data['_form'] + if form._need_invoice_document(invoice): + form._prepare_invoice_pdf_report(invoice, invoice_data) + form._hook_invoice_document_after_pdf_report_render(invoice, invoice_data) + form._link_invoice_documents(invoice, invoice_data) + + def _generate_invoice_fallback_documents(self, invoices_data): + """ Generate the invoice PDF and electronic documents. + + :param invoices_data: The collected data for invoices so far. + """ + for invoice, invoice_data in invoices_data.items(): + if self._need_invoice_document(invoice) and invoice_data.get('error'): + invoice_data.pop('error') + self._prepare_invoice_proforma_pdf_report(invoice, invoice_data) + self._hook_invoice_document_after_pdf_report_render(invoice, invoice_data) + invoice_data['proforma_pdf_attachment'] = self.env['ir.attachment']\ + .create(invoice_data.pop('proforma_pdf_attachment_values')) + + @api.model + def _download(self, attachment_id): + """ Download the PDF. """ + return { + 'type': 'ir.actions.act_url', + 'url': f'/web/content/{attachment_id}?download=true', + 'close': True, # close the wizard } - def _prepare_document(self, invoice): - """ To be overridden by modules adding support for different action in send&print. - :return: 'prepared_data' - { 'error' : str - if any, will be raised - 'xxx_values': {} - prefered naming for object to be linked as attachments - 'xxx_options': {} - prefered naming for object to be used later on } + def action_send_and_print(self, from_cron=False, allow_fallback_pdf=False): + """ Create the documents and send them to the end customers. + + :param from_cron: Flag indicating if the method is called from a cron. In that case, we avoid raising any + error. + :param allow_fallback_pdf: In case of error when generating the documents for invoices, generate a + proforma PDF report instead. """ self.ensure_one() - return {} - - def _call_web_service(self, prepared_data_list): - # TO OVERRIDE - return - - def _generate_documents(self, moves, from_cron): - """ Main entry point to generate the invoice pdf and the related attachments. - """ - prepared_data_list = [] - success, errors = self.env['account.move'], [] - for move in moves: - try: - if self._need_document(move): - prepared_data = self._prepare_document(move) - if prepared_data.get('error'): - raise UserError(prepared_data['error']) - else: - prepared_data_list.append((move, prepared_data)) - except UserError as e: - errors.append((move, str(e))) - - try: - self._call_web_service(prepared_data_list) - except UserError as e: - for move, _prepared_data in prepared_data_list: - errors.append((move, str(e))) - return success, errors - - for move, prepared_data in prepared_data_list: - self._render_document(move, prepared_data) - self._postprocess_document(move, prepared_data) - self._link_document(move, prepared_data) - success |= move - - return success, errors - - def action_send_and_print(self, from_cron=False): - self.ensure_one() download = self.enable_download and self.checkbox_download - generate_documents = self.mode == 'invoice_single' or from_cron + generate_invoice_documents = self.mode == 'invoice_single' or (self.mode == 'invoice_multi' and from_cron) + moves_data = {move: {} for move in self.move_ids} - if generate_documents: - success_moves, errors = self._generate_documents(self.move_ids, from_cron) + if len(moves_data) == 1: + moves_data[self.move_ids]['_form'] = self + else: + for move in self.move_ids: + moves_data[move]['_form'] = self.new(self._get_available_field_values_in_multi(move)) - self._generate_documents_success_hook(success_moves, from_cron) - self._generate_documents_failed_hook(errors, from_cron) + if generate_invoice_documents: + # Generate all invoice documents. + self._generate_invoice_documents(moves_data, allow_fallback_pdf=allow_fallback_pdf) + + # Manage errors. + errors = {move: move_data for move, move_data in moves_data.items() if move_data.get('error')} + if errors: + self._hook_if_errors(errors, from_cron=from_cron, allow_fallback_pdf=allow_fallback_pdf) + + # Cleanup the error if we don't want to block the regular pdf generation. + for move_data in errors.values(): + if move_data.get('pdf_attachment_values'): + move_data.pop('error') + + # Fallback in case of error. + errors = {move: move_data for move, move_data in moves_data.items() if move_data.get('error')} + if allow_fallback_pdf and errors: + self._generate_invoice_fallback_documents(errors) + + # Send mail. + success = {move: move_data for move, move_data in moves_data.items() if not move_data.get('error')} + if success: + self._hook_if_success(success, from_cron=from_cron, allow_fallback_pdf=allow_fallback_pdf) self.mode = 'done' @@ -481,7 +580,11 @@ class AccountMoveSend(models.Model): self.env.ref('account.ir_cron_account_move_send')._trigger() if download: - return self._download(self.move_ids) + attachment = self.move_ids.invoice_pdf_report_id + if not attachment: + attachment = list(moves_data.values())[0].get('proforma_pdf_attachment') + if attachment: + return self._download(attachment.id) return {'type': 'ir.actions.act_window_close'} diff --git a/addons/account_edi_ubl_cii/models/account_move_send.py b/addons/account_edi_ubl_cii/models/account_move_send.py index 0bb050d2e42..4c20056a663 100644 --- a/addons/account_edi_ubl_cii/models/account_move_send.py +++ b/addons/account_edi_ubl_cii/models/account_move_send.py @@ -22,7 +22,16 @@ class AccountMoveSend(models.Model): @api.model def _get_default_enable_ubl_cii_xml(self, move): - return not move.invoice_pdf_report_id and move.is_sale_document() and move.partner_id.ubl_cii_format + return not move.invoice_pdf_report_id \ + and not move.ubl_cii_xml_id \ + and move.is_sale_document() \ + and bool(move.partner_id.ubl_cii_format) + + def _get_available_field_values_in_multi(self, move): + # EXTENDS 'account' + values = super()._get_available_field_values_in_multi(move) + values['checkbox_ubl_cii_xml'] = self.checkbox_ubl_cii_xml + return values # ------------------------------------------------------------------------- # COMPUTE METHODS @@ -31,18 +40,18 @@ class AccountMoveSend(models.Model): @api.depends('move_ids') def _compute_checkbox_ubl_cii_label(self): for wizard in self: - code_to_label = dict(wizard.move_ids.partner_id._fields['ubl_cii_format'].selection) - codes = wizard.move_ids.partner_id.mapped('ubl_cii_format') - if any(codes): - wizard.checkbox_ubl_cii_label = ", ".join(code_to_label[c] for c in set(codes) if c) - else: - wizard.checkbox_ubl_cii_label = False + wizard.checkbox_ubl_cii_label = False + if wizard.mode in ('invoice_single', 'invoice_multi'): + code_to_label = dict(wizard.move_ids.partner_id._fields['ubl_cii_format'].selection) + codes = wizard.move_ids.partner_id.mapped('ubl_cii_format') + if any(codes): + wizard.checkbox_ubl_cii_label = ", ".join(code_to_label[c] for c in set(codes) if c) def _compute_send_mail_extra_fields(self): # EXTENDS 'account' super()._compute_send_mail_extra_fields() for wizard in self: - wizard.enable_ubl_cii_xml = any(self._get_default_enable_ubl_cii_xml(m) for m in wizard.move_ids) + wizard.enable_ubl_cii_xml = any(wizard._get_default_enable_ubl_cii_xml(m) for m in wizard.move_ids) @api.depends('checkbox_ubl_cii_xml') def _compute_mail_attachments_widget(self): @@ -52,16 +61,16 @@ class AccountMoveSend(models.Model): @api.depends('enable_ubl_cii_xml') def _compute_checkbox_ubl_cii_xml(self): for wizard in self: - wizard.checkbox_ubl_cii_xml = wizard.company_id.invoice_is_ubl_cii + wizard.checkbox_ubl_cii_xml = wizard.enable_ubl_cii_xml and wizard.company_id.invoice_is_ubl_cii # ------------------------------------------------------------------------- # ATTACHMENTS # ------------------------------------------------------------------------- @api.model - def _get_linked_attachments(self, move): + def _get_invoice_extra_attachments(self, move): # EXTENDS 'account' - return super()._get_linked_attachments(move) + move.ubl_cii_xml_id + return super()._get_invoice_extra_attachments(move) + move.ubl_cii_xml_id def _needs_ubl_cii_placeholder(self): return self.enable_ubl_cii_xml and self.checkbox_ubl_cii_xml @@ -70,7 +79,7 @@ class AccountMoveSend(models.Model): # EXTENDS 'account' results = super()._get_placeholder_mail_attachments_data(move) - if self._needs_ubl_cii_placeholder(): + if self.mode == 'invoice_single' and self._needs_ubl_cii_placeholder(): builder = move.partner_id._get_edi_builder() filename = builder._export_invoice_filename(move) results.append({ @@ -86,11 +95,11 @@ class AccountMoveSend(models.Model): # BUSINESS ACTIONS # ------------------------------------------------------------------------- - def _prepare_document(self, invoice): + def _hook_invoice_document_before_pdf_report_render(self, invoice, invoice_data): # EXTENDS 'account' - results = super()._prepare_document(invoice) + super()._hook_invoice_document_before_pdf_report_render(invoice, invoice_data) - if self.checkbox_ubl_cii_xml and self._get_default_enable_ubl_cii_xml(invoice): + if self.mode == 'invoice_single' and self.checkbox_ubl_cii_xml and self._get_default_enable_ubl_cii_xml(invoice): builder = invoice.partner_id._get_edi_builder() xml_content, errors = builder._export_invoice(invoice) @@ -98,41 +107,38 @@ class AccountMoveSend(models.Model): # Failed. if errors: - return { - 'error': "".join([ - _("Errors occured while creating the EDI document (format: %s):", builder._description), - "\n", - "

  • " + "
  • ".join(errors) + "
  • " if self.mode == 'invoice_multi' \ - else "\n".join(errors) - ]), + invoice_data['error'] = "".join([ + _("Errors occured while creating the EDI document (format: %s):", builder._description), + "\n", + "

  • " + "
  • ".join(errors) + "
  • " if self.mode == 'invoice_multi' \ + else "\n".join(errors) + ]) + invoice_data['error_but_continue'] = True + else: + invoice_data['ubl_cii_xml_attachment_values'] = { + 'name': filename, + 'raw': xml_content, + 'mimetype': 'application/xml', + 'res_model': invoice._name, + 'res_id': invoice.id, + 'res_field': 'ubl_cii_xml_file', # Binary field + } + invoice_data['ubl_cii_xml_options'] = { + 'ubl_cii_format': invoice.partner_id.ubl_cii_format, + 'builder': builder, } - results['ubl_cii_xml_attachment_values'] = { - 'name': filename, - 'raw': xml_content, - 'mimetype': 'application/xml', - 'res_model': invoice._name, - 'res_id': invoice.id, - 'res_field': 'ubl_cii_xml_file', # Binary field - } - results['ubl_cii_xml_options'] = { - 'ubl_cii_format': invoice.partner_id.ubl_cii_format, - 'builder': builder, - } - - return results - - def _postprocess_document(self, invoice, prepared_data): + def _hook_invoice_document_after_pdf_report_render(self, invoice, invoice_data): # EXTENDS 'account' - super()._postprocess_document(invoice, prepared_data) + super()._hook_invoice_document_after_pdf_report_render(invoice, invoice_data) # Add PDF to XML - if 'ubl_cii_xml_options' in prepared_data and prepared_data['ubl_cii_xml_options']['ubl_cii_format'] != 'facturx': - self._postprocess_invoice_ubl_xml(invoice, prepared_data) + if 'ubl_cii_xml_options' in invoice_data and invoice_data['ubl_cii_xml_options']['ubl_cii_format'] != 'facturx': + self._postprocess_invoice_ubl_xml(invoice, invoice_data) # Always silently generate a Factur-X and embed it inside the PDF (inter-portability) - if 'ubl_cii_xml_options' in prepared_data and prepared_data['ubl_cii_xml_options']['ubl_cii_format'] == 'facturx': - xml_facturx = prepared_data['ubl_cii_xml_attachment_values']['raw'] + if 'ubl_cii_xml_options' in invoice_data and invoice_data['ubl_cii_xml_options']['ubl_cii_format'] == 'facturx': + xml_facturx = invoice_data['ubl_cii_xml_attachment_values']['raw'] else: xml_facturx = self.env['account.edi.xml.cii']._export_invoice(invoice)[0] @@ -147,7 +153,7 @@ class AccountMoveSend(models.Model): return # Read pdf content. - reader_buffer = io.BytesIO(prepared_data['pdf_attachment_values']['raw']) + reader_buffer = io.BytesIO(invoice_data['pdf_attachment_values']['raw']) reader = OdooPdfFileReader(reader_buffer, strict=False) # Post-process. @@ -157,8 +163,8 @@ class AccountMoveSend(models.Model): writer.addAttachment('factur-x.xml', xml_facturx, subtype='text/xml') # PDF-A. - if 'ubl_cii_xml_options' in prepared_data \ - and prepared_data['ubl_cii_xml_options']['ubl_cii_format'] == 'facturx' \ + if 'ubl_cii_xml_options' in invoice_data \ + and invoice_data['ubl_cii_xml_options']['ubl_cii_format'] == 'facturx' \ and not writer.is_pdfa: try: writer.convert_to_pdfa() @@ -178,19 +184,19 @@ class AccountMoveSend(models.Model): # Replace the current content. writer_buffer = io.BytesIO() writer.write(writer_buffer) - prepared_data['pdf_attachment_values']['raw'] = writer_buffer.getvalue() + invoice_data['pdf_attachment_values']['raw'] = writer_buffer.getvalue() reader_buffer.close() writer_buffer.close() - def _postprocess_invoice_ubl_xml(self, invoice, prepared_data): + def _postprocess_invoice_ubl_xml(self, invoice, invoice_data): # Add PDF to XML - tree = etree.fromstring(prepared_data['ubl_cii_xml_attachment_values']['raw']) + tree = etree.fromstring(invoice_data['ubl_cii_xml_attachment_values']['raw']) anchor_elements = tree.xpath("//*[local-name()='AccountingSupplierParty']") if not anchor_elements: return - filename = prepared_data['pdf_attachment_values']['name'] - content = prepared_data['pdf_attachment_values']['raw'] + filename = invoice_data['pdf_attachment_values']['name'] + content = invoice_data['pdf_attachment_values']['raw'] to_inject = f''' \n" \ + invoice_data['ubl_cii_xml_attachment_values']['raw'] = b"\n" \ + etree.tostring(cleanup_xml_node(tree)) - def _link_document(self, invoice, prepared_data): + def _link_invoice_documents(self, invoice, invoice_data): # EXTENDS 'account' - super()._link_document(invoice, prepared_data) + super()._link_invoice_documents(invoice, invoice_data) - attachment_vals = prepared_data.get('ubl_cii_xml_attachment_values') + attachment_vals = invoice_data.get('ubl_cii_xml_attachment_values') if attachment_vals: self.env['ir.attachment'].create(attachment_vals) - invoice.invalidate_model(fnames=['ubl_cii_xml_id', 'ubl_cii_xml_file']) + invoice.invalidate_recordset(fnames=['ubl_cii_xml_id', 'ubl_cii_xml_file']) diff --git a/addons/account_peppol/wizard/account_move_send.py b/addons/account_peppol/wizard/account_move_send.py index 906ace33e01..01279e14a0c 100644 --- a/addons/account_peppol/wizard/account_move_send.py +++ b/addons/account_peppol/wizard/account_move_send.py @@ -17,10 +17,11 @@ class AccountMoveSend(models.Model): help='Send the invoice via PEPPOL', ) peppol_proxy_state = fields.Selection(related='company_id.account_peppol_proxy_state') + # technical field needed for computing a warning text about the peppol configuration peppol_warning = fields.Char( string="Warning", compute="_compute_peppol_warning", - ) # technical field needed for computing a warning text about the peppol configuration + ) # ------------------------------------------------------------------------- # COMPUTE METHODS @@ -62,7 +63,7 @@ class AccountMoveSend(models.Model): # BUSINESS ACTIONS # ------------------------------------------------------------------------- - def action_send_and_print(self, from_cron=False): + def action_send_and_print(self, from_cron=False, allow_fallback_pdf=False): # Extends the wizard to mark the move to be sent via PEPPOL first self.ensure_one() @@ -71,72 +72,60 @@ class AccountMoveSend(models.Model): if not move.peppol_move_state: move.peppol_move_state = 'to_send' - return super().action_send_and_print(from_cron) + return super().action_send_and_print(from_cron=from_cron, allow_fallback_pdf=allow_fallback_pdf) - def _call_web_service(self, prepared_data_list): + def _call_web_service(self, invoices_data): # Overrides 'account' + super()._call_web_service(invoices_data) + if not self.checkbox_send_peppol: - return super()._call_web_service(prepared_data_list) + return params = {'documents': []} - all_moves = self.env['account.move'] - if not prepared_data_list: - # if a user tries to resend an invoice that has a peppol error state, - # the prepared_data_list is going to be empty, as the xml has been aready generated - error_moves = self.move_ids.filtered(lambda m: m.peppol_move_state == 'error') - if not error_moves: - return - - for error_move in error_moves: - receiver_identification = f"{error_move.partner_id.peppol_eas}:{error_move.partner_id.peppol_endpoint}" - if not error_move.ubl_cii_xml_id: - # this happens when a user manually deletes the xml file - # but doesn't delete the pdf file - # they should delete both if they want to regenerate attachments - raise UserError(_('The following invoice does not have an XML attachment: %s', error_move.name)) - - params['documents'].append({ - 'filename': error_move.name, - 'receiver': receiver_identification, - 'ubl': b64encode(error_move.ubl_cii_xml_id.raw).decode(), - }) - all_moves |= error_move - - for move, prepared_data in prepared_data_list: - if not prepared_data: - move.peppol_move_state = False - raise UserError(_('Please select the UBL format')) - - if move.peppol_move_state not in ('to_send', 'error'): + invoices_data_peppol = {} + for invoice, invoice_data in invoices_data.items(): + if invoice.peppol_move_state not in ('to_send', 'error'): continue - xml_file = prepared_data['ubl_cii_xml_attachment_values']['raw'] - filename = prepared_data['ubl_cii_xml_attachment_values']['name'] + if invoice_data.get('ubl_cii_xml_attachment_values'): + xml_file = invoice_data['ubl_cii_xml_attachment_values']['raw'] + filename = invoice_data['ubl_cii_xml_attachment_values']['name'] + elif invoice.ubl_cii_xml_id: + xml_file = invoice.ubl_cii_xml_id.raw + filename = invoice.ubl_cii_xml_id.name + else: + continue - if not move.partner_id.peppol_eas or not move.partner_id.peppol_endpoint: + if not invoice.partner_id.peppol_eas or not invoice.partner_id.peppol_endpoint: # should never happen but in case it does, we need to handle it - move.peppol_move_state = 'error' - move._message_log(body=_('The partner is missing Peppol EAS and/or Endpoint identifier.')) + invoice.peppol_move_state = 'error' + invoice_data['error'] = _('The partner is missing Peppol EAS and/or Endpoint identifier.') continue - if not move.partner_id.account_peppol_is_endpoint_valid: - move.peppol_move_state = 'error' - move._message_log(body=_('Please verify partner configuration in partner settings.')) + if not invoice.partner_id.account_peppol_is_endpoint_valid: + invoice.peppol_move_state = 'error' + invoice_data['error'] = _('Please verify partner configuration in partner settings.') continue - receiver_identification = f"{move.partner_id.peppol_eas}:{move.partner_id.peppol_endpoint}" + receiver_identification = f"{invoice.partner_id.peppol_eas}:{invoice.partner_id.peppol_endpoint}" params['documents'].append({ 'filename': filename, 'receiver': receiver_identification, 'ubl': b64encode(xml_file).decode(), }) - all_moves |= move + invoices_data_peppol[invoice] = invoice_data - edi_user = self.env['account_edi_proxy_client.user'].search([ - ('company_id', '=', self.company_id.id), - ('proxy_type', '=', 'peppol'), - ]) + if not params['documents']: + return + + edi_user = self.env['account_edi_proxy_client.user'].search( + [ + ('company_id', '=', self.company_id.id), + ('proxy_type', '=', 'peppol'), + ], + limit=1, + ) try: response = edi_user._make_request( @@ -145,18 +134,20 @@ class AccountMoveSend(models.Model): ) if response.get('error'): # at the moment the only error that can happen here is ParticipantNotReady error - all_moves._message_log_batch(bodies=dict((move.id, response['error']['message']) for move in all_moves)) - all_moves.peppol_move_state = 'error' - raise UserError(response['error']['message']) + for invoice, invoice_data in invoices_data_peppol.items(): + invoice.peppol_move_state = 'error' + invoice_data['error'] = response['error']['message'] except AccountEdiProxyError as e: - all_moves._message_log_batch(bodies=dict((move.id, e.message) for move in all_moves)) - all_moves.peppol_move_state = 'error' - raise UserError(e.message) + for invoice, invoice_data in invoices_data_peppol.items(): + invoice.peppol_move_state = 'error' + invoice_data['error'] = e.message else: # the response only contains message uuids, # so we have to rely on the order to connect peppol messages to account.move - for i, move in enumerate(all_moves): - move.peppol_message_uuid = response['messages'][i]['message_uuid'] - move.peppol_move_state = 'processing' + invoices = self.env['account.move'] + for i, (invoice, invoice_data) in enumerate(invoices_data_peppol.items()): + invoice.peppol_message_uuid = response['messages'][i]['message_uuid'] + invoice.peppol_move_state = 'processing' + invoices |= invoice log_message = _('The document has been sent to the Peppol Access Point for processing') - all_moves._message_log_batch(bodies=dict((move.id, log_message) for move in all_moves)) + invoices._message_log_batch(bodies=dict((invoice.id, log_message) for invoice in invoices_data_peppol)) diff --git a/addons/l10n_account_edi_ubl_cii_tests/tests/common.py b/addons/l10n_account_edi_ubl_cii_tests/tests/common.py index e9a93ef1e14..1109706c9d3 100644 --- a/addons/l10n_account_edi_ubl_cii_tests/tests/common.py +++ b/addons/l10n_account_edi_ubl_cii_tests/tests/common.py @@ -184,7 +184,7 @@ class TestUBLCommon(AccountTestInvoicingCommon): # ------------------------------------------------------------------------- @freeze_time('2017-01-01') - def _generate_move(self, seller, buyer, **invoice_kwargs): + def _generate_move(self, seller, buyer, send=True, **invoice_kwargs): """ Create and post an account.move. """ @@ -221,7 +221,8 @@ class TestUBLCommon(AccountTestInvoicingCommon): }) account_move.action_post() - account_move._generate_pdf_and_send_invoice(self.move_template) + if send: + account_move._generate_pdf_and_send_invoice(self.move_template) return account_move def _assert_invoice_attachment(self, attachment, xpaths, expected_file): diff --git a/addons/l10n_account_edi_ubl_cii_tests/tests/test_xml_ubl_be.py b/addons/l10n_account_edi_ubl_cii_tests/tests/test_xml_ubl_be.py index b4323d3b028..c47643e6598 100644 --- a/addons/l10n_account_edi_ubl_cii_tests/tests/test_xml_ubl_be.py +++ b/addons/l10n_account_edi_ubl_cii_tests/tests/test_xml_ubl_be.py @@ -1,10 +1,12 @@ # -*- coding: utf-8 -*- +from odoo import Command from odoo.addons.l10n_account_edi_ubl_cii_tests.tests.common import TestUBLCommon +from odoo.addons.account.tests.test_account_move_send import TestAccountMoveSendCommon from odoo.tests import tagged import base64 @tagged('post_install_l10n', 'post_install', '-at_install') -class TestUBLBE(TestUBLCommon): +class TestUBLBE(TestUBLCommon, TestAccountMoveSendCommon): @classmethod def setUpClass(cls, chart_template_ref="be"): @@ -67,6 +69,8 @@ class TestUBLBE(TestUBLCommon): 'country_id': cls.env.ref('base.be').id, }) + cls.env.company.invoice_is_ubl_cii = True + @classmethod def setup_company_data(cls, company_name, chart_template): # OVERRIDE @@ -495,3 +499,84 @@ class TestUBLBE(TestUBLCommon): list_line_subtotals=[99], currency_id=self.currency_data['currency'].id, list_line_price_unit=[99], list_line_discount=[0], list_line_taxes=[tax_21+self.recupel], move_type='out_invoice', ) + + #################################################### + # Test Send & print + #################################################### + + def test_send_and_print(self): + invoice = self._generate_move( + self.partner_1, + self.partner_2, + send=False, + move_type='out_invoice', + invoice_line_ids=[ + { + 'product_id': self.product_a.id, + 'tax_ids': [Command.set(self.tax_21.ids)], + }, + ], + ) + wizard = self.create_send_and_print(invoice) + wizard._compute_send_mail_extra_fields() + self.assertRecordValues(wizard, [{ + 'mode': 'invoice_single', + 'checkbox_ubl_cii_label': "Peppol BIS Billing 3.0", + 'enable_ubl_cii_xml': True, + 'checkbox_ubl_cii_xml': True, + }]) + self._assert_mail_attachments_widget(wizard, [ + { + 'mimetype': 'application/pdf', + 'name': 'INV_2017_00001.pdf', + 'placeholder': True, + }, + { + 'mimetype': 'application/xml', + 'name': 'INV_2017_00001_ubl_bis3.xml', + 'placeholder': True, + }, + ]) + self.assertFalse(invoice.invoice_pdf_report_id) + self.assertFalse(invoice.ubl_cii_xml_id) + + # Send. + wizard.action_send_and_print() + self.assertTrue(invoice.invoice_pdf_report_id) + self.assertTrue(invoice.ubl_cii_xml_id) + invoice_attachments = self.env['ir.attachment'].search([ + ('res_model', '=', invoice._name), + ('res_id', '=', invoice.id), + ('res_field', 'in', ('invoice_pdf_report_file', 'ubl_cii_xml_file')), + ]) + self.assertEqual(len(invoice_attachments), 2) + + # Send again. + wizard = self.create_send_and_print(invoice) + self.assertRecordValues(wizard, [{ + 'mode': 'invoice_single', + 'checkbox_ubl_cii_label': 'Peppol BIS Billing 3.0', + 'enable_ubl_cii_xml': False, + 'checkbox_ubl_cii_xml': False, + }]) + self._assert_mail_attachments_widget(wizard, [ + { + 'id': invoice.invoice_pdf_report_id.id, + 'mimetype': 'application/pdf', + 'name': 'INV_2019_00001.pdf', + }, + { + 'id': invoice.ubl_cii_xml_id.id, + 'mimetype': 'application/xml', + 'name': 'INV_2019_00001_ubl_bis3.xml', + }, + ]) + wizard.action_send_and_print() + self.assertTrue(invoice.invoice_pdf_report_id) + self.assertTrue(invoice.ubl_cii_xml_id) + invoice_attachments = self.env['ir.attachment'].search([ + ('res_model', '=', invoice._name), + ('res_id', '=', invoice.id), + ('res_field', 'in', ('invoice_pdf_report_file', 'ubl_cii_xml_file')), + ]) + self.assertEqual(len(invoice_attachments), 2) diff --git a/addons/l10n_es_edi_facturae/models/account_move.py b/addons/l10n_es_edi_facturae/models/account_move.py index 7c96949d4b5..4d99f00bcd2 100644 --- a/addons/l10n_es_edi_facturae/models/account_move.py +++ b/addons/l10n_es_edi_facturae/models/account_move.py @@ -80,7 +80,11 @@ class AccountMove(models.Model): def _l10n_es_edi_facturae_get_default_enable(self): self.ensure_one() - return self.company_id.country_code == 'ES' and self.company_id.currency_id.name == 'EUR' + return not self.invoice_pdf_report_id \ + and self.l10n_es_edi_facturae_xml_id \ + and self.is_invoice(include_receipts=True) \ + and self.company_id.country_code == 'ES' \ + and self.company_id.currency_id.name == 'EUR' def _l10n_es_edi_facturae_get_filename(self): self.ensure_one() diff --git a/addons/l10n_es_edi_facturae/models/account_move_send.py b/addons/l10n_es_edi_facturae/models/account_move_send.py index a9466a658ec..d535d797d92 100644 --- a/addons/l10n_es_edi_facturae/models/account_move_send.py +++ b/addons/l10n_es_edi_facturae/models/account_move_send.py @@ -37,7 +37,7 @@ class AccountMoveSend(models.Model): # EXTENDS 'account' results = super()._get_placeholder_mail_attachments_data(move) - if self.l10n_es_edi_facturae_enable_xml and self.l10n_es_edi_facturae_checkbox_xml: + if self.mode == 'invoice_single' and self.l10n_es_edi_facturae_enable_xml and self.l10n_es_edi_facturae_checkbox_xml: filename = f'{move.name.replace("/", "_")}_facturae_signed.xml' results.append({ 'id': f'placeholder_{filename}', @@ -52,18 +52,19 @@ class AccountMoveSend(models.Model): # BUSINESS ACTIONS # ------------------------------------------------------------------------- - def _prepare_document(self, invoice): + def _hook_invoice_document_before_pdf_report_render(self, invoice, invoice_data): # EXTENDS 'account' - results = super()._prepare_document(invoice) + results = super()._hook_invoice_document_before_pdf_report_render(invoice, invoice_data) if self.l10n_es_edi_facturae_checkbox_xml and invoice._l10n_es_edi_facturae_get_default_enable(): try: xml_content = invoice._l10n_es_edi_facturae_render_facturae() except Exception as error: - return { - 'error': "".join((_("Errors occured while creating the EDI document (format: %s):", "Facturae"), - "\n", str(error))), - } + invoice_data['error'] = "".join(( + _("Errors occured while creating the EDI document (format: %s):", "Facturae"), + "\n", + str(error), + )) results['l10n_es_edi_facturae_attachment_values'] = { 'name': invoice._l10n_es_edi_facturae_get_filename(), @@ -76,11 +77,11 @@ class AccountMoveSend(models.Model): return results - def _link_document(self, invoice, prepared_data): + def _link_invoice_documents(self, invoice, invoice_data): # EXTENDS 'account' - super()._link_document(invoice, prepared_data) + super()._link_invoice_documents(invoice, invoice_data) - attachment_vals = prepared_data.get('l10n_es_edi_facturae_attachment_values') + attachment_vals = invoice_data.get('l10n_es_edi_facturae_attachment_values') if attachment_vals: self.env['ir.attachment'].create(attachment_vals) - invoice.invalidate_model(fnames=['l10n_es_edi_facturae_xml_id', 'l10n_es_edi_facturae_xml_file']) + invoice.invalidate_recordset(fnames=['l10n_es_edi_facturae_xml_id', 'l10n_es_edi_facturae_xml_file']) diff --git a/addons/snailmail_account/wizard/account_move_send.py b/addons/snailmail_account/wizard/account_move_send.py index f9dfdc83d40..eabec5accf5 100644 --- a/addons/snailmail_account/wizard/account_move_send.py +++ b/addons/snailmail_account/wizard/account_move_send.py @@ -15,6 +15,16 @@ class AccountMoveSend(models.Model): ) send_by_post_warning_message = fields.Text(compute='_compute_send_by_post_warning_message') + def _get_available_field_values_in_multi(self, move): + # EXTENDS 'account' + values = super()._get_available_field_values_in_multi(move) + values['checkbox_send_by_post'] = self.checkbox_send_by_post + return values + + # ------------------------------------------------------------------------- + # COMPUTE METHODS + # ------------------------------------------------------------------------- + @api.depends('mode') def _compute_enable_send_by_post(self): for wizard in self: @@ -54,18 +64,24 @@ class AccountMoveSend(models.Model): 'attachment_id': move.invoice_pdf_report_id.id, } - def _generate_documents_success_hook(self, moves, from_cron): + def _hook_if_success(self, moves_data, from_cron=False, allow_fallback_pdf=False): # EXTENDS 'account' - moves = super()._generate_documents_success_hook(moves, from_cron) + super()._hook_if_success(moves_data, from_cron=from_cron, allow_fallback_pdf=allow_fallback_pdf) send_by_post = self.enable_send_by_post and self.checkbox_send_by_post + if not send_by_post: + return - if send_by_post: - self.env['snailmail.letter']\ - .create([ - self._prepare_snailmail_letter_values(move) - for move in moves - ])\ - ._snailmail_print(immediate=False) + moves = self.env['account.move'] + for move in moves_data: + moves |= move + moves = moves.filtered('invoice_pdf_report_id') + if not moves: + return - return moves + self.env['snailmail.letter']\ + .create([ + self._prepare_snailmail_letter_values(move) + for move in moves + ])\ + ._snailmail_print(immediate=False)