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

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

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

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