add cost recalculation patches

This commit is contained in:
Àngel Àlvarez 2021-04-09 10:36:06 +02:00
parent e513bf349b
commit 4b42d9a3aa
16 changed files with 2435 additions and 7 deletions

View File

@ -0,0 +1,18 @@
diff --git a/production.py b/production.py
index afdcdc3..85e6ec5 100644
--- a/trytond/trytond/modules/production/production.py
+++ b/trytond/trytond/modules/production/production.py
@@ -467,6 +467,13 @@ class Production(Workflow, ModelSQL, ModelView):
if move.production_input not in productions:
cls.__queue__.set_cost([move.production_input])
productions.add(move.production_input)
+ pending_productions = cls.search([
+ ('inputs', '=', None),
+ ('state', '!=', 'cancel'),
+ ])
+ for production in pending_productions:
+ cls.__queue__.set_cost([production])
+ productions.add(production)
Move.write(moves, {'production_cost_price_updated': False})
@classmethod

View File

@ -1,13 +1,14 @@
diff --git a/move.py b/move.py
index 94fa17d..7e4d733 100644
index 1b41353..85c89eb 100644
--- a/trytond/trytond/modules/product_cost_fifo/move.py
+++ b/trytond/trytond/modules/product_cost_fifo/move.py
@@ -63,7 +63,7 @@ class Move(metaclass=PoolMeta):
cost_price += move_unit_price * Decimal(str(move_qty))
@@ -105,6 +105,9 @@ class Move(metaclass=PoolMeta):
move_qty = Uom.compute_qty(self.product.default_uom, move_qty,
- move.uom, round=False)
+ move.uom, round=True)
move.uom, round=False)
move.fifo_quantity = (move.fifo_quantity or 0.0) + move_qty
+ # Due to float, the fifo quantity result can exceed the quantity.
+ assert move.quantity >= move.fifo_quantity - move.uom.rounding
+ move.fifo_quantity = min(move.fifo_quantity, move.quantity)
to_save.append(move)
if to_save:
# TODO save in do method when product change

View File

