From c4c7e5b5e2a04571c51b91490de86beefd56b97c Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Fri, 3 Apr 2026 21:53:19 +0700 Subject: [PATCH] refactor: enhance location restriction logic with exhaustive context lookups and diagnostic logging --- models/stock_location.py | 149 +++++++----------- .../js/stock_move_line_x2_many_field_patch.js | 43 +++-- 2 files changed, 86 insertions(+), 106 deletions(-) diff --git a/models/stock_location.py b/models/stock_location.py index 70c62a0..c3945f1 100644 --- a/models/stock_location.py +++ b/models/stock_location.py @@ -10,74 +10,70 @@ class StockLocation(models.Model): def _get_allowed_locations(self): """ - Helper to retrieve allowed locations based on the current context. - PRIORITY 1: Use explicit IDs passed in context (from views). - PRIORITY 2: Manual lookup from picking type or MO. - Returns: - list: IDs of allowed locations. - False: If no restriction should be applied. + Exhaustive lookup for allowed locations with deep diagnostic logging. """ ctx = self.env.context - # 1. PRIORITY: Check the context for allowed locations (Passed by views/JS) - target_keys = ['allowed_source_location_ids', 'default_allowed_source_location_ids'] + # 1. PRIORITY: Explicitly passed IDs from UI (Context / JS) + target_keys = [ + 'allowed_source_location_ids', + 'default_allowed_source_location_ids', + 'allowed_location_ids' + ] + for key in target_keys: val = ctx.get(key) if not val: continue - # Case: List of IDs or Commands + # Simple list of IDs + if isinstance(val, list) and all(isinstance(x, int) for x in val): + _logger.info(f"DEBUG_RESTRICT: Found IDs in context key '{key}': {val}") + return val + + # Command format: [(6, 0, [IDs]), (4, ID, 0), ...] if isinstance(val, list): - # Simple list of integers - if val and all(isinstance(x, int) for x in val): - return val - - # Command format: [(6, 0, [IDs]), (4, ID, 0), ...] col_ids = [] for entry in val: if isinstance(entry, (list, tuple)): - if entry[0] == 6: # SET command - return entry[2] - if entry[0] == 4: # LINK command - col_ids.append(entry[1]) + if entry[0] == 6: return entry[2] + if entry[0] == 4: col_ids.append(entry[1]) if col_ids: + _logger.info(f"DEBUG_RESTRICT: Extracted IDs from Command key '{key}': {col_ids}") return col_ids - # Case: Single ID + # Single integer if isinstance(val, int): + _logger.info(f"DEBUG_RESTRICT: Found single ID in context key '{key}': {val}") return [val] - # 2. FALLBACK: Identify the Picking Type (Operation Type) + # 2. FALLBACK: Operation Type (Picking Type) picking_type_id = (ctx.get('default_picking_type_id') or ctx.get('picking_type_id') or ctx.get('active_picking_type_id')) - # MO IDs - mo_id = (ctx.get('active_mo_id') or - ctx.get('default_mo_id') or - ctx.get('default_production_id') or - ctx.get('mo_id') or - ctx.get('production_id')) - + mo_id = (ctx.get('active_mo_id') or ctx.get('default_mo_id') or ctx.get('default_production_id')) + if not picking_type_id and mo_id: mo = self.env['mrp.production'].browse(mo_id) if mo.exists(): picking_type_id = mo.picking_type_id.id - if not picking_type_id: - return False + if picking_type_id: + pt = self.env['stock.picking.type'].browse(picking_type_id) + if pt.exists(): + if pt.default_location_src_ids: + _logger.info(f"DEBUG_RESTRICT: Found IDs via Picking Type M2M: {pt.default_location_src_ids.ids}") + return pt.default_location_src_ids.ids + if pt.default_location_src_id: + _logger.info(f"DEBUG_RESTRICT: Found ID via Picking Type M21: {pt.default_location_src_id.id}") + return [pt.default_location_src_id.id] + + # 3. FINAL FALLBACK: Current Source Location + if ctx.get('default_location_id'): + _logger.info(f"DEBUG_RESTRICT: Falling back to default_location_id: {ctx.get('default_location_id')}") + return [ctx.get('default_location_id')] - # 3. Retrieve allowed locations from the identified picking type - picking_type = self.env['stock.picking.type'].browse(picking_type_id) - if picking_type.exists(): - # Many-to-Many "Allowed Source Locations" - if picking_type.default_location_src_ids: - return picking_type.default_location_src_ids.ids - - # Many-to-One "Default Source Location" - if picking_type.default_location_src_id: - return [picking_type.default_location_src_id.id] - return False class StockQuant(models.Model): @@ -85,17 +81,15 @@ class StockQuant(models.Model): @api.model def web_search_read(self, domain, specification, offset=0, limit=None, order=None, count_limit=None): - """ - UI-SURFACE OVERRIDE: Applies location filtering ONLY for the web interface. - Uses 'child_of' to support stock stored in shelves/aisles of allowed locations. - """ ctx = self.env.context if not ctx.get('skip_location_restriction') and ctx.get('uid'): - allowed_location_ids = self.env['stock.location']._get_allowed_locations() - if allowed_location_ids: - # Add location filter to the domain ONLY if we have an explicit list - domain = expression.AND([domain, [('location_id', 'child_of', allowed_location_ids)]]) - + allowed_ids = self.env['stock.location']._get_allowed_locations() + if allowed_ids: + # Add location filter; ensure no existing location_id filter blocks us + # Odoo's native JS often adds its own ["location_id", "child_of", ...] + # We prioritize our list. + domain = expression.AND([domain, [('location_id', 'child_of', allowed_ids)]]) + _logger.error(f"DEBUG_RESTRICT: SEARCH QUANT - Allowed: {allowed_ids} - Final Domain: {domain}") return super().web_search_read(domain, specification, offset=offset, limit=limit, order=order, count_limit=count_limit) class StockLot(models.Model): @@ -103,59 +97,28 @@ class StockLot(models.Model): @api.model def name_search(self, name='', args=None, operator='ilike', limit=100): - """ - UI-SURFACE OVERRIDE: Filters the many2one lot selection dropdown. - """ ctx = self.env.context if not ctx.get('skip_location_restriction') and ctx.get('uid'): - allowed_location_ids = self.env['stock.location']._get_allowed_locations() - - # Safe Fallback for lots - if not allowed_location_ids and ctx.get('default_location_id'): - allowed_location_ids = [ctx.get('default_location_id')] - - if allowed_location_ids: - quant_domain = [ - ('location_id', 'child_of', allowed_location_ids), - ('quantity', '>', 0), - ('lot_id', '!=', False) - ] - product_id = ctx.get('default_product_id') - if product_id: - quant_domain.append(('product_id', '=', product_id)) + allowed_ids = self.env['stock.location']._get_allowed_locations() + if allowed_ids: + quant_domain = [('location_id', 'child_of', allowed_ids), ('quantity', '>', 0), ('lot_id', '!=', False)] + if ctx.get('default_product_id'): + quant_domain.append(('product_id', '=', ctx.get('default_product_id'))) quants = self.env['stock.quant'].with_context(skip_location_restriction=True).sudo().search(quant_domain) - lot_ids = quants.mapped('lot_id').ids - - args = expression.AND([args or [], [('id', 'in', lot_ids)]]) - + args = expression.AND([args or [], [('id', 'in', quants.mapped('lot_id').ids)]]) return super().name_search(name, args=args, operator=operator, limit=limit) @api.model def web_search_read(self, domain, specification, offset=0, limit=None, order=None, count_limit=None): - """ - UI-SURFACE OVERRIDE: Applies filtering for the Lot Catalog and list views. - """ ctx = self.env.context if not ctx.get('skip_location_restriction') and ctx.get('uid'): - allowed_location_ids = self.env['stock.location']._get_allowed_locations() - - if not allowed_location_ids and ctx.get('default_location_id'): - allowed_location_ids = [ctx.get('default_location_id')] - - if allowed_location_ids: - quant_domain = [ - ('location_id', 'child_of', allowed_location_ids), - ('quantity', '>', 0), - ('lot_id', '!=', False) - ] - product_id = ctx.get('default_product_id') - if product_id: - quant_domain.append(('product_id', '=', product_id)) - - quants = self.env['stock.quant'].with_context(skip_location_restriction=True).sudo().search(quant_domain) - lot_ids = quants.mapped('lot_id').ids + allowed_ids = self.env['stock.location']._get_allowed_locations() + if allowed_ids: + quant_domain = [('location_id', 'child_of', allowed_ids), ('quantity', '>', 0), ('lot_id', '!=', False)] + if ctx.get('default_product_id'): + quant_domain.append(('product_id', '=', ctx.get('default_product_id'))) - domain = expression.AND([domain, [('id', 'in', lot_ids)]]) - + quants = self.env['stock.quant'].with_context(skip_location_restriction=True).sudo().search(quant_domain) + domain = expression.AND([domain, [('id', 'in', quants.mapped('lot_id').ids)]]) return super().web_search_read(domain, specification, offset=offset, limit=limit, order=order, count_limit=count_limit) diff --git a/static/src/js/stock_move_line_x2_many_field_patch.js b/static/src/js/stock_move_line_x2_many_field_patch.js index cf13fb3..413acf0 100644 --- a/static/src/js/stock_move_line_x2_many_field_patch.js +++ b/static/src/js/stock_move_line_x2_many_field_patch.js @@ -12,29 +12,45 @@ patch(SMLX2ManyField.prototype, { return super.onAdd({ context, editable }); } + console.log("DEBUG_RESTRICT: Intercepting SMLX2ManyField.onAdd"); + // 1. Sync Dirty Data (Internal Odoo Logic) await this.updateDirtyQuantsData(); - // 2. Prepare Context (Bridge to Python) + // 2. Resolve Allowed Locations from Record or Context + // This is key to fixing the "No record found" issue + let allowedIds = []; + const recordAllowedField = this.props.record.data.allowed_source_location_ids; + + if (recordAllowedField && recordAllowedField.records) { + allowedIds = recordAllowedField.records.map(r => r.resId); + } + + if (!allowedIds.length) { + allowedIds = this.props.context.default_allowed_source_location_ids || []; + } + + // 3. Prepare Context (Bridge to Python) context = { ...context, single_product: true, list_view_ref: "stock.view_stock_quant_tree_simple", - // Pass identification keys to our Python _get_allowed_locations() - active_mo_id: context?.active_mo_id || this.props.context.active_mo_id || this.props.context.default_production_id, - default_picking_type_id: context?.default_picking_type_id || this.props.context.default_picking_type_id, - default_allowed_source_location_ids: context?.default_allowed_source_location_ids || this.props.context.default_allowed_source_location_ids, + active_mo_id: (this.props.context.active_mo_id || + this.props.context.default_production_id || + this.props.record.data.raw_material_production_id?.[0]), + default_picking_type_id: this.props.context.default_picking_type_id, + default_allowed_source_location_ids: allowedIds, }; const productName = this.props.record.data.product_id.display_name; const title = _t("Add line: %s", productName); - // 3. Construct Domain (The Fix) - // Odoo natively uses ODOO_DEFAULT_LOCATION_ID which is just ONE location. - // We override this to use ALL ALLOWED locations if they exist. - const allowedLocationIds = context.default_allowed_source_location_ids; - const targetLocation = allowedLocationIds || this.props.context.default_location_id; + // 4. Construct Domain (The Fix) + // We prioritize our multi-location list. Fallback to default Odoo behavior if empty. + const targetLocation = allowedIds.length > 0 ? allowedIds : this.props.context.default_location_id; + console.log("DEBUG_RESTRICT: Target Location(s) for Catalog Domain:", targetLocation); + let domain = [ ["product_id", "=", this.props.record.data.product_id.id], ["location_id", "child_of", targetLocation], @@ -45,7 +61,7 @@ patch(SMLX2ManyField.prototype, { domain.push(["on_hand", "=", true]); } - // 4. Filter out fully used quants (Internal Odoo Logic) + // 5. Filter out fully used quants (Internal Odoo Logic) if (this.dirtyQuantsData.size) { const notFullyUsed = []; const fullyUsed = []; @@ -57,7 +73,6 @@ patch(SMLX2ManyField.prototype, { } } if (fullyUsed.length) { - // Combine domains using Odoo's Domain utility domain = Domain.and([domain, [["id", "not in", fullyUsed]]]).toList(); } if (notFullyUsed.length) { @@ -65,7 +80,9 @@ patch(SMLX2ManyField.prototype, { } } - // 5. Open the selection modal with our custom domain + console.log("DEBUG_RESTRICT: Final Constructed Domain:", domain); + + // 6. Open the selection modal with our custom domain return this.selectCreate({ domain, context, title }); } });