# Workflow: Control 3-Way Match (PO vs Recepción vs Factura)

**Código:** WF-PUR-001
**Versión:** 1.0
**Fecha:** 2025-12-08
**Módulo:** MGN-006 (Compras Básico)
**Referencia:** Odoo purchase (3-way matching)

---

## 1. Resumen

### 1.1 Propósito
El 3-Way Match es un control interno que valida la concordancia entre tres documentos:
1. **Orden de Compra (PO):** Cantidad ordenada
2. **Recepción (Receipt):** Cantidad recibida
3. **Factura (Invoice):** Cantidad facturada

### 1.2 Objetivo
Prevenir fraudes y errores como:
- Facturar sin haber recibido (pagar por productos no entregados)
- Facturar más de lo ordenado (sobrepago)
- Facturar más de lo recibido (discrepancia de inventario)

### 1.3 Flujo General

```
┌─────────────┐      ┌─────────────┐      ┌─────────────┐
│  ORDEN DE   │ ───▶ │  RECEPCIÓN  │ ───▶ │  FACTURA    │
│   COMPRA    │      │  (Receipt)  │      │  (Invoice)  │
│             │      │             │      │             │
│ qty_ordered │      │ qty_received│      │ qty_invoiced│
│    = 100    │      │    = 100    │      │    = 100    │
└─────────────┘      └─────────────┘      └─────────────┘
       ▲                    ▲                    ▲
       │                    │                    │
       └────────────────────┴────────────────────┘
                    3-WAY MATCH
                 (Deben coincidir)
```

---

## 2. Modelo de Datos

### 2.1 Campos en Línea de Orden de Compra

```sql
ALTER TABLE core_purchasing.purchase_order_lines ADD COLUMN IF NOT EXISTS
    -- Cantidades de control
    product_qty DECIMAL(15,4) NOT NULL,            -- Cantidad ordenada
    qty_received DECIMAL(15,4) NOT NULL DEFAULT 0, -- Cantidad recibida (computed)
    qty_invoiced DECIMAL(15,4) NOT NULL DEFAULT 0, -- Cantidad facturada (computed)
    qty_to_invoice DECIMAL(15,4) GENERATED ALWAYS AS (
        CASE
            WHEN purchase_method = 'purchase' THEN product_qty - qty_invoiced
            ELSE qty_received - qty_invoiced
        END
    ) STORED,                                       -- Cantidad pendiente de facturar

    -- Método de recepción
    qty_received_method VARCHAR(20) DEFAULT 'manual'
        CHECK (qty_received_method IN ('manual', 'stock')),
    qty_received_manual DECIMAL(15,4) DEFAULT 0,

    -- Política de facturación (heredada del producto o manual)
    purchase_method VARCHAR(20) NOT NULL DEFAULT 'receive'
        CHECK (purchase_method IN ('purchase', 'receive'));

-- Índices
CREATE INDEX idx_pol_qty_to_invoice ON core_purchasing.purchase_order_lines(order_id)
    WHERE qty_to_invoice > 0;

COMMENT ON COLUMN core_purchasing.purchase_order_lines.product_qty IS 'Cantidad ordenada al proveedor';
COMMENT ON COLUMN core_purchasing.purchase_order_lines.qty_received IS 'Cantidad recibida acumulada';
COMMENT ON COLUMN core_purchasing.purchase_order_lines.qty_invoiced IS 'Cantidad facturada acumulada';
COMMENT ON COLUMN core_purchasing.purchase_order_lines.qty_to_invoice IS 'Cantidad pendiente de facturar';
COMMENT ON COLUMN core_purchasing.purchase_order_lines.purchase_method IS 'receive=facturar recibido, purchase=facturar ordenado';
```

### 2.2 Campos en Orden de Compra

