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:
- Evaluaciones de Desempeño: Sistema de evaluación periódica de empleados
- Reclutamiento Básico: Pipeline de selección de candidatos
- 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