erp-core/docs/04-modelado/especificaciones-tecnicas/transversal/SPEC-RRHH-EVALUACIONES-SKILLS.md

40 KiB

SPEC-RRHH-EVALUACIONES-SKILLS

Información del Documento

Campo Valor
Código Gap GAP-MGN-010-001, GAP-MGN-010-002, GAP-MGN-010-003
Módulo MGN-010 (RRHH Básico)
Título Evaluaciones de Desempeño, Reclutamiento y Skills/Competencias
Prioridad P1
Complejidad Alta
Referencia Odoo hr_appraisal, hr_recruitment, hr_skills

1. Resumen Ejecutivo

1.1 Descripción de los Gaps

Este documento especifica tres funcionalidades P1 del módulo de RRHH:

  1. Evaluaciones de Desempeño: Sistema de evaluación periódica de empleados
  2. Reclutamiento Básico: Pipeline de selección de candidatos
  3. Skills/Competencias: Matriz de competencias y niveles por empleado

1.2 Justificación de Negocio

  • Gestión del talento: Identificar y desarrollar competencias clave
  • Proceso estructurado: Pipeline visual de reclutamiento
  • Decisiones basadas en datos: Matriz de competencias para promociones
  • Cumplimiento: Documentación de evaluaciones para auditoría

1.3 Alcance

  • Sistema de evaluaciones con ciclos configurables
  • Pipeline kanban de reclutamiento
  • Catálogo de skills con tipos y niveles
  • Historial de evolución de competencias
  • Integración entre módulos

2. Análisis de Referencia (Odoo)

2.1 Módulo hr_recruitment - Pipeline de Reclutamiento

# Modelo principal: hr.applicant
class HrApplicant(models.Model):
    _name = 'hr.applicant'
    _inherit = ['mail.thread', 'mail.activity.mixin']

    # Identificación
    candidate_id = fields.Many2one('hr.candidate', required=True)
    partner_name = fields.Char(related='candidate_id.partner_name')
    email_from = fields.Char()

    # Posición
    job_id = fields.Many2one('hr.job')
    department_id = fields.Many2one(related='job_id.department_id')

    # Pipeline
    stage_id = fields.Many2one('hr.recruitment.stage', required=True)
    kanban_state = fields.Selection([
        ('normal', 'Grey'),
        ('done', 'Green'),
        ('blocked', 'Red')
    ])

    # Estado
    application_status = fields.Selection([
        ('ongoing', 'Ongoing'),
        ('hired', 'Hired'),
        ('refused', 'Refused'),
        ('archived', 'Archived')
    ], compute='_compute_application_status')

    # Evaluación
    priority = fields.Selection([
        ('0', 'Normal'),
        ('1', 'Good'),
        ('2', 'Very Good'),
        ('3', 'Excellent')
    ])
    interviewer_ids = fields.Many2many('res.users')

    # Salario
    salary_proposed = fields.Float()
    salary_expected = fields.Float()

    # Fechas
    create_date = fields.Datetime()
    date_closed = fields.Datetime()  # Hire date
    refuse_date = fields.Datetime()

2.2 Módulo hr_skills - Competencias

# Tipos de competencia
class HrSkillType(models.Model):
    _name = 'hr.skill.type'

    name = fields.Char(required=True)
    skill_ids = fields.One2many('hr.skill', 'skill_type_id')
    skill_level_ids = fields.One2many('hr.skill.level', 'skill_type_id')

# Competencia individual
class HrSkill(models.Model):
    _name = 'hr.skill'

    name = fields.Char(required=True)
    skill_type_id = fields.Many2one('hr.skill.type', required=True)

# Niveles de competencia
class HrSkillLevel(models.Model):
    _name = 'hr.skill.level'

    name = fields.Char(required=True)
    skill_type_id = fields.Many2one('hr.skill.type')
    level_progress = fields.Integer()  # 0-100%
    default_level = fields.Boolean()

# Skill asignado a empleado
class HrEmployeeSkill(models.Model):
    _name = 'hr.employee.skill'

    employee_id = fields.Many2one('hr.employee', required=True)
    skill_id = fields.Many2one('hr.skill', required=True)
    skill_level_id = fields.Many2one('hr.skill.level', required=True)
    skill_type_id = fields.Many2one('hr.skill.type', required=True)
    level_progress = fields.Integer(related='skill_level_id.level_progress')

    _sql_constraints = [
        ('unique_skill', 'UNIQUE(employee_id, skill_id)', 'Skill already assigned')
    ]

# Historial de cambios
class HrEmployeeSkillLog(models.Model):
    _name = 'hr.employee.skill.log'

    employee_id = fields.Many2one('hr.employee')
    skill_id = fields.Many2one('hr.skill')
    skill_level_id = fields.Many2one('hr.skill.level')
    date = fields.Date()

    _sql_constraints = [
        ('unique_log', 'UNIQUE(employee_id, skill_id, date)', 'One log per day')
    ]

