changeset eb8be9375a53 in modules/stock:default
details: https://hg.tryton.org/modules/stock?cmd=changeset;node=eb8be9375a53
description:
        Store product cost price revision

        The "Modify Cost Price" wizard create a revision record for each 
template or
        product. The cost price re-computation methods apply those revisions at 
the
        beginning of their date.
        The stock accounting move is created and posted when the cost price is 
modified
        by the re-computation.

        issue8930
        review260751002
diffstat:

 CHANGELOG                                     |    2 +
 __init__.py                                   |    3 +
 doc/index.rst                                 |   15 +
 exceptions.py                                 |    4 +
 message.xml                                   |   11 +-
 product.py                                    |  217 ++++++++++++++++++++++++-
 product.xml                                   |   81 +++++++++
 setup.py                                      |    2 +-
 tests/scenario_stock_average_cost_price.rst   |   24 +--
 view/product_cost_price_revision_form.xml     |   15 +
 view/product_cost_price_revision_list.xml     |   10 +
 view/product_modify_cost_price_start_form.xml |    9 +
 12 files changed, 360 insertions(+), 33 deletions(-)

diffs (568 lines):

diff -r 49eea85821ea -r eb8be9375a53 CHANGELOG
--- a/CHANGELOG Sun Jan 26 15:29:23 2020 +0100
+++ b/CHANGELOG Sun Jan 26 21:10:40 2020 +0100
@@ -1,3 +1,5 @@
+* Add cost price revision
+* Add wizard to modify product cost price
 * Add cron task to recompute product cost
 * Update cost of moves when recomputing product cost
 
diff -r 49eea85821ea -r eb8be9375a53 __init__.py
--- a/__init__.py       Sun Jan 26 15:29:23 2020 +0100
+++ b/__init__.py       Sun Jan 26 21:10:40 2020 +0100
@@ -43,6 +43,8 @@
         product.ProductQuantitiesByWarehouse,
         product.ProductQuantitiesByWarehouseContext,
         product.RecomputeCostPriceStart,
+        product.CostPriceRevision,
+        product.ModifyCostPriceStart,
         inventory.Inventory,
         inventory.InventoryLine,
         inventory.CountSearch,
@@ -59,6 +61,7 @@
         shipment.AssignShipmentInReturn,
         product.OpenProductQuantitiesByWarehouse,
         product.RecomputeCostPrice,
+        product.ModifyCostPrice,
         inventory.Count,
         party.PartyReplace,
         party.PartyErase,
diff -r 49eea85821ea -r eb8be9375a53 doc/index.rst
--- a/doc/index.rst     Sun Jan 26 15:29:23 2020 +0100
+++ b/doc/index.rst     Sun Jan 26 21:10:40 2020 +0100
@@ -280,3 +280,18 @@
     cost price method than `fixed` is used.
     Individual moves from supplier to each locations must be used with the cost
     price as unit price.
+
+Product
+*******
+
+The cost price of a product can only be modified using the "Modify Cost Price"
+wizard once it is associated with stock moves. The wizard stores, for each
+template or product the cost price revision. This revision contains a formula
+that compute the new cost price based on the current one. E.g.  `cost_price *
+0.9` to reduce the cost price by 10%.
+The cost price revisions are applied at the beginning of the stored date when
+the cost price of a product is re-computed.
+
+.. warning::
+    If the user modifies a revision manually, they must also run the "Recompute
+    Cost Price" wizard.
diff -r 49eea85821ea -r eb8be9375a53 exceptions.py
--- a/exceptions.py     Sun Jan 26 15:29:23 2020 +0100
+++ b/exceptions.py     Sun Jan 26 21:10:40 2020 +0100
@@ -26,3 +26,7 @@
 
 class MoveOriginWarning(UserWarning):
     pass
