# SPEC-NOMINA-BASICA ## Metadatos | Campo | Valor | |-------|-------| | **Código** | SPEC-TRANS-013 | | **Versión** | 1.0.0 | | **Fecha** | 2025-01-15 | | **Autor** | Requirements-Analyst Agent | | **Estado** | DRAFT | | **Prioridad** | P0 | | **Módulos Afectados** | MGN-010 (RRHH Básico) | | **Gaps Cubiertos** | GAP-MGN-010-001 | ## Resumen Ejecutivo Esta especificación define el sistema completo de nómina para ERP Core, incluyendo: 1. **Recibos de Nómina (Payslips)**: Documentos de pago a empleados con cálculo automático 2. **Reglas Salariales**: Motor de cálculo configurable con fórmulas Python 3. **Estructuras de Nómina**: Plantillas de reglas por tipo de empleado/país 4. **Contratos**: Vinculación de empleados con condiciones salariales 5. **Integración Contable**: Generación automática de asientos contables 6. **Inputs Variables**: Horas extra, bonos, deducciones por período ### Referencia Odoo 18 Basado en análisis exhaustivo del módulo `hr_payroll` de Odoo 18: - **hr.payslip**: Recibo de nómina con líneas calculadas - **hr.salary.rule**: Reglas con condiciones y fórmulas Python - **hr.payroll.structure**: Estructuras agrupando reglas - **hr.contract**: Contratos con salario y calendario - **hr.rule.parameter**: Parámetros versionados por fecha --- ## Parte 1: Arquitectura del Sistema ### 1.1 Visión General ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ SISTEMA DE NÓMINA │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ Contrato │───▶│ Payslip │◀───│ Inputs │ │ │ │ Employee │ │ (Recibo) │ │ Variables │ │ │ └──────────────┘ └──────┬───────┘ └──────────────┘ │ │ │ │ │ ▼ │ │ ┌──────────────────────────┐ │ │ │ PayrollEngine │ │ │ │ ────────────────────── │ │ │ │ • computePayslip() │ │ │ │ • applyRules() │ │ │ │ • calculateCategories()│ │ │ └──────────────┬───────────┘ │ │ │ │ │ ┌───────────────────┼───────────────────┐ │ │ │ │ │ │ │ ▼ ▼ ▼ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ Structure │ │ Rules │ │ Parameters │ │ │ │ (Template) │───▶│ (Cálculo) │◀───│ (Valores) │ │ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ │ │ │ ▼ │ │ ┌──────────────────────────┐ │ │ │ Payslip Lines │ │ │ │ + Journal Entry │ │ │ └──────────────────────────┘ │ └─────────────────────────────────────────────────────────────────────────┘ ``` ### 1.2 Flujo de Cálculo ``` 1. CREAR PAYSLIP ├─ Seleccionar empleado ├─ Definir período (date_from, date_to) └─ Asignar estructura salarial (desde contrato) │ ▼ 2. CALCULAR DÍAS TRABAJADOS ├─ Obtener calendario laboral del contrato ├─ Calcular días/horas del período └─ Restar ausencias (vacaciones, permisos, etc.) │ ▼ 3. CARGAR INPUTS VARIABLES ├─ Horas extra del período ├─ Bonos aprobados └─ Deducciones variables │ ▼ 4. EJECUTAR REGLAS SALARIALES (por sequence) ├─ Evaluar condición de cada regla ├─ Si aplica, calcular monto ├─ Acumular en categorías (BASIC, GROSS, DED, NET) └─ Generar línea de payslip │ ▼ 5. VALIDAR Y CONFIRMAR ├─ Revisar líneas calculadas ├─ Aprobar payslip └─ Cambiar estado a DONE │ ▼ 6. GENERAR ASIENTO CONTABLE ├─ Crear account.move en borrador ├─ Generar líneas por cada regla con cuentas └─ Publicar asiento ``` --- ## Parte 2: Modelo de Datos ### 2.1 Tipos Enumerados ```sql -- Estado del contrato CREATE TYPE contract_state AS ENUM ( 'draft', -- Nuevo/Borrador 'open', -- Vigente/Activo 'close', -- Expirado 'cancel' -- Cancelado ); -- Estado del recibo de nómina CREATE TYPE payslip_state AS ENUM ( 'draft', -- Borrador 'verify', -- En verificación 'done', -- Confirmado 'paid', -- Pagado 'cancel' -- Cancelado ); -- Tipo de selección de condición CREATE TYPE rule_condition_select AS ENUM ( 'none', -- Sin condición (siempre aplica) 'python', -- Condición Python 'range' -- Rango numérico ); -- Tipo de selección de monto CREATE TYPE rule_amount_select AS ENUM ( 'fix', -- Monto fijo 'percentage', -- Porcentaje de base 'code' -- Código Python ); -- Tipo de uso de impuesto/regla CREATE TYPE salary_rule_use AS ENUM ( 'employee', -- Descuento al empleado (aparece en payslip) 'employer', -- Costo del empleador (no aparece en payslip) 'both' -- Ambos ); ``` ### 2.2 Estructuras de Nómina ```sql -- Tipos de estructura salarial (por país/tipo empleado) CREATE TABLE payroll_structure_types ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Identificación name VARCHAR(200) NOT NULL, code VARCHAR(50), -- País country_id UUID REFERENCES countries(id), -- Calendario laboral por defecto default_calendar_id UUID REFERENCES resource_calendars(id), -- Estado is_active BOOLEAN DEFAULT true, -- Auditoría created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by UUID NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_by UUID NOT NULL ); -- Estructuras de nómina (plantillas de reglas) CREATE TABLE payroll_structures ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Identificación name VARCHAR(200) NOT NULL, code VARCHAR(50) NOT NULL, -- Relaciones structure_type_id UUID NOT NULL REFERENCES payroll_structure_types(id), country_id UUID REFERENCES countries(id), company_id UUID REFERENCES companies(id), -- Reporte report_template VARCHAR(200), -- Configuración use_worked_day_lines BOOLEAN DEFAULT true, schedule_pay VARCHAR(20) DEFAULT 'monthly', -- monthly, weekly, bi-weekly -- Estado is_active BOOLEAN DEFAULT true, -- Auditoría created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by UUID NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_by UUID NOT NULL, CONSTRAINT uq_payroll_structure_code UNIQUE (company_id, code) ); -- Índices CREATE INDEX idx_payroll_structures_type ON payroll_structures(structure_type_id); CREATE INDEX idx_payroll_structures_country ON payroll_structures(country_id); COMMENT ON TABLE payroll_structures IS 'Estructuras de nómina que agrupan reglas salariales. Similar a hr.payroll.structure de Odoo.'; ``` ### 2.3 Categorías de Reglas Salariales ```sql -- Categorías de reglas (BASIC, GROSS, NET, DED, ALW) CREATE TABLE salary_rule_categories ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Identificación name VARCHAR(200) NOT NULL, code VARCHAR(50) NOT NULL, -- Jerarquía parent_id UUID REFERENCES salary_rule_categories(id), -- País (opcional) country_id UUID REFERENCES countries(id), -- Estado is_active BOOLEAN DEFAULT true, -- Auditoría created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT uq_salary_rule_category_code UNIQUE (country_id, code) ); -- Datos iniciales (categorías estándar) INSERT INTO salary_rule_categories (code, name) VALUES ('BASIC', 'Basic Salary'), ('ALW', 'Allowances'), ('GROSS', 'Gross Salary'), ('DED', 'Deductions'), ('NET', 'Net Salary'), ('COMP', 'Company Contributions'); COMMENT ON TABLE salary_rule_categories IS 'Categorías para agrupar reglas salariales y calcular subtotales automáticos.'; ``` ### 2.4 Reglas Salariales ```sql -- Reglas salariales (motor de cálculo) CREATE TABLE salary_rules ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Identificación name VARCHAR(200) NOT NULL, code VARCHAR(50) NOT NULL, -- Categoría category_id UUID NOT NULL REFERENCES salary_rule_categories(id), -- Estructura struct_id UUID NOT NULL REFERENCES payroll_structures(id), -- Secuencia (orden de ejecución) sequence INTEGER NOT NULL DEFAULT 100, -- Condición condition_select rule_condition_select NOT NULL DEFAULT 'none', condition_python TEXT, -- Código Python para condición condition_range_min DECIMAL(15,4), condition_range_max DECIMAL(15,4), -- Cálculo del monto amount_select rule_amount_select NOT NULL DEFAULT 'fix', amount_fix DECIMAL(15,4) DEFAULT 0, amount_percentage DECIMAL(8,4), -- Porcentaje (ej: 10.5 = 10.5%) amount_percentage_base VARCHAR(50), -- Código de categoría/regla base amount_python_compute TEXT, -- Código Python para cálculo -- Visibilidad appears_on_payslip BOOLEAN DEFAULT true, appears_on_report BOOLEAN DEFAULT true, -- Tipo (empleado/empleador) rule_use salary_rule_use DEFAULT 'employee', -- Integración contable account_debit_id UUID, -- REFERENCES accounts(id) account_credit_id UUID, -- REFERENCES accounts(id) -- Notas note TEXT, -- Estado is_active BOOLEAN DEFAULT true, -- Auditoría created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by UUID NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_by UUID NOT NULL, CONSTRAINT uq_salary_rule_code_struct UNIQUE (struct_id, code) ); -- Índices CREATE INDEX idx_salary_rules_struct ON salary_rules(struct_id); CREATE INDEX idx_salary_rules_category ON salary_rules(category_id); CREATE INDEX idx_salary_rules_sequence ON salary_rules(sequence); COMMENT ON TABLE salary_rules IS 'Reglas salariales con fórmulas de cálculo. Similar a hr.salary.rule de Odoo.'; ``` ### 2.5 Parámetros de Reglas ```sql -- Parámetros de reglas (valores configurables) CREATE TABLE rule_parameters ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Identificación name VARCHAR(200) NOT NULL, code VARCHAR(100) NOT NULL, -- País country_id UUID REFERENCES countries(id), -- Descripción description TEXT, -- Estado is_active BOOLEAN DEFAULT true, -- Auditoría created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT uq_rule_parameter_code UNIQUE (country_id, code) ); -- Valores de parámetros (versionados por fecha) CREATE TABLE rule_parameter_values ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), parameter_id UUID NOT NULL REFERENCES rule_parameters(id) ON DELETE CASCADE, -- Valor parameter_value TEXT NOT NULL, -- JSON para valores complejos -- Vigencia date_from DATE NOT NULL, date_to DATE, -- NULL = sin fecha fin -- Auditoría created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Índice para búsqueda por vigencia CREATE INDEX idx_rule_param_values_dates ON rule_parameter_values(parameter_id, date_from, date_to); COMMENT ON TABLE rule_parameters IS 'Parámetros configurables para reglas salariales (tasas, brackets, etc.)'; COMMENT ON TABLE rule_parameter_values IS 'Valores históricos de parámetros, versionados por fecha de vigencia.'; ``` ### 2.6 Contratos de Empleado ```sql -- Contratos de empleado CREATE TABLE employee_contracts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Empleado employee_id UUID NOT NULL REFERENCES employees(id), company_id UUID NOT NULL REFERENCES companies(id), -- Identificación name VARCHAR(200), reference VARCHAR(50), -- Tipo y estructura structure_type_id UUID REFERENCES payroll_structure_types(id), struct_id UUID REFERENCES payroll_structures(id), -- Salario wage DECIMAL(15,2) NOT NULL, -- Salario mensual bruto wage_type VARCHAR(20) DEFAULT 'monthly', -- monthly, hourly hourly_wage DECIMAL(10,2), -- Salario por hora (si aplica) -- Calendario laboral resource_calendar_id UUID REFERENCES resource_calendars(id), -- Vigencia date_start DATE NOT NULL, date_end DATE, -- Período de prueba trial_date_end DATE, -- Estado state contract_state NOT NULL DEFAULT 'draft', -- Departamento y puesto department_id UUID REFERENCES departments(id), job_id UUID REFERENCES job_positions(id), -- Notas notes TEXT, -- Auditoría created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by UUID NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_by UUID NOT NULL ); -- Índices CREATE INDEX idx_contracts_employee ON employee_contracts(employee_id); CREATE INDEX idx_contracts_state ON employee_contracts(state); CREATE INDEX idx_contracts_dates ON employee_contracts(date_start, date_end); COMMENT ON TABLE employee_contracts IS 'Contratos laborales con condiciones salariales y vigencia.'; ``` ### 2.7 Recibos de Nómina (Payslips) ```sql -- Recibos de nómina CREATE TABLE payslips ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Identificación name VARCHAR(200), number VARCHAR(50), -- Número de referencia -- Empleado y contrato employee_id UUID NOT NULL REFERENCES employees(id), contract_id UUID NOT NULL REFERENCES employee_contracts(id), company_id UUID NOT NULL REFERENCES companies(id), -- Estructura struct_id UUID NOT NULL REFERENCES payroll_structures(id), -- Período date_from DATE NOT NULL, date_to DATE NOT NULL, date_payment DATE, -- Fecha de pago -- Estado state payslip_state NOT NULL DEFAULT 'draft', -- Flags credit_note BOOLEAN DEFAULT false, -- Es nota de crédito edited BOOLEAN DEFAULT false, -- Editado manualmente -- Lote de nómina payslip_run_id UUID REFERENCES payslip_runs(id), -- Integración contable move_id UUID, -- REFERENCES account_moves(id) -- Auditoría created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by UUID NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_by UUID NOT NULL ); -- Líneas del recibo (resultado de cada regla) CREATE TABLE payslip_lines ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), payslip_id UUID NOT NULL REFERENCES payslips(id) ON DELETE CASCADE, -- Regla salary_rule_id UUID NOT NULL REFERENCES salary_rules(id), category_id UUID NOT NULL REFERENCES salary_rule_categories(id), -- Identificación name VARCHAR(200) NOT NULL, code VARCHAR(50) NOT NULL, -- Valores sequence INTEGER NOT NULL, quantity DECIMAL(15,4) DEFAULT 1, rate DECIMAL(8,4) DEFAULT 100, -- Porcentaje aplicado amount DECIMAL(15,2) NOT NULL, total DECIMAL(15,2) NOT NULL, -- quantity * rate * amount / 100 -- Relación con empleado (desnormalizado para reportes) employee_id UUID NOT NULL REFERENCES employees(id), contract_id UUID NOT NULL REFERENCES employee_contracts(id), -- Auditoría created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Índices CREATE INDEX idx_payslips_employee ON payslips(employee_id); CREATE INDEX idx_payslips_period ON payslips(date_from, date_to); CREATE INDEX idx_payslips_state ON payslips(state); CREATE INDEX idx_payslip_lines_payslip ON payslip_lines(payslip_id); CREATE INDEX idx_payslip_lines_category ON payslip_lines(category_id); COMMENT ON TABLE payslips IS 'Recibos de nómina con estado y líneas calculadas.'; ``` ### 2.8 Días Trabajados y Entradas Variables ```sql -- Tipos de entrada de trabajo CREATE TABLE work_entry_types ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Identificación name VARCHAR(200) NOT NULL, code VARCHAR(50) NOT NULL, -- Configuración is_leave BOOLEAN DEFAULT false, -- Es ausencia is_paid BOOLEAN DEFAULT true, -- Es pagado -- Colores UI color VARCHAR(20), -- Estado is_active BOOLEAN DEFAULT true, CONSTRAINT uq_work_entry_type_code UNIQUE (code) ); -- Datos iniciales INSERT INTO work_entry_types (code, name, is_leave, is_paid) VALUES ('WORK100', 'Attendance', false, true), ('LEAVE110', 'Paid Time Off', true, true), ('LEAVE120', 'Sick Leave', true, true), ('LEAVE210', 'Unpaid Leave', true, false), ('EXTRA', 'Extra Hours', false, true); -- Días trabajados en el payslip CREATE TABLE payslip_worked_days ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), payslip_id UUID NOT NULL REFERENCES payslips(id) ON DELETE CASCADE, -- Tipo de trabajo work_entry_type_id UUID NOT NULL REFERENCES work_entry_types(id), -- Descripción name VARCHAR(200) NOT NULL, -- Cantidades number_of_days DECIMAL(10,4) NOT NULL DEFAULT 0, number_of_hours DECIMAL(10,4) NOT NULL DEFAULT 0, -- Monto calculado amount DECIMAL(15,2) NOT NULL DEFAULT 0, -- Es pagado is_paid BOOLEAN DEFAULT true, -- Auditoría created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Tipos de input (horas extra, bonos, etc.) CREATE TABLE payslip_input_types ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Identificación name VARCHAR(200) NOT NULL, code VARCHAR(50) NOT NULL, -- Estructura asociada struct_ids UUID[], -- Estructuras donde aplica -- País country_id UUID REFERENCES countries(id), -- Estado is_active BOOLEAN DEFAULT true, CONSTRAINT uq_payslip_input_type_code UNIQUE (country_id, code) ); -- Inputs variables en el payslip CREATE TABLE payslip_inputs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), payslip_id UUID NOT NULL REFERENCES payslips(id) ON DELETE CASCADE, -- Tipo input_type_id UUID NOT NULL REFERENCES payslip_input_types(id), -- Descripción name VARCHAR(200), -- Cantidad/Monto amount DECIMAL(15,2) NOT NULL DEFAULT 0, -- Auditoría created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Índices CREATE INDEX idx_payslip_worked_days_payslip ON payslip_worked_days(payslip_id); CREATE INDEX idx_payslip_inputs_payslip ON payslip_inputs(payslip_id); COMMENT ON TABLE payslip_worked_days IS 'Días y horas trabajados por tipo para cada payslip.'; COMMENT ON TABLE payslip_inputs IS 'Entradas variables (horas extra, bonos, deducciones) para cada payslip.'; ``` ### 2.9 Lotes de Nómina ```sql -- Lotes de nómina (procesamiento masivo) CREATE TABLE payslip_runs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Identificación name VARCHAR(200) NOT NULL, -- Empresa company_id UUID NOT NULL REFERENCES companies(id), -- Período date_start DATE NOT NULL, date_end DATE NOT NULL, -- Estado state payslip_state NOT NULL DEFAULT 'draft', -- Notas note TEXT, -- Auditoría created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by UUID NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_by UUID NOT NULL ); -- Índices CREATE INDEX idx_payslip_runs_company ON payslip_runs(company_id); CREATE INDEX idx_payslip_runs_period ON payslip_runs(date_start, date_end); COMMENT ON TABLE payslip_runs IS 'Lotes para procesar múltiples payslips de forma masiva.'; ``` --- ## Parte 3: Motor de Cálculo ### 3.1 Servicio de Cálculo de Nómina ```typescript // src/modules/payroll/domain/services/payroll-engine.service.ts import { Injectable } from '@nestjs/common'; export interface PayslipContext { payslip: PayslipData; employee: EmployeeData; contract: ContractData; worked_days: Record; inputs: Record; categories: Record; rules: Record; } @Injectable() export class PayrollEngineService { constructor( private readonly ruleRepository: SalaryRuleRepository, private readonly parameterService: RuleParameterService, private readonly pythonExecutor: PythonExecutorService ) {} /** * Calcular un payslip completo */ async computePayslip(payslipId: string): Promise { // 1. Cargar datos del payslip const payslip = await this.loadPayslipData(payslipId); // 2. Construir contexto inicial const context = await this.buildContext(payslip); // 3. Obtener reglas de la estructura, ordenadas por sequence const rules = await this.ruleRepository.findByStructure( payslip.structId, { orderBy: 'sequence ASC' } ); // 4. Ejecutar cada regla const lines: PayslipLine[] = []; for (const rule of rules) { const line = await this.executeRule(rule, context); if (line) { lines.push(line); // Actualizar contexto con resultado this.updateContext(context, rule, line); } } return lines; } /** * Construir contexto de ejecución */ private async buildContext(payslip: PayslipData): Promise { const employee = await this.loadEmployee(payslip.employeeId); const contract = await this.loadContract(payslip.contractId); const workedDays = await this.loadWorkedDays(payslip.id); const inputs = await this.loadInputs(payslip.id); return { payslip: { ...payslip, dict: { employee_id: employee, contract_id: contract, }, rule_parameter: (code: string) => this.getRuleParameter(code, payslip.dateFrom), result_rules: {} }, employee, contract, worked_days: this.indexByCode(workedDays), inputs: this.indexByCode(inputs), categories: {}, // Se llena durante ejecución rules: {} // Se llena durante ejecución }; } /** * Ejecutar una regla salarial */ private async executeRule( rule: SalaryRule, context: PayslipContext ): Promise { // 1. Evaluar condición const conditionMet = await this.evaluateCondition(rule, context); if (!conditionMet) { return null; } // 2. Calcular monto const result = await this.calculateAmount(rule, context); // 3. Crear línea return { salaryRuleId: rule.id, categoryId: rule.categoryId, name: rule.name, code: rule.code, sequence: rule.sequence, quantity: result.quantity, rate: result.rate, amount: result.amount, total: result.quantity * result.rate * result.amount / 100 }; } /** * Evaluar condición de la regla */ private async evaluateCondition( rule: SalaryRule, context: PayslipContext ): Promise { switch (rule.conditionSelect) { case 'none': return true; case 'python': return await this.pythonExecutor.evaluate( rule.conditionPython!, this.buildLocalDict(context) ); case 'range': const value = context.categories[rule.amountPercentageBase || 'BASIC'] || 0; return value >= (rule.conditionRangeMin || 0) && value <= (rule.conditionRangeMax || Infinity); default: return true; } } /** * Calcular monto de la regla */ private async calculateAmount( rule: SalaryRule, context: PayslipContext ): Promise<{ quantity: number; rate: number; amount: number }> { const defaultResult = { quantity: 1, rate: 100, amount: 0 }; switch (rule.amountSelect) { case 'fix': return { ...defaultResult, amount: rule.amountFix || 0 }; case 'percentage': const base = context.categories[rule.amountPercentageBase || 'BASIC'] || 0; return { ...defaultResult, amount: base, rate: rule.amountPercentage || 0 }; case 'code': const pythonResult = await this.pythonExecutor.execute( rule.amountPythonCompute!, this.buildLocalDict(context) ); return { quantity: pythonResult.result_qty ?? 1, rate: pythonResult.result_rate ?? 100, amount: pythonResult.result ?? 0 }; default: return defaultResult; } } /** * Construir diccionario local para ejecución Python */ private buildLocalDict(context: PayslipContext): Record { return { payslip: context.payslip, employee: context.employee, contract: context.contract, worked_days: this.createAccessibleDict(context.worked_days), inputs: this.createAccessibleDict(context.inputs), categories: this.createAccessibleDict(context.categories), rules: this.createAccessibleDict(context.rules), // Helpers float: parseFloat, int: parseInt, round: Math.round, min: Math.min, max: Math.max, abs: Math.abs }; } /** * Crear diccionario accesible por notación de punto */ private createAccessibleDict(obj: Record): Record & { [key: string]: T } { return new Proxy(obj, { get: (target, prop: string) => { // Acceso por punto o corchetes return target[prop] ?? target[prop.replace('.', '_')] ?? 0; } }) as Record & { [key: string]: T }; } /** * Actualizar contexto después de ejecutar regla */ private updateContext( context: PayslipContext, rule: SalaryRule, line: PayslipLine ): void { // Actualizar reglas context.rules[rule.code] = line.total; context.payslip.result_rules[rule.code] = line.total; // Actualizar categorías (acumular) const categoryCode = rule.categoryCode; context.categories[categoryCode] = (context.categories[categoryCode] || 0) + line.total; // Acumular en categoría padre si existe if (rule.parentCategoryCode) { context.categories[rule.parentCategoryCode] = (context.categories[rule.parentCategoryCode] || 0) + line.total; } } /** * Obtener parámetro de regla por código y fecha */ private async getRuleParameter(code: string, date: Date): Promise { const value = await this.parameterService.getValue(code, date); if (!value) return null; // Intentar parsear como JSON try { return JSON.parse(value); } catch { // Intentar como número const num = parseFloat(value); return isNaN(num) ? value : num; } } /** * Indexar array por código */ private indexByCode(items: T[]): Record { return items.reduce((acc, item) => { acc[item.code] = item; return acc; }, {} as Record); } } ``` ### 3.2 Ejecutor de Python (Sandbox) ```typescript // src/modules/payroll/infrastructure/python-executor.service.ts import { Injectable } from '@nestjs/common'; import { VM } from 'vm2'; @Injectable() export class PythonExecutorService { /** * Ejecutar código Python-like en sandbox * Nota: Usa JavaScript/VM2 para simular sintaxis Python básica */ async execute(code: string, localDict: Record): Promise { // Convertir sintaxis Python básica a JavaScript const jsCode = this.pythonToJs(code); // Crear sandbox con variables const vm = new VM({ timeout: 1000, // 1 segundo máximo sandbox: { ...localDict, result: 0, result_qty: 1, result_rate: 100, } }); try { vm.run(jsCode); return { result: vm.getGlobal('result'), result_qty: vm.getGlobal('result_qty'), result_rate: vm.getGlobal('result_rate') }; } catch (error) { console.error(`Error executing rule code: ${error.message}`); return { result: 0, result_qty: 1, result_rate: 100 }; } } /** * Evaluar expresión booleana */ async evaluate(code: string, localDict: Record): Promise { const jsCode = this.pythonToJs(code); const vm = new VM({ timeout: 500, sandbox: localDict }); try { return vm.run(`(${jsCode})`); } catch { return false as T; } } /** * Convertir sintaxis Python básica a JavaScript */ private pythonToJs(pythonCode: string): string { return pythonCode // Comentarios .replace(/#(.*)$/gm, '//$1') // True/False/None .replace(/\bTrue\b/g, 'true') .replace(/\bFalse\b/g, 'false') .replace(/\bNone\b/g, 'null') // and/or/not .replace(/\band\b/g, '&&') .replace(/\bor\b/g, '||') .replace(/\bnot\b/g, '!') // elif -> else if .replace(/\belif\b/g, 'else if') // for in range -> for loop .replace(/for\s+(\w+)\s+in\s+range\((\d+)\):/g, 'for(let $1=0;$1<$2;$1++){') // for in -> for of .replace(/for\s+(\w+)\s+in\s+(\w+):/g, 'for(let $1 of $2){') // if/else con : .replace(/if\s+(.+):/g, 'if($1){') .replace(/else:/g, '}else{') // Indentación a llaves (simplificado) .replace(/:\s*$/gm, '{') // Cerrar bloques (heurística simple) .replace(/^(\s*)(\w)/gm, (match, indent, char) => { // Agregar } antes de líneas con menos indentación return match; }); } } ``` ### 3.3 Servicio de Días Trabajados ```typescript // src/modules/payroll/domain/services/worked-days.service.ts @Injectable() export class WorkedDaysService { constructor( private readonly calendarService: ResourceCalendarService, private readonly leaveService: LeaveService ) {} /** * Calcular días trabajados para un payslip */ async calculateWorkedDays( employeeId: string, contractId: string, dateFrom: Date, dateTo: Date ): Promise { const contract = await this.getContract(contractId); const calendar = await this.calendarService.getById(contract.resourceCalendarId); // 1. Calcular días/horas del período según calendario const scheduledDays = this.calculateScheduledDays(calendar, dateFrom, dateTo); // 2. Obtener ausencias del empleado en el período const leaves = await this.leaveService.getApprovedLeaves(employeeId, dateFrom, dateTo); // 3. Construir resultado const workedDays: WorkedDay[] = []; // Días de trabajo normal const workDays = this.subtractLeaves(scheduledDays, leaves); workedDays.push({ workEntryTypeCode: 'WORK100', name: 'Attendance', numberOfDays: workDays.days, numberOfHours: workDays.hours, amount: this.calculateDailyAmount(contract, workDays.days), isPaid: true }); // Agregar cada tipo de ausencia for (const leave of leaves) { workedDays.push({ workEntryTypeCode: leave.leaveType.code, name: leave.leaveType.name, numberOfDays: leave.numberOfDays, numberOfHours: leave.numberOfHours, amount: leave.leaveType.isPaid ? this.calculateDailyAmount(contract, leave.numberOfDays) : 0, isPaid: leave.leaveType.isPaid }); } return workedDays; } /** * Calcular monto diario del contrato */ private calculateDailyAmount(contract: Contract, days: number): number { // Salario mensual / 30 días * días trabajados return (contract.wage / 30) * days; } } ``` --- ## Parte 4: API REST ### 4.1 Controlador de Payslips ```typescript // src/modules/payroll/interfaces/http/payslip.controller.ts @Controller('payroll/payslips') @ApiTags('Payroll') export class PayslipController { constructor( private readonly payslipService: PayslipService, private readonly payrollEngine: PayrollEngineService ) {} @Get() @ApiOperation({ summary: 'List payslips' }) async list( @Query() filters: PayslipFiltersDto ): Promise> { return this.payslipService.findAll(filters); } @Get(':id') @ApiOperation({ summary: 'Get payslip by ID' }) async getById(@Param('id') id: string): Promise { return this.payslipService.findById(id); } @Post() @ApiOperation({ summary: 'Create payslip' }) async create( @Body() dto: CreatePayslipDto, @CurrentUser() user: User ): Promise { return this.payslipService.create(dto, user.id); } @Post(':id/compute') @ApiOperation({ summary: 'Compute payslip lines' }) async compute(@Param('id') id: string): Promise { const lines = await this.payrollEngine.computePayslip(id); return this.payslipService.saveLines(id, lines); } @Post(':id/confirm') @ApiOperation({ summary: 'Confirm payslip' }) async confirm( @Param('id') id: string, @CurrentUser() user: User ): Promise { return this.payslipService.confirm(id, user.id); } @Post(':id/create-move') @ApiOperation({ summary: 'Create journal entry for payslip' }) async createMove(@Param('id') id: string): Promise<{ moveId: string }> { return this.payslipService.createJournalEntry(id); } @Post(':id/cancel') @ApiOperation({ summary: 'Cancel payslip' }) async cancel( @Param('id') id: string, @CurrentUser() user: User ): Promise { return this.payslipService.cancel(id, user.id); } @Get(':id/pdf') @ApiOperation({ summary: 'Download payslip PDF' }) @Header('Content-Type', 'application/pdf') async downloadPdf(@Param('id') id: string): Promise { return this.payslipService.generatePdf(id); } } // DTOs class CreatePayslipDto { @IsUUID() employeeId: string; @IsUUID() contractId: string; @IsDateString() dateFrom: string; @IsDateString() dateTo: string; @IsOptional() @IsUUID() structId?: string; } class PayslipFiltersDto { @IsOptional() @IsUUID() employeeId?: string; @IsOptional() @IsEnum(PayslipState) state?: PayslipState; @IsOptional() @IsDateString() dateFrom?: string; @IsOptional() @IsDateString() dateTo?: string; } ``` ### 4.2 Controlador de Lotes ```typescript // src/modules/payroll/interfaces/http/payslip-run.controller.ts @Controller('payroll/runs') @ApiTags('Payroll Runs') export class PayslipRunController { constructor( private readonly runService: PayslipRunService ) {} @Get() @ApiOperation({ summary: 'List payslip runs' }) async list(): Promise { return this.runService.findAll(); } @Post() @ApiOperation({ summary: 'Create payslip run' }) async create( @Body() dto: CreatePayslipRunDto, @CurrentUser() user: User ): Promise { return this.runService.create(dto, user.id); } @Post(':id/generate') @ApiOperation({ summary: 'Generate payslips for all employees' }) async generate(@Param('id') id: string): Promise<{ count: number }> { return this.runService.generatePayslips(id); } @Post(':id/compute-all') @ApiOperation({ summary: 'Compute all payslips in run' }) async computeAll(@Param('id') id: string): Promise<{ computed: number }> { return this.runService.computeAll(id); } @Post(':id/confirm-all') @ApiOperation({ summary: 'Confirm all payslips in run' }) async confirmAll( @Param('id') id: string, @CurrentUser() user: User ): Promise<{ confirmed: number }> { return this.runService.confirmAll(id, user.id); } } ``` ### 4.3 Controlador de Reglas ```typescript // src/modules/payroll/interfaces/http/salary-rule.controller.ts @Controller('payroll/rules') @ApiTags('Salary Rules') export class SalaryRuleController { constructor( private readonly ruleService: SalaryRuleService ) {} @Get() @ApiOperation({ summary: 'List salary rules' }) async list( @Query('structId') structId?: string ): Promise { return this.ruleService.findAll({ structId }); } @Get(':id') @ApiOperation({ summary: 'Get rule by ID' }) async getById(@Param('id') id: string): Promise { return this.ruleService.findById(id); } @Post() @ApiOperation({ summary: 'Create salary rule' }) async create( @Body() dto: CreateSalaryRuleDto, @CurrentUser() user: User ): Promise { return this.ruleService.create(dto, user.id); } @Put(':id') @ApiOperation({ summary: 'Update salary rule' }) async update( @Param('id') id: string, @Body() dto: UpdateSalaryRuleDto, @CurrentUser() user: User ): Promise { return this.ruleService.update(id, dto, user.id); } @Post(':id/test') @ApiOperation({ summary: 'Test rule calculation' }) async testRule( @Param('id') id: string, @Body() dto: TestRuleDto ): Promise { return this.ruleService.testRule(id, dto); } } ``` --- ## Parte 5: Reglas de México (Ejemplo) ### 5.1 Estructura de Nómina México ```typescript // src/modules/payroll/data/templates/mx-payroll.template.ts export const mexicoPayrollTemplate: PayrollTemplateDefinition = { structureType: { code: 'mx_employee', name: 'México: Empleado Regular', countryCode: 'MX' }, structure: { code: 'MX_MONTHLY', name: 'México: Nómina Mensual', schedulePay: 'monthly' }, categories: [ { code: 'BASIC', name: 'Salario Base', parent: null }, { code: 'PERC', name: 'Percepciones', parent: 'ALW' }, { code: 'DEDU', name: 'Deducciones', parent: 'DED' }, { code: 'IMSS_EMP', name: 'IMSS Empleado', parent: 'DED' }, { code: 'IMSS_PAT', name: 'IMSS Patrón', parent: 'COMP' }, { code: 'ISR', name: 'ISR', parent: 'DED' }, ], rules: [ // BÁSICO { code: 'BASIC', name: 'Salario Base', categoryCode: 'BASIC', sequence: 10, amountSelect: 'code', amountPythonCompute: ` # Salario diario integrado result = contract.wage ` }, // PERCEPCIONES { code: 'PREMIO_PUNT', name: 'Premio por Puntualidad', categoryCode: 'PERC', sequence: 20, conditionSelect: 'python', conditionPython: 'worked_days.WORK100.number_of_days >= 15', amountSelect: 'percentage', amountPercentage: 10, amountPercentageBase: 'BASIC' }, { code: 'PREMIO_ASIST', name: 'Premio por Asistencia', categoryCode: 'PERC', sequence: 21, conditionSelect: 'python', conditionPython: 'worked_days.LEAVE110.number_of_days == 0', amountSelect: 'percentage', amountPercentage: 10, amountPercentageBase: 'BASIC' }, // GROSS { code: 'GROSS', name: 'Salario Bruto', categoryCode: 'GROSS', sequence: 50, amountSelect: 'code', amountPythonCompute: 'result = categories.BASIC + categories.ALW' }, // DEDUCCIONES IMSS EMPLEADO { code: 'IMSS_ENF_MAT_EMP', name: 'IMSS Enf. y Mat. (Empleado)', categoryCode: 'IMSS_EMP', sequence: 60, amountSelect: 'code', amountPythonCompute: ` sdi = payslip.rule_parameter('mx_sdi') or contract.wage / 30 uma = payslip.rule_parameter('mx_uma') excedente = max(0, sdi - 3 * uma) result = -(excedente * 30 * 0.004) # 0.40% ` }, { code: 'IMSS_INV_VIDA_EMP', name: 'IMSS Inv. y Vida (Empleado)', categoryCode: 'IMSS_EMP', sequence: 61, amountSelect: 'code', amountPythonCompute: ` sdi = payslip.rule_parameter('mx_sdi') or contract.wage / 30 result = -(sdi * 30 * 0.00625) # 0.625% ` }, { code: 'IMSS_CESANTIA_EMP', name: 'IMSS Cesantía (Empleado)', categoryCode: 'IMSS_EMP', sequence: 62, amountSelect: 'code', amountPythonCompute: ` sdi = payslip.rule_parameter('mx_sdi') or contract.wage / 30 result = -(sdi * 30 * 0.01125) # 1.125% ` }, // TOTAL IMSS EMPLEADO { code: 'IMSS_TOTAL_EMP', name: 'Total IMSS Empleado', categoryCode: 'DEDU', sequence: 70, amountSelect: 'code', amountPythonCompute: "result = categories['IMSS_EMP']" }, // ISR { code: 'ISR_MENSUAL', name: 'ISR Mensual', categoryCode: 'ISR', sequence: 80, amountSelect: 'code', amountPythonCompute: ` # Base gravable base = categories.GROSS - abs(categories['IMSS_EMP']) # Tabla ISR mensual brackets = payslip.rule_parameter('mx_isr_mensual') # [(limite_inf, limite_sup, cuota_fija, porcentaje), ...] isr = 0 for lim_inf, lim_sup, cuota, pct in brackets: if lim_sup == 'inf': lim_sup = float('inf') if lim_inf <= base <= lim_sup: excedente = base - lim_inf isr = cuota + (excedente * pct / 100) break # Subsidio al empleo subsidio = payslip.rule_parameter('mx_subsidio_empleo') or 0 # Similar: buscar en tabla por base result = -(max(0, isr - subsidio)) ` }, // NETO { code: 'NET', name: 'Salario Neto', categoryCode: 'NET', sequence: 190, amountSelect: 'code', amountPythonCompute: 'result = categories.GROSS + categories.DED' }, // CONTRIBUCIONES PATRÓN (no aparecen en payslip) { code: 'IMSS_RIESGO_PAT', name: 'IMSS Riesgo de Trabajo (Patrón)', categoryCode: 'IMSS_PAT', sequence: 200, appearsOnPayslip: false, amountSelect: 'code', amountPythonCompute: ` sdi = payslip.rule_parameter('mx_sdi') or contract.wage / 30 prima_riesgo = payslip.rule_parameter('mx_prima_riesgo') or 0.54355 # Clase II result = sdi * 30 * prima_riesgo / 100 ` }, { code: 'IMSS_ENF_MAT_PAT', name: 'IMSS Enf. y Mat. (Patrón)', categoryCode: 'IMSS_PAT', sequence: 201, appearsOnPayslip: false, amountSelect: 'code', amountPythonCompute: ` sdi = payslip.rule_parameter('mx_sdi') or contract.wage / 30 uma = payslip.rule_parameter('mx_uma') # Cuota fija cuota_fija = uma * 30 * 0.204 # 20.40% # Excedente 3 UMA excedente = max(0, sdi - 3 * uma) cuota_excedente = excedente * 30 * 0.011 # 1.10% # Prestaciones en dinero prestaciones = sdi * 30 * 0.007 # 0.70% # Gastos médicos pensionados gastos_med = sdi * 30 * 0.0105 # 1.05% result = cuota_fija + cuota_excedente + prestaciones + gastos_med ` }, { code: 'INFONAVIT_PAT', name: 'INFONAVIT (Patrón)', categoryCode: 'IMSS_PAT', sequence: 210, appearsOnPayslip: false, amountSelect: 'code', amountPythonCompute: ` sdi = payslip.rule_parameter('mx_sdi') or contract.wage / 30 result = sdi * 30 * 0.05 # 5% ` } ], parameters: [ { code: 'mx_uma', name: 'UMA (Unidad de Medida y Actualización)', values: [ { dateFrom: '2024-01-01', value: '108.57' }, { dateFrom: '2025-01-01', value: '113.14' } ] }, { code: 'mx_isr_mensual', name: 'Tabla ISR Mensual', values: [ { dateFrom: '2024-01-01', value: JSON.stringify([ [0.01, 746.04, 0, 1.92], [746.05, 6332.05, 14.32, 6.40], [6332.06, 11128.01, 371.83, 10.88], [11128.02, 12935.82, 893.63, 16.00], [12935.83, 15487.71, 1182.88, 17.92], [15487.72, 31236.49, 1640.18, 21.36], [31236.50, 49233.00, 5004.12, 23.52], [49233.01, 93993.90, 9236.89, 30.00], [93993.91, 125325.20, 22665.17, 32.00], [125325.21, 375975.61, 32691.18, 34.00], [375975.62, 'inf', 117912.32, 35.00] ]) } ] }, { code: 'mx_prima_riesgo', name: 'Prima de Riesgo de Trabajo', values: [ { dateFrom: '2024-01-01', value: '0.54355' } // Clase II por defecto ] } ] }; ``` --- ## Parte 6: Integración Contable ### 6.1 Generación de Asientos ```typescript // src/modules/payroll/domain/services/payroll-accounting.service.ts @Injectable() export class PayrollAccountingService { constructor( private readonly accountMoveService: AccountMoveService, private readonly journalService: JournalService ) {} /** * Crear asiento contable desde payslip */ async createJournalEntry(payslipId: string): Promise { const payslip = await this.getPayslipWithLines(payslipId); // Obtener diario de nómina const journal = await this.journalService.findByType( payslip.companyId, 'payroll' ); // Agrupar líneas por cuenta const moveLines = this.buildMoveLines(payslip); // Crear asiento const move = await this.accountMoveService.create({ journalId: journal.id, date: payslip.dateTo, ref: `Payslip ${payslip.number}`, lines: moveLines }); // Vincular al payslip await this.updatePayslipMove(payslipId, move.id); return move.id; } /** * Construir líneas del asiento contable */ private buildMoveLines(payslip: PayslipWithLines): MoveLineData[] { const lines: MoveLineData[] = []; const linesByAccount = new Map(); for (const line of payslip.lines) { const rule = line.salaryRule; // Solo procesar reglas con cuentas configuradas if (!rule.accountDebitId && !rule.accountCreditId) continue; const amount = Math.abs(line.total); // Débito (gasto o activo) if (rule.accountDebitId && line.total > 0) { const current = linesByAccount.get(`D_${rule.accountDebitId}`) || 0; linesByAccount.set(`D_${rule.accountDebitId}`, current + amount); } // Crédito (pasivo o gasto negativo) if (rule.accountCreditId) { const current = linesByAccount.get(`C_${rule.accountCreditId}`) || 0; linesByAccount.set(`C_${rule.accountCreditId}`, current + amount); } } // Convertir a líneas de asiento for (const [key, amount] of linesByAccount) { const [type, accountId] = key.split('_'); lines.push({ accountId, name: `Payslip ${payslip.number} - ${payslip.employee.name}`, debit: type === 'D' ? amount : 0, credit: type === 'C' ? amount : 0, partnerId: payslip.employeeId }); } // Agregar línea de banco/efectivo (neto a pagar) const netAmount = payslip.lines .find(l => l.code === 'NET')?.total || 0; if (netAmount > 0) { const bankAccount = await this.getBankAccount(payslip.companyId); lines.push({ accountId: bankAccount.id, name: `Pago nómina - ${payslip.employee.name}`, debit: 0, credit: netAmount }); } return lines; } } ``` --- ## Parte 7: Migraciones ```sql -- migrations/YYYYMMDD_add_payroll_system.sql -- Tipos enumerados DO $$ BEGIN CREATE TYPE contract_state AS ENUM ('draft', 'open', 'close', 'cancel'); EXCEPTION WHEN duplicate_object THEN null; END $$; DO $$ BEGIN CREATE TYPE payslip_state AS ENUM ('draft', 'verify', 'done', 'paid', 'cancel'); EXCEPTION WHEN duplicate_object THEN null; END $$; DO $$ BEGIN CREATE TYPE rule_condition_select AS ENUM ('none', 'python', 'range'); EXCEPTION WHEN duplicate_object THEN null; END $$; DO $$ BEGIN CREATE TYPE rule_amount_select AS ENUM ('fix', 'percentage', 'code'); EXCEPTION WHEN duplicate_object THEN null; END $$; -- Tablas principales (ver DDL completo arriba) -- ... -- Índices de rendimiento CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_payslips_compute ON payslips(employee_id, state, date_from, date_to); CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_salary_rules_execution ON salary_rules(struct_id, sequence, is_active); -- Comentarios COMMENT ON TABLE payslips IS 'Recibos de nómina con cálculo automático de líneas'; COMMENT ON TABLE salary_rules IS 'Reglas salariales con fórmulas Python-like'; COMMENT ON TABLE employee_contracts IS 'Contratos laborales con condiciones salariales'; ``` --- ## Parte 8: Resumen ### 8.1 Gap Cubierto | Gap ID | Descripción | Estado | |--------|-------------|--------| | GAP-MGN-010-001 | Cálculo de nómina básico | ✅ Especificado | ### 8.2 Componentes Principales | Componente | Tipo | Descripción | |------------|------|-------------| | `payslips` | Tabla | Recibos de nómina | | `payslip_lines` | Tabla | Líneas calculadas | | `salary_rules` | Tabla | Reglas con fórmulas | | `payroll_structures` | Tabla | Plantillas de reglas | | `employee_contracts` | Tabla | Contratos laborales | | `rule_parameters` | Tabla | Parámetros versionados | | `PayrollEngineService` | Service | Motor de cálculo | | `PayslipController` | API | Endpoints REST | ### 8.3 Características Implementadas - Motor de cálculo con fórmulas Python-like en sandbox - Reglas con condiciones (none, python, range) - Cálculos (fijo, porcentaje, código) - Categorías con acumulación automática - Parámetros versionados por fecha - Días trabajados y ausencias - Inputs variables (horas extra, bonos) - Integración contable automática - Lotes de nómina para procesamiento masivo - Plantilla de México como ejemplo --- ## Referencias - Odoo 18 `hr_payroll` - Módulo de nómina - Odoo 18 `l10n_mx_hr_payroll` - Localización México - IMSS - Ley del Seguro Social (cuotas obrero-patronales) - SAT - Tablas ISR y subsidio al empleo - NOM-035 - Factores de riesgo psicosocial