@ -0,0 +1,126 @@
From d23677f089ff7e283e30a1aaa0e1ed2e42c31fa9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=80ngel=20=C3=80lvarez?= <angel@nan-tic.com>
Date: Thu, 8 Apr 2021 11:53:18 +0200
Subject: [PATCH] Use original cost price for returned move
and factorize the cost price of move for cost computation
issue9440
review327491003
---
move.py | 23 +++++------------------
product.py | 23 ++---------------------
2 files changed, 7 insertions(+), 39 deletions(-)
diff --git a/move.py b/move.py
index 9e20a1d..423a4bd 100644
--- a/trytond/trytond/modules/product_cost_fifo/move.py
+++ b/trytond/trytond/modules/product_cost_fifo/move.py
@@ -68,7 +68,6 @@ class Move(metaclass=PoolMeta):
'''
pool = Pool()
Uom = pool.get('product.uom')
- Currency = pool.get('currency.currency')
total_qty = Uom.compute_qty(self.uom, self.quantity,
self.product.default_uom, round=False)
@@ -81,16 +80,7 @@ class Move(metaclass=PoolMeta):
to_save = []
for move, move_qty in fifo_moves:
consumed_qty += move_qty
- if move.from_location.type in {'supplier', 'production'}:
- with Transaction().set_context(date=move.effective_date):
- move_unit_price = Currency.compute(
- move.currency, move.unit_price,
- self.company.currency, round=False)
- move_unit_price = Uom.compute_price(
- move.uom, move_unit_price, move.product.default_uom)
- else:
- move_unit_price = move.cost_price or 0
- cost_price += move_unit_price * Decimal(str(move_qty))
+ cost_price += move.get_cost_price() * Decimal(str(move_qty))
move_qty = Uom.compute_qty(self.product.default_uom, move_qty,
move.uom, round=False)
@@ -110,12 +100,9 @@ class Move(metaclass=PoolMeta):
'cost_price', **self._cost_price_pattern)
# Compute average cost price
- unit_price = self.unit_price
- self.unit_price = Uom.compute_price(
- self.product.default_uom, cost_price, self.uom)
- average_cost_price = self._compute_product_cost_price('out')
- self.unit_price = unit_price
-
+ average_cost_price = self._compute_product_cost_price(
+ 'out', product_cost_price=cost_price)
+
if cost_price:
digits = self.__class__.cost_price.digits
cost_price = cost_price.quantize(
@@ -126,7 +113,7 @@ class Move(metaclass=PoolMeta):
def _do(self):
cost_price = super(Move, self)._do()
- if (self.from_location.type in ('supplier', 'production')
+ if (self.from_location.type != 'storage'
and self.to_location.type == 'storage'
and self.product.cost_price_method == 'fifo'):
cost_price = self._compute_product_cost_price('in')
diff --git a/product.py b/product.py
index b7e25bb..13e7d95 100644
--- a/trytond/trytond/modules/product_cost_fifo/product.py
+++ b/trytond/trytond/modules/product_cost_fifo/product.py
@@ -4,7 +4,6 @@ import datetime as dt
from decimal import Decimal
from trytond.config import config
-from trytond.transaction import Transaction
from trytond.pool import Pool, PoolMeta
__all__ = ['Template', 'Product']
@@ -92,7 +91,6 @@ class Product(metaclass=PoolMeta):
def recompute_cost_price_fifo(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
@@ -148,16 +146,7 @@ class Product(metaclass=PoolMeta):
consumed_qty = 0
for move, move_qty in fifo_moves:
consumed_qty += move_qty
- if move.from_location.type in {'supplier', 'production'}:
- with Transaction().set_context(date=move.effective_date):
- unit_price = Currency.compute(
- move.currency, move.unit_price,
- move.company.currency, round=False)
- unit_price = Uom.compute_price(
- move.uom, unit_price, move.product.default_uom)
- else:
- unit_price = move.cost_price or 0
- cost_price += unit_price * Decimal(str(move_qty))
+ cost_price += move.get_cost_price() * Decimal(str(move_qty))
if consumed_qty:
return (cost_price / Decimal(str(consumed_qty))).quantize(
Decimal(str(10.0 ** -digits[1])))
@@ -203,15 +192,7 @@ class Product(metaclass=PoolMeta):
if move.from_location.type == 'storage':
qty *= -1
if in_move(move):
- if move.from_location.type in {'supplier', 'production'}:
- with Transaction().set_context(date=move.effective_date):
- 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)
- else:
- unit_price = cost_price
+ unit_price = move.get_cost_price(product_cost_price=cost_price)
if quantity + qty > 0 and quantity >= 0:
cost_price = (
(cost_price * quantity) + (unit_price * qty)
--
2.25.1

View File

@ -0,0 +1,52 @@
From 017277e73dbe9120a983d4c3bf27a7bc3e5ee9e6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=80ngel=20=C3=80lvarez?= <angel@nan-tic.com>
Date: Thu, 8 Apr 2021 12:07:12 +0200
Subject: [PATCH] product_cost_fifo: Do not update average cost price if the
quantity is negative
This is the same test done in Move._compute_product_cost_price. When the stock
quantity is below zero, the average cost price should not change.
issue9484
---
product.py | 11 +++++++----
1 file changed, 7 insertions(+), 4 deletions(-)
diff --git a/product.py b/product.py
index 13e7d95..415d833 100644
--- a/trytond/trytond/modules/product_cost_fifo/product.py
+++ b/trytond/trytond/modules/product_cost_fifo/product.py
@@ -138,9 +138,7 @@ class Product(metaclass=PoolMeta):
return move.from_location.type == 'storage'
def compute_fifo_cost_price(quantity, date):
- fifo_moves = self.get_fifo_move(
- float(quantity),
- date=current_moves[-1].effective_date)
+ fifo_moves = self.get_fifo_move(float(quantity), date=date)
cost_price = Decimal(0)
consumed_qty = 0
@@ -151,6 +149,11 @@ class Product(metaclass=PoolMeta):
return (cost_price / Decimal(str(consumed_qty))).quantize(
Decimal(str(10.0 ** -digits[1])))
+ # For each day, process the incoming moves first
+ # in order to keep quantity positive where possible
+ # We do not re-browse because we expect only small changes
+ moves = sorted(moves, key=lambda m: (
+ m.effective_date, out_move(m), m.id))
current_moves = []
current_out_qty = 0
current_cost_price = cost_price
@@ -173,7 +176,7 @@ class Product(metaclass=PoolMeta):
m for m in out_moves
if m.cost_price != fifo_cost_price],
dict(cost_price=fifo_cost_price))
- if quantity:
+ if quantity > 0 and quantity + current_out_qty >= 0:
cost_price = (
((current_cost_price * (
quantity + current_out_qty))
--
2.25.1

View File

@ -0,0 +1,36 @@
From 75cb0d7a6bbadbef4b133772454be2f33118db7f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=80ngel=20=C3=80lvarez?= <angel@nan-tic.com>
Date: Thu, 8 Apr 2021 12:11:17 +0200
Subject: [PATCH] Enforce filling cost price of move
We set cost price only for outgoing or incoming moves.
issue9397
---
move.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/move.py b/move.py
index 423a4bd..f4a7b61 100644
--- a/trytond/trytond/modules/product_cost_fifo/move.py
+++ b/trytond/trytond/modules/product_cost_fifo/move.py
@@ -102,7 +102,7 @@ class Move(metaclass=PoolMeta):
# Compute average cost price
average_cost_price = self._compute_product_cost_price(
'out', product_cost_price=cost_price)
-
+
if cost_price:
digits = self.__class__.cost_price.digits
cost_price = cost_price.quantize(
@@ -126,7 +126,7 @@ class Move(metaclass=PoolMeta):
and self.product.cost_price_method == 'fifo'):
fifo_cost_price, cost_price = (
self._update_fifo_out_product_cost_price())
- if self.cost_price is None:
+ if self.cost_price_required and self.cost_price is None:
self.cost_price = fifo_cost_price
return cost_price
--
2.25.1

View File

@ -0,0 +1,189 @@
From eaae8d1ba4909042e276065d106c47e9117b2a13 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=80ngel=20=C3=80lvarez?= <angel@nan-tic.com>
Date: Thu, 8 Apr 2021 10:40:42 +0200
Subject: [PATCH] product_cost_fifo: Use all moves to compute FIFO
We must consider any incoming moves (ex: inventory) for the computation of the
FIFO otherwise the back computation to find first in moves does not pick enough
moves. As not all incoming moves have a unit price, we use the current cost
price as fallback.
Also in re-computation, the in or out move test should only rely on the storage
location usage in order to properly compute the average cost.
issue9274
product_cost_fifo: Keep last cost price when quantity is zero
When there is no quantity in stock, we keep the last cost price instead of
setting it to zero. This gives the same behavior between on move computation
and on product recomputation.
issue9443
---
product.py | 41 +++++++++++--------
...product_cost_fifo_recompute_cost_price.rst | 23 +++++++----
2 files changed, 38 insertions(+), 26 deletions(-)
diff --git a/product.py b/product.py
index 0c204bd..b7e25bb 100644
--- a/trytond/trytond/modules/product_cost_fifo/product.py
+++ b/trytond/trytond/modules/product_cost_fifo/product.py
@@ -30,7 +30,7 @@ class Product(metaclass=PoolMeta):
domain = [
('product', '=', self.id),
self._domain_moves_cost(),
- ('from_location.type', 'in', ['supplier', 'production']),
+ ('from_location.type', '!=', 'storage'),
('to_location.type', '=', 'storage'),
]
if not date:
@@ -134,11 +134,10 @@ class Product(metaclass=PoolMeta):
quantity = Decimal(str(quantity))
def in_move(move):
- return (move.from_location.type in ['supplier', 'production']
- or move.to_location.type == 'supplier')
+ return move.to_location.type == 'storage'
def out_move(move):
- return not in_move(move)
+ return move.from_location.type == 'storage'
def compute_fifo_cost_price(quantity, date):
fifo_moves = self.get_fifo_move(
@@ -149,12 +148,15 @@ class Product(metaclass=PoolMeta):
consumed_qty = 0
for move, move_qty in fifo_moves:
consumed_qty += move_qty
- with Transaction().set_context(date=move.effective_date):
- unit_price = Currency.compute(
- move.currency, move.unit_price,
- move.company.currency, round=False)
- unit_price = Uom.compute_price(
- move.uom, unit_price, move.product.default_uom)
+ if move.from_location.type in {'supplier', 'production'}:
+ with Transaction().set_context(date=move.effective_date):
+ unit_price = Currency.compute(
+ move.currency, move.unit_price,
+ move.company.currency, round=False)
+ unit_price = Uom.compute_price(
+ move.uom, unit_price, move.product.default_uom)
+ else:
+ unit_price = move.cost_price or 0
cost_price += unit_price * Decimal(str(move_qty))
if consumed_qty:
return (cost_price / Decimal(str(consumed_qty))).quantize(
@@ -189,7 +191,7 @@ class Product(metaclass=PoolMeta):
- (fifo_cost_price * current_out_qty))
/ quantity)
else:
- cost_price = Decimal(0)
+ cost_price = current_cost_price
current_cost_price = cost_price.quantize(
Decimal(str(10.0 ** -digits[1])))
current_moves.clear()
@@ -201,12 +203,15 @@ class Product(metaclass=PoolMeta):
if move.from_location.type == 'storage':
qty *= -1
if in_move(move):
- with Transaction().set_context(date=move.effective_date):
- 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)
+ if move.from_location.type in {'supplier', 'production'}:
+ with Transaction().set_context(date=move.effective_date):
+ 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)
+ else:
+ unit_price = cost_price
if quantity + qty > 0 and quantity >= 0:
cost_price = (
(cost_price * quantity) + (unit_price * qty)
@@ -215,7 +220,7 @@ class Product(metaclass=PoolMeta):
cost_price = unit_price
current_cost_price = cost_price.quantize(
Decimal(str(10.0 ** -digits[1])))
- else:
+ elif out_move(move):
current_out_qty += -qty
quantity += qty
diff --git a/tests/scenario_product_cost_fifo_recompute_cost_price.rst b/tests/scenario_product_cost_fifo_recompute_cost_price.rst
index f8b952f..ff76174 100644
--- a/trytond/trytond/modules/product_cost_fifo/tests/scenario_product_cost_fifo_recompute_cost_price.rst
+++ b/trytond/trytond/modules/product_cost_fifo/tests/scenario_product_cost_fifo_recompute_cost_price.rst
@@ -45,6 +45,7 @@ Get stock locations::
>>> supplier_loc, = Location.find([('code', '=', 'SUP')])
>>> storage_loc, = Location.find([('code', '=', 'STO')])
>>> customer_loc, = Location.find([('code', '=', 'CUS')])
+ >>> lost_found, = Location.find([('name', '=', "Lost and Found")])
Create some moves::
@@ -65,6 +66,12 @@ Create some moves::
... effective_date=today - dt.timedelta(days=1)).click('do')
>>> StockMove(
... product=product,
+ ... quantity=1,
+ ... from_location=lost_found,
+ ... to_location=storage_loc,
+ ... effective_date=today - dt.timedelta(days=1)).click('do')
+ >>> StockMove(
+ ... product=product,
... quantity=2,
... from_location=storage_loc,
... to_location=customer_loc,
@@ -88,17 +95,16 @@ Create some moves::
... product=product,
... quantity=1,
... from_location=storage_loc,
- ... to_location=customer_loc,
- ... unit_price=Decimal('300'),
+ ... to_location=lost_found,
... effective_date=today).click('do')
>>> [m.cost_price for m in StockMove.find([])]
- [Decimal('100.0000'), Decimal('110.0000'), Decimal('105.0000'), Decimal('110.0000'), Decimal('113.3333'), Decimal('100.0000')]
+ [Decimal('100.0000'), Decimal('116.6666'), Decimal('106.6666'), Decimal('110.0000'), Decimal('113.3333'), Decimal('113.3333'), Decimal('100.0000')]
>>> product.reload()
>>> product.cost_price
- Decimal('100.0000')
+ Decimal('99.9998')
Recompute cost price::
@@ -106,11 +112,12 @@ Recompute cost price::
>>> recompute.execute('recompute')
>>> [m.cost_price for m in StockMove.find([])]
- [Decimal('106.6667'), Decimal('106.6667'), Decimal('105.0000'), Decimal('110.0000'), Decimal('113.3333'), Decimal('100.0000')]
+ [Decimal('111.1111'), Decimal('111.1111'), Decimal('106.6666'), Decimal('110.0000'), Decimal('113.3333'), Decimal('113.3333'), Decimal('100.0000')]
>>> product.reload()
>>> product.cost_price
- Decimal('99.9999')
+ Decimal('99.9998')
+
Recompute cost price from a date::
@@ -119,8 +126,8 @@ Recompute cost price from a date::
>>> recompute.execute('recompute')
>>> [m.cost_price for m in StockMove.find([])]
- [Decimal('106.6667'), Decimal('106.6667'), Decimal('105.0000'), Decimal('110.0000'), Decimal('113.3333'), Decimal('100.0000')]
+ [Decimal('111.1111'), Decimal('111.1111'), Decimal('106.6666'), Decimal('110.0000'), Decimal('113.3333'), Decimal('113.3333'), Decimal('100.0000')]
>>> product.reload()
>>> product.cost_price
- Decimal('99.9999')
+ Decimal('99.9998')
--
2.25.1

View File

@ -0,0 +1,195 @@
# HG changeset patch
# User Cédric Krier <cedric.krier@b2ck.com>
Use all moves to compute FIFO
product_cost_fifo: Use all moves to compute FIFO
We must consider any incoming moves (ex: inventory) for the computation of the
FIFO otherwise the back computation to find first in moves does not pick enough
moves. As not all incoming moves have a unit price, we use the current cost
price as fallback.
Also in re-computation, the in or out move test should only rely on the storage
location usage in order to properly compute the average cost.
issue9274
review321471002
Index: move.py
===================================================================
--- a/trytond/trytond/modules/product_cost_fifo/move.py
+++ b/trytond/trytond/modules/product_cost_fifo/move.py
@@ -81,13 +81,15 @@
to_save = []
for move, move_qty in fifo_moves:
consumed_qty += move_qty
-
- with Transaction().set_context(date=move.effective_date):
- move_unit_price = Currency.compute(
- move.currency, move.unit_price,
- self.company.currency, round=False)
- move_unit_price = Uom.compute_price(move.uom, move_unit_price,
- move.product.default_uom)
+ if move.from_location.type in {'supplier', 'production'}:
+ with Transaction().set_context(date=move.effective_date):
+ move_unit_price = Currency.compute(
+ move.currency, move.unit_price,
+ self.company.currency, round=False)
+ move_unit_price = Uom.compute_price(
+ move.uom, move_unit_price, move.product.default_uom)
+ else:
+ move_unit_price = move.cost_price or 0
cost_price += move_unit_price * Decimal(str(move_qty))
move_qty = Uom.compute_qty(self.product.default_uom, move_qty,
Index: product.py
===================================================================
--- a/trytond/trytond/modules/product_cost_fifo/product.py
+++ b/trytond/trytond/modules/product_cost_fifo/product.py
@@ -31,7 +31,7 @@
domain = [
('product', '=', self.id),
self._domain_moves_cost(),
- ('from_location.type', 'in', ['supplier', 'production']),
+ ('from_location.type', '!=', 'storage'),
('to_location.type', '=', 'storage'),
]
if not date:
@@ -137,11 +137,10 @@
quantity = Decimal(str(quantity))
def in_move(move):
- return (move.from_location.type in ['supplier', 'production']
- or move.to_location.type == 'supplier')
+ return move.to_location.type == 'storage'
def out_move(move):
- return not in_move(move)
+ return move.from_location.type == 'storage'
def compute_fifo_cost_price(quantity, date):
fifo_moves = self.get_fifo_move(
@@ -152,12 +151,15 @@
consumed_qty = 0
for move, move_qty in fifo_moves:
consumed_qty += move_qty
- with Transaction().set_context(date=move.effective_date):
- unit_price = Currency.compute(
- move.currency, move.unit_price,
- move.company.currency, round=False)
- unit_price = Uom.compute_price(
- move.uom, unit_price, move.product.default_uom)
+ if move.from_location.type in {'supplier', 'production'}:
+ with Transaction().set_context(date=move.effective_date):
+ unit_price = Currency.compute(
+ move.currency, move.unit_price,
+ move.company.currency, round=False)
+ unit_price = Uom.compute_price(
+ move.uom, unit_price, move.product.default_uom)
+ else:
+ unit_price = move.cost_price or 0
cost_price += unit_price * Decimal(str(move_qty))
if consumed_qty:
return round_price(cost_price / Decimal(str(consumed_qty)))
@@ -204,12 +206,15 @@
if move.from_location.type == 'storage':
qty *= -1
if in_move(move):
- with Transaction().set_context(date=move.effective_date):
- 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)
+ if move.from_location.type in {'supplier', 'production'}:
+ with Transaction().set_context(date=move.effective_date):
+ 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)
+ else:
+ unit_price = cost_price
if quantity + qty > 0 and quantity >= 0:
cost_price = (
(cost_price * quantity) + (unit_price * qty)
@@ -217,7 +222,7 @@
elif qty > 0:
cost_price = unit_price
current_cost_price = round_price(cost_price)
- else:
+ elif out_move(move):
current_out_qty += -qty
quantity += qty
Index: tests/scenario_product_cost_fifo_recompute_cost_price.rst
===================================================================
--- a/trytond/trytond/modules/product_cost_fifo/tests/scenario_product_cost_fifo_recompute_cost_price.rst
+++ b/trytond/trytond/modules/product_cost_fifo/tests/scenario_product_cost_fifo_recompute_cost_price.rst
@@ -45,6 +45,7 @@
>>> supplier_loc, = Location.find([('code', '=', 'SUP')])
>>> storage_loc, = Location.find([('code', '=', 'STO')])
>>> customer_loc, = Location.find([('code', '=', 'CUS')])
+ >>> lost_found, = Location.find([('name', '=', "Lost and Found")])
Create some moves::
@@ -65,6 +66,12 @@
... effective_date=today - dt.timedelta(days=1)).click('do')
>>> StockMove(
... product=product,
+ ... quantity=1,
+ ... from_location=lost_found,
+ ... to_location=storage_loc,
+ ... effective_date=today - dt.timedelta(days=1)).click('do')
+ >>> StockMove(
+ ... product=product,
... quantity=2,
... from_location=storage_loc,
... to_location=customer_loc,
@@ -88,17 +95,16 @@
... product=product,
... quantity=1,
... from_location=storage_loc,
- ... to_location=customer_loc,
- ... unit_price=Decimal('300'),
+ ... to_location=lost_found,
... effective_date=today).click('do')
>>> [m.cost_price for m in StockMove.find([])]
- [Decimal('100.0000'), Decimal('110.0000'), Decimal('105.0000'), Decimal('110.0000'), Decimal('113.3333'), Decimal('100.0000')]
+ [Decimal('100.0000'), Decimal('116.6666'), Decimal('106.6666'), Decimal('110.0000'), Decimal('113.3333'), Decimal('113.3333'), Decimal('100.0000')]
>>> product.reload()
>>> product.cost_price
- Decimal('100.0000')
+ Decimal('99.9998')
Recompute cost price::
@@ -106,11 +112,11 @@
>>> recompute.execute('recompute')
>>> [m.cost_price for m in StockMove.find([])]
- [Decimal('106.6667'), Decimal('106.6667'), Decimal('105.0000'), Decimal('110.0000'), Decimal('113.3333'), Decimal('100.0000')]
+ [Decimal('111.1111'), Decimal('111.1111'), Decimal('106.6666'), Decimal('110.0000'), Decimal('113.3333'), Decimal('113.3333'), Decimal('100.0000')]
>>> product.reload()
>>> product.cost_price
- Decimal('99.9998')
+ Decimal('100.0000')
Recompute cost price from a date::
@@ -119,8 +125,8 @@
>>> recompute.execute('recompute')
>>> [m.cost_price for m in StockMove.find([])]
- [Decimal('106.6667'), Decimal('106.6667'), Decimal('105.0000'), Decimal('110.0000'), Decimal('113.3333'), Decimal('100.0000')]
+ [Decimal('111.1111'), Decimal('111.1111'), Decimal('106.6666'), Decimal('110.0000'), Decimal('113.3333'), Decimal('113.3333'), Decimal('100.0000')]
>>> product.reload()
>>> product.cost_price
- Decimal('99.9998')
+ Decimal('100.0000')

View File

@ -0,0 +1,136 @@
From a5edc544456d361175a4de1ed2fc12948cd67118 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=80ngel=20=C3=80lvarez?= <angel@nan-tic.com>
Date: Thu, 8 Apr 2021 09:15:58 +0200
Subject: [PATCH] Keep cost of unused input products
We set the average cost price of the input products as the unit price of the
output of the same product. This way the cost price computation for this
product does not change.
issue9637
review316051002
---
production.py | 85 +++++++++++++++++++++++++++++++++++++++++++--------
1 file changed, 73 insertions(+), 12 deletions(-)
diff --git a/production.py b/production.py
index 2bd52d1..13ec112 100644
--- a/trytond/trytond/modules/production/production.py
+++ b/trytond/trytond/modules/production/production.py
@@ -1,7 +1,7 @@
# 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 decimal import Decimal
-
+from collections import defaultdict
from sql import Null
from trytond.i18n import gettext
@@ -21,6 +21,12 @@ BOM_CHANGES = ['bom', 'product', 'quantity', 'uom', 'warehouse', 'location',
'company', 'inputs', 'outputs']
+
+def round_price(value, rounding=None):
+ "Round price using the price digits"
+ return value.quantize(
+ Decimal(1) / 10 ** price_digits[1], rounding=rounding)
+
class Production(Workflow, ModelSQL, ModelView):
"Production"
__name__ = 'production'
@@ -461,26 +467,81 @@ class Production(Workflow, ModelSQL, ModelView):
productions.add(move.production_input)
Move.write(moves, {'production_cost_price_updated': False})
+ @property
+ def _list_price_context(self):
+ return {
+ 'company': self.company.id,
+ }
+
@classmethod
def set_cost(cls, productions):
pool = Pool()
Uom = pool.get('product.uom')
Move = pool.get('stock.move')
- digits = Move.unit_price.digits
- digit = Decimal(str(10 ** -digits[1]))
moves = []
for production in productions:
- if not production.quantity or not production.uom:
- continue
- if production.company.currency.is_zero(
- production.cost - production.output_cost):
- continue
- unit_price = production.cost / Decimal(str(production.quantity))
+ sum_ = Decimal(0)
+ prices = {}
+ cost = production.cost
+
+ input_quantities = defaultdict(Decimal)
+ input_costs = defaultdict(Decimal)
+ for input_ in production.inputs:
+ if input_.cost_price is not None:
+ cost_price = input_.cost_price
+ else:
+ cost_price = input_.product.cost_price
+ input_quantities[input_.product] += (
+ Decimal(str(input_.internal_quantity)))
+ input_costs[input_.product] += (
+ Decimal(str(input_.internal_quantity)) * cost_price)
+ outputs = []
for output in production.outputs:
- if output.product == production.product:
- output.unit_price = Uom.compute_price(
- production.uom, unit_price, output.uom).quantize(digit)
+ product = output.product
+ if input_quantities.get(output.product):
+ cost_price = (
+ input_costs[product] / input_quantities[product])
+ unit_price = round_price(Uom.compute_price(
+ product.default_uom, cost_price, output.uom))
+ if output.unit_price != unit_price:
+ output.unit_price = unit_price
+ moves.append(output)
+ cost -= min(
+ unit_price * Decimal(str(output.quantity)), cost)
+ else:
+ outputs.append(output)
+
+ for output in outputs:
+ product = output.product
+ with Transaction().set_context(production._list_price_context):
+ list_price = product.list_price_used
+ product_price = (Decimal(str(output.quantity))
+ * Uom.compute_price(
+ product.default_uom, list_price, output.uom))
+ prices[output] = product_price
+ sum_ += product_price
+
+ if not sum_ and production.product:
+ prices.clear()
+ for output in outputs:
+ if output.product == production.product:
+ quantity = Uom.compute_qty(
+ output.uom, output.quantity,
+ output.product.default_uom, round=False)
+ quantity = Decimal(str(quantity))
+ prices[output] = quantity
+ sum_ += quantity
+
+ for output in outputs:
+ if sum_:
+ ratio = prices.get(output, 0) / sum_
+ else:
+ ratio = Decimal(1) / len(outputs)
+ quantity = Decimal(str(output.quantity))
+ unit_price = round_price(cost * ratio / quantity)
+ if output.unit_price != unit_price:
+ output.unit_price = unit_price
moves.append(output)
Move.save(moves)
--
2.25.1

View File

@ -0,0 +1,263 @@
From cda3ebad04807a8a059ffbe5e57082b0c92fb7b0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=80ngel=20=C3=80lvarez?= <angel@nan-tic.com>
Date: Wed, 7 Apr 2021 12:24:55 +0200
Subject: [PATCH] Update cost of moves when recomputing product cost
issue8795
issue7271
---
__init__.py | 2 +
ir.py | 14 ++++
production.py | 16 ++++
production.xml | 5 ++
stock.py | 21 +++++
tests/scenario_production_set_cost.rst | 105 +++++++++++++++++++++++++
tests/test_production.py | 4 +
7 files changed, 167 insertions(+)
create mode 100644 ir.py
create mode 100644 tests/scenario_production_set_cost.rst
diff --git a/__init__.py b/__init__.py
index b8909ab..94292a3 100644
--- a/trytond/trytond/modules/production/__init__.py
+++ b/trytond/trytond/modules/production/__init__.py
@@ -7,6 +7,7 @@ from .bom import *
from .product import *
from .production import *
from .stock import *
+from . import ir
def register():
@@ -27,6 +28,7 @@ def register():
ProductionLeadTime,
Location,
Move,
+ ir.Cron,
module='production', type_='model')
Pool.register(
Assign,
diff --git a/ir.py b/ir.py
new file mode 100644
index 0000000..3ccc082
--- a/trytond/trytond/modules/production/ir.py
+++ b/trytond/trytond/modules/production/ir.py
@@ -0,0 +1,14 @@
+# 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([
+ ('production|set_cost_from_moves', "Set Cost from Moves"),
+ ])
diff --git a/production.py b/production.py
index acd7a35..2bd52d1 100644
--- a/trytond/trytond/modules/production/production.py
+++ b/trytond/trytond/modules/production/production.py
@@ -445,6 +445,22 @@ class Production(Workflow, ModelSQL, ModelView):
move.save()
self._set_move_planned_date()
+ @classmethod
+ def set_cost_from_moves(cls):
+ pool = Pool()
+ Move = pool.get('stock.move')
+ productions = set()
+ moves = Move.search([
+ ('production_cost_price_updated', '=', True),
+ ('production_input', '!=', None),
+ ],
+ order=[('effective_date', 'ASC')])
+ for move in moves:
+ if move.production_input not in productions:
+ cls.__queue__.set_cost([move.production_input])
+ productions.add(move.production_input)
+ Move.write(moves, {'production_cost_price_updated': False})
+
@classmethod
def set_cost(cls, productions):
pool = Pool()
diff --git a/production.xml b/production.xml
index e67fbb5..2f46f8c 100644
--- a/trytond/trytond/modules/production/production.xml
+++ b/trytond/trytond/modules/production/production.xml
@@ -305,5 +305,10 @@ this repository contains the full copyright notices and license terms. -->
<field name="name">assign_failed_form</field>
</record>
+ <record model="ir.cron" id="cron_set_cost_from_moves">
+ <field name="method">production|set_cost_from_moves</field>
+ <field name="interval_number" eval="1"/>
+ <field name="interval_type">days</field>
+ </record>
</data>
</tryton>
diff --git a/stock.py b/stock.py
index 092cdba..0b8bbc3 100644
--- a/trytond/trytond/modules/production/stock.py
+++ b/trytond/trytond/modules/production/stock.py
@@ -32,6 +32,12 @@ class Move(metaclass=PoolMeta):
readonly=True, select=True, ondelete='CASCADE',
domain=[('company', '=', Eval('company'))],
depends=['company'])
+ production_cost_price_updated = fields.Boolean(
+ "Cost Price Updated", readonly=True,
+ states={
+ 'invisible': ~Eval('production_input') & (Eval('state') == 'done'),
+ },
+ depends=['production_input', 'state'])
def set_effective_date(self):
if not self.effective_date and self.production_input:
@@ -39,3 +45,18 @@ class Move(metaclass=PoolMeta):
if not self.effective_date and self.production_output:
self.effective_date = self.production_output.effective_date
super(Move, self).set_effective_date()
+
+ @classmethod
+ def write(cls, *args):
+ super().write(*args)
+ cost_price_update = []
+ actions = iter(args)
+ for moves, values in zip(actions, actions):
+ for move in moves:
+ if (move.state == 'done'
+ and move.production_input
+ and 'cost_price' in values):
+ cost_price_update.append(move)
+ if cost_price_update:
+ cls.write(
+ cost_price_update, {'production_cost_price_updated': True})
diff --git a/tests/scenario_production_set_cost.rst b/tests/scenario_production_set_cost.rst
new file mode 100644
index 0000000..0117fba
--- /dev/null
+++ b/trytond/trytond/modules/production/tests/scenario_production_set_cost.rst
@@ -0,0 +1,105 @@
+===================
+Production Set Cost
+===================
+
+Imports::
+
+ >>> 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
+
+Install production Module::
+
+ >>> config = activate_modules('production')
+
+Create company::
+
+ >>> _ = create_company()
+ >>> company = get_company()
+
+Create main product::
+
+ >>> ProductUom = Model.get('product.uom')
+ >>> unit, = ProductUom.find([('name', '=', 'Unit')])
+ >>> ProductTemplate = Model.get('product.template')
+
+ >>> template = ProductTemplate()
+ >>> template.name = 'product'
+ >>> template.default_uom = unit
+ >>> template.type = 'goods'
+ >>> template.producible = True
+ >>> template.list_price = Decimal(20)
+ >>> template.save()
+ >>> product, = template.products
+
+Create component::
+
+ >>> template = ProductTemplate()
+ >>> template.name = 'component'
+ >>> template.default_uom = unit
+ >>> template.type = 'goods'
+ >>> template.save()
+ >>> component, = template.products
+ >>> component.cost_price = Decimal(5)
+ >>> component.save()
+
+Create Bill of Material::
+
+ >>> BOM = Model.get('production.bom')
+ >>> bom = BOM(name='product')
+ >>> input = bom.inputs.new()
+ >>> input.product = component
+ >>> input.quantity = 2
+ >>> output = bom.outputs.new()
+ >>> output.product = product
+ >>> output.quantity = 1
+ >>> bom.save()
+
+Make a production::
+
+ >>> Production = Model.get('production')
+ >>> production = Production()
+ >>> production.product = product
+ >>> production.bom = bom
+ >>> production.quantity = 2
+ >>> production.click('wait')
+ >>> production.click('assign_force')
+ >>> production.click('run')
+ >>> production.click('done')
+
+Check output price::
+
+ >>> production.cost
+ Decimal('20.0000')
+ >>> output, = production.outputs
+ >>> output.unit_price
+ Decimal('10.0000')
+
+
+Change cost of input::
+
+ >>> Move = Model.get('stock.move')
+ >>> input, = production.inputs
+ >>> Move.write([input], {'cost_price': Decimal(6)}, config.context)
+ >>> input.reload()
+ >>> bool(input.production_cost_price_updated)
+ True
+
+Launch cron task::
+
+ >>> Cron = Model.get('ir.cron')
+ >>> Company = Model.get('company.company')
+ >>> cron_set_cost, = Cron.find([
+ ... ('method', '=', 'production|set_cost_from_moves'),
+ ... ])
+ >>> cron_set_cost.companies.append(Company(company.id))
+ >>> cron_set_cost.click('run_once')
+
+ >>> output.reload()
+ >>> output.unit_price
+ Decimal('12.0000')
+ >>> input.reload()
+ >>> bool(input.production_cost_price_updated)
+ False
diff --git a/tests/test_production.py b/tests/test_production.py
index 2bcf2ec..e9f596c 100644
--- a/trytond/trytond/modules/production/tests/test_production.py
+++ b/trytond/trytond/modules/production/tests/test_production.py
@@ -54,4 +54,8 @@ def suite():
tearDown=doctest_teardown, encoding='utf-8',
checker=doctest_checker,
optionflags=doctest.REPORT_ONLY_FIRST_FAILURE))
+ suite.addTests(doctest.DocFileSuite('scenario_production_set_cost.rst',
+ tearDown=doctest_teardown, encoding='utf-8',
+ checker=doctest_checker,
+ optionflags=doctest.REPORT_ONLY_FIRST_FAILURE))
return suite
--
2.25.1

View File

@ -0,0 +1,427 @@
From b82c2659c6703f1f25dbeb4f698734e147483a8f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=80ngel=20=C3=80lvarez?= <angel@nan-tic.com>
Date: Wed, 7 Apr 2021 12:25:52 +0200
Subject: [PATCH] Update cost of moves when recomputing product cost issue8795
issue7271
---
product.py | 235 +++++++++++++++---
...product_cost_fifo_recompute_cost_price.rst | 126 ++++++++++
tests/test_product_cost_fifo.py | 5 +
3 files changed, 331 insertions(+), 35 deletions(-)
create mode 100644 tests/scenario_product_cost_fifo_recompute_cost_price.rst
diff --git a/product.py b/product.py
index 86a6faa..0c204bd 100644
--- a/trytond/trytond/modules/product_cost_fifo/product.py
+++ b/trytond/trytond/modules/product_cost_fifo/product.py
@@ -1,6 +1,9 @@
# 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 datetime
+import datetime as dt
+from decimal import Decimal
+
+from trytond.config import config
from trytond.transaction import Transaction
from trytond.pool import Pool, PoolMeta
@@ -21,34 +24,25 @@ class Template(metaclass=PoolMeta):
class Product(metaclass=PoolMeta):
__name__ = 'product.product'
- def _get_available_fifo_moves(self):
+ def _get_available_fifo_moves(self, date=None, offset=0, limit=None):
pool = Pool()
Move = pool.get('stock.move')
- return Move.search([
- ('product', '=', self.id),
- ('state', '=', 'done'),
- self._domain_moves_cost,
- ('fifo_quantity_available', '>', 0),
- ('to_location.type', '=', 'storage'),
- ('from_location.type', '!=', 'storage'),
- ('to_location.type', '=', 'storage'),
- ], order=[('effective_date', 'DESC'), ('id', 'DESC')])
-
- def _get_fifo_quantity(self):
- pool = Pool()
- Location = pool.get('stock.location')
-
- locations = Location.search([
- ('type', '=', 'storage'),
- ])
- stock_date_end = datetime.date.today()
- location_ids = [l.id for l in locations]
- with Transaction().set_context(
- locations=location_ids,
- stock_date_end=stock_date_end):
- return self.__class__(self.id).quantity
-
- def get_fifo_move(self, quantity=0.0):
+ domain = [
+ ('product', '=', self.id),
+ self._domain_moves_cost(),
+ ('from_location.type', 'in', ['supplier', 'production']),
+ ('to_location.type', '=', 'storage'),
+ ]
+ if not date:
+ domain.append(('fifo_quantity_available', '>', 0))
+ else:
+ domain.append(('effective_date', '<=', date))
+ return Move.search(
+ domain,
+ offset=offset, limit=limit,
+ order=[('effective_date', 'DESC'), ('id', 'DESC')])
+
+ def get_fifo_move(self, quantity=0.0, date=None):
'''
Return a list of (move, qty) where move is the move to be
consumed and qty is the quantity (in the product default uom)
@@ -58,13 +52,30 @@ class Product(metaclass=PoolMeta):
pool = Pool()
Uom = pool.get('product.uom')
- avail_qty = self._get_fifo_quantity()
+ avail_qty = self._get_storage_quantity(date=date)
+ if date:
+ # On recomputation, we must pretend
+ # outgoing moves are not yet done.
+ avail_qty += quantity
fifo_moves = []
- moves = self._get_available_fifo_moves()
- for move in moves:
- qty = Uom.compute_qty(move.uom,
- move.fifo_quantity_available,
- self.default_uom, round=False)
+
+ size = config.getint('cache', 'record')
+
+ def moves():
+ offset, limit = 0, size
+ while True:
+ moves = self._get_available_fifo_moves(
+ date=date, offset=offset, limit=limit)
+ if not moves:
+ break
+ for move in moves:
+ yield move
+ offset += size
+
+ for move in moves():
+ qty = move.fifo_quantity_available if not date else move.quantity
+ qty = Uom.compute_qty(
+ move.uom, qty, self.default_uom, round=False)
avail_qty -= qty
if avail_qty <= quantity:
@@ -78,5 +89,159 @@ class Product(metaclass=PoolMeta):
fifo_moves.reverse()
return fifo_moves
- def recompute_cost_price_fifo(self):
- return self.recompute_cost_price_average()
+ def recompute_cost_price_fifo(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
+
+ 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'),
+ ],
+ ],
+ ]
+ 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 - dt.timedelta(days=1))
+ quantity = Decimal(str(quantity))
+
+ def in_move(move):
+ return (move.from_location.type in ['supplier', 'production']
+ or move.to_location.type == 'supplier')
+
+ def out_move(move):
+ return not in_move(move)
+
+ def compute_fifo_cost_price(quantity, date):
+ fifo_moves = self.get_fifo_move(
+ float(quantity),
+ date=current_moves[-1].effective_date)
+
+ cost_price = Decimal(0)
+ consumed_qty = 0
+ for move, move_qty in fifo_moves:
+ consumed_qty += move_qty
+ with Transaction().set_context(date=move.effective_date):
+ unit_price = Currency.compute(
+ move.currency, move.unit_price,
+ move.company.currency, round=False)
+ unit_price = Uom.compute_price(
+ move.uom, unit_price, move.product.default_uom)
+ cost_price += unit_price * Decimal(str(move_qty))
+ if consumed_qty:
+ return (cost_price / Decimal(str(consumed_qty))).quantize(
+ Decimal(str(10.0 ** -digits[1])))
+
+ current_moves = []
+ current_out_qty = 0
+ 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 filter(in_move, current_moves)
+ if m.cost_price != current_cost_price],
+ dict(cost_price=current_cost_price))
+
+ out_moves = list(filter(out_move, current_moves))
+ if out_moves:
+ fifo_cost_price = compute_fifo_cost_price(
+ current_out_qty, current_moves[-1].effective_date)
+ if fifo_cost_price is None:
+ fifo_cost_price = current_cost_price
+ Move.write([
+ m for m in out_moves
+ if m.cost_price != fifo_cost_price],
+ dict(cost_price=fifo_cost_price))
+ if quantity:
+ cost_price = (
+ ((current_cost_price * (
+ quantity + current_out_qty))
+ - (fifo_cost_price * current_out_qty))
+ / quantity)
+ else:
+ cost_price = Decimal(0)
+ current_cost_price = cost_price.quantize(
+ Decimal(str(10.0 ** -digits[1])))
+ current_moves.clear()
+ current_out_qty = 0
+ 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':
+ qty *= -1
+ if in_move(move):
+ with Transaction().set_context(date=move.effective_date):
+ 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)
+ 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])))
+ else:
+ current_out_qty += -qty
+ quantity += qty
+
+ Move.write([
+ m for m in filter(in_move, current_moves)
+ if m.cost_price != current_cost_price],
+ dict(cost_price=current_cost_price))
+
+ out_moves = list(filter(out_move, current_moves))
+ if out_moves:
+ fifo_cost_price = compute_fifo_cost_price(
+ current_out_qty, current_moves[-1].effective_date)
+ if fifo_cost_price is None:
+ fifo_cost_price = current_cost_price
+ Move.write([
+ m for m in out_moves
+ if m.cost_price != fifo_cost_price],
+ dict(cost_price=fifo_cost_price))
+ if quantity:
+ cost_price = (
+ ((current_cost_price * (
+ quantity + current_out_qty))
+ - (fifo_cost_price * current_out_qty))
+ / quantity)
+ else:
+ cost_price = Decimal(0)
+ current_cost_price = cost_price.quantize(
+ Decimal(str(10.0 ** -digits[1])))
+ return current_cost_price
diff --git a/tests/scenario_product_cost_fifo_recompute_cost_price.rst b/tests/scenario_product_cost_fifo_recompute_cost_price.rst
new file mode 100644
index 0000000..f8b952f
--- /dev/null
+++ b/trytond/trytond/modules/product_cost_fifo/tests/scenario_product_cost_fifo_recompute_cost_price.rst
@@ -0,0 +1,126 @@
+======================================
+Product Cost FIFO Recompute 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('product_cost_fifo')
+
+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 = 'fifo'
+ >>> 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=supplier_loc,
+ ... to_location=storage_loc,
+ ... unit_price=Decimal('120'),
+ ... effective_date=today - dt.timedelta(days=1)).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=3,
+ ... from_location=supplier_loc,
+ ... to_location=storage_loc,
+ ... unit_price=Decimal('100'),
+ ... effective_date=today).click('do')
+ >>> StockMove(
+ ... product=product,
+ ... quantity=2,
+ ... from_location=storage_loc,
+ ... to_location=customer_loc,
+ ... unit_price=Decimal('300'),
+ ... effective_date=today).click('do')
+ >>> StockMove(
+ ... product=product,
+ ... quantity=1,
+ ... from_location=storage_loc,
+ ... to_location=customer_loc,
+ ... unit_price=Decimal('300'),
+ ... effective_date=today).click('do')
+
+
+ >>> [m.cost_price for m in StockMove.find([])]
+ [Decimal('100.0000'), Decimal('110.0000'), Decimal('105.0000'), Decimal('110.0000'), Decimal('113.3333'), Decimal('100.0000')]
+
+ >>> product.reload()
+ >>> product.cost_price
+ Decimal('100.0000')
+
+Recompute cost price::
+
+ >>> recompute = Wizard('product.recompute_cost_price', [product])
+ >>> recompute.execute('recompute')
+
+ >>> [m.cost_price for m in StockMove.find([])]
+ [Decimal('106.6667'), Decimal('106.6667'), Decimal('105.0000'), Decimal('110.0000'), Decimal('113.3333'), Decimal('100.0000')]
+
+ >>> product.reload()
+ >>> product.cost_price
+ Decimal('99.9999')
+
+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('106.6667'), Decimal('106.6667'), Decimal('105.0000'), Decimal('110.0000'), Decimal('113.3333'), Decimal('100.0000')]
+
+ >>> product.reload()
+ >>> product.cost_price
+ Decimal('99.9999')
diff --git a/tests/test_product_cost_fifo.py b/tests/test_product_cost_fifo.py
index 7b81f5e..5ee6fb6 100644
--- a/trytond/trytond/modules/product_cost_fifo/tests/test_product_cost_fifo.py
+++ b/trytond/trytond/modules/product_cost_fifo/tests/test_product_cost_fifo.py
@@ -32,4 +32,9 @@ def suite():
tearDown=doctest_teardown, encoding='utf-8',
checker=doctest_checker,
optionflags=doctest.REPORT_ONLY_FIRST_FAILURE))
+ suite.addTests(doctest.DocFileSuite(
+ 'scenario_product_cost_fifo_recompute_cost_price.rst',
+ tearDown=doctest_teardown, encoding='utf-8',
+ checker=doctest_checker,
+ optionflags=doctest.REPORT_ONLY_FIRST_FAILURE))
return suite
--
2.25.1