```sql
ALTER TABLE core_purchasing.purchase_orders ADD COLUMN IF NOT EXISTS
    invoice_status VARCHAR(20) NOT NULL DEFAULT 'no'
        CHECK (invoice_status IN ('no', 'to_invoice', 'invoiced'));

COMMENT ON COLUMN core_purchasing.purchase_orders.invoice_status IS
    'no=sin facturas pendientes, to_invoice=hay cantidad por facturar, invoiced=completamente facturado';
```

### 2.3 Campos en Línea de Factura

```sql
ALTER TABLE core_financial.invoice_lines ADD COLUMN IF NOT EXISTS
    purchase_line_id UUID REFERENCES core_purchasing.purchase_order_lines(id);

CREATE INDEX idx_invoice_lines_pol ON core_financial.invoice_lines(purchase_line_id);

COMMENT ON COLUMN core_financial.invoice_lines.purchase_line_id IS
    'Referencia a línea de PO para 3-way match';
```

### 2.4 Vista de Matching

```sql
CREATE OR REPLACE VIEW core_purchasing.v_bill_line_match AS
-- Líneas de PO con cantidad pendiente
SELECT
    'pol_' || pol.id AS id,
    pol.id AS purchase_line_id,
    NULL::UUID AS invoice_line_id,
    pol.order_id AS purchase_order_id,
    NULL::UUID AS invoice_id,
    po.partner_id,
    pol.product_id,
    pol.product_qty AS qty_ordered,
    pol.qty_received,
    pol.qty_invoiced,
    pol.qty_to_invoice,
    pol.price_unit,
    'pending' AS match_status
FROM core_purchasing.purchase_order_lines pol
JOIN core_purchasing.purchase_orders po ON po.id = pol.order_id
WHERE po.state IN ('purchase', 'done')
  AND pol.qty_to_invoice > 0

UNION ALL

-- Líneas de factura sin vincular
SELECT
    'aml_' || aml.id AS id,
    NULL::UUID AS purchase_line_id,
    aml.id AS invoice_line_id,
    NULL::UUID AS purchase_order_id,
    aml.invoice_id,
    inv.partner_id,
    aml.product_id,
    NULL::DECIMAL AS qty_ordered,
    NULL::DECIMAL AS qty_received,
    NULL::DECIMAL AS qty_invoiced,
    aml.quantity AS qty_to_invoice,
    aml.price_unit,
    'unmatched' AS match_status
FROM core_financial.invoice_lines aml
JOIN core_financial.invoices inv ON inv.id = aml.invoice_id
WHERE inv.type IN ('in_invoice', 'in_refund')
  AND inv.state IN ('draft', 'posted')
  AND aml.purchase_line_id IS NULL
  AND aml.display_type = 'product';
```

---

## 3. Políticas de Facturación

### 3.1 Política "Recibido" (receive) - Por Defecto

```
Regla: Solo se puede facturar lo que se ha recibido

qty_to_invoice = qty_received - qty_invoiced

Ejemplo:
  Ordenado: 100 unidades
  Recibido: 50 unidades
  Facturado: 0 unidades
  Pendiente: 50 unidades (solo se pueden facturar 50)

Uso típico:
  - Productos tangibles (materiales, insumos)
  - Control estricto de inventario
```

### 3.2 Política "Ordenado" (purchase)

```
Regla: Se puede facturar la cantidad ordenada sin esperar recepción

qty_to_invoice = product_qty - qty_invoiced

Ejemplo:
  Ordenado: 100 unidades
  Recibido: 0 unidades
  Facturado: 0 unidades
  Pendiente: 100 unidades (se pueden facturar las 100)

Uso típico:
  - Servicios (consultoría, honorarios)
  - Suscripciones
  - Productos digitales
```

### 3.3 Configuración por Producto

```typescript
// En producto
interface Product {
  purchaseMethod: 'receive' | 'purchase';
}

// Asignación automática según tipo
function getDefaultPurchaseMethod(productType: ProductType): PurchaseMethod {
  switch (productType) {
    case 'service':
      return 'purchase'; // Servicios: facturar al ordenar
    case 'consumable':
    case 'storable':
    default:
      return 'receive'; // Productos: facturar al recibir
  }
}
```