+
+
+class ProductCostPriceError(ValidationError):
+    pass
diff -r 49eea85821ea -r eb8be9375a53 message.xml
--- a/message.xml       Sun Jan 26 15:29:23 2020 +0100
+++ b/message.xml       Sun Jan 26 21:10:40 2020 +0100
@@ -4,10 +4,17 @@
 <tryton>
     <data grouped="1">
         <record model="ir.message" id="msg_product_change_default_uom">
-            <field name="text">You cannot change the default UOM of a product 
which is associated to stock moves.</field>
+            <field name="text">You cannot change the default UOM of a product 
which is associated with stock moves.</field>
         </record>
         <record model="ir.message" id="msg_product_change_type">
-            <field name="text">You cannot change the type of a product which 
is associated to stock moves.</field>
+            <field name="text">You cannot change the type of a product which 
is associated with stock moves.</field>
+        </record>
+        <record model="ir.message" id="msg_product_change_cost_price">
+            <field name="text">You cannot change the cost price of a product 
which is associated with stock moves.
+You must use the "Modify Cost Price" wizard.</field>
+        </record>
+        <record model="ir.message" id="msg_invalid_cost_price">
+            <field name="text">Invalid cost price "%(cost_price)s" for product 
"%(product)s" with exception "%(exception)s".</field>
         </record>
         <record model="ir.message" id="msg_location_invalid_type_for_moves">
             <field name="text">You cannot change the type of location 
"%(location)s" to "%(type)s"
diff -r 49eea85821ea -r eb8be9375a53 product.py
--- a/product.py        Sun Jan 26 15:29:23 2020 +0100
+++ b/product.py        Sun Jan 26 21:10:40 2020 +0100
@@ -5,6 +5,8 @@
 from decimal import Decimal
 from collections import defaultdict
 
+from simpleeval import simple_eval
+
 from sql import Literal, Null, Select
 from sql.aggregate import Max
 from sql.functions import CurrentTimestamp
@@ -15,11 +17,13 @@
 from trytond.model.exceptions import AccessError
 from trytond.wizard import (
     Wizard, StateTransition, StateAction, StateView, Button)
-from trytond.pyson import Eval, Or, PYSONEncoder
+from trytond.pyson import Eval, If, Bool, PYSONEncoder
+from trytond.tools import decistmt
 from trytond.transaction import Transaction
 from trytond.pool import Pool, PoolMeta
 from trytond.tools import grouped_slice
 
+from .exceptions import ProductCostPriceError
 from .move import StockMixin
 
 
@@ -82,14 +86,11 @@
 
     @classmethod
     def __setup__(cls):
-        super(Template, cls).__setup__()
-        cls.cost_price.states['required'] = Or(
-            cls.cost_price.states.get('required', True),
-            Eval('type').in_(['goods', 'assets']))
-        cls.cost_price.depends.append('type')
+        super().__setup__()
         cls._modify_no_move = [
             ('default_uom', 'stock.msg_product_change_default_uom'),
             ('type', 'stock.msg_product_change_type'),
+            ('cost_price', 'stock.msg_product_change_cost_price'),
             ]
 
     @classmethod
@@ -249,15 +250,17 @@
         if updated:
             Move.write(updated, {'unit_price_updated': False})
 
-        if not costs:
-            return
+        if costs:
+            cls.update_cost_price(costs)
 
+    @classmethod
+    def update_cost_price(cls, costs):
+        "Update cost price of products from costs re-computation dictionary"
         to_write = []
-        for cost, records in costs.items():
-            to_write.append(records)
+        for cost, products in costs.items():
+            to_write.append(products)
             to_write.append({'cost_price': cost})
 
-        # Enforce check access for account_stock*
         with Transaction().set_context(_check_access=False):
             cls.write(*to_write)
 
@@ -294,6 +297,7 @@
         Move = pool.get('stock.move')
         Currency = pool.get('currency.currency')
         Uom = pool.get('product.uom')
