From 7fd47ef7cf677b986dd4c4671b79ee1cc1dd8cb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sun, 26 Jan 2020 22:09:09 +0100 Subject: [PATCH] Use stock move as history source issue8858 review280481002 --- CHANGELOG | 2 + __init__.py | 3 +- doc/index.rst | 11 +- product.py | 142 ++++++++++++++++++--- setup.py | 4 +- tests/scenario_product_cost_history.rst | 156 ++++++++++++++++++++++++ tests/test_product_cost_history.py | 8 ++ tryton.cfg | 1 + view/product_cost_history_tree.xml | 3 +- 9 files changed, 302 insertions(+), 28 deletions(-) create mode 100644 tests/scenario_product_cost_history.rst diff --git a/CHANGELOG b/CHANGELOG index 9aca611e8c..af7bf40d26 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,5 @@ +* Use stock move as history source for goods and assets + Version 5.4.0 - 2019-11-04 * Bug fixes (see mercurial logs for details) diff --git a/__init__.py b/__init__.py index 4aa1166038..e1b2af2b83 100644 --- a/__init__.py +++ b/__init__.py @@ -9,6 +9,7 @@ __all__ = ['register'] def register(): Pool.register( - product.ProductCostPrice, + product.Product, + product.CostPrice, product.ProductCostHistory, module='product_cost_history', type_='model') diff --git a/doc/index.rst b/doc/index.rst index 8728272d36..7645a6eeae 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -1,8 +1,9 @@ Product Cost History Module ########################### -The Product Cost History Module enable the historization of the cost -price on the product. Every time the product cost is updated, the new -value is stored in an historization table. The module also add a *Cost -History* button on the bottom of the product form that allow to look -at the historical values of the cost price. +The Product Cost History Module adds a *Cost History* relate on the product +form showing the cost price evolution of the product. The history is based on +the cost price stored on the incoming stock moves for goods and assets and +based on the history table for service. +When a historic cost price is needed the value is taken from this history for +goods and assets. diff --git a/product.py b/product.py index d5a65c93bd..b7765162d3 100644 --- a/product.py +++ b/product.py @@ -1,16 +1,58 @@ # 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 sql import Column +from decimal import Decimal + +from sql import Column, Literal, Window from sql.aggregate import Max from sql.conditionals import Coalesce +from sql.functions import CurrentTimestamp, LastValue from trytond.model import ModelView, ModelSQL, fields from trytond.pool import Pool, PoolMeta - -__all__ = ['ProductCostPrice', 'ProductCostHistory'] +from trytond.transaction import Transaction -class ProductCostPrice(metaclass=PoolMeta): +def convert_from(table, tables): + right, condition = tables[None] + if table: + table = table.join(right, condition=condition) + else: + table = right + for k, sub_tables in tables.items(): + if k is None: + continue + table = convert_from(table, sub_tables) + return table + + +class Product(metaclass=PoolMeta): + __name__ = 'product.product' + + def get_multivalue(self, name, **pattern): + context = Transaction().context + if (name == 'cost_price' + and context.get('_datetime') + and self.type in ['goods', 'assets']): + cost_price = self.get_cost_price_at( + context['_datetime'].date()) + if cost_price is not None: + return cost_price + return super().get_multivalue(name, **pattern) + + def get_cost_price_at(self, date): + pool = Pool() + CostHistory = pool.get('product.product.cost_history') + records = CostHistory.search([ + ('date', '<=', date), + ('product', '=', self.id), + ], limit=1, order=[('date', 'DESC')]) + if records: + record, = records + return record.cost_price.quantize( + Decimal(str(10. ** -self.__class__.cost_price.digits[1]))) + + +class CostPrice(metaclass=PoolMeta): __name__ = 'product.cost_price' _history = True @@ -19,7 +61,7 @@ class ProductCostHistory(ModelSQL, ModelView): 'History of Product Cost' __name__ = 'product.product.cost_history' product = fields.Many2One('product.product', "Product") - date = fields.DateTime('Date') + date = fields.Date('Date') cost_price = fields.Numeric('Cost Price') @classmethod @@ -30,20 +72,82 @@ class ProductCostHistory(ModelSQL, ModelView): @classmethod def table_query(cls): pool = Pool() - ProductCostPrice = pool.get('product.cost_price') - history = ProductCostPrice.__table_history__() - return history.select(Max(Column(history, '__id')).as_('id'), - Max(history.create_uid).as_('create_uid'), - Max(history.create_date).as_('create_date'), - Max(history.write_uid).as_('write_uid'), - Max(history.write_date).as_('write_date'), - Coalesce(history.write_date, - history.create_date).as_('date'), - history.product.as_('product'), - history.cost_price.as_('cost_price'), - group_by=(history.id, - Coalesce(history.write_date, history.create_date), - history.product, history.cost_price)) + Move = pool.get('stock.move') + Product = pool.get('product.product') + Template = pool.get('product.template') + CostPrice = pool.get('product.cost_price') + move = Move.__table__() + product = Product.__table__() + template = Template.__table__() + history = CostPrice.__table_history__() + database = Transaction().database + + tables, clause = Move.search_domain([ + ('state', '=', 'done'), + Product()._domain_moves_cost(), + # Incoming moves + ('to_location.type', '=', 'storage'), + ('from_location.type', '!=', 'storage'), + ], tables={ + None: (move, None), + }) + + if database.has_window_functions(): + window = Window( + [move.effective_date, move.product], + order_by=[move.write_date.asc, move.id.asc]) + cost_price = LastValue(move.cost_price, window=window) + else: + cost_price = cls.cost_price.sql_cast(move.cost_price) + + move_history = convert_from(None, tables).select( + (move.id * 2).as_('id'), + move.effective_date.as_('date'), + move.product.as_('product'), + cost_price.as_('cost_price'), + where=clause) + query = move_history.select( + Max(move_history.id).as_('id'), + Literal(0).as_('create_uid'), + CurrentTimestamp().as_('create_date'), + Literal(None).as_('write_uid'), + Literal(None).as_('write_date'), + move_history.date.as_('date'), + move_history.product.as_('product'), + Max(move_history.cost_price).as_('cost_price'), + group_by=[move_history.date, move_history.product]) + + price_datetime = Coalesce(history.write_date, history.create_date) + price_date = cls.date.sql_cast(price_datetime) + if database.has_window_functions(): + window = Window( + [price_date, history.product], + order_by=[price_datetime.asc]) + cost_price = LastValue(history.cost_price, window=window) + else: + cost_price = cls.cost_price.sql_cast(history.cost_price) + + price_history = (history + .join(product, condition=history.product == product.id) + .join(template, condition=product.template == template.id) + .select( + (Column(history, '__id') * 2 + 1).as_('id'), + price_date.as_('date'), + history.product.as_('product'), + cost_price.as_('cost_price'), + where=~template.type.in_(['goods', 'assets']))) + + query |= price_history.select( + Max(price_history.id).as_('id'), + Literal(0).as_('create_uid'), + CurrentTimestamp().as_('create_date'), + Literal(None).as_('write_uid'), + Literal(None).as_('write_date'), + price_history.date.as_('date'), + price_history.product.as_('product'), + Max(price_history.cost_price).as_('cost_price'), + group_by=[price_history.date, price_history.product]) + return query def get_rec_name(self, name): return str(self.date) diff --git a/setup.py b/setup.py index ae03a70d18..20a53ebfa0 100755 --- a/setup.py +++ b/setup.py @@ -57,6 +57,7 @@ for dep in info.get('depends', []): requires.append(get_require_version('trytond_%s' % dep)) requires.append(get_require_version('trytond')) +tests_require = [get_require_version('proteus')] dependency_links = [] if minor_version % 2: dependency_links.append('https://trydevpi.tryton.org/') @@ -84,7 +85,7 @@ setup(name=name, ), package_data={ 'trytond.modules.product_cost_history': (info.get('xml', []) - + ['tryton.cfg', 'view/*.xml', 'locale/*.po']), + + ['tryton.cfg', 'view/*.xml', 'locale/*.po', 'tests/*.rst']), }, classifiers=[ 'Development Status :: 5 - Production/Stable', @@ -134,4 +135,5 @@ setup(name=name, """, test_suite='tests', test_loader='trytond.test_loader:Loader', + tests_require=tests_require, ) diff --git a/tests/scenario_product_cost_history.rst b/tests/scenario_product_cost_history.rst new file mode 100644 index 0000000000..fe78f1ea33 --- /dev/null +++ b/tests/scenario_product_cost_history.rst @@ -0,0 +1,156 @@ +==================== +Product Cost History +==================== + +Imports:: + + >>> import datetime as dt + >>> import time + >>> 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() + >>> now = dt.datetime.now() + +Install product_cost_history Module:: + + >>> config = activate_modules('product_cost_history') + +Create company:: + + >>> _ = create_company() + >>> company = get_company() + +Create product:: + + >>> ProductUom = Model.get('product.uom') + >>> ProductTemplate = Model.get('product.template') + >>> Product = Model.get('product.product') + >>> unit, = ProductUom.find([('name', '=', 'Unit')]) + + >>> template = ProductTemplate() + >>> template.name = 'Product' + >>> template.default_uom = unit + >>> template.type = 'goods' + >>> template.list_price = Decimal('200.0000') + >>> product, = template.products + >>> product.cost_price = Decimal('100.0000') + >>> 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')]) + +Cost history is empty:: + + >>> ProductCostHistory = Model.get('product.product.cost_history') + >>> ProductCostHistory.find([]) + [] + +Create some moves at different dates and with different cost:: + + >>> StockMove = Model.get('stock.move') + >>> product.cost_price = Decimal('100.0000') + >>> product.save() + >>> StockMove( + ... product=product, + ... quantity=1, + ... from_location=supplier_loc, + ... to_location=storage_loc, + ... unit_price=Decimal('100.0000'), + ... effective_date=today - dt.timedelta(days=2)).click('do') + >>> modify_cost_price = Wizard('product.modify_cost_price', [product]) + >>> modify_cost_price.form.cost_price = '90.0000' + >>> modify_cost_price.execute('modify') + >>> StockMove( + ... product=product, + ... quantity=1, + ... from_location=supplier_loc, + ... to_location=storage_loc, + ... unit_price=Decimal('90.0000'), + ... effective_date=today - dt.timedelta(days=1)).click('do') + >>> modify_cost_price = Wizard('product.modify_cost_price', [product]) + >>> modify_cost_price.form.cost_price = '120.0000' + >>> modify_cost_price.execute('modify') + >>> StockMove( + ... product=product, + ... quantity=1, + ... from_location=supplier_loc, + ... to_location=storage_loc, + ... unit_price=Decimal('120.0000'), + ... effective_date=today - dt.timedelta(days=1)).click('do') + >>> modify_cost_price = Wizard('product.modify_cost_price', [product]) + >>> modify_cost_price.form.cost_price = '110.0000' + >>> modify_cost_price.execute('modify') + >>> StockMove( + ... product=product, + ... quantity=1, + ... from_location=supplier_loc, + ... to_location=storage_loc, + ... unit_price=Decimal('110.0000'), + ... effective_date=today).click('do') + + +Check cost history:: + + >>> order = [('date', 'ASC')] + >>> [c.cost_price for c in ProductCostHistory.find([], order=order)] == [ + ... Decimal('100.0000'), Decimal('120.0000'), Decimal('110.0000')] + True + >>> [str(c.date) for c in ProductCostHistory.find([], order=order)] == [ + ... str(today - dt.timedelta(days=2)), + ... str(today - dt.timedelta(days=1)), + ... str(today)] + True + +Check cost price history on product:: + + >>> product.reload() + >>> product.cost_price + Decimal('110.0000') + + >>> with config.set_context(_datetime=now - dt.timedelta(days=3)): + ... product = Product(product.id) + >>> product.cost_price + Decimal('0') + + >>> with config.set_context(_datetime=now - dt.timedelta(days=2)): + ... product = Product(product.id) + >>> product.cost_price + Decimal('100.0000') + + >>> with config.set_context(_datetime=now - dt.timedelta(days=1)): + ... product = Product(product.id) + >>> product.cost_price + Decimal('120.0000') + + +Create service:: + + >>> template = ProductTemplate() + >>> template.name = 'Service' + >>> template.default_uom = unit + >>> template.type = 'service' + >>> template.list_price = Decimal('50.0000') + >>> service, = template.products + >>> service.cost_price = Decimal('30.0000') + >>> template.save() + >>> sevice, = template.products + +Update cost price:: + + >>> service.cost_price += 5 + >>> service.save() + +Check cost history:: + + >>> history, = ProductCostHistory.find([('product', '=', service.id)]) + >>> history.cost_price == Decimal('35.0000') + True diff --git a/tests/test_product_cost_history.py b/tests/test_product_cost_history.py index b598c53f6b..d0aa57eb50 100644 --- a/tests/test_product_cost_history.py +++ b/tests/test_product_cost_history.py @@ -1,8 +1,11 @@ # 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 doctest import unittest import trytond.tests.test_tryton from trytond.tests.test_tryton import ModuleTestCase +from trytond.tests.test_tryton import doctest_teardown +from trytond.tests.test_tryton import doctest_checker class ProductCostHistoryTestCase(ModuleTestCase): @@ -14,4 +17,9 @@ def suite(): suite = trytond.tests.test_tryton.suite() suite.addTests(unittest.TestLoader().loadTestsFromTestCase( ProductCostHistoryTestCase)) + suite.addTests(doctest.DocFileSuite( + 'scenario_product_cost_history.rst', + tearDown=doctest_teardown, encoding='utf-8', + checker=doctest_checker, + optionflags=doctest.REPORT_ONLY_FIRST_FAILURE)) return suite diff --git a/tryton.cfg b/tryton.cfg index c499029b84..10a579505e 100644 --- a/tryton.cfg +++ b/tryton.cfg @@ -3,5 +3,6 @@ version=5.5.0 depends: ir product + stock xml: product.xml diff --git a/view/product_cost_history_tree.xml b/view/product_cost_history_tree.xml index 1e11954742..95e49a941f 100644 --- a/view/product_cost_history_tree.xml +++ b/view/product_cost_history_tree.xml @@ -3,7 +3,6 @@ this repository contains the full copyright notices and license terms. --> - - +