From a9658d5bf58bf2a9e906e2684f87d39e19e745c7 Mon Sep 17 00:00:00 2001 From: jem-odoo Date: Wed, 13 Jun 2018 14:24:34 +0200 Subject: [PATCH] [IMP] sale: untaxed amount to invoice/invoiced on sale line Similar fields were introduced at d2e33ba57c0517b88f3cdf987f6b161883b8272e but their computation were not totally exact, so someone removed them. Also, for performance sake, they were replaced by a (too) simpler calculation at 39d0613187d36f16c541734e80925e9d. Turns out, those fields were usefull ! Neither of those 2 implementations were 100% correct, so this commit reintroduced thoses 2 fields on sale.order.line object: 1/ untaxed_amount_invoiced: will contain the amount (money without taxes) invoiced from the sale line, taking refund linked to the same SO line into account. The formula is SUM(inv_line.price_subtotal) - SUM(ref_line.price_subtotal) where `inv_line` is a customer invoice line linked to the SO line `ref_line` is a customer credit note (refund) line linked to the SO line 2/ untaxed_amount_to_invoice: will contain the amount (taxes excluded) of the money to invoice from this sale line. The formula is SOL.total - SOL.untaxed_amount_invoiced Those numbers are interesting in reporting, and to manage the billing part for salespersons. Task #1857652 --- addons/sale/models/sale.py | 49 +++++++++ addons/sale/tests/test_sale_to_invoice.py | 127 +++++++++++++++++++--- 2 files changed, 159 insertions(+), 17 deletions(-) diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index d28c315fb02..59023d1970a 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -933,6 +933,9 @@ class SaleOrderLine(models.Model): compute='_get_invoice_qty', string='Invoiced', store=True, readonly=True, digits=dp.get_precision('Product Unit of Measure')) + untaxed_amount_invoiced = fields.Monetary("Untaxed Invoiced Amount", compute='_compute_untaxed_amount_invoiced', compute_sudo=True, store=True) + untaxed_amount_to_invoice = fields.Monetary("Untaxed Amount To Invoice", compute='_compute_untaxed_amount_to_invoice', compute_sudo=True, store=True) + salesman_id = fields.Many2one(related='order_id.user_id', store=True, string='Salesperson', readonly=True) currency_id = fields.Many2one(related='order_id.currency_id', depends=['order_id'], store=True, string='Currency', readonly=True) company_id = fields.Many2one(related='order_id.company_id', string='Company', store=True, readonly=True) @@ -1047,6 +1050,52 @@ class SaleOrderLine(models.Model): else: line.qty_delivered_manual = 0.0 + @api.depends('invoice_lines', 'invoice_lines.price_total', 'invoice_lines.invoice_id.state', 'invoice_lines.invoice_id.type') + def _compute_untaxed_amount_invoiced(self): + """ Compute the untaxed amount already invoiced from the sale order line, taking the refund attached + the so line into account. This amount is computed as + SUM(inv_line.price_subtotal) - SUM(ref_line.price_subtotal) + where + `inv_line` is a customer invoice line linked to the SO line + `ref_line` is a customer credit note (refund) line linked to the SO line + """ + for line in self: + amount_invoiced = 0.0 + for invoice_line in line.invoice_lines: + if invoice_line.invoice_id.state in ['open', 'paid']: + invoice_date = invoice_line.invoice_id.date_invoice or fields.Date.today() + if invoice_line.invoice_id.type == 'out_invoice': + amount_invoiced += invoice_line.currency_id._convert(invoice_line.price_subtotal, line.currency_id, line.company_id, invoice_date) + elif invoice_line.invoice_id.type == 'out_refund': + amount_invoiced -= invoice_line.currency_id._convert(invoice_line.price_subtotal, line.currency_id, line.company_id, invoice_date) + line.untaxed_amount_invoiced = amount_invoiced + + @api.depends('state', 'product_id', 'untaxed_amount_invoiced', 'qty_delivered') + def _compute_untaxed_amount_to_invoice(self): + """ Total of remaining amount to invoice on the sale order line (taxes excl.) as + total_sol - amount already invoiced + where Total_sol depends on the invoice policy of the product. + + Note: Draft invoice are ignored on purpose, the 'to invoice' amount should + come only from the SO lines. + """ + for line in self: + amount_to_invoice = 0.0 + if line.state in ['sale', 'done']: + # Note: do not use price_subtotal field as it returns zero when the ordered quantity is + # zero. It causes problem for expense line (e.i.: ordered qty = 0, deli qty = 4, + # price_unit = 20 ; subtotal is zero), but when you can invoice the line, you see an + # amount and not zero. Since we compute untaxed amount, we can use directly the price + # unit without using `compute_all()` method on taxes. + price_subtotal = 0.0 + if line.product_id.invoice_policy == 'delivery': + price_subtotal = line.price_unit * line.qty_delivered + else: + price_subtotal = line.price_unit * line.product_uom_qty + + amount_to_invoice = price_subtotal - line.untaxed_amount_invoiced + line.untaxed_amount_to_invoice = amount_to_invoice + @api.multi def _prepare_invoice_line(self, qty): """ diff --git a/addons/sale/tests/test_sale_to_invoice.py b/addons/sale/tests/test_sale_to_invoice.py index 83a72ab30fe..a321f06c1c0 100644 --- a/addons/sale/tests/test_sale_to_invoice.py +++ b/addons/sale/tests/test_sale_to_invoice.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo.tools import pycompat +from odoo.tools import pycompat, float_is_zero from .test_sale_common import TestCommonSaleNoChart @@ -115,6 +115,11 @@ class TestSaleToInvoice(TestCommonSaleNoChart): product_price = line.price_unit * line.product_uom_qty self.assertEquals(line.discount, (product_price - line.price_subtotal) / product_price * 100, 'Discount should be applied on order line') + # lines are in draft + for line in self.sale_order.order_line: + self.assertTrue(float_is_zero(line.untaxed_amount_to_invoice, precision_digits=2), "The amount to invoice should be zero, as the line is in draf state") + self.assertTrue(float_is_zero(line.untaxed_amount_invoiced, precision_digits=2), "The invoiced amount should be zero, as the line is in draft state") + self.sale_order.action_confirm() # Let's do an invoice with invoiceable lines @@ -131,14 +136,26 @@ class TestSaleToInvoice(TestCommonSaleNoChart): def test_invoice_refund(self): """ Test invoice with a refund and check customer invoices credit note is created from respective invoice """ + # lines are in draft + for line in self.sale_order.order_line: + self.assertTrue(float_is_zero(line.untaxed_amount_to_invoice, precision_digits=2), "The amount to invoice should be zero, as the line is in draf state") + self.assertTrue(float_is_zero(line.untaxed_amount_invoiced, precision_digits=2), "The invoiced amount should be zero, as the line is in draft state") + # Confirm the SO self.sale_order.action_confirm() - # Take only invoicable line - order_line = self.sale_order.order_line.filtered(lambda l: l.product_id.invoice_policy == 'order') + # Check ordered quantity, quantity to invoice and invoiced quantity of SO lines - for line in order_line: - self.assertEquals(line.qty_to_invoice, line.product_uom_qty, 'Quantity to invoice should be same as ordered quantity') - self.assertEquals(line.qty_invoiced, 0.0, 'Invoiced quantity should be zero as no any invoice created for SO') + for line in self.sale_order.order_line: + if line.product_id.invoice_policy == 'delivery': + self.assertEquals(line.qty_to_invoice, 0.0, 'Quantity to invoice should be same as ordered quantity') + self.assertEquals(line.qty_invoiced, 0.0, 'Invoiced quantity should be zero as no any invoice created for SO') + self.assertEquals(line.untaxed_amount_to_invoice, 0.0, "The amount to invoice should be zero, as the line based on delivered quantity") + self.assertEquals(line.untaxed_amount_invoiced, 0.0, "The invoiced amount should be zero, as the line based on delivered quantity") + else: + self.assertEquals(line.qty_to_invoice, line.product_uom_qty, 'Quantity to invoice should be same as ordered quantity') + self.assertEquals(line.qty_invoiced, 0.0, 'Invoiced quantity should be zero as no any invoice created for SO') + self.assertEquals(line.untaxed_amount_to_invoice, line.product_uom_qty * line.price_unit, "The amount to invoice should the total of the line, as the line is confirmed") + self.assertEquals(line.untaxed_amount_invoiced, 0.0, "The invoiced amount should be zero, as the line is confirmed") # Let's do an invoice with invoiceable lines payment = self.env['sale.advance.payment.inv'].with_context(self.context).create({ @@ -149,22 +166,98 @@ class TestSaleToInvoice(TestCommonSaleNoChart): invoice = self.sale_order.invoice_ids[0] # Update quantity of an invoice lines - invoice.invoice_line_ids[0].write({'quantity': 3.0}) - invoice.invoice_line_ids[1].write({'quantity': 2.0}) + invoice.invoice_line_ids[0].write({'quantity': 3.0}) # product ordered: from 5 to 3 + invoice.invoice_line_ids[1].write({'quantity': 2.0}) # service ordered: from 3 to 2 + + # amount to invoice / invoiced should not have changed (amounts take only confirmed invoice into account) + for line in self.sale_order.order_line: + if line.product_id.invoice_policy == 'delivery': + self.assertEquals(line.qty_to_invoice, 0.0, "Quantity to invoice should be zero") + self.assertEquals(line.qty_invoiced, 0.0, "Invoiced quantity should be zero as delivered lines are not delivered yet") + self.assertEquals(line.untaxed_amount_to_invoice, 0.0, "The amount to invoice should be zero, as the line based on delivered quantity (no confirmed invoice)") + self.assertEquals(line.untaxed_amount_invoiced, 0.0, "The invoiced amount should be zero, as no invoice are validated for now") + else: + if line == self.sol_prod_order: + self.assertEquals(self.sol_prod_order.qty_to_invoice, 2.0, "Changing the quantity on draft invoice update the qty to invoice on SO lines") + self.assertEquals(self.sol_prod_order.qty_invoiced, 3.0, "Changing the quantity on draft invoice update the invoiced qty on SO lines") + else: + self.assertEquals(self.sol_serv_order.qty_to_invoice, 1.0, "Changing the quantity on draft invoice update the qty to invoice on SO lines") + self.assertEquals(self.sol_serv_order.qty_invoiced, 2.0, "Changing the quantity on draft invoice update the invoiced qty on SO lines") + self.assertEquals(line.untaxed_amount_to_invoice, line.product_uom_qty * line.price_unit, "The amount to invoice should the total of the line, as the line is confirmed (no confirmed invoice)") + self.assertEquals(line.untaxed_amount_invoiced, 0.0, "The invoiced amount should be zero, as no invoice are validated for now") + invoice.action_invoice_open() # Check quantity to invoice on SO lines - for line in order_line: - self.assertEquals(line.qty_to_invoice, line.product_uom_qty - line.qty_invoiced, 'Quantity to invoice should be a difference between ordered quantity and invoiced quantity') + for line in self.sale_order.order_line: + if line.product_id.invoice_policy == 'delivery': + self.assertEquals(line.qty_to_invoice, 0.0, "Quantity to invoice should be same as ordered quantity") + self.assertEquals(line.qty_invoiced, 0.0, "Invoiced quantity should be zero as no any invoice created for SO") + self.assertEquals(line.untaxed_amount_to_invoice, 0.0, "The amount to invoice should be zero, as the line based on delivered quantity") + self.assertEquals(line.untaxed_amount_invoiced, 0.0, "The invoiced amount should be zero, as the line based on delivered quantity") + else: + if line == self.sol_prod_order: + self.assertEquals(line.qty_to_invoice, 2.0, "The ordered sale line are totally invoiced (qty to invoice is zero)") + self.assertEquals(line.qty_invoiced, 3.0, "The ordered (prod) sale line are totally invoiced (qty invoiced come from the invoice lines)") + else: + self.assertEquals(line.qty_to_invoice, 1.0, "The ordered sale line are totally invoiced (qty to invoice is zero)") + self.assertEquals(line.qty_invoiced, 2.0, "The ordered (serv) sale line are totally invoiced (qty invoiced = the invoice lines)") + self.assertEquals(line.untaxed_amount_to_invoice, line.price_unit * line.qty_to_invoice, "Amount to invoice is now set as qty to invoice * unit price since no price change on invoice, for ordered products") + self.assertEquals(line.untaxed_amount_invoiced, line.price_unit * line.qty_invoiced, "Amount invoiced is now set as qty invoiced * unit price since no price change on invoice, for ordered products") # Make a credit note - credit_note = self.env['account.invoice.refund'].with_context({'active_ids': [invoice.id], 'active_id': invoice.id}).create({ - 'filter_refund': 'refund', - 'description': 'test' + credit_note_wizard = self.env['account.invoice.refund'].with_context({'active_ids': [invoice.id], 'active_id': invoice.id}).create({ + 'filter_refund': 'modify', # this is the only mode for which the SO line is linked to the refund (https://github.com/odoo/odoo/commit/e680f29560ac20133c7af0c6364c6ef494662eac) + 'description': 'reason test', }) - credit_note.invoice_refund() - invoice_credit_note = self.sale_order.invoice_ids[1] + credit_note_wizard.invoice_refund() + invoice_2 = self.sale_order.invoice_ids.sorted(key=lambda inv: inv.id, reverse=False)[-1] # the first invoice, its refund, and the new invoice # Check invoice's type and number - self.assertEquals(invoice_credit_note.type, 'out_refund', 'Invoice type should be a customer credit note') - self.assertEquals(invoice.number, invoice_credit_note.origin, 'Customer invoices credit note should be created from respective invoice') + self.assertEquals(invoice_2.type, 'out_invoice', 'The last created invoiced should be a customer invoice') + self.assertEquals(invoice_2.state, 'draft', 'Last Customer invoices should be in draft') + + # At this time, the invoice 1 and its refund are confirmed, so the amounts invoiced are zero. The third invoice + # (2nd customer inv) is in draft state. + for line in self.sale_order.order_line: + if line.product_id.invoice_policy == 'delivery': + self.assertEquals(line.qty_to_invoice, 0.0, "Quantity to invoice should be same as ordered quantity") + self.assertEquals(line.qty_invoiced, 0.0, "Invoiced quantity should be zero as no any invoice created for SO") + self.assertEquals(line.untaxed_amount_to_invoice, 0.0, "The amount to invoice should be zero, as the line based on delivered quantity") + self.assertEquals(line.untaxed_amount_invoiced, 0.0, "The invoiced amount should be zero, as the line based on delivered quantity") + else: + if line == self.sol_prod_order: + self.assertEquals(line.qty_to_invoice, 2.0, "The qty to invoice does not change when confirming the new invoice (2)") + self.assertEquals(line.qty_invoiced, 3.0, "The ordered (prod) sale line does not change on invoice 2 confirmation") + self.assertEquals(line.untaxed_amount_to_invoice, line.price_unit * 5, "Amount to invoice is now set as qty to invoice * unit price since no price change on invoice, for ordered products") + self.assertEquals(line.untaxed_amount_invoiced, 0.0, "Amount invoiced is zero as the invoice 1 and its refund are reconcilied") + else: + self.assertEquals(line.qty_to_invoice, 1.0, "The qty to invoice does not change when confirming the new invoice (2)") + self.assertEquals(line.qty_invoiced, 2.0, "The ordered (serv) sale line does not change on invoice 2 confirmation") + self.assertEquals(line.untaxed_amount_to_invoice, line.price_unit * 3, "Amount to invoice is now set as unit price * ordered qty - refund qty) even if the ") + self.assertEquals(line.untaxed_amount_invoiced, 0.0, "Amount invoiced is zero as the invoice 1 and its refund are reconcilied") + + # Change unit of ordered product on refund lines + invoice_2.invoice_line_ids.filtered(lambda invl: invl.product_id == self.sol_prod_order.product_id).write({'price_unit': 100}) + invoice_2.invoice_line_ids.filtered(lambda invl: invl.product_id == self.sol_serv_order.product_id).write({'price_unit': 50}) + + # Validate the refund + invoice_2.action_invoice_open() + + for line in self.sale_order.order_line: + if line.product_id.invoice_policy == 'delivery': + self.assertEquals(line.qty_to_invoice, 0.0, "Quantity to invoice should be same as ordered quantity") + self.assertEquals(line.qty_invoiced, 0.0, "Invoiced quantity should be zero as no any invoice created for SO") + self.assertEquals(line.untaxed_amount_to_invoice, 0.0, "The amount to invoice should be zero, as the line based on delivered quantity") + self.assertEquals(line.untaxed_amount_invoiced, 0.0, "The invoiced amount should be zero, as the line based on delivered quantity") + else: + if line == self.sol_prod_order: + self.assertEquals(line.qty_to_invoice, 2.0, "The qty to invoice does not change when confirming the new invoice (2)") + self.assertEquals(line.qty_invoiced, 3.0, "The ordered sale line are totally invoiced (qty invoiced = ordered qty)") + self.assertEquals(line.untaxed_amount_to_invoice, 1100.0, "") + self.assertEquals(line.untaxed_amount_invoiced, 300.0, "") + else: + self.assertEquals(line.qty_to_invoice, 1.0, "The qty to invoice does not change when confirming the new invoice (2)") + self.assertEquals(line.qty_invoiced, 2.0, "The ordered sale line are totally invoiced (qty invoiced = ordered qty)") + self.assertEquals(line.untaxed_amount_to_invoice, 170.0, "") + self.assertEquals(line.untaxed_amount_invoiced, 100.0, "")