+        Revision = pool.get('product.cost_price.revision')
         digits = self.__class__.cost_price.digits
 
         domain = [
@@ -314,6 +318,8 @@
         moves = Move.search(
                 domain, order=[('effective_date', 'ASC'), ('id', 'ASC')])
 
+        revisions = Revision.get_for_product(self)
+
         cost_price = Decimal(0)
         quantity = 0
         if start:
@@ -345,6 +351,8 @@
                 current_moves.clear()
             current_moves.append(move)
 
+            cost_price = Revision.apply_up_to(
+                revisions, cost_price, move.effective_date)
             qty = Uom.compute_qty(move.uom, move.quantity, self.default_uom)
             qty = Decimal(str(qty))
             if move.from_location.type == 'storage':
@@ -371,7 +379,10 @@
                 m for m in current_moves
                 if m.cost_price != current_cost_price],
             dict(cost_price=current_cost_price))
-        return current_cost_price
+
+        for revision in revisions:
+            cost_price = revision.get_cost_price(cost_price)
+        return cost_price
 
 
 class ProductByLocationContext(ModelView):
@@ -666,3 +677,185 @@
     "Recompute Cost Price"
     __name__ = 'product.recompute_cost_price.start'
     from_ = fields.Date("From")
+
+
+class ModifyCostPrice(Wizard):
+    "Modify Cost Price"
+    __name__ = 'product.modify_cost_price'
+    start = StateView(
+        'product.modify_cost_price.start',
+        'stock.product_modify_cost_price_start_form', [
+            Button("Cancel", 'end', 'tryton-cancel'),
+            Button("OK", 'modify', default=True),
+            ])
+    modify = StateTransition()
+
+    def transition_modify(self):
+        pool = Pool()
+        Product = pool.get('product.product')
+        Template = pool.get('product.template')
+        Revision = pool.get('product.cost_price.revision')
+        Date = pool.get('ir.date')
+        context = Transaction().context
+        today = Date.today()
+        revisions = []
+        costs = defaultdict(list)
+        if context['active_model'] == 'product.product':
+            recompute_cost_price = Product.recompute_cost_price
+            products = records = Product.browse(context['active_ids'])
+            for product in products:
+                revision = self.get_revision(Revision)
+                revision.product = product
+                revision.template = product.template
+                revisions.append(revision)
+                if ((product.cost_price_method == 'fixed'
+                            and revision.date == today)
+                        or product.type == 'service'):
+                    cost = revision.get_cost_price(product.cost_price)
+                    costs[cost].append(product)
+                    records.remove(product)
+            Revision.save(revisions)
+            Product.recompute_cost_price(products)  # TODO start
+        elif context['active_model'] == 'product.template':
+            recompute_cost_price = Template.recompute_cost_price
+            templates = records = Template.browse(context['active_ids'])
+            for template in templates:
+                revision = self.get_revision(Revision)
+                revision.template = template
+                revisions.append(revision)
+                if ((template.cost_price_method == 'fixed'
+                            and revision.date == today)
+                        or product.type == 'service'):
+                    for product in template.products:
+                        cost = revision.get_cost_price(product.cost_price)
+                        costs[cost].append(product)
+                    records.remove(template)
+        Revision.save(revisions)
+        if costs:
+            Product.update_cost_price(costs)
+        if records:
+            recompute_cost_price(records)  # TODO start
+        return 'end'
+
+    def get_revision(self, Revision):
+        return Revision(
+            company=Transaction().context.get('company'),
+            date=self.start.date,
+            cost_price=self.start.cost_price,
+            )
+
+
+class ModifyCostPriceStart(ModelView):
+    "Modify Cost Price"
+    __name__ = 'product.modify_cost_price.start'
+    date = fields.Date("Date", required=True)
+    cost_price = fields.Char(
+        "New Cost Price", required=True,
+        help="Python expression that will be evaluated with:\n"
+        "- cost_price: the current cost price of the product")
+
+    @classmethod
+    def default_date(cls):
+        pool = Pool()
+        Date = pool.get('ir.date')
+        return Date.today()
+
+    @classmethod
+    def default_cost_price(cls):
+        return 'cost_price'
+
+
+class CostPriceRevision(ModelSQL, ModifyCostPriceStart):
+    "Product Cost Price Revision"
+    __name__ = 'product.cost_price.revision'
+    template = fields.Many2One(
+        'product.template', "Product",
+        ondelete='CASCADE', select=True, required=True,
+        domain=[
+            If(Bool(Eval('product')),
+                ('products', '=', Eval('product')),
+                ()),
+            ],
+        depends=['product'])
+    product = fields.Many2One(
+        'product.product', "Variant",
+        ondelete='CASCADE', select=True,
+        domain=[
+            If(Bool(Eval('template')),
+                ('template', '=', Eval('template')),
+                ()),
+            ],
+        depends=['template'])
+    company = fields.Many2One(
+        'company.company', "Company", ondelete='CASCADE', required=True)
+
+    @classmethod
+    def __setup__(cls):
+        super().__setup__()
+        cls._order.insert(0, ('date', 'DESC'))
+
+    @classmethod
+    def default_company(cls):
+        return Transaction().context.get('company')
+
+    @fields.depends('product', '_parent_product.template')
+    def on_change_product(self):
+        if self.product:
+            self.template = self.product.template
+
+    @classmethod
+    def validate(cls, revisions):
+        super().validate(revisions)
+        for revision in revisions:
+            revision.check_cost_price()
+
+    def check_cost_price(self):
+        try:
+            if not isinstance(self.get_cost_price(Decimal(0)), Decimal):
+                raise ValueError
+        except Exception as exception:
+            raise ProductCostPriceError(
+                'stock.msg_invalid_cost_price',
+                cost_price=self.cost_price,
+                product=self.product.rec_name,
+                exception=exception) from exception
+
+    def get_cost_price(self, cost_price, **context):
+        context.setdefault('names', {})['cost_price'] = cost_price
+        context.setdefault('functions', {})['Decimal'] = Decimal
+        return simple_eval(decistmt(self.cost_price), **context)
+
+    @classmethod
+    def _get_for_product_domain(cls):
+        context = Transaction().context
+        return [
+            ('company', '=', context.get('company')),
+            ]
+
+    @classmethod
+    def get_for_product(cls, product):
+        revisions = cls.search(['OR',
+                ('product', '=', product.id),
+                [
+                    ('template', '=', product.template.id),
+                    ('product', '=', None),
+                    ],
+                ],
+            order=[('date', 'ASC'), ('id', 'ASC')])
+        return revisions
+
+    @classmethod
+    def apply_up_to(cls, revisions, cost_price, date):
+        """Apply revision to cost price up to date
+        revisions list is modified"""
+        try:
+            while True:
+                revision = revisions.pop(0)
+                if revision.date <= date:
+                    cost_price = revision.get_cost_price(cost_price)
+                else:
+                    revisions.insert(0, revision)
+                    break
+        except IndexError:
+            pass
+        return cost_price
diff -r 49eea85821ea -r eb8be9375a53 product.xml
--- a/product.xml       Sun Jan 26 15:29:23 2020 +0100
+++ b/product.xml       Sun Jan 26 21:10:40 2020 +0100
@@ -44,6 +44,87 @@
             <field name="interval_type">days</field>
         </record>
 
