# 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 ```python # 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 ```python # 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 ```sql -- ===================================================== -- 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 ```python # 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 ```python # 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 - [Odoo hr_recruitment](https://github.com/odoo/odoo/tree/18.0/addons/hr_recruitment) - [Odoo hr_skills](https://github.com/odoo/odoo/tree/18.0/addons/hr_skills)