Use stock move as history source

issue8858
review280481002
This commit is contained in:
Cédric Krier committed 2020-01-26 22:09:09 +01:00
1 parent 5bf788919c
commit 7fd47ef7cf
9 files changed
+302 -28

No files matched your search

+2
View File
@@ -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)
+2 -1
View File
@@ -9,6 +9,7 @@ __all__ = ['register']
def register():
Pool.register(
product.ProductCostPrice,
product.Product,
product.CostPrice,
product.ProductCostHistory,
module='product_cost_history', type_='model')
+6 -5
View File
@@ -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.
+123 -19
View File
@@ -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)
+3 -1
View File
@@ -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,
)
+156
View File
@@ -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
+8
View File
@@ -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
+1
View File
@@ -3,5 +3,6 @@ version=5.5.0
depends:
ir
product
stock
xml:
product.xml
+1 -2
View File
@@ -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>