51 KiB
51 KiB
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:
- Recibos de Nómina (Payslips): Documentos de pago a empleados con cálculo automático
- Reglas Salariales: Motor de cálculo configurable con fórmulas Python
- Estructuras de Nómina: Plantillas de reglas por tipo de empleado/país
- Contratos: Vinculación de empleados con condiciones salariales
- Integración Contable: Generación automática de asientos contables
- 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
-- 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
-- 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
-- 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
-- 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
-- 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
-- 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)
-- 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
-- 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
-- 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
// 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<string, WorkedDayData>;
inputs: Record<string, InputData>;
categories: Record<string, number>;
rules: Record<string, number>;
}
@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<PayslipLine[]> {
// 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<PayslipContext> {
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<PayslipLine | null> {
// 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<boolean> {
switch (rule.conditionSelect) {
case 'none':
return true;
case 'python':
return await this.pythonExecutor.evaluate<boolean>(
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<string, any> {
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<T>(obj: Record<string, T>): Record<string, T> & { [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<string, T> & { [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<any> {
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<T extends { code: string }>(items: T[]): Record<string, T> {
return items.reduce((acc, item) => {
acc[item.code] = item;
return acc;
}, {} as Record<string, T>);
}
}
3.2 Ejecutor de Python (Sandbox)
// 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<string, any>): Promise<any> {
// 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<T>(code: string, localDict: Record<string, any>): Promise<T> {
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
// 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<WorkedDay[]> {
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
// 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<PaginatedResult<PayslipDto>> {
return this.payslipService.findAll(filters);
}
@Get(':id')
@ApiOperation({ summary: 'Get payslip by ID' })
async getById(@Param('id') id: string): Promise<PayslipDetailDto> {
return this.payslipService.findById(id);
}
@Post()
@ApiOperation({ summary: 'Create payslip' })
async create(
@Body() dto: CreatePayslipDto,
@CurrentUser() user: User
): Promise<PayslipDto> {
return this.payslipService.create(dto, user.id);
}
@Post(':id/compute')
@ApiOperation({ summary: 'Compute payslip lines' })
async compute(@Param('id') id: string): Promise<PayslipDetailDto> {
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<PayslipDto> {
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<PayslipDto> {
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<Buffer> {
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
// 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<PayslipRunDto[]> {
return this.runService.findAll();
}
@Post()
@ApiOperation({ summary: 'Create payslip run' })
async create(
@Body() dto: CreatePayslipRunDto,
@CurrentUser() user: User
): Promise<PayslipRunDto> {
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
// 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<SalaryRuleDto[]> {
return this.ruleService.findAll({ structId });
}
@Get(':id')
@ApiOperation({ summary: 'Get rule by ID' })
async getById(@Param('id') id: string): Promise<SalaryRuleDetailDto> {
return this.ruleService.findById(id);
}
@Post()
@ApiOperation({ summary: 'Create salary rule' })
async create(
@Body() dto: CreateSalaryRuleDto,
@CurrentUser() user: User
): Promise<SalaryRuleDto> {
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<SalaryRuleDto> {
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<TestRuleResultDto> {
return this.ruleService.testRule(id, dto);
}
}
Parte 5: Reglas de México (Ejemplo)
5.1 Estructura de Nómina México
// 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
// 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<string> {
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<string, number>();
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
-- 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