erp-core/docs/04-modelado/especificaciones-tecnicas/transversal/SPEC-PORTAL-PROVEEDORES.md

72 KiB

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

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

# 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

# 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

# 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

# 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

// Ejemplo de estructura de componentes React

// Layout principal del portal
<PortalLayout>
  <PortalHeader>
    <CompanyLogo />
    <UserMenu />
    <NotificationBell />
  </PortalHeader>

  <PortalSidebar>
    <NavItem icon="dashboard" to="/portal/dashboard">Dashboard</NavItem>
    <NavItem icon="file-text" to="/portal/rfqs" badge={pendingRfqs}>
      Solicitudes de Cotización
    </NavItem>
    <NavItem icon="shopping-cart" to="/portal/orders">
      Órdenes de Compra
    </NavItem>
    <NavItem icon="file" to="/portal/documents">
      Documentos
    </NavItem>
    <NavItem icon="message" to="/portal/messages" badge={unreadMessages}>
      Mensajes
    </NavItem>
    <NavItem icon="user" to="/portal/profile">
      Mi Perfil
    </NavItem>
  </PortalSidebar>

  <PortalContent>
    <Outlet /> {/* Contenido de la página */}
  </PortalContent>
</PortalLayout>

// Página de respuesta a RFQ
<RFQResponsePage rfqId={id}>
  <RFQHeader rfq={rfq} />

  <RFQRequestedItems lines={rfq.lines} />

  <RFQResponseForm
    rfqId={rfq.id}
    lines={rfq.lines}
    existingResponse={myResponse}
    onSave={handleSaveDraft}
    onSubmit={handleSubmit}
  >
    {lines.map(line => (
      <ResponseLineInput
        key={line.id}
        rfqLine={line}
        onChange={updateLine}
      />
    ))}

    <ResponseTerms
      validUntil={validUntil}
      expectedDelivery={expectedDelivery}
      paymentTerms={paymentTerms}
      onChange={updateTerms}
    />

    <VendorNotes
      value={notes}
      onChange={setNotes}
    />

    <DocumentUpload
      referenceType="rfq_response"
      referenceId={responseId}
      onUpload={handleDocumentUpload}
    />
  </RFQResponseForm>

  <RFQMessages
    rfqId={rfq.id}
    messages={messages}
    onSend={sendMessage}
  />

  <FormActions>
    <Button variant="outline" onClick={saveDraft}>
      Guardar Borrador
    </Button>
    <Button variant="primary" onClick={submitResponse}>
      Enviar Cotización
    </Button>
  </FormActions>
</RFQResponsePage>

6. Notificaciones

6.1 Eventos de Notificación

# 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

<!-- templates/portal_rfq_received.html -->
<!DOCTYPE html>
<html>
<head>
    <style>
        .container { max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif; }
        .header { background: #1a56db; color: white; padding: 20px; text-align: center; }
        .content { padding: 20px; }
        .button { display: inline-block; background: #1a56db; color: white;
                  padding: 12px 24px; text-decoration: none; border-radius: 4px; }
        .footer { padding: 20px; text-align: center; color: #666; font-size: 12px; }
    </style>
</head>
<body>
    <div class="container">
        <div class="header">
            <h1>Nueva Solicitud de Cotización</h1>
        </div>

        <div class="content">
            <p>Estimado/a {{ user_name }},</p>

            <p>Ha recibido una nueva solicitud de cotización de <strong>{{ company_name }}</strong>.</p>

            <table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
                <tr>
                    <td style="padding: 8px; border-bottom: 1px solid #eee;"><strong>Referencia:</strong></td>
                    <td style="padding: 8px; border-bottom: 1px solid #eee;">{{ rfq_code }}</td>
                </tr>
                <tr>
                    <td style="padding: 8px; border-bottom: 1px solid #eee;"><strong>Productos:</strong></td>
                    <td style="padding: 8px; border-bottom: 1px solid #eee;">{{ line_count }} líneas</td>
                </tr>
                <tr>
                    <td style="padding: 8px; border-bottom: 1px solid #eee;"><strong>Fecha límite:</strong></td>
                    <td style="padding: 8px; border-bottom: 1px solid #eee;">{{ deadline }}</td>
                </tr>
            </table>

            <p style="text-align: center; margin: 30px 0;">
                <a href="{{ portal_link }}" class="button">Ver Solicitud y Cotizar</a>
            </p>

            <p>Si tiene alguna pregunta, puede responder directamente desde el portal.</p>
        </div>

        <div class="footer">
            <p>Este es un mensaje automático del Portal de Proveedores.</p>
            <p>© {{ current_year }} {{ company_name }}</p>
        </div>
    </div>
</body>
</html>

7. Seguridad

7.1 Políticas de Acceso

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

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