---

## 4. Diagrama de Flujo Detallado

```
┌─────────────────────────────────────────────────────────────────────┐
│                        CREAR ORDEN DE COMPRA                         │
│  state = 'draft' | product_qty = X | qty_received = 0               │
│  qty_invoiced = 0 | qty_to_invoice = 0 | invoice_status = 'no'      │
└─────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────┐
│                        CONFIRMAR PO                                  │
│  state = 'purchase' | invoice_status recalculado                    │
└─────────────────────────────────────────────────────────────────────┘
                                    │
           ┌────────────────────────┴────────────────────────┐
           ▼                                                  ▼
┌─────────────────────────┐                    ┌─────────────────────────┐
│ purchase_method='receive'│                   │ purchase_method='purchase'│
│ (Productos físicos)      │                   │ (Servicios)              │
└───────────┬─────────────┘                    └───────────┬─────────────┘
            │                                               │
            ▼                                               ▼
┌─────────────────────────┐                    ┌─────────────────────────┐
│    CREAR RECEPCIÓN      │                    │ qty_to_invoice = X      │
│  (Picking de entrada)   │                    │ invoice_status =        │
│  qty_received += X      │                    │   'to_invoice'          │
└───────────┬─────────────┘                    └───────────┬─────────────┘
            │                                               │
            ▼                                               │
┌─────────────────────────┐                                │
│ qty_to_invoice =        │                                │
│   qty_received -        │                                │
│   qty_invoiced          │                                │
│                         │                                │
│ invoice_status =        │                                │
│   'to_invoice'          │                                │
└───────────┬─────────────┘                                │
            │                                               │
            └───────────────────┬───────────────────────────┘
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────────┐
│                     CREAR FACTURA DE PROVEEDOR                       │
│  Desde PO o manual con vinculación                                  │
└─────────────────────────────────────────────────────────────────────┘
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────────┐
│                     VALIDAR 3-WAY MATCH                              │
│  ¿qty facturada <= qty_to_invoice?                                  │
└─────────────────────────────────────────────────────────────────────┘
                                │
              ┌─────────────────┼─────────────────┐
              ▼                 ▼                 ▼
        [Coincide]      [Menor cantidad]    [Mayor cantidad]
              │                 │                 │
              ▼                 ▼                 ▼
        [Aprobar]        [Aprobar         [Requiere
        [Factura]        (parcial)]        aprobación]
              │                 │                 │
              └─────────────────┼─────────────────┘
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────────┐
│                        POSTEAR FACTURA                               │
│  qty_invoiced += cantidad facturada                                 │
│  qty_to_invoice recalculado                                         │
│  invoice_status actualizado                                         │
└─────────────────────────────────────────────────────────────────────┘
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────────┐
│                        VERIFICAR ESTADO                              │
│  ¿Todas las líneas tienen qty_to_invoice = 0?                       │
└─────────────────────────────────────────────────────────────────────┘
                                │
              ┌─────────────────┴─────────────────┐
              ▼                                   ▼
        [Sí - Todo]                         [No - Parcial]
              │                                   │
              ▼                                   ▼
        invoice_status =                    invoice_status =
        'invoiced'                          'to_invoice'
```

---

## 5. Reglas de Negocio

### 5.1 Validaciones de Cantidad

| ID | Regla | Descripción | Acción |
|----|-------|-------------|--------|
| RN-3WM-001 | No facturar más de lo ordenado | qty_invoiced <= product_qty | Error |
| RN-3WM-002 | No facturar más de lo recibido (receive) | qty_invoiced <= qty_received | Error |
| RN-3WM-003 | Tolerancia configurable | Permitir ±X% de discrepancia | Warning |
| RN-3WM-004 | Precio debe coincidir | price_unit igual en PO y factura | Warning |

