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()