From 7bd93cc64b582cdd559a6b6f855c1950a992df68 Mon Sep 17 00:00:00 2001 From: "John Laterre (jol)" Date: Thu, 16 Feb 2023 14:20:33 +0000 Subject: [PATCH] [IMP] sale: add confirmed SO to reveivable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The goal is to take into account the confirmed and un-invoiced sales orders in the total receivable of a client. It will be useful in the scope of the credit limit trigger. This is broken down in 3 steps: 1. Add the Remaining Amount to Invoice (RAI) in the Invoice creation wizard of the Sales Order and store this data. 2. Add the RAI in the Total Receivable of the client. 3. The Remaining amount to invoice goes to optional="hide" in the list view of "Orders to invoice" list view. task-3122390 closes odoo/odoo#112905 Signed-off-by: William André (wan) --- addons/account/models/account_move.py | 20 ++++--- addons/sale/models/account_move.py | 19 +++++++ addons/sale/models/res_partner.py | 8 +++ addons/sale/models/sale_order.py | 22 +++++++- .../tests/test_sale_order_down_payment.py | 55 +++++++++++++++++++ addons/sale/views/sale_order_views.xml | 1 + .../sale/wizard/sale_make_invoice_advance.py | 45 ++++++++++++--- .../sale_make_invoice_advance_views.xml | 42 +++++++------- addons/sale_timesheet/tests/test_reinvoice.py | 1 - 9 files changed, 172 insertions(+), 41 deletions(-) diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index 6cf6ed74e03..ecb36dd558c 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -568,6 +568,7 @@ class AccountMove(models.Model): compute='_compute_partner_credit_warning', groups="account.group_account_invoice,account.group_account_readonly", ) + partner_credit = fields.Monetary(compute='_compute_partner_credit') duplicated_ref_ids = fields.Many2many(comodel_name='account.move', compute='_compute_duplicated_ref_ids') # used to display the various dates and amount dues on the invoice's PDF @@ -1378,24 +1379,29 @@ class AccountMove(models.Model): move.move_type == 'out_invoice' and \ move.company_id.account_use_credit_limit if show_warning: - amount_total_currency = move.currency_id._convert(move.tax_totals['amount_total'], move.company_currency_id, move.company_id, move.date) - updated_credit = move.partner_id.commercial_partner_id.credit + amount_total_currency - move.partner_credit_warning = self._build_credit_warning_message(move, updated_credit) + move.partner_credit_warning = self._build_credit_warning_message( + move, move.partner_credit, move.tax_totals['amount_total'] > 0.0) - def _build_credit_warning_message(self, record, updated_credit): - ''' Build the warning message that will be displayed in a yellow banner on top of the current record + @api.depends('partner_id') + def _compute_partner_credit(self): + for move in self: + move.partner_credit = move.partner_id.commercial_partner_id.credit + + def _build_credit_warning_message(self, record, updated_credit, include): + """ Build the warning message that will be displayed in a yellow banner on top of the current record if the partner exceeds a credit limit (set on the company or the partner itself). :param record: The record where the warning will appear (Invoice, Sales Order...). :param updated_credit (float): The partner's updated credit limit including the current record. + :param include (bool): Whether the current record's amount is included in the warning message. :return (str): The warning message to be showed. - ''' + """ partner_id = record.partner_id.commercial_partner_id if not partner_id.credit_limit or updated_credit <= partner_id.credit_limit: return '' msg = _('%s has reached its Credit Limit of : %s\nTotal amount due ', partner_id.name, formatLang(self.env, partner_id.credit_limit, currency_obj=record.company_id.currency_id)) - if updated_credit > partner_id.credit: + if include: msg += _('(including this document) ') msg += ': %s' % formatLang(self.env, updated_credit, currency_obj=record.company_id.currency_id) return msg diff --git a/addons/sale/models/account_move.py b/addons/sale/models/account_move.py index 467eeea5084..0087647235a 100644 --- a/addons/sale/models/account_move.py +++ b/addons/sale/models/account_move.py @@ -145,3 +145,22 @@ class AccountMove(models.Model): # OVERRIDE self.ensure_one() return self.line_ids.sale_line_ids and all(sale_line.is_downpayment for sale_line in self.line_ids.sale_line_ids) or False + + @api.depends('line_ids.sale_line_ids.order_id', 'currency_id', 'tax_totals', 'date') + def _compute_partner_credit(self): + super()._compute_partner_credit() + for move in self: + sale_order = move.line_ids.sale_line_ids.order_id + amount_total_currency = move.currency_id._convert( + move.tax_totals['amount_total'], + move.company_currency_id, + move.company_id, + move.date + ) + amount_to_invoice_currency = sale_order.currency_id._convert( + sale_order.amount_to_invoice, + move.company_currency_id, + move.company_id, + move.date + ) + move.partner_credit += max(amount_total_currency - amount_to_invoice_currency, 0.0) diff --git a/addons/sale/models/res_partner.py b/addons/sale/models/res_partner.py index b143a05b41a..30ec3525427 100644 --- a/addons/sale/models/res_partner.py +++ b/addons/sale/models/res_partner.py @@ -54,3 +54,11 @@ class ResPartner(models.Model): all_child = self.with_context(active_test=False).search([('id', 'child_of', self.ids)]) action["domain"] = [("partner_id", "in", all_child.ids)] return action + + def _credit_debit_get(self): + super()._credit_debit_get() + domain = [('partner_id', 'in', self.ids), ('state', 'in', ['sale', 'done'])] + group = self.env['sale.order'].read_group(domain, ['amount_to_invoice'], ['partner_id']) + for res in group: + partner = self.browse(res['partner_id'][0]) + partner.credit += res['amount_to_invoice'] diff --git a/addons/sale/models/sale_order.py b/addons/sale/models/sale_order.py index 8c05e855459..d583ee1a054 100644 --- a/addons/sale/models/sale_order.py +++ b/addons/sale/models/sale_order.py @@ -218,6 +218,8 @@ class SaleOrder(models.Model): amount_untaxed = fields.Monetary(string="Untaxed Amount", store=True, compute='_compute_amounts', tracking=5) amount_tax = fields.Monetary(string="Taxes", store=True, compute='_compute_amounts') amount_total = fields.Monetary(string="Total", store=True, compute='_compute_amounts', tracking=4) + amount_to_invoice = fields.Monetary(string="Amount to invoice", store=True, compute='_compute_amount_to_invoice') + amount_invoiced = fields.Monetary(string="Already invoiced", compute='_compute_amount_invoiced') invoice_count = fields.Integer(string="Invoice Count", compute='_get_invoiced') invoice_ids = fields.Many2many( @@ -578,6 +580,24 @@ class SaleOrder(models.Model): else: record.tax_country_id = record.company_id.account_fiscal_country_id + @api.depends('invoice_ids.state', 'currency_id', 'amount_total') + def _compute_amount_to_invoice(self): + for order in self: + order.amount_to_invoice = order.amount_total + for invoice in order.invoice_ids.filtered(lambda x: x.state == 'posted'): + invoice_amount_currency = invoice.currency_id._convert( + invoice.tax_totals['amount_total'], + order.currency_id, + invoice.company_id, + invoice.date, + ) + order.amount_to_invoice -= invoice_amount_currency + + @api.depends('amount_total', 'amount_to_invoice') + def _compute_amount_invoiced(self): + for order in self: + order.amount_invoiced = order.amount_total - order.amount_to_invoice + @api.depends('company_id', 'partner_id', 'amount_total') def _compute_partner_credit_warning(self): for order in self: @@ -588,7 +608,7 @@ class SaleOrder(models.Model): if show_warning: updated_credit = order.partner_id.commercial_partner_id.credit + (order.amount_total * order.currency_rate) order.partner_credit_warning = self.env['account.move']._build_credit_warning_message( - order, updated_credit) + order, updated_credit, order.amount_total > 0.0) @api.depends('order_line.tax_id', 'order_line.price_unit', 'amount_total', 'amount_untaxed', 'currency_id') def _compute_tax_totals(self): diff --git a/addons/sale/tests/test_sale_order_down_payment.py b/addons/sale/tests/test_sale_order_down_payment.py index e23aec7c2e5..eae07013b1d 100644 --- a/addons/sale/tests/test_sale_order_down_payment.py +++ b/addons/sale/tests/test_sale_order_down_payment.py @@ -403,3 +403,58 @@ class TestSaleOrderDownPayment(TestSaleCommon): [self.receivable_account.id, self.env['account.tax'], down_pay_amt, 0, False ], ] self._assert_invoice_lines_values(invoice.line_ids, expected) + + def test_warning_on_invoice_with_credit_limit(self): + # Activate the Credit Limit feature and set a value for partner_a. + self.env.company.account_use_credit_limit = True + self.partner_a.credit_limit = 1000.0 + + # Create and confirm a SO to reach (but not exceed) partner_a's credit limit. + sale_order = self.env['sale.order'].create({ + 'partner_id': self.partner_a.id, + 'partner_invoice_id': self.partner_a.id, + 'partner_shipping_id': self.partner_a.id, + 'pricelist_id': self.company_data['default_pricelist'].id, + 'order_line': [Command.create({ + 'name': self.company_data['product_order_no'].name, + 'product_id': self.company_data['product_order_no'].id, + 'product_uom_qty': 1, + 'product_uom': self.company_data['product_order_no'].uom_id.id, + 'price_unit': 1000.0, + 'tax_id': False, + })] + }) + + # Check that partner_a's credit is 0.0. + self.assertEqual(self.partner_a.credit, 0.0) + + # Make sure partner_a's credit includes the newly confirmed SO. + sale_order.action_confirm() + self.partner_a.invalidate_recordset(['credit']) + self.assertEqual(self.partner_a.credit, 1000.0) + + # Create a 50% down payment invoice. + self.env['sale.advance.payment.inv'].with_context({ + 'active_model': 'sale.order', + 'active_ids': [sale_order.id], + 'active_id': sale_order.id, + 'default_journal_id': self.company_data['default_journal_sale'].id, + }).create({ + 'advance_payment_method': 'percentage', + 'amount': 50, + 'deposit_account_id': self.revenue_account.id, + }).create_invoices() + + # Check that the warning does not appear even though we are creating an invoice + # that should bring partner_a's credit above its limit. + self.assertEqual(sale_order.invoice_ids.partner_credit_warning, '') + + # Make the down payment invoice amount larger than the Amount to Invoice + # and check that the warning appears with the correct amounts, + # i.e. 1.500 instead of 2.500 (1.000 SO + 1.500 down payment invoice). + sale_order.invoice_ids.invoice_line_ids.quantity = 3 + self.assertEqual( + sale_order.invoice_ids.partner_credit_warning, + "partner_a has reached its Credit Limit of : $\xa01,000.00\n" + "Total amount due (including this document) : $\xa01,500.00" + ) diff --git a/addons/sale/views/sale_order_views.xml b/addons/sale/views/sale_order_views.xml index f3a86504889..b155e664b13 100644 --- a/addons/sale/views/sale_order_views.xml +++ b/addons/sale/views/sale_order_views.xml @@ -132,6 +132,7 @@ decoration-warning="invoice_status == 'upselling'" widget="badge" optional="show"/> + diff --git a/addons/sale/wizard/sale_make_invoice_advance.py b/addons/sale/wizard/sale_make_invoice_advance.py index 206041fd791..a061788b79c 100644 --- a/addons/sale/wizard/sale_make_invoice_advance.py +++ b/addons/sale/wizard/sale_make_invoice_advance.py @@ -55,6 +55,14 @@ class SaleAdvancePaymentInv(models.TransientModel): comodel_name='res.company', compute='_compute_company_id', store=True) + amount_invoiced = fields.Monetary( + string="Already invoiced", + compute="_compute_invoice_amounts", + help="Only confirmed down payments are considered.") + amount_to_invoice = fields.Monetary( + string="Amount to invoice", + compute="_compute_invoice_amounts", + help="The amount to invoice = Sale Order Total - Confirmed Down Payments.") # Only used when there is no down payment product available # to setup the down payment product @@ -71,6 +79,7 @@ class SaleAdvancePaymentInv(models.TransientModel): # UI display_draft_invoice_warning = fields.Boolean(compute="_compute_display_draft_invoice_warning") + display_invoice_amount_warning = fields.Boolean(compute="_compute_display_invoice_amount_warning") #=== COMPUTE METHODS ===# @@ -109,13 +118,24 @@ class SaleAdvancePaymentInv(models.TransientModel): if wizard.count == 1: wizard.product_id = wizard.company_id.sale_down_payment_product_id - @api.depends('sale_order_ids', 'advance_payment_method', 'deduct_down_payments') + @api.depends('amount', 'fixed_amount', 'advance_payment_method', 'amount_to_invoice') + def _compute_display_invoice_amount_warning(self): + for wizard in self: + invoice_amount = wizard.fixed_amount + if wizard.advance_payment_method == 'percentage': + invoice_amount = wizard.amount / 100 * sum(wizard.sale_order_ids.mapped('amount_total')) + wizard.display_invoice_amount_warning = invoice_amount > wizard.amount_to_invoice + + @api.depends('sale_order_ids') def _compute_display_draft_invoice_warning(self): for wizard in self: - wizard.display_draft_invoice_warning = ( - wizard.advance_payment_method == 'delivered' and - any(aml.parent_state == 'draft' for aml in wizard.sale_order_ids.order_line.invoice_lines) - ) + wizard.display_draft_invoice_warning = wizard.sale_order_ids.invoice_ids.filtered(lambda invoice: invoice.state == 'draft') + + @api.depends('sale_order_ids') + def _compute_invoice_amounts(self): + for wizard in self: + wizard.amount_invoiced = sum(wizard.sale_order_ids.mapped('amount_invoiced')) + wizard.amount_to_invoice = sum(wizard.sale_order_ids.mapped('amount_to_invoice')) #=== ONCHANGE METHODS ===# @@ -127,7 +147,6 @@ class SaleAdvancePaymentInv(models.TransientModel): #=== CONSTRAINT METHODS ===# - @api.constrains('advance_payment_method', 'amount', 'fixed_amount') def _check_amount_is_positive(self): for wizard in self: if wizard.advance_payment_method == 'percentage' and wizard.amount <= 0.00: @@ -153,11 +172,19 @@ class SaleAdvancePaymentInv(models.TransientModel): #=== ACTION METHODS ===# def create_invoices(self): + self._check_amount_is_positive() invoices = self._create_invoices(self.sale_order_ids) - if self.env.context.get('open_invoices'): - return self.sale_order_ids.action_view_invoice(invoices=invoices) + return self.sale_order_ids.action_view_invoice(invoices=invoices) - return {'type': 'ir.actions.act_window_close'} + def view_draft_invoices(self): + return { + 'name': _('Draft Invoices'), + 'type': 'ir.actions.act_window', + 'view_mode': 'tree', + 'views': [(False, 'list'), (False, 'form')], + 'res_model': 'account.move', + 'domain': [('line_ids.sale_line_ids.order_id', 'in', self.sale_order_ids.ids), ('state', '=', 'draft')], + } #=== BUSINESS METHODS ===# diff --git a/addons/sale/wizard/sale_make_invoice_advance_views.xml b/addons/sale/wizard/sale_make_invoice_advance_views.xml index 97784ecfd06..1b6cb6e28ca 100644 --- a/addons/sale/wizard/sale_make_invoice_advance_views.xml +++ b/addons/sale/wizard/sale_make_invoice_advance_views.xml @@ -6,29 +6,20 @@ sale.advance.payment.inv
-

- Invoices will be created in draft so that you can review - them before validation. -

- + + - - @@ -37,6 +28,7 @@ + + + + diff --git a/addons/sale_timesheet/tests/test_reinvoice.py b/addons/sale_timesheet/tests/test_reinvoice.py index 471b84b9b4f..d10df27b5fc 100644 --- a/addons/sale_timesheet/tests/test_reinvoice.py +++ b/addons/sale_timesheet/tests/test_reinvoice.py @@ -332,7 +332,6 @@ class TestReInvoice(TestCommonSaleTimesheet): "active_model": 'sale.order', "active_ids": [sale_order.id], "active_id": sale_order.id, - 'open_invoices': True, } # Invoice the 1 wizard = self.env['sale.advance.payment.inv'].with_context(context).create({