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 @@ + + +
+