From 4b42d9a3aa8ab606584f43f37d1f84fe98f4dc23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=80ngel=20=C3=80lvarez?= Date: Fri, 9 Apr 2021 10:36:06 +0200 Subject: [PATCH] add cost recalculation patches --- cost_price_in_productions_without_inputs.diff | 18 + fifo_quantity_round.diff | 13 +- ...riginal-cost-price-for-returned-move.patch | 126 ++++ ...o-Do-not-update-average-cost-price-i.patch | 52 ++ ...o-Enforce-filling-cost-price-of-move.patch | 36 + ...t_fifo-Use-all-moves-to-compute-FIFO.patch | 189 +++++ ...ifo-update-all-moves-to-compute-fifo.patch | 195 ++++++ ...n-Keep-cost-of-unused-input-products.patch | 136 ++++ ...-moves-when-recomputing-product-cost.patch | 263 +++++++ ...-moves-when-recomputing-product-cost.patch | 427 ++++++++++++ ...riginal-cost-price-for-returned-move.patch | 73 ++ ...riginal-cost-price-for-returned-move.patch | 43 ++ series | 18 +- ...k-Enforce-filling-cost-price-of-move.patch | 69 ++ ...-moves-when-recomputing-product-cost.patch | 657 ++++++++++++++++++ ...riginal-cost-price-for-returned-move.patch | 127 ++++ 16 files changed, 2435 insertions(+), 7 deletions(-) create mode 100644 cost_price_in_productions_without_inputs.diff create mode 100644 product-cost-fifo-Use-original-cost-price-for-returned-move.patch create mode 100644 product_cost_fifo-Do-not-update-average-cost-price-i.patch create mode 100644 product_cost_fifo-Enforce-filling-cost-price-of-move.patch create mode 100644 product_cost_fifo-Use-all-moves-to-compute-FIFO.patch create mode 100644 product_cost_fifo-update-all-moves-to-compute-fifo.patch create mode 100644 production-Keep-cost-of-unused-input-products.patch create mode 100644 production-Update-cost-of-moves-when-recomputing-product-cost.patch create mode 100644 production_cost_fifo-Update-cost-of-moves-when-recomputing-product-cost.patch create mode 100644 sale-Use-original-cost-price-for-returned-move.patch create mode 100644 sale_supply_drop_shippment-Use-original-cost-price-for-returned-move.patch create mode 100644 stock-Enforce-filling-cost-price-of-move.patch create mode 100644 stock-Update-cost-of-moves-when-recomputing-product-cost.patch create mode 100644 stock-Use-original-cost-price-for-returned-move.patch diff --git a/cost_price_in_productions_without_inputs.diff b/cost_price_in_productions_without_inputs.diff new file mode 100644 index 0000000..c1584e2 --- /dev/null +++ b/cost_price_in_productions_without_inputs.diff @@ -0,0 +1,18 @@ +diff --git a/production.py b/production.py +index afdcdc3..85e6ec5 100644 +--- a/trytond/trytond/modules/production/production.py ++++ b/trytond/trytond/modules/production/production.py +@@ -467,6 +467,13 @@ class Production(Workflow, ModelSQL, ModelView): + if move.production_input not in productions: + cls.__queue__.set_cost([move.production_input]) + productions.add(move.production_input) ++ pending_productions = cls.search([ ++ ('inputs', '=', None), ++ ('state', '!=', 'cancel'), ++ ]) ++ for production in pending_productions: ++ cls.__queue__.set_cost([production]) ++ productions.add(production) + Move.write(moves, {'production_cost_price_updated': False}) + + @classmethod diff --git a/fifo_quantity_round.diff b/fifo_quantity_round.diff index 7b644ed..b158edb 100644 --- a/fifo_quantity_round.diff +++ b/fifo_quantity_round.diff @@ -1,13 +1,14 @@ diff --git a/move.py b/move.py -index 94fa17d..7e4d733 100644 +index 1b41353..85c89eb 100644 --- a/trytond/trytond/modules/product_cost_fifo/move.py +++ b/trytond/trytond/modules/product_cost_fifo/move.py -@@ -63,7 +63,7 @@ class Move(metaclass=PoolMeta): - cost_price += move_unit_price * Decimal(str(move_qty)) - +@@ -105,6 +105,9 @@ class Move(metaclass=PoolMeta): move_qty = Uom.compute_qty(self.product.default_uom, move_qty, -- move.uom, round=False) -+ move.uom, round=True) + move.uom, round=False) move.fifo_quantity = (move.fifo_quantity or 0.0) + move_qty ++ # Due to float, the fifo quantity result can exceed the quantity. ++ assert move.quantity >= move.fifo_quantity - move.uom.rounding ++ move.fifo_quantity = min(move.fifo_quantity, move.quantity) to_save.append(move) if to_save: + # TODO save in do method when product change diff --git a/product-cost-fifo-Use-original-cost-price-for-returned-move.patch b/product-cost-fifo-Use-original-cost-price-for-returned-move.patch new file mode 100644 index 0000000..170f263 --- /dev/null +++ b/product-cost-fifo-Use-original-cost-price-for-returned-move.patch @@ -0,0 +1,126 @@ +From d23677f089ff7e283e30a1aaa0e1ed2e42c31fa9 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?=C3=80ngel=20=C3=80lvarez?= +Date: Thu, 8 Apr 2021 11:53:18 +0200 +Subject: [PATCH] Use original cost price for returned move + +and factorize the cost price of move for cost computation + +issue9440 +review327491003 +--- + move.py | 23 +++++------------------ + product.py | 23 ++--------------------- + 2 files changed, 7 insertions(+), 39 deletions(-) + +diff --git a/move.py b/move.py +index 9e20a1d..423a4bd 100644 +--- a/trytond/trytond/modules/product_cost_fifo/move.py ++++ b/trytond/trytond/modules/product_cost_fifo/move.py +@@ -68,7 +68,6 @@ class Move(metaclass=PoolMeta): + ''' + pool = Pool() + Uom = pool.get('product.uom') +- Currency = pool.get('currency.currency') + + total_qty = Uom.compute_qty(self.uom, self.quantity, + self.product.default_uom, round=False) +@@ -81,16 +80,7 @@ class Move(metaclass=PoolMeta): + to_save = [] + for move, move_qty in fifo_moves: + consumed_qty += move_qty +- if move.from_location.type in {'supplier', 'production'}: +- with Transaction().set_context(date=move.effective_date): +- move_unit_price = Currency.compute( +- move.currency, move.unit_price, +- self.company.currency, round=False) +- move_unit_price = Uom.compute_price( +- move.uom, move_unit_price, move.product.default_uom) +- else: +- move_unit_price = move.cost_price or 0 +- cost_price += move_unit_price * Decimal(str(move_qty)) ++ cost_price += move.get_cost_price() * Decimal(str(move_qty)) + + move_qty = Uom.compute_qty(self.product.default_uom, move_qty, + move.uom, round=False) +@@ -110,12 +100,9 @@ class Move(metaclass=PoolMeta): + 'cost_price', **self._cost_price_pattern) + + # Compute average cost price +- unit_price = self.unit_price +- self.unit_price = Uom.compute_price( +- self.product.default_uom, cost_price, self.uom) +- average_cost_price = self._compute_product_cost_price('out') +- self.unit_price = unit_price +- ++ average_cost_price = self._compute_product_cost_price( ++ 'out', product_cost_price=cost_price) ++ + if cost_price: + digits = self.__class__.cost_price.digits + cost_price = cost_price.quantize( +@@ -126,7 +113,7 @@ class Move(metaclass=PoolMeta): + + def _do(self): + cost_price = super(Move, self)._do() +- if (self.from_location.type in ('supplier', 'production') ++ if (self.from_location.type != 'storage' + and self.to_location.type == 'storage' + and self.product.cost_price_method == 'fifo'): + cost_price = self._compute_product_cost_price('in') +diff --git a/product.py b/product.py +index b7e25bb..13e7d95 100644 +--- a/trytond/trytond/modules/product_cost_fifo/product.py ++++ b/trytond/trytond/modules/product_cost_fifo/product.py +@@ -4,7 +4,6 @@ import datetime as dt + from decimal import Decimal + + from trytond.config import config +-from trytond.transaction import Transaction + from trytond.pool import Pool, PoolMeta + + __all__ = ['Template', 'Product'] +@@ -92,7 +91,6 @@ class Product(metaclass=PoolMeta): + 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 + +@@ -148,16 +146,7 @@ class Product(metaclass=PoolMeta): + consumed_qty = 0 + for move, move_qty in fifo_moves: + consumed_qty += move_qty +- if move.from_location.type in {'supplier', 'production'}: +- 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) +- else: +- unit_price = move.cost_price or 0 +- cost_price += unit_price * Decimal(str(move_qty)) ++ cost_price += move.get_cost_price() * Decimal(str(move_qty)) + if consumed_qty: + return (cost_price / Decimal(str(consumed_qty))).quantize( + Decimal(str(10.0 ** -digits[1]))) +@@ -203,15 +192,7 @@ class Product(metaclass=PoolMeta): + if move.from_location.type == 'storage': + qty *= -1 + if in_move(move): +- if move.from_location.type in {'supplier', 'production'}: +- 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) +- else: +- unit_price = cost_price ++ unit_price = move.get_cost_price(product_cost_price=cost_price) + if quantity + qty > 0 and quantity >= 0: + cost_price = ( + (cost_price * quantity) + (unit_price * qty) +-- +2.25.1 diff --git a/product_cost_fifo-Do-not-update-average-cost-price-i.patch b/product_cost_fifo-Do-not-update-average-cost-price-i.patch new file mode 100644 index 0000000..0e3ff84 --- /dev/null +++ b/product_cost_fifo-Do-not-update-average-cost-price-i.patch @@ -0,0 +1,52 @@ +From 017277e73dbe9120a983d4c3bf27a7bc3e5ee9e6 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?=C3=80ngel=20=C3=80lvarez?= +Date: Thu, 8 Apr 2021 12:07:12 +0200 +Subject: [PATCH] product_cost_fifo: Do not update average cost price if the + quantity is negative + +This is the same test done in Move._compute_product_cost_price. When the stock +quantity is below zero, the average cost price should not change. + +issue9484 +--- + product.py | 11 +++++++---- + 1 file changed, 7 insertions(+), 4 deletions(-) + +diff --git a/product.py b/product.py +index 13e7d95..415d833 100644 +--- a/trytond/trytond/modules/product_cost_fifo/product.py ++++ b/trytond/trytond/modules/product_cost_fifo/product.py +@@ -138,9 +138,7 @@ class Product(metaclass=PoolMeta): + return move.from_location.type == 'storage' + + def compute_fifo_cost_price(quantity, date): +- fifo_moves = self.get_fifo_move( +- float(quantity), +- date=current_moves[-1].effective_date) ++ fifo_moves = self.get_fifo_move(float(quantity), date=date) + + cost_price = Decimal(0) + consumed_qty = 0 +@@ -151,6 +149,11 @@ class Product(metaclass=PoolMeta): + return (cost_price / Decimal(str(consumed_qty))).quantize( + Decimal(str(10.0 ** -digits[1]))) + ++ # For each day, process the incoming moves first ++ # in order to keep quantity positive where possible ++ # We do not re-browse because we expect only small changes ++ moves = sorted(moves, key=lambda m: ( ++ m.effective_date, out_move(m), m.id)) + current_moves = [] + current_out_qty = 0 + current_cost_price = cost_price +@@ -173,7 +176,7 @@ class Product(metaclass=PoolMeta): + m for m in out_moves + if m.cost_price != fifo_cost_price], + dict(cost_price=fifo_cost_price)) +- if quantity: ++ if quantity > 0 and quantity + current_out_qty >= 0: + cost_price = ( + ((current_cost_price * ( + quantity + current_out_qty)) +-- +2.25.1 diff --git a/product_cost_fifo-Enforce-filling-cost-price-of-move.patch b/product_cost_fifo-Enforce-filling-cost-price-of-move.patch new file mode 100644 index 0000000..72dcbea --- /dev/null +++ b/product_cost_fifo-Enforce-filling-cost-price-of-move.patch @@ -0,0 +1,36 @@ +From 75cb0d7a6bbadbef4b133772454be2f33118db7f Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?=C3=80ngel=20=C3=80lvarez?= +Date: Thu, 8 Apr 2021 12:11:17 +0200 +Subject: [PATCH] Enforce filling cost price of move + +We set cost price only for outgoing or incoming moves. + +issue9397 +--- + move.py | 4 ++-- + 1 file changed, 2 insertions(+), 2 deletions(-) + +diff --git a/move.py b/move.py +index 423a4bd..f4a7b61 100644 +--- a/trytond/trytond/modules/product_cost_fifo/move.py ++++ b/trytond/trytond/modules/product_cost_fifo/move.py +@@ -102,7 +102,7 @@ class Move(metaclass=PoolMeta): + # Compute average cost price + average_cost_price = self._compute_product_cost_price( + 'out', product_cost_price=cost_price) +- ++ + if cost_price: + digits = self.__class__.cost_price.digits + cost_price = cost_price.quantize( +@@ -126,7 +126,7 @@ class Move(metaclass=PoolMeta): + and self.product.cost_price_method == 'fifo'): + fifo_cost_price, cost_price = ( + self._update_fifo_out_product_cost_price()) +- if self.cost_price is None: ++ if self.cost_price_required and self.cost_price is None: + self.cost_price = fifo_cost_price + return cost_price + +-- +2.25.1 diff --git a/product_cost_fifo-Use-all-moves-to-compute-FIFO.patch b/product_cost_fifo-Use-all-moves-to-compute-FIFO.patch new file mode 100644 index 0000000..00c3d01 --- /dev/null +++ b/product_cost_fifo-Use-all-moves-to-compute-FIFO.patch @@ -0,0 +1,189 @@ +From eaae8d1ba4909042e276065d106c47e9117b2a13 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?=C3=80ngel=20=C3=80lvarez?= +Date: Thu, 8 Apr 2021 10:40:42 +0200 +Subject: [PATCH] product_cost_fifo: Use all moves to compute FIFO + +We must consider any incoming moves (ex: inventory) for the computation of the +FIFO otherwise the back computation to find first in moves does not pick enough +moves. As not all incoming moves have a unit price, we use the current cost +price as fallback. +Also in re-computation, the in or out move test should only rely on the storage +location usage in order to properly compute the average cost. + +issue9274 + +product_cost_fifo: Keep last cost price when quantity is zero + +When there is no quantity in stock, we keep the last cost price instead of +setting it to zero. This gives the same behavior between on move computation +and on product recomputation. + +issue9443 +--- + product.py | 41 +++++++++++-------- + ...product_cost_fifo_recompute_cost_price.rst | 23 +++++++---- + 2 files changed, 38 insertions(+), 26 deletions(-) + +diff --git a/product.py b/product.py +index 0c204bd..b7e25bb 100644 +--- a/trytond/trytond/modules/product_cost_fifo/product.py ++++ b/trytond/trytond/modules/product_cost_fifo/product.py +@@ -30,7 +30,7 @@ class Product(metaclass=PoolMeta): + domain = [ + ('product', '=', self.id), + self._domain_moves_cost(), +- ('from_location.type', 'in', ['supplier', 'production']), ++ ('from_location.type', '!=', 'storage'), + ('to_location.type', '=', 'storage'), + ] + if not date: +@@ -134,11 +134,10 @@ class Product(metaclass=PoolMeta): + quantity = Decimal(str(quantity)) + + def in_move(move): +- return (move.from_location.type in ['supplier', 'production'] +- or move.to_location.type == 'supplier') ++ return move.to_location.type == 'storage' + + def out_move(move): +- return not in_move(move) ++ return move.from_location.type == 'storage' + + def compute_fifo_cost_price(quantity, date): + fifo_moves = self.get_fifo_move( +@@ -149,12 +148,15 @@ class Product(metaclass=PoolMeta): + 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) ++ if move.from_location.type in {'supplier', 'production'}: ++ 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) ++ else: ++ unit_price = move.cost_price or 0 + cost_price += unit_price * Decimal(str(move_qty)) + if consumed_qty: + return (cost_price / Decimal(str(consumed_qty))).quantize( +@@ -189,7 +191,7 @@ class Product(metaclass=PoolMeta): + - (fifo_cost_price * current_out_qty)) + / quantity) + else: +- cost_price = Decimal(0) ++ cost_price = current_cost_price + current_cost_price = cost_price.quantize( + Decimal(str(10.0 ** -digits[1]))) + current_moves.clear() +@@ -201,12 +203,15 @@ class Product(metaclass=PoolMeta): + 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 move.from_location.type in {'supplier', 'production'}: ++ 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) ++ else: ++ unit_price = cost_price + if quantity + qty > 0 and quantity >= 0: + cost_price = ( + (cost_price * quantity) + (unit_price * qty) +@@ -215,7 +220,7 @@ class Product(metaclass=PoolMeta): + cost_price = unit_price + current_cost_price = cost_price.quantize( + Decimal(str(10.0 ** -digits[1]))) +- else: ++ elif out_move(move): + current_out_qty += -qty + quantity += qty + +diff --git a/tests/scenario_product_cost_fifo_recompute_cost_price.rst b/tests/scenario_product_cost_fifo_recompute_cost_price.rst +index f8b952f..ff76174 100644 +--- a/trytond/trytond/modules/product_cost_fifo/tests/scenario_product_cost_fifo_recompute_cost_price.rst ++++ b/trytond/trytond/modules/product_cost_fifo/tests/scenario_product_cost_fifo_recompute_cost_price.rst +@@ -45,6 +45,7 @@ Get stock locations:: + >>> supplier_loc, = Location.find([('code', '=', 'SUP')]) + >>> storage_loc, = Location.find([('code', '=', 'STO')]) + >>> customer_loc, = Location.find([('code', '=', 'CUS')]) ++ >>> lost_found, = Location.find([('name', '=', "Lost and Found")]) + + Create some moves:: + +@@ -65,6 +66,12 @@ Create some moves:: + ... effective_date=today - dt.timedelta(days=1)).click('do') + >>> StockMove( + ... product=product, ++ ... quantity=1, ++ ... from_location=lost_found, ++ ... to_location=storage_loc, ++ ... effective_date=today - dt.timedelta(days=1)).click('do') ++ >>> StockMove( ++ ... product=product, + ... quantity=2, + ... from_location=storage_loc, + ... to_location=customer_loc, +@@ -88,17 +95,16 @@ Create some moves:: + ... product=product, + ... quantity=1, + ... from_location=storage_loc, +- ... to_location=customer_loc, +- ... unit_price=Decimal('300'), ++ ... to_location=lost_found, + ... 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')] ++ [Decimal('100.0000'), Decimal('116.6666'), Decimal('106.6666'), Decimal('110.0000'), Decimal('113.3333'), Decimal('113.3333'), Decimal('100.0000')] + + >>> product.reload() + >>> product.cost_price +- Decimal('100.0000') ++ Decimal('99.9998') + + Recompute cost price:: + +@@ -106,11 +112,12 @@ Recompute cost price:: + >>> 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')] ++ [Decimal('111.1111'), Decimal('111.1111'), Decimal('106.6666'), Decimal('110.0000'), Decimal('113.3333'), Decimal('113.3333'), Decimal('100.0000')] + + >>> product.reload() + >>> product.cost_price +- Decimal('99.9999') ++ Decimal('99.9998') ++ + + Recompute cost price from a date:: + +@@ -119,8 +126,8 @@ Recompute cost price from a date:: + >>> 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')] ++ [Decimal('111.1111'), Decimal('111.1111'), Decimal('106.6666'), Decimal('110.0000'), Decimal('113.3333'), Decimal('113.3333'), Decimal('100.0000')] + + >>> product.reload() + >>> product.cost_price +- Decimal('99.9999') ++ Decimal('99.9998') +-- +2.25.1 diff --git a/product_cost_fifo-update-all-moves-to-compute-fifo.patch b/product_cost_fifo-update-all-moves-to-compute-fifo.patch new file mode 100644 index 0000000..0bfc0ae --- /dev/null +++ b/product_cost_fifo-update-all-moves-to-compute-fifo.patch @@ -0,0 +1,195 @@ +# HG changeset patch +# User Cédric Krier +Use all moves to compute FIFO + +product_cost_fifo: Use all moves to compute FIFO + +We must consider any incoming moves (ex: inventory) for the computation of the +FIFO otherwise the back computation to find first in moves does not pick enough +moves. As not all incoming moves have a unit price, we use the current cost +price as fallback. +Also in re-computation, the in or out move test should only rely on the storage +location usage in order to properly compute the average cost. + +issue9274 + +review321471002 + +Index: move.py +=================================================================== +--- a/trytond/trytond/modules/product_cost_fifo/move.py ++++ b/trytond/trytond/modules/product_cost_fifo/move.py +@@ -81,13 +81,15 @@ + to_save = [] + for move, move_qty in fifo_moves: + consumed_qty += move_qty +- +- with Transaction().set_context(date=move.effective_date): +- move_unit_price = Currency.compute( +- move.currency, move.unit_price, +- self.company.currency, round=False) +- move_unit_price = Uom.compute_price(move.uom, move_unit_price, +- move.product.default_uom) ++ if move.from_location.type in {'supplier', 'production'}: ++ with Transaction().set_context(date=move.effective_date): ++ move_unit_price = Currency.compute( ++ move.currency, move.unit_price, ++ self.company.currency, round=False) ++ move_unit_price = Uom.compute_price( ++ move.uom, move_unit_price, move.product.default_uom) ++ else: ++ move_unit_price = move.cost_price or 0 + cost_price += move_unit_price * Decimal(str(move_qty)) + + move_qty = Uom.compute_qty(self.product.default_uom, move_qty, +Index: product.py +=================================================================== +--- a/trytond/trytond/modules/product_cost_fifo/product.py ++++ b/trytond/trytond/modules/product_cost_fifo/product.py +@@ -31,7 +31,7 @@ + domain = [ + ('product', '=', self.id), + self._domain_moves_cost(), +- ('from_location.type', 'in', ['supplier', 'production']), ++ ('from_location.type', '!=', 'storage'), + ('to_location.type', '=', 'storage'), + ] + if not date: +@@ -137,11 +137,10 @@ + quantity = Decimal(str(quantity)) + + def in_move(move): +- return (move.from_location.type in ['supplier', 'production'] +- or move.to_location.type == 'supplier') ++ return move.to_location.type == 'storage' + + def out_move(move): +- return not in_move(move) ++ return move.from_location.type == 'storage' + + def compute_fifo_cost_price(quantity, date): + fifo_moves = self.get_fifo_move( +@@ -152,12 +151,15 @@ + 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) ++ if move.from_location.type in {'supplier', 'production'}: ++ 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) ++ else: ++ unit_price = move.cost_price or 0 + cost_price += unit_price * Decimal(str(move_qty)) + if consumed_qty: + return round_price(cost_price / Decimal(str(consumed_qty))) +@@ -204,12 +206,15 @@ + 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 move.from_location.type in {'supplier', 'production'}: ++ 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) ++ else: ++ unit_price = cost_price + if quantity + qty > 0 and quantity >= 0: + cost_price = ( + (cost_price * quantity) + (unit_price * qty) +@@ -217,7 +222,7 @@ + elif qty > 0: + cost_price = unit_price + current_cost_price = round_price(cost_price) +- else: ++ elif out_move(move): + current_out_qty += -qty + quantity += qty + +Index: tests/scenario_product_cost_fifo_recompute_cost_price.rst +=================================================================== +--- a/trytond/trytond/modules/product_cost_fifo/tests/scenario_product_cost_fifo_recompute_cost_price.rst ++++ b/trytond/trytond/modules/product_cost_fifo/tests/scenario_product_cost_fifo_recompute_cost_price.rst +@@ -45,6 +45,7 @@ + >>> supplier_loc, = Location.find([('code', '=', 'SUP')]) + >>> storage_loc, = Location.find([('code', '=', 'STO')]) + >>> customer_loc, = Location.find([('code', '=', 'CUS')]) ++ >>> lost_found, = Location.find([('name', '=', "Lost and Found")]) + + Create some moves:: + +@@ -65,6 +66,12 @@ + ... effective_date=today - dt.timedelta(days=1)).click('do') + >>> StockMove( + ... product=product, ++ ... quantity=1, ++ ... from_location=lost_found, ++ ... to_location=storage_loc, ++ ... effective_date=today - dt.timedelta(days=1)).click('do') ++ >>> StockMove( ++ ... product=product, + ... quantity=2, + ... from_location=storage_loc, + ... to_location=customer_loc, +@@ -88,17 +95,16 @@ + ... product=product, + ... quantity=1, + ... from_location=storage_loc, +- ... to_location=customer_loc, +- ... unit_price=Decimal('300'), ++ ... to_location=lost_found, + ... 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')] ++ [Decimal('100.0000'), Decimal('116.6666'), Decimal('106.6666'), Decimal('110.0000'), Decimal('113.3333'), Decimal('113.3333'), Decimal('100.0000')] + + >>> product.reload() + >>> product.cost_price +- Decimal('100.0000') ++ Decimal('99.9998') + + Recompute cost price:: + +@@ -106,11 +112,11 @@ + >>> 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')] ++ [Decimal('111.1111'), Decimal('111.1111'), Decimal('106.6666'), Decimal('110.0000'), Decimal('113.3333'), Decimal('113.3333'), Decimal('100.0000')] + + >>> product.reload() + >>> product.cost_price +- Decimal('99.9998') ++ Decimal('100.0000') + + Recompute cost price from a date:: + +@@ -119,8 +125,8 @@ + >>> 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')] ++ [Decimal('111.1111'), Decimal('111.1111'), Decimal('106.6666'), Decimal('110.0000'), Decimal('113.3333'), Decimal('113.3333'), Decimal('100.0000')] + + >>> product.reload() + >>> product.cost_price +- Decimal('99.9998') ++ Decimal('100.0000') diff --git a/production-Keep-cost-of-unused-input-products.patch b/production-Keep-cost-of-unused-input-products.patch new file mode 100644 index 0000000..1614e43 --- /dev/null +++ b/production-Keep-cost-of-unused-input-products.patch @@ -0,0 +1,136 @@ +From a5edc544456d361175a4de1ed2fc12948cd67118 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?=C3=80ngel=20=C3=80lvarez?= +Date: Thu, 8 Apr 2021 09:15:58 +0200 +Subject: [PATCH] Keep cost of unused input products + +We set the average cost price of the input products as the unit price of the +output of the same product. This way the cost price computation for this +product does not change. + +issue9637 +review316051002 +--- + production.py | 85 +++++++++++++++++++++++++++++++++++++++++++-------- + 1 file changed, 73 insertions(+), 12 deletions(-) + +diff --git a/production.py b/production.py +index 2bd52d1..13ec112 100644 +--- a/trytond/trytond/modules/production/production.py ++++ b/trytond/trytond/modules/production/production.py +@@ -1,7 +1,7 @@ + # This file is part of Tryton. The COPYRIGHT file at the top level of + # this repository contains the full copyright notices and license terms. + from decimal import Decimal +- ++from collections import defaultdict + from sql import Null + + from trytond.i18n import gettext +@@ -21,6 +21,12 @@ BOM_CHANGES = ['bom', 'product', 'quantity', 'uom', 'warehouse', 'location', + 'company', 'inputs', 'outputs'] + + ++ ++def round_price(value, rounding=None): ++ "Round price using the price digits" ++ return value.quantize( ++ Decimal(1) / 10 ** price_digits[1], rounding=rounding) ++ + class Production(Workflow, ModelSQL, ModelView): + "Production" + __name__ = 'production' +@@ -461,26 +467,81 @@ class Production(Workflow, ModelSQL, ModelView): + productions.add(move.production_input) + Move.write(moves, {'production_cost_price_updated': False}) + ++ @property ++ def _list_price_context(self): ++ return { ++ 'company': self.company.id, ++ } ++ + @classmethod + def set_cost(cls, productions): + pool = Pool() + Uom = pool.get('product.uom') + Move = pool.get('stock.move') + +- digits = Move.unit_price.digits +- digit = Decimal(str(10 ** -digits[1])) + moves = [] + for production in productions: +- if not production.quantity or not production.uom: +- continue +- if production.company.currency.is_zero( +- production.cost - production.output_cost): +- continue +- unit_price = production.cost / Decimal(str(production.quantity)) ++ sum_ = Decimal(0) ++ prices = {} ++ cost = production.cost ++ ++ input_quantities = defaultdict(Decimal) ++ input_costs = defaultdict(Decimal) ++ for input_ in production.inputs: ++ if input_.cost_price is not None: ++ cost_price = input_.cost_price ++ else: ++ cost_price = input_.product.cost_price ++ input_quantities[input_.product] += ( ++ Decimal(str(input_.internal_quantity))) ++ input_costs[input_.product] += ( ++ Decimal(str(input_.internal_quantity)) * cost_price) ++ outputs = [] + for output in production.outputs: +- if output.product == production.product: +- output.unit_price = Uom.compute_price( +- production.uom, unit_price, output.uom).quantize(digit) ++ product = output.product ++ if input_quantities.get(output.product): ++ cost_price = ( ++ input_costs[product] / input_quantities[product]) ++ unit_price = round_price(Uom.compute_price( ++ product.default_uom, cost_price, output.uom)) ++ if output.unit_price != unit_price: ++ output.unit_price = unit_price ++ moves.append(output) ++ cost -= min( ++ unit_price * Decimal(str(output.quantity)), cost) ++ else: ++ outputs.append(output) ++ ++ for output in outputs: ++ product = output.product ++ with Transaction().set_context(production._list_price_context): ++ list_price = product.list_price_used ++ product_price = (Decimal(str(output.quantity)) ++ * Uom.compute_price( ++ product.default_uom, list_price, output.uom)) ++ prices[output] = product_price ++ sum_ += product_price ++ ++ if not sum_ and production.product: ++ prices.clear() ++ for output in outputs: ++ if output.product == production.product: ++ quantity = Uom.compute_qty( ++ output.uom, output.quantity, ++ output.product.default_uom, round=False) ++ quantity = Decimal(str(quantity)) ++ prices[output] = quantity ++ sum_ += quantity ++ ++ for output in outputs: ++ if sum_: ++ ratio = prices.get(output, 0) / sum_ ++ else: ++ ratio = Decimal(1) / len(outputs) ++ quantity = Decimal(str(output.quantity)) ++ unit_price = round_price(cost * ratio / quantity) ++ if output.unit_price != unit_price: ++ output.unit_price = unit_price + moves.append(output) + Move.save(moves) + +-- +2.25.1 diff --git a/production-Update-cost-of-moves-when-recomputing-product-cost.patch b/production-Update-cost-of-moves-when-recomputing-product-cost.patch new file mode 100644 index 0000000..4e1306e --- /dev/null +++ b/production-Update-cost-of-moves-when-recomputing-product-cost.patch @@ -0,0 +1,263 @@ +From cda3ebad04807a8a059ffbe5e57082b0c92fb7b0 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?=C3=80ngel=20=C3=80lvarez?= +Date: Wed, 7 Apr 2021 12:24:55 +0200 +Subject: [PATCH] Update cost of moves when recomputing product cost + +issue8795 +issue7271 +--- + __init__.py | 2 + + ir.py | 14 ++++ + production.py | 16 ++++ + production.xml | 5 ++ + stock.py | 21 +++++ + tests/scenario_production_set_cost.rst | 105 +++++++++++++++++++++++++ + tests/test_production.py | 4 + + 7 files changed, 167 insertions(+) + create mode 100644 ir.py + create mode 100644 tests/scenario_production_set_cost.rst + +diff --git a/__init__.py b/__init__.py +index b8909ab..94292a3 100644 +--- a/trytond/trytond/modules/production/__init__.py ++++ b/trytond/trytond/modules/production/__init__.py +@@ -7,6 +7,7 @@ from .bom import * + from .product import * + from .production import * + from .stock import * ++from . import ir + + + def register(): +@@ -27,6 +28,7 @@ def register(): + ProductionLeadTime, + Location, + Move, ++ ir.Cron, + module='production', type_='model') + Pool.register( + Assign, +diff --git a/ir.py b/ir.py +new file mode 100644 +index 0000000..3ccc082 +--- a/trytond/trytond/modules/production/ir.py ++++ b/trytond/trytond/modules/production/ir.py +@@ -0,0 +1,14 @@ ++# This file is part of Tryton. The COPYRIGHT file at the top level of ++# this repository contains the full copyright notices and license terms. ++from trytond.pool import PoolMeta ++ ++ ++class Cron(metaclass=PoolMeta): ++ __name__ = 'ir.cron' ++ ++ @classmethod ++ def __setup__(cls): ++ super().__setup__() ++ cls.method.selection.extend([ ++ ('production|set_cost_from_moves', "Set Cost from Moves"), ++ ]) +diff --git a/production.py b/production.py +index acd7a35..2bd52d1 100644 +--- a/trytond/trytond/modules/production/production.py ++++ b/trytond/trytond/modules/production/production.py +@@ -445,6 +445,22 @@ class Production(Workflow, ModelSQL, ModelView): + move.save() + self._set_move_planned_date() + ++ @classmethod ++ def set_cost_from_moves(cls): ++ pool = Pool() ++ Move = pool.get('stock.move') ++ productions = set() ++ moves = Move.search([ ++ ('production_cost_price_updated', '=', True), ++ ('production_input', '!=', None), ++ ], ++ order=[('effective_date', 'ASC')]) ++ for move in moves: ++ if move.production_input not in productions: ++ cls.__queue__.set_cost([move.production_input]) ++ productions.add(move.production_input) ++ Move.write(moves, {'production_cost_price_updated': False}) ++ + @classmethod + def set_cost(cls, productions): + pool = Pool() +diff --git a/production.xml b/production.xml +index e67fbb5..2f46f8c 100644 +--- a/trytond/trytond/modules/production/production.xml ++++ b/trytond/trytond/modules/production/production.xml +@@ -305,5 +305,10 @@ this repository contains the full copyright notices and license terms. --> + assign_failed_form + + ++ ++ production|set_cost_from_moves ++ ++ days ++ + + +diff --git a/stock.py b/stock.py +index 092cdba..0b8bbc3 100644 +--- a/trytond/trytond/modules/production/stock.py ++++ b/trytond/trytond/modules/production/stock.py +@@ -32,6 +32,12 @@ class Move(metaclass=PoolMeta): + readonly=True, select=True, ondelete='CASCADE', + domain=[('company', '=', Eval('company'))], + depends=['company']) ++ production_cost_price_updated = fields.Boolean( ++ "Cost Price Updated", readonly=True, ++ states={ ++ 'invisible': ~Eval('production_input') & (Eval('state') == 'done'), ++ }, ++ depends=['production_input', 'state']) + + def set_effective_date(self): + if not self.effective_date and self.production_input: +@@ -39,3 +45,18 @@ class Move(metaclass=PoolMeta): + if not self.effective_date and self.production_output: + self.effective_date = self.production_output.effective_date + super(Move, self).set_effective_date() ++ ++ @classmethod ++ def write(cls, *args): ++ super().write(*args) ++ cost_price_update = [] ++ actions = iter(args) ++ for moves, values in zip(actions, actions): ++ for move in moves: ++ if (move.state == 'done' ++ and move.production_input ++ and 'cost_price' in values): ++ cost_price_update.append(move) ++ if cost_price_update: ++ cls.write( ++ cost_price_update, {'production_cost_price_updated': True}) +diff --git a/tests/scenario_production_set_cost.rst b/tests/scenario_production_set_cost.rst +new file mode 100644 +index 0000000..0117fba +--- /dev/null ++++ b/trytond/trytond/modules/production/tests/scenario_production_set_cost.rst +@@ -0,0 +1,105 @@ ++=================== ++Production Set Cost ++=================== ++ ++Imports:: ++ ++ >>> 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 ++ ++Install production Module:: ++ ++ >>> config = activate_modules('production') ++ ++Create company:: ++ ++ >>> _ = create_company() ++ >>> company = get_company() ++ ++Create main product:: ++ ++ >>> ProductUom = Model.get('product.uom') ++ >>> unit, = ProductUom.find([('name', '=', 'Unit')]) ++ >>> ProductTemplate = Model.get('product.template') ++ ++ >>> template = ProductTemplate() ++ >>> template.name = 'product' ++ >>> template.default_uom = unit ++ >>> template.type = 'goods' ++ >>> template.producible = True ++ >>> template.list_price = Decimal(20) ++ >>> template.save() ++ >>> product, = template.products ++ ++Create component:: ++ ++ >>> template = ProductTemplate() ++ >>> template.name = 'component' ++ >>> template.default_uom = unit ++ >>> template.type = 'goods' ++ >>> template.save() ++ >>> component, = template.products ++ >>> component.cost_price = Decimal(5) ++ >>> component.save() ++ ++Create Bill of Material:: ++ ++ >>> BOM = Model.get('production.bom') ++ >>> bom = BOM(name='product') ++ >>> input = bom.inputs.new() ++ >>> input.product = component ++ >>> input.quantity = 2 ++ >>> output = bom.outputs.new() ++ >>> output.product = product ++ >>> output.quantity = 1 ++ >>> bom.save() ++ ++Make a production:: ++ ++ >>> Production = Model.get('production') ++ >>> production = Production() ++ >>> production.product = product ++ >>> production.bom = bom ++ >>> production.quantity = 2 ++ >>> production.click('wait') ++ >>> production.click('assign_force') ++ >>> production.click('run') ++ >>> production.click('done') ++ ++Check output price:: ++ ++ >>> production.cost ++ Decimal('20.0000') ++ >>> output, = production.outputs ++ >>> output.unit_price ++ Decimal('10.0000') ++ ++ ++Change cost of input:: ++ ++ >>> Move = Model.get('stock.move') ++ >>> input, = production.inputs ++ >>> Move.write([input], {'cost_price': Decimal(6)}, config.context) ++ >>> input.reload() ++ >>> bool(input.production_cost_price_updated) ++ True ++ ++Launch cron task:: ++ ++ >>> Cron = Model.get('ir.cron') ++ >>> Company = Model.get('company.company') ++ >>> cron_set_cost, = Cron.find([ ++ ... ('method', '=', 'production|set_cost_from_moves'), ++ ... ]) ++ >>> cron_set_cost.companies.append(Company(company.id)) ++ >>> cron_set_cost.click('run_once') ++ ++ >>> output.reload() ++ >>> output.unit_price ++ Decimal('12.0000') ++ >>> input.reload() ++ >>> bool(input.production_cost_price_updated) ++ False +diff --git a/tests/test_production.py b/tests/test_production.py +index 2bcf2ec..e9f596c 100644 +--- a/trytond/trytond/modules/production/tests/test_production.py ++++ b/trytond/trytond/modules/production/tests/test_production.py +@@ -54,4 +54,8 @@ def suite(): + tearDown=doctest_teardown, encoding='utf-8', + checker=doctest_checker, + optionflags=doctest.REPORT_ONLY_FIRST_FAILURE)) ++ suite.addTests(doctest.DocFileSuite('scenario_production_set_cost.rst', ++ tearDown=doctest_teardown, encoding='utf-8', ++ checker=doctest_checker, ++ optionflags=doctest.REPORT_ONLY_FIRST_FAILURE)) + return suite +-- +2.25.1 diff --git a/production_cost_fifo-Update-cost-of-moves-when-recomputing-product-cost.patch b/production_cost_fifo-Update-cost-of-moves-when-recomputing-product-cost.patch new file mode 100644 index 0000000..47b0059 --- /dev/null +++ b/production_cost_fifo-Update-cost-of-moves-when-recomputing-product-cost.patch @@ -0,0 +1,427 @@ +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 diff --git a/sale-Use-original-cost-price-for-returned-move.patch b/sale-Use-original-cost-price-for-returned-move.patch new file mode 100644 index 0000000..bfb1e6e --- /dev/null +++ b/sale-Use-original-cost-price-for-returned-move.patch @@ -0,0 +1,73 @@ +From f3cddba895305b7ecef3c07fb5db6c356a7bc8e2 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?=C3=80ngel=20=C3=80lvarez?= +Date: Thu, 8 Apr 2021 11:56:28 +0200 +Subject: [PATCH] Use original cost price for returned move + +and factorize the cost price of move for cost computation + +issue9440 +review327491003 +--- + stock.py | 34 ++++++++++++++++++++++++++++++++++ + 1 file changed, 34 insertions(+) + +diff --git a/stock.py b/stock.py +index 8d34c30..28f1116 100644 +--- a/trytond/trytond/modules/sale/stock.py ++++ b/trytond/trytond/modules/sale/stock.py +@@ -1,5 +1,6 @@ + # This file is part of Tryton. The COPYRIGHT file at the top level of + # this repository contains the full copyright notices and license terms. ++from decimal import Decimal + from functools import wraps + + from trytond.i18n import gettext +@@ -11,6 +12,12 @@ from trytond.pool import Pool, PoolMeta + __all__ = ['ShipmentOut', 'ShipmentOutReturn', 'Move'] + + ++def round_price(value, rounding=None): ++ "Round price using the price digits" ++ return value.quantize( ++ Decimal(1) / 10 ** price_digits[1], rounding=rounding) ++ ++ + def process_sale(moves_field): + def _process_sale(func): + @wraps(func) +@@ -155,6 +162,33 @@ class Move(metaclass=PoolMeta): + category = self.origin.unit.category.id + return category + ++ def get_cost_price(self, product_cost_price=None): ++ pool = Pool() ++ SaleLine = pool.get('sale.line') ++ Sale = pool.get('sale.sale') ++ # For return sale's move use the cost price of the original sale ++ if (isinstance(self.origin, SaleLine) ++ and self.origin.quantity < 0 ++ and self.from_location.type != 'storage' ++ and self.to_location.type == 'storage' ++ and isinstance(self.origin.origin, Sale)): ++ sale = self.origin.origin ++ cost = Decimal(0) ++ qty = Decimal(0) ++ for move in sale.moves: ++ if (move.state == 'done' ++ and move.from_location.type == 'storage' ++ and move.to_location.type == 'customer' ++ and move.product == self.product): ++ move_quantity = Decimal(str(move.internal_quantity)) ++ cost_price = move.get_cost_price( ++ product_cost_price=move.cost_price) ++ qty += move_quantity ++ cost += cost_price * move_quantity ++ if qty: ++ product_cost_price = round_price(cost / qty) ++ return super().get_cost_price(product_cost_price=product_cost_price) ++ + @property + def origin_name(self): + pool = Pool() +-- +2.25.1 diff --git a/sale_supply_drop_shippment-Use-original-cost-price-for-returned-move.patch b/sale_supply_drop_shippment-Use-original-cost-price-for-returned-move.patch new file mode 100644 index 0000000..0aba1e3 --- /dev/null +++ b/sale_supply_drop_shippment-Use-original-cost-price-for-returned-move.patch @@ -0,0 +1,43 @@ +From 479eff31f971acef41cc0fe77513305c3bb2b4a7 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?=C3=80ngel=20=C3=80lvarez?= +Date: Thu, 8 Apr 2021 11:58:52 +0200 +Subject: [PATCH] Use original cost price for returned move + +and factorize the cost price of move for cost computation + +issue9440 +review327491003 +--- + stock.py | 9 +-------- + 1 file changed, 1 insertion(+), 8 deletions(-) + +diff --git a/stock.py b/stock.py +index 46dc496..84b00ca 100644 +--- a/trytond/trytond/modules/sale_supply_drop_shipment/stock.py ++++ b/trytond/trytond/modules/sale_supply_drop_shipment/stock.py +@@ -404,7 +404,6 @@ class ShipmentDrop(Workflow, ModelSQL, ModelView): + pool = Pool() + UoM = pool.get('product.uom') + Move = pool.get('stock.move') +- Currency = pool.get('currency.currency') + + to_save = [] + cost_exp = Decimal(str(10.0 ** -Move.cost_price.digits[1])) +@@ -424,14 +423,8 @@ class ShipmentDrop(Workflow, ModelSQL, ModelView): + if s_move.state == 'cancel': + continue + internal_quantity = Decimal(str(s_move.internal_quantity)) +- with Transaction().set_context(date=s_move.effective_date): +- unit_price = Currency.compute( +- s_move.currency, s_move.unit_price, +- s_move.company.currency, round=False) +- unit_price = UoM.compute_price( +- s_move.uom, unit_price, s_move.product.default_uom) + product_cost[s_move.product] += ( +- unit_price * internal_quantity) ++ s_move.get_cost_price() * internal_quantity) + + quantity = UoM.compute_qty( + s_move.uom, s_move.quantity, s_move.product.default_uom, +-- +2.25.1 diff --git a/series b/series index 23a1da6..4ffa345 100644 --- a/series +++ b/series @@ -44,7 +44,7 @@ issue10009.diff # [trytond] Remove active test from sequences and languages acti issue9616.diff # [analytic_invoice] Analytic move is not created when closing an asset -fifo_quantity_round.diff # issue9664 [product_cost_fifo] Round fifo_quantity before save, to avoid problems when compare with quantity [Remove on 5.8] +#fifo_quantity_round.diff # issue9664 [product_cost_fifo] Round fifo_quantity before save, to avoid problems when compare with quantity [Remove on 5.8] account_payment_search_payment_amount.diff # [account_payment] Optimize searcher search_payment_amount @@ -70,3 +70,19 @@ project_invoice_progress_compute_qty.diff # [project_invoice] Twice compute qty issue10068.diff # [trytond] Use safe_join in SharedDataMiddlewareIndex issue10053.diff # [party] Configure available identifiers + +# Cost and Recompute Cost issues +production_cost_fifo-Update-cost-of-moves-when-recomputing-product-cost.patch #[issue8795 + issue7271] Remove on 5.8 +production-Update-cost-of-moves-when-recomputing-product-cost.patch #[issue8795 + issue7271] Remove on 5.8 +stock-Update-cost-of-moves-when-recomputing-product-cost.patch #[issue8795 + issue7271] Remove on 5.8 +production-Keep-cost-of-unused-input-products.patch #[issue9637] Remove on 5.8 +cost_price_in_productions_without_inputs.diff # [production] +product_cost_fifo-Use-all-moves-to-compute-FIFO.patch #[issue9443 issue9274] Remove on 5.8 +product-cost-fifo-Use-original-cost-price-for-returned-move.patch #[issue9440] Remove on 6.0 +stock-Use-original-cost-price-for-returned-move.patch #[issue9440] Remove on 6.0 +sale-Use-original-cost-price-for-returned-move.patch #[issue9440] Remove on 6.0 +sale_supply_drop_shippment-Use-original-cost-price-for-returned-move.patch #[issue9440] Remove on 6.0 +product_cost_fifo-Do-not-update-average-cost-price-i.patch #[issue9484] Remove on 6.0 +product_cost_fifo-Enforce-filling-cost-price-of-move.patch #[issue9397] Remove on 6.0 +stock-Enforce-filling-cost-price-of-move.patch #[issue9397] Remove on 6.0 +# End Cost and Recompute Cost issues diff --git a/stock-Enforce-filling-cost-price-of-move.patch b/stock-Enforce-filling-cost-price-of-move.patch new file mode 100644 index 0000000..77fd178 --- /dev/null +++ b/stock-Enforce-filling-cost-price-of-move.patch @@ -0,0 +1,69 @@ +From 443f3cb536af34952e95205bc3334b6ff77b1c39 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?=C3=80ngel=20=C3=80lvarez?= +Date: Thu, 8 Apr 2021 12:20:12 +0200 +Subject: [PATCH] stock: Enforce filling cost price of move + +We set cost price only for outgoing or incoming moves. + +issue9397 +--- + move.py | 23 ++++++++++++++++++++--- + 1 file changed, 20 insertions(+), 3 deletions(-) + +diff --git a/move.py b/move.py +index 19f4401..839a393 100644 +--- a/trytond/trytond/modules/stock/move.py ++++ b/trytond/trytond/modules/stock/move.py +@@ -266,8 +266,15 @@ class Move(Workflow, ModelSQL, ModelView): + 'invisible': Eval('state') != 'done', + }, + depends=['state']) +- cost_price = fields.Numeric('Cost Price', digits=price_digits, +- readonly=True) ++ cost_price = fields.Numeric( ++ "Cost Price", digits=price_digits, readonly=True, ++ states={ ++ 'invisible': ~Eval('cost_price_required'), ++ 'required': ( ++ (Eval('state') == 'done') ++ & Eval('cost_price_required', False)), ++ }, ++ depends=['cost_price_required']) + currency = fields.Many2One('currency.currency', 'Currency', + states={ + 'invisible': ~Eval('unit_price_required'), +@@ -279,6 +286,9 @@ class Move(Workflow, ModelSQL, ModelView): + unit_price_required = fields.Function( + fields.Boolean('Unit Price Required'), + 'on_change_with_unit_price_required') ++ cost_price_required = fields.Function( ++ fields.Boolean("Cost Price Required"), ++ 'on_change_with_cost_price_required') + assignation_required = fields.Function( + fields.Boolean('Assignation Required'), + 'on_change_with_assignation_required') +@@ -416,6 +426,13 @@ class Move(Workflow, ModelSQL, ModelView): + return True + return False + ++ @fields.depends('from_location', 'to_location') ++ def on_change_with_cost_price_required(self, name=None): ++ from_type = self.from_location.type if self.from_location else None ++ to_type = self.to_location.type if self.to_location else None ++ return ((from_type != 'storage' and to_type == 'storage') ++ or (from_type == 'storage' and to_type != 'storage')) ++ + @fields.depends('from_location') + def on_change_with_assignation_required(self, name=None): + if self.from_location: +@@ -620,7 +637,7 @@ class Move(Workflow, ModelSQL, ModelView): + cost_values.append( + (move.product, cost_price, + move._cost_price_pattern)) +- if move.cost_price is None: ++ if move.cost_price_required and move.cost_price is None: + if cost_price is None: + cost_price = move.product.get_multivalue( + 'cost_price', **move._cost_price_pattern) +-- +2.25.1 diff --git a/stock-Update-cost-of-moves-when-recomputing-product-cost.patch b/stock-Update-cost-of-moves-when-recomputing-product-cost.patch new file mode 100644 index 0000000..fa3dc50 --- /dev/null +++ b/stock-Update-cost-of-moves-when-recomputing-product-cost.patch @@ -0,0 +1,657 @@ +From 5ddcf090b2ffba088a81e764a00c36984f101798 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?=C3=80ngel=20=C3=80lvarez?= +Date: Wed, 7 Apr 2021 12:23:41 +0200 +Subject: [PATCH] Update cost of moves when recomputing product cost + +issue8795 +issue7271 +--- + __init__.py | 3 + + ir.py | 15 ++ + move.py | 18 +- + product.py | 188 +++++++++++++++--- + product.xml | 13 +- + tests/scenario_stock_average_cost_price.rst | 2 + + ...rio_stock_recompute_average_cost_price.rst | 160 +++++++++++++++ + tests/test_stock.py | 5 + + view/recompute_cost_price_start_form.xml | 7 + + 9 files changed, 377 insertions(+), 34 deletions(-) + create mode 100644 ir.py + create mode 100644 tests/scenario_stock_recompute_average_cost_price.rst + create mode 100644 view/recompute_cost_price_start_form.xml + +diff --git a/__init__.py b/__init__.py +index daf5784..6d65b2e 100644 +--- a/trytond/trytond/modules/stock/__init__.py ++++ b/trytond/trytond/modules/stock/__init__.py +@@ -11,6 +11,7 @@ from . import inventory + from . import configuration + from . import party + from . import res ++from . import ir + + from .move import StockMixin + +@@ -41,6 +42,7 @@ def register(): + product.ProductByLocationContext, + product.ProductQuantitiesByWarehouse, + product.ProductQuantitiesByWarehouseContext, ++ product.RecomputeCostPriceStart, + inventory.Inventory, + inventory.InventoryLine, + inventory.CountSearch, +@@ -49,6 +51,7 @@ def register(): + configuration.ConfigurationSequence, + configuration.ConfigurationLocation, + res.User, ++ ir.Cron, + module='stock', type_='model') + Pool.register( + shipment.AssignShipmentOut, +diff --git a/ir.py b/ir.py +new file mode 100644 +index 0000000..0508a53 +--- /a//trytond/trytond/modules/stock/ir.py ++++ b/trytond/trytond/modules/stock/ir.py +@@ -0,0 +1,15 @@ ++# This file is part of Tryton. The COPYRIGHT file at the top level of ++# this repository contains the full copyright notices and license terms. ++from trytond.pool import PoolMeta ++ ++ ++class Cron(metaclass=PoolMeta): ++ __name__ = 'ir.cron' ++ ++ @classmethod ++ def __setup__(cls): ++ super().__setup__() ++ cls.method.selection.extend([ ++ ('product.product|recompute_cost_price_from_moves', ++ "Recompute Cost Price from Moves"), ++ ]) +diff --git a/move.py b/move.py +index 7995ab4..42cf691 100644 +--- a/trytond/trytond/modules/stock/move.py ++++ b/trytond/trytond/modules/stock/move.py +@@ -246,6 +246,12 @@ class Move(Workflow, ModelSQL, ModelView): + 'readonly': Eval('state') != 'draft', + }, + depends=['unit_price_required', 'state']) ++ unit_price_updated = fields.Boolean( ++ "Unit Price Updated", readonly=True, ++ states={ ++ 'invisible': Eval('state') != 'done', ++ }, ++ depends=['state']) + cost_price = fields.Numeric('Cost Price', digits=price_digits, + readonly=True) + currency = fields.Many2One('currency.currency', 'Currency', +@@ -270,7 +276,7 @@ class Move(Workflow, ModelSQL, ModelView): + 'from_location', 'to_location', 'company', 'currency']) + cls._deny_modify_done_cancel = (cls._deny_modify_assigned | + set(['planned_date', 'effective_date', 'state'])) +- cls._allow_modify_closed_period = set() ++ cls._allow_modify_closed_period = {'cost_price'} + + t = cls.__table__() + cls._sql_constraints += [ +@@ -346,6 +352,10 @@ class Move(Workflow, ModelSQL, ModelView): + def default_company(): + return Transaction().context.get('company') + ++ @classmethod ++ def default_unit_price_updated(cls): ++ return True ++ + @staticmethod + def default_currency(): + Company = Pool().get('company.company') +@@ -678,6 +688,7 @@ class Move(Workflow, ModelSQL, ModelView): + super(Move, cls).write(*args) + + to_write = [] ++ unit_price_update = [] + actions = iter(args) + for moves, values in zip(actions, actions): + if any(f not in cls._allow_modify_closed_period for f in values): +@@ -691,8 +702,13 @@ class Move(Workflow, ModelSQL, ModelView): + to_write.extend(([move], { + 'internal_quantity': internal_quantity, + })) ++ if move.state == 'done' and 'unit_price' in values: ++ unit_price_update.append(move) ++ + if to_write: + cls.write(*to_write) ++ if unit_price_update: ++ cls.write(unit_price_update, {'unit_price_updated': True}) + + @classmethod + def delete(cls, moves): +diff --git a/product.py b/product.py +index 1d1d448..8de40b1 100644 +--- a/trytond/trytond/modules/stock/product.py ++++ b/trytond/trytond/modules/stock/product.py +@@ -9,7 +9,8 @@ from sql import Literal, Null, Select + from sql.aggregate import Max + from sql.functions import CurrentTimestamp + from sql.conditionals import Coalesce +- ++from trytond.wizard import ( ++ Wizard, StateTransition, StateAction, StateView, Button) + from trytond.i18n import gettext + from trytond.model import ModelSQL, ModelView, fields + from trytond.model.exceptions import AccessError +@@ -102,12 +103,12 @@ class Template(metaclass=PoolMeta): + super(Template, cls).write(*args) + + @classmethod +- def recompute_cost_price(cls, templates): ++ def recompute_cost_price(cls, templates, start=None): + pool = Pool() + Product = pool.get('product.product') + + products = [p for t in templates for p in t.products] +- Product.recompute_cost_price(products) ++ Product.recompute_cost_price(products, start=start) + + + class Product(StockMixin, object, metaclass=PoolMeta): +@@ -211,17 +212,48 @@ class Product(StockMixin, object, metaclass=PoolMeta): + return quantities + + @classmethod +- def recompute_cost_price(cls, products): ++ def recompute_cost_price_from_moves(cls): ++ pool = Pool() ++ Move = pool.get('stock.move') ++ products = set() ++ for move in Move.search([ ++ ('unit_price_updated', '=', True), ++ cls._domain_moves_cost(), ++ ], ++ order=[('effective_date', 'ASC')]): ++ if move.product not in products: ++ cls.__queue__.recompute_cost_price( ++ [move.product], start=move.effective_date) ++ products.add(move.product) ++ ++ @classmethod ++ def recompute_cost_price(cls, products, start=None): ++ pool = Pool() ++ Move = pool.get('stock.move') + digits = cls.cost_price.digits + costs = defaultdict(list) + for product in products: + if product.type == 'service': + continue +- cost = getattr(product, +- 'recompute_cost_price_%s' % product.cost_price_method)() ++ cost = getattr( ++ product, 'recompute_cost_price_%s' % ++ product.cost_price_method)(start) + cost = cost.quantize(Decimal(str(10.0 ** -digits[1]))) + costs[cost].append(product) + ++ updated = [] ++ for sub_products in grouped_slice(products): ++ domain = [ ++ ('unit_price_updated', '=', True), ++ cls._domain_moves_cost(), ++ ('product', 'in', [p.id for p in sub_products]), ++ ] ++ if start: ++ domain.append(('effective_date', '>=', start)) ++ updated += Move.search(domain, order=[]) ++ if updated: ++ Move.write(updated, {'unit_price_updated': False}) ++ + if not costs: + return + +@@ -231,45 +263,93 @@ class Product(StockMixin, object, metaclass=PoolMeta): + to_write.append({'cost_price': cost}) + + # Enforce check access for account_stock* +- with Transaction().set_context(_check_access=True): ++ with Transaction().set_context(_check_access=False): + cls.write(*to_write) + +- def recompute_cost_price_fixed(self): ++ def recompute_cost_price_fixed(self, start=None): + return self.cost_price + +- @property +- def _domain_moves_cost(self): ++ @classmethod ++ def _domain_moves_cost(cls): + "Returns the domain for moves to use in cost computation" + context = Transaction().context + return [ + ('company', '=', context.get('company')), ++ ('state', '=', 'done'), + ] + +- def recompute_cost_price_average(self): ++ def _get_storage_quantity(self, date=None): ++ pool = Pool() ++ Location = pool.get('stock.location') ++ ++ locations = Location.search([ ++ ('type', '=', 'storage'), ++ ]) ++ if not date: ++ date = datetime.date.today() ++ location_ids = [l.id for l in locations] ++ with Transaction().set_context( ++ locations=location_ids, ++ with_childs=False, ++ stock_date_end=date): ++ return self.__class__(self.id).quantity ++ ++ def recompute_cost_price_average(self, start=None): + pool = Pool() + Move = pool.get('stock.move') + Currency = pool.get('currency.currency') + Uom = pool.get('product.uom') + +- moves = Move.search([ +- ('product', '=', self.id), +- ('state', '=', 'done'), +- self._domain_moves_cost, +- ['OR', +- [ +- ('to_location.type', '=', 'storage'), +- ('from_location.type', '!=', 'storage'), +- ], +- [ +- ('from_location.type', '=', 'storage'), +- ('to_location.type', '!=', 'storage'), +- ], +- ], +- ], order=[('effective_date', 'ASC'), ('id', 'ASC')]) ++ 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 - datetime.timedelta(days=1)) ++ quantity = Decimal(str(quantity)) ++ current_moves = [] ++ 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 current_moves ++ if m.cost_price != current_cost_price], ++ dict(cost_price=current_cost_price)) ++ current_moves.clear() ++ 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': +@@ -280,16 +360,23 @@ class Product(StockMixin, object, metaclass=PoolMeta): + 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) ++ 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]))) + quantity += qty +- return cost_price ++ ++ Move.write([ ++ m for m in current_moves ++ if m.cost_price != current_cost_price], ++ dict(cost_price=current_cost_price)) ++ return current_cost_price + + + class ProductByLocationContext(ModelView): +@@ -530,9 +617,40 @@ class OpenProductQuantitiesByWarehouse(Wizard): + class RecomputeCostPrice(Wizard): + 'Recompute Cost Price' + __name__ = 'product.recompute_cost_price' +- start_state = 'recompute' ++ start = StateView( ++ 'product.recompute_cost_price.start', ++ 'stock.recompute_cost_price_start_view_form', [ ++ Button("Cancel", 'end'), ++ Button("Recompute", 'recompute', default=True)]) + recompute = StateTransition() + ++ def default_start(self, fields): ++ pool = Pool() ++ Move = pool.get('stock.move') ++ Product = pool.get('product.product') ++ Template = pool.get('product.template') ++ context = Transaction().context ++ ++ if context['active_model'] == 'product.product': ++ products = Product.browse(context['active_ids']) ++ elif context['active_model'] == 'product.template': ++ templates = Template.browse(context['active_ids']) ++ products = sum((t.products for t in templates), ()) ++ ++ from_ = None ++ for sub_products in grouped_slice(products): ++ moves = Move.search([ ++ ('unit_price_updated', '=', True), ++ Product._domain_moves_cost(), ++ ('product', 'in', [p.id for p in sub_products]), ++ ], ++ order=[('effective_date', 'ASC')], ++ limit=1) ++ if moves: ++ move, = moves ++ from_ = min(from_ or datetime.date.max, move.effective_date) ++ return {'from_': from_} ++ + def transition_recompute(self): + pool = Pool() + Product = pool.get('product.product') +@@ -542,8 +660,14 @@ class RecomputeCostPrice(Wizard): + + if context['active_model'] == 'product.product': + products = Product.browse(context['active_ids']) +- Product.recompute_cost_price(products) ++ Product.recompute_cost_price(products, start=self.start.from_) + elif context['active_model'] == 'product.template': + templates = Template.browse(context['active_ids']) +- Template.recompute_cost_price(templates) ++ Template.recompute_cost_price(templates, start=self.start.from_) + return 'end' ++ ++ ++class RecomputeCostPriceStart(ModelView): ++ "Recompute Cost Price" ++ __name__ = 'product.recompute_cost_price.start' ++ from_ = fields.Date("From") +diff --git a/product.xml b/product.xml +index b74a8ed..fbe6d49 100644 +--- a/trytond/trytond/modules/stock/product.xml ++++ b/trytond/trytond/modules/stock/product.xml +@@ -32,6 +32,18 @@ this repository contains the full copyright notices and license terms. --> + + + ++ ++ product.recompute_cost_price.start ++ form ++ recompute_cost_price_start_form ++ ++ ++ ++ product.product|recompute_cost_price_from_moves ++ ++ days ++ ++ + + stock.location + tree +@@ -184,6 +196,5 @@ this repository contains the full copyright notices and license terms. --> + + + +- + + +diff --git a/tests/scenario_stock_average_cost_price.rst b/tests/scenario_stock_average_cost_price.rst +index 8223c5c..1af0e0d 100644 +--- a/trytond/trytond/modules/stock/tests/scenario_stock_average_cost_price.rst ++++ b/trytond/trytond/modules/stock/tests/scenario_stock_average_cost_price.rst +@@ -153,6 +153,7 @@ Change Cost Price to 125, to force to write recomputed price later:: + Recompute Cost Price:: + + >>> recompute = Wizard('product.recompute_cost_price', [product]) ++ >>> recompute.execute('recompute') + >>> product.cost_price + Decimal('175.0000') + +@@ -250,5 +251,6 @@ Change Cost Price to 5, to force to write recomputed price later:: + Recompute Cost Price:: + + >>> recompute = Wizard('product.recompute_cost_price', [negative_product]) ++ >>> recompute.execute('recompute') + >>> negative_product.cost_price + Decimal('2.0000') +diff --git a/tests/scenario_stock_recompute_average_cost_price.rst b/tests/scenario_stock_recompute_average_cost_price.rst +new file mode 100644 +index 0000000..f733b8f +--- /dev/null ++++ b/trytond/trytond/modules/stock/tests/scenario_stock_recompute_average_cost_price.rst +@@ -0,0 +1,160 @@ ++================================== ++Stock Recompute Average 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('stock') ++ ++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 = 'average' ++ >>> 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=storage_loc, ++ ... to_location=customer_loc, ++ ... unit_price=Decimal('300'), ++ ... effective_date=today - dt.timedelta(days=1)).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=3, ++ ... from_location=supplier_loc, ++ ... to_location=storage_loc, ++ ... unit_price=Decimal('100'), ++ ... effective_date=today).click('do') ++ ++ >>> [m.cost_price for m in StockMove.find([])] ++ [Decimal('105.0000'), Decimal('120.0000'), Decimal('100.0000'), Decimal('100.0000')] ++ ++ >>> product.reload() ++ >>> product.cost_price ++ Decimal('105.0000') ++ ++Recompute cost price:: ++ ++ >>> recompute = Wizard('product.recompute_cost_price', [product]) ++ >>> recompute.execute('recompute') ++ ++ >>> [m.cost_price for m in StockMove.find([])] ++ [Decimal('105.0000'), Decimal('120.0000'), Decimal('120.0000'), Decimal('100.0000')] ++ ++ >>> product.reload() ++ >>> product.cost_price ++ Decimal('105.0000') ++ ++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('105.0000'), Decimal('120.0000'), Decimal('120.0000'), Decimal('100.0000')] ++ ++ >>> product.reload() ++ >>> product.cost_price ++ Decimal('105.0000') ++ ++Update unit price of a move:: ++ ++ >>> move, = StockMove.find([ ++ ... ('from_location', '=', supplier_loc.id), ++ ... ('effective_date', '=', today - dt.timedelta(days=1)), ++ ... ]) ++ >>> bool(move.unit_price_updated) ++ False ++ >>> move.unit_price = Decimal('130') ++ >>> move.save() ++ >>> bool(move.unit_price_updated) ++ True ++ ++ >>> recompute = Wizard('product.recompute_cost_price', [product]) ++ >>> recompute.form.from_ = move.effective_date + dt.timedelta(days=1) ++ >>> recompute.execute('recompute') ++ >>> move.reload() ++ >>> bool(move.unit_price_updated) ++ True ++ ++ >>> recompute = Wizard('product.recompute_cost_price', [product]) ++ >>> recompute.form.from_ == move.effective_date ++ True ++ >>> recompute.execute('recompute') ++ >>> move.reload() ++ >>> bool(move.unit_price_updated) ++ False ++ >>> [m.cost_price for m in StockMove.find([])] ++ [Decimal('107.5000'), Decimal('130.0000'), Decimal('130.0000'), Decimal('100.0000')] ++ ++Launch cron task:: ++ ++ >>> move.unit_price = Decimal('120') ++ >>> move.save() ++ ++ >>> Cron = Model.get('ir.cron') ++ >>> Company = Model.get('company.company') ++ >>> cron_recompute, = Cron.find([ ++ ... ('method', '=', 'product.product|recompute_cost_price_from_moves'), ++ ... ]) ++ >>> cron_recompute.companies.append(Company(company.id)) ++ >>> cron_recompute.click('run_once') ++ ++ >>> move.reload() ++ >>> bool(move.unit_price_updated) ++ False ++ >>> [m.cost_price for m in StockMove.find([])] ++ [Decimal('105.0000'), Decimal('120.0000'), Decimal('120.0000'), Decimal('100.0000')] +diff --git a/tests/test_stock.py b/tests/test_stock.py +index 60c5cc2..8fda0ea 100644 +--- a/trytond/trytond/modules/stock/tests/test_stock.py ++++ b/trytond/trytond/modules/stock/tests/test_stock.py +@@ -1437,6 +1437,11 @@ def suite(): + tearDown=doctest_teardown, encoding='utf-8', + checker=doctest_checker, + optionflags=doctest.REPORT_ONLY_FIRST_FAILURE)) ++ suite.addTests(doctest.DocFileSuite( ++ 'scenario_stock_recompute_average_cost_price.rst', ++ tearDown=doctest_teardown, encoding='utf-8', ++ checker=doctest_checker, ++ optionflags=doctest.REPORT_ONLY_FIRST_FAILURE)) + suite.addTests(doctest.DocFileSuite( + 'scenario_stock_inventory.rst', + tearDown=doctest_teardown, encoding='utf-8', +diff --git a/view/recompute_cost_price_start_form.xml b/view/recompute_cost_price_start_form.xml +new file mode 100644 +index 0000000..70f258f +--- /dev/null ++++ b/trytond/trytond/modules/stock/view/recompute_cost_price_start_form.xml +@@ -0,0 +1,7 @@ ++ ++ ++
++