erp-core/docs/04-modelado/especificaciones-tecnicas/transversal/SPEC-NOMINA-BASICA.md

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:

  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

-- 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