changeset 49eea85821ea in modules/stock:default
details: https://hg.tryton.org/modules/stock?cmd=changeset;node=49eea85821ea
description:
        Update cost of moves when recomputing product cost

        issue8795
        issue7271
        review260381003
diffstat:

 CHANGELOG                                             |    3 +
 __init__.py                                           |    3 +
 doc/index.rst                                         |    2 +
 ir.py                                                 |   15 +
 move.py                                               |   18 +-
 product.py                                            |  191 ++++++++++++++---
 product.xml                                           |   13 +-
 tests/scenario_stock_average_cost_price.rst           |    2 +
 tests/scenario_stock_recompute_average_cost_price.rst |  160 +++++++++++++++
 tests/test_stock.py                                   |    5 +
 view/recompute_cost_price_start_form.xml              |    7 +
 11 files changed, 381 insertions(+), 38 deletions(-)

diffs (654 lines):

diff -r 9ffc06cf86ba -r 49eea85821ea CHANGELOG
--- a/CHANGELOG Sat Jan 25 20:18:49 2020 +0100
+++ b/CHANGELOG Sun Jan 26 15:29:23 2020 +0100
@@ -1,3 +1,6 @@
+* Add cron task to recompute product cost
+* Update cost of moves when recomputing product cost
+
 Version 5.4.0 - 2019-11-04
 * Bug fixes (see mercurial logs for details)
 * Add today record in Product Quantities By Warehouse
diff -r 9ffc06cf86ba -r 49eea85821ea __init__.py
--- a/__init__.py       Sat Jan 25 20:18:49 2020 +0100
+++ b/__init__.py       Sun Jan 26 15:29:23 2020 +0100
@@ -10,6 +10,7 @@
 from . import inventory
 from . import configuration
 from . import party
+from . import ir
 from . import res
 
 from .move import StockMixin
@@ -41,6 +42,7 @@
         product.ProductByLocationContext,
         product.ProductQuantitiesByWarehouse,
         product.ProductQuantitiesByWarehouseContext,
+        product.RecomputeCostPriceStart,
         inventory.Inventory,
         inventory.InventoryLine,
         inventory.CountSearch,
@@ -48,6 +50,7 @@
         configuration.Configuration,
         configuration.ConfigurationSequence,
         configuration.ConfigurationLocation,
+        ir.Cron,
         res.User,
         module='stock', type_='model')
     Pool.register(
diff -r 9ffc06cf86ba -r 49eea85821ea doc/index.rst
--- a/doc/index.rst     Sat Jan 25 20:18:49 2020 +0100
+++ b/doc/index.rst     Sun Jan 26 15:29:23 2020 +0100
@@ -109,6 +109,8 @@
   A phantom state used to create in advance move that should not be taken for
   stock computation.
 
+A cron task runs every day and recomputes the cost price of moves if a past
+unit price has changed.
 
 Product Quantities
 ------------------
diff -r 9ffc06cf86ba -r 49eea85821ea ir.py
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/ir.py     Sun Jan 26 15:29:23 2020 +0100
@@ -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 -r 9ffc06cf86ba -r 49eea85821ea move.py
--- a/move.py   Sat Jan 25 20:18:49 2020 +0100
+++ b/move.py   Sun Jan 26 15:29:23 2020 +0100
@@ -245,6 +245,12 @@
             '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',
@@ -269,7 +275,7 @@
             '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 += [
@@ -345,6 +351,10 @@
     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')
@@ -687,6 +697,7 @@
         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):
@@ -700,8 +711,13 @@
                     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 -r 9ffc06cf86ba -r 49eea85821ea product.py
--- a/product.py        Sat Jan 25 20:18:49 2020 +0100
+++ b/product.py        Sun Jan 26 15:29:23 2020 +0100
@@ -13,7 +13,8 @@
 from trytond.i18n import gettext
 from trytond.model import ModelSQL, ModelView, fields
 from trytond.model.exceptions import AccessError
