#!/usr/bin/env python3 """ Script para corregir capas de valoración (SVL) faltantes en Odoo 16. ======================================================================= PROBLEMA: Productos con stock en almacén y costo definido, pero sin capas de valoración (stock.valuation.layer), lo que causa que el listado de existencias muestre Costo Unitario = $0.00 y Valor Total = $0.00. QUÉ HACE: - Identifica productos con stock > 0, costo > 0, pero sin SVL - Crea una capa de valoración inicial para cada uno - NO modifica cantidades, stock.quant, stock.move ni contabilidad QUÉ NO HACE: - NO toca cantidades de inventario - NO crea movimientos de stock - NO genera asientos contables - NO modifica productos existentes USO: python3 fix_svl_missing.py --dry-run # Solo muestra lo que haría python3 fix_svl_missing.py --apply # Aplica la corrección python3 fix_svl_missing.py --verify # Verifica después de aplicar """ import xmlrpc.client import sys import logging from datetime import datetime # ─── Configuración ──────────────────────────────────────────────────────── ODOO_URL = 'http://localhost:8069' ODOO_DB = 'hazard_new' ODOO_USER = 'admin' ODOO_PASS = 'admin' COMPANY_ID = 1 # ─── Logging ────────────────────────────────────────────────────────────── log_filename = f"/tmp/fix_svl_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log" logging.basicConfig( level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s', handlers=[ logging.FileHandler(log_filename), logging.StreamHandler(sys.stdout) ] ) logger = logging.getLogger(__name__) def connect(): """Establece conexión XML-RPC con Odoo.""" common = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/common') uid = common.authenticate(ODOO_DB, ODOO_USER, ODOO_PASS, {}) if not uid: raise ConnectionError("No se pudo autenticar con Odoo") models = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/object') logger.info("Conectado a Odoo (uid=%s, db=%s)", uid, ODOO_DB) return uid, models def find_affected_products(uid, models): """ Encuentra productos que tienen: 1. stock.quant con quantity > 0 en ubicación interna 2. standard_price > 0 3. NO tienen stock.valuation.layer Retorna lista de dicts con product_id, qty, standard_price, quant_id. """ logger.info("─── Buscando productos afectados ───") # Paso 1: Obtener todos los quants con stock positivo en ubicaciones internas quant_ids = models.execute_kw(ODOO_DB, uid, ODOO_PASS, 'stock.quant', 'search', [[['quantity', '>', 0], ['location_id.usage', '=', 'internal']]]) quants = models.execute_kw(ODOO_DB, uid, ODOO_PASS, 'stock.quant', 'read', [quant_ids], {'fields': ['product_id', 'quantity', 'location_id', 'company_id']}) logger.info("Quants con stock positivo en almacén: %d", len(quants)) # Paso 2: Agrupar por product_id (sumar qty si hay múltiples quants) product_quants = {} for q in quants: pid = q['product_id'][0] if pid not in product_quants: product_quants[pid] = { 'product_id': pid, 'product_name': q['product_id'][1], 'total_qty': 0.0, 'company_id': q['company_id'][0] if q['company_id'] else COMPANY_ID, 'quant_ids': [] } product_quants[pid]['total_qty'] += q['quantity'] product_quants[pid]['quant_ids'].append(q['id']) logger.info("Productos únicos con stock: %d", len(product_quants)) # Paso 3: Filtrar los que NO tienen SVL all_product_ids = list(product_quants.keys()) svl_product_ids_raw = models.execute_kw(ODOO_DB, uid, ODOO_PASS, 'stock.valuation.layer', 'search_read', [[['product_id', 'in', all_product_ids]]], {'fields': ['product_id'], 'limit': 0}) products_with_svl = set(s['product_id'][0] for s in svl_product_ids_raw) products_without_svl = {pid: data for pid, data in product_quants.items() if pid not in products_with_svl} logger.info("Productos SIN SVL: %d", len(products_without_svl)) # Paso 4: Obtener standard_price y filtrar los que tienen costo > 0 if not products_without_svl: return [] pids_to_check = list(products_without_svl.keys()) # Leer en lotes de 200 para evitar timeout affected = [] batch_size = 200 for i in range(0, len(pids_to_check), batch_size): batch = pids_to_check[i:i+batch_size] prods = models.execute_kw(ODOO_DB, uid, ODOO_PASS, 'product.product', 'read', [batch], {'fields': ['standard_price', 'default_code', 'display_name', 'cost_method', 'categ_id']}) for p in prods: if p['standard_price'] and p['standard_price'] > 0: info = products_without_svl[p['id']] info['standard_price'] = p['standard_price'] info['default_code'] = p.get('default_code') or '' info['display_name'] = p.get('display_name') or '' info['cost_method'] = p.get('cost_method') or 'standard' info['categ_id'] = p.get('categ_id', [False, ''])[0] affected.append(info) logger.info("Productos con costo > 0 pero SIN SVL: %d", len(affected)) return affected def dry_run(affected): """Muestra lo que haría sin modificar nada.""" logger.info("") logger.info("═══════════════════════════════════════════════════") logger.info(" DRY RUN - No se modificará nada") logger.info("═══════════════════════════════════════════════════") logger.info("") total_value = 0.0 for i, item in enumerate(affected, 1): value = item['total_qty'] * item['standard_price'] total_value += value logger.info( "[%3d] %-50s | Qty: %6.2f | Costo: $%10.2f | Valor: $%10.2f", i, item.get('default_code') or item.get('display_name', '')[:50], item['total_qty'], item['standard_price'], value ) logger.info("") logger.info("─── RESUMEN ───") logger.info("Productos a corregir: %d", len(affected)) logger.info("Valor total faltante: $%.2f", total_value) logger.info("") logger.info("Para aplicar la corrección ejecutar con --apply") def apply_fix(uid, models, affected): """ Crea las capas de valoración faltantes. Para cada producto afectado, crea UN registro en stock.valuation.layer que representa la entrada inicial del inventario con su costo correcto. """ logger.info("") logger.info("═══════════════════════════════════════════════════") logger.info(" APLICANDO CORRECCIÓN") logger.info("═══════════════════════════════════════════════════") logger.info("") success_count = 0 error_count = 0 total_value_created = 0.0 for i, item in enumerate(affected, 1): pid = item['product_id'] qty = item['total_qty'] cost = item['standard_price'] value = qty * cost try: # Crear la capa de valoración # Estos campos replican lo que Odoo crea en un ajuste de inventario svl_vals = { 'product_id': pid, 'company_id': item['company_id'], 'quantity': qty, 'unit_cost': cost, 'value': value, 'remaining_qty': qty, 'remaining_value': value, 'description': 'Corrección de valoración - inventario inicial sin capa', } svl_id = models.execute_kw(ODOO_DB, uid, ODOO_PASS, 'stock.valuation.layer', 'create', [svl_vals]) success_count += 1 total_value_created += value if i % 25 == 0 or i <= 5: logger.info( "[%3d/%d] ✅ SVL #%d creado para %s (qty=%.2f, costo=$%.2f, valor=$%.2f)", i, len(affected), svl_id, item.get('default_code') or str(pid), qty, cost, value ) except Exception as e: error_count += 1 logger.error( "[%3d/%d] ❌ Error en producto %s (id=%d): %s", i, len(affected), item.get('default_code') or item.get('display_name', ''), pid, str(e) ) logger.info("") logger.info("─── RESULTADO ───") logger.info("Exitosos: %d", success_count) logger.info("Errores: %d", error_count) logger.info("Valor total creado: $%.2f", total_value_created) logger.info("") return success_count, error_count def verify(uid, models): """Verifica que la corrección funcionó comparando antes/después.""" logger.info("") logger.info("═══════════════════════════════════════════════════") logger.info(" VERIFICACIÓN POST-CORRECCIÓN") logger.info("═══════════════════════════════════════════════════") logger.info("") # 1. Verificar el OVERGRIP BLANCO (caso original) test_ids = [9364, 9365, 9366] for pid in test_ids: prod = models.execute_kw(ODOO_DB, uid, ODOO_PASS, 'product.product', 'read', [pid], {'fields': ['display_name', 'standard_price', 'quantity_svl', 'value_svl']}) if prod: p = prod[0] logger.info( "Producto %-50s | standard_price=$%.2f | quantity_svl=%.2f | value_svl=$%.2f", p['display_name'][:50], p['standard_price'], p['quantity_svl'], p['value_svl'] ) quants = models.execute_kw(ODOO_DB, uid, ODOO_PASS, 'stock.quant', 'search_read', [[['product_id', '=', pid], ['location_id.usage', '=', 'internal']]], {'fields': ['quantity', 'value']}) for q in quants: logger.info( " └─ Quant: qty=%.2f, value=$%.2f %s", q['quantity'], q['value'], '✅' if q['value'] > 0 else '❌ AÚN EN CERO' ) # 2. Contar cuántos quants siguen en value=0 zero_count = models.execute_kw(ODOO_DB, uid, ODOO_PASS, 'stock.quant', 'search_count', [[['quantity', '>', 0], ['value', '=', 0], ['location_id.usage', '=', 'internal']]]) total_count = models.execute_kw(ODOO_DB, uid, ODOO_PASS, 'stock.quant', 'search_count', [[['quantity', '>', 0], ['location_id.usage', '=', 'internal']]]) logger.info("") logger.info("─── ESTADO GENERAL ───") logger.info("Quants con stock en almacén: %d", total_count) logger.info("Quants con value = 0 (problema): %d", zero_count) logger.info("Quants con value > 0 (OK): %d", total_count - zero_count) def main(): if len(sys.argv) < 2 or sys.argv[1] not in ('--dry-run', '--apply', '--verify'): print(__doc__) print("Uso:") print(" python3 fix_svl_missing.py --dry-run # Solo muestra lo que haría") print(" python3 fix_svl_missing.py --apply # Aplica la corrección") print(" python3 fix_svl_missing.py --verify # Verifica después de aplicar") sys.exit(1) mode = sys.argv[1] logger.info("Modo: %s", mode) logger.info("Log: %s", log_filename) uid, models_proxy = connect() if mode == '--verify': verify(uid, models_proxy) return affected = find_affected_products(uid, models_proxy) if not affected: logger.info("✅ No se encontraron productos afectados. No hay nada que corregir.") return if mode == '--dry-run': dry_run(affected) elif mode == '--apply': logger.info("") logger.info("⚠️ CONFIRMACIÓN: Se crearán %d capas de valoración.", len(affected)) logger.info(" Respaldo previo: backups/tmp/hazard_new_pre_svl_fix_*.dump") logger.info("") success, errors = apply_fix(uid, models_proxy, affected) if errors == 0: logger.info("✅ Corrección completada sin errores.") else: logger.warning("⚠️ Corrección completada con %d errores. Revisar log.", errors) # Verificación automática post-aplicación logger.info("") logger.info("Ejecutando verificación automática...") verify(uid, models_proxy) if __name__ == '__main__': main()