View File

@ -0,0 +1,73 @@
From f3cddba895305b7ecef3c07fb5db6c356a7bc8e2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=80ngel=20=C3=80lvarez?= <angel@nan-tic.com>
Date: Thu, 8 Apr 2021 11:56:28 +0200
Subject: [PATCH] Use original cost price for returned move
and factorize the cost price of move for cost computation
issue9440
review327491003
---
stock.py | 34 ++++++++++++++++++++++++++++++++++
1 file changed, 34 insertions(+)
diff --git a/stock.py b/stock.py
index 8d34c30..28f1116 100644
--- a/trytond/trytond/modules/sale/stock.py
+++ b/trytond/trytond/modules/sale/stock.py
@@ -1,5 +1,6 @@
# 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 decimal import Decimal
from functools import wraps
from trytond.i18n import gettext
@@ -11,6 +12,12 @@ from trytond.pool import Pool, PoolMeta
__all__ = ['ShipmentOut', 'ShipmentOutReturn', 'Move']
+def round_price(value, rounding=None):
+ "Round price using the price digits"
+ return value.quantize(
+ Decimal(1) / 10 ** price_digits[1], rounding=rounding)
+
+
def process_sale(moves_field):
def _process_sale(func):
@wraps(func)
@@ -155,6 +162,33 @@ class Move(metaclass=PoolMeta):
category = self.origin.unit.category.id
return category
+ def get_cost_price(self, product_cost_price=None):
+ pool = Pool()
+ SaleLine = pool.get('sale.line')
+ Sale = pool.get('sale.sale')
+ # For return sale's move use the cost price of the original sale
+ if (isinstance(self.origin, SaleLine)
+ and self.origin.quantity < 0
+ and self.from_location.type != 'storage'
+ and self.to_location.type == 'storage'
+ and isinstance(self.origin.origin, Sale)):
+ sale = self.origin.origin
+ cost = Decimal(0)
+ qty = Decimal(0)
+ for move in sale.moves:
+ if (move.state == 'done'
+ and move.from_location.type == 'storage'
+ and move.to_location.type == 'customer'
+ and move.product == self.product):
+ move_quantity = Decimal(str(move.internal_quantity))
+ cost_price = move.get_cost_price(
+ product_cost_price=move.cost_price)
+ qty += move_quantity
+ cost += cost_price * move_quantity
+ if qty:
+ product_cost_price = round_price(cost / qty)
+ return super().get_cost_price(product_cost_price=product_cost_price)
+
@property
def origin_name(self):
pool = Pool()
--
2.25.1

