Update cost of moves when recomputing product cost

issue8795
issue7271
review260381003
This commit is contained in:
Cédric Krier
2020-01-26 15:29:23 +01:00
parent 6b05c83318
commit f204b7bd0b
11 changed files with 381 additions and 38 deletions
+3
View File
@@ -1,3 +1,6 @@
* Add cron task to recompute product cost
* Update cost of moves when recomputing product cost
Version 5.4.0 - 2019-11-04
* Bug fixes (see mercurial logs for details)
* Add today record in Product Quantities By Warehouse
+3
View File
@@ -10,6 +10,7 @@ from . import product
from . import inventory
from . import configuration
from . import party
from . import ir
from . import res
from .move import StockMixin
@@ -41,6 +42,7 @@ def register():
product.ProductByLocationContext,
product.ProductQuantitiesByWarehouse,
product.ProductQuantitiesByWarehouseContext,
product.RecomputeCostPriceStart,
inventory.Inventory,
inventory.InventoryLine,
inventory.CountSearch,
@@ -48,6 +50,7 @@ def register():
configuration.Configuration,
configuration.ConfigurationSequence,
configuration.ConfigurationLocation,
ir.Cron,
res.User,
module='stock', type_='model')
Pool.register(
+2
View File
@@ -109,6 +109,8 @@ A move can be in one of this states:
A phantom state used to create in advance move that should not be taken for
stock computation.
A cron task runs every day and recomputes the cost price of moves if a past
unit price has changed.
Product Quantities
------------------
+15
View File
@@ -0,0 +1,15 @@
# 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 trytond.pool import PoolMeta
class Cron(metaclass=PoolMeta):
__name__ = 'ir.cron'
@classmethod
def __setup__(cls):
super().__setup__()
cls.method.selection.extend([
('product.product|recompute_cost_price_from_moves',
"Recompute Cost Price from Moves"),
])
+17 -1
View File
@@ -245,6 +245,12 @@ class Move(Workflow, ModelSQL, ModelView):
'readonly': Eval('state') != 'draft',
},
depends=['unit_price_required', 'state'])
unit_price_updated = fields.Boolean(
"Unit Price Updated", readonly=True,
states={
'invisible': Eval('state') != 'done',
},
depends=['state'])
cost_price = fields.Numeric('Cost Price', digits=price_digits,
readonly=True)
currency = fields.Many2One('currency.currency', 'Currency',
@@ -269,7 +275,7 @@ class Move(Workflow, ModelSQL, ModelView):
'from_location', 'to_location', 'company', 'currency'])
cls._deny_modify_done_cancel = (cls._deny_modify_assigned |
set(['planned_date', 'effective_date', 'state']))
cls._allow_modify_closed_period = set()
cls._allow_modify_closed_period = {'cost_price'}
t = cls.__table__()
cls._sql_constraints += [
@@ -345,6 +351,10 @@ class Move(Workflow, ModelSQL, ModelView):
def default_company():
return Transaction().context.get('company')
@classmethod
def default_unit_price_updated(cls):
return True
@staticmethod
def default_currency():
Company = Pool().get('company.company')
@@ -687,6 +697,7 @@ class Move(Workflow, ModelSQL, ModelView):
super(Move, cls).write(*args)
to_write = []
unit_price_update = []
actions = iter(args)
for moves, values in zip(actions, actions):
if any(f not in cls._allow_modify_closed_period for f in values):
@@ -700,8 +711,13 @@ class Move(Workflow, ModelSQL, ModelView):
to_write.extend(([move], {
'internal_quantity': internal_quantity,
}))
if move.state == 'done' and 'unit_price' in values:
unit_price_update.append(move)
if to_write:
cls.write(*to_write)
if unit_price_update:
cls.write(unit_price_update, {'unit_price_updated': True})
@classmethod
def delete(cls, moves):
+155 -36
View File
@@ -13,7 +13,8 @@ from sql.conditionals import Coalesce
from trytond.i18n import gettext
from trytond.model import ModelSQL, ModelView, fields
from trytond.model.exceptions import AccessError
from trytond.wizard import Wizard, StateTransition, StateAction
from trytond.wizard import (
Wizard, StateTransition, StateAction, StateView, Button)
from trytond.pyson import Eval, Or, PYSONEncoder
from trytond.transaction import Transaction
from trytond.pool import Pool, PoolMeta
@@ -21,11 +22,6 @@ from trytond.tools import grouped_slice
from .move import StockMixin
__all__ = ['Template', 'Product',
'ProductByLocationContext',
'ProductQuantitiesByWarehouse', 'ProductQuantitiesByWarehouseContext',
'RecomputeCostPrice']
def check_no_move(func):
@@ -102,12 +98,12 @@ class Template(metaclass=PoolMeta):
super(Template, cls).write(*args)
@classmethod
def recompute_cost_price(cls, templates):
def recompute_cost_price(cls, templates, start=None):
pool = Pool()
Product = pool.get('product.product')
products = [p for t in templates for p in t.products]
Product.recompute_cost_price(products)
Product.recompute_cost_price(products, start=start)
class Product(StockMixin, object, metaclass=PoolMeta):
@@ -211,17 +207,48 @@ class Product(StockMixin, object, metaclass=PoolMeta):
return quantities
@classmethod
def recompute_cost_price(cls, products):
def recompute_cost_price_from_moves(cls):
pool = Pool()
Move = pool.get('stock.move')
products = set()
for move in Move.search([
('unit_price_updated', '=', True),
cls._domain_moves_cost(),
],
order=[('effective_date', 'ASC')]):
if move.product not in products:
cls.__queue__.recompute_cost_price(
[move.product], start=move.effective_date)
products.add(move.product)
@classmethod
def recompute_cost_price(cls, products, start=None):
pool = Pool()
Move = pool.get('stock.move')
digits = cls.cost_price.digits
costs = defaultdict(list)
for product in products:
if product.type == 'service':
continue
cost = getattr(product,
'recompute_cost_price_%s' % product.cost_price_method)()
cost = getattr(
product, 'recompute_cost_price_%s' %
product.cost_price_method)(start)
cost = cost.quantize(Decimal(str(10.0 ** -digits[1])))
costs[cost].append(product)
updated = []
for sub_products in grouped_slice(products):
domain = [
('unit_price_updated', '=', True),
cls._domain_moves_cost(),
('product', 'in', [p.id for p in sub_products]),
]
if start:
domain.append(('effective_date', '>=', start))
updated += Move.search(domain, order=[])
if updated:
Move.write(updated, {'unit_price_updated': False})
if not costs:
return
@@ -231,45 +258,93 @@ class Product(StockMixin, object, metaclass=PoolMeta):
to_write.append({'cost_price': cost})
# Enforce check access for account_stock*
with Transaction().set_context(_check_access=True):
with Transaction().set_context(_check_access=False):
cls.write(*to_write)
def recompute_cost_price_fixed(self):
def recompute_cost_price_fixed(self, start=None):
return self.cost_price
@property
def _domain_moves_cost(self):
@classmethod
def _domain_moves_cost(cls):
"Returns the domain for moves to use in cost computation"
context = Transaction().context
return [
('company', '=', context.get('company')),
('state', '=', 'done'),
]
def recompute_cost_price_average(self):
def _get_storage_quantity(self, date=None):
pool = Pool()
Location = pool.get('stock.location')
locations = Location.search([
('type', '=', 'storage'),
])
if not date:
date = datetime.date.today()
location_ids = [l.id for l in locations]
with Transaction().set_context(
locations=location_ids,
with_childs=False,
stock_date_end=date):
return self.__class__(self.id).quantity
def recompute_cost_price_average(self, start=None):
pool = Pool()
Move = pool.get('stock.move')
Currency = pool.get('currency.currency')
Uom = pool.get('product.uom')
digits = self.__class__.cost_price.digits
moves = Move.search([
('product', '=', self.id),
('state', '=', 'done'),
self._domain_moves_cost,
['OR',
[
('to_location.type', '=', 'storage'),
('from_location.type', '!=', 'storage'),
],
[
('from_location.type', '=', 'storage'),
('to_location.type', '!=', 'storage'),
],
domain = [
('product', '=', self.id),
self._domain_moves_cost(),
['OR',
[
('to_location.type', '=', 'storage'),
('from_location.type', '!=', 'storage'),
], [
('from_location.type', '=', 'storage'),
('to_location.type', '!=', 'storage'),
],
], order=[('effective_date', 'ASC'), ('id', 'ASC')])
],
]
if start:
domain.append(('effective_date', '>=', start))
moves = Move.search(
domain, order=[('effective_date', 'ASC'), ('id', 'ASC')])
cost_price = Decimal(0)
quantity = 0
if start:
domain.remove(('effective_date', '>=', start))
domain.append(('effective_date', '<', start))
domain.append(
('from_location.type', 'in', ['supplier', 'production']))
prev_moves = Move.search(
domain,
order=[('effective_date', 'DESC'), ('id', 'DESC')],
limit=1)
if prev_moves:
move, = prev_moves
cost_price = move.cost_price
quantity = self._get_storage_quantity(
date=start - datetime.timedelta(days=1))
quantity = Decimal(str(quantity))
current_moves = []
current_cost_price = cost_price
for move in moves:
if (current_moves
and current_moves[-1].effective_date
!= move.effective_date):
Move.write([
m for m in current_moves
if m.cost_price != current_cost_price],
dict(cost_price=current_cost_price))
current_moves.clear()
current_moves.append(move)
qty = Uom.compute_qty(move.uom, move.quantity, self.default_uom)
qty = Decimal(str(qty))
if move.from_location.type == 'storage':
@@ -280,16 +355,23 @@ class Product(StockMixin, object, metaclass=PoolMeta):
unit_price = Currency.compute(
move.currency, move.unit_price,
move.company.currency, round=False)
unit_price = Uom.compute_price(move.uom, unit_price,
self.default_uom)
unit_price = Uom.compute_price(
move.uom, unit_price, self.default_uom)
if quantity + qty > 0 and quantity >= 0:
cost_price = (
(cost_price * quantity) + (unit_price * qty)
) / (quantity + qty)
elif qty > 0:
cost_price = unit_price
current_cost_price = cost_price.quantize(
Decimal(str(10.0 ** -digits[1])))
quantity += qty
return cost_price
Move.write([
m for m in current_moves
if m.cost_price != current_cost_price],
dict(cost_price=current_cost_price))
return current_cost_price
class ProductByLocationContext(ModelView):
@@ -530,9 +612,40 @@ class OpenProductQuantitiesByWarehouse(Wizard):
class RecomputeCostPrice(Wizard):
'Recompute Cost Price'
__name__ = 'product.recompute_cost_price'
start_state = 'recompute'
start = StateView(
'product.recompute_cost_price.start',
'stock.recompute_cost_price_start_view_form', [
Button("Cancel", 'end'),
Button("Recompute", 'recompute', default=True)])
recompute = StateTransition()
def default_start(self, fields):
pool = Pool()
Move = pool.get('stock.move')
Product = pool.get('product.product')
Template = pool.get('product.template')
context = Transaction().context
if context['active_model'] == 'product.product':
products = Product.browse(context['active_ids'])
elif context['active_model'] == 'product.template':
templates = Template.browse(context['active_ids'])
products = sum((t.products for t in templates), ())
from_ = None
for sub_products in grouped_slice(products):
moves = Move.search([
('unit_price_updated', '=', True),
Product._domain_moves_cost(),
('product', 'in', [p.id for p in sub_products]),
],
order=[('effective_date', 'ASC')],
limit=1)
if moves:
move, = moves
from_ = min(from_ or datetime.date.max, move.effective_date)
return {'from_': from_}
def transition_recompute(self):
pool = Pool()
Product = pool.get('product.product')
@@ -542,8 +655,14 @@ class RecomputeCostPrice(Wizard):
if context['active_model'] == 'product.product':
products = Product.browse(context['active_ids'])
Product.recompute_cost_price(products)
Product.recompute_cost_price(products, start=self.start.from_)
elif context['active_model'] == 'product.template':
templates = Template.browse(context['active_ids'])
Template.recompute_cost_price(templates)
Template.recompute_cost_price(templates, start=self.start.from_)
return 'end'
class RecomputeCostPriceStart(ModelView):
"Recompute Cost Price"
__name__ = 'product.recompute_cost_price.start'
from_ = fields.Date("From")
+12 -1
View File
@@ -32,6 +32,18 @@ this repository contains the full copyright notices and license terms. -->
<field name="group" ref="product.group_product_admin"/>
</record>
<record model="ir.ui.view" id="recompute_cost_price_start_view_form">
<field name="model">product.recompute_cost_price.start</field>
<field name="type">form</field>
<field name="name">recompute_cost_price_start_form</field>
</record>
<record model="ir.cron" id="cron_recompute_cost_price_from_moves">
<field name="method">product.product|recompute_cost_price_from_moves</field>
<field name="interval_number" eval="1"/>
<field name="interval_type">days</field>
</record>
<record model="ir.ui.view" id="location_quantity_view_tree">
<field name="model">stock.location</field>
<field name="type">tree</field>
@@ -197,6 +209,5 @@ this repository contains the full copyright notices and license terms. -->
<field name="action" ref="act_product_quantities_warehouse"/>
<field name="group" ref="group_stock"/>
</record>
</data>
</tryton>
@@ -153,6 +153,7 @@ Change Cost Price to 125, to force to write recomputed price later::
Recompute Cost Price::
>>> recompute = Wizard('product.recompute_cost_price', [product])
>>> recompute.execute('recompute')
>>> product.cost_price
Decimal('175.0000')
@@ -250,5 +251,6 @@ Change Cost Price to 5, to force to write recomputed price later::
Recompute Cost Price::
>>> recompute = Wizard('product.recompute_cost_price', [negative_product])
>>> recompute.execute('recompute')
>>> negative_product.cost_price
Decimal('2.0000')
@@ -0,0 +1,160 @@
==================================
Stock Recompute Average Cost Price
==================================
Imports::
>>> import datetime as dt
>>> 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()
Install stock Module::
>>> config = activate_modules('stock')
Create company::
>>> _ = create_company()
>>> company = get_company()
Create product::
>>> ProductUom = Model.get('product.uom')
>>> ProductTemplate = Model.get('product.template')
>>> unit, = ProductUom.find([('name', '=', 'Unit')])
>>> template = ProductTemplate()
>>> template.name = 'Product'
>>> template.default_uom = unit
>>> template.type = 'goods'
>>> template.list_price = Decimal('300')
>>> template.cost_price_method = 'average'
>>> product, = template.products
>>> product.cost_price = Decimal('80')
>>> 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')])
Create some moves::
>>> StockMove = Model.get('stock.move')
>>> StockMove(
... product=product,
... quantity=1,
... from_location=supplier_loc,
... to_location=storage_loc,
... unit_price=Decimal('100'),
... effective_date=today - dt.timedelta(days=2)).click('do')
>>> StockMove(
... product=product,
... quantity=2,
... from_location=storage_loc,
... to_location=customer_loc,
... unit_price=Decimal('300'),
... effective_date=today - dt.timedelta(days=1)).click('do')
>>> StockMove(
... product=product,
... quantity=2,
... from_location=supplier_loc,
... to_location=storage_loc,
... unit_price=Decimal('120'),
... effective_date=today - dt.timedelta(days=1)).click('do')
>>> StockMove(
... product=product,
... quantity=3,
... from_location=supplier_loc,
... to_location=storage_loc,
... unit_price=Decimal('100'),
... effective_date=today).click('do')
>>> [m.cost_price for m in StockMove.find([])]
[Decimal('105.0000'), Decimal('120.0000'), Decimal('100.0000'), Decimal('100.0000')]
>>> product.reload()
>>> product.cost_price
Decimal('105.0000')
Recompute cost price::
>>> recompute = Wizard('product.recompute_cost_price', [product])
>>> recompute.execute('recompute')
>>> [m.cost_price for m in StockMove.find([])]
[Decimal('105.0000'), Decimal('120.0000'), Decimal('120.0000'), Decimal('100.0000')]
>>> product.reload()
>>> product.cost_price
Decimal('105.0000')
Recompute cost price from a date::
>>> recompute = Wizard('product.recompute_cost_price', [product])
>>> recompute.form.from_ = today - dt.timedelta(days=1)
>>> recompute.execute('recompute')
>>> [m.cost_price for m in StockMove.find([])]
[Decimal('105.0000'), Decimal('120.0000'), Decimal('120.0000'), Decimal('100.0000')]
>>> product.reload()
>>> product.cost_price
Decimal('105.0000')
Update unit price of a move::
>>> move, = StockMove.find([
... ('from_location', '=', supplier_loc.id),
... ('effective_date', '=', today - dt.timedelta(days=1)),
... ])
>>> bool(move.unit_price_updated)
False
>>> move.unit_price = Decimal('130')
>>> move.save()
>>> bool(move.unit_price_updated)
True
>>> recompute = Wizard('product.recompute_cost_price', [product])
>>> recompute.form.from_ = move.effective_date + dt.timedelta(days=1)
>>> recompute.execute('recompute')
>>> move.reload()
>>> bool(move.unit_price_updated)
True
>>> recompute = Wizard('product.recompute_cost_price', [product])
>>> recompute.form.from_ == move.effective_date
True
>>> recompute.execute('recompute')
>>> move.reload()
>>> bool(move.unit_price_updated)
False
>>> [m.cost_price for m in StockMove.find([])]
[Decimal('107.5000'), Decimal('130.0000'), Decimal('130.0000'), Decimal('100.0000')]
Launch cron task::
>>> move.unit_price = Decimal('120')
>>> move.save()
>>> Cron = Model.get('ir.cron')
>>> Company = Model.get('company.company')
>>> cron_recompute, = Cron.find([
... ('method', '=', 'product.product|recompute_cost_price_from_moves'),
... ])
>>> cron_recompute.companies.append(Company(company.id))
>>> cron_recompute.click('run_once')
>>> move.reload()
>>> bool(move.unit_price_updated)
False
>>> [m.cost_price for m in StockMove.find([])]
[Decimal('105.0000'), Decimal('120.0000'), Decimal('120.0000'), Decimal('100.0000')]
+5
View File
@@ -1422,6 +1422,11 @@ def suite():
tearDown=doctest_teardown, encoding='utf-8',
checker=doctest_checker,
optionflags=doctest.REPORT_ONLY_FIRST_FAILURE))
suite.addTests(doctest.DocFileSuite(
'scenario_stock_recompute_average_cost_price.rst',
tearDown=doctest_teardown, encoding='utf-8',
checker=doctest_checker,
optionflags=doctest.REPORT_ONLY_FIRST_FAILURE))
suite.addTests(doctest.DocFileSuite(
'scenario_stock_inventory.rst',
tearDown=doctest_teardown, encoding='utf-8',
+7
View File
@@ -0,0 +1,7 @@
<?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>
<label name="from_"/>
<field name="from_"/>
</form>