refactor: replace StockQuant override with StockMoveLine unlink safety patch and update frontend RPC logic for MO location filtering
This commit is contained in:
parent
38c5fca1ea
commit
ba97eac4cc
43
README.md
43
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.
|
||||
|
||||
@ -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'))
|
||||
|
||||
@ -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 });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user