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