From 0096fed6557dcc3eac8feeb7c8e51ee96f9a285a Mon Sep 17 00:00:00 2001 From: Guillem Barba Date: Mon, 9 Sep 2013 14:11:53 +0200 Subject: [PATCH] Add Mixin class with helper to setup stock quantity fields Add StockMixin class in move.py file with helper functions to be used in getter and searcher methods of quantity fields. Reimplemented getter and searcher of quantity/forecast_quantity fields of Product to use these helper functions. Add test over product's quantity fields. issue3279 review963003 --- CHANGELOG | 1 + move.py | 119 +++++++++++++++++++++++++++++++++++++++++++- product.py | 82 ++++-------------------------- tests/test_stock.py | 15 +++++- 4 files changed, 141 insertions(+), 76 deletions(-) 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,