erp-core/docs/04-modelado/workflows/WORKFLOW-3-WAY-MATCH.md

26 KiB

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

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

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

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

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

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

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

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

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

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

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

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