[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:
@@ -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
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user