diff --git a/README.md b/README.md index 5429e40..5dc08be 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,29 @@ # Stock Restrict Source Location -This module implements strict location filtering for Stock moves and Manufacturing Orders based on the configuration set in the Operation Type. +This module implements targeted location filtering for Stock Moves and Manufacturing Orders, ensuring users can only pick components and stock from authorized source locations defined in the Operation Type. ## Features -- **UI-Level Restriction**: Restricts the "Pick From" location selection in both standard Transfers and MO "Detailed Operations" dialogs. -- **Background Reservation Restriction**: Overrides the reservation engine (`_get_gather_domain`) to ensure auto-pick (Check Availability) only pulls stock from authorized locations. -- **MO Support**: Specifically handles Manufacturing Orders by injecting the `active_mo_id` and `active_move_id` into the context. -- **Frontend Patch**: Includes a JavaScript patch (`stock_move_line_x2_many_field_patch.js`) to ensure picking type context is maintained during frontend searches. + +- **Surgical UI Filtering**: Restricts the "Pick From" (Quant) and "Lot/Serial Number" (Lot) selection in both standard Transfers and MO "Detailed Operations" dialogs. +- **Backend Sync Safety**: Specifically designed to bypass restrictions for background Odoo operations (reservation, unreservation, record-linking) to prevent "Missing Product" validation errors. +- **Context-Aware MO Support**: Handles the complex Manufacturing Order "Components" view by injecting the correct picking type and source location into the search context. +- **Frontend Patch**: Includes a JavaScript patch to ensure location context is correctly passed from the UI to the backend search methods. ## Dependencies + - `stock` - `mrp` -- `stock_picking_type_m2m` (Used to retrieve the list of allowed locations) +- `stock_picking_type_m2m` (Used to configure the list of allowed locations on the Operation Type) ## Usage -1. Configure **Allowed Source Locations** on your Operation Type (e.g., Manufacturing). -2. Create an MO or Transfer using that Operation Type. -3. Both manual selection and the "Check Availability" button will now be restricted to precisely the locations you defined. + +1. **Configure**: On a **Picking Type** (Operation Type), set the **Allowed Source Locations** (M2M field). +2. **Operation**: Create an MO or Transfer using that Picking Type. +3. **Lot Selection**: When a user clicks to select a Lot or a Quant in the UI, the list will be filtered to only show availability from the allowed locations. +4. **Backend**: Odoo's internal "Check Availability" and "Save" processes will still function normally, ensuring that reservations are linked correctly to existing stock. + +## Technical Implementation (V19) + +- Overrides `StockQuant` and `StockLot` `_search` methods with a **UI-only bypass**. +- Only restricts queries that include UI indicators (like `params`, `search_view_ref`, or `bin_size`). +- Bypasses restrictions for the system user (`uid=False`) and searches by direct ID to maintain ORM integrity. diff --git a/models/stock_location.py b/models/stock_location.py index fbbe8e2..b947322 100644 --- a/models/stock_location.py +++ b/models/stock_location.py @@ -84,64 +84,46 @@ class StockQuant(models.Model): return allowed_location_ids - @api.model_create_multi - def create(self, vals_list): - """Diagnostic override to catch the 'Missing Product' error root cause""" - for vals in vals_list: - if not vals.get('product_id'): - # LOG THE CALLER for debugging - _logger.error("DEBUG_RESTRICT: Creating StockQuant WITHOUT product_id!") - _logger.error(f"DEBUG_RESTRICT: Vals: {vals}") - _logger.error(f"DEBUG_RESTRICT: Context: {self.env.context}") - # We do NOT raise here to avoid breaking Odoo's original error UX, - # but this will appear in logs or help us see the issue. - return super().create(vals_list) - - @api.model - def _get_gather_domain(self, product_id, location_id, lot_id=None, package_id=None, owner_id=None, strict=False): - """Override to apply location restrictions during reservation (gather)""" - result_domain = super()._get_gather_domain(product_id, location_id, lot_id, package_id, owner_id, strict) - + def _search(self, domain, offset=0, limit=None, order=None, *args, **kwargs): + """Override to apply location restrictions during search (e.g. catalog or list selection)""" ctx = self.env.context - # 0. Bypass if skip flag is set OR we are in a system operation - if ctx.get('skip_location_restriction') or not ctx.get('uid'): - return result_domain + + # 0. CRITICAL BYPASS: Only restrict if it's a UI Search (params, bin_size, etc.) + # AND we are not searching for specific record IDs (backend sync). + search_by_id = any(isinstance(leaf, (list, tuple)) and leaf[0] == 'id' for leaf in domain) + is_ui_search = (ctx.get('params') or + ctx.get('bin_size') or + ctx.get('search_view_ref') or + ctx.get('list_view_ref')) + + if (search_by_id or + ctx.get('skip_location_restriction') or + not is_ui_search or + not ctx.get('uid')): + return super()._search(domain, offset=offset, limit=limit, order=order, *args, **kwargs) - # 1. FIX: If reserving from a non-internal location, DO NOT apply internal restrictions. - if location_id and location_id.usage != 'internal': - return result_domain - allowed_location_ids = self._get_allowed_locations() if allowed_location_ids: - # 2. Smart Domain Swap: find if there's already a location_id restriction (like child_of) + # 1. Smart Domain Swap: find if there's already a location_id restriction (like child_of) found_collision = False new_domain = [] - for leaf in result_domain: + for leaf in domain: if (isinstance(leaf, (list, tuple)) and len(leaf) == 3 and leaf[0] == 'location_id' and - leaf[1] in ('child_of', '=', 'in')): + leaf[1] == 'child_of'): new_domain.append(('location_id', 'in', allowed_location_ids)) found_collision = True else: new_domain.append(leaf) + if found_collision: - result_domain = new_domain + domain = new_domain else: - result_domain = Domain.AND([result_domain, [('location_id', 'in', allowed_location_ids)]]) + domain = Domain.AND([domain, [('location_id', 'in', allowed_location_ids)]]) - return result_domain - - def _search(self, domain, offset=0, limit=None, order=None, *args, **kwargs): - """Override to apply location restrictions during search (e.g. catalog or list selection)""" - ctx = self.env.context - # 0. CRITICAL FIX: If searching for specific IDs OR skip flag set OR NOT UI Search, bypass custom filtering - search_by_id = any(isinstance(leaf, (list, tuple)) and leaf[0] == 'id' for leaf in domain) - is_ui_search = ctx.get('params') or ctx.get('search_view_ref') or ctx.get('list_view_ref') - - if search_by_id or ctx.get('skip_location_restriction') or not is_ui_search: - return super()._search(domain, offset=offset, limit=limit, order=order, *args, **kwargs) + return super()._search(domain, offset=offset, limit=limit, order=order, *args, **kwargs) allowed_location_ids = self._get_allowed_locations() if allowed_location_ids: @@ -191,10 +173,16 @@ class StockLot(models.Model): # 0. NEW: Detect UI Search vs Backend Sync search_by_id = any(isinstance(leaf, (list, tuple)) and leaf[0] == 'id' for leaf in domain) - is_ui_search = ctx.get('params') or ctx.get('search_view_ref') or ctx.get('list_view_ref') + is_ui_search = (ctx.get('params') or + ctx.get('bin_size') or + ctx.get('search_view_ref') or + ctx.get('list_view_ref')) # FIX: If we are searching for specific IDs OR skip flag set OR NOT UI search, bypass - if search_by_id or ctx.get('skip_location_restriction') or not is_ui_search: + if (search_by_id or + ctx.get('skip_location_restriction') or + not is_ui_search or + not ctx.get('uid')): return super()._search(domain, offset=offset, limit=limit, order=order, *args, **kwargs) # 1. Identify which locations we should look into for quants