[REF] stock_account: fifo "normal" cost method with valuation layers

We don't implement fifo negative handling in this commit.
We make sure we run fifo in average mode to get a proper remaining_qty
field.
`_run_fifo` is now a method on product (since it could be user outside
of a stock move context). It is really similar to its ancestor on the
stock move, with a few float_is_zero and compare more and a missing
remaing_value.

We move the valuation sanity check into a proper helper,
_sanity_check_for_valuation.

We complete the _create_correction_svl method with accounting entries.

task-1875873
This commit is contained in:
Simon Lejeune
2019-06-10 20:42:56 +00:00
parent 168f4d75ef
commit 8d21d0f888
5 changed files with 548 additions and 434 deletions
+59 -2
View File
@@ -98,11 +98,11 @@ class ProductProduct(models.Model):
'unit_cost': unit_cost,
'quantity': quantity,
}
if self.cost_method in ('average'):
if self.cost_method in ('average', 'fifo'):
vals['remaining_qty'] = quantity
return vals
def _prepare_out_svl_vals(self, quantity):
def _prepare_out_svl_vals(self, quantity, company):
"""Prepare the values for a stock valuation layer created by a delivery.
:param quantity: the quantity to value, expressed in `self.uom_id`
@@ -118,6 +118,11 @@ class ProductProduct(models.Model):
'unit_cost': self.standard_price,
'quantity': quantity,
}
if self.cost_method in ('average', 'fifo'):
fifo_vals = self._run_fifo(abs(quantity), company)
vals['remaining_qty'] = fifo_vals.get('remaining_qty')
if self.cost_method == 'fifo':
vals.update(fifo_vals)
return vals
def _change_standard_price(self, new_price, counterpart_account_id=False):
@@ -198,6 +203,58 @@ class ProductProduct(models.Model):
# Actually update the standard price.
self.write({'standard_price': new_price})
def _run_fifo(self, quantity, company):
self.ensure_one()
# Find back incoming stock valuation layers (called candidates here) to value `quantity`.
qty_to_take_on_candidates = quantity
candidates = self.env['stock.valuation.layer'].search([
('product_id', '=', self.id),
('remaining_qty', '>', 0),
('company_id', '=', company.id),
])
new_standard_price = 0
tmp_value = 0 # to accumulate the value taken on the candidates
for candidate in candidates:
new_standard_price = candidate.unit_cost
qty_taken_on_candidate = min(qty_to_take_on_candidates, candidate.remaining_qty)
value_taken_on_candidate = qty_taken_on_candidate * candidate.unit_cost
candidate_vals = {
'remaining_qty': candidate.remaining_qty - qty_taken_on_candidate,
}
candidate.write(candidate_vals)
qty_to_take_on_candidates -= qty_taken_on_candidate
tmp_value += value_taken_on_candidate
if float_is_zero(qty_to_take_on_candidates, precision_rounding=self.uom_id.rounding):
break
# Update the standard price with the price of the last used candidate, if any.
if new_standard_price and self.cost_method == 'fifo':
self.sudo().with_context(force_company=company.id).standard_price = new_standard_price
# If there's still quantity to value but we're out of candidates, we fall in the
# negative stock use case. We chose to value the out move at the price of the
# last out and a correction entry will be made once `_fifo_vacuum` is called.
vals = {}
if float_is_zero(qty_to_take_on_candidates, precision_rounding=self.uom_id.rounding):
vals = {
'value': -tmp_value,
'unit_cost': tmp_value / quantity,
}
else:
assert qty_to_take_on_candidates > 0
last_fifo_price = new_standard_price or self.standard_price
negative_stock_value = last_fifo_price * -qty_to_take_on_candidates
tmp_value += abs(negative_stock_value)
vals = {
'remaining_qty': -qty_to_take_on_candidates,
'value': -tmp_value,
'unit_cost': last_fifo_price,
}
return vals
# -------------------------------------------------------------------------
# Miscellaneous
# -------------------------------------------------------------------------
+10 -22
View File
@@ -389,26 +389,23 @@ class StockMove(models.Model):
res = super(StockMove, self)._action_done(cancel_backorder=cancel_backorder)
valued_moves_set = self.env['stock.move']
standard_average_valued_moves_set = self.env['stock.move']
for valued_type, valued_move_list in valued_moves.items():
for valued_move in valued_move_list:
if valued_move._is_dropshipped() or valued_move._is_dropshipped_returned():
continue
if valued_move.product_id.cost_method in ('standard', 'average'):
standard_average_valued_moves_set |= valued_move
else:
valued_moves_set |= valued_move
stock_valuation_layers = self.env['stock.valuation.layer']
# Create the valuation layers in batch by calling `moves._create_valued_type_svl`.
for valued_type in self._get_valued_types():
todo_valued_moves = valued_moves[valued_type]
if todo_valued_moves and todo_valued_moves <= standard_average_valued_moves_set:
if todo_valued_moves:
todo_valued_moves._sanity_check_for_valuation()
stock_valuation_layers |= getattr(todo_valued_moves, '_create_%s_svl' % valued_type)()
continue
for move in valued_moves_set:
for stock_valuation_layer in stock_valuation_layers:
if not stock_valuation_layer.product_id.valuation == 'real_time':
continue
stock_valuation_layer.stock_move_id.with_context(svl_id=stock_valuation_layer.id, force_valuation_amount=abs(stock_valuation_layer.value), forced_quantity=stock_valuation_layer.quantity)._account_entry_move()
return res
def _sanity_check_for_valuation(self):
for move in self:
# Apply restrictions on the stock move to be able to make
# consistent accounting entries.
if move._is_in() and move._is_out():
@@ -424,15 +421,6 @@ class StockMove(models.Model):
raise UserError(_("The move lines are not in a consistent states: they do not share the same origin or destination company."))
if company_src and company_dst and company_src.id != company_dst.id:
raise UserError(_("The move lines are not in a consistent states: they are doing an intercompany in a single step while they should go through the intercompany transit location."))
move._run_valuation()
for move in valued_moves_set.filtered(lambda m: m.product_id.valuation == 'real_time'):
move._account_entry_move()
for stock_valuation_layer in stock_valuation_layers:
if not stock_valuation_layer.product_id.valuation == 'real_time':
continue
stock_valuation_layer.stock_move_id.with_context(svl_id=stock_valuation_layer.id, force_valuation_amount=stock_valuation_layer.value, forced_quantity=stock_valuation_layer.quantity)._account_entry_move()
return res
@api.multi
def product_price_update_before_done(self, forced_qty=None):
+13 -8
View File
@@ -26,12 +26,12 @@ class StockMoveLine(models.Model):
self._create_correction_svl(move, diff)
return move_lines
for line in move_lines:
move = line.move_id
if move.state == 'done':
correction_value = move._run_valuation(line.qty_done)
if move.product_id.valuation == 'real_time' and (move._is_in() or move._is_out()):
move.with_context(force_valuation_amount=correction_value)._account_entry_move()
# for line in move_lines:
# move = line.move_id
# if move.state == 'done':
# correction_value = move._run_valuation(line.qty_done)
# if move.product_id.valuation == 'real_time' and (move._is_in() or move._is_out()):
# move.with_context(force_valuation_amount=correction_value)._account_entry_move()
return move_lines
def write(self, vals):
@@ -104,9 +104,14 @@ class StockMoveLine(models.Model):
# -------------------------------------------------------------------------
@api.model
def _create_correction_svl(self, move, diff):
stock_valuation_layers = self.env['stock.valuation.layer']
if move._is_in() and diff > 0 or move._is_out() and diff < 0:
move.product_price_update_before_done(forced_qty=diff)
move._create_in_svl(forced_quantity=abs(diff))
stock_valuation_layers |= move._create_in_svl(forced_quantity=abs(diff))
elif move._is_in() and diff < 0 or move._is_out() and diff > 0:
move._create_out_svl(forced_quantity=abs(diff))
stock_valuation_layers |= move._create_out_svl(forced_quantity=abs(diff))
for stock_valuation_layer in stock_valuation_layers:
if not stock_valuation_layer.product_id.valuation == 'real_time':
continue
stock_valuation_layer.stock_move_id.with_context(svl_id=stock_valuation_layer.id, force_valuation_amount=stock_valuation_layer.value, forced_quantity=stock_valuation_layer.quantity, forced_ref=stock_valuation_layer.description)._account_entry_move()
File diff suppressed because it is too large Load Diff
@@ -296,3 +296,93 @@ class TestStockValuationAVCO(TestStockValuationCommon):
self.assertEqual(self.product1.quantity_svl, 10)
self.assertEqual(self.product1.standard_price, 15)
class TestStockValuationFIFO(TestStockValuationCommon):
def setUp(self):
super(TestStockValuationFIFO, self).setUp()
self.product1.product_tmpl_id.categ_id.property_cost_method = 'fifo'
def test_normal_1(self):
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
move1 = self._make_in_move(self.product1, 10, unit_cost=10)
move2 = self._make_in_move(self.product1, 10, unit_cost=20)
move3 = self._make_out_move(self.product1, 15)
self.assertEqual(self.product1.value_svl, 100)
self.assertEqual(self.product1.quantity_svl, 5)
self.assertEqual(sum(self.product1.stock_valuation_layer_ids.mapped('remaining_qty')), 5)
def test_change_in_past_decrease_in_1(self):
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
move1 = self._make_in_move(self.product1, 20, unit_cost=10)
move2 = self._make_out_move(self.product1, 10)
move1.move_line_ids.qty_done = 10
self.assertEqual(self.product1.value_svl, 0)
self.assertEqual(self.product1.quantity_svl, 0)
@skip('fifo negative')
def test_change_in_past_decrease_in_2(self):
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
move1 = self._make_in_move(self.product1, 20, unit_cost=10)
move2 = self._make_out_move(self.product1, 10)
move3 = self._make_out_move(self.product1, 10)
move1.move_line_ids.qty_done = 10
move1 = self._make_in_move(self.product1, 20, unit_cost=15)
self.assertEqual(self.product1.value_svl, 150)
self.assertEqual(self.product1.quantity_svl, 10)
def test_change_in_past_increase_in_1(self):
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
move1 = self._make_in_move(self.product1, 10, unit_cost=10)
move2 = self._make_in_move(self.product1, 10, unit_cost=15)
move3 = self._make_out_move(self.product1, 20)
move1.move_line_ids.qty_done = 20
self.assertEqual(self.product1.value_svl, 100)
self.assertEqual(self.product1.quantity_svl, 10)
@skip('fifo negative')
def test_change_in_past_increase_in_2(self):
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
move1 = self._make_in_move(self.product1, 10, unit_cost=10)
move2 = self._make_in_move(self.product1, 10, unit_cost=12)
move3 = self._make_out_move(self.product1, 15)
move4 = self._make_out_move(self.product1, 20)
move5 = self._make_in_move(self.product1, 100, unit_cost=15)
move1.move_line_ids.qty_done = 20
self.assertEqual(self.product1.value_svl, 1375)
self.assertEqual(self.product1.quantity_svl, 95)
def test_change_in_past_decrease_out_1(self):
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
move1 = self._make_in_move(self.product1, 20, unit_cost=10)
move2 = self._make_out_move(self.product1, 15)
move3 = self._make_in_move(self.product1, 20, unit_cost=15)
move2.move_line_ids.qty_done = 5
self.assertEqual(self.product1.value_svl, 450)
self.assertEqual(self.product1.quantity_svl, 35)
self.assertEqual(sum(self.product1.stock_valuation_layer_ids.mapped('remaining_qty')), 35)
def test_return_delivery_1(self):
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
move1 = self._make_in_move(self.product1, 10, unit_cost=10)
move2 = self._make_out_move(self.product1, 10, create_picking=True)
move3 = self._make_in_move(self.product1, 10, unit_cost=20)
move4 = self._make_return(move2, 10)
self.assertEqual(self.product1.value_svl, 300)
self.assertEqual(self.product1.quantity_svl, 20)
def test_return_receipt_1(self):
self.product1.product_tmpl_id.categ_id.property_valuation = 'manual_periodic'
move1 = self._make_in_move(self.product1, 10, unit_cost=10, create_picking=True)
move2 = self._make_in_move(self.product1, 10, unit_cost=20)
move3 = self._make_return(move1, 2)
self.assertEqual(self.product1.value_svl, 280)
self.assertEqual(self.product1.quantity_svl, 18)