changeset 6542d4065178 in modules/product_cost_history:default
details:
https://hg.tryton.org/modules/product_cost_history?cmd=changeset;node=6542d4065178
description:
Use stock move as history source
issue8858
review280481002
diffstat:
CHANGELOG | 2 +
__init__.py | 3 +-
doc/index.rst | 11 +-
product.py | 140 +++++++++++++++++++++++++---
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, 301 insertions(+), 27 deletions(-)
diffs (443 lines):
diff -r 99f760c933cf -r 6542d4065178 CHANGELOG
--- a/CHANGELOG Wed Dec 04 11:09:36 2019 +0100
+++ b/CHANGELOG Sun Jan 26 22:09:09 2020 +0100
@@ -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 -r 99f760c933cf -r 6542d4065178 __init__.py
--- a/__init__.py Wed Dec 04 11:09:36 2019 +0100
+++ b/__init__.py Sun Jan 26 22:09:09 2020 +0100
@@ -9,6 +9,7 @@
def register():
Pool.register(
- product.ProductCostPrice,
+ product.Product,
+ product.CostPrice,
product.ProductCostHistory,
module='product_cost_history', type_='model')
diff -r 99f760c933cf -r 6542d4065178 doc/index.rst
--- a/doc/index.rst Wed Dec 04 11:09:36 2019 +0100
+++ b/doc/index.rst Sun Jan 26 22:09:09 2020 +0100
@@ -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 -r 99f760c933cf -r 6542d4065178 product.py
--- a/product.py Wed Dec 04 11:09:36 2019 +0100
+++ b/product.py Sun Jan 26 22:09:09 2020 +0100
@@ -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
+from trytond.transaction import Transaction
-__all__ = ['ProductCostPrice', 'ProductCostHistory']
+
+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 ProductCostPrice(metaclass=PoolMeta):
+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 @@
'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 @@
@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 -r 99f760c933cf -r 6542d4065178 setup.py
--- a/setup.py Wed Dec 04 11:09:36 2019 +0100
+++ b/setup.py Sun Jan 26 22:09:09 2020 +0100
@@ -57,6 +57,7 @@
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 @@
),
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 @@
""",
test_suite='tests',
test_loader='trytond.test_loader:Loader',
+ tests_require=tests_require,
)
diff -r 99f760c933cf -r 6542d4065178 tests/scenario_product_cost_history.rst
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/scenario_product_cost_history.rst Sun Jan 26 22:09:09 2020 +0100
@@ -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 -r 99f760c933cf -r 6542d4065178 tests/test_product_cost_history.py
--- a/tests/test_product_cost_history.py Wed Dec 04 11:09:36 2019 +0100
+++ b/tests/test_product_cost_history.py Sun Jan 26 22:09:09 2020 +0100
@@ -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 @@
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 -r 99f760c933cf -r 6542d4065178 tryton.cfg
--- a/tryton.cfg Wed Dec 04 11:09:36 2019 +0100
+++ b/tryton.cfg Sun Jan 26 22:09:09 2020 +0100
@@ -3,5 +3,6 @@
depends:
ir
product
+ stock
xml:
product.xml
diff -r 99f760c933cf -r 6542d4065178 view/product_cost_history_tree.xml
--- a/view/product_cost_history_tree.xml Wed Dec 04 11:09:36 2019 +0100
+++ b/view/product_cost_history_tree.xml Sun Jan 26 22:09:09 2020 +0100
@@ -3,7 +3,6 @@
this repository contains the full copyright notices and license terms. -->
<tree>
<field name="product"/>
- <field name="date" widget="date"/>
- <field name="date" widget="time" string="Time"/>
+ <field name="date"/>
<field name="cost_price"/>
</tree>