View File

@ -0,0 +1,43 @@
From 479eff31f971acef41cc0fe77513305c3bb2b4a7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=80ngel=20=C3=80lvarez?= <angel@nan-tic.com>
Date: Thu, 8 Apr 2021 11:58:52 +0200
Subject: [PATCH] Use original cost price for returned move
and factorize the cost price of move for cost computation
issue9440
review327491003
---
stock.py | 9 +--------
1 file changed, 1 insertion(+), 8 deletions(-)
diff --git a/stock.py b/stock.py
index 46dc496..84b00ca 100644
--- a/trytond/trytond/modules/sale_supply_drop_shipment/stock.py
+++ b/trytond/trytond/modules/sale_supply_drop_shipment/stock.py
@@ -404,7 +404,6 @@ class ShipmentDrop(Workflow, ModelSQL, ModelView):
pool = Pool()
UoM = pool.get('product.uom')
Move = pool.get('stock.move')
- Currency = pool.get('currency.currency')
to_save = []
cost_exp = Decimal(str(10.0 ** -Move.cost_price.digits[1]))
@@ -424,14 +423,8 @@ class ShipmentDrop(Workflow, ModelSQL, ModelView):
if s_move.state == 'cancel':
continue
internal_quantity = Decimal(str(s_move.internal_quantity))
- with Transaction().set_context(date=s_move.effective_date):
- unit_price = Currency.compute(
- s_move.currency, s_move.unit_price,
- s_move.company.currency, round=False)
- unit_price = UoM.compute_price(
- s_move.uom, unit_price, s_move.product.default_uom)
product_cost[s_move.product] += (
- unit_price * internal_quantity)
+ s_move.get_cost_price() * internal_quantity)
quantity = UoM.compute_qty(
s_move.uom, s_move.quantity, s_move.product.default_uom,
--
2.25.1

18
series
View File

@ -44,7 +44,7 @@ issue10009.diff # [trytond] Remove active test from sequences and languages acti
issue9616.diff # [analytic_invoice] Analytic move is not created when closing an asset
fifo_quantity_round.diff # issue9664 [product_cost_fifo] Round fifo_quantity before save, to avoid problems when compare with quantity [Remove on 5.8]
#fifo_quantity_round.diff # issue9664 [product_cost_fifo] Round fifo_quantity before save, to avoid problems when compare with quantity [Remove on 5.8]
account_payment_search_payment_amount.diff # [account_payment] Optimize searcher search_payment_amount
@ -70,3 +70,19 @@ project_invoice_progress_compute_qty.diff # [project_invoice] Twice compute qty
issue10068.diff # [trytond] Use safe_join in SharedDataMiddlewareIndex
issue10053.diff # [party] Configure available identifiers
# Cost and Recompute Cost issues
production_cost_fifo-Update-cost-of-moves-when-recomputing-product-cost.patch #[issue8795 + issue7271] Remove on 5.8
production-Update-cost-of-moves-when-recomputing-product-cost.patch #[issue8795 + issue7271] Remove on 5.8
stock-Update-cost-of-moves-when-recomputing-product-cost.patch #[issue8795 + issue7271] Remove on 5.8
production-Keep-cost-of-unused-input-products.patch #[issue9637] Remove on 5.8
cost_price_in_productions_without_inputs.diff # [production]
product_cost_fifo-Use-all-moves-to-compute-FIFO.patch #[issue9443 issue9274] Remove on 5.8
product-cost-fifo-Use-original-cost-price-for-returned-move.patch #[issue9440] Remove on 6.0
stock-Use-original-cost-price-for-returned-move.patch #[issue9440] Remove on 6.0
sale-Use-original-cost-price-for-returned-move.patch #[issue9440] Remove on 6.0
sale_supply_drop_shippment-Use-original-cost-price-for-returned-move.patch #[issue9440] Remove on 6.0
product_cost_fifo-Do-not-update-average-cost-price-i.patch #[issue9484] Remove on 6.0
product_cost_fifo-Enforce-filling-cost-price-of-move.patch #[issue9397] Remove on 6.0
stock-Enforce-filling-cost-price-of-move.patch #[issue9397] Remove on 6.0
# End Cost and Recompute Cost issues

View File

@ -0,0 +1,69 @@
From 443f3cb536af34952e95205bc3334b6ff77b1c39 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=80ngel=20=C3=80lvarez?= <angel@nan-tic.com>
Date: Thu, 8 Apr 2021 12:20:12 +0200
Subject: [PATCH] stock: Enforce filling cost price of move
We set cost price only for outgoing or incoming moves.
issue9397
---
move.py | 23 ++++++++++++++++++++---
1 file changed, 20 insertions(+), 3 deletions(-)
diff --git a/move.py b/move.py
index 19f4401..839a393 100644
--- a/trytond/trytond/modules/stock/move.py
+++ b/trytond/trytond/modules/stock/move.py
@@ -266,8 +266,15 @@ class Move(Workflow, ModelSQL, ModelView):
'invisible': Eval('state') != 'done',
},
depends=['state'])
- cost_price = fields.Numeric('Cost Price', digits=price_digits,
- readonly=True)
+ cost_price = fields.Numeric(
+ "Cost Price", digits=price_digits, readonly=True,
+ states={
+ 'invisible': ~Eval('cost_price_required'),
+ 'required': (
+ (Eval('state') == 'done')
+ & Eval('cost_price_required', False)),
+ },
+ depends=['cost_price_required'])
currency = fields.Many2One('currency.currency', 'Currency',
states={
'invisible': ~Eval('unit_price_required'),
@@ -279,6 +286,9 @@ class Move(Workflow, ModelSQL, ModelView):
unit_price_required = fields.Function(
fields.Boolean('Unit Price Required'),
'on_change_with_unit_price_required')
+ cost_price_required = fields.Function(
+ fields.Boolean("Cost Price Required"),
+ 'on_change_with_cost_price_required')
assignation_required = fields.Function(
fields.Boolean('Assignation Required'),
'on_change_with_assignation_required')
@@ -416,6 +426,13 @@ class Move(Workflow, ModelSQL, ModelView):
return True
return False
+ @fields.depends('from_location', 'to_location')
+ def on_change_with_cost_price_required(self, name=None):
+ from_type = self.from_location.type if self.from_location else None
+ to_type = self.to_location.type if self.to_location else None
+ return ((from_type != 'storage' and to_type == 'storage')
+ or (from_type == 'storage' and to_type != 'storage'))
+
@fields.depends('from_location')
def on_change_with_assignation_required(self, name=None):
if self.from_location:
@@ -620,7 +637,7 @@ class Move(Workflow, ModelSQL, ModelView):
cost_values.append(
(move.product, cost_price,
move._cost_price_pattern))
- if move.cost_price is None:
+ if move.cost_price_required and move.cost_price is None:
if cost_price is None:
cost_price = move.product.get_multivalue(
'cost_price', **move._cost_price_pattern)
--
2.25.1

View File

@ -0,0 +1,657 @@
From 5ddcf090b2ffba088a81e764a00c36984f101798 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=80ngel=20=C3=80lvarez?= <angel@nan-tic.com>
Date: Wed, 7 Apr 2021 12:23:41 +0200
Subject: [PATCH] Update cost of moves when recomputing product cost
issue8795
issue7271
---
__init__.py | 3 +
ir.py | 15 ++
move.py | 18 +-
product.py | 188 +++++++++++++++---
product.xml | 13 +-
tests/scenario_stock_average_cost_price.rst | 2 +
...rio_stock_recompute_average_cost_price.rst | 160 +++++++++++++++
tests/test_stock.py | 5 +
view/recompute_cost_price_start_form.xml | 7 +
9 files changed, 377 insertions(+), 34 deletions(-)
create mode 100644 ir.py
create mode 100644 tests/scenario_stock_recompute_average_cost_price.rst
create mode 100644 view/recompute_cost_price_start_form.xml
diff --git a/__init__.py b/__init__.py
index daf5784..6d65b2e 100644
--- a/trytond/trytond/modules/stock/__init__.py
+++ b/trytond/trytond/modules/stock/__init__.py
@@ -11,6 +11,7 @@ from . import inventory
from . import configuration
from . import party
from . import res
+from . import ir
from .move import StockMixin
@@ -41,6 +42,7 @@ def register():
product.ProductByLocationContext,
product.ProductQuantitiesByWarehouse,
product.ProductQuantitiesByWarehouseContext,
+ product.RecomputeCostPriceStart,
inventory.Inventory,
inventory.InventoryLine,
inventory.CountSearch,
@@ -49,6 +51,7 @@ def register():
configuration.ConfigurationSequence,
configuration.ConfigurationLocation,
res.User,
+ ir.Cron,
module='stock', type_='model')
Pool.register(
shipment.AssignShipmentOut,
diff --git a/ir.py b/ir.py
new file mode 100644
index 0000000..0508a53
--- /a//trytond/trytond/modules/stock/ir.py
+++ b/trytond/trytond/modules/stock/ir.py
@@ -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"),
+ ])
diff --git a/move.py b/move.py
index 7995ab4..42cf691 100644
--- a/trytond/trytond/modules/stock/move.py
+++ b/trytond/trytond/modules/stock/move.py
@@ -246,6 +246,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',
@@ -270,7 +276,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 += [
@@ -346,6 +352,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')
@@ -678,6 +688,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):
@@ -691,8 +702,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):
diff --git a/product.py b/product.py
index 1d1d448..8de40b1 100644
--- a/trytond/trytond/modules/stock/product.py
+++ b/trytond/trytond/modules/stock/product.py
@@ -9,7 +9,8 @@ from sql import Literal, Null, Select
from sql.aggregate import Max
from sql.functions import CurrentTimestamp
from sql.conditionals import Coalesce
-
+from trytond.wizard import (
+ Wizard, StateTransition, StateAction, StateView, Button)
from trytond.i18n import gettext
from trytond.model import ModelSQL, ModelView, fields
from trytond.model.exceptions import AccessError
@@ -102,12 +103,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 +212,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 +263,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')
- 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'),
- ],
- ],
- ], order=[('effective_date', 'ASC'), ('id', 'ASC')])
+ digits = self.__class__.cost_price.digits
+
+ 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'),
+ ],
+ ],
+ ]
+ 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 +360,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 +617,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 +660,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")
diff --git a/product.xml b/product.xml
index b74a8ed..fbe6d49 100644
--- a/trytond/trytond/modules/stock/product.xml
+++ b/trytond/trytond/modules/stock/product.xml
@@ -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>
@@ -184,6 +196,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>
diff --git a/tests/scenario_stock_average_cost_price.rst b/tests/scenario_stock_average_cost_price.rst
index 8223c5c..1af0e0d 100644
--- a/trytond/trytond/modules/stock/tests/scenario_stock_average_cost_price.rst
+++ b/trytond/trytond/modules/stock/tests/scenario_stock_average_cost_price.rst
@@ -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')
diff --git a/tests/scenario_stock_recompute_average_cost_price.rst b/tests/scenario_stock_recompute_average_cost_price.rst
new file mode 100644
index 0000000..f733b8f
--- /dev/null
+++ b/trytond/trytond/modules/stock/tests/scenario_stock_recompute_average_cost_price.rst
@@ -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')]
diff --git a/tests/test_stock.py b/tests/test_stock.py
index 60c5cc2..8fda0ea 100644
--- a/trytond/trytond/modules/stock/tests/test_stock.py
+++ b/trytond/trytond/modules/stock/tests/test_stock.py
@@ -1437,6 +1437,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',
diff --git a/view/recompute_cost_price_start_form.xml b/view/recompute_cost_price_start_form.xml
new file mode 100644
index 0000000..70f258f
--- /dev/null
+++ b/trytond/trytond/modules/stock/view/recompute_cost_price_start_form.xml
@@ -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>
--
2.25.1

