add cost recalculation patches
This commit is contained in:
parent
e513bf349b
commit
4b42d9a3aa
|
@ -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
|
|
@ -1,13 +1,14 @@
|
||||||
diff --git a/move.py b/move.py
|
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
|
--- a/trytond/trytond/modules/product_cost_fifo/move.py
|
||||||
+++ b/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):
|
@@ -105,6 +105,9 @@ class Move(metaclass=PoolMeta):
|
||||||
cost_price += move_unit_price * Decimal(str(move_qty))
|
|
||||||
|
|
||||||
move_qty = Uom.compute_qty(self.product.default_uom, move_qty,
|
move_qty = Uom.compute_qty(self.product.default_uom, move_qty,
|
||||||
- move.uom, round=False)
|
move.uom, round=False)
|
||||||
+ move.uom, round=True)
|
|
||||||
move.fifo_quantity = (move.fifo_quantity or 0.0) + move_qty
|
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)
|
to_save.append(move)
|
||||||
if to_save:
|
if to_save:
|
||||||
|
# TODO save in do method when product change
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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')
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
18
series
|
@ -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
|
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
|
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
|
issue10068.diff # [trytond] Use safe_join in SharedDataMiddlewareIndex
|
||||||
|
|
||||||
issue10053.diff # [party] Configure available identifiers
|
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
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
Loading…
Reference in New Issue