# Especificación Técnica: Sistema de Valoración de Inventario **Código:** SPEC-TRANS-002 **Versión:** 1.0 **Fecha:** 2025-12-08 **Estado:** Especificado **Basado en:** Odoo stock.valuation.layer (v18.0) --- ## 1. Resumen Ejecutivo ### 1.1 Propósito El Sistema de Valoración de Inventario gestiona el costeo de productos almacenables mediante capas de valoración (Stock Valuation Layers - SVL). Implementa los métodos FIFO, Costo Promedio y Costo Estándar, integrándose con contabilidad para generar asientos automáticos. ### 1.2 Alcance - Tracking de capas de valoración para cada movimiento de inventario - Algoritmo FIFO para consumo de capas en orden cronológico - Cálculo de costo promedio ponderado (AVCO) - Corrección automática de stock negativo (FIFO Vacuum) - Generación de asientos contables en tiempo real - Soporte multi-empresa y multi-moneda - Valoración por lote/serie (opcional) ### 1.3 Módulos Afectados | Módulo | Rol | |--------|-----| | MGN-005 (Inventario) | Movimientos de stock, capas de valoración | | MGN-004 (Financiero) | Asientos contables de valoración | | MGN-006 (Compras) | Recepciones con costo de compra | | MGN-007 (Ventas) | Salidas con costo FIFO/AVCO | --- ## 2. Conceptos Clave ### 2.1 Métodos de Costeo | Método | Descripción | Uso Recomendado | |--------|-------------|-----------------| | **FIFO** | First-In-First-Out. Consume primero las capas más antiguas | Productos perecederos, trazabilidad de costos | | **AVCO** | Costo Promedio Ponderado. Recalcula precio en cada recepción | Productos de alto volumen, commodities | | **Standard** | Costo fijo definido manualmente | Manufactura con costos estimados | ### 2.2 Métodos de Contabilización | Método | Descripción | Asientos | |--------|-------------|----------| | **Perpetuo (real_time)** | Asientos automáticos en cada movimiento | Automático | | **Periódico (manual)** | Ajustes manuales al cierre de período | Manual | ### 2.3 Capa de Valoración (SVL) Registro inmutable que asocia cantidad y valor a cada movimiento de entrada: ``` SVL = { product_id, // Producto quantity, // Cantidad (+ entrada, - salida) unit_cost, // Costo unitario value, // Valor total (quantity × unit_cost) remaining_qty, // Cantidad no consumida (para FIFO) remaining_value, // Valor no consumido stock_move_id, // Movimiento de origen lot_id // Lote (opcional) } ``` --- ## 3. Modelo de Datos ### 3.1 Tabla Principal: `inventory.stock_valuation_layers` ```sql CREATE TABLE inventory.stock_valuation_layers ( -- Identificación id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Referencias product_id UUID NOT NULL REFERENCES inventory.products(id), stock_move_id UUID REFERENCES inventory.stock_moves(id), lot_id UUID REFERENCES inventory.stock_lots(id), company_id UUID NOT NULL REFERENCES core_auth.companies(id), tenant_id UUID NOT NULL, -- Valores de la capa quantity DECIMAL(16,4) NOT NULL, -- Cantidad (positiva=entrada, negativa=salida) unit_cost DECIMAL(16,6) NOT NULL, -- Costo unitario value DECIMAL(16,4) NOT NULL, -- Valor total currency_id UUID NOT NULL REFERENCES core_catalogs.currencies(id), -- Tracking FIFO (solo para entradas) remaining_qty DECIMAL(16,4) NOT NULL DEFAULT 0, -- Cantidad restante por consumir remaining_value DECIMAL(16,4) NOT NULL DEFAULT 0, -- Valor restante -- Diferencia de precio (facturas vs recepción) price_diff_value DECIMAL(16,4) DEFAULT 0, -- Referencias contables account_move_id UUID REFERENCES accounting.account_moves(id), account_move_line_id UUID REFERENCES accounting.account_move_lines(id), -- Corrección de vacío (link a capa corregida) parent_svl_id UUID REFERENCES inventory.stock_valuation_layers(id), -- Metadata description VARCHAR(500), reference VARCHAR(255), -- Auditoría created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by UUID REFERENCES core_auth.users(id), -- Constraints CONSTRAINT chk_svl_value CHECK ( ABS(value - (quantity * unit_cost)) < 0.01 OR quantity = 0 ) ); -- Índice principal para FIFO (crítico para performance) CREATE INDEX idx_svl_fifo_candidates ON inventory.stock_valuation_layers ( product_id, remaining_qty, stock_move_id, company_id, created_at ) WHERE remaining_qty > 0; -- Índice para agregación de valoración CREATE INDEX idx_svl_valuation ON inventory.stock_valuation_layers ( product_id, company_id, id, value, quantity ); -- Índice por lote CREATE INDEX idx_svl_lot ON inventory.stock_valuation_layers (lot_id) WHERE lot_id IS NOT NULL; -- Índice por movimiento CREATE INDEX idx_svl_move ON inventory.stock_valuation_layers (stock_move_id); -- Comentarios COMMENT ON TABLE inventory.stock_valuation_layers IS 'Capas de valoración de inventario para costeo FIFO/AVCO'; COMMENT ON COLUMN inventory.stock_valuation_layers.remaining_qty IS 'Cantidad aún no consumida por FIFO'; COMMENT ON COLUMN inventory.stock_valuation_layers.parent_svl_id IS 'Referencia a capa padre cuando es corrección de vacío'; ``` ### 3.2 Campos Adicionales en Productos ```sql -- Agregar a inventory.products o inventory.product_categories ALTER TABLE inventory.product_categories ADD COLUMN IF NOT EXISTS cost_method VARCHAR(20) NOT NULL DEFAULT 'fifo' CHECK (cost_method IN ('standard', 'average', 'fifo')); ALTER TABLE inventory.product_categories ADD COLUMN IF NOT EXISTS valuation_method VARCHAR(20) NOT NULL DEFAULT 'real_time' CHECK (valuation_method IN ('manual', 'real_time')); ALTER TABLE inventory.products ADD COLUMN IF NOT EXISTS standard_price DECIMAL(16,6) NOT NULL DEFAULT 0; ALTER TABLE inventory.products ADD COLUMN IF NOT EXISTS lot_valuated BOOLEAN NOT NULL DEFAULT FALSE; -- Vista materializada para valores agregados de SVL por producto CREATE MATERIALIZED VIEW inventory.product_valuation_summary AS SELECT svl.product_id, svl.company_id, SUM(svl.quantity) AS quantity_svl, SUM(svl.value) AS value_svl, CASE WHEN SUM(svl.quantity) > 0 THEN SUM(svl.value) / SUM(svl.quantity) ELSE 0 END AS avg_cost FROM inventory.stock_valuation_layers svl GROUP BY svl.product_id, svl.company_id; CREATE UNIQUE INDEX idx_product_valuation_pk ON inventory.product_valuation_summary (product_id, company_id); -- Refresh periódico o trigger COMMENT ON MATERIALIZED VIEW inventory.product_valuation_summary IS 'Resumen de valoración por producto - refrescar con REFRESH MATERIALIZED VIEW CONCURRENTLY'; ``` ### 3.3 Cuentas Contables de Inventario ```sql -- Configuración de cuentas por categoría de producto CREATE TABLE inventory.category_stock_accounts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), category_id UUID NOT NULL REFERENCES inventory.product_categories(id), company_id UUID NOT NULL REFERENCES core_auth.companies(id), -- Cuentas de valoración stock_input_account_id UUID REFERENCES accounting.accounts(id), -- Entrada de stock stock_output_account_id UUID REFERENCES accounting.accounts(id), -- Salida de stock stock_valuation_account_id UUID REFERENCES accounting.accounts(id), -- Valoración (activo) expense_account_id UUID REFERENCES accounting.accounts(id), -- Gasto/COGS -- Diario para asientos de stock stock_journal_id UUID REFERENCES accounting.journals(id), tenant_id UUID NOT NULL, CONSTRAINT uq_category_stock_accounts UNIQUE (category_id, company_id, tenant_id) ); COMMENT ON TABLE inventory.category_stock_accounts IS 'Cuentas contables para valoración de inventario por categoría'; ``` --- ## 4. Algoritmo FIFO ### 4.1 Diagrama de Flujo ``` ┌─────────────────────────────────────────────────────────────────┐ │ ALGORITMO FIFO │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ ENTRADA (Recepción): │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ 1. Crear SVL con: │ │ │ │ - quantity = qty_recibida (positivo) │ │ │ │ - remaining_qty = qty_recibida │ │ │ │ - unit_cost = precio_compra │ │ │ │ - value = quantity × unit_cost │ │ │ │ - remaining_value = value │ │ │ │ │ │ │ │ 2. Si valuation = 'real_time': │ │ │ │ Generar asiento: │ │ │ │ DR: Stock Valuation Account │ │ │ │ CR: Stock Input Account │ │ │ │ │ │ │ │ 3. Si cost_method = 'average': │ │ │ │ Recalcular standard_price (promedio ponderado) │ │ │ │ │ │ │ │ 4. Ejecutar FIFO Vacuum (corregir stock negativo) │ │ │ └─────────────────────────────────────────────────────────┘ │ │ │ │ SALIDA (Entrega/Consumo): │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ 1. Obtener candidatos FIFO: │ │ │ │ SELECT * FROM stock_valuation_layers │ │ │ │ WHERE product_id = :product │ │ │ │ AND remaining_qty > 0 │ │ │ │ AND company_id = :company │ │ │ │ ORDER BY created_at, id │ │ │ │ │ │ │ │ 2. Para cada candidato (en orden FIFO): │ │ │ │ a) qty_tomar = MIN(remaining_qty, qty_pendiente) │ │ │ │ b) valor_tomar = qty_tomar × (remaining_value / │ │ │ │ remaining_qty) │ │ │ │ c) Actualizar candidato: │ │ │ │ remaining_qty -= qty_tomar │ │ │ │ remaining_value -= valor_tomar │ │ │ │ d) Acumular: valor_total += valor_tomar │ │ │ │ e) qty_pendiente -= qty_tomar │ │ │ │ f) Si qty_pendiente = 0: break │ │ │ │ │ │ │ │ 3. Si qty_pendiente > 0 (STOCK NEGATIVO): │ │ │ │ - Crear SVL con remaining_qty negativo │ │ │ │ - Valorar al último precio FIFO │ │ │ │ - Será corregido por FIFO Vacuum posteriormente │ │ │ │ │ │ │ │ 4. Crear SVL de salida: │ │ │ │ - quantity = -qty_total (negativo) │ │ │ │ - value = -valor_total (negativo) │ │ │ │ - unit_cost = valor_total / qty_total │ │ │ │ │ │ │ │ 5. Si valuation = 'real_time': │ │ │ │ Generar asiento: │ │ │ │ DR: Stock Output Account (Expense/COGS) │ │ │ │ CR: Stock Valuation Account │ │ │ └─────────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────┘ ``` ### 4.2 Pseudocódigo del Algoritmo FIFO ```typescript interface FifoResult { value: number; unitCost: number; remainingQty?: number; // Para stock negativo } async function runFifo( productId: UUID, quantity: number, companyId: UUID, lotId?: UUID ): Promise { // 1. Obtener candidatos FIFO ordenados por fecha de creación const candidates = await db.query(` SELECT id, remaining_qty, remaining_value, unit_cost, currency_id FROM inventory.stock_valuation_layers WHERE product_id = $1 AND remaining_qty > 0 AND company_id = $2 AND ($3::uuid IS NULL OR lot_id = $3) ORDER BY created_at ASC, id ASC FOR UPDATE -- Bloquear para concurrencia `, [productId, companyId, lotId]); let qtyToTake = quantity; let totalValue = 0; let lastUnitCost = 0; // 2. Consumir capas en orden FIFO for (const candidate of candidates) { if (qtyToTake <= 0) break; const qtyTaken = Math.min(candidate.remaining_qty, qtyToTake); const candidateUnitCost = candidate.remaining_value / candidate.remaining_qty; const valueTaken = round(qtyTaken * candidateUnitCost, 4); // Actualizar capa candidata await db.query(` UPDATE inventory.stock_valuation_layers SET remaining_qty = remaining_qty - $1, remaining_value = remaining_value - $2 WHERE id = $3 `, [qtyTaken, valueTaken, candidate.id]); qtyToTake -= qtyTaken; totalValue += valueTaken; lastUnitCost = candidateUnitCost; } // 3. Manejar stock negativo if (qtyToTake > 0) { // Stock insuficiente - usar último precio conocido const negativeValue = lastUnitCost * qtyToTake; totalValue += negativeValue; return { value: -totalValue, unitCost: totalValue / quantity, remainingQty: -qtyToTake // Marcador de stock negativo }; } // 4. Actualizar standard_price si es FIFO const product = await getProduct(productId); if (product.costMethod === 'fifo') { const nextCandidate = candidates.find(c => c.remaining_qty > 0); if (nextCandidate) { await updateStandardPrice(productId, companyId, nextCandidate.unit_cost); } } return { value: -totalValue, unitCost: totalValue / quantity }; } ``` ### 4.3 FIFO Vacuum (Corrección de Stock Negativo) Cuando se vende antes de recibir (stock negativo), el sistema crea una capa con `remaining_qty` negativo. Al recibir producto, el vacuum corrige la valoración: ```typescript async function runFifoVacuum( productId: UUID, companyId: UUID ): Promise { // 1. Encontrar capas con remaining_qty negativo (stock negativo) const negativeLayers = await db.query(` SELECT id, remaining_qty, unit_cost, stock_move_id, lot_id, created_at FROM inventory.stock_valuation_layers WHERE product_id = $1 AND company_id = $2 AND remaining_qty < 0 ORDER BY created_at ASC, id ASC FOR UPDATE `, [productId, companyId]); if (negativeLayers.length === 0) return; const minDate = negativeLayers[0].created_at; // 2. Encontrar capas positivas creadas después del stock negativo const positiveLayers = await db.query(` SELECT id, remaining_qty, remaining_value, unit_cost, lot_id FROM inventory.stock_valuation_layers WHERE product_id = $1 AND company_id = $2 AND remaining_qty > 0 AND created_at >= $3 ORDER BY created_at ASC, id ASC FOR UPDATE `, [productId, companyId, minDate]); // 3. Para cada capa negativa, buscar corrección for (const negLayer of negativeLayers) { const candidates = positiveLayers.filter(p => p.created_at > negLayer.created_at && (!negLayer.lot_id || p.lot_id === negLayer.lot_id) && p.remaining_qty > 0 ); let qtyToCorrect = Math.abs(negLayer.remaining_qty); let valueTaken = 0; for (const candidate of candidates) { if (qtyToCorrect <= 0) break; const qtyTaken = Math.min(candidate.remaining_qty, qtyToCorrect); const candidateUnitCost = candidate.remaining_value / candidate.remaining_qty; const value = round(qtyTaken * candidateUnitCost, 4); // Reducir remaining del candidato candidate.remaining_qty -= qtyTaken; candidate.remaining_value -= value; await db.query(` UPDATE inventory.stock_valuation_layers SET remaining_qty = $1, remaining_value = $2 WHERE id = $3 `, [candidate.remaining_qty, candidate.remaining_value, candidate.id]); qtyToCorrect -= qtyTaken; valueTaken += value; } // 4. Calcular varianza (precio estimado vs real) const estimatedValue = Math.abs(negLayer.remaining_qty) * negLayer.unit_cost; const variance = estimatedValue - valueTaken; // Actualizar capa negativa await db.query(` UPDATE inventory.stock_valuation_layers SET remaining_qty = remaining_qty + $1 WHERE id = $2 `, [Math.abs(negLayer.remaining_qty) - qtyToCorrect, negLayer.id]); // 5. Crear capa de varianza si hay diferencia if (Math.abs(variance) > 0.01) { await createVacuumLayer({ productId, companyId, value: variance, quantity: 0, // Solo ajuste de valor unitCost: 0, stockMoveId: negLayer.stock_move_id, parentSvlId: negLayer.id, description: `Revaluación de inventario negativo` }); // Generar asiento de ajuste if (product.valuationMethod === 'real_time') { await createVarianceAccountingEntry(variance); } } } } ``` --- ## 5. Costo Promedio (AVCO) ### 5.1 Fórmula de Actualización Al recibir nuevo inventario: ``` nuevo_precio = (qty_actual × precio_actual + qty_nueva × precio_nuevo) ───────────────────────────────────────────────────────── (qty_actual + qty_nueva) ``` ### 5.2 Implementación ```typescript async function updateAveragePrice( productId: UUID, companyId: UUID, newQty: number, newUnitCost: number ): Promise { // Obtener cantidad y valor actual const current = await db.queryOne(` SELECT COALESCE(SUM(quantity), 0) AS qty_available, COALESCE(SUM(value), 0) AS value_total FROM inventory.stock_valuation_layers WHERE product_id = $1 AND company_id = $2 `, [productId, companyId]); const qtyAvailable = current.qty_available; const currentValue = current.value_total; const currentPrice = qtyAvailable > 0 ? currentValue / qtyAvailable : 0; // Calcular nuevo precio promedio const totalQty = qtyAvailable + newQty; if (totalQty <= 0) { // Sin stock, usar precio de entrada await updateStandardPrice(productId, companyId, newUnitCost); } else { // Promedio ponderado const newPrice = (currentPrice * qtyAvailable + newUnitCost * newQty) / totalQty; await updateStandardPrice(productId, companyId, newPrice); } } ``` --- ## 6. Generación de Asientos Contables ### 6.1 Cuentas Utilizadas | Movimiento | Cuenta Débito | Cuenta Crédito | |------------|---------------|----------------| | **Recepción** | Stock Valuation (Activo) | Stock Input | | **Entrega** | Stock Output (Gasto/COGS) | Stock Valuation (Activo) | | **Devolución Cliente** | Stock Valuation | Stock Output | | **Devolución Proveedor** | Stock Input | Stock Valuation | | **Ajuste Vacuum** | Stock Valuation / Expense | Expense / Stock Valuation | ### 6.2 Ejemplo de Asientos ``` RECEPCIÓN: Compra 100 unidades @ $10 = $1,000 ──────────────────────────────────────────────── Inventario (Stock Valuation) 1,000.00 | Stock Input | 1,000.00 ENTREGA: Venta 60 unidades (FIFO: 60 @ $10 = $600) ──────────────────────────────────────────────── Costo de Ventas (COGS) 600.00 | Inventario (Stock Valuation) | 600.00 VACUUM: Corrección de $50 (vendió a $10, recibió a $10.50) ──────────────────────────────────────────────── Costo de Ventas (COGS) 50.00 | Inventario (Stock Valuation) | 50.00 ``` ### 6.3 Implementación de Asientos ```typescript interface AccountingEntry { journalId: UUID; date: Date; reference: string; lines: { accountId: UUID; debit: number; credit: number; productId?: UUID; quantity?: number; partnerId?: UUID; }[]; } async function createValuationAccountingEntry( svl: StockValuationLayer, moveType: 'in' | 'out' | 'return_in' | 'return_out' ): Promise { const accounts = await getCategoryStockAccounts(svl.productId, svl.companyId); const absValue = Math.abs(svl.value); let debitAccount: UUID; let creditAccount: UUID; switch (moveType) { case 'in': // Recepción: Valuation <- Input debitAccount = accounts.stockValuationAccountId; creditAccount = accounts.stockInputAccountId; break; case 'out': // Entrega: Output <- Valuation debitAccount = accounts.stockOutputAccountId; creditAccount = accounts.stockValuationAccountId; break; case 'return_in': // Devolución de cliente: Valuation <- Output debitAccount = accounts.stockValuationAccountId; creditAccount = accounts.stockOutputAccountId; break; case 'return_out': // Devolución a proveedor: Input <- Valuation debitAccount = accounts.stockInputAccountId; creditAccount = accounts.stockValuationAccountId; break; } const entry: AccountingEntry = { journalId: accounts.stockJournalId, date: new Date(), reference: svl.reference, lines: [ { accountId: debitAccount, debit: absValue, credit: 0, productId: svl.productId, quantity: Math.abs(svl.quantity) }, { accountId: creditAccount, debit: 0, credit: absValue, productId: svl.productId, quantity: Math.abs(svl.quantity) } ] }; return await createAccountMove(entry); } ``` --- ## 7. API REST ### 7.1 Endpoints #### GET `/api/v1/inventory/products/:id/valuation` Obtiene el resumen de valoración de un producto. **Response:** ```json { "productId": "uuid", "companyId": "uuid", "costMethod": "fifo", "valuationMethod": "real_time", "quantityOnHand": 150.0, "valuationTotal": 1575.00, "averageCost": 10.50, "standardPrice": 10.75, "layers": [ { "id": "uuid", "createdAt": "2025-01-15T10:30:00Z", "quantity": 100, "unitCost": 10.00, "remainingQty": 50, "remainingValue": 500.00, "reference": "PO-2025-0001", "lotId": null }, { "id": "uuid", "createdAt": "2025-01-20T14:00:00Z", "quantity": 100, "unitCost": 11.00, "remainingQty": 100, "remainingValue": 1100.00, "reference": "PO-2025-0002", "lotId": null } ] } ``` #### GET `/api/v1/inventory/valuation-layers` Lista capas de valoración con filtros. **Query Params:** - `productId` - Filtrar por producto - `companyId` - Filtrar por empresa - `lotId` - Filtrar por lote - `hasRemaining` - Solo capas con remaining_qty > 0 - `fromDate`, `toDate` - Rango de fechas - `page`, `limit` - Paginación #### POST `/api/v1/inventory/products/:id/recalculate-valuation` Recalcula la valoración de un producto (uso administrativo). **Request:** ```json { "companyId": "uuid", "reason": "Corrección de datos históricos" } ``` --- ## 8. Consideraciones de Performance ### 8.1 Índices Críticos ```sql -- Índice compuesto para búsqueda FIFO (el más importante) CREATE INDEX idx_svl_fifo_candidates ON inventory.stock_valuation_layers ( product_id, remaining_qty, company_id, created_at ) WHERE remaining_qty > 0; -- Para agregaciones de valoración CREATE INDEX idx_svl_product_company ON inventory.stock_valuation_layers ( product_id, company_id ) INCLUDE (quantity, value); ``` ### 8.2 Estrategias de Optimización 1. **Materializar totales**: Vista materializada para cantidad/valor por producto 2. **Vacuum batch**: Ejecutar vacuum en batch durante carga baja 3. **Particionado**: Considerar particionado por fecha para tablas grandes 4. **Cache**: Cache de standard_price en Redis ### 8.3 Bloqueos y Concurrencia ```sql -- Usar FOR UPDATE SKIP LOCKED para alta concurrencia SELECT id, remaining_qty, remaining_value FROM inventory.stock_valuation_layers WHERE product_id = $1 AND remaining_qty > 0 ORDER BY created_at ASC FOR UPDATE SKIP LOCKED; ``` --- ## 9. Integración con Otros Módulos ### 9.1 Compras (MGN-006) - Al confirmar recepción → Crear SVL de entrada - Precio de compra → unit_cost del SVL - Diferencia precio factura vs PO → price_diff_value ### 9.2 Ventas (MGN-007) - Al confirmar entrega → Ejecutar FIFO y crear SVL de salida - Calcular COGS para facturación ### 9.3 Inventario (MGN-005) - Ajustes de inventario → SVL con tipo adjustment - Transferencias internas → No afectan valoración (misma empresa) - Transferencias inter-empresa → Generan IN/OUT en cada empresa ### 9.4 Contabilidad (MGN-004) - Asientos automáticos en tiempo real - Conciliación de cuentas de inventario - Reportes de valoración --- ## 10. Casos de Uso ### 10.1 Flujo Normal de Compra-Venta ``` 1. Crear PO por 100 units @ $10 2. Recibir mercancía → SVL(+100, $10) → Asiento DR:Inventory CR:GR/IR 3. Recibir factura → Conciliar con GR/IR 4. Vender 60 units 5. Entregar mercancía → FIFO consume SVL → SVL(-60, $10) → Asiento DR:COGS CR:Inventory ``` ### 10.2 Stock Negativo ``` 1. Stock actual: 0 units 2. Vender 10 units (permitido por configuración) 3. Entregar → SVL(-10, remaining_qty=-10, unit_cost=last_price) 4. Recibir 20 units @ $12 5. Vacuum ejecuta: - Corrige SVL negativo - Genera varianza si precio difiere - Asiento de ajuste si aplica ``` ### 10.3 Devolución de Cliente ``` 1. Cliente devuelve 5 units (de venta con COGS $50) 2. Crear SVL(+5, $10) con tipo return_in 3. Asiento: DR:Inventory CR:COGS 4. remaining_qty = 5 (disponible para futuras ventas) ``` --- ## 11. Configuración por Tenant ### 11.1 Parámetros Configurables | Parámetro | Tipo | Default | Descripción | |-----------|------|---------|-------------| | `allow_negative_stock` | boolean | false | Permitir entregas sin stock | | `default_cost_method` | enum | 'fifo' | Método de costeo por defecto | | `default_valuation` | enum | 'real_time' | Método de contabilización | | `auto_vacuum_enabled` | boolean | true | Ejecutar vacuum automático | | `vacuum_batch_size` | number | 100 | Productos por batch de vacuum | ### 11.2 Configuración en Base de Datos ```sql -- Parámetros de inventario por tenant CREATE TABLE inventory.valuation_settings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL UNIQUE, company_id UUID REFERENCES core_auth.companies(id), allow_negative_stock BOOLEAN NOT NULL DEFAULT FALSE, default_cost_method VARCHAR(20) NOT NULL DEFAULT 'fifo', default_valuation VARCHAR(20) NOT NULL DEFAULT 'real_time', auto_vacuum_enabled BOOLEAN NOT NULL DEFAULT TRUE, vacuum_batch_size INTEGER NOT NULL DEFAULT 100, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); ``` --- ## 12. Migración desde Sistemas Legacy ### 12.1 Importación Inicial ```sql -- Crear SVL inicial para saldos existentes INSERT INTO inventory.stock_valuation_layers ( product_id, quantity, unit_cost, value, remaining_qty, remaining_value, company_id, tenant_id, description ) SELECT p.id, p.qty_on_hand, p.standard_price, p.qty_on_hand * p.standard_price, p.qty_on_hand, p.qty_on_hand * p.standard_price, p.company_id, p.tenant_id, 'Saldo inicial de migración' FROM inventory.products p WHERE p.qty_on_hand > 0; ``` --- ## 13. Testing ### 13.1 Casos de Prueba Críticos 1. **FIFO Básico**: Comprar 10@$10, 10@$12, vender 15 → COGS = $10×10 + $12×5 = $160 2. **Stock Negativo**: Vender sin stock, luego recibir → Vacuum corrige 3. **AVCO**: Comprar 100@$10, comprar 100@$20 → standard_price = $15 4. **Devoluciones**: Vender, devolver, revender → Costos correctos 5. **Multi-lote**: FIFO respeta lotes cuando lot_valuated = true 6. **Concurrencia**: Múltiples ventas simultáneas no corrompen datos --- ## 14. Monitoreo y Alertas ### 14.1 Métricas a Monitorear - Tiempo de ejecución de FIFO - Cantidad de capas con remaining_qty negativo (pendientes vacuum) - Diferencias de valoración vs libro mayor - Volumen de transacciones por minuto ### 14.2 Queries de Diagnóstico ```sql -- Productos con stock negativo pendiente SELECT p.name, SUM(svl.remaining_qty) AS negative_qty FROM inventory.stock_valuation_layers svl JOIN inventory.products p ON svl.product_id = p.id WHERE svl.remaining_qty < 0 GROUP BY p.id, p.name ORDER BY negative_qty ASC; -- Discrepancia valoración vs cantidad física SELECT p.name, p.qty_on_hand AS physical_qty, COALESCE(SUM(svl.quantity), 0) AS svl_qty, p.qty_on_hand - COALESCE(SUM(svl.quantity), 0) AS diff FROM inventory.products p LEFT JOIN inventory.stock_valuation_layers svl ON p.id = svl.product_id GROUP BY p.id, p.name, p.qty_on_hand HAVING ABS(p.qty_on_hand - COALESCE(SUM(svl.quantity), 0)) > 0.001; ``` --- ## 15. Referencias - **Odoo Source**: `addons/stock_account/models/stock_valuation_layer.py` - **Odoo Source**: `addons/stock_account/models/product.py` (`_run_fifo`, `_run_fifo_vacuum`) - **NIC 2**: Norma Internacional de Contabilidad para Inventarios - **GAAP**: Generally Accepted Accounting Principles (US) --- ## Historial de Cambios | Versión | Fecha | Autor | Cambios | |---------|-------|-------|---------| | 1.0 | 2025-12-08 | Requirements-Analyst | Versión inicial |