View File

@ -0,0 +1,127 @@
From 3cdf3e96289fb280363f1df7f5a1d66a3447a0bf Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=80ngel=20=C3=80lvarez?= <angel@nan-tic.com>
Date: Thu, 8 Apr 2021 11:54:47 +0200
Subject: [PATCH] Use original cost price for returned move
and factorize the cost price of move for cost computation
issue9440
review327491003
---
move.py | 62 ++++++++++++++++++++++++++++++++++++++++++++++++---------
1 file changed, 53 insertions(+), 9 deletions(-)
diff --git a/move.py b/move.py
index df296f3..19f4401 100644
--- a/trytond/trytond/modules/stock/move.py
+++ b/trytond/trytond/modules/stock/move.py
@@ -1,4 +1,4 @@
-# This file is part of Tryton. The COPYRIGHT file at the top level of
+7# 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 datetime
import hashlib
@@ -40,6 +40,12 @@ LOCATION_DOMAIN = [
LOCATION_DEPENDS = ['state']
+def round_price(value, rounding=None):
+ "Round price using the price digits"
+ return value.quantize(
+ Decimal(1) / 10 ** price_digits[1], rounding=rounding)
+
+
class StockMixin(object):
'''Mixin class with helper to setup stock quantity field.'''
__slots__ = ()
@@ -246,6 +252,14 @@ class Move(Workflow, ModelSQL, ModelView):
'readonly': Eval('state') != 'draft',
},
depends=['unit_price_required', 'state'])
+ unit_price_company = fields.Function(
+ fields.Numeric("Unit Price", digits=price_digits,
+ states={
+ 'invisible': ~Eval('unit_price_required'),
+ },
+ depends=['unit_price_required'],
+ help="Unit price in company currency."),
+ 'get_unit_price_company')
unit_price_updated = fields.Boolean(
"Unit Price Updated", readonly=True,
states={
@@ -407,6 +421,28 @@ class Move(Workflow, ModelSQL, ModelView):
if self.from_location:
return self.from_location.type in {'storage', 'view'}
+ @classmethod
+ def get_unit_price_company(cls, moves, name):
+ pool = Pool()
+ Currency = pool.get('currency.currency')
+ Uom = pool.get('product.uom')
+ Date = pool.get('ir.date')
+ today = Date.today()
+ prices = {}
+ for move in moves:
+ if move.unit_price is not None:
+ date = move.effective_date or move.planned_date or today
+ with Transaction().set_context(date=date):
+ unit_price = Currency.compute(
+ move.currency, move.unit_price,
+ move.company.currency, round=False)
+ unit_price = Uom.compute_price(
+ move.uom, unit_price, move.product.default_uom)
+ prices[move.id] = round_price(unit_price)
+ else:
+ prices[move.id] = None
+ return prices
+
@staticmethod
def _get_shipment():
'Return list of Model names for shipment Reference'
@@ -472,7 +508,7 @@ class Move(Workflow, ModelSQL, ModelView):
def search_rec_name(cls, name, clause):
return [('product.rec_name',) + tuple(clause[1:])]
- def _compute_product_cost_price(self, direction):
+ def _compute_product_cost_price(self, direction, product_cost_price=None):
"""
Update the cost price on the given product.
The direction must be "in" if incoming and "out" if outgoing.
@@ -490,13 +526,7 @@ class Move(Workflow, ModelSQL, ModelView):
qty = Decimal(str(qty))
product_qty = Decimal(str(self.product.quantity))
- # convert wrt currency
- with Transaction().set_context(date=self.effective_date):
- unit_price = Currency.compute(self.currency, self.unit_price,
- self.company.currency, round=False)
- # convert wrt to the uom
- unit_price = Uom.compute_price(self.uom, unit_price,
- self.product.default_uom)
+ unit_price = self.get_cost_price(product_cost_price=product_cost_price)
cost_price = self.product.get_multivalue(
'cost_price', **self._cost_price_pattern)
if product_qty + qty > 0 and product_qty >= 0:
@@ -616,6 +646,20 @@ class Move(Workflow, ModelSQL, ModelView):
('company', self.company.id),
)
+ def get_cost_price(self, product_cost_price=None):
+ "Return the cost price of the move for computation"
+ with Transaction().set_context(date=self.effective_date):
+ if (self.from_location.type in {'supplier', 'production'}
+ or self.to_location.type == 'supplier'):
+ return self.unit_price_company
+ elif product_cost_price is not None:
+ return product_cost_price
+ elif self.cost_price is not None:
+ return self.cost_price
+ else:
+ return self.product.get_multivalue(
+ 'cost_price', **self._cost_price_pattern)
+
@classmethod
def _cost_price_context(cls, moves):
pool = Pool()
--
2.25.1