# SPEC-PORTAL-PROVEEDORES: Portal de Autoservicio para Proveedores ## Metadata - **Código**: SPEC-PORTAL-PROVEEDORES - **Versión**: 1.0.0 - **Fecha**: 2025-01-15 - **Gap Relacionado**: GAP-MGN-013-001 - **Módulo**: MGN-013 (Portal) - **Prioridad**: P1 - **Story Points**: 13 - **Odoo Referencia**: portal, purchase, purchase_requisition --- ## 1. Resumen Ejecutivo ### 1.1 Descripción del Gap El sistema actual no cuenta con un portal de autoservicio para proveedores que les permita responder a solicitudes de cotización (RFQ), consultar órdenes de compra, actualizar fechas de entrega y gestionar documentos de manera autónoma. ### 1.2 Impacto en el Negocio | Aspecto | Sin Portal | Con Portal | |---------|-----------|------------| | Respuesta a RFQs | Por email/teléfono | Autoservicio 24/7 | | Tiempo de cotización | 2-5 días | Horas | | Errores de transcripción | Frecuentes | Eliminados | | Visibilidad de OC | Por solicitud | Tiempo real | | Carga administrativa | Alta | Reducida 70% | | Comunicación | Fragmentada | Centralizada | | Trazabilidad | Manual | Automática | ### 1.3 Objetivos de la Especificación 1. Implementar portal web de autoservicio para proveedores 2. Permitir respuesta directa a solicitudes de cotización (RFQ) 3. Proporcionar visibilidad de órdenes de compra y su estado 4. Gestionar documentos (facturas, certificados, entregas) 5. Configurar notificaciones y preferencias 6. Garantizar seguridad multi-tenant con aislamiento por proveedor --- ## 2. Arquitectura del Portal ### 2.1 Diagrama de Arquitectura ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ ARQUITECTURA PORTAL PROVEEDORES │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌─────────────────────────────────────────────────────────────────────┐ │ │ │ CAPA DE PRESENTACIÓN │ │ │ │ │ │ │ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ │ │ │ │ Login │ │ Dashboard │ │ RFQs │ │ Órdenes │ │ │ │ │ │ Portal │ │ Vendor │ │ Response │ │ Compra │ │ │ │ │ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │ │ │ │ │ │ │ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ │ │ │ │Documentos │ │ Perfil │ │ Facturas │ │ Mensajes │ │ │ │ │ │ Upload │ │ Empresa │ │ Status │ │ Chat │ │ │ │ │ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────────────┐ │ │ │ API GATEWAY │ │ │ │ │ │ │ │ /api/portal/v1/* │ │ │ │ └─► JWT Auth (Portal Scope) │ │ │ │ └─► Rate Limiting (100 req/min) │ │ │ │ └─► Partner-based filtering │ │ │ └─────────────────────────────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────────────┐ │ │ │ CAPA DE SERVICIOS │ │ │ │ │ │ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ │ │ Portal │ │ RFQ │ │ Purchase │ │ │ │ │ │ Auth │ │ Response │ │ Order │ │ │ │ │ │ Service │ │ Service │ │ Service │ │ │ │ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ │ │ │ │ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ │ │ Document │ │Notification │ │ Message │ │ │ │ │ │ Service │ │ Service │ │ Service │ │ │ │ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────────────┐ │ │ │ CAPA DE DATOS │ │ │ │ │ │ │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ │ │ PostgreSQL + RLS │ │ │ │ │ │ │ │ │ │ │ │ portal_users │ rfqs │ purchase_orders │ documents │ │ │ │ │ │ portal_tokens │ rfq_responses │ attachments │ │ │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ ``` ### 2.2 Modelo de Datos ```sql -- ============================================================================= -- SCHEMA: portal -- ============================================================================= -- ----------------------------------------------------------------------------- -- Tabla: portal_users (Usuarios del portal - proveedores) -- ----------------------------------------------------------------------------- CREATE TABLE portal.portal_users ( -- Identificación id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), partner_id UUID NOT NULL REFERENCES core.partners(id), email VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL, -- Información personal name VARCHAR(200) NOT NULL, phone VARCHAR(50), position VARCHAR(100), -- Cargo en la empresa -- Estado is_active BOOLEAN NOT NULL DEFAULT TRUE, is_verified BOOLEAN NOT NULL DEFAULT FALSE, verification_token VARCHAR(100), verification_expires_at TIMESTAMPTZ, -- Seguridad last_login_at TIMESTAMPTZ, failed_login_count INTEGER DEFAULT 0, locked_until TIMESTAMPTZ, password_changed_at TIMESTAMPTZ, must_change_password BOOLEAN DEFAULT FALSE, -- Preferencias language VARCHAR(10) DEFAULT 'es', timezone VARCHAR(50) DEFAULT 'America/Mexico_City', notification_preferences JSONB DEFAULT '{ "rfq_received": true, "rfq_reminder": true, "po_confirmed": true, "payment_received": true, "message_received": true }'::jsonb, -- Multi-tenant tenant_id UUID NOT NULL, company_id UUID REFERENCES core.companies(id), -- Auditoría created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by UUID, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, -- Restricciones CONSTRAINT uq_portal_user_email_tenant UNIQUE (email, tenant_id) ); -- Índices CREATE INDEX idx_portal_users_partner ON portal.portal_users(partner_id); CREATE INDEX idx_portal_users_email ON portal.portal_users(email); CREATE INDEX idx_portal_users_tenant ON portal.portal_users(tenant_id); -- RLS ALTER TABLE portal.portal_users ENABLE ROW LEVEL SECURITY; CREATE POLICY portal_users_tenant_isolation ON portal.portal_users USING (tenant_id = current_setting('app.current_tenant_id')::uuid); -- ----------------------------------------------------------------------------- -- Tabla: portal_sessions (Sesiones del portal) -- ----------------------------------------------------------------------------- CREATE TABLE portal.portal_sessions ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), user_id UUID NOT NULL REFERENCES portal.portal_users(id) ON DELETE CASCADE, token_hash VARCHAR(64) NOT NULL, refresh_token_hash VARCHAR(64), -- Metadata ip_address INET, user_agent TEXT, device_type VARCHAR(20), -- desktop, mobile, tablet -- Tiempo de vida created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at TIMESTAMPTZ NOT NULL, last_activity_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, revoked_at TIMESTAMPTZ, CONSTRAINT uq_portal_session_token UNIQUE (token_hash) ); CREATE INDEX idx_portal_sessions_user ON portal.portal_sessions(user_id); CREATE INDEX idx_portal_sessions_token ON portal.portal_sessions(token_hash); -- ----------------------------------------------------------------------------- -- Tabla: rfq_responses (Respuestas de proveedores a RFQs) -- ----------------------------------------------------------------------------- CREATE TABLE portal.rfq_responses ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), rfq_id UUID NOT NULL REFERENCES purchasing.rfqs(id), partner_id UUID NOT NULL REFERENCES core.partners(id), portal_user_id UUID REFERENCES portal.portal_users(id), -- Estado de respuesta response_status VARCHAR(20) NOT NULL DEFAULT 'draft', -- 'draft': En preparación -- 'submitted': Enviada -- 'revised': Revisada (después de feedback) -- 'accepted': Aceptada por comprador -- 'rejected': Rechazada -- 'expired': Expirada (pasó deadline) -- Totales total_amount NUMERIC(18,2), currency_id UUID REFERENCES core.currencies(id), -- Fechas submitted_at TIMESTAMPTZ, valid_until DATE, -- Hasta cuándo es válida la cotización expected_delivery DATE, -- Fecha estimada de entrega -- Notas vendor_notes TEXT, internal_notes TEXT, -- No visible para proveedor -- Términos payment_terms TEXT, delivery_terms TEXT, warranty_terms TEXT, -- Auditoría tenant_id UUID NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT uq_rfq_response_partner UNIQUE (rfq_id, partner_id) ); CREATE INDEX idx_rfq_responses_rfq ON portal.rfq_responses(rfq_id); CREATE INDEX idx_rfq_responses_partner ON portal.rfq_responses(partner_id); CREATE INDEX idx_rfq_responses_status ON portal.rfq_responses(response_status); -- RLS ALTER TABLE portal.rfq_responses ENABLE ROW LEVEL SECURITY; CREATE POLICY rfq_responses_tenant_isolation ON portal.rfq_responses USING (tenant_id = current_setting('app.current_tenant_id')::uuid); -- ----------------------------------------------------------------------------- -- Tabla: rfq_response_lines (Líneas de respuesta a RFQ) -- ----------------------------------------------------------------------------- CREATE TABLE portal.rfq_response_lines ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), response_id UUID NOT NULL REFERENCES portal.rfq_responses(id) ON DELETE CASCADE, rfq_line_id UUID NOT NULL REFERENCES purchasing.rfq_lines(id), -- Producto cotizado product_id UUID REFERENCES inventory.products(id), description TEXT, -- Cantidades requested_qty NUMERIC(18,4) NOT NULL, quoted_qty NUMERIC(18,4), -- Puede diferir de lo solicitado uom_id UUID REFERENCES inventory.units_of_measure(id), -- Precios unit_price NUMERIC(18,6), discount_percent NUMERIC(5,2) DEFAULT 0, subtotal NUMERIC(18,2) GENERATED ALWAYS AS ( COALESCE(quoted_qty, 0) * COALESCE(unit_price, 0) * (1 - COALESCE(discount_percent, 0) / 100) ) STORED, -- Entrega lead_time_days INTEGER, -- Días para entrega delivery_date DATE, -- Estado can_supply BOOLEAN DEFAULT TRUE, alternative_product_id UUID REFERENCES inventory.products(id), alternative_notes TEXT, -- Notas line_notes TEXT, CONSTRAINT uq_response_line UNIQUE (response_id, rfq_line_id) ); CREATE INDEX idx_response_lines_response ON portal.rfq_response_lines(response_id); -- ----------------------------------------------------------------------------- -- Tabla: portal_documents (Documentos compartidos en portal) -- ----------------------------------------------------------------------------- CREATE TABLE portal.portal_documents ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), -- Referencia document_type VARCHAR(50) NOT NULL, -- 'rfq': Solicitud de cotización -- 'rfq_response': Respuesta a RFQ -- 'purchase_order': Orden de compra -- 'invoice': Factura -- 'delivery_note': Nota de entrega -- 'certificate': Certificado (calidad, origen, etc.) -- 'contract': Contrato reference_type VARCHAR(50) NOT NULL, -- Tabla relacionada reference_id UUID NOT NULL, -- ID del registro relacionado -- Archivo file_name VARCHAR(255) NOT NULL, file_path VARCHAR(500) NOT NULL, file_size INTEGER, mime_type VARCHAR(100), checksum VARCHAR(64), -- Metadata title VARCHAR(200), description TEXT, -- Visibilidad is_visible_to_vendor BOOLEAN NOT NULL DEFAULT TRUE, is_visible_to_buyer BOOLEAN NOT NULL DEFAULT TRUE, -- Carga uploaded_by_type VARCHAR(20) NOT NULL, -- 'internal', 'portal' uploaded_by UUID NOT NULL, -- Multi-tenant tenant_id UUID NOT NULL, partner_id UUID REFERENCES core.partners(id), -- Auditoría created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT chk_document_type CHECK (document_type IN ( 'rfq', 'rfq_response', 'purchase_order', 'invoice', 'delivery_note', 'certificate', 'contract', 'other' )) ); CREATE INDEX idx_portal_docs_reference ON portal.portal_documents(reference_type, reference_id); CREATE INDEX idx_portal_docs_partner ON portal.portal_documents(partner_id); CREATE INDEX idx_portal_docs_type ON portal.portal_documents(document_type); -- RLS ALTER TABLE portal.portal_documents ENABLE ROW LEVEL SECURITY; -- ----------------------------------------------------------------------------- -- Tabla: portal_messages (Mensajes entre comprador y proveedor) -- ----------------------------------------------------------------------------- CREATE TABLE portal.portal_messages ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), -- Referencia al documento (RFQ, PO, etc.) reference_type VARCHAR(50) NOT NULL, reference_id UUID NOT NULL, -- Participantes sender_type VARCHAR(20) NOT NULL, -- 'internal', 'portal' sender_id UUID NOT NULL, partner_id UUID NOT NULL REFERENCES core.partners(id), -- Contenido subject VARCHAR(200), body TEXT NOT NULL, -- Estado is_read BOOLEAN NOT NULL DEFAULT FALSE, read_at TIMESTAMPTZ, -- Adjuntos attachment_ids UUID[], -- Multi-tenant tenant_id UUID NOT NULL, -- Auditoría created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_portal_messages_reference ON portal.portal_messages(reference_type, reference_id); CREATE INDEX idx_portal_messages_partner ON portal.portal_messages(partner_id); CREATE INDEX idx_portal_messages_unread ON portal.portal_messages(partner_id, is_read) WHERE is_read = FALSE; -- RLS ALTER TABLE portal.portal_messages ENABLE ROW LEVEL SECURITY; -- ----------------------------------------------------------------------------- -- Vista: Portal RFQs disponibles para un proveedor -- ----------------------------------------------------------------------------- CREATE OR REPLACE VIEW portal.vendor_rfqs AS SELECT r.id, r.code, r.name, r.request_date, r.deadline_date, r.status as rfq_status, r.company_id, c.name as company_name, -- Líneas (SELECT COUNT(*) FROM purchasing.rfq_lines WHERE rfq_id = r.id) as line_count, -- Respuesta del proveedor (si existe) rr.id as response_id, rr.response_status, rr.submitted_at, rr.total_amount as quoted_amount, -- Fechas importantes CASE WHEN r.deadline_date < CURRENT_DATE THEN 'expired' WHEN r.deadline_date = CURRENT_DATE THEN 'today' WHEN r.deadline_date <= CURRENT_DATE + 3 THEN 'urgent' ELSE 'normal' END as urgency, r.tenant_id FROM purchasing.rfqs r JOIN core.companies c ON c.id = r.company_id LEFT JOIN portal.rfq_responses rr ON rr.rfq_id = r.id WHERE r.status IN ('sent', 'responded', 'accepted', 'rejected'); -- ----------------------------------------------------------------------------- -- Vista: Órdenes de compra visibles para proveedor -- ----------------------------------------------------------------------------- CREATE OR REPLACE VIEW portal.vendor_purchase_orders AS SELECT po.id, po.code, po.order_date, po.expected_date, po.status, po.amount_total, po.currency_id, cur.code as currency_code, po.company_id, c.name as company_name, po.partner_id, -- Líneas (SELECT COUNT(*) FROM purchasing.purchase_order_lines WHERE order_id = po.id) as line_count, -- Estado de recepción po.receipt_status, -- Estado de facturación po.invoice_status, po.tenant_id FROM purchasing.purchase_orders po JOIN core.companies c ON c.id = po.company_id JOIN core.currencies cur ON cur.id = po.currency_id WHERE po.status NOT IN ('draft', 'cancelled'); ``` --- ## 3. Servicios del Portal ### 3.1 Servicio de Autenticación Portal ```python # services/portal_auth_service.py from typing import Optional, Tuple from uuid import UUID from datetime import datetime, timedelta import secrets import hashlib from dataclasses import dataclass @dataclass class PortalToken: access_token: str refresh_token: str expires_in: int token_type: str = "Bearer" class PortalAuthService: """Servicio de autenticación para el portal de proveedores.""" def __init__( self, db_session, password_hasher, jwt_service, email_service ): self.db = db_session self.hasher = password_hasher self.jwt = jwt_service self.email = email_service # Configuración MAX_FAILED_ATTEMPTS = 5 LOCKOUT_DURATION_MINUTES = 30 SESSION_DURATION_HOURS = 24 REFRESH_TOKEN_DAYS = 7 async def authenticate( self, email: str, password: str, tenant_id: UUID, ip_address: Optional[str] = None, user_agent: Optional[str] = None ) -> Tuple['PortalUser', PortalToken]: """ Autentica un usuario del portal. Proceso: 1. Buscar usuario por email y tenant 2. Verificar que no está bloqueado 3. Validar contraseña 4. Crear sesión y tokens 5. Registrar login Returns: Tuple[PortalUser, PortalToken] Raises: AuthenticationError: Si credenciales inválidas o usuario bloqueado """ # Buscar usuario user = await self.db.execute( """ SELECT * FROM portal.portal_users WHERE email = :email AND tenant_id = :tenant_id """, {'email': email.lower(), 'tenant_id': tenant_id} ) if not user: raise AuthenticationError("Credenciales inválidas") # Verificar bloqueo if user.locked_until and user.locked_until > datetime.utcnow(): remaining = (user.locked_until - datetime.utcnow()).seconds // 60 raise AuthenticationError( f"Cuenta bloqueada. Intente de nuevo en {remaining} minutos" ) # Verificar cuenta activa if not user.is_active: raise AuthenticationError("Cuenta desactivada") # Verificar contraseña if not self.hasher.verify(password, user.password_hash): await self._handle_failed_login(user) raise AuthenticationError("Credenciales inválidas") # Verificar email verificado if not user.is_verified: raise AuthenticationError( "Debe verificar su email antes de iniciar sesión" ) # Limpiar intentos fallidos await self._clear_failed_attempts(user.id) # Crear sesión session = await self._create_session( user_id=user.id, ip_address=ip_address, user_agent=user_agent ) # Generar tokens tokens = await self._generate_tokens(user, session) # Registrar login await self.db.execute( """ UPDATE portal.portal_users SET last_login_at = CURRENT_TIMESTAMP WHERE id = :user_id """, {'user_id': user.id} ) return user, tokens async def register_portal_user( self, partner_id: UUID, email: str, name: str, tenant_id: UUID, invited_by: Optional[UUID] = None ) -> 'PortalUser': """ Registra un nuevo usuario del portal. El usuario recibirá un email para establecer su contraseña. """ # Verificar que el partner existe partner = await self.db.execute( "SELECT id, name FROM core.partners WHERE id = :id", {'id': partner_id} ) if not partner: raise ValidationError("Proveedor no encontrado") # Verificar email único existing = await self.db.execute( """ SELECT id FROM portal.portal_users WHERE email = :email AND tenant_id = :tenant_id """, {'email': email.lower(), 'tenant_id': tenant_id} ) if existing: raise ValidationError("Este email ya está registrado") # Generar token de verificación verification_token = secrets.token_urlsafe(32) # Crear usuario (sin contraseña aún) user = await self.db.execute( """ INSERT INTO portal.portal_users (partner_id, email, name, password_hash, tenant_id, verification_token, verification_expires_at, created_by) VALUES (:partner_id, :email, :name, '', :tenant_id, :token, :expires, :created_by) RETURNING * """, { 'partner_id': partner_id, 'email': email.lower(), 'name': name, 'tenant_id': tenant_id, 'token': verification_token, 'expires': datetime.utcnow() + timedelta(days=7), 'created_by': invited_by } ) # Enviar email de activación await self.email.send_template( to=email, template='portal_welcome', data={ 'user_name': name, 'company_name': partner.name, 'activation_link': f"/portal/activate?token={verification_token}" } ) return user async def activate_account( self, token: str, password: str ) -> 'PortalUser': """Activa una cuenta de portal y establece la contraseña.""" # Buscar usuario por token user = await self.db.execute( """ SELECT * FROM portal.portal_users WHERE verification_token = :token AND verification_expires_at > CURRENT_TIMESTAMP AND is_verified = FALSE """, {'token': token} ) if not user: raise ValidationError("Token inválido o expirado") # Validar contraseña self._validate_password_strength(password) # Actualizar usuario password_hash = self.hasher.hash(password) await self.db.execute( """ UPDATE portal.portal_users SET password_hash = :password_hash, is_verified = TRUE, verification_token = NULL, verification_expires_at = NULL, password_changed_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = :user_id """, {'user_id': user.id, 'password_hash': password_hash} ) return await self._get_user(user.id) async def request_password_reset( self, email: str, tenant_id: UUID ): """Solicita restablecimiento de contraseña.""" user = await self.db.execute( """ SELECT * FROM portal.portal_users WHERE email = :email AND tenant_id = :tenant_id AND is_active = TRUE """, {'email': email.lower(), 'tenant_id': tenant_id} ) if not user: # No revelar si el email existe return # Generar token reset_token = secrets.token_urlsafe(32) await self.db.execute( """ UPDATE portal.portal_users SET verification_token = :token, verification_expires_at = :expires WHERE id = :user_id """, { 'user_id': user.id, 'token': reset_token, 'expires': datetime.utcnow() + timedelta(hours=24) } ) # Enviar email await self.email.send_template( to=email, template='portal_password_reset', data={ 'user_name': user.name, 'reset_link': f"/portal/reset-password?token={reset_token}" } ) async def logout( self, session_id: UUID ): """Cierra sesión del portal.""" await self.db.execute( """ UPDATE portal.portal_sessions SET revoked_at = CURRENT_TIMESTAMP WHERE id = :session_id """, {'session_id': session_id} ) async def _create_session( self, user_id: UUID, ip_address: Optional[str], user_agent: Optional[str] ) -> 'PortalSession': """Crea una nueva sesión de portal.""" token = secrets.token_urlsafe(32) refresh_token = secrets.token_urlsafe(32) session = await self.db.execute( """ INSERT INTO portal.portal_sessions (user_id, token_hash, refresh_token_hash, ip_address, user_agent, device_type, expires_at) VALUES (:user_id, :token_hash, :refresh_hash, :ip, :ua, :device, CURRENT_TIMESTAMP + :duration) RETURNING * """, { 'user_id': user_id, 'token_hash': hashlib.sha256(token.encode()).hexdigest(), 'refresh_hash': hashlib.sha256(refresh_token.encode()).hexdigest(), 'ip': ip_address, 'ua': user_agent, 'device': self._detect_device_type(user_agent), 'duration': timedelta(hours=self.SESSION_DURATION_HOURS) } ) session.raw_token = token session.raw_refresh_token = refresh_token return session async def _generate_tokens( self, user: 'PortalUser', session: 'PortalSession' ) -> PortalToken: """Genera JWT tokens para el usuario.""" # Payload del token payload = { 'sub': str(user.id), 'partner_id': str(user.partner_id), 'tenant_id': str(user.tenant_id), 'email': user.email, 'type': 'portal', 'session_id': str(session.id) } access_token = self.jwt.encode( payload, expires_delta=timedelta(hours=self.SESSION_DURATION_HOURS) ) return PortalToken( access_token=access_token, refresh_token=session.raw_refresh_token, expires_in=self.SESSION_DURATION_HOURS * 3600 ) async def _handle_failed_login(self, user: 'PortalUser'): """Maneja un intento de login fallido.""" new_count = user.failed_login_count + 1 update_data = { 'user_id': user.id, 'count': new_count, 'locked_until': None } if new_count >= self.MAX_FAILED_ATTEMPTS: update_data['locked_until'] = ( datetime.utcnow() + timedelta(minutes=self.LOCKOUT_DURATION_MINUTES) ) await self.db.execute( """ UPDATE portal.portal_users SET failed_login_count = :count, locked_until = :locked_until WHERE id = :user_id """, update_data ) def _validate_password_strength(self, password: str): """Valida requisitos de contraseña.""" if len(password) < 8: raise ValidationError("La contraseña debe tener al menos 8 caracteres") if not any(c.isupper() for c in password): raise ValidationError("La contraseña debe incluir mayúsculas") if not any(c.islower() for c in password): raise ValidationError("La contraseña debe incluir minúsculas") if not any(c.isdigit() for c in password): raise ValidationError("La contraseña debe incluir números") ``` ### 3.2 Servicio de Respuesta a RFQs ```python # services/rfq_response_service.py from typing import Optional, List, Dict from uuid import UUID from datetime import date, datetime from decimal import Decimal from enum import Enum class RFQResponseStatus(str, Enum): DRAFT = "draft" SUBMITTED = "submitted" REVISED = "revised" ACCEPTED = "accepted" REJECTED = "rejected" EXPIRED = "expired" class RFQResponseService: """Servicio para gestión de respuestas a RFQs desde el portal.""" def __init__(self, db_session, notification_service, document_service): self.db = db_session self.notifications = notification_service self.documents = document_service async def get_available_rfqs( self, partner_id: UUID, tenant_id: UUID, filters: Optional[Dict] = None ) -> List[Dict]: """ Obtiene RFQs disponibles para responder por un proveedor. Solo muestra RFQs donde: - El proveedor está en la lista de partner_ids del RFQ - El RFQ está en estado 'sent' - El deadline no ha pasado (o muestra como expirado) """ query = """ SELECT r.*, c.name as company_name, (SELECT json_agg(json_build_object( 'id', rl.id, 'product_id', rl.product_id, 'product_name', p.name, 'description', rl.description, 'quantity', rl.quantity, 'uom_name', u.name )) FROM purchasing.rfq_lines rl LEFT JOIN inventory.products p ON p.id = rl.product_id LEFT JOIN inventory.units_of_measure u ON u.id = rl.uom_id WHERE rl.rfq_id = r.id) as lines, -- Respuesta existente del proveedor rr.id as response_id, rr.response_status, rr.total_amount as quoted_total FROM purchasing.rfqs r JOIN core.companies c ON c.id = r.company_id LEFT JOIN portal.rfq_responses rr ON rr.rfq_id = r.id AND rr.partner_id = :partner_id WHERE r.tenant_id = :tenant_id AND :partner_id = ANY(r.partner_ids) AND r.status IN ('sent', 'responded') ORDER BY CASE WHEN r.deadline_date < CURRENT_DATE THEN 1 ELSE 0 END, r.deadline_date ASC """ params = {'partner_id': partner_id, 'tenant_id': tenant_id} # Aplicar filtros opcionales if filters: if filters.get('status'): query = query.replace( "AND r.status IN ('sent', 'responded')", f"AND r.status = '{filters['status']}'" ) return await self.db.execute(query, params) async def get_rfq_detail( self, rfq_id: UUID, partner_id: UUID, tenant_id: UUID ) -> Dict: """Obtiene detalle de un RFQ para el proveedor.""" # Verificar acceso rfq = await self._verify_rfq_access(rfq_id, partner_id, tenant_id) # Obtener líneas lines = await self.db.execute( """ SELECT rl.*, p.name as product_name, p.default_code as product_code, u.name as uom_name, -- Respuesta existente para esta línea rrl.unit_price as quoted_price, rrl.quoted_qty, rrl.lead_time_days, rrl.can_supply, rrl.line_notes FROM purchasing.rfq_lines rl LEFT JOIN inventory.products p ON p.id = rl.product_id LEFT JOIN inventory.units_of_measure u ON u.id = rl.uom_id LEFT JOIN portal.rfq_responses rr ON rr.rfq_id = rl.rfq_id AND rr.partner_id = :partner_id LEFT JOIN portal.rfq_response_lines rrl ON rrl.response_id = rr.id AND rrl.rfq_line_id = rl.id WHERE rl.rfq_id = :rfq_id ORDER BY rl.sequence """, {'rfq_id': rfq_id, 'partner_id': partner_id} ) # Obtener documentos adjuntos documents = await self.documents.get_for_reference( reference_type='rfq', reference_id=rfq_id, visible_to_vendor=True ) # Obtener mensajes messages = await self._get_rfq_messages(rfq_id, partner_id) return { 'rfq': rfq, 'lines': lines, 'documents': documents, 'messages': messages, 'can_respond': rfq.status == 'sent' and rfq.deadline_date >= date.today() } async def create_or_update_response( self, rfq_id: UUID, partner_id: UUID, portal_user_id: UUID, tenant_id: UUID, lines: List[Dict], vendor_notes: Optional[str] = None, valid_until: Optional[date] = None, expected_delivery: Optional[date] = None, payment_terms: Optional[str] = None ) -> 'RFQResponse': """ Crea o actualiza una respuesta a RFQ. Args: rfq_id: ID del RFQ partner_id: ID del proveedor portal_user_id: Usuario del portal que responde tenant_id: Tenant lines: Lista de líneas con precios vendor_notes: Notas del proveedor valid_until: Fecha de validez de la cotización expected_delivery: Fecha estimada de entrega payment_terms: Términos de pago Returns: RFQResponse creada/actualizada """ # Verificar acceso rfq = await self._verify_rfq_access(rfq_id, partner_id, tenant_id) if rfq.status not in ['sent', 'responded']: raise ValidationError("Este RFQ ya no acepta respuestas") if rfq.deadline_date < date.today(): raise ValidationError("El plazo para responder ha expirado") # Buscar respuesta existente existing = await self.db.execute( """ SELECT id FROM portal.rfq_responses WHERE rfq_id = :rfq_id AND partner_id = :partner_id """, {'rfq_id': rfq_id, 'partner_id': partner_id} ) if existing: response_id = existing.id # Actualizar respuesta existente await self.db.execute( """ UPDATE portal.rfq_responses SET vendor_notes = :notes, valid_until = :valid_until, expected_delivery = :expected_delivery, payment_terms = :payment_terms, response_status = 'draft', updated_at = CURRENT_TIMESTAMP WHERE id = :response_id """, { 'response_id': response_id, 'notes': vendor_notes, 'valid_until': valid_until, 'expected_delivery': expected_delivery, 'payment_terms': payment_terms } ) else: # Crear nueva respuesta result = await self.db.execute( """ INSERT INTO portal.rfq_responses (rfq_id, partner_id, portal_user_id, tenant_id, vendor_notes, valid_until, expected_delivery, payment_terms, currency_id) SELECT :rfq_id, :partner_id, :portal_user_id, :tenant_id, :notes, :valid_until, :expected_delivery, :payment_terms, r.currency_id FROM purchasing.rfqs r WHERE r.id = :rfq_id RETURNING id """, { 'rfq_id': rfq_id, 'partner_id': partner_id, 'portal_user_id': portal_user_id, 'tenant_id': tenant_id, 'notes': vendor_notes, 'valid_until': valid_until, 'expected_delivery': expected_delivery, 'payment_terms': payment_terms } ) response_id = result.id # Actualizar líneas await self._update_response_lines(response_id, lines) # Recalcular total await self._recalculate_response_total(response_id) return await self._get_response(response_id) async def submit_response( self, response_id: UUID, portal_user_id: UUID ) -> 'RFQResponse': """ Envía/confirma una respuesta a RFQ. Cambia estado de draft → submitted y notifica al comprador. """ response = await self._get_response(response_id) if response.response_status != RFQResponseStatus.DRAFT.value: raise ValidationError("Solo se pueden enviar respuestas en borrador") # Validar que todas las líneas tienen precio incomplete = await self.db.execute( """ SELECT COUNT(*) FROM portal.rfq_response_lines WHERE response_id = :response_id AND (unit_price IS NULL OR unit_price = 0) AND can_supply = TRUE """, {'response_id': response_id} ) if incomplete.count > 0: raise ValidationError( f"Hay {incomplete.count} líneas sin precio. " "Complete todos los precios o marque como 'No puede suministrar'" ) # Actualizar estado await self.db.execute( """ UPDATE portal.rfq_responses SET response_status = 'submitted', submitted_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = :response_id """, {'response_id': response_id} ) # Actualizar RFQ a estado responded si no lo está await self.db.execute( """ UPDATE purchasing.rfqs SET status = 'responded', response_date = CURRENT_DATE, updated_at = CURRENT_TIMESTAMP WHERE id = :rfq_id AND status = 'sent' """, {'rfq_id': response.rfq_id} ) # Notificar al comprador await self.notifications.notify_rfq_response_received( rfq_id=response.rfq_id, partner_id=response.partner_id ) return await self._get_response(response_id) async def _update_response_lines( self, response_id: UUID, lines: List[Dict] ): """Actualiza las líneas de respuesta.""" for line_data in lines: # Verificar si existe existing = await self.db.execute( """ SELECT id FROM portal.rfq_response_lines WHERE response_id = :response_id AND rfq_line_id = :rfq_line_id """, { 'response_id': response_id, 'rfq_line_id': line_data['rfq_line_id'] } ) if existing: # Actualizar await self.db.execute( """ UPDATE portal.rfq_response_lines SET unit_price = :unit_price, quoted_qty = :quoted_qty, discount_percent = :discount, lead_time_days = :lead_time, delivery_date = :delivery_date, can_supply = :can_supply, line_notes = :notes WHERE id = :line_id """, { 'line_id': existing.id, 'unit_price': line_data.get('unit_price'), 'quoted_qty': line_data.get('quoted_qty'), 'discount': line_data.get('discount_percent', 0), 'lead_time': line_data.get('lead_time_days'), 'delivery_date': line_data.get('delivery_date'), 'can_supply': line_data.get('can_supply', True), 'notes': line_data.get('notes') } ) else: # Obtener datos de la línea RFQ rfq_line = await self.db.execute( "SELECT * FROM purchasing.rfq_lines WHERE id = :id", {'id': line_data['rfq_line_id']} ) # Insertar await self.db.execute( """ INSERT INTO portal.rfq_response_lines (response_id, rfq_line_id, product_id, requested_qty, uom_id, unit_price, quoted_qty, discount_percent, lead_time_days, delivery_date, can_supply, line_notes) VALUES (:response_id, :rfq_line_id, :product_id, :requested_qty, :uom_id, :unit_price, :quoted_qty, :discount, :lead_time, :delivery_date, :can_supply, :notes) """, { 'response_id': response_id, 'rfq_line_id': line_data['rfq_line_id'], 'product_id': rfq_line.product_id, 'requested_qty': rfq_line.quantity, 'uom_id': rfq_line.uom_id, 'unit_price': line_data.get('unit_price'), 'quoted_qty': line_data.get('quoted_qty', rfq_line.quantity), 'discount': line_data.get('discount_percent', 0), 'lead_time': line_data.get('lead_time_days'), 'delivery_date': line_data.get('delivery_date'), 'can_supply': line_data.get('can_supply', True), 'notes': line_data.get('notes') } ) async def _recalculate_response_total(self, response_id: UUID): """Recalcula el total de la respuesta.""" await self.db.execute( """ UPDATE portal.rfq_responses SET total_amount = ( SELECT COALESCE(SUM(subtotal), 0) FROM portal.rfq_response_lines WHERE response_id = :response_id AND can_supply = TRUE ), updated_at = CURRENT_TIMESTAMP WHERE id = :response_id """, {'response_id': response_id} ) async def _verify_rfq_access( self, rfq_id: UUID, partner_id: UUID, tenant_id: UUID ) -> 'RFQ': """Verifica que el proveedor tiene acceso al RFQ.""" rfq = await self.db.execute( """ SELECT * FROM purchasing.rfqs WHERE id = :rfq_id AND tenant_id = :tenant_id AND :partner_id = ANY(partner_ids) """, {'rfq_id': rfq_id, 'partner_id': partner_id, 'tenant_id': tenant_id} ) if not rfq: raise NotFoundError("RFQ no encontrado o sin acceso") return rfq ``` --- ## 4. API REST del Portal ### 4.1 Endpoints ```yaml # Autenticación Portal POST /api/portal/v1/auth/login # Iniciar sesión POST /api/portal/v1/auth/logout # Cerrar sesión POST /api/portal/v1/auth/refresh # Refrescar token POST /api/portal/v1/auth/forgot-password # Solicitar reset POST /api/portal/v1/auth/reset-password # Restablecer contraseña POST /api/portal/v1/auth/activate # Activar cuenta # Dashboard GET /api/portal/v1/dashboard # Dashboard del proveedor GET /api/portal/v1/dashboard/stats # Estadísticas # RFQs GET /api/portal/v1/rfqs # Listar RFQs disponibles GET /api/portal/v1/rfqs/{id} # Detalle de RFQ POST /api/portal/v1/rfqs/{id}/response # Crear/actualizar respuesta POST /api/portal/v1/rfqs/{id}/submit # Enviar respuesta GET /api/portal/v1/rfqs/{id}/response # Ver mi respuesta # Órdenes de Compra GET /api/portal/v1/purchase-orders # Listar OCs GET /api/portal/v1/purchase-orders/{id} # Detalle de OC POST /api/portal/v1/purchase-orders/{id}/confirm-dates # Confirmar fechas GET /api/portal/v1/purchase-orders/{id}/pdf # Descargar PDF # Documentos GET /api/portal/v1/documents # Listar documentos POST /api/portal/v1/documents # Subir documento GET /api/portal/v1/documents/{id} # Descargar documento DELETE /api/portal/v1/documents/{id} # Eliminar documento # Facturas GET /api/portal/v1/invoices # Listar facturas GET /api/portal/v1/invoices/{id} # Detalle de factura GET /api/portal/v1/invoices/{id}/pdf # Descargar PDF # Mensajes GET /api/portal/v1/messages # Listar mensajes POST /api/portal/v1/messages # Enviar mensaje PATCH /api/portal/v1/messages/{id}/read # Marcar como leído # Perfil GET /api/portal/v1/profile # Ver perfil PUT /api/portal/v1/profile # Actualizar perfil PUT /api/portal/v1/profile/password # Cambiar contraseña PUT /api/portal/v1/profile/notifications # Preferencias de notificación ``` ### 4.2 Schemas ```python # schemas/portal_schemas.py from pydantic import BaseModel, Field, EmailStr from typing import Optional, List from uuid import UUID from decimal import Decimal from datetime import date, datetime # ================== AUTH ================== class PortalLoginRequest(BaseModel): email: EmailStr password: str remember_me: bool = False class PortalLoginResponse(BaseModel): access_token: str refresh_token: str expires_in: int token_type: str = "Bearer" user: 'PortalUserResponse' class PortalUserResponse(BaseModel): id: UUID email: str name: str partner_id: UUID partner_name: str company_name: Optional[str] language: str timezone: str last_login_at: Optional[datetime] # ================== DASHBOARD ================== class DashboardResponse(BaseModel): pending_rfqs: int urgent_rfqs: int active_orders: int pending_deliveries: int pending_invoices: int unread_messages: int recent_rfqs: List['RFQSummary'] recent_orders: List['OrderSummary'] class RFQSummary(BaseModel): id: UUID code: str company_name: str deadline_date: date line_count: int urgency: str has_response: bool class OrderSummary(BaseModel): id: UUID code: str company_name: str order_date: date amount_total: Decimal status: str # ================== RFQ RESPONSE ================== class RFQDetailResponse(BaseModel): rfq: 'RFQInfo' lines: List['RFQLineInfo'] documents: List['DocumentInfo'] messages: List['MessageInfo'] my_response: Optional['RFQResponseInfo'] can_respond: bool class RFQInfo(BaseModel): id: UUID code: str name: Optional[str] company_id: UUID company_name: str request_date: date deadline_date: date status: str buyer_notes: Optional[str] class RFQLineInfo(BaseModel): id: UUID product_id: Optional[UUID] product_name: Optional[str] product_code: Optional[str] description: Optional[str] quantity: Decimal uom_name: str class RFQResponseLineInput(BaseModel): rfq_line_id: UUID unit_price: Optional[Decimal] = Field(None, ge=0) quoted_qty: Optional[Decimal] = Field(None, ge=0) discount_percent: Decimal = Field(default=0, ge=0, le=100) lead_time_days: Optional[int] = Field(None, ge=0) delivery_date: Optional[date] = None can_supply: bool = True notes: Optional[str] = None class RFQResponseInput(BaseModel): lines: List[RFQResponseLineInput] vendor_notes: Optional[str] = None valid_until: Optional[date] = None expected_delivery: Optional[date] = None payment_terms: Optional[str] = None delivery_terms: Optional[str] = None class RFQResponseInfo(BaseModel): id: UUID response_status: str total_amount: Optional[Decimal] submitted_at: Optional[datetime] valid_until: Optional[date] lines: List['RFQResponseLineInfo'] class RFQResponseLineInfo(BaseModel): rfq_line_id: UUID product_name: Optional[str] requested_qty: Decimal quoted_qty: Optional[Decimal] unit_price: Optional[Decimal] discount_percent: Decimal subtotal: Optional[Decimal] lead_time_days: Optional[int] can_supply: bool notes: Optional[str] # ================== PURCHASE ORDERS ================== class PurchaseOrderListResponse(BaseModel): orders: List['PurchaseOrderSummary'] total: int page: int page_size: int class PurchaseOrderSummary(BaseModel): id: UUID code: str order_date: date expected_date: Optional[date] company_name: str amount_total: Decimal currency_code: str status: str receipt_status: str invoice_status: str line_count: int class PurchaseOrderDetailResponse(BaseModel): order: 'PurchaseOrderInfo' lines: List['PurchaseOrderLineInfo'] documents: List['DocumentInfo'] delivery_history: List['DeliveryInfo'] class PurchaseOrderInfo(BaseModel): id: UUID code: str order_date: date expected_date: Optional[date] company_id: UUID company_name: str buyer_name: str amount_untaxed: Decimal amount_tax: Decimal amount_total: Decimal currency_code: str status: str receipt_status: str invoice_status: str payment_terms: Optional[str] notes: Optional[str] class PurchaseOrderLineInfo(BaseModel): id: UUID product_id: Optional[UUID] product_name: str product_code: Optional[str] description: Optional[str] quantity: Decimal received_qty: Decimal unit_price: Decimal subtotal: Decimal expected_date: Optional[date] class ConfirmDatesRequest(BaseModel): expected_date: date line_dates: Optional[List[dict]] = None # [{line_id, expected_date}] notes: Optional[str] = None # ================== DOCUMENTS ================== class DocumentInfo(BaseModel): id: UUID document_type: str file_name: str file_size: int mime_type: str title: Optional[str] created_at: datetime class DocumentUploadRequest(BaseModel): document_type: str reference_type: str reference_id: UUID title: Optional[str] = None description: Optional[str] = None # ================== MESSAGES ================== class MessageInfo(BaseModel): id: UUID sender_type: str sender_name: str subject: Optional[str] body: str is_read: bool created_at: datetime attachments: List[str] class SendMessageRequest(BaseModel): reference_type: str reference_id: UUID subject: Optional[str] = None body: str attachment_ids: Optional[List[UUID]] = None ``` --- ## 5. Interfaz de Usuario ### 5.1 Páginas del Portal ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ ESTRUCTURA DE PÁGINAS │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ /portal/login │ │ ├─► Formulario de login │ │ ├─► Link "Olvidé mi contraseña" │ │ └─► Logo de la empresa (multi-tenant) │ │ │ │ /portal/dashboard │ │ ├─► Widgets de resumen (RFQs pendientes, OCs activas, etc.) │ │ ├─► Lista de RFQs urgentes │ │ ├─► Notificaciones recientes │ │ └─► Accesos rápidos │ │ │ │ /portal/rfqs │ │ ├─► Lista filtrable de RFQs │ │ │ ├─► Filtros: estado, fecha, búsqueda │ │ │ └─► Indicadores de urgencia │ │ └─► Acciones: Ver, Responder │ │ │ │ /portal/rfqs/:id │ │ ├─► Header: código, empresa, fechas, estado │ │ ├─► Tabla de líneas solicitadas │ │ ├─► Formulario de respuesta │ │ │ ├─► Por cada línea: precio, cantidad, plazo │ │ │ ├─► Notas generales │ │ │ └─► Términos de pago/entrega │ │ ├─► Upload de documentos │ │ ├─► Historial de mensajes │ │ └─► Botones: Guardar borrador, Enviar cotización │ │ │ │ /portal/orders │ │ ├─► Lista de órdenes de compra │ │ │ ├─► Filtros: estado, fecha, búsqueda │ │ │ └─► Estados: Confirmada, En tránsito, Recibida │ │ └─► Acciones: Ver detalle, Descargar PDF │ │ │ │ /portal/orders/:id │ │ ├─► Header: código, fecha, estado │ │ ├─► Información del comprador │ │ ├─► Tabla de líneas │ │ │ └─► Producto, cantidad, precio, subtotal │ │ ├─► Historial de entregas │ │ ├─► Estado de facturación │ │ └─► Documentos relacionados │ │ │ │ /portal/documents │ │ ├─► Lista de documentos por categoría │ │ ├─► Upload de nuevos documentos │ │ └─► Búsqueda y filtros │ │ │ │ /portal/profile │ │ ├─► Información de la empresa │ │ ├─► Datos de contacto │ │ ├─► Cambio de contraseña │ │ └─► Preferencias de notificación │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ ``` ### 5.2 Componentes Principales ```tsx // Ejemplo de estructura de componentes React // Layout principal del portal Dashboard Solicitudes de Cotización Órdenes de Compra Documentos Mensajes Mi Perfil {/* Contenido de la página */} // Página de respuesta a RFQ {lines.map(line => ( ))} ``` --- ## 6. Notificaciones ### 6.1 Eventos de Notificación ```python # notifications/portal_notifications.py class PortalNotificationEvents: """Eventos que generan notificaciones en el portal.""" # RFQ Events RFQ_RECEIVED = "rfq.received" # Nueva RFQ para el proveedor RFQ_REMINDER = "rfq.reminder" # Recordatorio de deadline RFQ_RESPONSE_ACCEPTED = "rfq.accepted" # Cotización aceptada RFQ_RESPONSE_REJECTED = "rfq.rejected" # Cotización rechazada # PO Events PO_CONFIRMED = "po.confirmed" # Nueva OC confirmada PO_UPDATED = "po.updated" # OC modificada PO_CANCELLED = "po.cancelled" # OC cancelada # Payment Events PAYMENT_RECEIVED = "payment.received" # Pago recibido INVOICE_DUE = "invoice.due" # Factura por vencer # Message Events MESSAGE_RECEIVED = "message.received" # Nuevo mensaje del comprador class PortalNotificationService: """Servicio de notificaciones para el portal.""" async def notify_rfq_received( self, rfq_id: UUID, partner_ids: List[UUID] ): """Notifica a proveedores sobre nueva RFQ.""" rfq = await self._get_rfq(rfq_id) for partner_id in partner_ids: # Obtener usuarios portal del proveedor portal_users = await self._get_portal_users(partner_id) for user in portal_users: # Verificar preferencias if not user.notification_preferences.get('rfq_received', True): continue # Crear notificación in-app await self._create_notification( user_id=user.id, event=PortalNotificationEvents.RFQ_RECEIVED, title="Nueva Solicitud de Cotización", body=f"Ha recibido una nueva solicitud de cotización {rfq.code}", data={'rfq_id': rfq_id}, link=f"/portal/rfqs/{rfq_id}" ) # Enviar email await self.email_service.send_template( to=user.email, template='portal_rfq_received', data={ 'user_name': user.name, 'rfq_code': rfq.code, 'company_name': rfq.company_name, 'deadline': rfq.deadline_date.strftime('%d/%m/%Y'), 'line_count': len(rfq.lines), 'portal_link': f"{self.portal_url}/rfqs/{rfq_id}" } ) async def send_rfq_reminder(self): """Job para enviar recordatorios de RFQs próximos a vencer.""" # RFQs que vencen en 2 días sin respuesta expiring_rfqs = await self.db.execute( """ SELECT DISTINCT r.*, p.id as partner_id FROM purchasing.rfqs r CROSS JOIN UNNEST(r.partner_ids) as p(id) LEFT JOIN portal.rfq_responses rr ON rr.rfq_id = r.id AND rr.partner_id = p.id WHERE r.status = 'sent' AND r.deadline_date = CURRENT_DATE + 2 AND (rr.id IS NULL OR rr.response_status = 'draft') """ ) for row in expiring_rfqs: await self.notify_rfq_reminder( rfq_id=row.id, partner_id=row.partner_id, days_remaining=2 ) ``` ### 6.2 Templates de Email ```html

