# 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 ```sql -- ============================================================================= -- 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 ```sql -- 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 ```python # 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 ```python # 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 ```python # 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 ```python # 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 ```yaml # 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 ```python # 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 ```python # 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 ```python # 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 ```python 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 ```python # 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 ```python # 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 ```python # 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 ```python 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 ```sql -- 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 ```sql -- Í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 ```python 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 |