Use stock move as history source
issue8858 review280481002
This commit is contained in:
1 parent
5bf788919c
commit
7fd47ef7cf
9 files changed
+302
-28
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -3,5 +3,6 @@ version=5.5.0
|
||||
depends:
|
||||
ir
|
||||
product
|
||||
stock
|
||||
xml:
|
||||
product.xml
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user