diff --git a/CHANGELOG b/CHANGELOG index ae283f2bc6..59f3b9d7e3 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +* Add Mixin class with helper to setup stock quantity fields * Allow to customize move creation of Inventory * Allow to customize unique constraint on Inventory * Allow specific grouping Period Cache diff --git a/move.py b/move.py index bfe7a61ac5..03908c707b 100644 --- a/move.py +++ b/move.py @@ -1,5 +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. +import datetime +import operator from decimal import Decimal from functools import reduce, partial from sql import Column @@ -11,7 +13,7 @@ from trytond.pyson import In, Eval, Not, Equal, If, Get, Bool from trytond.transaction import Transaction from trytond.pool import Pool -__all__ = ['Move'] +__all__ = ['StockMixin', 'Move'] STATES = { 'readonly': In(Eval('state'), ['cancel', 'assigned', 'done']), @@ -19,6 +21,121 @@ STATES = { DEPENDS = ['state'] +class StockMixin: + '''Mixin class with helper to setup stock quantity field.''' + + @classmethod + def _quantity_context(cls, name): + pool = Pool() + Date = pool.get('ir.date') + + context = Transaction().context + new_context = {} + if name == 'quantity': + if (context.get('stock_date_end') + and context['stock_date_end'] > Date.today()): + new_context['stock_date_end'] = Date.today() + elif name == 'forecast_quantity': + new_context['forecast'] = True + if not context.get('stock_date_end'): + new_context['stock_date_end'] = datetime.date.max + return new_context + + @classmethod + def _get_quantity(cls, records, name, location_ids, products=None, + grouping=('product',), position=-1): + """ + Compute for each record the stock quantity in the default uom of the + product. + + location_ids is the list of IDs of locations to take account to compute + the stock. It can't be empty. + products restrict the stock computation to the this products (more + efficient), so it should be the products related to records. + If it is None all products are used. + grouping defines how stock moves are grouped. + position defines which field of grouping corresponds to the record + whose quantity is computed. + + Return a dictionary with records id as key and quantity as value. + """ + pool = Pool() + Product = pool.get('product.product') + + record_ids = [r.id for r in records] + quantities = dict.fromkeys(record_ids, 0.0) + if not location_ids: + return quantities + + product_ids = products and [p.id for p in products] or None + + with Transaction().set_context(cls._quantity_context(name)): + pbl = Product.products_by_location(location_ids=location_ids, + product_ids=product_ids, with_childs=True, + grouping=grouping) + + for key, quantity in pbl.iteritems(): + # pbl could return None in some keys + if (key[position] is not None and + key[position] in quantities): + quantities[key[position]] += quantity + return quantities + + @staticmethod + @classmethod + def _search_quantity(cls, name, location_ids, domain=None, + grouping=('product',), position=-1): + """ + Compute the domain to filter records which validates the domain over + quantity field. + + location_ids is the list of IDs of locations to take account to compute + the stock. It can't be empty. + grouping defines how stock moves are grouped. + position defines which field of grouping corresponds to the record + whose quantity is computed. + """ + pool = Pool() + Product = pool.get('product.product') + + if not location_ids or not domain: + return [] + + def _search_quantity_eval_domain(line, domain): + operator_funcs = { + '=': operator.eq, + '>=': operator.ge, + '>': operator.gt, + '<=': operator.le, + '<': operator.lt, + '!=': operator.ne, + 'in': lambda v, l: v in l, + 'not in': lambda v, l: v not in l, + } + + field, op, operand = domain + value = line.get(field) + return operator_funcs[op](value, operand) + + with Transaction().set_context(cls._quantity_context(name)): + pbl = Product.products_by_location( + location_ids=location_ids, + with_childs=True, grouping=grouping) + + processed_lines = [] + for key, quantity in pbl.iteritems(): + # pbl could return None in some keys + if key[position] is not None: + processed_lines.append({ + 'record_id': key[position], + name: quantity, + }) + + record_ids = [line['record_id'] for line in processed_lines + if _search_quantity_eval_domain(line, domain)] + return [('id', 'in', record_ids)] + + class Move(Workflow, ModelSQL, ModelView): "Stock Move" __name__ = 'stock.move' diff --git a/product.py b/product.py index b492026953..4ca8975c17 100644 --- a/product.py +++ b/product.py @@ -12,9 +12,11 @@ from trytond.model import ModelSQL, ModelView, fields from trytond.wizard import Wizard, StateView, StateAction, Button from trytond.pyson import PYSONEncoder, Eval, Or from trytond.transaction import Transaction -from trytond.tools import safe_eval, reduce_ids +from trytond.tools import reduce_ids from trytond.pool import Pool, PoolMeta +from .move import StockMixin + __all__ = ['Template', 'Product', 'ProductByLocationStart', 'ProductByLocation', 'ProductQuantitiesByWarehouse', 'ProductQuantitiesByWarehouseStart', @@ -75,7 +77,8 @@ class Template: super(Template, cls).write(templates, vals) -class Product: +class Product(object, StockMixin): + __metaclass__ = PoolMeta __name__ = "product.product" quantity = fields.Function(fields.Float('Quantity'), 'get_quantity', searcher='search_quantity') @@ -86,80 +89,13 @@ class Product: @classmethod def get_quantity(cls, products, name): - Date = Pool().get('ir.date') - - quantities = dict((p.id, 0.0) for p in products) - if not Transaction().context.get('locations'): - return quantities - - context = {} - if (name == 'quantity' - and Transaction().context.get('stock_date_end') - and Transaction().context.get('stock_date_end') > - Date.today()): - context['stock_date_end'] = Date.today() - - if name == 'forecast_quantity': - context['forecast'] = True - if not Transaction().context.get('stock_date_end'): - context['stock_date_end'] = datetime.date.max - with Transaction().set_context(context): - pbl = cls.products_by_location( - location_ids=Transaction().context['locations'], - product_ids=quantities.keys(), with_childs=True) - - for location in Transaction().context['locations']: - for product in products: - quantities[product.id] += pbl.get((location, product.id), 0.0) - return quantities - - @staticmethod - def _search_quantity_eval_domain(line, domain): - field, operator, operand = domain - value = line.get(field) - if value is None: - return False - if operator not in ("=", ">=", "<=", ">", "<", "!="): - return False - if operator == "=": - operator = "==" - return (safe_eval(str(value) + operator + str(operand))) + location_ids = Transaction().context.get('locations') + return cls._get_quantity(products, name, location_ids, products) @classmethod def search_quantity(cls, name, domain=None): - Date = Pool().get('ir.date') - - if not (Transaction().context.get('locations') and domain): - return [] - - context = {} - if (name == 'quantity' - and Transaction().context.get('stock_date_end') - and Transaction().context.get('stock_date_end') > - Date.today()): - context['stock_date_end'] = Date.today() - - if name == 'forecast_quantity': - context['forecast'] = True - if not Transaction().context.get('stock_date_end'): - context['stock_date_end'] = datetime.date.max - - with Transaction().set_context(context): - pbl = cls.products_by_location( - location_ids=Transaction().context['locations'], - with_childs=True).iteritems() - - processed_lines = [] - for (location, product), quantity in pbl: - processed_lines.append({ - 'location': location, # XXX useful ? - 'product': product, - name: quantity, - }) - - res = [line['product'] for line in processed_lines - if cls._search_quantity_eval_domain(line, domain)] - return [('id', 'in', res)] + location_ids = Transaction().context.get('locations') + return cls._search_quantity(name, location_ids, domain) @classmethod def get_cost_value(cls, products, name): diff --git a/tests/test_stock.py b/tests/test_stock.py index dc1749a2f3..f0f6c7cc9d 100644 --- a/tests/test_stock.py +++ b/tests/test_stock.py @@ -266,6 +266,17 @@ class StockTestCase(unittest.TestCase): }, 6), ] + def tests_product_quantity(context, quantity): + with transaction.set_context(locations=[storage.id]): + product_reloaded = self.product(product.id) + if (not context.get('stock_date_end') + or context['stock_date_end'] > today + or context.get('forecast')): + self.assertEqual(product_reloaded.forecast_quantity, + quantity) + else: + self.assertEqual(product_reloaded.quantity, quantity) + def test_products_by_location(): for context, quantity in tests: with transaction.set_context(context): @@ -274,6 +285,7 @@ class StockTestCase(unittest.TestCase): else: self.assertEqual(products_by_location(), {(storage.id, product.id): quantity}) + tests_product_quantity(context, quantity) test_products_by_location() @@ -283,7 +295,7 @@ class StockTestCase(unittest.TestCase): today + relativedelta(days=-4), today + relativedelta(days=-3), today + relativedelta(days=-2), - ] + ] moves = self.move.create([{ 'product': product.id, @@ -450,7 +462,6 @@ class StockTestCase(unittest.TestCase): 'currency': currency.id, }]) - tests = [ (-5, { supplier.id: -10,