83 KiB
83 KiB
SPEC-BLANKET-ORDERS: Acuerdos Marco y Órdenes Abiertas
Metadata
- Código: SPEC-BLANKET-ORDERS
- Versión: 1.0.0
- Fecha: 2025-01-15
- Gap Relacionado: GAP-MGN-006-002
- Módulo: MGN-006 (Compras)
- Prioridad: P1
- Story Points: 13
- Odoo Referencia: purchase_requisition, purchase_requisition_stock
1. Resumen Ejecutivo
1.1 Descripción del Gap
El módulo de compras actual carece de funcionalidad para gestionar acuerdos marco (blanket orders) con proveedores, que permiten negociar precios y cantidades comprometidas para un período determinado, y crear órdenes de compra parciales contra ese acuerdo.
1.2 Impacto en el Negocio
| Aspecto | Sin Acuerdos Marco | Con Acuerdos Marco |
|---|---|---|
| Negociación | Por cada orden individual | Una vez por período |
| Precios | Variables según demanda | Fijos y predecibles |
| Gestión proveedores | Transaccional | Estratégica |
| Planificación | Reactiva | Proactiva |
| Descuentos | Por volumen individual | Por compromiso anual |
| Cumplimiento | No medible | Trazable vs comprometido |
1.3 Objetivos de la Especificación
- Definir modelo de datos para acuerdos marco y plantillas de compra
- Implementar flujo de estados del acuerdo (borrador → confirmado → cerrado)
- Crear mecanismo de tracking de cantidades ordenadas vs comprometidas
- Soportar proceso de licitación con comparación de ofertas
- Integrar con reabastecimiento automático de inventario
2. Arquitectura de Datos
2.1 Diagrama Entidad-Relación
┌─────────────────────────────────────────────────────────────────────────┐
│ BLANKET ORDER SYSTEM │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ ┌─────────────────────┐ │
│ │ blanket_orders │─────────│blanket_order_lines │ │
│ │ │ 1 N │ │ │
│ │ - vendor_id │ │ - product_id │ │
│ │ - order_type │ │ - quantity │ │
│ │ - date_start │ │ - price_unit │ │
│ │ - date_end │ │ - qty_ordered │ │
│ │ - state │ │ - supplier_info_id │ │
│ └────────┬─────────┘ └──────────┬──────────┘ │
│ │ │ │
│ │ 1 │ │
│ │ ▼ │
│ ▼ ┌─────────────────────┐ │
│ ┌──────────────────┐ │product_supplier_info│ │
│ │ purchase_orders │ │ │ │
│ │ │ │ - blanket_order_id │ │
│ │ - blanket_id │ │ - blanket_line_id │ │
│ │ - group_id │ │ - price │ │
│ └────────┬─────────┘ └─────────────────────┘ │
│ │ │
│ │ N │
│ ▼ │
│ ┌──────────────────┐ │
│ │ po_groups │ (Para gestión de licitaciones) │
│ │ │ │
│ │ - order_ids[] │ │
│ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
2.2 Definición de Tablas
-- =============================================================================
-- SCHEMA: purchasing
-- =============================================================================
-- -----------------------------------------------------------------------------
-- Tabla: blanket_orders (Acuerdos Marco)
-- -----------------------------------------------------------------------------
CREATE TABLE purchasing.blanket_orders (
-- Identificación
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
code VARCHAR(20) NOT NULL,
name VARCHAR(200),
reference VARCHAR(100),
-- Tipo de acuerdo
order_type VARCHAR(20) NOT NULL DEFAULT 'blanket_order',
-- 'blanket_order': Acuerdo marco con proveedor fijo
-- 'purchase_template': Plantilla de compra sin proveedor definido
-- Partes involucradas
vendor_id UUID REFERENCES core.partners(id),
buyer_id UUID NOT NULL REFERENCES core.users(id),
company_id UUID NOT NULL REFERENCES core.companies(id),
-- Período de vigencia
date_start DATE,
date_end DATE,
-- Configuración
currency_id UUID NOT NULL REFERENCES core.currencies(id),
warehouse_id UUID REFERENCES inventory.warehouses(id),
picking_type_id UUID REFERENCES inventory.picking_types(id),
-- Estado
state VARCHAR(20) NOT NULL DEFAULT 'draft',
-- 'draft': En negociación/edición
-- 'confirmed': Activo y vigente
-- 'done': Completado/Cerrado
-- 'cancelled': Cancelado
-- Términos y condiciones
terms_conditions TEXT,
notes TEXT,
-- Auditoría
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by UUID NOT NULL REFERENCES core.users(id),
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_by UUID NOT NULL REFERENCES core.users(id),
-- Restricciones
CONSTRAINT chk_order_type CHECK (order_type IN ('blanket_order', 'purchase_template')),
CONSTRAINT chk_state CHECK (state IN ('draft', 'confirmed', 'done', 'cancelled')),
CONSTRAINT chk_dates CHECK (date_end IS NULL OR date_start IS NULL OR date_end >= date_start),
CONSTRAINT uq_blanket_code_company UNIQUE (code, company_id)
);
-- Índices para blanket_orders
CREATE INDEX idx_blanket_orders_vendor ON purchasing.blanket_orders(vendor_id);
CREATE INDEX idx_blanket_orders_state ON purchasing.blanket_orders(state);
CREATE INDEX idx_blanket_orders_dates ON purchasing.blanket_orders(date_start, date_end);
CREATE INDEX idx_blanket_orders_company ON purchasing.blanket_orders(company_id);
CREATE INDEX idx_blanket_orders_type_state ON purchasing.blanket_orders(order_type, state);
-- Trigger para generación de código automático
CREATE OR REPLACE FUNCTION purchasing.generate_blanket_order_code()
RETURNS TRIGGER AS $$
DECLARE
v_prefix VARCHAR(2);
v_sequence INTEGER;
BEGIN
IF NEW.code IS NULL OR NEW.code = '' THEN
-- Prefijo según tipo
v_prefix := CASE NEW.order_type
WHEN 'blanket_order' THEN 'BO'
WHEN 'purchase_template' THEN 'PT'
ELSE 'XX'
END;
-- Obtener siguiente número de secuencia
SELECT COALESCE(MAX(
CAST(SUBSTRING(code FROM 3) AS INTEGER)
), 0) + 1
INTO v_sequence
FROM purchasing.blanket_orders
WHERE code LIKE v_prefix || '%'
AND company_id = NEW.company_id;
NEW.code := v_prefix || LPAD(v_sequence::TEXT, 5, '0');
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_blanket_order_code
BEFORE INSERT ON purchasing.blanket_orders
FOR EACH ROW
EXECUTE FUNCTION purchasing.generate_blanket_order_code();
-- -----------------------------------------------------------------------------
-- Tabla: blanket_order_lines (Líneas del Acuerdo Marco)
-- -----------------------------------------------------------------------------
CREATE TABLE purchasing.blanket_order_lines (
-- Identificación
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
blanket_order_id UUID NOT NULL REFERENCES purchasing.blanket_orders(id) ON DELETE CASCADE,
sequence INTEGER NOT NULL DEFAULT 10,
-- Producto
product_id UUID NOT NULL REFERENCES inventory.products(id),
product_uom_id UUID NOT NULL REFERENCES inventory.units_of_measure(id),
description TEXT,
-- Cantidades
product_qty NUMERIC(18,4) NOT NULL DEFAULT 0,
qty_ordered NUMERIC(18,4) NOT NULL DEFAULT 0, -- Calculado
qty_remaining NUMERIC(18,4) GENERATED ALWAYS AS (product_qty - qty_ordered) STORED,
-- Precio
price_unit NUMERIC(18,6) NOT NULL DEFAULT 0,
-- Distribución analítica
analytic_distribution JSONB,
-- Vinculación con inventario
move_dest_id UUID REFERENCES inventory.stock_moves(id),
-- Referencia a supplier info creado
supplier_info_id UUID REFERENCES inventory.product_supplier_info(id),
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by UUID NOT NULL REFERENCES core.users(id),
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_by UUID NOT NULL REFERENCES core.users(id),
-- Restricciones
CONSTRAINT chk_product_qty_positive CHECK (product_qty >= 0),
CONSTRAINT chk_price_unit_positive CHECK (price_unit >= 0)
);
-- Índices para blanket_order_lines
CREATE INDEX idx_blanket_lines_order ON purchasing.blanket_order_lines(blanket_order_id);
CREATE INDEX idx_blanket_lines_product ON purchasing.blanket_order_lines(product_id);
CREATE INDEX idx_blanket_lines_supplier_info ON purchasing.blanket_order_lines(supplier_info_id);
-- -----------------------------------------------------------------------------
-- Tabla: purchase_order_groups (Agrupación para Licitaciones)
-- -----------------------------------------------------------------------------
CREATE TABLE purchasing.purchase_order_groups (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100),
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by UUID NOT NULL REFERENCES core.users(id)
);
-- -----------------------------------------------------------------------------
-- Extensión: purchase_orders (Agregar campos para blanket orders)
-- -----------------------------------------------------------------------------
ALTER TABLE purchasing.purchase_orders
ADD COLUMN IF NOT EXISTS blanket_order_id UUID REFERENCES purchasing.blanket_orders(id),
ADD COLUMN IF NOT EXISTS purchase_group_id UUID REFERENCES purchasing.purchase_order_groups(id);
CREATE INDEX IF NOT EXISTS idx_po_blanket ON purchasing.purchase_orders(blanket_order_id);
CREATE INDEX IF NOT EXISTS idx_po_group ON purchasing.purchase_orders(purchase_group_id);
-- Vista: alternative_purchase_orders (Órdenes alternativas en mismo grupo)
CREATE OR REPLACE VIEW purchasing.alternative_purchase_orders AS
SELECT
po.id AS purchase_order_id,
alt.id AS alternative_id,
alt.code AS alternative_code,
alt.vendor_id AS alternative_vendor_id,
v.name AS alternative_vendor_name,
alt.amount_total AS alternative_total,
alt.state AS alternative_state
FROM purchasing.purchase_orders po
JOIN purchasing.purchase_orders alt
ON alt.purchase_group_id = po.purchase_group_id
AND alt.id != po.id
LEFT JOIN core.partners v ON v.id = alt.vendor_id
WHERE po.purchase_group_id IS NOT NULL;
-- -----------------------------------------------------------------------------
-- Extensión: product_supplier_info (Agregar referencia a blanket order)
-- -----------------------------------------------------------------------------
ALTER TABLE inventory.product_supplier_info
ADD COLUMN IF NOT EXISTS blanket_order_id UUID REFERENCES purchasing.blanket_orders(id),
ADD COLUMN IF NOT EXISTS blanket_order_line_id UUID REFERENCES purchasing.blanket_order_lines(id);
CREATE INDEX IF NOT EXISTS idx_supplier_info_blanket
ON inventory.product_supplier_info(blanket_order_id);
2.3 Función: Calcular Cantidad Ordenada
-- Función que calcula qty_ordered agregando desde POs confirmadas
CREATE OR REPLACE FUNCTION purchasing.calculate_blanket_line_qty_ordered(
p_blanket_line_id UUID
)
RETURNS NUMERIC AS $$
DECLARE
v_total_qty NUMERIC := 0;
v_blanket_order_id UUID;
v_product_id UUID;
v_target_uom_id UUID;
BEGIN
-- Obtener datos de la línea del acuerdo marco
SELECT
bol.blanket_order_id,
bol.product_id,
bol.product_uom_id
INTO v_blanket_order_id, v_product_id, v_target_uom_id
FROM purchasing.blanket_order_lines bol
WHERE bol.id = p_blanket_line_id;
IF NOT FOUND THEN
RETURN 0;
END IF;
-- Sumar cantidades de líneas de PO confirmadas/completadas
SELECT COALESCE(SUM(
CASE
WHEN pol.product_uom_id = v_target_uom_id THEN pol.product_qty
ELSE inventory.convert_uom_qty(
pol.product_qty,
pol.product_uom_id,
v_target_uom_id,
v_product_id
)
END
), 0)
INTO v_total_qty
FROM purchasing.purchase_order_lines pol
JOIN purchasing.purchase_orders po ON po.id = pol.purchase_order_id
WHERE po.blanket_order_id = v_blanket_order_id
AND po.state IN ('purchase', 'done')
AND pol.product_id = v_product_id;
RETURN v_total_qty;
END;
$$ LANGUAGE plpgsql;
-- Trigger para actualizar qty_ordered cuando cambia estado de PO
CREATE OR REPLACE FUNCTION purchasing.update_blanket_qty_ordered()
RETURNS TRIGGER AS $$
BEGIN
-- Si la PO tiene blanket_order_id y cambió de estado
IF NEW.blanket_order_id IS NOT NULL AND
(TG_OP = 'INSERT' OR OLD.state != NEW.state) THEN
-- Actualizar todas las líneas del acuerdo que coinciden con productos de la PO
UPDATE purchasing.blanket_order_lines bol
SET qty_ordered = purchasing.calculate_blanket_line_qty_ordered(bol.id),
updated_at = CURRENT_TIMESTAMP
WHERE bol.blanket_order_id = NEW.blanket_order_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_po_update_blanket_qty
AFTER INSERT OR UPDATE OF state ON purchasing.purchase_orders
FOR EACH ROW
EXECUTE FUNCTION purchasing.update_blanket_qty_ordered();
3. Máquina de Estados
3.1 Diagrama de Estados del Acuerdo Marco
┌─────────────────────────────┐
│ DRAFT │
│ • Edición libre │
│ • Sin supplier_info │
│ • Sin validaciones precio │
└──────────────┬──────────────┘
│
action_confirm() │
│ Validaciones:
│ • Al menos 1 línea
│ • price_unit > 0 (blanket_order)
│ • product_qty > 0 (blanket_order)
▼
┌─────────────────────────────┐
│ CONFIRMED │
│ • Activo y vigente │
│ • supplier_info creado │
│ • Permite crear POs │
│ • Campos principales R/O │
┌───────────────┴──────────────┬──────────────┴───────────────┐
│ │ │
action_cancel() action_done() Nueva PO
│ │ │
▼ ▼ ▼
┌─────────────────────────┐ ┌─────────────────────────┐ ┌────────────────────┐
│ CANCELLED │ │ DONE │ │ purchase_orders │
│ • supplier_info borrado│ │ • Sin POs pendientes │ │ blanket_order_id │
│ • POs canceladas │ │ • supplier_info borrado│ │ = this.id │
│ • Puede volver a draft │ │ • Histórico/referencia │ └────────────────────┘
└─────────────────────────┘ └─────────────────────────┘
│
action_draft()
│
└──────────────────────────────────────────────────────────────────►
3.2 Transiciones de Estado
# services/blanket_order_service.py
from enum import Enum
from typing import Optional, List
from datetime import date
from uuid import UUID
from dataclasses import dataclass
class BlanketOrderState(str, Enum):
DRAFT = "draft"
CONFIRMED = "confirmed"
DONE = "done"
CANCELLED = "cancelled"
class BlanketOrderType(str, Enum):
BLANKET_ORDER = "blanket_order"
PURCHASE_TEMPLATE = "purchase_template"
@dataclass
class ValidationError:
field: str
message: str
code: str
class BlanketOrderStateMachine:
"""Máquina de estados para Acuerdos Marco."""
ALLOWED_TRANSITIONS = {
BlanketOrderState.DRAFT: [BlanketOrderState.CONFIRMED, BlanketOrderState.CANCELLED],
BlanketOrderState.CONFIRMED: [BlanketOrderState.DONE, BlanketOrderState.CANCELLED],
BlanketOrderState.DONE: [], # Terminal
BlanketOrderState.CANCELLED: [BlanketOrderState.DRAFT],
}
def __init__(self, blanket_order: 'BlanketOrder'):
self.blanket_order = blanket_order
def can_transition_to(self, new_state: BlanketOrderState) -> bool:
"""Verifica si la transición es permitida."""
current = BlanketOrderState(self.blanket_order.state)
return new_state in self.ALLOWED_TRANSITIONS.get(current, [])
def validate_confirm(self) -> List[ValidationError]:
"""Validaciones para confirmar el acuerdo."""
errors = []
bo = self.blanket_order
# Debe tener al menos una línea
if not bo.lines or len(bo.lines) == 0:
errors.append(ValidationError(
field="lines",
message="El acuerdo debe contener al menos una línea de producto",
code="BLANKET_NO_LINES"
))
# Validaciones específicas para blanket_order
if bo.order_type == BlanketOrderType.BLANKET_ORDER:
# Proveedor requerido
if not bo.vendor_id:
errors.append(ValidationError(
field="vendor_id",
message="Se requiere un proveedor para acuerdos marco",
code="BLANKET_NO_VENDOR"
))
# Todas las líneas deben tener precio y cantidad
for idx, line in enumerate(bo.lines):
if line.price_unit <= 0:
errors.append(ValidationError(
field=f"lines[{idx}].price_unit",
message=f"Línea {idx + 1}: El precio debe ser mayor a 0",
code="BLANKET_LINE_INVALID_PRICE"
))
if line.product_qty <= 0:
errors.append(ValidationError(
field=f"lines[{idx}].product_qty",
message=f"Línea {idx + 1}: La cantidad debe ser mayor a 0",
code="BLANKET_LINE_INVALID_QTY"
))
return errors
def validate_done(self) -> List[ValidationError]:
"""Validaciones para cerrar el acuerdo."""
errors = []
bo = self.blanket_order
# No debe haber POs en estados pendientes
pending_states = ['draft', 'sent', 'to_approve']
pending_pos = [
po for po in bo.purchase_orders
if po.state in pending_states
]
if pending_pos:
errors.append(ValidationError(
field="purchase_orders",
message=f"Hay {len(pending_pos)} órdenes de compra pendientes. "
"Confirme o cancele antes de cerrar el acuerdo.",
code="BLANKET_PENDING_POS"
))
return errors
class BlanketOrderService:
"""Servicio para gestión de Acuerdos Marco."""
def __init__(self, db_session, supplier_info_service, purchase_order_service):
self.db = db_session
self.supplier_info_service = supplier_info_service
self.po_service = purchase_order_service
async def confirm(self, blanket_order_id: UUID, user_id: UUID) -> 'BlanketOrder':
"""
Confirma un acuerdo marco.
Acciones:
1. Valida el acuerdo
2. Crea supplier_info para cada línea
3. Cambia estado a 'confirmed'
"""
blanket_order = await self._get_blanket_order(blanket_order_id)
state_machine = BlanketOrderStateMachine(blanket_order)
# Verificar transición permitida
if not state_machine.can_transition_to(BlanketOrderState.CONFIRMED):
raise InvalidStateTransitionError(
f"No se puede confirmar desde estado '{blanket_order.state}'"
)
# Validaciones
errors = state_machine.validate_confirm()
if errors:
raise ValidationException(errors)
# Advertencia si ya existe acuerdo abierto con mismo proveedor
await self._warn_existing_blanket_order(blanket_order)
# Crear supplier_info para cada línea (solo blanket_order)
if blanket_order.order_type == BlanketOrderType.BLANKET_ORDER:
for line in blanket_order.lines:
supplier_info = await self.supplier_info_service.create({
'partner_id': blanket_order.vendor_id,
'product_id': line.product_id,
'price': line.price_unit,
'currency_id': blanket_order.currency_id,
'blanket_order_id': blanket_order.id,
'blanket_order_line_id': line.id,
'date_start': blanket_order.date_start,
'date_end': blanket_order.date_end,
})
line.supplier_info_id = supplier_info.id
# Actualizar estado
blanket_order.state = BlanketOrderState.CONFIRMED.value
blanket_order.updated_by = user_id
await self.db.commit()
# Emitir evento
await self._emit_event('blanket_order.confirmed', blanket_order)
return blanket_order
async def close(self, blanket_order_id: UUID, user_id: UUID) -> 'BlanketOrder':
"""
Cierra un acuerdo marco (estado 'done').
Acciones:
1. Valida que no haya POs pendientes
2. Elimina supplier_info
3. Cambia estado a 'done'
"""
blanket_order = await self._get_blanket_order(blanket_order_id)
state_machine = BlanketOrderStateMachine(blanket_order)
if not state_machine.can_transition_to(BlanketOrderState.DONE):
raise InvalidStateTransitionError(
f"No se puede cerrar desde estado '{blanket_order.state}'"
)
errors = state_machine.validate_done()
if errors:
raise ValidationException(errors)
# Eliminar supplier_info asociados
for line in blanket_order.lines:
if line.supplier_info_id:
await self.supplier_info_service.delete(line.supplier_info_id)
line.supplier_info_id = None
blanket_order.state = BlanketOrderState.DONE.value
blanket_order.updated_by = user_id
await self.db.commit()
await self._emit_event('blanket_order.done', blanket_order)
return blanket_order
async def cancel(self, blanket_order_id: UUID, user_id: UUID) -> 'BlanketOrder':
"""
Cancela un acuerdo marco.
Acciones:
1. Elimina supplier_info
2. Cancela POs asociadas
3. Cambia estado a 'cancelled'
"""
blanket_order = await self._get_blanket_order(blanket_order_id)
state_machine = BlanketOrderStateMachine(blanket_order)
if not state_machine.can_transition_to(BlanketOrderState.CANCELLED):
raise InvalidStateTransitionError(
f"No se puede cancelar desde estado '{blanket_order.state}'"
)
# Eliminar supplier_info
for line in blanket_order.lines:
if line.supplier_info_id:
await self.supplier_info_service.delete(line.supplier_info_id)
line.supplier_info_id = None
# Cancelar POs asociadas en estados draft/sent
for po in blanket_order.purchase_orders:
if po.state in ['draft', 'sent', 'to_approve']:
await self.po_service.cancel(po.id, user_id)
blanket_order.state = BlanketOrderState.CANCELLED.value
blanket_order.updated_by = user_id
await self.db.commit()
await self._emit_event('blanket_order.cancelled', blanket_order)
return blanket_order
async def reset_to_draft(self, blanket_order_id: UUID, user_id: UUID) -> 'BlanketOrder':
"""Restablece un acuerdo cancelado a borrador."""
blanket_order = await self._get_blanket_order(blanket_order_id)
state_machine = BlanketOrderStateMachine(blanket_order)
if not state_machine.can_transition_to(BlanketOrderState.DRAFT):
raise InvalidStateTransitionError(
f"No se puede restablecer desde estado '{blanket_order.state}'"
)
blanket_order.state = BlanketOrderState.DRAFT.value
blanket_order.updated_by = user_id
await self.db.commit()
return blanket_order
async def _warn_existing_blanket_order(self, blanket_order: 'BlanketOrder'):
"""Verifica si existe otro acuerdo abierto con el mismo proveedor."""
if blanket_order.order_type != BlanketOrderType.BLANKET_ORDER:
return
existing = await self.db.execute(
"""
SELECT id, code FROM purchasing.blanket_orders
WHERE vendor_id = :vendor_id
AND state = 'confirmed'
AND order_type = 'blanket_order'
AND company_id = :company_id
AND id != :current_id
""",
{
'vendor_id': blanket_order.vendor_id,
'company_id': blanket_order.company_id,
'current_id': blanket_order.id
}
)
if existing:
# Log warning pero no bloquear
logger.warning(
f"Ya existe un acuerdo marco abierto ({existing.code}) "
f"con el proveedor {blanket_order.vendor_id}"
)
4. Proceso de Licitación
4.1 Flujo de Call-for-Tenders
┌─────────────────────────────────────────────────────────────────────────────┐
│ PROCESO DE LICITACIÓN (CALL-FOR-TENDERS) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. CREACIÓN │
│ ┌─────────────┐ │
│ │ RFQ Inicial │ ◄─── Crear solicitud de cotización │
│ │ Proveedor A │ │
│ └──────┬──────┘ │
│ │ │
│ 2. ALTERNATIVAS │
│ ▼ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ RFQ Alt. 1 │ │ RFQ Alt. 2 │ │ RFQ Alt. 3 │ │
│ │ Proveedor B │ │ Proveedor C │ │ Proveedor D │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ └───────────────────┼───────────────────┘ │
│ │ │
│ 3. COMPARACIÓN ▼ │
│ ┌─────────────────────┐ │
│ │ COMPARAR OFERTAS │ │
│ │ │ │
│ │ Producto Best │ │
│ │ ───────────────── │ │
│ │ Widget A $10 (B) │ ◄── Mejor precio total │
│ │ Widget B $15 (C) │ ◄── Mejor precio unitario │
│ │ Widget C $20 (A) │ ◄── Mejor fecha entrega │
│ └─────────┬───────────┘ │
│ │ │
│ 4. ADJUDICACIÓN ▼ │
│ ┌─────────────────────┐ │
│ │ SELECCIONAR GANADOR │ │
│ │ │ │
│ │ [x] Proveedor B │ │
│ │ [ ] Proveedor C │ │
│ │ [ ] Proveedor D │ │
│ └─────────┬───────────┘ │
│ │ │
│ 5. CONFIRMACIÓN ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ ¿Qué hacer con alternativas? │ │
│ │ │ │
│ │ [Mantener] [Cancelar otras] │ │
│ └─────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
4.2 Servicio de Comparación de Ofertas
# services/tender_comparison_service.py
from dataclasses import dataclass
from typing import List, Dict, Optional
from uuid import UUID
from decimal import Decimal
from datetime import date
@dataclass
class TenderLineComparison:
"""Resultado de comparación de una línea de producto."""
product_id: UUID
product_name: str
# Mejor precio total (cantidad * precio)
best_total_price_line_ids: List[UUID]
best_total_price_value: Decimal
best_total_price_vendor: str
# Mejor precio unitario
best_unit_price_line_ids: List[UUID]
best_unit_price_value: Decimal
best_unit_price_vendor: str
# Mejor fecha de entrega
best_date_line_ids: List[UUID]
best_date_value: Optional[date]
best_date_vendor: str
@dataclass
class TenderComparison:
"""Resultado completo de comparación de ofertas."""
purchase_order_id: UUID
alternative_ids: List[UUID]
line_comparisons: List[TenderLineComparison]
summary: Dict
class TenderComparisonService:
"""Servicio para comparar ofertas de licitación."""
def __init__(self, db_session, currency_service):
self.db = db_session
self.currency_service = currency_service
async def compare_alternatives(
self,
purchase_order_id: UUID
) -> TenderComparison:
"""
Compara ofertas alternativas para identificar las mejores.
Algoritmo:
1. Obtener PO principal y alternativas
2. Agrupar líneas por producto
3. Para cada producto:
- Encontrar mejor precio total
- Encontrar mejor precio unitario
- Encontrar mejor fecha de entrega
4. Manejar empates (múltiples ganadores)
Returns:
TenderComparison con resultados de comparación
"""
# Obtener PO y alternativas
main_po = await self._get_purchase_order(purchase_order_id)
if not main_po.purchase_group_id:
raise ValueError("La orden de compra no tiene alternativas")
all_pos = await self.db.execute(
"""
SELECT po.*, v.name as vendor_name
FROM purchasing.purchase_orders po
LEFT JOIN core.partners v ON v.id = po.vendor_id
WHERE po.purchase_group_id = :group_id
AND po.state NOT IN ('cancel', 'purchase', 'done')
""",
{'group_id': main_po.purchase_group_id}
)
# Agrupar todas las líneas por producto
product_lines: Dict[UUID, List] = {}
for po in all_pos:
lines = await self._get_po_lines(po.id)
for line in lines:
if not line.product_qty or line.product_qty <= 0:
continue
if not line.price_total_cc or line.price_total_cc <= 0:
continue
if line.product_id not in product_lines:
product_lines[line.product_id] = []
product_lines[line.product_id].append({
'line': line,
'po': po,
'vendor_name': po.vendor_name,
'total_price': line.price_total_cc,
'unit_price': line.price_total_cc / line.product_qty,
'date_planned': line.date_planned
})
# Comparar cada producto
comparisons = []
for product_id, lines_data in product_lines.items():
comparison = await self._compare_product_lines(product_id, lines_data)
comparisons.append(comparison)
# Generar resumen
summary = self._generate_summary(comparisons, all_pos)
return TenderComparison(
purchase_order_id=purchase_order_id,
alternative_ids=[po.id for po in all_pos if po.id != purchase_order_id],
line_comparisons=comparisons,
summary=summary
)
async def _compare_product_lines(
self,
product_id: UUID,
lines_data: List[Dict]
) -> TenderLineComparison:
"""Compara líneas de un producto específico."""
product = await self._get_product(product_id)
# Inicializar mejores valores
best_total = {'value': Decimal('Infinity'), 'lines': [], 'vendor': ''}
best_unit = {'value': Decimal('Infinity'), 'lines': [], 'vendor': ''}
best_date = {'value': None, 'lines': [], 'vendor': ''}
for data in lines_data:
# Mejor precio total
if data['total_price'] < best_total['value']:
best_total = {
'value': data['total_price'],
'lines': [data['line'].id],
'vendor': data['vendor_name']
}
elif data['total_price'] == best_total['value']:
best_total['lines'].append(data['line'].id)
# Mejor precio unitario
if data['unit_price'] < best_unit['value']:
best_unit = {
'value': data['unit_price'],
'lines': [data['line'].id],
'vendor': data['vendor_name']
}
elif data['unit_price'] == best_unit['value']:
best_unit['lines'].append(data['line'].id)
# Mejor fecha de entrega
if data['date_planned']:
if best_date['value'] is None or data['date_planned'] < best_date['value']:
best_date = {
'value': data['date_planned'],
'lines': [data['line'].id],
'vendor': data['vendor_name']
}
elif data['date_planned'] == best_date['value']:
best_date['lines'].append(data['line'].id)
return TenderLineComparison(
product_id=product_id,
product_name=product.name,
best_total_price_line_ids=best_total['lines'],
best_total_price_value=best_total['value'],
best_total_price_vendor=best_total['vendor'],
best_unit_price_line_ids=best_unit['lines'],
best_unit_price_value=best_unit['value'],
best_unit_price_vendor=best_unit['vendor'],
best_date_line_ids=best_date['lines'],
best_date_value=best_date['value'],
best_date_vendor=best_date['vendor']
)
def _generate_summary(
self,
comparisons: List[TenderLineComparison],
all_pos: List
) -> Dict:
"""Genera resumen ejecutivo de la comparación."""
# Contar victorias por proveedor
vendor_wins = {}
for comp in comparisons:
for vendor in [comp.best_total_price_vendor,
comp.best_unit_price_vendor,
comp.best_date_vendor]:
if vendor:
vendor_wins[vendor] = vendor_wins.get(vendor, 0) + 1
# Ordenar por victorias
sorted_vendors = sorted(
vendor_wins.items(),
key=lambda x: x[1],
reverse=True
)
return {
'total_products_compared': len(comparisons),
'total_alternatives': len(all_pos),
'vendor_ranking': [
{'vendor': v, 'wins': w} for v, w in sorted_vendors
],
'recommended_vendor': sorted_vendors[0][0] if sorted_vendors else None
}
async def create_alternative(
self,
source_po_id: UUID,
new_vendor_id: UUID,
user_id: UUID
) -> 'PurchaseOrder':
"""
Crea una RFQ alternativa para licitación.
1. Copia estructura de PO original
2. Cambia proveedor
3. Vincula al mismo grupo de licitación
"""
source_po = await self._get_purchase_order(source_po_id)
# Crear grupo si no existe
if not source_po.purchase_group_id:
group = await self.db.execute(
"""
INSERT INTO purchasing.purchase_order_groups (created_by)
VALUES (:user_id)
RETURNING id
""",
{'user_id': user_id}
)
source_po.purchase_group_id = group.id
# Crear PO alternativa
new_po_data = {
'vendor_id': new_vendor_id,
'purchase_group_id': source_po.purchase_group_id,
'currency_id': source_po.currency_id,
'blanket_order_id': source_po.blanket_order_id,
'origin': f"Alternativa de {source_po.code}",
'created_by': user_id,
}
new_po = await self.po_service.create(new_po_data)
# Copiar líneas
for source_line in source_po.lines:
await self.po_service.add_line(new_po.id, {
'product_id': source_line.product_id,
'product_qty': source_line.product_qty,
'price_unit': Decimal('0'), # Proveedor debe cotizar
'product_uom_id': source_line.product_uom_id,
})
return new_po
5. Creación de Órdenes desde Acuerdo Marco
5.1 Servicio de Creación de PO
# services/blanket_order_po_service.py
from typing import Optional, List
from uuid import UUID
from decimal import Decimal
from datetime import date, datetime
class BlanketOrderPOService:
"""Servicio para crear órdenes de compra desde acuerdos marco."""
def __init__(self, db_session, po_service, product_service):
self.db = db_session
self.po_service = po_service
self.product_service = product_service
async def create_po_from_blanket(
self,
blanket_order_id: UUID,
user_id: UUID,
line_quantities: Optional[Dict[UUID, Decimal]] = None
) -> 'PurchaseOrder':
"""
Crea una orden de compra desde un acuerdo marco.
Args:
blanket_order_id: ID del acuerdo marco
user_id: Usuario que crea la PO
line_quantities: Opcional, cantidades específicas por línea
Si no se especifica, usa cantidades del acuerdo
Para blanket_order, inicia en 0 por defecto
Proceso:
1. Validar estado del acuerdo (debe estar confirmado)
2. Crear PO con datos del acuerdo
3. Crear líneas con precios acordados
4. Vincular PO al acuerdo
"""
blanket_order = await self._get_blanket_order(blanket_order_id)
# Validar estado
if blanket_order.state != 'confirmed':
raise InvalidStateError(
"Solo se pueden crear órdenes desde acuerdos confirmados"
)
# Validar vigencia
today = date.today()
if blanket_order.date_start and today < blanket_order.date_start:
raise ValidationError(
"El acuerdo aún no está vigente "
f"(inicia el {blanket_order.date_start})"
)
if blanket_order.date_end and today > blanket_order.date_end:
raise ValidationError(
"El acuerdo ha expirado "
f"(terminó el {blanket_order.date_end})"
)
# Crear cabecera de PO
po_data = {
'vendor_id': blanket_order.vendor_id,
'blanket_order_id': blanket_order.id,
'currency_id': blanket_order.currency_id,
'origin': blanket_order.code,
'notes': blanket_order.terms_conditions,
'warehouse_id': blanket_order.warehouse_id,
'picking_type_id': blanket_order.picking_type_id,
'company_id': blanket_order.company_id,
'created_by': user_id,
}
# Fecha de orden: máximo entre hoy y fecha_start del acuerdo
po_data['date_order'] = max(
datetime.now(),
datetime.combine(blanket_order.date_start, datetime.min.time())
if blanket_order.date_start else datetime.now()
)
new_po = await self.po_service.create(po_data)
# Crear líneas desde el acuerdo
for bo_line in blanket_order.lines:
# Determinar cantidad
if line_quantities and bo_line.id in line_quantities:
quantity = line_quantities[bo_line.id]
elif blanket_order.order_type == 'blanket_order':
# Para blanket orders, iniciar en 0 (usuario debe especificar)
quantity = Decimal('0')
else:
# Para templates, usar cantidad del acuerdo
quantity = bo_line.product_qty
# Obtener nombre del producto traducido al idioma del proveedor
product_name = await self._get_product_name_for_vendor(
bo_line.product_id,
blanket_order.vendor_id
)
# Calcular fecha planeada
seller = await self.product_service.get_seller(
product_id=bo_line.product_id,
partner_id=blanket_order.vendor_id,
quantity=quantity,
date=po_data['date_order'].date()
)
date_planned = max(
datetime.now(),
datetime.combine(blanket_order.date_start, datetime.min.time())
if blanket_order.date_start else datetime.now()
)
if seller and seller.delay:
date_planned += timedelta(days=seller.delay)
# Convertir precio si UOM diferente
price_unit = bo_line.price_unit
if bo_line.product_uom_id != seller.product_uom_id if seller else None:
price_unit = await self._convert_price_uom(
price_unit,
bo_line.product_uom_id,
seller.product_uom_id if seller else bo_line.product_uom_id,
bo_line.product_id
)
line_data = {
'product_id': bo_line.product_id,
'name': product_name,
'product_qty': quantity,
'price_unit': price_unit,
'product_uom_id': bo_line.product_uom_id,
'date_planned': date_planned,
'analytic_distribution': bo_line.analytic_distribution,
}
await self.po_service.add_line(new_po.id, line_data)
return new_po
async def _get_product_name_for_vendor(
self,
product_id: UUID,
vendor_id: UUID
) -> str:
"""Obtiene nombre del producto en el idioma del proveedor."""
vendor = await self._get_partner(vendor_id)
# Si el proveedor tiene idioma configurado, traducir
if vendor.lang:
product = await self.product_service.get_with_translation(
product_id,
vendor.lang
)
return product.name
# Si no, usar nombre por defecto
product = await self.product_service.get(product_id)
return product.name
5.2 Integración con Reabastecimiento Automático
# services/stock_rule_blanket_integration.py
class StockRuleBlanketIntegration:
"""Integración de reglas de stock con acuerdos marco."""
async def prepare_purchase_order_from_rule(
self,
product_id: UUID,
quantity: Decimal,
warehouse_id: UUID,
company_id: UUID,
origin_move_id: Optional[UUID] = None
) -> Dict:
"""
Prepara datos de PO considerando acuerdos marco activos.
Cuando MRP/procurement crea una PO desde una regla de stock,
verifica si existe un acuerdo marco activo para el producto
y vincula la PO al acuerdo.
"""
# Buscar seller preferido para el producto
seller = await self.product_service.select_seller(
product_id=product_id,
quantity=quantity,
date=date.today()
)
if not seller:
raise NoSupplierFoundError(
f"No se encontró proveedor para producto {product_id}"
)
po_data = {
'vendor_id': seller.partner_id,
'currency_id': seller.currency_id,
'company_id': company_id,
}
# Si el seller viene de un acuerdo marco, vincular
if seller.blanket_order_id:
blanket = await self._get_blanket_order(seller.blanket_order_id)
# Verificar que el acuerdo sigue activo
if blanket.state == 'confirmed':
po_data['blanket_order_id'] = blanket.id
po_data['currency_id'] = blanket.currency_id
po_data['origin'] = blanket.code
# Usar configuración de almacén del acuerdo si existe
if blanket.warehouse_id:
po_data['warehouse_id'] = blanket.warehouse_id
if blanket.picking_type_id:
po_data['picking_type_id'] = blanket.picking_type_id
return po_data
async def get_po_grouping_domain(
self,
product_id: UUID,
seller: 'SupplierInfo'
) -> Dict:
"""
Obtiene dominio para agrupar POs del mismo acuerdo marco.
Cuando hay múltiples órdenes de reabastecimiento para el mismo
proveedor y acuerdo, se consolidan en una sola PO.
"""
domain = {
'vendor_id': seller.partner_id,
'state': 'draft',
}
# Agrupar por acuerdo marco si existe
if seller.blanket_order_id:
domain['blanket_order_id'] = seller.blanket_order_id
return domain
6. API REST
6.1 Endpoints
# Acuerdos Marco
POST /api/v1/purchasing/blanket-orders # Crear acuerdo
GET /api/v1/purchasing/blanket-orders # Listar acuerdos
GET /api/v1/purchasing/blanket-orders/{id} # Obtener acuerdo
PUT /api/v1/purchasing/blanket-orders/{id} # Actualizar acuerdo
DELETE /api/v1/purchasing/blanket-orders/{id} # Eliminar acuerdo (solo draft)
# Acciones de estado
POST /api/v1/purchasing/blanket-orders/{id}/confirm # Confirmar
POST /api/v1/purchasing/blanket-orders/{id}/close # Cerrar
POST /api/v1/purchasing/blanket-orders/{id}/cancel # Cancelar
POST /api/v1/purchasing/blanket-orders/{id}/draft # Volver a borrador
# Líneas del acuerdo
POST /api/v1/purchasing/blanket-orders/{id}/lines # Agregar línea
PUT /api/v1/purchasing/blanket-orders/{id}/lines/{line_id} # Actualizar línea
DELETE /api/v1/purchasing/blanket-orders/{id}/lines/{line_id} # Eliminar línea
# Crear orden de compra desde acuerdo
POST /api/v1/purchasing/blanket-orders/{id}/create-purchase-order
# Licitaciones
POST /api/v1/purchasing/purchase-orders/{id}/create-alternative # Crear alternativa
GET /api/v1/purchasing/purchase-orders/{id}/compare-alternatives # Comparar ofertas
POST /api/v1/purchasing/purchase-orders/{id}/confirm-and-cancel-alternatives # Confirmar y cancelar otras
# Consultas
GET /api/v1/purchasing/blanket-orders/{id}/consumption # Ver consumo vs comprometido
GET /api/v1/purchasing/blanket-orders/by-vendor/{vendor_id} # Acuerdos por proveedor
6.2 Schemas de Request/Response
# schemas/blanket_order_schemas.py
from pydantic import BaseModel, Field
from typing import Optional, List
from uuid import UUID
from decimal import Decimal
from datetime import date
from enum import Enum
class BlanketOrderType(str, Enum):
BLANKET_ORDER = "blanket_order"
PURCHASE_TEMPLATE = "purchase_template"
class BlanketOrderState(str, Enum):
DRAFT = "draft"
CONFIRMED = "confirmed"
DONE = "done"
CANCELLED = "cancelled"
# ================== CREATE ==================
class BlanketOrderLineCreate(BaseModel):
product_id: UUID
product_qty: Decimal = Field(ge=0)
price_unit: Decimal = Field(ge=0)
product_uom_id: Optional[UUID] = None
description: Optional[str] = None
analytic_distribution: Optional[dict] = None
class BlanketOrderCreate(BaseModel):
order_type: BlanketOrderType = BlanketOrderType.BLANKET_ORDER
vendor_id: Optional[UUID] = None # Requerido para blanket_order
name: Optional[str] = None
reference: Optional[str] = None
date_start: Optional[date] = None
date_end: Optional[date] = None
currency_id: UUID
warehouse_id: Optional[UUID] = None
picking_type_id: Optional[UUID] = None
terms_conditions: Optional[str] = None
notes: Optional[str] = None
lines: List[BlanketOrderLineCreate] = []
# ================== UPDATE ==================
class BlanketOrderLineUpdate(BaseModel):
product_qty: Optional[Decimal] = Field(None, ge=0)
price_unit: Optional[Decimal] = Field(None, ge=0)
description: Optional[str] = None
analytic_distribution: Optional[dict] = None
class BlanketOrderUpdate(BaseModel):
vendor_id: Optional[UUID] = None
name: Optional[str] = None
reference: Optional[str] = None
date_start: Optional[date] = None
date_end: Optional[date] = None
warehouse_id: Optional[UUID] = None
picking_type_id: Optional[UUID] = None
terms_conditions: Optional[str] = None
notes: Optional[str] = None
# ================== RESPONSE ==================
class BlanketOrderLineResponse(BaseModel):
id: UUID
sequence: int
product_id: UUID
product_name: str
product_uom_id: UUID
product_uom_name: str
description: Optional[str]
product_qty: Decimal
qty_ordered: Decimal
qty_remaining: Decimal
price_unit: Decimal
subtotal: Decimal # product_qty * price_unit
consumption_percent: float # (qty_ordered / product_qty) * 100
analytic_distribution: Optional[dict]
class Config:
from_attributes = True
class BlanketOrderResponse(BaseModel):
id: UUID
code: str
name: Optional[str]
reference: Optional[str]
order_type: BlanketOrderType
vendor_id: Optional[UUID]
vendor_name: Optional[str]
buyer_id: UUID
buyer_name: str
company_id: UUID
date_start: Optional[date]
date_end: Optional[date]
currency_id: UUID
currency_code: str
warehouse_id: Optional[UUID]
warehouse_name: Optional[str]
state: BlanketOrderState
terms_conditions: Optional[str]
notes: Optional[str]
# Totales
total_committed: Decimal # Suma de (qty * price) de todas las líneas
total_ordered: Decimal # Suma de POs confirmadas
total_remaining: Decimal # total_committed - total_ordered
# Contadores
purchase_order_count: int
# Líneas
lines: List[BlanketOrderLineResponse]
# Auditoría
created_at: str
created_by: UUID
updated_at: str
class Config:
from_attributes = True
# ================== COMPARACIÓN ==================
class LineComparisonResponse(BaseModel):
product_id: UUID
product_name: str
best_total_price: dict # {value, line_ids, vendor}
best_unit_price: dict
best_delivery_date: dict
class TenderComparisonResponse(BaseModel):
purchase_order_id: UUID
alternative_ids: List[UUID]
line_comparisons: List[LineComparisonResponse]
summary: dict # {total_products, vendor_ranking, recommended_vendor}
# ================== CONSUMO ==================
class ConsumptionResponse(BaseModel):
blanket_order_id: UUID
blanket_order_code: str
vendor_name: str
date_start: Optional[date]
date_end: Optional[date]
days_remaining: Optional[int]
total_committed_amount: Decimal
total_ordered_amount: Decimal
consumption_percent: float
lines: List[dict] # [{product, committed_qty, ordered_qty, percent}]
purchase_orders: List[dict] # [{id, code, date, amount, state}]
# ================== CREAR PO ==================
class CreatePOFromBlanketRequest(BaseModel):
line_quantities: Optional[dict] = None # {line_id: quantity}
6.3 Implementación de Endpoints
# routers/blanket_order_router.py
from fastapi import APIRouter, Depends, HTTPException, status
from typing import List, Optional
from uuid import UUID
router = APIRouter(prefix="/api/v1/purchasing/blanket-orders", tags=["Blanket Orders"])
@router.post("", response_model=BlanketOrderResponse, status_code=status.HTTP_201_CREATED)
async def create_blanket_order(
data: BlanketOrderCreate,
current_user: User = Depends(get_current_user),
service: BlanketOrderService = Depends(get_blanket_order_service)
):
"""
Crear nuevo acuerdo marco o plantilla de compra.
- **blanket_order**: Requiere vendor_id, precios y cantidades obligatorios
- **purchase_template**: Sin vendor_id requerido, precios auto-calculados
"""
return await service.create(data.dict(), current_user.id)
@router.get("", response_model=List[BlanketOrderResponse])
async def list_blanket_orders(
state: Optional[BlanketOrderState] = None,
order_type: Optional[BlanketOrderType] = None,
vendor_id: Optional[UUID] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None,
skip: int = 0,
limit: int = 100,
current_user: User = Depends(get_current_user),
service: BlanketOrderService = Depends(get_blanket_order_service)
):
"""Listar acuerdos marco con filtros opcionales."""
filters = {
'state': state,
'order_type': order_type,
'vendor_id': vendor_id,
'date_from': date_from,
'date_to': date_to,
}
return await service.list(filters, skip, limit, current_user.company_id)
@router.get("/{blanket_order_id}", response_model=BlanketOrderResponse)
async def get_blanket_order(
blanket_order_id: UUID,
current_user: User = Depends(get_current_user),
service: BlanketOrderService = Depends(get_blanket_order_service)
):
"""Obtener detalle de un acuerdo marco."""
result = await service.get(blanket_order_id)
if not result:
raise HTTPException(status_code=404, detail="Acuerdo marco no encontrado")
return result
@router.put("/{blanket_order_id}", response_model=BlanketOrderResponse)
async def update_blanket_order(
blanket_order_id: UUID,
data: BlanketOrderUpdate,
current_user: User = Depends(get_current_user),
service: BlanketOrderService = Depends(get_blanket_order_service)
):
"""
Actualizar acuerdo marco.
Restricciones según estado:
- **draft**: Todos los campos editables
- **confirmed**: Solo notas y términos
- **done/cancelled**: No editable
"""
return await service.update(blanket_order_id, data.dict(exclude_unset=True), current_user.id)
@router.delete("/{blanket_order_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_blanket_order(
blanket_order_id: UUID,
current_user: User = Depends(get_current_user),
service: BlanketOrderService = Depends(get_blanket_order_service)
):
"""
Eliminar acuerdo marco.
Solo permitido en estados: draft, cancelled
"""
await service.delete(blanket_order_id, current_user.id)
# ================== ACCIONES DE ESTADO ==================
@router.post("/{blanket_order_id}/confirm", response_model=BlanketOrderResponse)
async def confirm_blanket_order(
blanket_order_id: UUID,
current_user: User = Depends(get_current_user),
service: BlanketOrderService = Depends(get_blanket_order_service)
):
"""
Confirmar acuerdo marco.
Validaciones:
- Mínimo una línea
- Para blanket_order: todas las líneas con precio > 0 y cantidad > 0
- Crea supplier_info para cada línea
"""
return await service.confirm(blanket_order_id, current_user.id)
@router.post("/{blanket_order_id}/close", response_model=BlanketOrderResponse)
async def close_blanket_order(
blanket_order_id: UUID,
current_user: User = Depends(get_current_user),
service: BlanketOrderService = Depends(get_blanket_order_service)
):
"""
Cerrar acuerdo marco.
Validaciones:
- No debe haber POs pendientes (draft/sent/to_approve)
- Elimina supplier_info asociados
"""
return await service.close(blanket_order_id, current_user.id)
@router.post("/{blanket_order_id}/cancel", response_model=BlanketOrderResponse)
async def cancel_blanket_order(
blanket_order_id: UUID,
current_user: User = Depends(get_current_user),
service: BlanketOrderService = Depends(get_blanket_order_service)
):
"""
Cancelar acuerdo marco.
Acciones:
- Elimina supplier_info
- Cancela POs en draft/sent
"""
return await service.cancel(blanket_order_id, current_user.id)
@router.post("/{blanket_order_id}/draft", response_model=BlanketOrderResponse)
async def reset_to_draft(
blanket_order_id: UUID,
current_user: User = Depends(get_current_user),
service: BlanketOrderService = Depends(get_blanket_order_service)
):
"""Restablecer acuerdo cancelado a borrador."""
return await service.reset_to_draft(blanket_order_id, current_user.id)
# ================== LÍNEAS ==================
@router.post("/{blanket_order_id}/lines", response_model=BlanketOrderLineResponse)
async def add_line(
blanket_order_id: UUID,
data: BlanketOrderLineCreate,
current_user: User = Depends(get_current_user),
service: BlanketOrderService = Depends(get_blanket_order_service)
):
"""Agregar línea al acuerdo marco."""
return await service.add_line(blanket_order_id, data.dict(), current_user.id)
@router.put("/{blanket_order_id}/lines/{line_id}", response_model=BlanketOrderLineResponse)
async def update_line(
blanket_order_id: UUID,
line_id: UUID,
data: BlanketOrderLineUpdate,
current_user: User = Depends(get_current_user),
service: BlanketOrderService = Depends(get_blanket_order_service)
):
"""Actualizar línea del acuerdo marco."""
return await service.update_line(blanket_order_id, line_id, data.dict(exclude_unset=True), current_user.id)
@router.delete("/{blanket_order_id}/lines/{line_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_line(
blanket_order_id: UUID,
line_id: UUID,
current_user: User = Depends(get_current_user),
service: BlanketOrderService = Depends(get_blanket_order_service)
):
"""Eliminar línea del acuerdo marco."""
await service.delete_line(blanket_order_id, line_id, current_user.id)
# ================== CREAR PO ==================
@router.post("/{blanket_order_id}/create-purchase-order", response_model=dict)
async def create_po_from_blanket(
blanket_order_id: UUID,
data: CreatePOFromBlanketRequest,
current_user: User = Depends(get_current_user),
po_service: BlanketOrderPOService = Depends(get_blanket_po_service)
):
"""
Crear orden de compra desde acuerdo marco.
- El acuerdo debe estar en estado 'confirmed'
- Las cantidades pueden especificarse o usar default (0 para blanket_order)
- Precios se toman del acuerdo
"""
po = await po_service.create_po_from_blanket(
blanket_order_id,
current_user.id,
data.line_quantities
)
return {"purchase_order_id": po.id, "code": po.code}
# ================== CONSUMO ==================
@router.get("/{blanket_order_id}/consumption", response_model=ConsumptionResponse)
async def get_consumption(
blanket_order_id: UUID,
current_user: User = Depends(get_current_user),
service: BlanketOrderService = Depends(get_blanket_order_service)
):
"""
Obtener reporte de consumo del acuerdo marco.
Muestra:
- Cantidades comprometidas vs ordenadas por línea
- Porcentaje de consumo
- Lista de órdenes de compra asociadas
- Días restantes de vigencia
"""
return await service.get_consumption_report(blanket_order_id)
7. Validaciones y Reglas de Negocio
7.1 Validaciones por Estado
# validators/blanket_order_validators.py
class BlanketOrderValidators:
"""Validaciones de reglas de negocio para acuerdos marco."""
@staticmethod
def validate_dates(date_start: date, date_end: date) -> None:
"""Validar que fecha_fin >= fecha_inicio."""
if date_end and date_start and date_end < date_start:
raise ValidationError(
field="date_end",
message="La fecha de fin no puede ser anterior a la fecha de inicio",
code="INVALID_DATE_RANGE"
)
@staticmethod
def validate_for_confirmation(blanket_order: 'BlanketOrder') -> List[ValidationError]:
"""Validaciones antes de confirmar."""
errors = []
# Al menos una línea
if not blanket_order.lines:
errors.append(ValidationError(
field="lines",
message="El acuerdo debe tener al menos una línea",
code="NO_LINES"
))
# Validaciones para blanket_order
if blanket_order.order_type == 'blanket_order':
if not blanket_order.vendor_id:
errors.append(ValidationError(
field="vendor_id",
message="Se requiere un proveedor para acuerdos marco",
code="NO_VENDOR"
))
for idx, line in enumerate(blanket_order.lines):
if line.price_unit <= 0:
errors.append(ValidationError(
field=f"lines[{idx}].price_unit",
message=f"Línea {idx + 1}: El precio debe ser mayor a cero",
code="INVALID_PRICE"
))
if line.product_qty <= 0:
errors.append(ValidationError(
field=f"lines[{idx}].product_qty",
message=f"Línea {idx + 1}: La cantidad debe ser mayor a cero",
code="INVALID_QTY"
))
return errors
@staticmethod
def validate_for_done(blanket_order: 'BlanketOrder') -> List[ValidationError]:
"""Validaciones antes de cerrar."""
errors = []
pending_states = ['draft', 'sent', 'to_approve']
pending_count = sum(
1 for po in blanket_order.purchase_orders
if po.state in pending_states
)
if pending_count > 0:
errors.append(ValidationError(
field="purchase_orders",
message=f"Hay {pending_count} órdenes de compra pendientes",
code="PENDING_POS"
))
return errors
@staticmethod
def validate_delete(blanket_order: 'BlanketOrder') -> None:
"""Validaciones antes de eliminar."""
if blanket_order.state not in ['draft', 'cancelled']:
raise ValidationError(
field="state",
message="Solo se pueden eliminar acuerdos en estado borrador o cancelado",
code="CANNOT_DELETE"
)
@staticmethod
def validate_line_modification(
blanket_order: 'BlanketOrder',
line_data: dict
) -> None:
"""Validaciones para modificar líneas."""
# En estado confirmado, solo se puede modificar bajo ciertas condiciones
if blanket_order.state == 'confirmed':
if 'price_unit' in line_data and line_data['price_unit'] <= 0:
raise ValidationError(
field="price_unit",
message="En estado confirmado, el precio debe ser mayor a cero",
code="INVALID_PRICE_CONFIRMED"
)
# En estado done, no se puede modificar
if blanket_order.state == 'done':
raise ValidationError(
field="state",
message="No se pueden modificar líneas de un acuerdo cerrado",
code="CANNOT_MODIFY_DONE"
)
7.2 Campos de Solo Lectura por Estado
READONLY_FIELDS_BY_STATE = {
'draft': [], # Todo editable
'confirmed': [
'order_type',
'vendor_id',
'currency_id',
'company_id',
],
'done': [
# Todo de solo lectura
'order_type', 'vendor_id', 'name', 'reference',
'date_start', 'date_end', 'currency_id', 'warehouse_id',
'picking_type_id', 'terms_conditions', 'notes', 'company_id'
],
'cancelled': [
# Similar a done
'order_type', 'vendor_id', 'name', 'reference',
'date_start', 'date_end', 'currency_id', 'warehouse_id',
'picking_type_id', 'terms_conditions', 'notes', 'company_id'
],
}
8. Eventos del Sistema
8.1 Eventos Emitidos
# events/blanket_order_events.py
@dataclass
class BlanketOrderConfirmedEvent:
"""Evento: Acuerdo marco confirmado."""
blanket_order_id: UUID
vendor_id: UUID
total_committed: Decimal
line_count: int
timestamp: datetime
@dataclass
class BlanketOrderClosedEvent:
"""Evento: Acuerdo marco cerrado."""
blanket_order_id: UUID
vendor_id: UUID
total_consumed: Decimal
consumption_percent: float
purchase_orders_count: int
timestamp: datetime
@dataclass
class BlanketOrderConsumptionThresholdEvent:
"""Evento: Umbral de consumo alcanzado (ej: 80%)."""
blanket_order_id: UUID
blanket_order_code: str
vendor_id: UUID
vendor_name: str
threshold_percent: float
current_percent: float
remaining_amount: Decimal
timestamp: datetime
@dataclass
class BlanketOrderExpiringEvent:
"""Evento: Acuerdo próximo a vencer."""
blanket_order_id: UUID
blanket_order_code: str
vendor_id: UUID
vendor_name: str
date_end: date
days_remaining: int
consumption_percent: float
timestamp: datetime
8.2 Suscriptores de Eventos
# event_handlers/blanket_order_handlers.py
class BlanketOrderEventHandlers:
"""Manejadores de eventos de acuerdos marco."""
@event_handler(BlanketOrderConfirmedEvent)
async def on_confirmed(self, event: BlanketOrderConfirmedEvent):
"""Acciones cuando se confirma un acuerdo."""
# Notificar al proveedor (si tiene portal)
await self.notification_service.notify_vendor(
vendor_id=event.vendor_id,
template='blanket_order_confirmed',
data={'blanket_order_id': event.blanket_order_id}
)
# Log de auditoría
await self.audit_service.log(
entity='blanket_order',
entity_id=event.blanket_order_id,
action='confirmed',
details={'total_committed': str(event.total_committed)}
)
@event_handler(BlanketOrderConsumptionThresholdEvent)
async def on_consumption_threshold(self, event: BlanketOrderConsumptionThresholdEvent):
"""Acciones cuando se alcanza umbral de consumo."""
# Notificar al comprador responsable
await self.notification_service.notify_users(
role='purchase_manager',
template='blanket_order_threshold',
data={
'code': event.blanket_order_code,
'vendor': event.vendor_name,
'percent': event.current_percent,
'remaining': str(event.remaining_amount)
}
)
@event_handler(BlanketOrderExpiringEvent)
async def on_expiring(self, event: BlanketOrderExpiringEvent):
"""Acciones cuando acuerdo está por vencer."""
# Notificar para renovación
await self.notification_service.notify_users(
role='purchase_manager',
template='blanket_order_expiring',
data={
'code': event.blanket_order_code,
'vendor': event.vendor_name,
'days_remaining': event.days_remaining,
'consumption': event.consumption_percent
}
)
9. Trabajos Programados
9.1 Monitoreo de Vencimientos
# jobs/blanket_order_monitoring.py
class BlanketOrderMonitoringJob:
"""Job para monitorear acuerdos marco."""
# Ejecutar diariamente a las 8:00 AM
schedule = "0 8 * * *"
async def run(self):
"""Verificar acuerdos próximos a vencer y umbrales de consumo."""
# 1. Acuerdos por vencer en los próximos 30 días
expiring = await self.db.execute(
"""
SELECT
bo.id,
bo.code,
bo.vendor_id,
v.name as vendor_name,
bo.date_end,
(bo.date_end - CURRENT_DATE) as days_remaining
FROM purchasing.blanket_orders bo
JOIN core.partners v ON v.id = bo.vendor_id
WHERE bo.state = 'confirmed'
AND bo.date_end IS NOT NULL
AND bo.date_end BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '30 days'
ORDER BY bo.date_end
"""
)
for record in expiring:
# Calcular consumo
consumption = await self._calculate_consumption(record.id)
await self.event_bus.emit(BlanketOrderExpiringEvent(
blanket_order_id=record.id,
blanket_order_code=record.code,
vendor_id=record.vendor_id,
vendor_name=record.vendor_name,
date_end=record.date_end,
days_remaining=record.days_remaining,
consumption_percent=consumption['percent'],
timestamp=datetime.now()
))
# 2. Acuerdos con consumo > 80%
high_consumption = await self._find_high_consumption_agreements(80.0)
for record in high_consumption:
await self.event_bus.emit(BlanketOrderConsumptionThresholdEvent(
blanket_order_id=record['id'],
blanket_order_code=record['code'],
vendor_id=record['vendor_id'],
vendor_name=record['vendor_name'],
threshold_percent=80.0,
current_percent=record['consumption_percent'],
remaining_amount=record['remaining_amount'],
timestamp=datetime.now()
))
async def _calculate_consumption(self, blanket_order_id: UUID) -> dict:
"""Calcular porcentaje de consumo de un acuerdo."""
result = await self.db.execute(
"""
SELECT
COALESCE(SUM(bol.product_qty * bol.price_unit), 0) as total_committed,
COALESCE(SUM(bol.qty_ordered * bol.price_unit), 0) as total_ordered
FROM purchasing.blanket_order_lines bol
WHERE bol.blanket_order_id = :id
""",
{'id': blanket_order_id}
)
total_committed = result.total_committed or Decimal('0')
total_ordered = result.total_ordered or Decimal('0')
percent = (
float(total_ordered / total_committed * 100)
if total_committed > 0 else 0.0
)
return {
'total_committed': total_committed,
'total_ordered': total_ordered,
'percent': percent,
'remaining': total_committed - total_ordered
}
10. Consideraciones de Implementación
10.1 Permisos y Seguridad
BLANKET_ORDER_PERMISSIONS = {
'blanket_order.create': ['purchase_user', 'purchase_manager'],
'blanket_order.read': ['purchase_user', 'purchase_manager', 'inventory_user'],
'blanket_order.update': ['purchase_user', 'purchase_manager'],
'blanket_order.delete': ['purchase_manager'],
'blanket_order.confirm': ['purchase_manager'],
'blanket_order.close': ['purchase_manager'],
'blanket_order.cancel': ['purchase_manager'],
'blanket_order.create_po': ['purchase_user', 'purchase_manager'],
'tender.compare': ['purchase_user', 'purchase_manager'],
'tender.create_alternative': ['purchase_user', 'purchase_manager'],
}
10.2 Reglas de Aislamiento Multi-Empresa
-- Row Level Security
ALTER TABLE purchasing.blanket_orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY blanket_orders_company_isolation ON purchasing.blanket_orders
USING (company_id IN (
SELECT company_id FROM core.user_companies
WHERE user_id = current_setting('app.current_user_id')::uuid
));
ALTER TABLE purchasing.blanket_order_lines ENABLE ROW LEVEL SECURITY;
CREATE POLICY blanket_order_lines_company_isolation ON purchasing.blanket_order_lines
USING (blanket_order_id IN (
SELECT id FROM purchasing.blanket_orders
WHERE company_id IN (
SELECT company_id FROM core.user_companies
WHERE user_id = current_setting('app.current_user_id')::uuid
)
));
10.3 Índices para Rendimiento
-- Índices adicionales para consultas frecuentes
CREATE INDEX idx_blanket_orders_vendor_state
ON purchasing.blanket_orders(vendor_id, state)
WHERE state = 'confirmed';
CREATE INDEX idx_blanket_orders_expiring
ON purchasing.blanket_orders(date_end)
WHERE state = 'confirmed' AND date_end IS NOT NULL;
-- Índice para cálculo de consumo
CREATE INDEX idx_blanket_lines_consumption
ON purchasing.blanket_order_lines(blanket_order_id, product_id)
INCLUDE (product_qty, qty_ordered, price_unit);
11. Casos de Uso Principales
11.1 Crear Acuerdo Marco Anual
Actor: Gerente de Compras
Precondición: Negociación completada con proveedor
Flujo:
1. Usuario crea nuevo acuerdo tipo 'blanket_order'
2. Selecciona proveedor
3. Define período de vigencia (ej: 01/01/2025 - 31/12/2025)
4. Agrega líneas con productos, cantidades comprometidas y precios acordados
5. Revisa totales y términos
6. Confirma el acuerdo
7. Sistema crea supplier_info para cada línea
8. Sistema notifica al proveedor
Resultado: Acuerdo activo listo para generar órdenes
11.2 Proceso de Licitación
Actor: Comprador
Precondición: Necesidad de compra identificada
Flujo:
1. Usuario crea RFQ inicial con productos requeridos
2. Envía RFQ al primer proveedor
3. Crea alternativas para otros proveedores
4. Recibe cotizaciones de cada proveedor
5. Usa "Comparar Ofertas" para ver análisis
6. Selecciona mejor opción
7. Confirma PO ganadora
8. Elige cancelar alternativas no seleccionadas
9. Sistema cancela POs perdedoras
Resultado: PO confirmada con mejor proveedor
11.3 Crear Orden desde Acuerdo
Actor: Comprador
Precondición: Acuerdo marco confirmado y vigente
Flujo:
1. Usuario accede al acuerdo marco
2. Verifica cantidades disponibles (committed - ordered)
3. Click en "Crear Orden de Compra"
4. Sistema crea PO con datos del acuerdo
5. Usuario ingresa cantidades específicas para esta orden
6. Confirma la PO
7. Sistema actualiza qty_ordered en líneas del acuerdo
Resultado: PO vinculada al acuerdo, consumo actualizado
12. Métricas y KPIs
12.1 Dashboard de Acuerdos Marco
class BlanketOrderMetrics:
"""Métricas para dashboard de acuerdos marco."""
async def get_summary_metrics(self, company_id: UUID) -> dict:
"""Obtener métricas resumen."""
return await self.db.execute(
"""
SELECT
COUNT(*) FILTER (WHERE state = 'confirmed') as active_agreements,
COUNT(*) FILTER (
WHERE state = 'confirmed'
AND date_end BETWEEN CURRENT_DATE AND CURRENT_DATE + 30
) as expiring_soon,
COALESCE(SUM(
CASE WHEN state = 'confirmed'
THEN (SELECT SUM(product_qty * price_unit)
FROM purchasing.blanket_order_lines
WHERE blanket_order_id = bo.id)
END
), 0) as total_committed,
COALESCE(SUM(
CASE WHEN state = 'confirmed'
THEN (SELECT SUM(qty_ordered * price_unit)
FROM purchasing.blanket_order_lines
WHERE blanket_order_id = bo.id)
END
), 0) as total_consumed
FROM purchasing.blanket_orders bo
WHERE company_id = :company_id
""",
{'company_id': company_id}
)
async def get_consumption_by_vendor(self, company_id: UUID) -> List[dict]:
"""Consumo por proveedor."""
return await self.db.execute(
"""
SELECT
v.id as vendor_id,
v.name as vendor_name,
COUNT(bo.id) as agreement_count,
SUM(committed.total) as total_committed,
SUM(consumed.total) as total_consumed,
CASE
WHEN SUM(committed.total) > 0
THEN (SUM(consumed.total) / SUM(committed.total) * 100)::NUMERIC(5,2)
ELSE 0
END as consumption_percent
FROM purchasing.blanket_orders bo
JOIN core.partners v ON v.id = bo.vendor_id
LEFT JOIN LATERAL (
SELECT SUM(product_qty * price_unit) as total
FROM purchasing.blanket_order_lines
WHERE blanket_order_id = bo.id
) committed ON TRUE
LEFT JOIN LATERAL (
SELECT SUM(qty_ordered * price_unit) as total
FROM purchasing.blanket_order_lines
WHERE blanket_order_id = bo.id
) consumed ON TRUE
WHERE bo.company_id = :company_id
AND bo.state = 'confirmed'
GROUP BY v.id, v.name
ORDER BY total_committed DESC
""",
{'company_id': company_id}
)
13. Referencias
13.1 Odoo
addons/purchase_requisition/models/purchase_requisition.pyaddons/purchase_requisition/models/purchase.pyaddons/purchase_requisition_stock/models/
13.2 Estándares
- Procure-to-Pay (P2P) best practices
- Framework Agreement patterns (EU procurement)
13.3 Documentos Relacionados
- SPEC-COMPRAS (módulo base de compras)
- SPEC-PROVEEDORES (gestión de proveedores)
- SPEC-PRICING-RULES (reglas de precios)
Historial de Cambios
| Versión | Fecha | Autor | Cambios |
|---|---|---|---|
| 1.0.0 | 2025-01-15 | AI Assistant | Versión inicial |