From 32fc7661b330e39afef064b36a748ec4edfe8fbe Mon Sep 17 00:00:00 2001 From: "Ahmed Khalaf (ahkh)" Date: Mon, 13 Jun 2022 16:22:00 +0000 Subject: [PATCH] [IMP] stock: replenishment locations this commit will allow users to setup replenish locations and replenish them directly if they have negative forecast instead of checking forecast qty per warehouse, this adds the abiltiy to replenish to any internal stock location, marked as replenish location, instead of just the warehouse location Task Id: 2796757 closes odoo/odoo#93633 Related: odoo/upgrade#3626 Signed-off-by: William Henrotin (whe) --- addons/stock/models/stock_location.py | 17 +++ addons/stock/models/stock_orderpoint.py | 106 +++++++----------- addons/stock/models/stock_warehouse.py | 1 + addons/stock/tests/test_proc_rule.py | 39 ++++++- addons/stock/views/stock_location_views.xml | 1 + addons/stock/views/stock_orderpoint_views.xml | 8 +- 6 files changed, 106 insertions(+), 66 deletions(-) diff --git a/addons/stock/models/stock_location.py b/addons/stock/models/stock_location.py index 55118a9a95e..d34027d5217 100644 --- a/addons/stock/models/stock_location.py +++ b/addons/stock/models/stock_location.py @@ -70,6 +70,8 @@ class Location(models.Model): help='Let this field empty if this location is shared between companies') scrap_location = fields.Boolean('Is a Scrap Location?', default=False, help='Check this box to allow using this location to put scrapped/damaged goods.') return_location = fields.Boolean('Is a Return Location?', help='Check this box to allow using this location as a return location.') + replenish_location = fields.Boolean('Replenish Location', copy=False, compute="_compute_replenish_location", readonly=False, store=True, + help='Activate this function to get all quantities to replenish at this particular location') removal_strategy_id = fields.Many2one( 'product.removal', 'Removal Strategy', help="Defines the default method used for suggesting the exact location (shelf) " @@ -167,6 +169,21 @@ class Location(models.Model): if self.usage not in ('internal', 'inventory'): self.scrap_location = False + @api.depends('usage') + def _compute_replenish_location(self): + for loc in self: + if loc.usage != 'internal': + loc.replenish_location = False + + @api.constrains('replenish_location', 'location_id', 'usage') + def _check_replenish_location(self): + for loc in self: + if loc.replenish_location: + # cannot have parent/child location set as replenish as well + replenish_wh_location = self.search([('id', '!=', loc.id), ('replenish_location', '=', True), '|', ('location_id', 'child_of', loc.id), ('location_id', 'parent_of', loc.id)], limit=1) + if replenish_wh_location: + raise ValidationError(_('Another parent/sub replenish location %s exists, if you wish to change it, uncheck it first', replenish_wh_location.name)) + @api.constrains('scrap_location') def _check_scrap_location(self): for record in self: diff --git a/addons/stock/models/stock_orderpoint.py b/addons/stock/models/stock_orderpoint.py index d13389f1ea2..bdee4f74590 100644 --- a/addons/stock/models/stock_orderpoint.py +++ b/addons/stock/models/stock_orderpoint.py @@ -335,82 +335,62 @@ class StockWarehouseOrderpoint(models.Model): orderpoints_removed = orderpoints._unlink_processed_orderpoints() orderpoints = orderpoints - orderpoints_removed to_refill = defaultdict(float) - all_product_ids = [] - all_warehouse_ids = [] - # Take 3 months since it's the max for the forecast report - to_date = add(fields.date.today(), months=3) - qty_by_product_warehouse = self.env['report.stock.quantity']._read_group( - [('date', '=', to_date), ('state', '=', 'forecast')], - ['product_id', 'product_qty', 'warehouse_id'], - ['product_id', 'warehouse_id'], lazy=False) - for group in qty_by_product_warehouse: - warehouse_id = group.get('warehouse_id') and group['warehouse_id'][0] - if group['product_qty'] >= 0.0 or not warehouse_id: - continue - all_product_ids.append(group['product_id'][0]) - all_warehouse_ids.append(warehouse_id) - to_refill[(group['product_id'][0], warehouse_id)] = group['product_qty'] - if not to_refill: - return action + all_product_ids = self.env['product.product'].search([('type', '=', 'product')]) + all_replenish_location_ids = self.env['stock.location'].search([('replenish_location', '=', True)]) + ploc_per_day = defaultdict(set) + product_loc_by_qty = {} + # For each replenish location get products with negative virtual_available aka forecast + for loc in all_replenish_location_ids: + quantities = all_product_ids.with_context(location=loc.id).mapped('virtual_available') + for product, quantity in zip(all_product_ids, quantities): + if float_compare(quantity, 0, precision_rounding=product.uom_id.rounding) >= 0: + continue + product_loc_by_qty[(product, loc)] = quantity - # Recompute the forecasted quantity for missing product today but at this time - # with their real lead days. - key_to_remove = [] - pwh_per_day = defaultdict(list) - for (product, warehouse) in to_refill.keys(): - product = self.env['product.product'].browse(product).with_prefetch(all_product_ids) - warehouse = self.env['stock.warehouse'].browse(warehouse).with_prefetch(all_warehouse_ids) - rules = product._get_rules_from_location(warehouse.lot_stock_id) - lead_days = rules.with_context(bypass_delay_description=True)._get_lead_days(product)[0] - pwh_per_day[(lead_days, warehouse)].append(product.id) - # group product by lead_days and warehouse in order to read virtual_available + # group product by lead_days and location in order to read virtual_available # in batch + for (product, loc), qty in product_loc_by_qty.items(): + rules = product._get_rules_from_location(loc) + lead_days = rules.with_context(bypass_delay_description=True)._get_lead_days(product)[0] + ploc_per_day[(lead_days, loc)].add(product.id) + + # recompute virtual_available with lead days today = fields.datetime.now().replace(hour=23, minute=59, second=59) - for (days, warehouse), p_ids in pwh_per_day.items(): - products = self.env['product.product'].browse(p_ids) + for (days, loc), product_ids in ploc_per_day.items(): + products = self.env['product.product'].browse(product_ids) qties = products.with_context( - warehouse=warehouse.id, + location=loc.id, to_date=today + relativedelta.relativedelta(days=days) ).read(['virtual_available']) for qty in qties: - if float_compare(qty['virtual_available'], 0, precision_rounding=product.uom_id.rounding) >= 0: - key_to_remove.append((qty['id'], warehouse.id)) - else: - to_refill[(qty['id'], warehouse.id)] = qty['virtual_available'] - - for key in key_to_remove: - del to_refill[key] + if float_compare(qty['virtual_available'], 0, precision_rounding=product.uom_id.rounding) < 0: + to_refill[(qty['id'], loc.id)] = qty['virtual_available'] if not to_refill: return action # Remove incoming quantity from other origin than moves (e.g RFQ) - product_ids, warehouse_ids = zip(*to_refill) - dummy, qty_by_product_wh = self.env['product.product'].browse(product_ids)._get_quantity_in_progress(warehouse_ids=warehouse_ids) + product_ids, location_ids = zip(*to_refill) + qty_by_product_loc, dummy = self.env['product.product'].browse(product_ids)._get_quantity_in_progress(location_ids=location_ids) rounding = self.env['decimal.precision'].precision_get('Product Unit of Measure') - # Group orderpoint by product-warehouse - orderpoint_by_product_warehouse = self.env['stock.warehouse.orderpoint']._read_group( + # Group orderpoint by product-location + orderpoint_by_product_location = self.env['stock.warehouse.orderpoint']._read_group( [('id', 'in', orderpoints.ids)], - ['product_id', 'warehouse_id', 'qty_to_order:sum'], - ['product_id', 'warehouse_id'], lazy=False) - orderpoint_by_product_warehouse = { - (record.get('product_id')[0], record.get('warehouse_id')[0]): record.get('qty_to_order') - for record in orderpoint_by_product_warehouse + ['product_id', 'location_id', 'qty_to_order:sum'], + ['product_id', 'location_id'], lazy=False) + orderpoint_by_product_location = { + (record.get('product_id')[0], record.get('location_id')[0]): record.get('qty_to_order') + for record in orderpoint_by_product_location } - for (product, warehouse), product_qty in to_refill.items(): - qty_in_progress = qty_by_product_wh.get((product, warehouse)) or 0.0 - qty_in_progress += orderpoint_by_product_warehouse.get((product, warehouse), 0.0) - # Add qty to order for other orderpoint under this warehouse. + for (product, location), product_qty in to_refill.items(): + qty_in_progress = qty_by_product_loc.get((product, location)) or 0.0 + qty_in_progress += orderpoint_by_product_location.get((product, location), 0.0) + # Add qty to order for other orderpoint under this location. if not qty_in_progress: continue - to_refill[(product, warehouse)] = product_qty + qty_in_progress + to_refill[(product, location)] = product_qty + qty_in_progress to_refill = {k: v for k, v in to_refill.items() if float_compare( v, 0.0, precision_digits=rounding) < 0.0} - lot_stock_id_by_warehouse = self.env['stock.warehouse'].with_context(active_test=False).search_read([ - ('id', 'in', [g[1] for g in to_refill.keys()]) - ], ['lot_stock_id']) - lot_stock_id_by_warehouse = {w['id']: w['lot_stock_id'][0] for w in lot_stock_id_by_warehouse} - # With archived ones to avoid `product_location_check` SQL constraints orderpoint_by_product_location = self.env['stock.warehouse.orderpoint'].with_context(active_test=False)._read_group( [('id', 'in', orderpoints.ids)], @@ -422,17 +402,17 @@ class StockWarehouseOrderpoint(models.Model): } orderpoint_values_list = [] - for (product, warehouse), product_qty in to_refill.items(): - lot_stock_id = lot_stock_id_by_warehouse[warehouse] - orderpoint_id = orderpoint_by_product_location.get((product, lot_stock_id)) + for (product, location_id), product_qty in to_refill.items(): + orderpoint_id = orderpoint_by_product_location.get((product, location_id)) if orderpoint_id: self.env['stock.warehouse.orderpoint'].browse(orderpoint_id).qty_forecast += product_qty else: - orderpoint_values = self.env['stock.warehouse.orderpoint']._get_orderpoint_values(product, lot_stock_id) + orderpoint_values = self.env['stock.warehouse.orderpoint']._get_orderpoint_values(product, location_id) + warehouse_id = self.env['stock.location'].browse(location_id).warehouse_id orderpoint_values.update({ 'name': _('Replenishment Report'), - 'warehouse_id': warehouse, - 'company_id': self.env['stock.warehouse'].browse(warehouse).company_id.id, + 'warehouse_id': warehouse_id.id, + 'company_id': warehouse_id.company_id.id, }) orderpoint_values_list.append(orderpoint_values) diff --git a/addons/stock/models/stock_warehouse.py b/addons/stock/models/stock_warehouse.py index 9885cdc3ebd..cb56bf97842 100644 --- a/addons/stock/models/stock_warehouse.py +++ b/addons/stock/models/stock_warehouse.py @@ -601,6 +601,7 @@ class Warehouse(models.Model): 'name': _('Stock'), 'active': True, 'usage': 'internal', + 'replenish_location': True, 'barcode': self._valid_barcode(code + '-STOCK', company_id) }, 'wh_input_stock_loc_id': { diff --git a/addons/stock/tests/test_proc_rule.py b/addons/stock/tests/test_proc_rule.py index 9a144733199..5e81ae16822 100644 --- a/addons/stock/tests/test_proc_rule.py +++ b/addons/stock/tests/test_proc_rule.py @@ -326,7 +326,7 @@ class TestProcRule(TransactionCase): self.assertAlmostEqual(picking_pick.move_ids.product_uom_qty, 5.0) self.assertAlmostEqual(picking_ship.move_ids.product_uom_qty, 3.0) - def test_orderpoint_replenishment_view(self): + def test_orderpoint_replenishment_view_1(self): """ Create two warehouses + two moves verify that the replenishment view is consistent""" warehouse_1 = self.env['stock.warehouse'].search([('company_id', '=', self.env.company.id)], limit=1) @@ -380,6 +380,43 @@ class TestProcRule(TransactionCase): {'location_id': warehouse_3.lot_stock_id.id, 'route_id': route_3.id}, ]) + def test_orderpoint_replenishment_view_2(self): + """ Create a warehouse + location to replenish warehouse instead of main location + verify that the orderpoints created are for the replenish locations not the warehouse main location""" + warehouse_1 = self.env['stock.warehouse'].create({ + 'name': 'Warehouse 1', + 'code': 'WH1', + }) + warehouse_1.lot_stock_id.replenish_location = False + replenish_loc = self.env['stock.location'].create({ + 'name': 'Replenish Location', + 'location_id': warehouse_1.lot_stock_id.id, + 'replenish_location': True, + }) + product = self.env['product.product'].create({ + 'name': 'Rep Product', + 'type': 'product', + }) + move = self.env['stock.move'].create({ + 'name': 'Move WH2', + 'location_id': replenish_loc.id, + 'location_dest_id': self.partner.property_stock_customer.id, + 'product_id': product.id, + 'product_uom': product.uom_id.id, + 'product_uom_qty': 3, + }) + move._action_confirm() + # activate action of opening the replenishment view + self.env.flush_all() + self.env['stock.warehouse.orderpoint'].action_open_orderpoints() + replenishments = self.env['stock.warehouse.orderpoint'].search([ + ('product_id', '=', product.id), + ]) + # Verify the location and the qty + self.assertRecordValues(replenishments, [ + {'location_id': replenish_loc.id, 'qty_to_order': 3}, + ]) + def test_orderpoint_compute_warehouse_location(self): warehouse_a = self.env['stock.warehouse'].search([], limit=1) warehouse_b = self.env['stock.warehouse'].create({ diff --git a/addons/stock/views/stock_location_views.xml b/addons/stock/views/stock_location_views.xml index 3a50fff54f7..46611b5dd06 100644 --- a/addons/stock/views/stock_location_views.xml +++ b/addons/stock/views/stock_location_views.xml @@ -39,6 +39,7 @@ + diff --git a/addons/stock/views/stock_orderpoint_views.xml b/addons/stock/views/stock_orderpoint_views.xml index fada53a1b91..5e7339011af 100644 --- a/addons/stock/views/stock_orderpoint_views.xml +++ b/addons/stock/views/stock_orderpoint_views.xml @@ -92,6 +92,10 @@ + + + + @@ -211,8 +215,8 @@ action = model.with_context( search_default_filter_to_reorder=True, search_default_filter_not_snoozed=True, - search_default_filter_creation_trigger=True, - default_trigger='manual' + default_trigger='manual', + searchpanel_default_trigger='manual' ).action_open_orderpoints()