31 KiB
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
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
-- 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
-- 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
interface FifoResult {
value: number;
unitCost: number;
remainingQty?: number; // Para stock negativo
}
async function runFifo(
productId: UUID,
quantity: number,
companyId: UUID,
lotId?: UUID
): Promise<FifoResult> {
// 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:
async function runFifoVacuum(
productId: UUID,
companyId: UUID
): Promise<void> {
// 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
async function updateAveragePrice(
productId: UUID,
companyId: UUID,
newQty: number,
newUnitCost: number
): Promise<void> {
// 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
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<UUID> {
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:
{
"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 productocompanyId- Filtrar por empresalotId- Filtrar por lotehasRemaining- Solo capas con remaining_qty > 0fromDate,toDate- Rango de fechaspage,limit- Paginación
POST /api/v1/inventory/products/:id/recalculate-valuation
Recalcula la valoración de un producto (uso administrativo).
Request:
{
"companyId": "uuid",
"reason": "Corrección de datos históricos"
}
8. Consideraciones de Performance
8.1 Índices Críticos
-- Í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
- Materializar totales: Vista materializada para cantidad/valor por producto
- Vacuum batch: Ejecutar vacuum en batch durante carga baja
- Particionado: Considerar particionado por fecha para tablas grandes
- Cache: Cache de standard_price en Redis
8.3 Bloqueos y Concurrencia
-- 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
-- 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
-- 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
- FIFO Básico: Comprar 10@$10, 10@$12, vender 15 → COGS = $10×10 + $12×5 = $160
- Stock Negativo: Vender sin stock, luego recibir → Vacuum corrige
- AVCO: Comprar 100@$10, comprar 100@$20 → standard_price = $15
- Devoluciones: Vender, devolver, revender → Costos correctos
- Multi-lote: FIFO respeta lotes cuando lot_valuated = true
- 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
-- 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 |