From 05b8b0a7d65f4e11f7a7595c3b9646676eb70830 Mon Sep 17 00:00:00 2001 From: "Adrien Widart (awt)" Date: Fri, 13 Jan 2023 16:59:12 +0000 Subject: [PATCH] [FIX] sale_stock,stock_account: reset and post invoice with cogs To reproduce the issue: (Need account_accountant,purchase) 1. Setup a product P - Storable - Invoicing Policy: Delivered - Category: - FIFO + Auto 2. Process a PO with 1 x P at $10 3. Process a PO with 1 x P at $15 4. Create and confirm a SO with 2 x P 5. Process the delivery one by one (backorder) 6. Create one invoice for each delivery - The COGS of the first one are equal to $10 - The COGS of the first one are equal to $15 7. Reset and repost the first invoice Error: The COGS are equal to $15 instead of $10 Steps 5: it creates 2 out-SVL (1x$10 and 1x$15). Step 7, we try to get the anglo saxo price unit. It leads to `_compute_average_price` with the invoiced qty (1) and the qty to invoice (also 1). In this method, we iterate on each out-SVL, we first skip the invoiced quantity and then consume the quantity to invoice. Here is the issue: we will skip the SVL we should use and keep the one already used. As there isn't any link between an AML and the SVL it used, we need a new approach. OPW-3062069 closes odoo/odoo#110783 X-original-commit: 17438d5befcd6e9f1fc2ec2a54dfcebbc846d251 Signed-off-by: William Henrotin (whe) Signed-off-by: Adrien Widart --- addons/sale_stock/models/account_move.py | 8 +- .../tests/test_anglo_saxon_valuation.py | 75 +++++++++++++++++++ addons/stock_account/models/product.py | 41 +++------- .../models/stock_valuation_layer.py | 71 ++++++++++++++++++ 4 files changed, 161 insertions(+), 34 deletions(-) diff --git a/addons/sale_stock/models/account_move.py b/addons/sale_stock/models/account_move.py index 38c4040c9fa..42267af8a96 100644 --- a/addons/sale_stock/models/account_move.py +++ b/addons/sale_stock/models/account_move.py @@ -123,10 +123,12 @@ class AccountMoveLine(models.Model): if so_line: is_line_reversing = bool(self.move_id.reversed_entry_id) qty_to_invoice = self.product_uom_id._compute_quantity(self.quantity, self.product_id.uom_id) - posted_invoice_lines = so_line.invoice_lines.filtered(lambda l: l.move_id.state == 'posted' and bool(l.move_id.reversed_entry_id) == is_line_reversing) - qty_invoiced = sum([x.product_uom_id._compute_quantity(x.quantity, x.product_id.uom_id) for x in posted_invoice_lines]) + account_moves = so_line.invoice_lines.move_id.filtered(lambda m: m.state == 'posted' and bool(m.reversed_entry_id) == is_line_reversing) + posted_cogs = account_moves.line_ids.filtered(lambda l: l.display_type == 'cogs' and l.product_id == self.product_id and l.balance > 0) + qty_invoiced = sum([line.product_uom_id._compute_quantity(line.quantity, line.product_id.uom_id) for line in posted_cogs]) + value_invoiced = sum(posted_cogs.mapped('balance')) - product = self.product_id.with_company(self.company_id) + product = self.product_id.with_company(self.company_id).with_context(value_invoiced=value_invoiced) average_price_unit = product._compute_average_price(qty_invoiced, qty_to_invoice, so_line.move_ids, is_returned=is_line_reversing) price_unit = self.product_id.uom_id.with_company(self.company_id)._compute_price(average_price_unit, self.product_uom_id) return price_unit diff --git a/addons/sale_stock/tests/test_anglo_saxon_valuation.py b/addons/sale_stock/tests/test_anglo_saxon_valuation.py index 57a49715255..49168e916c4 100644 --- a/addons/sale_stock/tests/test_anglo_saxon_valuation.py +++ b/addons/sale_stock/tests/test_anglo_saxon_valuation.py @@ -1462,3 +1462,78 @@ class TestAngloSaxonValuation(ValuationReconciliationTestCommon): cogs_aml = amls.filtered(lambda aml: aml.account_id == self.company_data['default_account_expense']) self.assertEqual(cogs_aml.debit, 0) self.assertEqual(cogs_aml.credit, 20, 'Should be to the value of the returned product') + + def test_fifo_several_invoices_reset_repost(self): + self.product.categ_id.property_cost_method = 'fifo' + self.product.invoice_policy = 'delivery' + + svl_values = [10, 15, 65] + total_value = sum(svl_values) + in_moves = self.env['stock.move'].create([{ + 'name': 'IN move @%s' % p, + 'product_id': self.product.id, + 'location_id': self.env.ref('stock.stock_location_suppliers').id, + 'location_dest_id': self.company_data['default_warehouse'].lot_stock_id.id, + 'product_uom': self.product.uom_id.id, + 'product_uom_qty': 1, + 'price_unit': p, + } for p in svl_values]) + in_moves._action_confirm() + in_moves.quantity_done = 1 + in_moves._action_done() + + so = self.env['sale.order'].create({ + 'partner_id': self.partner_a.id, + 'order_line': [ + (0, 0, { + 'name': self.product.name, + 'product_id': self.product.id, + 'product_uom_qty': 3.0, + 'product_uom': self.product.uom_id.id, + 'price_unit': 100, + 'tax_id': False, + })], + }) + so.action_confirm() + + # Deliver one by one, so it creates an out-SVL each time. + # Then invoice the delivered quantity + invoices = self.env['account.move'] + picking = so.picking_ids + while picking: + picking.move_ids.quantity_done = 1 + action = picking.button_validate() + if isinstance(action, dict): + wizard = Form(self.env[action['res_model']].with_context(action['context'])).save() + wizard.process() + picking = picking.backorder_ids + + invoice = so._create_invoices() + invoice.action_post() + invoices |= invoice + + out_account = self.product.categ_id.property_stock_account_output_categ_id + invoice01, _invoice02, invoice03 = invoices + cogs = invoices.line_ids.filtered(lambda l: l.account_id == out_account) + self.assertEqual(cogs.mapped('credit'), svl_values) + + # Reset and repost each invoice + for i, inv in enumerate(invoices): + inv.button_draft() + inv.action_post() + cogs = invoices.line_ids.filtered(lambda l: l.account_id == out_account) + self.assertEqual(cogs.mapped('credit'), svl_values, 'Incorrect values while posting again invoice %s' % (i + 1)) + + # Reset and repost all invoices (we only check the total value as the + # distribution changes but does not really matter) + invoices.button_draft() + invoices.action_post() + cogs = invoices.line_ids.filtered(lambda l: l.account_id == out_account) + self.assertEqual(sum(cogs.mapped('credit')), total_value) + + # Reset and repost few invoices (we only check the total value as the + # distribution changes but does not really matter) + (invoice01 | invoice03).button_draft() + (invoice01 | invoice03).action_post() + cogs = invoices.line_ids.filtered(lambda l: l.account_id == out_account) + self.assertEqual(sum(cogs.mapped('credit')), total_value) diff --git a/addons/stock_account/models/product.py b/addons/stock_account/models/product.py index ef2e0044b77..63412a40136 100644 --- a/addons/stock_account/models/product.py +++ b/addons/stock_account/models/product.py @@ -686,50 +686,29 @@ class ProductProduct(models.Model): if not qty_to_invoice: return 0 - returned_quantities = defaultdict(float) - for move in stock_moves: - if move.origin_returned_move_id: - returned_quantities[move.origin_returned_move_id.id] += abs(sum(move.sudo().stock_valuation_layer_ids.mapped('quantity'))) candidates = stock_moves\ .sudo()\ .filtered(lambda m: is_returned == bool(m.origin_returned_move_id and sum(m.stock_valuation_layer_ids.mapped('quantity')) >= 0))\ .mapped('stock_valuation_layer_ids')\ .sorted() - qty_to_take_on_candidates = qty_to_invoice - tmp_value = 0 # to accumulate the value taken on the candidates - for candidate in candidates: - if not candidate.quantity: - continue - candidate_quantity = abs(candidate.quantity) - if candidate.stock_move_id.id in returned_quantities: - candidate_quantity -= returned_quantities[candidate.stock_move_id.id] - if float_is_zero(candidate_quantity, precision_rounding=candidate.uom_id.rounding): - continue # correction entries - if not float_is_zero(qty_invoiced, precision_rounding=candidate.uom_id.rounding): - qty_ignored = min(qty_invoiced, candidate_quantity) - qty_invoiced -= qty_ignored - candidate_quantity -= qty_ignored - if float_is_zero(candidate_quantity, precision_rounding=candidate.uom_id.rounding): - continue - qty_taken_on_candidate = min(qty_to_take_on_candidates, candidate_quantity) - qty_to_take_on_candidates -= qty_taken_on_candidate - tmp_value += qty_taken_on_candidate * \ - ((candidate.value + sum(candidate.stock_valuation_layer_ids.mapped('value'))) / candidate.quantity) - if float_is_zero(qty_to_take_on_candidates, precision_rounding=candidate.uom_id.rounding): - break + value_invoiced = self.env.context.get('value_invoiced', 0) + if 'value_invoiced' in self.env.context: + qty_valued, valuation = candidates._consume_all(qty_invoiced, value_invoiced, qty_to_invoice) + else: + qty_valued, valuation = candidates._consume_specific_qty(qty_invoiced, qty_to_invoice) # If there's still quantity to invoice but we're out of candidates, we chose the standard # price to estimate the anglo saxon price unit. + missing = qty_to_invoice - qty_valued for sml in stock_moves.move_line_ids: if not sml.owner_id or sml.owner_id == sml.company_id.partner_id: continue - qty_to_take_on_candidates -= sml.product_uom_id._compute_quantity(sml.qty_done, self.uom_id, rounding_method='HALF-UP') - if float_compare(qty_to_take_on_candidates, 0, precision_rounding=self.uom_id.rounding) > 0: - negative_stock_value = self.standard_price * qty_to_take_on_candidates - tmp_value += negative_stock_value + missing -= sml.product_uom_id._compute_quantity(sml.qty_done, self.uom_id, rounding_method='HALF-UP') + if float_compare(missing, 0, precision_rounding=self.uom_id.rounding) > 0: + valuation += self.standard_price * missing - return tmp_value / qty_to_invoice + return valuation / qty_to_invoice class ProductCategory(models.Model): diff --git a/addons/stock_account/models/stock_valuation_layer.py b/addons/stock_account/models/stock_valuation_layer.py index 5679d8194e7..b007da36dcd 100644 --- a/addons/stock_account/models/stock_valuation_layer.py +++ b/addons/stock_account/models/stock_valuation_layer.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, fields, models, tools +from odoo.tools import float_compare, float_is_zero class StockValuationLayer(models.Model): @@ -101,3 +102,73 @@ class StockValuationLayer(models.Model): 'views': [[False, "form"]], 'res_id': self.id, } + + def _consume_specific_qty(self, qty_valued, qty_to_value): + """ + Iterate on the SVL to first skip the qty already valued. Then, keep + iterating to consume `qty_to_value` and stop + The method returns the valued quantity and its valuation + """ + if not self: + return 0, 0 + + rounding = self.product_id.uom_id.rounding + qty_to_take_on_candidates = qty_to_value + tmp_value = 0 # to accumulate the value taken on the candidates + for candidate in self: + if float_is_zero(candidate.quantity, precision_rounding=rounding): + continue + candidate_quantity = abs(candidate.quantity) + returned_qty = sum([sm.product_uom._compute_quantity(sm.quantity_done, self.uom_id) + for sm in candidate.stock_move_id.returned_move_ids if sm.state == 'done']) + candidate_quantity -= returned_qty + if float_is_zero(candidate_quantity, precision_rounding=rounding): + continue + if not float_is_zero(qty_valued, precision_rounding=rounding): + qty_ignored = min(qty_valued, candidate_quantity) + qty_valued -= qty_ignored + candidate_quantity -= qty_ignored + if float_is_zero(candidate_quantity, precision_rounding=rounding): + continue + qty_taken_on_candidate = min(qty_to_take_on_candidates, candidate_quantity) + + qty_to_take_on_candidates -= qty_taken_on_candidate + tmp_value += qty_taken_on_candidate * ((candidate.value + sum(candidate.stock_valuation_layer_ids.mapped('value'))) / candidate.quantity) + if float_is_zero(qty_to_take_on_candidates, precision_rounding=rounding): + break + + return qty_to_value - qty_to_take_on_candidates, tmp_value + + def _consume_all(self, qty_valued, valued, qty_to_value): + """ + The method consumes all svl to get the total qty/value. Then it deducts + the already consumed qty/value. Finally, it tries to consume the `qty_to_value` + The method returns the valued quantity and its valuation + """ + if not self: + return 0, 0 + + rounding = self.product_id.uom_id.rounding + qty_total = -qty_valued + value_total = -valued + new_valued_qty = 0 + new_valuation = 0 + + for svl in self: + if float_is_zero(svl.quantity, precision_rounding=rounding): + continue + relevant_qty = abs(svl.quantity) + returned_qty = sum([sm.product_uom._compute_quantity(sm.quantity_done, self.uom_id) + for sm in svl.stock_move_id.returned_move_ids if sm.state == 'done']) + relevant_qty -= returned_qty + if float_is_zero(relevant_qty, precision_rounding=rounding): + continue + qty_total += relevant_qty + value_total += relevant_qty * ((svl.value + sum(svl.stock_valuation_layer_ids.mapped('value'))) / svl.quantity) + + if float_compare(qty_total, 0, precision_rounding=rounding) > 0: + unit_cost = value_total / qty_total + new_valued_qty = min(qty_total, qty_to_value) + new_valuation = unit_cost * new_valued_qty + + return new_valued_qty, new_valuation