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.
|
- **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.
|
||||||
|
|||||||
@ -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'))
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user