+        <record model="ir.ui.view" id="product_cost_price_revision_view_form">
+            <field name="model">product.cost_price.revision</field>
+            <field name="type">form</field>
+            <field name="name">product_cost_price_revision_form</field>
+        </record>
+        <record model="ir.ui.view" id="product_cost_price_revision_view_list">
+            <field name="model">product.cost_price.revision</field>
+            <field name="type">tree</field>
+            <field name="name">product_cost_price_revision_list</field>
+        </record>
+
+        <record model="ir.action.act_window" 
id="act_product_cost_price_revision">
+            <field name="name">Cost Price Revision</field>
+            <field name="res_model">product.cost_price.revision</field>
+            <field
+                name="domain"
+                eval="[If(Eval('active_ids', []) == [Eval('active_id')], 
(If(Eval('active_model') == 'product.template', 'template', 'product'), '=', 
Eval('active_id', -1)), (If(Eval('active_model') == 'product.template', 
'template', 'product'), 'in', Eval('active_ids', [])))]"
+                pyson="1"/>
+        </record>
+        <record model="ir.action.act_window.view" 
id="act_product_cost_price_revision_list_view">
+            <field name="sequence" eval="10"/>
+            <field name="view" ref="product_cost_price_revision_view_list"/>
+            <field name="act_window" ref="act_product_cost_price_revision"/>
+        </record>
+        <record model="ir.action.act_window.view" 
id="act_product_cost_price_revision_form_view">
+            <field name="sequence" eval="20"/>
+            <field name="view" ref="product_cost_price_revision_view_form"/>
+            <field name="act_window" ref="act_product_cost_price_revision"/>
+        </record>
+        <record model="ir.action.keyword" 
id="act_product_cost_price_revision_keyword1">
+            <field name="keyword">form_relate</field>
+            <field name="model">product.template,-1</field>
+            <field name="action" ref="act_product_cost_price_revision"/>
+        </record>
+        <record model="ir.action.keyword" 
id="act_product_cost_price_revision_keyword2">
+            <field name="keyword">form_relate</field>
+            <field name="model">product.product,-1</field>
+            <field name="action" ref="act_product_cost_price_revision"/>
+        </record>
+
+        <record model="ir.model.access" 
id="access_product_cost_price_revision">
+            <field name="model" search="[('model', '=', 
'product.cost_price.revision')]"/>
+            <field name="perm_read" eval="True"/>
+            <field name="perm_write" eval="False"/>
+            <field name="perm_create" eval="False"/>
+            <field name="perm_delete" eval="False"/>
+        </record>
+        <record model="ir.model.access" 
id="access_product_cost_price_revision_admin">
+            <field name="model" search="[('model', '=', 
'product.cost_price.revision')]"/>
+            <field name="group" ref="product.group_product_admin"/>
+            <field name="perm_read" eval="True"/>
+            <field name="perm_write" eval="True"/>
+            <field name="perm_create" eval="True"/>
+            <field name="perm_delete" eval="True"/>
+        </record>
+
+        <record model="ir.ui.view" id="product_modify_cost_price_start_form">
+            <field name="model">product.modify_cost_price.start</field>
+            <field name="type">form</field>
+            <field name="name">product_modify_cost_price_start_form</field>
+        </record>
+
+        <record model="ir.action.wizard" id="wizard_product_modify_cost_price">
+            <field name="name">Modify Cost Price</field>
+            <field name="wiz_name">product.modify_cost_price</field>
+        </record>
+        <record model="ir.action.keyword" 
id="product_modify_cost_price_keyword1">
+            <field name="keyword">form_action</field>
+            <field name="model">product.product,-1</field>
+            <field name="action" ref="wizard_product_modify_cost_price"/>
+        </record>
+        <record model="ir.action.keyword" 
id="product_modify_cost_price_keyword2">
+            <field name="keyword">form_action</field>
+            <field name="model">product.template,-1</field>
+            <field name="action" ref="wizard_product_modify_cost_price"/>
+        </record>
+        <record model="ir.action-res.group" 
id="wizard_product_modify_cost_price-group_account">
+            <field name="action" ref="wizard_product_modify_cost_price"/>
+            <field name="group" ref="product.group_product_admin" />
+        </record>
+
         <record model="ir.ui.view" id="location_quantity_view_tree">
             <field name="model">stock.location</field>
             <field name="type">tree</field>
