refactor: implement detailed location logging and global stock audit for restricted source locations

This commit is contained in:
Suherdy Yacob 2026-04-03 22:02:16 +07:00
parent a70ad3f2ac
commit d34bc37428
2 changed files with 63 additions and 66 deletions

View File

@ -9,69 +9,57 @@ class StockLocation(models.Model):
def _get_allowed_locations(self): def _get_allowed_locations(self):
""" """
Server-side lookup for allowed locations with direct database priority. Deep Data Audit: Log the names and types of every location found.
""" """
ctx = self.env.context ctx = self.env.context
# 1. HIGHEST PRIORITY: Direct Manufacturing Order Database Lookup # Identification variables
# This bypasses incomplete UI data by querying the MO directly via context IDs.
mo_id = (ctx.get('active_mo_id') or mo_id = (ctx.get('active_mo_id') or
ctx.get('default_production_id') or ctx.get('default_production_id') or
ctx.get('production_id') or ctx.get('production_id') or
ctx.get('active_id') if ctx.get('active_model') == 'mrp.production' else None) (ctx.get('active_id') if ctx.get('active_model') == 'mrp.production' else None))
allowed_ids = []
source_name = "None"
# 1. MO Lookup
if mo_id: if mo_id:
# sudo() ensures we can read the MO and its allowed locations regardless of field access
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
_logger.error(f"DEBUG_RESTRICT: Found IDs via Direct MO Lookup ({mo.name}): {allowed_ids}") source_name = f"MO {mo.name}"
return allowed_ids
# 2. FALLBACK 1: Explicit IDs passed in context (from Views/JS) # 2. Context ID Fallback
target_keys = ['allowed_source_location_ids', 'default_allowed_source_location_ids'] if not allowed_ids:
for key in target_keys: target_keys = ['allowed_source_location_ids', 'default_allowed_source_location_ids']
val = ctx.get(key) for key in target_keys:
if not val: val = ctx.get(key)
continue if val:
if isinstance(val, list):
if all(isinstance(x, int) for x in val):
allowed_ids = val
else:
for entry in val:
if isinstance(entry, (list, tuple)):
if entry[0] == 6: allowed_ids = entry[2]; break
if entry[0] == 4: allowed_ids.append(entry[1])
elif isinstance(val, int):
allowed_ids = [val]
# Simple IDs or Commands if allowed_ids:
if isinstance(val, list): source_name = f"Context {key}"
if val and all(isinstance(x, int) for x in val): break
_logger.error(f"DEBUG_RESTRICT: Found IDs in context key '{key}': {val}")
return val
col_ids = [] # 3. Final IDs check & Logging
for entry in val: if allowed_ids:
if isinstance(entry, (list, tuple)): # Audit the location names
if entry[0] == 6: return entry[2] # SET locations = self.env['stock.location'].sudo().browse(allowed_ids)
if entry[0] == 4: col_ids.append(entry[1]) # LINK loc_names = [f"{l.display_name} (ID: {l.id})" for l in locations]
if col_ids: _logger.error(f"DEBUG_RESTRICT: Identified {len(loc_names)} Allowed Locations for {source_name}: {loc_names}")
_logger.error(f"DEBUG_RESTRICT: Extracted IDs from context command '{key}': {col_ids}") return allowed_ids
return col_ids
if isinstance(val, int): # 4. Fallback to default
_logger.error(f"DEBUG_RESTRICT: Found single ID in context key '{key}': {val}")
return [val]
# 3. FALLBACK 2: Operation Type (Picking Type) Manual Lookup
picking_type_id = (ctx.get('default_picking_type_id') or
ctx.get('picking_type_id') or
ctx.get('active_picking_type_id'))
if picking_type_id:
pt = self.env['stock.picking.type'].sudo().browse(picking_type_id)
if pt.exists():
if pt.default_location_src_ids:
_logger.error(f"DEBUG_RESTRICT: Found IDs via Picking Type {pt.display_name}: {pt.default_location_src_ids.ids}")
return pt.default_location_src_ids.ids
if pt.default_location_src_id:
_logger.error(f"DEBUG_RESTRICT: Found ID via Picking Type {pt.display_name} (M21): {pt.default_location_src_id.id}")
return [pt.default_location_src_id.id]
# 4. FINAL FALLBACK: Current Default Source Location
if ctx.get('default_location_id'): if ctx.get('default_location_id'):
_logger.error(f"DEBUG_RESTRICT: Falling back to default_location_id: {ctx.get('default_location_id')}")
return [ctx.get('default_location_id')] return [ctx.get('default_location_id')]
return False return False
@ -82,27 +70,40 @@ class StockQuant(models.Model):
@api.model @api.model
def web_search_read(self, domain, specification, offset=0, limit=None, order=None, count_limit=None): def web_search_read(self, domain, specification, offset=0, limit=None, order=None, count_limit=None):
""" """
UI Override with ROBUST DOMAIN STRIPPING to fix RPC_ERROR/ValueError. UI Override with GLOBAL AUDIT to find hiding stock.
""" """
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'):
allowed_ids = self.env['stock.location']._get_allowed_locations() allowed_ids = self.env['stock.location']._get_allowed_locations()
if allowed_ids: if allowed_ids:
# STRIP both native location_id filters AND their orphaned operators. # Strip native filters
# The safest way is to extract only the non-location tuples (leaves).
# expression.AND(list_of_tuples) reconstruction a perfectly valid
# Polish notation with implicit 'AND' between our leaves.
clean_leaves = [] clean_leaves = []
product_id = None
for leaf in domain: for leaf in domain:
if isinstance(leaf, (list, tuple)): if isinstance(leaf, (list, tuple)):
if len(leaf) == 3 and leaf[0] == 'location_id': if len(leaf) == 3:
continue if leaf[0] == 'location_id': continue
if leaf[0] == 'product_id': product_id = leaf[2]
clean_leaves.append(leaf) clean_leaves.append(leaf)
# Re-apply our multi-location filter using expression.AND. # Apply multi-location filter
# This treats clean_leaves + [location_id filter] as a fresh, valid domain.
domain = expression.AND([clean_leaves, [('location_id', 'child_of', allowed_ids)]]) domain = expression.AND([clean_leaves, [('location_id', 'child_of', allowed_ids)]])
_logger.error(f"DEBUG_RESTRICT: SEARCH QUANT - Allowed: {allowed_ids} - Final Domain: {domain}")
# EXECUTE SEARCH once internally to see if it's empty
# If it's empty, we do a global warehouse search to tell the user WHERE his stock is.
res = super().web_search_read(domain, specification, offset=offset, limit=limit, order=order, count_limit=count_limit)
if res.get('length') == 0 and product_id:
# Global Audit!
_logger.error(f"DEBUG_RESTRICT: Catalog is EMPTY for product {product_id} in {allowed_ids}.")
all_quants = self.sudo().search([('product_id', '=', product_id), ('quantity', '>', 0)])
if all_quants:
loc_summary = [f"{q.location_id.display_name} (Qty: {q.quantity})" for q in all_quants]
_logger.error(f"DEBUG_RESTRICT: AUDIT: Product was found in {len(all_quants)} OTHER locations: {loc_summary}")
else:
_logger.error(f"DEBUG_RESTRICT: AUDIT: Product has ZERO quantity currently in the entire database (sudo).")
return res
return super().web_search_read(domain, specification, offset=offset, limit=limit, order=order, count_limit=count_limit) return super().web_search_read(domain, specification, offset=offset, limit=limit, order=order, count_limit=count_limit)

