[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) <whe@odoo.com>
Signed-off-by: Adrien Widart <awt@odoo.com>
This commit is contained in:
Adrien Widart (awt)
2023-01-24 10:20:41 +01:00
parent ed8b0cb1dc
commit 05b8b0a7d6
4 changed files with 161 additions and 34 deletions
+5 -3
View File
@@ -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
@@ -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)
+10 -31
View File
@@ -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):
@@ -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