diff -r 49eea85821ea -r eb8be9375a53 setup.py
--- a/setup.py  Sun Jan 26 15:29:23 2020 +0100
+++ b/setup.py  Sun Jan 26 21:10:40 2020 +0100
@@ -51,7 +51,7 @@
 if local_version:
     version += '+' + '.'.join(local_version)
 
-requires = ['python-sql >= 0.4']
+requires = ['python-sql >= 0.4', 'simpleeval']
 for dep in info.get('depends', []):
     if not re.match(r'(ir|res)(\W|$)', dep):
         requires.append(get_require_version('trytond_%s' % dep))
diff -r 49eea85821ea -r eb8be9375a53 tests/scenario_stock_average_cost_price.rst
--- a/tests/scenario_stock_average_cost_price.rst       Sun Jan 26 15:29:23 
2020 +0100
+++ b/tests/scenario_stock_average_cost_price.rst       Sun Jan 26 21:10:40 
2020 +0100
@@ -143,19 +143,14 @@
     >>> product.cost_price
     Decimal('175.0000')
 
-Change Cost Price to 125, to force to write recomputed price later::
-
-    >>> product.cost_price = Decimal('125.0000')
-    >>> product.save()
-    >>> product.cost_price
-    Decimal('125.0000')
+Reduce Cost Price by 80%, to force to write recomputed price later::
 
