refactor: restrict location filtering to UI-only searches and remove backend reservation overrides to prevent validation errors.

This commit is contained in:
Suherdy Yacob 2026-04-03 17:27:31 +07:00
parent c036b7cb9c
commit e341adf41e
2 changed files with 50 additions and 52 deletions

View File

@ -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.

View File

@ -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