### 5.2 Configuración de Tolerancias

```typescript
interface ThreeWayMatchConfig {
  // Tolerancia de cantidad (porcentaje)
  quantityTolerance: number; // ej: 0.02 = 2%

  // Tolerancia de precio (porcentaje)
  priceTolerance: number; // ej: 0.01 = 1%

  // Requiere aprobación si excede tolerancia
  requireApprovalOnMismatch: boolean;

  // Bloquear factura si excede límite
  blockOnExceedLimit: boolean;
}

// Ejemplo de configuración por empresa
const config: ThreeWayMatchConfig = {
  quantityTolerance: 0.05, // 5%
  priceTolerance: 0.02,    // 2%
  requireApprovalOnMismatch: true,
  blockOnExceedLimit: false,
};
```

### 5.3 Cálculo de Discrepancia

```typescript
interface MatchResult {
  status: 'match' | 'within_tolerance' | 'mismatch';
  quantityDiff: number;
  quantityDiffPercent: number;
  priceDiff: number;
  priceDiffPercent: number;
  requiresApproval: boolean;
}

function validateThreeWayMatch(
  polLine: PurchaseOrderLine,
  invoiceLine: InvoiceLine,
  config: ThreeWayMatchConfig
): MatchResult {
  // Calcular diferencia de cantidad
  const expectedQty = polLine.purchaseMethod === 'receive'
    ? polLine.qtyReceived - polLine.qtyInvoiced
    : polLine.productQty - polLine.qtyInvoiced;

  const qtyDiff = invoiceLine.quantity - expectedQty;
  const qtyDiffPercent = Math.abs(qtyDiff / expectedQty);

  // Calcular diferencia de precio
  const priceDiff = invoiceLine.priceUnit - polLine.priceUnit;
  const priceDiffPercent = Math.abs(priceDiff / polLine.priceUnit);

  // Determinar estado
  let status: 'match' | 'within_tolerance' | 'mismatch';

  if (qtyDiff === 0 && priceDiff === 0) {
    status = 'match';
  } else if (
    qtyDiffPercent <= config.quantityTolerance &&
    priceDiffPercent <= config.priceTolerance
  ) {
    status = 'within_tolerance';
  } else {
    status = 'mismatch';
  }

  return {
    status,
    quantityDiff: qtyDiff,
    quantityDiffPercent: qtyDiffPercent,
    priceDiff,
    priceDiffPercent,
    requiresApproval: status === 'mismatch' && config.requireApprovalOnMismatch,
  };
}
```

---

## 6. Estados de Facturación

### 6.1 Cálculo de invoice_status

```typescript
function calculateInvoiceStatus(order: PurchaseOrder): InvoiceStatus {
  // Solo POs confirmadas pueden tener estado de facturación
  if (!['purchase', 'done'].includes(order.state)) {
    return 'no';
  }

  const precision = 4; // decimales

  // Verificar si hay líneas pendientes
  const hasPendingLines = order.lines.some(line => {
    if (line.displayType) return false; // Ignorar notas y secciones
    return round(line.qtyToInvoice, precision) !== 0;
  });

  if (hasPendingLines) {
    return 'to_invoice';
  }

  // Verificar si hay facturas vinculadas
  const hasInvoices = order.invoiceIds.length > 0;

  if (hasInvoices) {
    return 'invoiced';
  }

  return 'no';
}
```

### 6.2 Transiciones de Estado

```
                 ┌──────────────────────────────────┐
                 │                                  │
                 ▼                                  │
┌─────────┐   confirmar   ┌──────────────┐   recibir/facturar   ┌───────────┐
│   no    │ ─────────────▶│  to_invoice  │◀────────────────────▶│ invoiced  │
└─────────┘               └──────────────┘                      └───────────┘
     ▲                          │                                    │
     │                          │  cancelar factura                  │
     │                          ▼                                    │
     │                    ┌──────────┐                               │
     └────────────────────│ (volver) │◀──────────────────────────────┘
                          └──────────┘
```

