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

View File

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

View File

@ -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 });