From b82c2659c6703f1f25dbeb4f698734e147483a8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80ngel=20=C3=80lvarez?= Date: Wed, 7 Apr 2021 12:25:52 +0200 Subject: [PATCH] Update cost of moves when recomputing product cost issue8795 issue7271 --- product.py | 235 +++++++++++++++--- ...product_cost_fifo_recompute_cost_price.rst | 126 ++++++++++ tests/test_product_cost_fifo.py | 5 + 3 files changed, 331 insertions(+), 35 deletions(-) create mode 100644 tests/scenario_product_cost_fifo_recompute_cost_price.rst diff --git a/product.py b/product.py index 86a6faa..0c204bd 100644 --- a/trytond/trytond/modules/product_cost_fifo/product.py +++ b/trytond/trytond/modules/product_cost_fifo/product.py @@ -1,6 +1,9 @@ # This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. -import datetime +import datetime as dt +from decimal import Decimal + +from trytond.config import config from trytond.transaction import Transaction from trytond.pool import Pool, PoolMeta @@ -21,34 +24,25 @@ class Template(metaclass=PoolMeta): class Product(metaclass=PoolMeta): __name__ = 'product.product' - def _get_available_fifo_moves(self): + def _get_available_fifo_moves(self, date=None, offset=0, limit=None): pool = Pool() Move = pool.get('stock.move') - return Move.search([ - ('product', '=', self.id), - ('state', '=', 'done'), - self._domain_moves_cost, - ('fifo_quantity_available', '>', 0), - ('to_location.type', '=', 'storage'), - ('from_location.type', '!=', 'storage'), - ('to_location.type', '=', 'storage'), - ], order=[('effective_date', 'DESC'), ('id', 'DESC')]) - - def _get_fifo_quantity(self): - pool = Pool() - Location = pool.get('stock.location') - - locations = Location.search([ - ('type', '=', 'storage'), - ]) - stock_date_end = datetime.date.today() - location_ids = [l.id for l in locations] - with Transaction().set_context( - locations=location_ids, - stock_date_end=stock_date_end): - return self.__class__(self.id).quantity - - def get_fifo_move(self, quantity=0.0): + domain = [ + ('product', '=', self.id), + self._domain_moves_cost(), + ('from_location.type', 'in', ['supplier', 'production']), + ('to_location.type', '=', 'storage'), + ] + if not date: + domain.append(('fifo_quantity_available', '>', 0)) + else: + domain.append(('effective_date', '<=', date)) + return Move.search( + domain, + offset=offset, limit=limit, + order=[('effective_date', 'DESC'), ('id', 'DESC')]) + + def get_fifo_move(self, quantity=0.0, date=None): ''' Return a list of (move, qty) where move is the move to be consumed and qty is the quantity (in the product default uom) @@ -58,13 +52,30 @@ class Product(metaclass=PoolMeta): pool = Pool() Uom = pool.get('product.uom') - avail_qty = self._get_fifo_quantity() + avail_qty = self._get_storage_quantity(date=date) + if date: + # On recomputation, we must pretend + # outgoing moves are not yet done. + avail_qty += quantity fifo_moves = [] - moves = self._get_available_fifo_moves() - for move in moves: - qty = Uom.compute_qty(move.uom, - move.fifo_quantity_available, - self.default_uom, round=False) + + size = config.getint('cache', 'record') + + def moves(): + offset, limit = 0, size + while True: + moves = self._get_available_fifo_moves( + date=date, offset=offset, limit=limit) + if not moves: + break + for move in moves: + yield move + offset += size + + for move in moves(): + qty = move.fifo_quantity_available if not date else move.quantity + qty = Uom.compute_qty( + move.uom, qty, self.default_uom, round=False) avail_qty -= qty if avail_qty <= quantity: @@ -78,5 +89,159 @@ class Product(metaclass=PoolMeta): fifo_moves.reverse() return fifo_moves - def recompute_cost_price_fifo(self): - return self.recompute_cost_price_average() + def recompute_cost_price_fifo(self, start=None): + pool = Pool() + Move = pool.get('stock.move') + Currency = pool.get('currency.currency') + Uom = pool.get('product.uom') + digits = self.__class__.cost_price.digits + + domain = [ + ('product', '=', self.id), + self._domain_moves_cost(), + ['OR', + [ + ('to_location.type', '=', 'storage'), + ('from_location.type', '!=', 'storage'), + ], + [ + ('from_location.type', '=', 'storage'), + ('to_location.type', '!=', 'storage'), + ], + ], + ] + if start: + domain.append(('effective_date', '>=', start)) + moves = Move.search( + domain, order=[('effective_date', 'ASC'), ('id', 'ASC')]) + + cost_price = Decimal(0) + quantity = 0 + if start: + domain.remove(('effective_date', '>=', start)) + domain.append(('effective_date', '<', start)) + domain.append( + ('from_location.type', 'in', ['supplier', 'production'])) + prev_moves = Move.search( + domain, + order=[('effective_date', 'DESC'), ('id', 'DESC')], + limit=1) + if prev_moves: + move, = prev_moves + cost_price = move.cost_price + quantity = self._get_storage_quantity( + date=start - dt.timedelta(days=1)) + quantity = Decimal(str(quantity)) + + def in_move(move): + return (move.from_location.type in ['supplier', 'production'] + or move.to_location.type == 'supplier') + + def out_move(move): + return not in_move(move) + + def compute_fifo_cost_price(quantity, date): + fifo_moves = self.get_fifo_move( + float(quantity), + date=current_moves[-1].effective_date) + + cost_price = Decimal(0) + consumed_qty = 0 + for move, move_qty in fifo_moves: + consumed_qty += move_qty + with Transaction().set_context(date=move.effective_date): + unit_price = Currency.compute( + move.currency, move.unit_price, + move.company.currency, round=False) + unit_price = Uom.compute_price( + move.uom, unit_price, move.product.default_uom) + cost_price += unit_price * Decimal(str(move_qty)) + if consumed_qty: + return (cost_price / Decimal(str(consumed_qty))).quantize( + Decimal(str(10.0 ** -digits[1]))) + + current_moves = [] + current_out_qty = 0 + current_cost_price = cost_price + for move in moves: + if (current_moves + and current_moves[-1].effective_date + != move.effective_date): + Move.write([ + m for m in filter(in_move, current_moves) + if m.cost_price != current_cost_price], + dict(cost_price=current_cost_price)) + + out_moves = list(filter(out_move, current_moves)) + if out_moves: + fifo_cost_price = compute_fifo_cost_price( + current_out_qty, current_moves[-1].effective_date) + if fifo_cost_price is None: + fifo_cost_price = current_cost_price + Move.write([ + m for m in out_moves + if m.cost_price != fifo_cost_price], + dict(cost_price=fifo_cost_price)) + if quantity: + cost_price = ( + ((current_cost_price * ( + quantity + current_out_qty)) + - (fifo_cost_price * current_out_qty)) + / quantity) + else: + cost_price = Decimal(0) + current_cost_price = cost_price.quantize( + Decimal(str(10.0 ** -digits[1]))) + current_moves.clear() + current_out_qty = 0 + current_moves.append(move) + + qty = Uom.compute_qty(move.uom, move.quantity, self.default_uom) + qty = Decimal(str(qty)) + if move.from_location.type == 'storage': + qty *= -1 + if in_move(move): + with Transaction().set_context(date=move.effective_date): + unit_price = Currency.compute( + move.currency, move.unit_price, + move.company.currency, round=False) + unit_price = Uom.compute_price( + move.uom, unit_price, self.default_uom) + if quantity + qty > 0 and quantity >= 0: + cost_price = ( + (cost_price * quantity) + (unit_price * qty) + ) / (quantity + qty) + elif qty > 0: + cost_price = unit_price + current_cost_price = cost_price.quantize( + Decimal(str(10.0 ** -digits[1]))) + else: + current_out_qty += -qty + quantity += qty + + Move.write([ + m for m in filter(in_move, current_moves) + if m.cost_price != current_cost_price], + dict(cost_price=current_cost_price)) + + out_moves = list(filter(out_move, current_moves)) + if out_moves: + fifo_cost_price = compute_fifo_cost_price( + current_out_qty, current_moves[-1].effective_date) + if fifo_cost_price is None: + fifo_cost_price = current_cost_price + Move.write([ + m for m in out_moves + if m.cost_price != fifo_cost_price], + dict(cost_price=fifo_cost_price)) + if quantity: + cost_price = ( + ((current_cost_price * ( + quantity + current_out_qty)) + - (fifo_cost_price * current_out_qty)) + / quantity) + else: + cost_price = Decimal(0) + current_cost_price = cost_price.quantize( + Decimal(str(10.0 ** -digits[1]))) + return current_cost_price diff --git a/tests/scenario_product_cost_fifo_recompute_cost_price.rst b/tests/scenario_product_cost_fifo_recompute_cost_price.rst new file mode 100644 index 0000000..f8b952f --- /dev/null +++ b/trytond/trytond/modules/product_cost_fifo/tests/scenario_product_cost_fifo_recompute_cost_price.rst @@ -0,0 +1,126 @@ +====================================== +Product Cost FIFO Recompute Cost Price +====================================== + +Imports:: + + >>> import datetime as dt + >>> from dateutil.relativedelta import relativedelta + >>> from decimal import Decimal + >>> from proteus import Model, Wizard + >>> from trytond.tests.tools import activate_modules + >>> from trytond.modules.company.tests.tools import create_company, \ + ... get_company + >>> today = dt.date.today() + +Install stock Module:: + + >>> config = activate_modules('product_cost_fifo') + +Create company:: + + >>> _ = create_company() + >>> company = get_company() + +Create product:: + + >>> ProductUom = Model.get('product.uom') + >>> ProductTemplate = Model.get('product.template') + >>> unit, = ProductUom.find([('name', '=', 'Unit')]) + + >>> template = ProductTemplate() + >>> template.name = 'Product' + >>> template.default_uom = unit + >>> template.type = 'goods' + >>> template.list_price = Decimal('300') + >>> template.cost_price_method = 'fifo' + >>> product, = template.products + >>> product.cost_price = Decimal('80') + >>> template.save() + >>> product, = template.products + +Get stock locations:: + + >>> Location = Model.get('stock.location') + >>> supplier_loc, = Location.find([('code', '=', 'SUP')]) + >>> storage_loc, = Location.find([('code', '=', 'STO')]) + >>> customer_loc, = Location.find([('code', '=', 'CUS')]) + +Create some moves:: + + >>> StockMove = Model.get('stock.move') + >>> StockMove( + ... product=product, + ... quantity=1, + ... from_location=supplier_loc, + ... to_location=storage_loc, + ... unit_price=Decimal('100'), + ... effective_date=today - dt.timedelta(days=2)).click('do') + >>> StockMove( + ... product=product, + ... quantity=2, + ... from_location=supplier_loc, + ... to_location=storage_loc, + ... unit_price=Decimal('120'), + ... effective_date=today - dt.timedelta(days=1)).click('do') + >>> StockMove( + ... product=product, + ... quantity=2, + ... from_location=storage_loc, + ... to_location=customer_loc, + ... unit_price=Decimal('300'), + ... effective_date=today - dt.timedelta(days=1)).click('do') + >>> StockMove( + ... product=product, + ... quantity=3, + ... from_location=supplier_loc, + ... to_location=storage_loc, + ... unit_price=Decimal('100'), + ... effective_date=today).click('do') + >>> StockMove( + ... product=product, + ... quantity=2, + ... from_location=storage_loc, + ... to_location=customer_loc, + ... unit_price=Decimal('300'), + ... effective_date=today).click('do') + >>> StockMove( + ... product=product, + ... quantity=1, + ... from_location=storage_loc, + ... to_location=customer_loc, + ... unit_price=Decimal('300'), + ... effective_date=today).click('do') + + + >>> [m.cost_price for m in StockMove.find([])] + [Decimal('100.0000'), Decimal('110.0000'), Decimal('105.0000'), Decimal('110.0000'), Decimal('113.3333'), Decimal('100.0000')] + + >>> product.reload() + >>> product.cost_price + Decimal('100.0000') + +Recompute cost price:: + + >>> recompute = Wizard('product.recompute_cost_price', [product]) + >>> recompute.execute('recompute') + + >>> [m.cost_price for m in StockMove.find([])] + [Decimal('106.6667'), Decimal('106.6667'), Decimal('105.0000'), Decimal('110.0000'), Decimal('113.3333'), Decimal('100.0000')] + + >>> product.reload() + >>> product.cost_price + Decimal('99.9999') + +Recompute cost price from a date:: + + >>> recompute = Wizard('product.recompute_cost_price', [product]) + >>> recompute.form.from_ = today - dt.timedelta(days=1) + >>> recompute.execute('recompute') + + >>> [m.cost_price for m in StockMove.find([])] + [Decimal('106.6667'), Decimal('106.6667'), Decimal('105.0000'), Decimal('110.0000'), Decimal('113.3333'), Decimal('100.0000')] + + >>> product.reload() + >>> product.cost_price + Decimal('99.9999') diff --git a/tests/test_product_cost_fifo.py b/tests/test_product_cost_fifo.py index 7b81f5e..5ee6fb6 100644 --- a/trytond/trytond/modules/product_cost_fifo/tests/test_product_cost_fifo.py +++ b/trytond/trytond/modules/product_cost_fifo/tests/test_product_cost_fifo.py @@ -32,4 +32,9 @@ def suite(): tearDown=doctest_teardown, encoding='utf-8', checker=doctest_checker, optionflags=doctest.REPORT_ONLY_FIRST_FAILURE)) + suite.addTests(doctest.DocFileSuite( + 'scenario_product_cost_fifo_recompute_cost_price.rst', + tearDown=doctest_teardown, encoding='utf-8', + checker=doctest_checker, + optionflags=doctest.REPORT_ONLY_FIRST_FAILURE)) return suite -- 2.25.1