26 KiB
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:
- Orden de Compra (PO): Cantidad ordenada
- Recepción (Receipt): Cantidad recibida
- 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:
- Usuario navega a PO con estado 'to_invoice'
- Usuario hace clic en "Crear Factura"
- Sistema crea factura con líneas vinculadas a POL
- Sistema calcula cantidades: qty_to_invoice
- Usuario revisa y confirma factura
- Sistema valida 3-way match
- Sistema postea factura
- 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:
- Usuario abre factura en estado draft
- Sistema muestra sugerencias de POs del proveedor
- Usuario selecciona PO a vincular
- Sistema carga líneas de PO con qty_to_invoice > 0
- Usuario ajusta cantidades si necesario
- Sistema valida 3-way match
- 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:
- Sistema detecta discrepancia > tolerancia
- Sistema bloquea posteo de factura
- Sistema notifica a supervisor
- Supervisor revisa discrepancia
- 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