erp-core/docs/04-modelado/especificaciones-tecnicas/transversal/SPEC-VALORACION-INVENTARIO.md

31 KiB
Raw Blame History

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 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:

{
  "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

  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

-- 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

  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

-- 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