2.3 Evaluaciones de Desempeño (Implementación Sugerida)

Nota: Odoo 18 no tiene módulo hr_appraisal separado. Se implementa mediante surveys y campos custom.


3. Especificación Técnica

3.1 Modelo de Datos

-- =====================================================
-- PARTE 1: RECLUTAMIENTO
-- =====================================================

-- Candidato (perfil reutilizable)
CREATE TABLE hr_candidates (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,

    -- Datos personales
    partner_id UUID REFERENCES contacts(id),
    partner_name VARCHAR(255) NOT NULL,
    email VARCHAR(255),
    phone VARCHAR(50),
    linkedin_profile VARCHAR(500),

    -- Documentos
    cv_attachment_id UUID,

    -- Estado global
    is_active BOOLEAN DEFAULT TRUE,

    -- Auditoría
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    created_by UUID REFERENCES users(id)
);

CREATE INDEX idx_hr_candidates_email ON hr_candidates(tenant_id, email);

-- Ofertas de trabajo
CREATE TABLE hr_jobs (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,

    -- Identificación
    name VARCHAR(255) NOT NULL,
    description TEXT,
    requirements TEXT,

    -- Organización
    department_id UUID REFERENCES departments(id),
    company_id UUID REFERENCES tenants(id),

    -- Reclutamiento
    recruiter_id UUID REFERENCES users(id),
    no_of_recruitment INTEGER DEFAULT 1,
    no_of_hired INTEGER DEFAULT 0,

    -- Estado
    is_published BOOLEAN DEFAULT FALSE,
    is_active BOOLEAN DEFAULT TRUE,

    -- Fechas
    date_from DATE,
    date_to DATE,

    -- Configuración
    survey_id UUID,  -- Encuesta de entrevista

    -- Auditoría
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Etapas del pipeline
CREATE TABLE hr_recruitment_stages (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,

    -- Identificación
    name VARCHAR(100) NOT NULL,
    sequence INTEGER DEFAULT 10,

    -- Configuración
    is_hired_stage BOOLEAN DEFAULT FALSE,
    is_initial_stage BOOLEAN DEFAULT FALSE,
    fold BOOLEAN DEFAULT FALSE,
    requirements TEXT,

    -- Template de email
    email_template_id UUID,

    -- Etiquetas kanban
    legend_normal VARCHAR(50) DEFAULT 'In Progress',
    legend_done VARCHAR(50) DEFAULT 'Ready',
    legend_blocked VARCHAR(50) DEFAULT 'Blocked',

    -- Auditoría
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Relación job-stage (many2many)
CREATE TABLE hr_job_stage_rel (
    job_id UUID NOT NULL REFERENCES hr_jobs(id) ON DELETE CASCADE,
    stage_id UUID NOT NULL REFERENCES hr_recruitment_stages(id) ON DELETE CASCADE,
    PRIMARY KEY (job_id, stage_id)
);

-- Aplicantes (proceso de selección)
CREATE TABLE hr_applicants (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,

    -- Referencias
    candidate_id UUID NOT NULL REFERENCES hr_candidates(id),
    job_id UUID REFERENCES hr_jobs(id),
    stage_id UUID NOT NULL REFERENCES hr_recruitment_stages(id),

    -- Estado
    kanban_state kanban_state_type DEFAULT 'normal',
    application_status application_status_type DEFAULT 'ongoing',
    is_active BOOLEAN DEFAULT TRUE,

    -- Evaluación
    priority INTEGER DEFAULT 0 CHECK (priority BETWEEN 0 AND 3),
    probability DECIMAL(5, 2),

    -- Salario
    salary_expected DECIMAL(20, 4),
    salary_proposed DECIMAL(20, 4),
    salary_currency_id UUID REFERENCES currencies(id),

    -- Entrevistas
    interviewer_ids UUID[] DEFAULT '{}',

    -- Rechazo
    refuse_reason_id UUID,
    refuse_date TIMESTAMPTZ,

    -- Fechas
    date_open TIMESTAMPTZ,  -- Asignación
    date_closed TIMESTAMPTZ,  -- Contratación
    date_last_stage_update TIMESTAMPTZ,

    -- Notas
    notes TEXT,

    -- UTM tracking
    source_id UUID,
    medium_id UUID,
    campaign_id UUID,

    -- Auditoría
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    created_by UUID REFERENCES users(id)
);

CREATE TYPE kanban_state_type AS ENUM ('normal', 'done', 'blocked');
CREATE TYPE application_status_type AS ENUM ('ongoing', 'hired', 'refused', 'archived');

CREATE INDEX idx_hr_applicants_job ON hr_applicants(job_id, stage_id);
CREATE INDEX idx_hr_applicants_status ON hr_applicants(tenant_id, application_status);

-- Razones de rechazo
CREATE TABLE hr_refuse_reasons (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    name VARCHAR(200) NOT NULL,
    sequence INTEGER DEFAULT 10,
    email_template_id UUID,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- =====================================================
-- PARTE 2: SKILLS/COMPETENCIAS
-- =====================================================

-- Tipos de skill (categorías)
CREATE TABLE hr_skill_types (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,

    name VARCHAR(100) NOT NULL,
    color INTEGER DEFAULT 0,
    is_active BOOLEAN DEFAULT TRUE,

    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    CONSTRAINT uq_skill_type_name UNIQUE (tenant_id, name)
);

-- Skills individuales
CREATE TABLE hr_skills (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    skill_type_id UUID NOT NULL REFERENCES hr_skill_types(id) ON DELETE CASCADE,

    name VARCHAR(100) NOT NULL,
    sequence INTEGER DEFAULT 10,

    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    CONSTRAINT uq_skill_name UNIQUE (tenant_id, skill_type_id, name)
);

-- Niveles de competencia
CREATE TABLE hr_skill_levels (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    skill_type_id UUID NOT NULL REFERENCES hr_skill_types(id) ON DELETE CASCADE,

    name VARCHAR(50) NOT NULL,
    level_progress INTEGER NOT NULL CHECK (level_progress BETWEEN 0 AND 100),
    is_default BOOLEAN DEFAULT FALSE,
    sequence INTEGER DEFAULT 10,

    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    CONSTRAINT uq_skill_level UNIQUE (skill_type_id, name)
);

-- Skills asignados a empleados
CREATE TABLE hr_employee_skills (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,

    employee_id UUID NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
    skill_id UUID NOT NULL REFERENCES hr_skills(id),
    skill_type_id UUID NOT NULL REFERENCES hr_skill_types(id),
    skill_level_id UUID NOT NULL REFERENCES hr_skill_levels(id),

    -- Progreso (denormalizado para reportes)
    level_progress INTEGER NOT NULL,

    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    created_by UUID REFERENCES users(id),

    CONSTRAINT uq_employee_skill UNIQUE (employee_id, skill_id)
);

CREATE INDEX idx_employee_skills_employee ON hr_employee_skills(employee_id);
CREATE INDEX idx_employee_skills_type ON hr_employee_skills(skill_type_id);

-- Historial de skills (audit trail)
CREATE TABLE hr_employee_skill_logs (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,

    employee_id UUID NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
    skill_id UUID NOT NULL REFERENCES hr_skills(id) ON DELETE CASCADE,
    skill_level_id UUID NOT NULL REFERENCES hr_skill_levels(id),
    skill_type_id UUID NOT NULL REFERENCES hr_skill_types(id),

    level_progress INTEGER NOT NULL,
    log_date DATE NOT NULL DEFAULT CURRENT_DATE,
    department_id UUID REFERENCES departments(id),

    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    CONSTRAINT uq_skill_log_daily UNIQUE (employee_id, skill_id, log_date)
);

CREATE INDEX idx_skill_logs_date ON hr_employee_skill_logs(employee_id, log_date DESC);

-- =====================================================
-- PARTE 3: EVALUACIONES DE DESEMPEÑO
-- =====================================================

-- Ciclos de evaluación
CREATE TABLE hr_appraisal_cycles (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,

    name VARCHAR(200) NOT NULL,
    description TEXT,

    -- Período
    date_from DATE NOT NULL,
    date_to DATE NOT NULL,

    -- Configuración
    state cycle_state_type NOT NULL DEFAULT 'draft',
    frequency appraisal_frequency_type NOT NULL DEFAULT 'annual',

    -- Participantes
    department_ids UUID[] DEFAULT '{}',  -- Departamentos incluidos
    employee_ids UUID[] DEFAULT '{}',    -- Empleados específicos

    -- Template
    appraisal_template_id UUID REFERENCES hr_appraisal_templates(id),

    -- Auditoría
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    created_by UUID REFERENCES users(id)
);

CREATE TYPE cycle_state_type AS ENUM (
    'draft',
    'in_progress',
    'completed',
    'cancelled'
);

CREATE TYPE appraisal_frequency_type AS ENUM (
    'monthly',
    'quarterly',
    'semi_annual',
    'annual'
);

-- Templates de evaluación
CREATE TABLE hr_appraisal_templates (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,

    name VARCHAR(200) NOT NULL,
    description TEXT,
    is_active BOOLEAN DEFAULT TRUE,

    -- Secciones y preguntas en JSONB
    sections JSONB NOT NULL DEFAULT '[]',
    /*
    [
      {
        "name": "Goals Achievement",
        "weight": 40,
        "questions": [
          {"text": "Did employee meet goals?", "type": "rating", "max_score": 5},
          {"text": "Comments on goals", "type": "text"}
        ]
      },
      {
        "name": "Competencies",
        "weight": 30,
        "questions": [
          {"text": "Technical skills", "type": "rating", "max_score": 5},
          {"text": "Communication", "type": "rating", "max_score": 5}
        ]
      }
    ]
    */

    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Evaluaciones individuales
CREATE TABLE hr_appraisals (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,

    -- Referencias
    cycle_id UUID REFERENCES hr_appraisal_cycles(id),
    template_id UUID REFERENCES hr_appraisal_templates(id),
    employee_id UUID NOT NULL REFERENCES employees(id),

    -- Evaluadores
    manager_id UUID REFERENCES employees(id),
    reviewer_ids UUID[] DEFAULT '{}',  -- Para 360

    -- Estado
    state appraisal_state_type NOT NULL DEFAULT 'draft',

    -- Período
    date_from DATE NOT NULL,
    date_to DATE NOT NULL,
    deadline DATE,

    -- Respuestas
    responses JSONB DEFAULT '{}',
    /*
    {
      "sections": [
        {
          "section_name": "Goals Achievement",
          "answers": [
            {"question_idx": 0, "score": 4, "comment": "Good progress"},
            {"question_idx": 1, "text": "Met 90% of objectives"}
          ]
        }
      ],
      "overall_comment": "Strong performer",
      "improvement_areas": ["Time management"]
    }
    */

    -- Scores calculados
    overall_score DECIMAL(5, 2),
    final_rating appraisal_rating_type,

    -- Self-assessment
    self_assessment JSONB,
    self_assessment_completed_at TIMESTAMPTZ,

    -- Manager assessment
    manager_assessment JSONB,
    manager_assessment_completed_at TIMESTAMPTZ,

    -- Firmas y confirmaciones
    employee_confirmed_at TIMESTAMPTZ,
    manager_confirmed_at TIMESTAMPTZ,

    -- Auditoría
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TYPE appraisal_state_type AS ENUM (
    'draft',
    'self_assessment',
    'manager_review',
    'meeting_scheduled',
    'completed',
    'cancelled'
);

CREATE TYPE appraisal_rating_type AS ENUM (
    'exceptional',
    'exceeds_expectations',
    'meets_expectations',
    'needs_improvement',
    'unsatisfactory'
);

CREATE INDEX idx_appraisals_employee ON hr_appraisals(employee_id, date_from DESC);
CREATE INDEX idx_appraisals_cycle ON hr_appraisals(cycle_id);
CREATE INDEX idx_appraisals_state ON hr_appraisals(tenant_id, state);

-- Objetivos de evaluación
CREATE TABLE hr_appraisal_goals (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    appraisal_id UUID NOT NULL REFERENCES hr_appraisals(id) ON DELETE CASCADE,

    name VARCHAR(500) NOT NULL,
    description TEXT,
    weight DECIMAL(5, 2) DEFAULT 0,  -- % del total
    target_value VARCHAR(200),
    achieved_value VARCHAR(200),
    achievement_percent DECIMAL(5, 2),

    -- Estado
    status goal_status_type DEFAULT 'pending',

    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TYPE goal_status_type AS ENUM (
    'pending',
    'in_progress',
    'achieved',
    'partially_achieved',
    'not_achieved'
);

-- =====================================================
-- PARTE 4: DATOS INICIALES
-- =====================================================

-- Etapas de reclutamiento por defecto
INSERT INTO hr_recruitment_stages (tenant_id, name, sequence, is_initial_stage, is_hired_stage)
SELECT t.id, stage.name, stage.seq, stage.is_initial, stage.is_hired
FROM tenants t
CROSS JOIN (VALUES
    ('New', 0, TRUE, FALSE),
    ('Initial Qualification', 10, FALSE, FALSE),
    ('First Interview', 20, FALSE, FALSE),
    ('Second Interview', 30, FALSE, FALSE),
    ('Contract Proposal', 40, FALSE, FALSE),
    ('Contract Signed', 50, FALSE, TRUE)
) AS stage(name, seq, is_initial, is_hired)
WHERE t.id = 'system';  -- Template

-- Tipos de skill por defecto
INSERT INTO hr_skill_types (tenant_id, name, color) VALUES
('system', 'Languages', 1),
('system', 'Technical Skills', 2),
('system', 'Soft Skills', 3),
('system', 'Certifications', 4);

-- Niveles por defecto (por tipo)
INSERT INTO hr_skill_levels (skill_type_id, name, level_progress, is_default, sequence)
SELECT st.id, level.name, level.progress, level.is_default, level.seq
FROM hr_skill_types st
CROSS JOIN (VALUES
    ('Beginner', 25, TRUE, 10),
    ('Intermediate', 50, FALSE, 20),
    ('Advanced', 75, FALSE, 30),
    ('Expert', 100, FALSE, 40)
) AS level(name, progress, is_default, seq);

3.2 Servicios de Dominio

# app/modules/hr/domain/services/recruitment_service.py

from datetime import datetime
from decimal import Decimal
from typing import Optional, List, Dict
from enum import Enum

from app.core.exceptions import DomainException
from app.core.logging import get_logger
from app.core.events import EventBus

logger = get_logger(__name__)


class ApplicationStatus(str, Enum):
    ONGOING = "ongoing"
    HIRED = "hired"
    REFUSED = "refused"
    ARCHIVED = "archived"


class KanbanState(str, Enum):
    NORMAL = "normal"
    DONE = "done"
    BLOCKED = "blocked"


class RecruitmentService:
    """Servicio de dominio para gestión de reclutamiento."""

    def __init__(
        self,
        applicant_repository: "ApplicantRepository",
        job_repository: "JobRepository",
        stage_repository: "StageRepository",
        candidate_repository: "CandidateRepository",
        employee_service: "EmployeeService",
        notification_service: "NotificationService",
        event_bus: EventBus
    ):
        self._applicant_repo = applicant_repository
        self._job_repo = job_repository
        self._stage_repo = stage_repository
        self._candidate_repo = candidate_repository
        self._employee_service = employee_service
        self._notification_service = notification_service
        self._event_bus = event_bus

    async def create_application(
        self,
        tenant_id: str,
        job_id: str,
        candidate_data: Dict,
        user_id: str
    ) -> "Applicant":
        """
        Crea una nueva aplicación para un candidato.
        """
        # Buscar o crear candidato
        candidate = await self._find_or_create_candidate(
            tenant_id, candidate_data, user_id
        )

        # Verificar si ya aplicó a este job
        existing = await self._applicant_repo.find_by_candidate_and_job(
            candidate_id=candidate.id,
            job_id=job_id
        )
        if existing and existing.application_status == ApplicationStatus.ONGOING:
            raise DomainException(
                code="DUPLICATE_APPLICATION",
                message="Candidate already has an active application for this job"
            )

        # Obtener etapa inicial
        initial_stage = await self._stage_repo.get_initial_for_job(job_id)
        if not initial_stage:
            initial_stage = await self._stage_repo.get_default_initial(tenant_id)

        # Crear aplicación
        applicant = await self._applicant_repo.create(
            tenant_id=tenant_id,
            candidate_id=candidate.id,
            job_id=job_id,
            stage_id=initial_stage.id,
            created_by=user_id
        )

        # Emitir evento
        await self._event_bus.publish(
            "recruitment.application.created",
            {
                "applicant_id": applicant.id,
                "job_id": job_id,
                "candidate_id": candidate.id
            }
        )

        logger.info(
            "Application created",
            applicant_id=applicant.id,
            job_id=job_id
        )

        return applicant

    async def move_to_stage(
        self,
        applicant_id: str,
        stage_id: str,
        user_id: str
    ) -> "Applicant":
        """Mueve aplicante a una nueva etapa del pipeline."""
        applicant = await self._applicant_repo.get(applicant_id)
        if not applicant:
            raise DomainException(code="APPLICANT_NOT_FOUND")

        old_stage = applicant.stage_id
        stage = await self._stage_repo.get(stage_id)

        # Actualizar aplicante
        await self._applicant_repo.update(
            applicant_id=applicant_id,
            stage_id=stage_id,
            date_last_stage_update=datetime.utcnow()
        )

        # Si es etapa de contratación
        if stage.is_hired_stage:
            await self._mark_as_hired(applicant, user_id)

        # Enviar email de template si configurado
        if stage.email_template_id:
            await self._send_stage_email(applicant, stage)

        # Emitir evento
        await self._event_bus.publish(
            "recruitment.stage.changed",
            {
                "applicant_id": applicant_id,
                "old_stage_id": old_stage,
                "new_stage_id": stage_id
            }
        )

        return await self._applicant_repo.get(applicant_id)

    async def _mark_as_hired(self, applicant: "Applicant", user_id: str):
        """Marca aplicante como contratado."""
        await self._applicant_repo.update(
            applicant_id=applicant.id,
            application_status=ApplicationStatus.HIRED,
            date_closed=datetime.utcnow()
        )

        # Actualizar contador del job
        job = await self._job_repo.get(applicant.job_id)
        await self._job_repo.update(
            job_id=job.id,
            no_of_hired=job.no_of_hired + 1
        )

    async def refuse_applicant(
        self,
        applicant_id: str,
        refuse_reason_id: str,
        user_id: str,
        send_email: bool = True
    ) -> "Applicant":
        """Rechaza un aplicante."""
        applicant = await self._applicant_repo.get(applicant_id)
        if not applicant:
            raise DomainException(code="APPLICANT_NOT_FOUND")

        await self._applicant_repo.update(
            applicant_id=applicant_id,
            application_status=ApplicationStatus.REFUSED,
            refuse_reason_id=refuse_reason_id,
            refuse_date=datetime.utcnow()
        )

        if send_email:
            await self._send_refuse_email(applicant, refuse_reason_id)

        return await self._applicant_repo.get(applicant_id)

    async def create_employee_from_applicant(
        self,
        applicant_id: str,
        user_id: str,
        additional_data: Optional[Dict] = None
    ) -> "Employee":
        """
        Convierte un aplicante contratado en empleado.
        """
        applicant = await self._applicant_repo.get(applicant_id)
        if not applicant:
            raise DomainException(code="APPLICANT_NOT_FOUND")

        if applicant.application_status != ApplicationStatus.HIRED:
            raise DomainException(
                code="NOT_HIRED",
                message="Only hired applicants can become employees"
            )

        candidate = await self._candidate_repo.get(applicant.candidate_id)
        job = await self._job_repo.get(applicant.job_id)

        # Crear empleado
        employee_data = {
            "name": candidate.partner_name,
            "email": candidate.email,
            "phone": candidate.phone,
            "department_id": job.department_id,
            "job_id": job.id,
            **(additional_data or {})
        }

        employee = await self._employee_service.create_employee(
            tenant_id=applicant.tenant_id,
            employee_data=employee_data,
            user_id=user_id
        )

        # Vincular candidato con empleado
        await self._candidate_repo.update(
            candidate_id=candidate.id,
            employee_id=employee.id
        )

        logger.info(
            "Employee created from applicant",
            applicant_id=applicant_id,
            employee_id=employee.id
        )

        return employee


class SkillService:
    """Servicio de dominio para gestión de competencias."""

    def __init__(
        self,
        skill_repository: "SkillRepository",
        employee_skill_repository: "EmployeeSkillRepository",
        skill_log_repository: "SkillLogRepository",
        event_bus: EventBus
    ):
        self._skill_repo = skill_repository
        self._employee_skill_repo = employee_skill_repository
        self._skill_log_repo = skill_log_repository
        self._event_bus = event_bus

    async def assign_skill(
        self,
        tenant_id: str,
        employee_id: str,
        skill_id: str,
        skill_level_id: str,
        user_id: str
    ) -> "EmployeeSkill":
        """
        Asigna o actualiza una competencia a un empleado.
        """
        # Obtener skill y validar
        skill = await self._skill_repo.get(skill_id)
        if not skill:
            raise DomainException(code="SKILL_NOT_FOUND")

        level = await self._skill_repo.get_level(skill_level_id)
        if not level:
            raise DomainException(code="LEVEL_NOT_FOUND")

        # Validar que nivel corresponde al tipo de skill
        if level.skill_type_id != skill.skill_type_id:
            raise DomainException(
                code="INVALID_LEVEL",
                message="Skill level does not belong to skill type"
            )

        # Buscar skill existente
        existing = await self._employee_skill_repo.find_by_employee_and_skill(
            employee_id=employee_id,
            skill_id=skill_id
        )

        if existing:
            # Actualizar nivel
            await self._employee_skill_repo.update(
                id=existing.id,
                skill_level_id=skill_level_id,
                level_progress=level.level_progress
            )
            employee_skill = await self._employee_skill_repo.get(existing.id)
        else:
            # Crear nuevo
            employee_skill = await self._employee_skill_repo.create(
                tenant_id=tenant_id,
                employee_id=employee_id,
                skill_id=skill_id,
                skill_type_id=skill.skill_type_id,
                skill_level_id=skill_level_id,
                level_progress=level.level_progress,
                created_by=user_id
            )

        # Crear log de cambio
        await self._create_skill_log(employee_skill)

        # Emitir evento
        await self._event_bus.publish(
            "hr.skill.assigned",
            {
                "employee_id": employee_id,
                "skill_id": skill_id,
                "level_progress": level.level_progress
            }
        )

        return employee_skill

    async def _create_skill_log(self, employee_skill: "EmployeeSkill"):
        """Crea registro en el historial de skills."""
        from datetime import date

        # Buscar log del día
        existing_log = await self._skill_log_repo.find_by_date(
            employee_id=employee_skill.employee_id,
            skill_id=employee_skill.skill_id,
            log_date=date.today()
        )

        if existing_log:
            # Actualizar log existente
            await self._skill_log_repo.update(
                id=existing_log.id,
                skill_level_id=employee_skill.skill_level_id,
                level_progress=employee_skill.level_progress
            )
        else:
            # Crear nuevo log
            await self._skill_log_repo.create(
                tenant_id=employee_skill.tenant_id,
                employee_id=employee_skill.employee_id,
                skill_id=employee_skill.skill_id,
                skill_type_id=employee_skill.skill_type_id,
                skill_level_id=employee_skill.skill_level_id,
                level_progress=employee_skill.level_progress,
                log_date=date.today()
            )

    async def get_skills_matrix(
        self,
        tenant_id: str,
        department_id: Optional[str] = None,
        skill_type_id: Optional[str] = None
    ) -> List[Dict]:
        """
        Obtiene matriz de competencias de empleados.
        """
        return await self._employee_skill_repo.get_matrix(
            tenant_id=tenant_id,
            department_id=department_id,
            skill_type_id=skill_type_id
        )

    async def get_skill_evolution(
        self,
        employee_id: str,
        skill_id: Optional[str] = None,
        date_from: Optional[date] = None,
        date_to: Optional[date] = None
    ) -> List[Dict]:
        """
        Obtiene evolución histórica de skills de un empleado.
        """
        return await self._skill_log_repo.get_evolution(
            employee_id=employee_id,
            skill_id=skill_id,
            date_from=date_from,
            date_to=date_to
        )


class AppraisalService:
    """Servicio de dominio para evaluaciones de desempeño."""

    def __init__(
        self,
        appraisal_repository: "AppraisalRepository",
        cycle_repository: "AppraisalCycleRepository",
        template_repository: "AppraisalTemplateRepository",
        notification_service: "NotificationService",
        event_bus: EventBus
    ):
        self._appraisal_repo = appraisal_repository
        self._cycle_repo = cycle_repository
        self._template_repo = template_repository
        self._notification_service = notification_service
        self._event_bus = event_bus

    async def start_cycle(
        self,
        cycle_id: str,
        user_id: str
    ) -> "AppraisalCycle":
        """
        Inicia un ciclo de evaluación, creando evaluaciones para empleados.
        """
        cycle = await self._cycle_repo.get(cycle_id)
        if not cycle:
            raise DomainException(code="CYCLE_NOT_FOUND")

        if cycle.state != "draft":
            raise DomainException(
                code="INVALID_STATE",
                message="Cycle must be in draft state to start"
            )

        # Obtener empleados a evaluar
        employees = await self._get_cycle_employees(cycle)

        if not employees:
            raise DomainException(
                code="NO_EMPLOYEES",
                message="No employees found for this cycle"
            )

        # Crear evaluación para cada empleado
        for employee in employees:
            await self._create_appraisal_for_employee(
                cycle=cycle,
                employee=employee,
                user_id=user_id
            )

        # Actualizar estado del ciclo
        await self._cycle_repo.update(
            cycle_id=cycle_id,
            state="in_progress"
        )

        # Notificar a participantes
        await self._notify_cycle_start(cycle, employees)

        logger.info(
            "Appraisal cycle started",
            cycle_id=cycle_id,
            employee_count=len(employees)
        )

        return await self._cycle_repo.get(cycle_id)

    async def submit_self_assessment(
        self,
        appraisal_id: str,
        responses: Dict,
        user_id: str
    ) -> "Appraisal":
        """
        Empleado envía su auto-evaluación.
        """
        appraisal = await self._appraisal_repo.get(appraisal_id)
        if not appraisal:
            raise DomainException(code="APPRAISAL_NOT_FOUND")

        if appraisal.state != "self_assessment":
            raise DomainException(
                code="INVALID_STATE",
                message="Self assessment not allowed in current state"
            )

        # Guardar respuestas
        await self._appraisal_repo.update(
            appraisal_id=appraisal_id,
            self_assessment=responses,
            self_assessment_completed_at=datetime.utcnow(),
            state="manager_review"
        )

        # Notificar al manager
        if appraisal.manager_id:
            await self._notification_service.send_notification(
                user_id=appraisal.manager_id,
                notification_type="in_app",
                subject="Appraisal Ready for Review",
                body_text=f"Self-assessment submitted for {appraisal.employee_name}"
            )

        return await self._appraisal_repo.get(appraisal_id)

    async def submit_manager_review(
        self,
        appraisal_id: str,
        responses: Dict,
        final_rating: str,
        user_id: str
    ) -> "Appraisal":
        """
        Manager envía su evaluación.
        """
        appraisal = await self._appraisal_repo.get(appraisal_id)
        if not appraisal:
            raise DomainException(code="APPRAISAL_NOT_FOUND")

        if appraisal.state != "manager_review":
            raise DomainException(code="INVALID_STATE")

        # Calcular score
        overall_score = self._calculate_score(responses)

        # Guardar
        await self._appraisal_repo.update(
            appraisal_id=appraisal_id,
            manager_assessment=responses,
            manager_assessment_completed_at=datetime.utcnow(),
            overall_score=overall_score,
            final_rating=final_rating,
            state="meeting_scheduled"
        )

        return await self._appraisal_repo.get(appraisal_id)

    def _calculate_score(self, responses: Dict) -> Decimal:
        """Calcula score general basado en respuestas."""
        total_score = Decimal("0")
        total_weight = Decimal("0")

        sections = responses.get("sections", [])
        for section in sections:
            section_weight = Decimal(str(section.get("weight", 0)))
            section_scores = []

            for answer in section.get("answers", []):
                if "score" in answer:
                    section_scores.append(answer["score"])

            if section_scores:
                avg_score = sum(section_scores) / len(section_scores)
                total_score += Decimal(str(avg_score)) * section_weight
                total_weight += section_weight

        if total_weight > 0:
            return total_score / total_weight
        return Decimal("0")

3.3 API REST

# app/modules/hr/api/v1/recruitment.py

from datetime import date
from typing import List, Optional
from fastapi import APIRouter, Depends, Query, Path, HTTPException
from pydantic import BaseModel, Field

router = APIRouter(prefix="/recruitment", tags=["Recruitment"])


# --- SCHEMAS ---

class CandidateCreate(BaseModel):
    partner_name: str
    email: Optional[str]
    phone: Optional[str]
    linkedin_profile: Optional[str]


class ApplicationCreate(BaseModel):
    job_id: str
    candidate: CandidateCreate
    salary_expected: Optional[float]
    notes: Optional[str]


class ApplicantResponse(BaseModel):
    id: str
    candidate_name: str
    job_name: str
    stage_name: str
    application_status: str
    priority: int
    created_at: str


class StageChangeRequest(BaseModel):
    stage_id: str


class RefuseRequest(BaseModel):
    refuse_reason_id: str
    send_email: bool = True


# --- ENDPOINTS ---

@router.get("/jobs", response_model=List[dict])
async def list_jobs(
    is_published: Optional[bool] = Query(None),
    department_id: Optional[str] = Query(None),
    service = Depends(),
    current_user = Depends()
):
    """Lista ofertas de trabajo."""
    return await service.list_jobs(
        tenant_id=current_user.tenant_id,
        is_published=is_published,
        department_id=department_id
    )


@router.get("/jobs/{job_id}/applicants", response_model=List[ApplicantResponse])
async def get_job_applicants(
    job_id: str = Path(...),
    stage_id: Optional[str] = Query(None),
    status: Optional[str] = Query(None),
    service = Depends(),
    current_user = Depends()
):
    """Lista aplicantes de un job."""
    return await service.get_applicants_by_job(
        job_id=job_id,
        stage_id=stage_id,
        status=status
    )


@router.post("/applications", response_model=ApplicantResponse)
async def create_application(
    request: ApplicationCreate,
    service = Depends(),
    current_user = Depends()
):
    """Crea nueva aplicación."""
    applicant = await service.create_application(
        tenant_id=current_user.tenant_id,
        job_id=request.job_id,
        candidate_data=request.candidate.dict(),
        user_id=current_user.id
    )
    return ApplicantResponse(**applicant.to_dict())


@router.post("/applicants/{applicant_id}/move-stage")
async def move_applicant_stage(
    applicant_id: str = Path(...),
    request: StageChangeRequest = None,
    service = Depends(),
    current_user = Depends()
):
    """Mueve aplicante a otra etapa."""
    applicant = await service.move_to_stage(
        applicant_id=applicant_id,
        stage_id=request.stage_id,
        user_id=current_user.id
    )
    return {"status": "success", "new_stage": applicant.stage_name}


@router.post("/applicants/{applicant_id}/refuse")
async def refuse_applicant(
    applicant_id: str = Path(...),
    request: RefuseRequest = None,
    service = Depends(),
    current_user = Depends()
):
    """Rechaza un aplicante."""
    await service.refuse_applicant(
        applicant_id=applicant_id,
        refuse_reason_id=request.refuse_reason_id,
        user_id=current_user.id,
        send_email=request.send_email
    )
    return {"status": "refused"}


@router.post("/applicants/{applicant_id}/hire")
async def create_employee(
    applicant_id: str = Path(...),
    service = Depends(),
    current_user = Depends()
):
    """Convierte aplicante en empleado."""
    employee = await service.create_employee_from_applicant(
        applicant_id=applicant_id,
        user_id=current_user.id
    )
    return {"employee_id": employee.id, "status": "hired"}

4. Reglas de Negocio

4.1 Reclutamiento

Regla Descripción
RN-REC-001 Un candidato solo puede tener una aplicación activa por job
RN-REC-002 Mover a etapa hired automáticamente marca como contratado
RN-REC-003 Rechazar envía email si la razón tiene template
RN-REC-004 Solo aplicantes hired pueden convertirse en empleados

4.2 Skills

Regla Descripción
RN-SKL-001 Un empleado no puede tener el mismo skill duplicado
RN-SKL-002 El nivel debe corresponder al tipo de skill
RN-SKL-003 Todo cambio de skill genera log diario
RN-SKL-004 Solo un nivel por tipo puede ser default

4.3 Evaluaciones

Regla Descripción
RN-APR-001 Auto-evaluación debe completarse antes de manager review
RN-APR-002 Score se calcula ponderando por peso de sección
RN-APR-003 Evaluación debe confirmarse por ambas partes

5. Métricas

Métrica Tipo Descripción
recruitment_applications_total Counter Aplicaciones creadas
recruitment_hired_total Counter Contrataciones
recruitment_time_to_hire_days Histogram Días para contratar
skills_assignments_total Counter Asignaciones de skills
appraisals_completed_total Counter Evaluaciones completadas

6. Referencias