---

## 7. Casos de Uso

### 7.1 UC-3WM-001: Facturar desde PO (Flujo Normal)

**Actor:** Usuario de Compras
**Precondición:** PO confirmada, productos recibidos

**Flujo Principal:**
1. Usuario navega a PO con estado 'to_invoice'
2. Usuario hace clic en "Crear Factura"
3. Sistema crea factura con líneas vinculadas a POL
4. Sistema calcula cantidades: qty_to_invoice
5. Usuario revisa y confirma factura
6. Sistema valida 3-way match
7. Sistema postea factura
8. Sistema actualiza qty_invoiced y invoice_status

### 7.2 UC-3WM-002: Vincular Factura Existente

**Actor:** Usuario de Compras
**Precondición:** Factura de proveedor recibida sin vincular

**Flujo Principal:**
1. Usuario abre factura en estado draft
2. Sistema muestra sugerencias de POs del proveedor
3. Usuario selecciona PO a vincular
4. Sistema carga líneas de PO con qty_to_invoice > 0
5. Usuario ajusta cantidades si necesario
6. Sistema valida 3-way match
7. Sistema vincula líneas (purchase_line_id)

### 7.3 UC-3WM-003: Manejar Discrepancia

**Actor:** Supervisor de Compras
**Precondición:** Factura con cantidad mayor a lo recibido

**Flujo Principal:**
1. Sistema detecta discrepancia > tolerancia
2. Sistema bloquea posteo de factura
3. Sistema notifica a supervisor
4. Supervisor revisa discrepancia
5. Opciones:
   a. Aprobar excepción (con motivo)
   b. Ajustar cantidad en factura
   c. Crear recepción adicional
   d. Rechazar factura

---

## 8. API REST

### 8.1 Endpoints

```yaml
# Crear factura desde PO
POST /api/v1/purchase-orders/{id}/create-invoice
Authorization: Bearer {token}

Response:
{
  "success": true,
  "data": {
    "invoice_id": "uuid",
    "lines": [
      {
        "purchase_line_id": "uuid",
        "quantity": 50,
        "price_unit": 100.00
      }
    ]
  }
}

# Obtener estado de facturación de PO
GET /api/v1/purchase-orders/{id}/invoice-status
Authorization: Bearer {token}

Response:
{
  "invoice_status": "to_invoice",
  "lines": [
    {
      "id": "uuid",
      "product_id": "uuid",
      "product_name": "Cemento",
      "product_qty": 100,
      "qty_received": 50,
      "qty_invoiced": 0,
      "qty_to_invoice": 50,
      "purchase_method": "receive"
    }
  ],
  "invoices": []
}

# Validar 3-way match antes de postear
POST /api/v1/invoices/{id}/validate-3way-match
Authorization: Bearer {token}

Response:
{
  "valid": false,
  "lines": [
    {
      "invoice_line_id": "uuid",
      "purchase_line_id": "uuid",
      "status": "mismatch",
      "quantity_diff": 10,
      "quantity_diff_percent": 0.20,
      "price_diff": 0,
      "requires_approval": true,
      "message": "Cantidad facturada (60) excede cantidad pendiente (50) en 20%"
    }
  ]
}

# Aprobar discrepancia
POST /api/v1/invoices/{id}/approve-mismatch
Authorization: Bearer {token}

Request:
{
  "reason": "Proveedor entregó cantidad adicional como compensación",
  "approved_lines": ["uuid1", "uuid2"]
}

# Vincular factura a PO
POST /api/v1/invoices/{id}/link-purchase-order
Authorization: Bearer {token}

Request:
{
  "purchase_order_id": "uuid",
  "line_mappings": [
    {
      "invoice_line_id": "uuid",
      "purchase_line_id": "uuid",
      "quantity": 50
    }
  ]
}
```

