refactor: replace StockQuant override with StockMoveLine unlink safety patch and update frontend RPC logic for MO location filtering

This commit is contained in:
Suherdy Yacob 2026-04-03 22:20:24 +07:00
parent 38c5fca1ea
commit ba97eac4cc
3 changed files with 61 additions and 55 deletions

View File

@ -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. - **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).
- **Backend Sync Safety**: Specifically designed to bypass restrictions for background Odoo operations (reservation, unreservation, record-linking) to prevent "Missing Product" validation errors. - **Sub-location Support**: Uses the `child_of` operator to ensure that stock in all sub-shelves/aisles within an allowed zone is correctly visible.
- **Context-Aware MO Support**: Handles the complex Manufacturing Order "Components" view by injecting the correct picking type and source location into the search context. - **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.
- **Frontend Patch**: Includes a JavaScript patch to ensure location context is correctly passed from the UI to the backend search methods. - **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 ## Dependencies
- `stock` - `stock`
- `mrp` - `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 ## Usage
1. **Configure**: On a **Picking Type** (Operation Type), set the **Allowed Source Locations** (M2M field). 1. **Configure**: On an **Operation Type** (e.g., WHBK/Manufacturing), set the **Allowed Source Locations** (e.g., Packaging, Preparation).
2. **Operation**: Create an MO or Transfer using that Picking Type. 2. **Operation**: Create a Manufacturing Order using that Operation 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. 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. **Backend**: Odoo's internal "Check Availability" and "Save" processes will still function normally, ensuring that reservations are linked correctly to existing stock. 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**. ### Javascript (Frontend)
- Only restricts queries that include UI indicators (like `params`, `search_view_ref`, or `bin_size`). - Patches the `SMLX2ManyField` component.
- Bypasses restrictions for the system user (`uid=False`) and searches by direct ID to maintain ORM integrity. - 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.

View File

@ -1,37 +1,37 @@
import logging import logging
import inspect
from odoo import api, fields, models, _ from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.osv import expression from odoo.osv import expression
from odoo.tools.float_utils import float_is_zero
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
class StockQuant(models.Model): class StockMoveLine(models.Model):
_inherit = 'stock.quant' _inherit = 'stock.move.line'
@api.model_create_multi def unlink(self):
def create(self, vals_list): """
for vals in vals_list: Safety Patch (Attempt 20): Prevent 'Missing product_id' crash on stock.quant.
if not vals.get('product_id'): This targets the native Odoo crash at stock_move_line.py line 570.
# 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]]) precision = self.env['decimal.precision'].precision_get('Product Unit')
_logger.error(f"DEBUG_RESTRICT: STOCK.QUANT CREATE with NO product_id! STACK:\n{stack}") for ml in self:
# Force visibility in browser # CRITICAL FIX: If product_id is missing (virtual records), skip reservation update.
raise UserError(_( if not ml.product_id:
"DEBUG_TRACEBACK: Detected STOCK.QUANT creation with no product_id.\n\n" _logger.info(f"DEBUG_RESTRICT: Skipping reservation update for product-less move line {ml.id}")
"Top calling files:\n%s" continue
) % stack)
return super().create(vals_list) # 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):
def write(self, vals): try:
if 'product_id' in vals and not vals.get('product_id'): self.env['stock.quant']._update_reserved_quantity(
stack = "\n".join([str(s.filename.split('/')[-1]) + " line " + str(s.lineno) for s in inspect.stack()[:10]]) ml.product_id, ml.location_id, -ml.quantity_product_uom,
_logger.error(f"DEBUG_RESTRICT: STOCK.QUANT WRITE with NO product_id! STACK:\n{stack}") lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id, strict=True
raise UserError(_( )
"DEBUG_TRACEBACK: Detected STOCK.QUANT write with no product_id.\n\n" except Exception as e:
"Top calling files:\n%s" _logger.error(f"DEBUG_RESTRICT: Failed to update reservation for line {ml.id}: {e}")
) % stack)
return super().write(vals) # Call super WITHOUT original logic to avoid double-processing or errors
return super(models.Model, self).unlink()
class StockLocation(models.Model): class StockLocation(models.Model):
_inherit = 'stock.location' _inherit = 'stock.location'
@ -44,14 +44,12 @@ class StockLocation(models.Model):
allowed_ids = [] allowed_ids = []
source_name = "None" source_name = "None"
# 1. MO Lookup (Direct Database Query)
if mo_id: if mo_id:
mo = self.env['mrp.production'].sudo().browse(mo_id) mo = self.env['mrp.production'].sudo().browse(mo_id)
if mo.exists() and mo.allowed_source_location_ids: if mo.exists() and mo.allowed_source_location_ids:
allowed_ids = mo.allowed_source_location_ids.ids allowed_ids = mo.allowed_source_location_ids.ids
source_name = f"MO {mo.name}" source_name = f"MO {mo.name}"
# 2. Picking Type Fallback
if not allowed_ids and picking_type_id: if not allowed_ids and picking_type_id:
pt = self.env['stock.picking.type'].sudo().browse(picking_type_id) pt = self.env['stock.picking.type'].sudo().browse(picking_type_id)
if pt.exists(): if pt.exists():
@ -73,7 +71,6 @@ class StockLot(models.Model):
@api.model @api.model
def name_search(self, name='', args=None, operator='ilike', limit=100): 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 ctx = self.env.context
if not ctx.get('skip_location_restriction') and ctx.get('uid'): 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')) mo_id = (ctx.get('active_mo_id') or ctx.get('default_production_id') or ctx.get('production_id'))

View File

@ -12,7 +12,7 @@ patch(SMLX2ManyField.prototype, {
return super.onAdd({ context, editable }); 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) // 1. Sync Dirty Data (Internal Odoo Logic)
await this.updateDirtyQuantsData(); await this.updateDirtyQuantsData();
@ -22,12 +22,12 @@ patch(SMLX2ManyField.prototype, {
this.props.context.default_production_id || this.props.context.default_production_id ||
this.props.record.data.raw_material_production_id?.[0]); 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 = []; let allowedIds = [];
try { try {
allowedIds = await this.orm.call( allowedIds = await this.orm.call(
"stock.location", "stock.location",
"get_allowed_locations_for_mo", // Public name (no underscore) "get_allowed_locations_for_mo",
[], [],
{ {
mo_id: mo_id, mo_id: mo_id,
@ -36,17 +36,17 @@ patch(SMLX2ManyField.prototype, {
); );
console.log("DEBUG_RESTRICT: RPC Success. Allowed IDs:", allowedIds); console.log("DEBUG_RESTRICT: RPC Success. Allowed IDs:", allowedIds);
} catch (e) { } 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); console.error("DEBUG_RESTRICT: RPC Failed, using safety fallback.", e);
allowedIds = []; allowedIds = [];
} }
// 4. Prepare Context // 4. Prepare Context (Reinforced with default_product_id for Attempt 20)
context = { context = {
...context, ...context,
single_product: true, single_product: true,
list_view_ref: "stock.view_stock_quant_tree_simple", list_view_ref: "stock.view_stock_quant_tree_simple",
active_mo_id: mo_id, 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_picking_type_id: this.props.context.default_picking_type_id,
default_allowed_source_location_ids: allowedIds, default_allowed_source_location_ids: allowedIds,
}; };
@ -54,8 +54,7 @@ patch(SMLX2ManyField.prototype, {
const productName = this.props.record.data.product_id.display_name; const productName = this.props.record.data.product_id.display_name;
const title = _t("Add line: %s", productName); const title = _t("Add line: %s", productName);
// 5. Construct Domain (The JS-Only Filter) // 5. Construct Domain
// If RPC failed or returned nothing, FALLBACK to standard default_location_id
const targetLocation = (allowedIds && allowedIds.length > 0) ? allowedIds : this.props.context.default_location_id; const targetLocation = (allowedIds && allowedIds.length > 0) ? allowedIds : this.props.context.default_location_id;
let domain = [ let domain = [
@ -83,12 +82,11 @@ patch(SMLX2ManyField.prototype, {
domain = Domain.and([domain, [["id", "not in", fullyUsed]]]).toList(); domain = Domain.and([domain, [["id", "not in", fullyUsed]]]).toList();
} }
if (notFullyUsed.length) { 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(); 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 // 7. Open the selection modal
return this.selectCreate({ domain, context, title }); return this.selectCreate({ domain, context, title });