diff --git a/move.py b/move.py index 1d4bd63..1b41353 100644 --- a/trytond/trytond/modules/product_cost_fifo/move.py +++ b/trytond/trytond/modules/product_cost_fifo/move.py @@ -10,6 +10,8 @@ from trytond.model.exceptions import AccessError from trytond.pool import Pool, PoolMeta from trytond.transaction import Transaction +from trytond.modules.product import round_price + __all__ = ['Move'] @@ -60,6 +62,24 @@ class Move(metaclass=PoolMeta): column, operator, value, expression) return expression + def _compute_product_cost_price(self, direction, product_cost_price=None): + pool = Pool() + Location = pool.get('stock.location') + Config = pool.get('stock.configuration') + + configuration = Config(1) + location_ids = [] + + if configuration.warehouse: + locations = Location.search([ + ('parent', 'child_of', [configuration.warehouse]), + ]) + location_ids = list(set(x.id for x in locations)) + with Transaction().set_context(locations=location_ids): + self = self.__class__(self.id) + return super()._compute_product_cost_price(direction, + product_cost_price) + def _update_fifo_out_product_cost_price(self): ''' Update the product cost price of the given product on the move. Update @@ -68,7 +88,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 +100,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) @@ -107,23 +117,18 @@ 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( - Decimal(str(10.0 ** -digits[1]))) + cost_price = round_price(cost_price) else: cost_price = average_cost_price return cost_price, average_cost_price 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') @@ -136,7 +141,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 diff --git a/product.py b/product.py index 9eaceac..aa1b06c 100644 --- a/trytond/trytond/modules/product_cost_fifo/product.py +++ b/trytond/trytond/modules/product_cost_fifo/product.py @@ -7,6 +7,8 @@ from trytond.config import config from trytond.transaction import Transaction from trytond.pool import Pool, PoolMeta +from trytond.modules.product import round_price + __all__ = ['Template', 'Product'] @@ -24,6 +26,49 @@ class Template(metaclass=PoolMeta): class Product(metaclass=PoolMeta): __name__ = 'product.product' + def _extra_domain_fifo_moves(self): + pool = Pool() + Location = pool.get('stock.location') + Config = pool.get('stock.configuration') + + configuration = Config(1) + + domain = [] + if configuration.warehouse: + storage_locations = Location.search(['type', '=', 'storage']) + locations = Location.search([ + ('parent', 'child_of', [configuration.warehouse]), + ]) + location_ids = list(set(x.id for x in storage_locations) - + set(x.id for x in locations)) + domain.extend([ + ('to_location.id', 'not in', location_ids), + ('from_location.id', 'not in', location_ids), + ]) + return domain + + def _get_storage_quantity(self, date=None): + pool = Pool() + Location = pool.get('stock.location') + Config = pool.get('stock.configuration') + + configuration = Config(1) + + if not configuration.warehouse: + return super()._get_storage_quantity(date) + + locations = Location.search([ + ('parent', 'child_of', [configuration.warehouse]), + ]) + if not date: + date = dt.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 _get_available_fifo_moves(self, date=None, offset=0, limit=None): pool = Pool() Move = pool.get('stock.move') @@ -31,13 +76,16 @@ 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: domain.append(('fifo_quantity_available', '>', 0)) else: domain.append(('effective_date', '<=', date)) + if self._extra_domain_fifo_moves(): + domain.extend(self._extra_domain_fifo_moves()) + return Move.search( domain, offset=offset, limit=limit, @@ -93,9 +141,7 @@ 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 domain = [ ('product', '=', self.id), @@ -113,6 +159,8 @@ class Product(metaclass=PoolMeta): ] if start: domain.append(('effective_date', '>=', start)) + if self._extra_domain_fifo_moves(): + domain.extend(self._extra_domain_fifo_moves()) moves = Move.search( domain, order=[('effective_date', 'ASC'), ('id', 'ASC')]) @@ -135,32 +183,27 @@ 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( - 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 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)) + 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]))) + return round_price(cost_price / Decimal(str(consumed_qty))) + # Process first the incoming per day + # in order to keep quantity positive as much as possible + # We do no re-browse because we expect only few permutations + 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 @@ -183,42 +226,33 @@ 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)) - (fifo_cost_price * current_out_qty)) / quantity) else: - cost_price = Decimal(0) - current_cost_price = cost_price.quantize( - Decimal(str(10.0 ** -digits[1]))) + cost_price = current_cost_price + current_cost_price = round_price(cost_price) 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) + 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) ) / (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 + current_cost_price = round_price(cost_price) + elif out_move(move): + current_out_qty += qty + quantity += -qty if out_move(move) else qty Move.write([ m for m in filter(in_move, current_moves) @@ -235,14 +269,10 @@ 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)) - (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 + return cost_price diff --git a/tests/scenario_product_cost_fifo_recompute_cost_price.rst b/tests/scenario_product_cost_fifo_recompute_cost_price.rst index f8b952f..d4f906d 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:: @@ -58,6 +59,12 @@ Create some moves:: ... effective_date=today - dt.timedelta(days=2)).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=supplier_loc, ... to_location=storage_loc, @@ -67,8 +74,7 @@ Create some moves:: ... product=product, ... quantity=2, ... from_location=storage_loc, - ... to_location=customer_loc, - ... unit_price=Decimal('300'), + ... to_location=lost_found, ... effective_date=today - dt.timedelta(days=1)).click('do') >>> StockMove( ... product=product, @@ -94,11 +100,11 @@ Create some moves:: >>> [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 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('100.0000') Recompute cost price from a date:: @@ -119,8 +125,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('100.0000') diff --git a/stock.py b/stock.py index c7b247f..34e7d47 100644 --- a/trytond/trytond/modules/sale/stock.py +++ b/trytond/trytond/modules/sale/stock.py @@ -1,6 +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 functools import wraps +from decimal import Decimal from trytond.i18n import gettext from trytond.model import Workflow, ModelView, fields @@ -8,6 +9,8 @@ from trytond.model.exceptions import AccessError from trytond.transaction import Transaction from trytond.pool import Pool, PoolMeta +from trytond.modules.product import round_price + __all__ = ['ShipmentOut', 'ShipmentOutReturn', 'Move'] @@ -155,6 +158,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.sale.origin, Sale)): + sale = self.origin.sale.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() diff --git a/move.py b/move.py index aafa9e8..4ed02e7 100644 --- a/trytond/trytond/modules/stock/move.py +++ b/trytond/trytond/modules/stock/move.py @@ -19,7 +19,7 @@ from trytond.tools import reduce_ids from trytond.transaction import Transaction from trytond.pool import Pool -from trytond.modules.product import price_digits +from trytond.modules.product import price_digits, round_price from .exceptions import MoveOriginWarning @@ -251,8 +251,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'), @@ -264,6 +271,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') @@ -401,6 +411,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: @@ -471,15 +488,13 @@ 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. """ pool = Pool() Uom = pool.get('product.uom') - Product = pool.get('product.product') - Currency = pool.get('currency.currency') if direction == 'in': quantity = self.quantity @@ -489,13 +504,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: @@ -507,9 +516,7 @@ class Move(Workflow, ModelSQL, ModelView): elif direction == 'out': new_cost_price = cost_price - digits = Product.cost_price.digits - return new_cost_price.quantize( - Decimal(str(10.0 ** -digits[1]))) + return round_price(new_cost_price) @staticmethod def _get_internal_quantity(quantity, uom, product): @@ -583,7 +590,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) @@ -609,6 +616,27 @@ 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" + pool = Pool() + Currency = pool.get('currency.currency') + UoM = pool.get('product.uom') + with Transaction().set_context(date=self.effective_date): + if (self.from_location.type in {'supplier', 'production'} + or self.to_location.type == 'supplier'): + unit_price = Currency.compute( + self.currency, self.unit_price, + self.company.currency, round=False) + return UoM.compute_price( + self.uom, unit_price, self.product.default_uom) + 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()