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>