-from trytond.wizard import Wizard, StateTransition, StateAction
+from trytond.wizard import (
+    Wizard, StateTransition, StateAction, StateView, Button)
 from trytond.pyson import Eval, Or, PYSONEncoder
 from trytond.transaction import Transaction
 from trytond.pool import Pool, PoolMeta
@@ -21,11 +22,6 @@
 
 from .move import StockMixin
 
-__all__ = ['Template', 'Product',
-    'ProductByLocationContext',
-    'ProductQuantitiesByWarehouse', 'ProductQuantitiesByWarehouseContext',
-    'RecomputeCostPrice']
-
 
 def check_no_move(func):
 
@@ -102,12 +98,12 @@
         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 +207,48 @@
         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 +258,93 @@
             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')
+        digits = self.__class__.cost_price.digits
 
-        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'),
-                        ],
+        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'),
                     ],
-                ], order=[('effective_date', 'ASC'), ('id', 'ASC')])
+                ],
+            ]
+        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 +355,23 @@
                     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 +612,40 @@
 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 +655,14 @@
 
         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 -r 9ffc06cf86ba -r 49eea85821ea product.xml
--- a/product.xml       Sat Jan 25 20:18:49 2020 +0100
+++ b/product.xml       Sun Jan 26 15:29:23 2020 +0100
@@ -32,6 +32,18 @@
             <field name="group" ref="product.group_product_admin"/>
         </record>
 
+        <record model="ir.ui.view" id="recompute_cost_price_start_view_form">
+            <field name="model">product.recompute_cost_price.start</field>
+            <field name="type">form</field>
+            <field name="name">recompute_cost_price_start_form</field>
+        </record>
+
+        <record model="ir.cron" id="cron_recompute_cost_price_from_moves">
+            <field 
name="method">product.product|recompute_cost_price_from_moves</field>
+            <field name="interval_number" eval="1"/>
+            <field name="interval_type">days</field>
+        </record>
+
         <record model="ir.ui.view" id="location_quantity_view_tree">
             <field name="model">stock.location</field>
             <field name="type">tree</field>
@@ -197,6 +209,5 @@
             <field name="action" ref="act_product_quantities_warehouse"/>
             <field name="group" ref="group_stock"/>
         </record>
-
     </data>
 </tryton>
diff -r 9ffc06cf86ba -r 49eea85821ea tests/scenario_stock_average_cost_price.rst
--- a/tests/scenario_stock_average_cost_price.rst       Sat Jan 25 20:18:49 
2020 +0100
+++ b/tests/scenario_stock_average_cost_price.rst       Sun Jan 26 15:29:23 
2020 +0100
@@ -153,6 +153,7 @@
 Recompute Cost Price::
 
     >>> recompute = Wizard('product.recompute_cost_price', [product])
+    >>> recompute.execute('recompute')
     >>> product.cost_price
     Decimal('175.0000')
 
@@ -250,5 +251,6 @@
 Recompute Cost Price::
 
     >>> recompute = Wizard('product.recompute_cost_price', [negative_product])
+    >>> recompute.execute('recompute')
     >>> negative_product.cost_price
     Decimal('2.0000')
diff -r 9ffc06cf86ba -r 49eea85821ea 
tests/scenario_stock_recompute_average_cost_price.rst
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/scenario_stock_recompute_average_cost_price.rst     Sun Jan 26 
15:29:23 2020 +0100
@@ -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 -r 9ffc06cf86ba -r 49eea85821ea tests/test_stock.py
--- a/tests/test_stock.py       Sat Jan 25 20:18:49 2020 +0100
+++ b/tests/test_stock.py       Sun Jan 26 15:29:23 2020 +0100
@@ -1423,6 +1423,11 @@
             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',
             checker=doctest_checker,
diff -r 9ffc06cf86ba -r 49eea85821ea view/recompute_cost_price_start_form.xml
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/view/recompute_cost_price_start_form.xml  Sun Jan 26 15:29:23 2020 +0100
@@ -0,0 +1,7 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton.  The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<form>
+    <label name="from_"/>
+    <field name="from_"/>
+</form>

Reply via email to