Nueva Solicitud de Cotización

Estimado/a {{ user_name }},

Ha recibido una nueva solicitud de cotización de {{ company_name }}.

Referencia: {{ rfq_code }}
Productos: {{ line_count }} líneas
Fecha límite: {{ deadline }}

Ver Solicitud y Cotizar

Si tiene alguna pregunta, puede responder directamente desde el portal.

``` --- ## 7. Seguridad ### 7.1 Políticas de Acceso ```python PORTAL_SECURITY_CONFIG = { # Autenticación 'password_min_length': 8, 'password_require_uppercase': True, 'password_require_lowercase': True, 'password_require_number': True, 'password_require_special': False, 'password_expiry_days': 90, # Sesiones 'session_duration_hours': 24, 'refresh_token_days': 7, 'max_concurrent_sessions': 5, 'session_inactivity_timeout_minutes': 60, # Bloqueo 'max_failed_attempts': 5, 'lockout_duration_minutes': 30, # Rate limiting 'login_rate_limit': '10/minute', 'api_rate_limit': '100/minute', # CORS 'allowed_origins': ['https://portal.empresa.com'], # Documentos 'max_file_size_mb': 10, 'allowed_file_types': ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.png', '.jpg'], } ``` ### 7.2 Row Level Security ```sql -- Políticas RLS para aislamiento de datos en portal -- Portal users solo ven sus propios datos CREATE POLICY portal_users_self ON portal.portal_users FOR ALL USING ( id = current_setting('app.current_portal_user_id')::uuid OR current_setting('app.is_internal_user')::boolean = TRUE ); -- RFQ responses: proveedor solo ve sus propias respuestas CREATE POLICY rfq_responses_partner ON portal.rfq_responses FOR ALL USING ( partner_id = current_setting('app.current_partner_id')::uuid OR current_setting('app.is_internal_user')::boolean = TRUE ); -- Documentos: según visibilidad configurada CREATE POLICY portal_documents_access ON portal.portal_documents FOR SELECT USING ( (is_visible_to_vendor = TRUE AND partner_id = current_setting('app.current_partner_id')::uuid) OR current_setting('app.is_internal_user')::boolean = TRUE ); -- Mensajes: solo participantes CREATE POLICY portal_messages_access ON portal.portal_messages FOR ALL USING ( partner_id = current_setting('app.current_partner_id')::uuid OR current_setting('app.is_internal_user')::boolean = TRUE ); ``` --- ## 8. Referencias ### 8.1 Odoo - `addons/portal/` (Framework de portal) - `addons/purchase/` (Compras y RFQs) - `addons/purchase_requisition/` (Licitaciones) ### 8.2 Documentos Relacionados - SPEC-COMPRAS (módulo base de compras) - SPEC-BLANKET-ORDERS (acuerdos marco) - SPEC-OAUTH2-SOCIAL-LOGIN (autenticación) --- ## Historial de Cambios | Versión | Fecha | Autor | Cambios | |---------|-------|-------|---------| | 1.0.0 | 2025-01-15 | AI Assistant | Versión inicial |