From 4d2b04a843c27981e44d6f932d7cf7e6838b4b9d Mon Sep 17 00:00:00 2001 From: Valentin Chevalier Date: Mon, 26 Jul 2021 11:11:10 +0000 Subject: [PATCH] [IMP] sale: improve sale flows Rework and code cleanup of automatic invoice and auto-confirm order flows. task-2541188 closes odoo/odoo#72368 Signed-off-by: Antoine Vandevenne (anv) --- addons/sale/models/payment_transaction.py | 134 ++++++++++++---------- addons/sale/models/sale_order.py | 16 ++- addons/sale/tests/test_payment_flow.py | 61 ++++++++++ 3 files changed, 145 insertions(+), 66 deletions(-) diff --git a/addons/sale/models/payment_transaction.py b/addons/sale/models/payment_transaction.py index efaf2de2545..b770cb15c0f 100644 --- a/addons/sale/models/payment_transaction.py +++ b/addons/sale/models/payment_transaction.py @@ -1,14 +1,11 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import logging -import re from datetime import datetime from dateutil import relativedelta -from odoo import api, fields, models, _, SUPERUSER_ID -from odoo.tools import float_compare - +from odoo import _, api, Command, fields, models, SUPERUSER_ID +from odoo.tools import format_amount _logger = logging.getLogger(__name__) @@ -49,34 +46,46 @@ class PaymentTransaction(models.Model): sales_orders._send_order_confirmation_mail() def _check_amount_and_confirm_order(self): - self.ensure_one() - for order in self.sale_order_ids.filtered(lambda so: so.state in ('draft', 'sent')): - if order.currency_id.compare_amounts(self.amount, order.amount_total) == 0: - order.with_context(send_email=True).action_confirm() - else: - _logger.warning( - '<%s> transaction AMOUNT MISMATCH for order %s (ID %s): expected %r, got %r', - self.acquirer_id.provider,order.name, order.id, - order.amount_total, self.amount, - ) - order.message_post( - subject=_("Amount Mismatch (%s)", self.acquirer_id.provider), - body=_("The order was not confirmed despite response from the acquirer (%s): order total is %r but acquirer replied with %r.") % ( - self.acquirer_id.provider, - order.amount_total, - self.amount, - ) - ) + """ Confirm the sales order based on the amount of a transaction. + + Confirm the sales orders only if the transaction amount is equal to the total amount of the + sales orders. Neither partial payments nor grouped payments (paying multiple sales orders in + one transaction) are not supported. + + :return: The confirmed sales orders. + :rtype: a `sale.order` recordset + """ + confirmed_orders = self.env['sale.order'] + for tx in self: + # We only support the flow where exactly one quotation is linked to a transaction and + # vice versa. + if len(tx.sale_order_ids) == 1: + quotation = tx.sale_order_ids.filtered(lambda so: so.state in ('draft', 'sent')) + if quotation and len(quotation.transaction_ids) == 1: + # Check if the SO is fully paid + if quotation.currency_id.compare_amounts(tx.amount, quotation.amount_total) == 0: + quotation.with_context(send_email=True).action_confirm() + confirmed_orders |= quotation + else: + _logger.warning( + '<%(provider)s> transaction AMOUNT MISMATCH for order %(so_name)s ' + '(ID %(so_id)s): expected %(so_amount)s, got %(tx_amount)s', { + 'provider': tx.provider, + 'so_name': quotation.name, + 'so_id': quotation.id, + 'so_amount': format_amount( + quotation.env, quotation.amount_total, quotation.currency_id + ), + 'tx_amount': format_amount(tx.env, tx.amount, tx.currency_id), + }, + ) + return confirmed_orders def _set_authorized(self, state_message=None): """ Override of payment to confirm the quotations automatically. """ super()._set_authorized(state_message=state_message) - sales_orders = self.mapped('sale_order_ids').filtered(lambda so: so.state in ('draft', 'sent')) - for tx in self: - tx._check_amount_and_confirm_order() - - # send order confirmation mail - sales_orders._send_order_confirmation_mail() + confirmed_orders = self._check_amount_and_confirm_order() + confirmed_orders._send_order_confirmation_mail() def _log_message_on_linked_documents(self, message): """ Override of payment to log a message on the sales orders linked to the transaction. @@ -92,33 +101,37 @@ class PaymentTransaction(models.Model): def _reconcile_after_done(self): """ Override of payment to automatically confirm quotations and generate invoices. """ - sales_orders = self.mapped('sale_order_ids').filtered(lambda so: so.state in ('draft', 'sent')) - for tx in self: - tx._check_amount_and_confirm_order() - # send order confirmation mail - sales_orders._send_order_confirmation_mail() - # invoice the sale orders if needed - self._invoice_sale_orders() - res = super()._reconcile_after_done() - if self.env['ir.config_parameter'].sudo().get_param('sale.automatic_invoice') and any(so.state in ('sale', 'done') for so in self.sale_order_ids): - self.filtered(lambda t: t.sale_order_ids.filtered(lambda so: so.state in ('sale', 'done')))._send_invoice() - return res + confirmed_orders = self._check_amount_and_confirm_order() + confirmed_orders._send_order_confirmation_mail() + + # invoice the sale orders if needed and send it + if self.env['ir.config_parameter'].sudo().get_param('sale.automatic_invoice'): + # Invoice the sale orders in self instead of in confirmed_orders to create the invoice + # even if only a partial payment was made. + self._invoice_sale_orders() + self._send_invoice() + return super()._reconcile_after_done() def _send_invoice(self): - default_template = self.env['ir.config_parameter'].sudo().get_param('sale.default_invoice_email_template') - if not default_template: + template_id = self.env['ir.config_parameter'].sudo().get_param( + 'sale.default_invoice_email_template' + ) + if not template_id: return - for trans in self: - trans = trans.with_company(trans.acquirer_id.company_id).with_context( - company_id=trans.acquirer_id.company_id.id, + for tx in self: + tx = tx.with_company(tx.company_id).with_context( + company_id=tx.company_id.id, ) - invoice_to_send = trans.invoice_ids.filtered( + invoice_to_send = tx.invoice_ids.filtered( lambda i: not i.is_move_sent and i.state == 'posted' and i._is_ready_to_be_sent() ) invoice_to_send.is_move_sent = True # Mark invoice as sent for invoice in invoice_to_send.with_user(SUPERUSER_ID): - invoice.message_post_with_template(int(default_template), email_layout_xmlid="mail.mail_notification_paynow") + invoice.message_post_with_template( + int(template_id), + email_layout_xmlid='mail.mail_notification_paynow', + ) def _cron_send_invoice(self): """ @@ -142,19 +155,20 @@ class PaymentTransaction(models.Model): ])._send_invoice() def _invoice_sale_orders(self): - if self.env['ir.config_parameter'].sudo().get_param('sale.automatic_invoice'): - for trans in self.filtered(lambda t: t.sale_order_ids): - trans = trans.with_company(trans.acquirer_id.company_id)\ - .with_context(company_id=trans.acquirer_id.company_id.id) - confirmed_orders = trans.sale_order_ids.filtered(lambda so: so.state in ('sale', 'done')) - if confirmed_orders: - confirmed_orders._force_lines_to_invoice_policy_order() - invoices = confirmed_orders._create_invoices() - # Setup access token in advance to avoid serialization failure between - # edi postprocessing of invoice and displaying the sale order on the portal - for invoice in invoices: - invoice._portal_ensure_token() - trans.invoice_ids = [(6, 0, invoices.ids)] + for tx in self.filtered(lambda tx: tx.sale_order_ids): + # Create invoices + tx = tx.with_company(tx.company_id).with_context(company_id=tx.company_id.id) + confirmed_orders = tx.sale_order_ids.filtered(lambda so: so.state in ('sale', 'done')) + if confirmed_orders: + confirmed_orders._force_lines_to_invoice_policy_order() + invoices = confirmed_orders.with_context( + raise_if_nothing_to_invoice=False + )._create_invoices() + # Setup access token in advance to avoid serialization failure between + # edi postprocessing of invoice and displaying the sale order on the portal + for invoice in invoices: + invoice._portal_ensure_token() + tx.invoice_ids = [Command.set(invoices.ids)] @api.model def _compute_reference_prefix(self, provider, separator, **values): diff --git a/addons/sale/models/sale_order.py b/addons/sale/models/sale_order.py index c063b968a8c..3a7503446cd 100644 --- a/addons/sale/models/sale_order.py +++ b/addons/sale/models/sale_order.py @@ -813,16 +813,20 @@ class SaleOrder(models.Model): return {'done', 'cancel'} def _send_order_confirmation_mail(self): + if not self: + return + if self.env.su: # sending mail in sudo was meant for it being sent from superuser self = self.with_user(SUPERUSER_ID) + mail_template = self._find_mail_template(force_confirmation_template=True) if mail_template: - for order in self: - order.with_context(force_send=True).message_post_with_template( - mail_template.id, composition_mode='comment', - email_layout_xmlid="mail.mail_notification_paynow" - ) + self.with_context(force_send=True).message_post_with_template( + mail_template.id, + composition_mode='comment', + email_layout_xmlid='mail.mail_notification_paynow', + ) def action_done(self): for order in self: @@ -1070,7 +1074,7 @@ class SaleOrder(models.Model): invoice_vals['invoice_line_ids'] += invoice_line_vals invoice_vals_list.append(invoice_vals) - if not invoice_vals_list: + if not invoice_vals_list and self._context.get('raise_if_nothing_to_invoice', True): raise UserError(order._nothing_to_invoice_error_message()) # 2) Manage 'grouped' parameter: group by (partner_id, currency_id). diff --git a/addons/sale/tests/test_payment_flow.py b/addons/sale/tests/test_payment_flow.py index cf3e883e6f0..01c3dc0a946 100644 --- a/addons/sale/tests/test_payment_flow.py +++ b/addons/sale/tests/test_payment_flow.py @@ -81,6 +81,7 @@ class TestSalePayment(PaymentHttpCommon): # Check validation of transaction correctly confirms the SO self.assertEqual(self.order.state, 'draft') + self.assertEqual(tx_sudo.sale_order_ids.transaction_ids, tx_sudo) tx_sudo._set_done() tx_sudo._finalize_post_processing() self.assertEqual(self.order.state, 'sale') @@ -120,6 +121,7 @@ class TestSalePayment(PaymentHttpCommon): self.assertEqual(tx_sudo.company_id, self.order.company_id) self.assertEqual(tx_sudo.currency_id, self.order.currency_id) self.assertEqual(tx_sudo.reference, self.reference) + self.assertEqual(tx_sudo.sale_order_ids.transaction_ids, tx_sudo) tx_sudo._set_done() with mute_logger('odoo.addons.sale.models.payment_transaction'): @@ -194,3 +196,62 @@ class TestSalePayment(PaymentHttpCommon): tx_sudo._finalize_post_processing() self.assertEqual(self.order.state, 'draft', 'a partial transaction with automatic invoice and invoice_policy = delivery should not validate a quote') + + def test_auto_confirm_and_auto_invoice(self): + # Set automatic invoice + self.env['ir.config_parameter'].sudo().set_param('sale.automatic_invoice', 'True') + + # Create the payment + self.amount = self.order.amount_total + tx = self._create_transaction(flow='redirect', sale_order_ids=[self.order.id], state='done') + with mute_logger('odoo.addons.sale.models.payment_transaction'): + tx._reconcile_after_done() + + self.assertEqual(self.order.state, 'sale') + self.assertTrue(tx.invoice_ids) + self.assertTrue(self.order.invoice_ids) + + def test_auto_done_and_auto_invoice(self): + # Set automatic invoice + self.env['ir.config_parameter'].sudo().set_param('sale.automatic_invoice', 'True') + # Lock the sale orders when confirmed + self.env.user.groups_id += self.env.ref('sale.group_auto_done_setting') + + # Create the payment + self.amount = self.order.amount_total + tx = self._create_transaction(flow='redirect', sale_order_ids=[self.order.id], state='done') + with mute_logger('odoo.addons.sale.models.payment_transaction'): + tx._reconcile_after_done() + + self.assertEqual(self.order.state, 'done') + self.assertTrue(tx.invoice_ids) + self.assertTrue(self.order.invoice_ids) + + def test_so_partial_payment_no_invoice(self): + # Set automatic invoice + self.env['ir.config_parameter'].sudo().set_param('sale.automatic_invoice', 'True') + + # Create the payment + self.amount = self.order.amount_total / 10. + tx = self._create_transaction(flow='redirect', sale_order_ids=[self.order.id], state='done') + with mute_logger('odoo.addons.sale.models.payment_transaction'): + tx._reconcile_after_done() + + self.assertEqual(self.order.state, 'draft') + self.assertFalse(tx.invoice_ids) + self.assertFalse(self.order.invoice_ids) + + def test_already_confirmed_so_payment(self): + # Set automatic invoice + self.env['ir.config_parameter'].sudo().set_param('sale.automatic_invoice', 'True') + + # Confirm order before payment + self.order.action_confirm() + + # Create the payment + self.amount = self.order.amount_total + tx = self._create_transaction(flow='redirect', sale_order_ids=[self.order.id], state='done') + tx._reconcile_after_done() + + self.assertTrue(tx.invoice_ids) + self.assertTrue(self.order.invoice_ids)