View File

@ -13,12 +13,12 @@ patch(SMLX2ManyField.prototype, {
} }
console.log("DEBUG_RESTRICT: Intercepting SMLX2ManyField.onAdd"); console.log("DEBUG_RESTRICT: Intercepting SMLX2ManyField.onAdd");
console.log("DEBUG_RESTRICT: Record Data:", this.props.record.data);
// 1. Sync Dirty Data (Internal Odoo Logic) // 1. Sync Dirty Data (Internal Odoo Logic)
await this.updateDirtyQuantsData(); await this.updateDirtyQuantsData();
// 2. Resolve Manufacturing Order ID (ID identification) // 2. Resolve Manufacturing Order ID (ID identification)
// We look in multiple places because Odoo 19 context keys can vary.
const mo_id = (this.props.context.active_mo_id || const mo_id = (this.props.context.active_mo_id ||
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]);
@ -30,17 +30,13 @@ patch(SMLX2ManyField.prototype, {
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_picking_type_id: this.props.context.default_picking_type_id, default_picking_type_id: this.props.context.default_picking_type_id,
// Keep the original IDs as fallback
default_allowed_source_location_ids: this.props.context.default_allowed_source_location_ids, default_allowed_source_location_ids: this.props.context.default_allowed_source_location_ids,
}; };
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);
// 4. Construct Domain (The Fix) // 4. Construct Domain
// We intentionally use Odoo's default_location_id here because our Python
// code in web_search_read will STRIP this and replace it with ALL allowed IDs.
// This ensures compatibility with Odoo's native JS domain construction.
let domain = [ let domain = [
["product_id", "=", this.props.record.data.product_id.id], ["product_id", "=", this.props.record.data.product_id.id],
["location_id", "child_of", this.props.context.default_location_id], ["location_id", "child_of", this.props.context.default_location_id],