-Recompute Cost Price::
-
-    >>> recompute = Wizard('product.recompute_cost_price', [product])
-    >>> recompute.execute('recompute')
+    >>> modify_cost_price = Wizard('product.modify_cost_price', [product])
+    >>> modify_cost_price.form.cost_price = 'cost_price * 0.8'
+    >>> modify_cost_price.form.date = today + datetime.timedelta(days=1)
+    >>> modify_cost_price.execute('modify')
     >>> product.cost_price
-    Decimal('175.0000')
+    Decimal('140.0000')
 
 Send one product we don't have in stock::
 
@@ -241,13 +236,6 @@
     >>> negative_product.cost_price
     Decimal('2.0000')
 
-Change Cost Price to 5, to force to write recomputed price later::
-
-    >>> negative_product.cost_price = Decimal('5.0000')
-    >>> negative_product.save()
-    >>> negative_product.cost_price
-    Decimal('5.0000')
-
 Recompute Cost Price::
 
     >>> recompute = Wizard('product.recompute_cost_price', [negative_product])
diff -r 49eea85821ea -r eb8be9375a53 view/product_cost_price_revision_form.xml
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/view/product_cost_price_revision_form.xml Sun Jan 26 21:10:40 2020 +0100
@@ -0,0 +1,15 @@
+<?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 cursor="cost_price">
+    <label name="template"/>
+    <field name="template"/>
+    <label name="product"/>
+    <field name="product"/>
+    <label name="date"/>
+    <field name="date"/>
+    <label name="company"/>
+    <field name="company"/>
+    <label name="cost_price"/>
+    <field name="cost_price" colspan="3"/>
+</form>
diff -r 49eea85821ea -r eb8be9375a53 view/product_cost_price_revision_list.xml
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/view/product_cost_price_revision_list.xml Sun Jan 26 21:10:40 2020 +0100
@@ -0,0 +1,10 @@
+<?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. -->
+<tree>
+    <field name="template" expand="1"/>
+    <field name="product" expand="1"/>
+    <field name="date"/>
+    <field name="cost_price" expand="2"/>
+    <field name="company"/>
+</tree>
diff -r 49eea85821ea -r eb8be9375a53 
view/product_modify_cost_price_start_form.xml
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/view/product_modify_cost_price_start_form.xml     Sun Jan 26 21:10:40 
2020 +0100
@@ -0,0 +1,9 @@
+<?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 col="2">
+    <label name="cost_price"/>
+    <field name="cost_price"/>
+    <label name="date"/>
+    <field name="date"/>
+</form>

Reply via email to