### 8.2 Permisos

| Endpoint | Permiso Requerido |
|----------|-------------------|
| POST create-invoice | `purchasing:invoice` |
| GET invoice-status | `purchasing:read` |
| POST validate-3way-match | `purchasing:invoice` |
| POST approve-mismatch | `purchasing:approve_mismatch` |
| POST link-purchase-order | `purchasing:invoice` |

---

## 9. Integración con Inventario

### 9.1 Actualización Automática de qty_received

Cuando se valida un picking de recepción:

```typescript
// En StockPickingService
async validatePicking(pickingId: string): Promise<void> {
  const picking = await this.findById(pickingId);

  // Actualizar qty_received en líneas de PO
  for (const move of picking.moves) {
    if (move.purchaseLineId) {
      await this.purchaseLineService.updateQtyReceived(
        move.purchaseLineId,
        move.productQty
      );
    }
  }
}

// En PurchaseOrderLineService
async updateQtyReceived(lineId: string, quantity: number): Promise<void> {
  const line = await this.findById(lineId);

  // Acumular cantidad recibida
  line.qtyReceived = (line.qtyReceived || 0) + quantity;

  // Recalcular qty_to_invoice (si purchase_method = 'receive')
  if (line.purchaseMethod === 'receive') {
    line.qtyToInvoice = line.qtyReceived - line.qtyInvoiced;
  }

  await this.save(line);

  // Recalcular invoice_status de la PO
  await this.purchaseOrderService.updateInvoiceStatus(line.orderId);
}
```

---

## 10. Reportes

### 10.1 Reporte de Estado de 3-Way Match

```sql
SELECT
    po.name AS po_number,
    po.date_order,
    pol.product_id,
    p.name AS product_name,
    pol.product_qty AS qty_ordered,
    pol.qty_received,
    pol.qty_invoiced,
    pol.qty_to_invoice,
    pol.purchase_method,
    CASE
        WHEN pol.qty_to_invoice > 0 THEN 'Pendiente de facturar'
        WHEN pol.qty_to_invoice < 0 THEN 'Sobre-facturado'
        ELSE 'Completo'
    END AS status
FROM core_purchasing.purchase_order_lines pol
JOIN core_purchasing.purchase_orders po ON po.id = pol.order_id
JOIN core_inventory.products p ON p.id = pol.product_id
WHERE po.state IN ('purchase', 'done')
ORDER BY po.date_order DESC, po.name;
```

### 10.2 KPIs de 3-Way Match

| KPI | Fórmula | Meta |
|-----|---------|------|
| Tasa de Match Perfecto | Facturas sin discrepancia / Total facturas | > 95% |
| Tiempo promedio de resolución | Días desde discrepancia hasta resolución | < 3 días |
| Monto en discrepancias | Suma de diferencias pendientes | < 1% de compras |

---

## 11. Mensajes de Error

| Código | Mensaje | Contexto |
|--------|---------|----------|
| 3WM_001 | "Cantidad excede lo pendiente de facturar" | qty > qty_to_invoice |
| 3WM_002 | "No hay cantidad pendiente de facturar" | qty_to_invoice = 0 |
| 3WM_003 | "Precio no coincide con orden de compra" | price_unit diferente |
| 3WM_004 | "Producto no encontrado en orden de compra" | product_id no match |
| 3WM_005 | "Orden de compra no confirmada" | state != 'purchase' |
| 3WM_006 | "Requiere aprobación de supervisor" | Discrepancia > tolerancia |

---

## 12. Referencias

- **Odoo purchase_order_line.py:** Campos de control
- **Odoo account_invoice.py:** Algoritmos de matching
- **MGN-006:** Módulo Compras Básico
- **MGN-005:** Módulo Inventario (integración)
- **MGN-004:** Módulo Financiero (facturas)

---

**Documento creado por:** Requirements-Analyst
**Fecha:** 2025-12-08
