From bbd8d31cdd940049d33fff862995325ad0617864 Mon Sep 17 00:00:00 2001 From: William Henrotin Date: Tue, 23 Jul 2019 08:29:17 +0000 Subject: [PATCH] [IMP] stock: introducing free quantity on products The stock module adds 4 quantities on products: Quantity on hand Incoming quantity Outgoing quantity and Forecast Quantity Forecast Quantity is equals to Quantity on hand + Incoming quantity - Outgoing quantity. Those two last ones are computed from the open stock moves wainting to be validated but the reservation is not taken into account. This commit introduces a fifth quantity 'free_qty' that acts like the forecast one but only with reserved quantities. --- addons/mrp/models/product.py | 4 ++++ addons/stock/models/product.py | 28 +++++++++++++++++++++++----- addons/stock/tests/test_product.py | 16 ++++++++++++++++ 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/addons/mrp/models/product.py b/addons/mrp/models/product.py index dd874cd31c5..325b303458a 100644 --- a/addons/mrp/models/product.py +++ b/addons/mrp/models/product.py @@ -88,6 +88,7 @@ class ProductProduct(models.Model): - 'qty_available' - 'incoming_qty' - 'outgoing_qty' + - 'free_qty' """ for product in self: bom_kit = self.env['mrp.bom']._bom_find(product=product, bom_type='phantom') @@ -97,6 +98,7 @@ class ProductProduct(models.Model): ratios_qty_available = [] ratios_incoming_qty = [] ratios_outgoing_qty = [] + ratios_free_qty = [] for bom_line, bom_line_data in bom_sub_lines: component = bom_line.product_id uom_qty_per_kit = bom_line_data['qty'] / bom_line_data['original_qty'] @@ -105,11 +107,13 @@ class ProductProduct(models.Model): ratios_qty_available.append(component.qty_available / qty_per_kit) ratios_incoming_qty.append(component.incoming_qty / qty_per_kit) ratios_outgoing_qty.append(component.outgoing_qty / qty_per_kit) + ratios_free_qty.append(component.free_qty / qty_per_kit) if bom_sub_lines: product.virtual_available = min(ratios_virtual_available) // 1 product.qty_available = min(ratios_qty_available) // 1 product.incoming_qty = min(ratios_incoming_qty) // 1 product.outgoing_qty = min(ratios_incoming_qty) // 1 + product.free_qty = min(ratios_free_qty) // 1 else: super(ProductProduct, self)._compute_quantities() diff --git a/addons/stock/models/product.py b/addons/stock/models/product.py index 0d01952915b..d3e079cdd70 100644 --- a/addons/stock/models/product.py +++ b/addons/stock/models/product.py @@ -47,6 +47,18 @@ class Product(models.Model): "of its children.\n" "Otherwise, this includes goods stored in any Stock Location " "with 'internal' type.") + free_qty = fields.Float( + 'Free To Use Quantity ', compute='_compute_quantities', search='_search_free_qty', + digits='Product Unit of Measure', + help="Forecast quantity (computed as Quantity On Hand " + "- reserved quantity)\n" + "In a context with a single Stock Location, this includes " + "goods stored in this location, or any of its children.\n" + "In a context with a single Warehouse, this includes " + "goods stored in the Stock Location of this Warehouse, or any " + "of its children.\n" + "Otherwise, this includes goods stored in any Stock Location " + "with 'internal' type.") incoming_qty = fields.Float( 'Incoming', compute='_compute_quantities', search='_search_incoming_qty', digits='Product Unit of Measure', @@ -84,6 +96,7 @@ class Product(models.Model): product.incoming_qty = res[product.id]['incoming_qty'] product.outgoing_qty = res[product.id]['outgoing_qty'] product.virtual_available = res[product.id]['virtual_available'] + product.free_qty = res[product.id]['free_qty'] def _product_available(self, field_names=None, arg=False): """ Compatibility method """ @@ -124,7 +137,7 @@ class Product(models.Model): domain_move_out_todo = [('state', 'in', ('waiting', 'confirmed', 'assigned', 'partially_available'))] + domain_move_out moves_in_res = dict((item['product_id'][0], item['product_qty']) for item in Move.read_group(domain_move_in_todo, ['product_id', 'product_qty'], ['product_id'], orderby='id')) moves_out_res = dict((item['product_id'][0], item['product_qty']) for item in Move.read_group(domain_move_out_todo, ['product_id', 'product_qty'], ['product_id'], orderby='id')) - quants_res = dict((item['product_id'][0], item['quantity']) for item in Quant.read_group(domain_quant, ['product_id', 'quantity'], ['product_id'], orderby='id')) + quants_res = dict((item['product_id'][0], (item['quantity'], item['reserved_quantity'])) for item in Quant.read_group(domain_quant, ['product_id', 'quantity', 'reserved_quantity'], ['product_id'], orderby='id')) if dates_in_the_past: # Calculate the moves that were done before now to calculate back in time (as most questions will be recent ones) domain_move_in_done = [('state', '=', 'done'), ('date', '>', to_date)] + domain_move_in_done @@ -137,17 +150,19 @@ class Product(models.Model): product_id = product.id if not product_id: res[product_id] = dict.fromkeys( - ['qty_available', 'incoming_qty', 'outgoing_qty', 'virtual_available'], + ['qty_available', 'free_qty', 'incoming_qty', 'outgoing_qty', 'virtual_available'], 0.0, ) continue rounding = product.uom_id.rounding res[product_id] = {} if dates_in_the_past: - qty_available = quants_res.get(product_id, 0.0) - moves_in_res_past.get(product_id, 0.0) + moves_out_res_past.get(product_id, 0.0) + qty_available = quants_res.get(product_id, [0.0])[0] - moves_in_res_past.get(product_id, 0.0) + moves_out_res_past.get(product_id, 0.0) else: - qty_available = quants_res.get(product_id, 0.0) + qty_available = quants_res.get(product_id, [0.0])[0] + reserved_quantity = quants_res.get(product_id, [False, 0.0])[1] res[product_id]['qty_available'] = float_round(qty_available, precision_rounding=rounding) + res[product_id]['free_qty'] = float_round(qty_available - reserved_quantity, precision_rounding=rounding) res[product_id]['incoming_qty'] = float_round(moves_in_res.get(product_id, 0.0), precision_rounding=rounding) res[product_id]['outgoing_qty'] = float_round(moves_out_res.get(product_id, 0.0), precision_rounding=rounding) res[product_id]['virtual_available'] = float_round( @@ -297,10 +312,13 @@ class Product(models.Model): # TDE FIXME: should probably clean the search methods return self._search_product_quantity(operator, value, 'outgoing_qty') + def _search_free_qty(self, operator, value): + return self._search_product_quantity(operator, value, 'free_qty') + def _search_product_quantity(self, operator, value, field): # TDE FIXME: should probably clean the search methods # to prevent sql injections - if field not in ('qty_available', 'virtual_available', 'incoming_qty', 'outgoing_qty'): + if field not in ('qty_available', 'virtual_available', 'incoming_qty', 'outgoing_qty', 'free_qty'): raise UserError(_('Invalid domain left operand %s') % field) if operator not in ('<', '>', '=', '!=', '<=', '>='): raise UserError(_('Invalid domain operator %s') % operator) diff --git a/addons/stock/tests/test_product.py b/addons/stock/tests/test_product.py index 606254f1ab5..d9a82f2e478 100644 --- a/addons/stock/tests/test_product.py +++ b/addons/stock/tests/test_product.py @@ -65,3 +65,19 @@ class TestVirtualAvailable(TestStockCommon): self.picking_out.action_assign() self.picking_out_2.action_assign() self.assertAlmostEqual(5.0, prod_context.virtual_available) + + def test_free_quantity(self): + """ Test the value of product.free_qty. Free_qty = qty_on_hand - qty_reserved""" + self.assertAlmostEqual(40.0, self.product_3.free_qty) + self.picking_out.action_confirm() + self.picking_out_2.action_confirm() + # No reservation so free_qty is unchanged + self.assertAlmostEqual(40.0, self.product_3.free_qty) + self.picking_out.action_assign() + self.picking_out_2.action_assign() + # 8 units are now reserved + self.assertAlmostEqual(32.0, self.product_3.free_qty) + self.picking_out.do_unreserve() + self.picking_out_2.do_unreserve() + # 8 units are available again + self.assertAlmostEqual(40.0, self.product_3.free_qty)