erp-core/docs/04-modelado/especificaciones-tecnicas/transversal/SPEC-BLANKET-ORDERS.md

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

  1. Definir modelo de datos para acuerdos marco y plantillas de compra
  2. Implementar flujo de estados del acuerdo (borrador → confirmado → cerrado)
  3. Crear mecanismo de tracking de cantidades ordenadas vs comprometidas
  4. Soportar proceso de licitación con comparación de ofertas
  5. 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.py
  • addons/purchase_requisition/models/purchase.py
  • addons/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