import { BadRequestException, Injectable, Optional, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { RrhhComisionesService } from '../comisiones/rrhh-comisiones.service';

// ── Tipos del resultado del motor ──────────────────────────────
export type LineaCalculada = {
  empleado_id: string;
  concepto_id: string;
  concepto_codigo: string;
  tipo: 'INGRESO' | 'EGRESO';
  monto: number;
  base_calculo?: number;
  porcentaje_aplicado?: number;
  origen: 'AUTOMATICO' | 'MANUAL' | 'IMPORTADO' | 'ANTICIPO' | 'PRESTAMO' | 'NOVEDAD' | 'IPS' | 'COMISION';
  referencia_origen_id?: string;
  centro_costo_id?: string;
  observacion?: string;
};

export type ResumenEmpleado = {
  empleado_id: string;
  salario_bruto: number;
  total_ingresos: number;
  total_egresos: number;
  base_ips: number;
  ips_obrero: number;
  ips_patronal: number;
  ips_admin: number;
  neto_a_pagar: number;
  dias_trabajados: number;
  dias_mes: number;
  alerta_descuento: boolean;
};

export type ResultadoCalculo = {
  detalles: LineaCalculada[];
  resumenes: ResumenEmpleado[];
  totales: {
    total_ingresos: number;
    total_egresos: number;
    total_neto: number;
    total_ips_obrero: number;
    total_ips_patronal: number;
    total_ips_admin: number;
    cantidad_empleados: number;
  };
  advertencias: string[];
};

const r2 = (n: number) => Math.round((n + Number.EPSILON) * 100) / 100;
const num = (v: unknown, fb = 0) => {
  const n = Number(v);
  return Number.isFinite(n) ? n : fb;
};
const diasEnMes = (anio: number, mes: number) => new Date(anio, mes, 0).getDate();

@Injectable()
export class RrhhEngineService {
  private readonly logger = new Logger(RrhhEngineService.name);

  constructor(
    private readonly prisma: PrismaService,
    @Optional() private readonly comisiones?: RrhhComisionesService,
  ) {}

  private async getParametroVigente(empresaId: string, clave: string, fecha: Date): Promise<number | null> {
    const p = await this.prisma.rrhh_parametros_sistema.findFirst({
      where: {
        empresa_id: empresaId,
        clave,
        vigencia_desde: { lte: fecha },
        OR: [{ vigencia_hasta: null }, { vigencia_hasta: { gte: fecha } }],
      },
      orderBy: { vigencia_desde: 'desc' },
    });
    if (!p) return null;
    const v = Number(p.valor);
    return Number.isFinite(v) ? v : null;
  }

  /**
   * Calcula el IPS (obrero / patronal / admin) de un empleado sobre una base
   * ya neteada de los conceptos marcados con `afecta_base_ips` (los INGRESO
   * suman, los EGRESO restan). Aplica el piso legal (SMLV o 80% en régimen
   * MICROEMPRESA_80) prorrateado por días trabajados. Única fuente de verdad
   * de la fórmula IPS: la usan tanto la liquidación mensual como el recálculo
   * de ajustes manuales (viáticos/comisiones cargados a mano). Sólo aplica a
   * liquidaciones que aportan IPS (mensual y vacaciones).
   */
  async calcularIpsSobreBase(
    empresaId: string,
    empleado: { aporta_ips: boolean; regimen_ips?: string | null },
    baseIngresosIps: number,
    ctx: { fechaCorte: Date; diasTrabajados: number; diasMes: number },
  ): Promise<{ baseIps: number; ipsObrero: number; ipsPatronal: number; ipsAdmin: number }> {
    if (!empleado.aporta_ips) return { baseIps: 0, ipsObrero: 0, ipsPatronal: 0, ipsAdmin: 0 };
    const { fechaCorte, diasTrabajados, diasMes } = ctx;
    const ipsObreroPct = (await this.getParametroVigente(empresaId, 'IPS_PORCENTAJE_OBRERO', fechaCorte)) ?? 0.09;
    const ipsPatronalPct = (await this.getParametroVigente(empresaId, 'IPS_PORCENTAJE_PATRONAL', fechaCorte)) ?? 0.165;
    const ipsAdminPct = (await this.getParametroVigente(empresaId, 'IPS_PORCENTAJE_ADMIN', fechaCorte)) ?? 0.01;
    const smlvMensual = (await this.getParametroVigente(empresaId, 'SMLV_MENSUAL', fechaCorte)) ?? 0;
    const microPct = (await this.getParametroVigente(empresaId, 'PORCENTAJE_MICROEMPRESA_BASE_IPS', fechaCorte)) ?? 0.8;
    const esProporcional = diasMes > 0 && diasTrabajados < diasMes;
    const minLegal = empleado.regimen_ips === 'MICROEMPRESA_80' ? r2(smlvMensual * microPct) : smlvMensual;
    const minProporcional = esProporcional ? r2((minLegal * diasTrabajados) / diasMes) : minLegal;
    const baseIps = Math.max(r2(baseIngresosIps), minProporcional);
    return {
      baseIps,
      ipsObrero: r2(baseIps * ipsObreroPct),
      ipsPatronal: r2(baseIps * ipsPatronalPct),
      ipsAdmin: r2(baseIps * ipsAdminPct),
    };
  }

  private async cargarConceptos(empresaId: string) {
    const conceptos = await this.prisma.rrhh_conceptos_liquidacion.findMany({
      where: { empresa_id: empresaId },
    });
    const porCodigo = new Map(conceptos.map((c) => [c.codigo, c]));
    const requerir = (codigo: string) => {
      const c = porCodigo.get(codigo);
      if (!c) {
        throw new BadRequestException(
          `Falta el concepto del sistema "${codigo}". Ejecutá «Sembrar defaults» en Configuración › Conceptos.`,
        );
      }
      return c;
    };
    return { conceptos, porCodigo, requerir };
  }

  private async cargarEmpleados(
    empresaId: string,
    inicioMes: Date,
    finMes: Date,
    sucursalId?: string | null,
    tipoEmpleadoId?: string | null,
  ) {
    const where: any = {
      empresa_id: empresaId,
      // Quien egresa durante el mes igual entra: cobra los días que trabajó.
      OR: [{ estado: { not: 'EGRESADO' } }, { fecha_egreso: { gte: inicioMes, lte: finMes } }],
      fecha_ingreso: { lte: finMes },
    };
    if (sucursalId) where.sucursal_id = sucursalId;
    if (tipoEmpleadoId) where.tipo_empleado_id = tipoEmpleadoId;
    const empleados = await this.prisma.rrhh_empleados.findMany({ where });

    // ...salvo que su finiquito ya se los haya pagado. La desvinculación liquida
    // `salario_pendiente` como SUELDOS_JORNALES, así que dejarlo también en la
    // planilla del mes paga los mismos días dos veces.
    //
    // Pasó en producción: una renuncia con último día 04/09, finiquito PAGADO por
    // Gs. 413.333 de salario pendiente, y la liquidación mensual calculada treinta
    // segundos después incluyendo al empleado por esos mismos 4 días.
    //
    // Se excluye sólo si la desvinculación sigue viva (no ANULADO): una anulada
    // no pagó nada y el empleado tiene que volver a la planilla.
    const desvinculados = await this.prisma.rrhh_desvinculaciones.findMany({
      where: {
        empresa_id: empresaId,
        empleado_id: { in: empleados.map((e) => e.id) },
        estado: { not: 'ANULADO' },
        fecha_ultimo_dia: { gte: inicioMes, lte: finMes },
      },
      select: { empleado_id: true },
    });
    if (desvinculados.length === 0) return empleados;

    const conFiniquito = new Set(desvinculados.map((d) => d.empleado_id));
    const excluidos = empleados.filter((e) => conFiniquito.has(e.id));
    if (excluidos.length > 0) {
      this.logger.log(
        `Liquidación ${inicioMes.toISOString().substring(0, 7)}: ${excluidos.length} empleado(s) excluidos porque su desvinculación ya liquidó el salario pendiente`,
      );
    }
    return empleados.filter((e) => !conFiniquito.has(e.id));
  }

  /**
   * Normaliza una fecha que viene de Prisma `@db.Date` (siempre medianoche UTC)
   * a una fecha "local" equivalente para comparar con inicioMes/finMes que están
   * construidos con el constructor local (`new Date(anio, mes-1, dia)`).
   * Evita corrimientos de un día en zonas horarias != UTC.
   */
  private aFechaLocal(value: Date | null): Date | null {
    if (!value) return null;
    return new Date(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate());
  }

  /** Días [a,b] (1..N) que un empleado trabajó en el mes (por ingreso/egreso a mitad).
   *  ultimoDia se acota a totalDiasMes para soportar mes comercial (base 30 en meses de 31 días). */
  private rangoTrabajadoDelMes(emp: { fecha_ingreso: Date | null; fecha_egreso: Date | null }, inicioMes: Date, finMes: Date, totalDiasMes: number) {
    let primerDia = 1;
    let ultimoDia = totalDiasMes;
    const ingreso = this.aFechaLocal(emp.fecha_ingreso);
    const egreso = this.aFechaLocal(emp.fecha_egreso);
    if (ingreso && ingreso > inicioMes && ingreso <= finMes) primerDia = ingreso.getDate();
    if (egreso && egreso >= inicioMes && egreso <= finMes) ultimoDia = Math.min(egreso.getDate(), totalDiasMes);
    return { primerDia, ultimoDia };
  }

  // ════════════════════════════════════════════════════════════
  // MENSUAL
  // ════════════════════════════════════════════════════════════
  async calcularMensual(
    empresaId: string,
    anio: number,
    mes: number,
    opts: { sucursalId?: string | null; tipoEmpleadoId?: string | null; liquidacionId?: string } = {},
  ): Promise<ResultadoCalculo> {
    const advertencias: string[] = [];
    const calendarDias = diasEnMes(anio, mes);
    const inicioMes = new Date(anio, mes - 1, 1);
    const finMes = new Date(anio, mes - 1, calendarDias);
    const fechaCorte = finMes;

    // ── Parámetros ──
    const ipsObreroPct = (await this.getParametroVigente(empresaId, 'IPS_PORCENTAJE_OBRERO', fechaCorte)) ?? 0.09;
    const ipsPatronalPct = (await this.getParametroVigente(empresaId, 'IPS_PORCENTAJE_PATRONAL', fechaCorte)) ?? 0.165;
    const ipsAdminPct = (await this.getParametroVigente(empresaId, 'IPS_PORCENTAJE_ADMIN', fechaCorte)) ?? 0.01;
    const smlvMensual = (await this.getParametroVigente(empresaId, 'SMLV_MENSUAL', fechaCorte)) ?? 0;
    const microPct = (await this.getParametroVigente(empresaId, 'PORCENTAJE_MICROEMPRESA_BASE_IPS', fechaCorte)) ?? 0.8;
    const horasMes = (await this.getParametroVigente(empresaId, 'HORAS_LABORALES_MES', fechaCorte)) ?? 200;
    const limiteDescuento = (await this.getParametroVigente(empresaId, 'LIMITE_DESCUENTO_SALARIO', fechaCorte)) ?? 0.3;
    // DIAS_BASE_LIQUIDACION: cualquier valor > 0 fija la base de días (ej. 30 = mes comercial).
    // 0 o null = días reales del calendario.
    const diasBaseLiq = (await this.getParametroVigente(empresaId, 'DIAS_BASE_LIQUIDACION', fechaCorte)) ?? 0;
    const totalDiasMes = diasBaseLiq > 0 ? Math.min(Math.round(diasBaseLiq), calendarDias) : calendarDias;

    const { conceptos, porCodigo, requerir } = await this.cargarConceptos(empresaId);
    // Conceptos cuya marca IPS afecta la base aportable. Suman si son INGRESO,
    // restan si son EGRESO (viáticos, deducciones, etc. marcadas con IPS).
    const idsAfectanIps = new Set(conceptos.filter((c) => c.afecta_base_ips).map((c) => c.id));
    const cSalarioBase = requerir('SALARIO_BASE');
    const cSalarioProp = porCodigo.get('SALARIO_PROPORCIONAL') ?? cSalarioBase;
    const cIpsObrero = requerir('IPS_OBRERO');
    const cIpsPatronal = requerir('IPS_PATRONAL');
    const cIpsAdmin = requerir('IPS_ADMIN');
    const cAnticipo = requerir('ANTICIPO_QUINCENAL');
    const cCuotaPrestamo = requerir('CUOTA_PRESTAMO');
    const cDescAusencia = requerir('DESCUENTO_AUSENCIA');
    const cDescTardanza = requerir('DESCUENTO_TARDANZA');
    // PAGO_QUINCENAL es opcional: sólo se requiere si hay quincenales CERRADAS
    // del período que haya que restar. Empresas que no usan quincenales no
    // necesitan tenerlo sembrado para calcular la mensual.
    const cPagoQuincenal = porCodigo.get('PAGO_QUINCENAL') ?? null;
    // COMISION_VENTAS: opcional. Solo se usa si la empresa tiene comisiones APROBADAS en el pool.
    const cComisionVentas = porCodigo.get('COMISION_VENTAS') ?? null;

    const empleados = await this.cargarEmpleados(empresaId, inicioMes, finMes, opts.sucursalId, opts.tipoEmpleadoId);
    if (empleados.length === 0) advertencias.push('No hay empleados activos para el período seleccionado.');
    const empleadoIds = empleados.map((e) => e.id);

    // ── Insumos del período ──
    const [anticipos, cuotas, planillaDets, novedades, quincenalesCerradas] = await Promise.all([
      empleadoIds.length
        ? this.prisma.rrhh_anticipos_salario.findMany({
            where: { empresa_id: empresaId, empleado_id: { in: empleadoIds }, periodo_anio: anio, periodo_mes: mes, estado: 'APROBADO' },
          })
        : Promise.resolve([]),
      empleadoIds.length
        ? this.prisma.rrhh_prestamos_cuotas.findMany({
            where: {
              periodo_anio: anio,
              periodo_mes: mes,
              periodo_tipo: 'MENSUAL',
              estado: 'PENDIENTE',
              prestamo: { empresa_id: empresaId, empleado_id: { in: empleadoIds }, estado: 'ACTIVO' },
            },
            include: { prestamo: { select: { empleado_id: true } } },
          })
        : Promise.resolve([]),
      empleadoIds.length
        ? this.prisma.rrhh_planillas_externas_det.findMany({
            where: {
              empleado_id: { in: empleadoIds },
              aplicado: false,
              planilla: { empresa_id: empresaId, periodo_anio: anio, periodo_mes: mes, estado: 'IMPORTADA' },
            },
            include: { concepto: { select: { id: true, codigo: true } } },
          })
        : Promise.resolve([]),
      empleadoIds.length
        ? this.prisma.rrhh_asistencia_novedades.findMany({
            where: {
              empresa_id: empresaId,
              empleado_id: { in: empleadoIds },
              fecha: { gte: inicioMes, lte: finMes },
              impacta_liquidacion: true,
              justificado: false,
              liquidacion_id: null,
              ...(opts.liquidacionId
                ? { OR: [{ preliquidacion_id: null }, { preliquidacion_id: opts.liquidacionId }] }
                : { preliquidacion_id: null }),
            },
          })
        : Promise.resolve([]),
      // Quincenales CERRADAS del mismo período (misma sucursal o sin restricción)
      this.prisma.rrhh_liquidaciones_cabecera.findMany({
        where: {
          empresa_id: empresaId,
          periodo_anio: anio,
          periodo_mes: mes,
          tipo: 'QUINCENAL',
          estado: 'CERRADA',
          ...(opts.sucursalId ? { sucursal_id: opts.sucursalId } : {}),
        },
        include: { resumenes: { select: { empleado_id: true, neto_a_pagar: true } } },
      }),
    ]);

    const mapBy = <T>(arr: T[], key: (x: T) => string) => {
      const m = new Map<string, T[]>();
      for (const x of arr) {
        const k = key(x);
        const a = m.get(k) ?? [];
        a.push(x);
        m.set(k, a);
      }
      return m;
    };
    const anticiposPorEmp = mapBy(anticipos, (a) => a.empleado_id);
    const cuotasPorEmp = mapBy(cuotas, (c) => (c as any).prestamo.empleado_id as string);
    const planillaPorEmp = mapBy(planillaDets, (p) => p.empleado_id);
    const novedadesPorEmp = mapBy(novedades, (n) => n.empleado_id);
    // Comisiones APROBADAS del pool RRHH (módulo comercial → nómina). Vacío si no hay service/comisiones.
    const comisionesPorEmp =
      cComisionVentas && empleadoIds.length && this.comisiones
        ? await this.comisiones.aprobadasPorEmpleado(empresaId, anio, mes)
        : new Map<string, { id: string; monto: number; descripcion: string | null }[]>();
    // Pago de quincenas acumulado por empleado
    const pagoQuincenaPorEmp = new Map<string, number>();
    for (const q of quincenalesCerradas) {
      for (const rs of q.resumenes) {
        pagoQuincenaPorEmp.set(rs.empleado_id, r2((pagoQuincenaPorEmp.get(rs.empleado_id) ?? 0) + num(rs.neto_a_pagar)));
      }
    }

    const detalles: LineaCalculada[] = [];
    const resumenes: ResumenEmpleado[] = [];
    let totIngresos = 0, totEgresos = 0, totIpsObrero = 0, totIpsPatronal = 0, totIpsAdmin = 0;

    for (const emp of empleados) {
      const lineas: LineaCalculada[] = [];
      const salarioBaseFull = num(emp.salario_base);
      const ccId = emp.centro_costo_id;
      const { primerDia, ultimoDia } = this.rangoTrabajadoDelMes(emp, inicioMes, finMes, totalDiasMes);
      const diasTrabajados = Math.max(0, ultimoDia - primerDia + 1);
      const esProporcional = diasTrabajados < totalDiasMes;
      const salarioPeriodo = esProporcional ? r2((salarioBaseFull * diasTrabajados) / totalDiasMes) : r2(salarioBaseFull);

      lineas.push({
        empleado_id: emp.id,
        concepto_id: esProporcional ? cSalarioProp.id : cSalarioBase.id,
        concepto_codigo: esProporcional ? cSalarioProp.codigo : cSalarioBase.codigo,
        tipo: 'INGRESO',
        monto: salarioPeriodo,
        base_calculo: salarioBaseFull,
        origen: 'AUTOMATICO',
        centro_costo_id: ccId,
        observacion: esProporcional ? `${diasTrabajados}/${totalDiasMes} días trabajados` : undefined,
      });

      // Planillas externas
      for (const pd of planillaPorEmp.get(emp.id) ?? []) {
        const conc = (pd as any).concepto as { id: string; codigo: string };
        lineas.push({
          empleado_id: emp.id,
          concepto_id: conc.id,
          concepto_codigo: conc.codigo,
          tipo: pd.tipo === 'INGRESO' ? 'INGRESO' : 'EGRESO',
          monto: r2(num(pd.monto)),
          origen: 'IMPORTADO',
          referencia_origen_id: pd.id,
          centro_costo_id: ccId,
          observacion: pd.observacion ?? undefined,
        });
      }

      // Comisiones APROBADAS del período (van como INGRESO COMISION_VENTAS y afectan base IPS).
      if (cComisionVentas) {
        for (const com of comisionesPorEmp.get(emp.id) ?? []) {
          if (com.monto <= 0) continue;
          lineas.push({
            empleado_id: emp.id,
            concepto_id: cComisionVentas.id,
            concepto_codigo: cComisionVentas.codigo,
            tipo: 'INGRESO',
            monto: r2(com.monto),
            origen: 'COMISION',
            referencia_origen_id: com.id,
            centro_costo_id: ccId,
            observacion: com.descripcion ?? 'Comisión',
          });
        }
      }

      // Novedades
      const salarioDiario = r2(salarioBaseFull / totalDiasMes);
      const salarioPorMinuto = horasMes > 0 ? salarioBaseFull / (horasMes * 60) : 0;
      for (const nv of novedadesPorEmp.get(emp.id) ?? []) {
        let monto = 0, conceptoId = cDescAusencia.id, conceptoCod = cDescAusencia.codigo, obs: string | undefined;
        if (nv.tipo_novedad === 'AUSENCIA' || nv.tipo_novedad === 'AUSENCIA_JORNADA') {
          monto = salarioDiario;
          obs = `Ausencia ${nv.fecha.toISOString().slice(0, 10)}`;
        } else if (nv.tipo_novedad === 'AUSENCIA_BLOQUE') {
          const minBloque = Math.max(0, num(nv.minutos_tardanza, 0));
          monto = r2(salarioPorMinuto * minBloque);
          obs = `Ausencia bloque ${minBloque} min ${nv.fecha.toISOString().slice(0, 10)}`;
        } else if (nv.tipo_novedad === 'TARDANZA' || nv.tipo_novedad === 'SALIDA_ANTICIPADA') {
          conceptoId = cDescTardanza.id;
          conceptoCod = cDescTardanza.codigo;
          monto = r2(salarioPorMinuto * num(nv.minutos_tardanza, 0));
          obs = `${
            nv.tipo_novedad === 'SALIDA_ANTICIPADA' ? 'Salida anticipada' : 'Tardanza'
          } ${num(nv.minutos_tardanza, 0)} min ${nv.fecha.toISOString().slice(0, 10)}`;
        } else continue;
        if (monto <= 0) continue;
        lineas.push({
          empleado_id: emp.id, concepto_id: conceptoId, concepto_codigo: conceptoCod,
          tipo: 'EGRESO', monto, origen: 'NOVEDAD', referencia_origen_id: nv.id, centro_costo_id: ccId, observacion: obs,
        });
      }

      // IPS — todos los conceptos marcados con IPS afectan la base aportable:
      // suman si son INGRESO, restan si son EGRESO (viáticos gravados, deducciones, etc.).
      const baseIpsAcumulada = lineas.reduce(
        (acc, l) => acc + (idsAfectanIps.has(l.concepto_id) ? (l.tipo === 'INGRESO' ? l.monto : -l.monto) : 0),
        0,
      );
      let baseIps = 0, ipsObrero = 0, ipsPatronal = 0, ipsAdmin = 0;
      if (emp.aporta_ips) {
        const minLegal = emp.regimen_ips === 'MICROEMPRESA_80' ? r2(smlvMensual * microPct) : smlvMensual;
        const minProporcional = esProporcional ? r2((minLegal * diasTrabajados) / totalDiasMes) : minLegal;
        baseIps = Math.max(r2(baseIpsAcumulada), minProporcional);
        ipsObrero = r2(baseIps * ipsObreroPct);
        ipsPatronal = r2(baseIps * ipsPatronalPct);
        ipsAdmin = r2(baseIps * ipsAdminPct);
        lineas.push({
          empleado_id: emp.id, concepto_id: cIpsObrero.id, concepto_codigo: cIpsObrero.codigo,
          tipo: 'EGRESO', monto: ipsObrero, base_calculo: baseIps, porcentaje_aplicado: ipsObreroPct, origen: 'IPS', centro_costo_id: ccId,
        });
        lineas.push({
          empleado_id: emp.id, concepto_id: cIpsPatronal.id, concepto_codigo: cIpsPatronal.codigo,
          tipo: 'EGRESO', monto: 0, base_calculo: baseIps, porcentaje_aplicado: ipsPatronalPct, origen: 'IPS', centro_costo_id: ccId,
          observacion: `Aporte patronal (costo empresa): ${ipsPatronal.toLocaleString('es-PY')}`,
        });
        lineas.push({
          empleado_id: emp.id, concepto_id: cIpsAdmin.id, concepto_codigo: cIpsAdmin.codigo,
          tipo: 'EGRESO', monto: 0, base_calculo: baseIps, porcentaje_aplicado: ipsAdminPct, origen: 'IPS', centro_costo_id: ccId,
          observacion: `Aporte administrativo (costo empresa): ${ipsAdmin.toLocaleString('es-PY')}`,
        });
      }

      // Anticipos
      for (const a of anticiposPorEmp.get(emp.id) ?? []) {
        lineas.push({
          empleado_id: emp.id, concepto_id: cAnticipo.id, concepto_codigo: cAnticipo.codigo,
          tipo: 'EGRESO', monto: r2(num(a.monto)), origen: 'ANTICIPO', referencia_origen_id: a.id, centro_costo_id: ccId, observacion: a.observacion ?? undefined,
        });
      }

      // Cuotas de préstamo MENSUAL
      for (const c of cuotasPorEmp.get(emp.id) ?? []) {
        lineas.push({
          empleado_id: emp.id, concepto_id: cCuotaPrestamo.id, concepto_codigo: cCuotaPrestamo.codigo,
          tipo: 'EGRESO', monto: r2(num(c.monto)), origen: 'PRESTAMO', referencia_origen_id: c.id, centro_costo_id: ccId, observacion: `Cuota ${c.numero_cuota}`,
        });
      }

      // Pago de quincenas ya cerradas (sólo si existe el concepto sembrado)
      const pagoQ = pagoQuincenaPorEmp.get(emp.id) ?? 0;
      if (pagoQ > 0) {
        if (!cPagoQuincenal) {
          throw new BadRequestException(
            'Hay quincenales cerradas en el período que deben descontarse, pero falta el concepto del sistema "PAGO_QUINCENAL". Ejecutá «Sembrar defaults» en Configuración › Conceptos.',
          );
        }
        lineas.push({
          empleado_id: emp.id, concepto_id: cPagoQuincenal.id, concepto_codigo: cPagoQuincenal.codigo,
          tipo: 'EGRESO', monto: r2(pagoQ), origen: 'AUTOMATICO', centro_costo_id: ccId,
          observacion: 'Quincenas ya pagadas del período',
        });
      }

      // Totales del empleado
      const ingresosEmp = r2(lineas.filter((l) => l.tipo === 'INGRESO').reduce((a, l) => a + l.monto, 0));
      const egresosEmp = r2(lineas.filter((l) => l.tipo === 'EGRESO').reduce((a, l) => a + l.monto, 0));
      const neto = r2(ingresosEmp - egresosEmp);
      const egresosNoIps = r2(lineas.filter((l) => l.tipo === 'EGRESO' && l.origen !== 'IPS').reduce((a, l) => a + l.monto, 0));
      const alertaDescuento = ingresosEmp > 0 && egresosNoIps / ingresosEmp > limiteDescuento;
      if (alertaDescuento) advertencias.push(`Empleado ${emp.numero_empleado}: los descuentos (${egresosNoIps.toLocaleString('es-PY')}) superan el ${(limiteDescuento * 100).toFixed(0)}% del bruto.`);
      if (neto < 0) advertencias.push(`Empleado ${emp.numero_empleado}: el neto a pagar resultó negativo (${neto}).`);

      detalles.push(...lineas);
      resumenes.push({
        empleado_id: emp.id, salario_bruto: ingresosEmp, total_ingresos: ingresosEmp, total_egresos: egresosEmp,
        base_ips: baseIps, ips_obrero: ipsObrero, ips_patronal: ipsPatronal, ips_admin: ipsAdmin,
        neto_a_pagar: neto, dias_trabajados: diasTrabajados, dias_mes: totalDiasMes, alerta_descuento: alertaDescuento,
      });
      totIngresos = r2(totIngresos + ingresosEmp);
      totEgresos = r2(totEgresos + egresosEmp);
      totIpsObrero = r2(totIpsObrero + ipsObrero);
      totIpsPatronal = r2(totIpsPatronal + ipsPatronal);
      totIpsAdmin = r2(totIpsAdmin + ipsAdmin);
    }

    return {
      detalles, resumenes,
      totales: {
        total_ingresos: totIngresos, total_egresos: totEgresos, total_neto: r2(totIngresos - totEgresos),
        total_ips_obrero: totIpsObrero, total_ips_patronal: totIpsPatronal, total_ips_admin: totIpsAdmin,
        cantidad_empleados: empleados.length,
      },
      advertencias,
    };
  }

  // ════════════════════════════════════════════════════════════
  // AGUINALDO ANUAL (Art. 243 Código del Trabajo Paraguay)
  // ────────────────────────────────────────────────────────────
  // El aguinaldo es 1/12 de las remuneraciones brutas percibidas en el año
  // calendario por cada trabajador. Se toma de las liquidaciones MENSUALES
  // CERRADAS del año, sumando los conceptos con afecta_base_aguinaldo = true.
  // No aporta IPS (queda fuera de la base aportable).
  // Plazo legal: hasta el 31 de diciembre. Por eso se materializa en mes = 12.
  // ════════════════════════════════════════════════════════════
  async calcularAguinaldo(
    empresaId: string,
    anio: number,
    opts: { sucursalId?: string | null; tipoEmpleadoId?: string | null } = {},
  ): Promise<ResultadoCalculo> {
    const advertencias: string[] = [];
    const inicioAnio = new Date(anio, 0, 1);
    const finAnio = new Date(anio, 11, 31);

    const { conceptos } = await this.cargarConceptos(empresaId);
    const cAguinaldo = conceptos.find((c) => c.codigo === 'AGUINALDO');
    if (!cAguinaldo) {
      throw new BadRequestException(
        'Falta el concepto del sistema "AGUINALDO". Ejecutá «Sembrar defaults» en Configuración › Conceptos.',
      );
    }
    const conceptosAguinaldoIds = new Set(
      conceptos.filter((c) => c.afecta_base_aguinaldo).map((c) => c.id),
    );

    // Empleados que tuvieron actividad en el año (incluye egresados durante el año).
    const empleados = await this.prisma.rrhh_empleados.findMany({
      where: {
        empresa_id: empresaId,
        fecha_ingreso: { lte: finAnio },
        OR: [{ estado: { not: 'EGRESADO' } }, { fecha_egreso: { gte: inicioAnio } }],
        ...(opts.sucursalId ? { sucursal_id: opts.sucursalId } : {}),
        ...(opts.tipoEmpleadoId ? { tipo_empleado_id: opts.tipoEmpleadoId } : {}),
      },
    });
    if (empleados.length === 0)
      advertencias.push('No hay empleados con actividad en el año seleccionado.');

    // Sumatoria de remuneraciones brutas (afecta_base_aguinaldo) por empleado,
    // tomadas de las MENSUALES CERRADAS del año.
    const detallesAnuales = empleados.length
      ? await this.prisma.rrhh_liquidaciones_detalle.findMany({
          where: {
            empleado_id: { in: empleados.map((e) => e.id) },
            tipo: 'INGRESO',
            concepto_id: { in: [...conceptosAguinaldoIds] },
            liquidacion: {
              empresa_id: empresaId,
              periodo_anio: anio,
              tipo: { in: ['MENSUAL', 'VACACIONES'] },
              estado: 'CERRADA',
              ...(opts.sucursalId ? { sucursal_id: opts.sucursalId } : {}),
            },
          },
          select: { empleado_id: true, monto: true },
        })
      : [];

    const acumPorEmp = new Map<string, number>();
    for (const d of detallesAnuales) {
      acumPorEmp.set(
        d.empleado_id,
        r2((acumPorEmp.get(d.empleado_id) ?? 0) + num(d.monto)),
      );
    }

    const detalles: LineaCalculada[] = [];
    const resumenes: ResumenEmpleado[] = [];
    let totIngresos = 0;

    for (const emp of empleados) {
      const baseAcumulada = acumPorEmp.get(emp.id) ?? 0;
      if (baseAcumulada <= 0) {
        advertencias.push(
          `Empleado ${emp.numero_empleado}: sin remuneraciones cerradas en ${anio}; aguinaldo en 0.`,
        );
      }
      const aguinaldo = r2(baseAcumulada / 12);

      const linea: LineaCalculada = {
        empleado_id: emp.id,
        concepto_id: cAguinaldo.id,
        concepto_codigo: cAguinaldo.codigo,
        tipo: 'INGRESO',
        monto: aguinaldo,
        base_calculo: baseAcumulada,
        origen: 'AUTOMATICO',
        centro_costo_id: emp.centro_costo_id,
        observacion: `1/12 de remuneraciones brutas ${anio}`,
      };
      detalles.push(linea);
      resumenes.push({
        empleado_id: emp.id,
        salario_bruto: aguinaldo,
        total_ingresos: aguinaldo,
        total_egresos: 0,
        base_ips: 0,
        ips_obrero: 0,
        ips_patronal: 0,
        ips_admin: 0,
        neto_a_pagar: aguinaldo,
        dias_trabajados: 0,
        dias_mes: 0,
        alerta_descuento: false,
      });
      totIngresos = r2(totIngresos + aguinaldo);
    }

    return {
      detalles,
      resumenes,
      totales: {
        total_ingresos: totIngresos,
        total_egresos: 0,
        total_neto: totIngresos,
        total_ips_obrero: 0,
        total_ips_patronal: 0,
        total_ips_admin: 0,
        cantidad_empleados: empleados.length,
      },
      advertencias,
    };
  }

  // ════════════════════════════════════════════════════════════
  // VACACIONES GOZADAS (Art. 219 Código del Trabajo Paraguay)
  // ────────────────────────────────────────────────────────────
  // Liquidación mensual que paga los días de vacaciones efectivamente tomados.
  // Para cada empleado, suma los días hábiles de TODAS las solicitudes APROBADAS
  // de tipo VACACION con afecta_salario = true que INICIAN en el mes.
  // El monto = días × salario_diario (salario_base / 30, criterio estándar PY).
  // Aporta IPS (es salario) si el empleado aporta_ips.
  // ════════════════════════════════════════════════════════════
  async calcularVacaciones(
    empresaId: string,
    anio: number,
    mes: number,
    opts: { sucursalId?: string | null; tipoEmpleadoId?: string | null } = {},
  ): Promise<ResultadoCalculo> {
    const advertencias: string[] = [];
    const inicioMes = new Date(anio, mes - 1, 1);
    const finMes = new Date(anio, mes - 1, diasEnMes(anio, mes));
    const fechaCorte = finMes;

    const ipsObreroPct = (await this.getParametroVigente(empresaId, 'IPS_PORCENTAJE_OBRERO', fechaCorte)) ?? 0.09;
    const ipsPatronalPct = (await this.getParametroVigente(empresaId, 'IPS_PORCENTAJE_PATRONAL', fechaCorte)) ?? 0.165;
    const ipsAdminPct = (await this.getParametroVigente(empresaId, 'IPS_PORCENTAJE_ADMIN', fechaCorte)) ?? 0.01;

    const { conceptos, porCodigo } = await this.cargarConceptos(empresaId);
    const idsAfectanIps = new Set(conceptos.filter((c) => c.afecta_base_ips).map((c) => c.id));
    const cPagoVac = porCodigo.get('PAGO_VACACIONES');
    if (!cPagoVac) {
      throw new BadRequestException(
        'Falta el concepto del sistema "PAGO_VACACIONES". Ejecutá «Sembrar defaults» en Configuración › Conceptos.',
      );
    }
    const cIpsObrero = porCodigo.get('IPS_OBRERO');
    const cIpsPatronal = porCodigo.get('IPS_PATRONAL');
    const cIpsAdmin = porCodigo.get('IPS_ADMIN');

    // Solicitudes aprobadas con afecta_salario=true que INICIAN en el mes.
    const solicitudes = await this.prisma.rrhh_vacaciones_solicitudes.findMany({
      where: {
        empresa_id: empresaId,
        tipo: 'VACACION',
        estado: 'APROBADO',
        afecta_salario: true,
        fecha_inicio: { gte: inicioMes, lte: finMes },
        empleado: (opts.sucursalId || opts.tipoEmpleadoId)
          ? {
              ...(opts.sucursalId ? { sucursal_id: opts.sucursalId } : {}),
              ...(opts.tipoEmpleadoId ? { tipo_empleado_id: opts.tipoEmpleadoId } : {}),
            }
          : undefined,
      },
      include: {
        empleado: true,
      },
    });

    if (solicitudes.length === 0) {
      advertencias.push('No hay solicitudes de vacaciones aprobadas con impacto salarial que inicien en el período.');
    }

    // Agrupar por empleado
    type Sol = (typeof solicitudes)[number];
    const porEmpleado = new Map<string, Sol[]>();
    for (const s of solicitudes) {
      const arr = porEmpleado.get(s.empleado_id) ?? [];
      arr.push(s);
      porEmpleado.set(s.empleado_id, arr);
    }

    const detalles: LineaCalculada[] = [];
    const resumenes: ResumenEmpleado[] = [];
    let totIngresos = 0, totEgresos = 0, totIpsObrero = 0, totIpsPatronal = 0, totIpsAdmin = 0;

    for (const [empleadoId, sols] of porEmpleado.entries()) {
      const emp = sols[0].empleado;
      const ccId = emp.centro_costo_id;
      const salarioBase = num(emp.salario_base);
      const salarioDiario = r2(salarioBase / 30);
      const lineas: LineaCalculada[] = [];

      let diasTotales = 0;
      for (const s of sols) {
        const dias = num(s.dias_habiles);
        if (dias <= 0) continue;
        diasTotales += dias;
        const monto = r2(salarioDiario * dias);
        lineas.push({
          empleado_id: empleadoId,
          concepto_id: cPagoVac.id,
          concepto_codigo: cPagoVac.codigo,
          tipo: 'INGRESO',
          monto,
          base_calculo: salarioDiario,
          origen: 'AUTOMATICO',
          referencia_origen_id: s.id,
          centro_costo_id: ccId,
          observacion: `${dias} día(s) · ${s.fecha_inicio.toISOString().slice(0, 10)} a ${s.fecha_fin.toISOString().slice(0, 10)}`,
        });
      }

      if (lineas.length === 0) continue;

      // IPS sobre el monto de vacaciones (conceptos marcados con IPS: + INGRESO / − EGRESO)
      const baseIpsAcumulada = lineas.reduce(
        (acc, l) => acc + (idsAfectanIps.has(l.concepto_id) ? (l.tipo === 'INGRESO' ? l.monto : -l.monto) : 0),
        0,
      );
      let baseIps = 0, ipsObrero = 0, ipsPatronal = 0, ipsAdmin = 0;
      if (emp.aporta_ips && baseIpsAcumulada > 0 && cIpsObrero && cIpsPatronal && cIpsAdmin) {
        baseIps = r2(baseIpsAcumulada);
        ipsObrero = r2(baseIps * ipsObreroPct);
        ipsPatronal = r2(baseIps * ipsPatronalPct);
        ipsAdmin = r2(baseIps * ipsAdminPct);
        lineas.push({
          empleado_id: empleadoId, concepto_id: cIpsObrero.id, concepto_codigo: cIpsObrero.codigo,
          tipo: 'EGRESO', monto: ipsObrero, base_calculo: baseIps, porcentaje_aplicado: ipsObreroPct, origen: 'IPS', centro_costo_id: ccId,
        });
        lineas.push({
          empleado_id: empleadoId, concepto_id: cIpsPatronal.id, concepto_codigo: cIpsPatronal.codigo,
          tipo: 'EGRESO', monto: 0, base_calculo: baseIps, porcentaje_aplicado: ipsPatronalPct, origen: 'IPS', centro_costo_id: ccId,
          observacion: `Aporte patronal (costo empresa): ${ipsPatronal.toLocaleString('es-PY')}`,
        });
        lineas.push({
          empleado_id: empleadoId, concepto_id: cIpsAdmin.id, concepto_codigo: cIpsAdmin.codigo,
          tipo: 'EGRESO', monto: 0, base_calculo: baseIps, porcentaje_aplicado: ipsAdminPct, origen: 'IPS', centro_costo_id: ccId,
          observacion: `Aporte administrativo (costo empresa): ${ipsAdmin.toLocaleString('es-PY')}`,
        });
      }

      const ingresosEmp = r2(lineas.filter((l) => l.tipo === 'INGRESO').reduce((a, l) => a + l.monto, 0));
      const egresosEmp = r2(lineas.filter((l) => l.tipo === 'EGRESO').reduce((a, l) => a + l.monto, 0));
      const neto = r2(ingresosEmp - egresosEmp);

      detalles.push(...lineas);
      resumenes.push({
        empleado_id: empleadoId, salario_bruto: ingresosEmp, total_ingresos: ingresosEmp, total_egresos: egresosEmp,
        base_ips: baseIps, ips_obrero: ipsObrero, ips_patronal: ipsPatronal, ips_admin: ipsAdmin,
        neto_a_pagar: neto, dias_trabajados: diasTotales, dias_mes: diasEnMes(anio, mes), alerta_descuento: false,
      });
      totIngresos = r2(totIngresos + ingresosEmp);
      totEgresos = r2(totEgresos + egresosEmp);
      totIpsObrero = r2(totIpsObrero + ipsObrero);
      totIpsPatronal = r2(totIpsPatronal + ipsPatronal);
      totIpsAdmin = r2(totIpsAdmin + ipsAdmin);
    }

    return {
      detalles,
      resumenes,
      totales: {
        total_ingresos: totIngresos,
        total_egresos: totEgresos,
        total_neto: r2(totIngresos - totEgresos),
        total_ips_obrero: totIpsObrero,
        total_ips_patronal: totIpsPatronal,
        total_ips_admin: totIpsAdmin,
        cantidad_empleados: resumenes.length,
      },
      advertencias,
    };
  }

  // ════════════════════════════════════════════════════════════
  // QUINCENAL (q = 1 → días 1..15 ; q = 2 → días 16..fin)
  // No calcula IPS (se hace al cierre mensual). No descuenta anticipos ni
  // planillas externas (van en la mensual). Sí descuenta cuotas de préstamo
  // con periodo_tipo QUINCENAL_1/QUINCENAL_2 y novedades de la quincena.
  // ════════════════════════════════════════════════════════════
  async calcularQuincenal(
    empresaId: string,
    anio: number,
    mes: number,
    quincena: 1 | 2,
    opts: { sucursalId?: string | null; tipoEmpleadoId?: string | null; liquidacionId?: string } = {},
  ): Promise<ResultadoCalculo> {
    if (quincena !== 1 && quincena !== 2) throw new BadRequestException('Quincena debe ser 1 o 2');
    const advertencias: string[] = [];
    const calendarDias = diasEnMes(anio, mes);
    const inicioMes = new Date(anio, mes - 1, 1);
    const finMes = new Date(anio, mes - 1, calendarDias);
    // DIAS_BASE_LIQUIDACION: cualquier valor > 0 fija la base de días. 0/null = días reales.
    const diasBaseLiq = (await this.getParametroVigente(empresaId, 'DIAS_BASE_LIQUIDACION', finMes)) ?? 0;
    const totalDiasMes = diasBaseLiq > 0 ? Math.min(Math.round(diasBaseLiq), calendarDias) : calendarDias;
    const diaInicioQ = quincena === 1 ? 1 : 16;
    const diaFinQ = quincena === 1 ? Math.min(15, totalDiasMes) : totalDiasMes;
    const inicioQ = new Date(anio, mes - 1, diaInicioQ);
    const finQ = new Date(anio, mes - 1, diaFinQ);
    const fechaCorte = finQ;

    const horasMes = (await this.getParametroVigente(empresaId, 'HORAS_LABORALES_MES', fechaCorte)) ?? 200;
    const limiteDescuento = (await this.getParametroVigente(empresaId, 'LIMITE_DESCUENTO_SALARIO', fechaCorte)) ?? 0.3;

    const { porCodigo, requerir } = await this.cargarConceptos(empresaId);
    const cSalarioBase = requerir('SALARIO_BASE');
    const cSalarioProp = porCodigo.get('SALARIO_PROPORCIONAL') ?? cSalarioBase;
    const cCuotaPrestamo = requerir('CUOTA_PRESTAMO');
    const cDescAusencia = requerir('DESCUENTO_AUSENCIA');
    const cDescTardanza = requerir('DESCUENTO_TARDANZA');

    const empleados = await this.cargarEmpleados(empresaId, inicioMes, finMes, opts.sucursalId, opts.tipoEmpleadoId);
    if (empleados.length === 0) advertencias.push('No hay empleados activos para el período seleccionado.');
    const empleadoIds = empleados.map((e) => e.id);
    const periodoTipoCuota = quincena === 1 ? 'QUINCENAL_1' : 'QUINCENAL_2';

    const [cuotas, novedades] = await Promise.all([
      empleadoIds.length
        ? this.prisma.rrhh_prestamos_cuotas.findMany({
            where: {
              periodo_anio: anio, periodo_mes: mes, periodo_tipo: periodoTipoCuota, estado: 'PENDIENTE',
              prestamo: { empresa_id: empresaId, empleado_id: { in: empleadoIds }, estado: 'ACTIVO' },
            },
            include: { prestamo: { select: { empleado_id: true } } },
          })
        : Promise.resolve([]),
      empleadoIds.length
        ? this.prisma.rrhh_asistencia_novedades.findMany({
            where: {
              empresa_id: empresaId, empleado_id: { in: empleadoIds },
              fecha: { gte: inicioQ, lte: finQ },
              impacta_liquidacion: true, justificado: false, liquidacion_id: null,
              ...(opts.liquidacionId
                ? { OR: [{ preliquidacion_id: null }, { preliquidacion_id: opts.liquidacionId }] }
                : { preliquidacion_id: null }),
            },
          })
        : Promise.resolve([]),
    ]);
    const cuotasPorEmp = new Map<string, typeof cuotas>();
    for (const c of cuotas) {
      const k = (c as any).prestamo.empleado_id as string;
      const a = cuotasPorEmp.get(k) ?? [];
      a.push(c);
      cuotasPorEmp.set(k, a);
    }
    const novedadesPorEmp = new Map<string, typeof novedades>();
    for (const n of novedades) {
      const a = novedadesPorEmp.get(n.empleado_id) ?? [];
      a.push(n);
      novedadesPorEmp.set(n.empleado_id, a);
    }

    const detalles: LineaCalculada[] = [];
    const resumenes: ResumenEmpleado[] = [];
    let totIngresos = 0, totEgresos = 0;

    for (const emp of empleados) {
      const lineas: LineaCalculada[] = [];
      const salarioBaseFull = num(emp.salario_base);
      const ccId = emp.centro_costo_id;

      // Días trabajados dentro de la quincena
      const { primerDia, ultimoDia } = this.rangoTrabajadoDelMes(emp, inicioMes, finMes, totalDiasMes);
      const desdeQ = Math.max(primerDia, diaInicioQ);
      const hastaQ = Math.min(ultimoDia, diaFinQ);
      const diasTrabajados = Math.max(0, hastaQ - desdeQ + 1);
      // Salario de la quincena prorrateado sobre el mes completo
      const salarioQ = r2((salarioBaseFull * diasTrabajados) / totalDiasMes);

      if (salarioQ > 0) {
        lineas.push({
          empleado_id: emp.id,
          concepto_id: cSalarioProp.id,
          concepto_codigo: cSalarioProp.codigo,
          tipo: 'INGRESO',
          monto: salarioQ,
          base_calculo: salarioBaseFull,
          origen: 'AUTOMATICO',
          centro_costo_id: ccId,
          observacion: `Quincena ${quincena} · ${diasTrabajados} día(s) (${desdeQ}-${hastaQ})`,
        });
      }

      // Novedades de la quincena
      const salarioDiario = r2(salarioBaseFull / totalDiasMes);
      const salarioPorMinuto = horasMes > 0 ? salarioBaseFull / (horasMes * 60) : 0;
      for (const nv of novedadesPorEmp.get(emp.id) ?? []) {
        let monto = 0, conceptoId = cDescAusencia.id, conceptoCod = cDescAusencia.codigo, obs: string | undefined;
        if (nv.tipo_novedad === 'AUSENCIA' || nv.tipo_novedad === 'AUSENCIA_JORNADA') {
          monto = salarioDiario;
          obs = `Ausencia ${nv.fecha.toISOString().slice(0, 10)}`;
        } else if (nv.tipo_novedad === 'AUSENCIA_BLOQUE') {
          const minBloque = Math.max(0, num(nv.minutos_tardanza, 0));
          monto = r2(salarioPorMinuto * minBloque);
          obs = `Ausencia bloque ${minBloque} min ${nv.fecha.toISOString().slice(0, 10)}`;
        } else if (nv.tipo_novedad === 'TARDANZA' || nv.tipo_novedad === 'SALIDA_ANTICIPADA') {
          conceptoId = cDescTardanza.id;
          conceptoCod = cDescTardanza.codigo;
          monto = r2(salarioPorMinuto * num(nv.minutos_tardanza, 0));
          obs = `${
            nv.tipo_novedad === 'SALIDA_ANTICIPADA' ? 'Salida anticipada' : 'Tardanza'
          } ${num(nv.minutos_tardanza, 0)} min ${nv.fecha.toISOString().slice(0, 10)}`;
        } else continue;
        if (monto <= 0) continue;
        lineas.push({
          empleado_id: emp.id, concepto_id: conceptoId, concepto_codigo: conceptoCod,
          tipo: 'EGRESO', monto, origen: 'NOVEDAD', referencia_origen_id: nv.id, centro_costo_id: ccId, observacion: obs,
        });
      }

      // Cuotas de préstamo de esta quincena
      for (const c of cuotasPorEmp.get(emp.id) ?? []) {
        lineas.push({
          empleado_id: emp.id, concepto_id: cCuotaPrestamo.id, concepto_codigo: cCuotaPrestamo.codigo,
          tipo: 'EGRESO', monto: r2(num(c.monto)), origen: 'PRESTAMO', referencia_origen_id: c.id, centro_costo_id: ccId,
          observacion: `Cuota ${c.numero_cuota} (${periodoTipoCuota})`,
        });
      }

      const ingresosEmp = r2(lineas.filter((l) => l.tipo === 'INGRESO').reduce((a, l) => a + l.monto, 0));
      const egresosEmp = r2(lineas.filter((l) => l.tipo === 'EGRESO').reduce((a, l) => a + l.monto, 0));
      const neto = r2(ingresosEmp - egresosEmp);
      const alertaDescuento = ingresosEmp > 0 && egresosEmp / ingresosEmp > limiteDescuento;
      if (neto < 0) advertencias.push(`Empleado ${emp.numero_empleado}: el neto de la quincena resultó negativo (${neto}).`);

      detalles.push(...lineas);
      resumenes.push({
        empleado_id: emp.id, salario_bruto: ingresosEmp, total_ingresos: ingresosEmp, total_egresos: egresosEmp,
        base_ips: 0, ips_obrero: 0, ips_patronal: 0, ips_admin: 0,
        neto_a_pagar: neto, dias_trabajados: diasTrabajados, dias_mes: totalDiasMes, alerta_descuento: alertaDescuento,
      });
      totIngresos = r2(totIngresos + ingresosEmp);
      totEgresos = r2(totEgresos + egresosEmp);
    }

    return {
      detalles, resumenes,
      totales: {
        total_ingresos: totIngresos, total_egresos: totEgresos, total_neto: r2(totIngresos - totEgresos),
        total_ips_obrero: 0, total_ips_patronal: 0, total_ips_admin: 0,
        cantidad_empleados: empleados.length,
      },
      advertencias,
    };
  }
}
