# 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
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 |