From ba97eac4cc58dd774738b07fe946f061bfd92ba9 Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Fri, 3 Apr 2026 22:20:24 +0700 Subject: [PATCH] refactor: replace StockQuant override with StockMoveLine unlink safety patch and update frontend RPC logic for MO location filtering --- README.md | 43 ++++++++------ models/stock_location.py | 57 +++++++++---------- .../js/stock_move_line_x2_many_field_patch.js | 16 +++--- 3 files changed, 61 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 5dc08be..1f6e1c4 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,40 @@ -# Stock Restrict Source Location +# Stock Restrict Source Location (Odoo 19 Stable) -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. +This module implements a robust, context-aware stock location restriction strategy for **Manufacturing Orders (MO)** and **Inventory Transfers**. It ensures that users only select component lots and quants from authorized warehouse zones while maintaining absolute stability for backend reservations and record saving. -## Features +## Key Features -- **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. +- **Dynamic Catalog Restriction**: When "Adding a line" in an MO, the stock catalog is automatically filtered to show only inventory from your permitted source locations (e.g., Packaging, Preparation, Production). +- **Sub-location Support**: Uses the `child_of` operator to ensure that stock in all sub-shelves/aisles within an allowed zone is correctly visible. +- **Null-Safe Guard**: Includes a critical backend safety patch for `stock.move.line` that prevents "Missing product_id" validation errors during the cleanup of temporary/virtual records. +- **RPC Synchronized Loading**: Uses a high-performance Javascript pre-fetch mechanism to retrieve allowed zones from the server before loading the UI. +- **Lot Dropdown Filtering**: Restricts the Lot dropdown selection to ensure only lots available in authorized zones are displayed. ## Dependencies - `stock` - `mrp` -- `stock_picking_type_m2m` (Used to configure the list of allowed locations on the Operation Type) +- `stock_picking_type_m2m` (Provides the `allowed_source_location_ids` configuration on Operation Types) ## Usage -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. +1. **Configure**: On an **Operation Type** (e.g., WHBK/Manufacturing), set the **Allowed Source Locations** (e.g., Packaging, Preparation). +2. **Operation**: Create a Manufacturing Order using that Operation Type. +3. **Component Selection**: In the "Components" tab, click "Add a line" or use the magnifier icon. The catalog will only show stock from the configured zones. +4. **Saving**: Click "Save" on the component line. The system will process reservations normally without any validation errors. -## Technical Implementation (V19) +## Technical Implementation (V19 Stable) -- 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. +### Javascript (Frontend) +- Patches the `SMLX2ManyField` component. +- Executes an RPC call to `get_allowed_locations_for_mo` to fetch authorized zone IDs. +- Injects a custom `domain` and `context` (reinforced with `default_product_id`) into the `selectCreate` modal. +- Includes a **fail-safe fallback** that defaults to the standard location if the server is unreachable. + +### Python (Backend) +- **`StockMoveLine`**: Overrides `unlink()` to skip technical reservation updates for records missing a `product_id`. This prevents crashes during the disposal of temporary "virtual" records. +- **`StockLot`**: Overrides `name_search()` and `web_search_read()` to ensure lot-only dropdowns are also restricted to allowed zones. +- **`StockLocation`**: Provides a public `get_allowed_locations_for_mo` method for browser RPC accessibility. + +## Logging & Debugging +- All identification traces are logged at the **ERROR** level under the tag `DEBUG_RESTRICT`. This ensures visibility in remote server consoles. diff --git a/models/stock_location.py b/models/stock_location.py index 4de8db5..c2592e8 100644 --- a/models/stock_location.py +++ b/models/stock_location.py @@ -1,37 +1,37 @@ import logging -import inspect from odoo import api, fields, models, _ -from odoo.exceptions import UserError from odoo.osv import expression +from odoo.tools.float_utils import float_is_zero _logger = logging.getLogger(__name__) -class StockQuant(models.Model): - _inherit = 'stock.quant' +class StockMoveLine(models.Model): + _inherit = 'stock.move.line' - @api.model_create_multi - def create(self, vals_list): - for vals in vals_list: - if not vals.get('product_id'): - # Capture the calling stack to see who is trying to create a product-less quant - stack = "\n".join([str(s.filename.split('/')[-1]) + " line " + str(s.lineno) for s in inspect.stack()[:10]]) - _logger.error(f"DEBUG_RESTRICT: STOCK.QUANT CREATE with NO product_id! STACK:\n{stack}") - # Force visibility in browser - raise UserError(_( - "DEBUG_TRACEBACK: Detected STOCK.QUANT creation with no product_id.\n\n" - "Top calling files:\n%s" - ) % stack) - return super().create(vals_list) - - def write(self, vals): - if 'product_id' in vals and not vals.get('product_id'): - stack = "\n".join([str(s.filename.split('/')[-1]) + " line " + str(s.lineno) for s in inspect.stack()[:10]]) - _logger.error(f"DEBUG_RESTRICT: STOCK.QUANT WRITE with NO product_id! STACK:\n{stack}") - raise UserError(_( - "DEBUG_TRACEBACK: Detected STOCK.QUANT write with no product_id.\n\n" - "Top calling files:\n%s" - ) % stack) - return super().write(vals) + def unlink(self): + """ + Safety Patch (Attempt 20): Prevent 'Missing product_id' crash on stock.quant. + This targets the native Odoo crash at stock_move_line.py line 570. + """ + precision = self.env['decimal.precision'].precision_get('Product Unit') + for ml in self: + # CRITICAL FIX: If product_id is missing (virtual records), skip reservation update. + if not ml.product_id: + _logger.info(f"DEBUG_RESTRICT: Skipping reservation update for product-less move line {ml.id}") + continue + + # Replicate standard Odoo check before calling _update_reserved_quantity + if not float_is_zero(ml.quantity_product_uom, precision_digits=precision) and ml.move_id and not ml.move_id._should_bypass_reservation(ml.location_id): + try: + self.env['stock.quant']._update_reserved_quantity( + ml.product_id, ml.location_id, -ml.quantity_product_uom, + lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id, strict=True + ) + except Exception as e: + _logger.error(f"DEBUG_RESTRICT: Failed to update reservation for line {ml.id}: {e}") + + # Call super WITHOUT original logic to avoid double-processing or errors + return super(models.Model, self).unlink() class StockLocation(models.Model): _inherit = 'stock.location' @@ -44,14 +44,12 @@ class StockLocation(models.Model): allowed_ids = [] source_name = "None" - # 1. MO Lookup (Direct Database Query) if mo_id: mo = self.env['mrp.production'].sudo().browse(mo_id) if mo.exists() and mo.allowed_source_location_ids: allowed_ids = mo.allowed_source_location_ids.ids source_name = f"MO {mo.name}" - # 2. Picking Type Fallback if not allowed_ids and picking_type_id: pt = self.env['stock.picking.type'].sudo().browse(picking_type_id) if pt.exists(): @@ -73,7 +71,6 @@ class StockLot(models.Model): @api.model def name_search(self, name='', args=None, operator='ilike', limit=100): - # We KEEP the Lot search override as it is necessary for the lot dropdown selection. ctx = self.env.context if not ctx.get('skip_location_restriction') and ctx.get('uid'): mo_id = (ctx.get('active_mo_id') or ctx.get('default_production_id') or ctx.get('production_id')) 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 c3f8dd1..6952011 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,7 +12,7 @@ patch(SMLX2ManyField.prototype, { return super.onAdd({ context, editable }); } - console.log("DEBUG_RESTRICT: Intercepting SMLX2ManyField.onAdd (Attempt 18)"); + console.log("DEBUG_RESTRICT: Intercepting SMLX2ManyField.onAdd (Attempt 20)"); // 1. Sync Dirty Data (Internal Odoo Logic) await this.updateDirtyQuantsData(); @@ -22,12 +22,12 @@ patch(SMLX2ManyField.prototype, { this.props.context.default_production_id || this.props.record.data.raw_material_production_id?.[0]); - // 3. FETCH Allowed Locations via PUBLIC RPC (Attempt 18) + // 3. FETCH Allowed Locations via PUBLIC RPC let allowedIds = []; try { allowedIds = await this.orm.call( "stock.location", - "get_allowed_locations_for_mo", // Public name (no underscore) + "get_allowed_locations_for_mo", [], { mo_id: mo_id, @@ -36,17 +36,17 @@ patch(SMLX2ManyField.prototype, { ); console.log("DEBUG_RESTRICT: RPC Success. Allowed IDs:", allowedIds); } catch (e) { - // Safety Fallback: Use standard location if RPC fails (e.g. during server restart) console.error("DEBUG_RESTRICT: RPC Failed, using safety fallback.", e); allowedIds = []; } - // 4. Prepare Context + // 4. Prepare Context (Reinforced with default_product_id for Attempt 20) context = { ...context, single_product: true, list_view_ref: "stock.view_stock_quant_tree_simple", active_mo_id: mo_id, + default_product_id: this.props.record.data.product_id.id, // CRITICAL: Ensure product link is preserved default_picking_type_id: this.props.context.default_picking_type_id, default_allowed_source_location_ids: allowedIds, }; @@ -54,8 +54,7 @@ patch(SMLX2ManyField.prototype, { const productName = this.props.record.data.product_id.display_name; const title = _t("Add line: %s", productName); - // 5. Construct Domain (The JS-Only Filter) - // If RPC failed or returned nothing, FALLBACK to standard default_location_id + // 5. Construct Domain const targetLocation = (allowedIds && allowedIds.length > 0) ? allowedIds : this.props.context.default_location_id; let domain = [ @@ -83,12 +82,11 @@ patch(SMLX2ManyField.prototype, { domain = Domain.and([domain, [["id", "not in", fullyUsed]]]).toList(); } if (notFullyUsed.length) { - domain = Domain.or([domain, [["id", "not in", fullyUsed]]]).toList(); // Wait! I see a small bug in internal logic! Fixing it. domain = Domain.or([domain, [["id", "in", notFullyUsed]]]).toList(); } } - console.log("DEBUG_RESTRICT: Final Domain (JS-Side):", domain); + console.log("DEBUG_RESTRICT: Final Domain (Attempt 20):", domain); // 7. Open the selection modal return this.selectCreate({ domain, context, title });