< Summary

Information
Class: FAU.Logica.Services.AguinaldoReciboService
Assembly: FAU.Logica
File(s): /home/runner/work/Sistema.Liquidacion.BE/Sistema.Liquidacion.BE/FAU.Logica/Services/AguinaldoReciboService.cs
Line coverage
95%
Covered lines: 394
Uncovered lines: 17
Coverable lines: 411
Total lines: 584
Line coverage: 95.8%
Branch coverage
85%
Covered branches: 106
Total branches: 124
Branch coverage: 85.4%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/Sistema.Liquidacion.BE/Sistema.Liquidacion.BE/FAU.Logica/Services/AguinaldoReciboService.cs

#LineLine coverage
 1using System.Globalization;
 2using System.IO.Compression;
 3using System.Reflection;
 4using FAU.DataAccess;
 5using FAU.Entidades;
 6using Microsoft.EntityFrameworkCore;
 7using QuestPDF.Fluent;
 8using QuestPDF.Helpers;
 9using QuestPDF.Infrastructure;
 10
 11namespace FAU.Logica.Services;
 12
 13public class AguinaldoReciboService : IAguinaldoReciboService
 14{
 15    private readonly ApplicationDbContext _context;
 16
 1317    public AguinaldoReciboService(ApplicationDbContext context)
 18    {
 1319        _context = context;
 1320    }
 21
 22    // ── SEMESTRAL ────────────────────────────────────────────────────────────
 23
 24    public async Task<(byte[] Pdf, string FileName)> GenerarPdfSemestralAsync(long personaId, int anio, short semestre)
 25    {
 426        var (aguinaldo, relacion) = await CargarDatosSemestralAsync(personaId, anio, semestre);
 227        var (fechaIngreso, fechaUltAsc) = await ObtenerFechasRelacionAsync(personaId, relacion.FechaInicio);
 228        var mes = semestre == 1 ? 6 : 12;
 229        var fileName = $"aguinaldo_{anio}_{mes:D2}_{relacion.Persona.Cedula}.pdf";
 230        return (GenerarPdfSemestral(aguinaldo, relacion, fechaIngreso, fechaUltAsc), fileName);
 231    }
 32
 33    public async Task<(Stream Zip, string FileName)> GenerarLoteSemestralZipAsync(int anio, short semestre, IEnumerable<
 34    {
 335        var query = _context.Aguinaldos
 336            .Where(a => a.Anio == anio && a.Semestre == semestre);
 37
 338        if (personaIds != null)
 39        {
 140            var ids = personaIds.ToList();
 141            query = query.Where(a => ids.Contains(a.PersonaId));
 42        }
 43
 344        var aguinaldos = await query.ToListAsync();
 45        var personaIdsUsados = aguinaldos.Select(a => a.PersonaId).ToList();
 346        var relacionesPorPersona = await CargarRelacionesPorPersonaAsync(personaIdsUsados);
 347        var fechasPorPersona = await ObtenerFechasRelacionesBatchAsync(personaIdsUsados, relacionesPorPersona);
 48
 349        var mes = semestre == 1 ? 6 : 12;
 350        var memoryStream = new MemoryStream();
 351        using (var zip = new ZipArchive(memoryStream, ZipArchiveMode.Create, leaveOpen: true))
 52        {
 853            foreach (var aguinaldo in aguinaldos)
 54            {
 155                if (!relacionesPorPersona.TryGetValue(aguinaldo.PersonaId, out var relacion))
 56                    continue;
 57
 158                fechasPorPersona.TryGetValue(aguinaldo.PersonaId, out var fechas);
 159                var pdfBytes = GenerarPdfSemestral(aguinaldo, relacion, fechas.FechaIngreso, fechas.FechaUltAscenso);
 160                var entryName = $"aguinaldo_{anio}_{mes:D2}_{relacion.Persona.Cedula}.pdf";
 161                var entry = zip.CreateEntry(entryName, CompressionLevel.Fastest);
 162                using var entryStream = entry.Open();
 163                await entryStream.WriteAsync(pdfBytes);
 164            }
 365        }
 66
 367        memoryStream.Position = 0;
 368        return (memoryStream, $"aguinaldos_{anio}_{mes:D2}.zip");
 369    }
 70
 71    // ── BAJA ─────────────────────────────────────────────────────────────────
 72
 73    public async Task<(byte[] Pdf, string FileName)> GenerarPdfBajaAsync(long aguinaldoBajaId)
 74    {
 375        var baja = await CargarAguinaldoBajaAsync(aguinaldoBajaId);
 276        var relacion = await CargarRelacionParaBajaAsync(baja.PersonaId);
 277        var (fechaIngreso, fechaUltAsc) = relacion != null
 278            ? await ObtenerFechasRelacionAsync(baja.PersonaId, relacion.FechaInicio)
 279            : ((DateTime?)null, (DateTime?)null);
 280        var fileName = $"aguinaldo_{baja.Anio}_{baja.Mes:D2}_{baja.Cedula}.pdf";
 281        return (GenerarPdfBaja(baja, relacion, fechaIngreso, fechaUltAsc), fileName);
 282    }
 83
 84    public async Task<(Stream Zip, string FileName)> GenerarLoteBajaZipAsync(IEnumerable<long> aguinaldoBajaIds)
 85    {
 386        var ids = aguinaldoBajaIds.ToList();
 387        var bajas = await _context.AguinaldosBaja
 388            .Include(b => b.Items)
 389            .Where(b => ids.Contains(b.Id))
 390            .ToListAsync();
 91
 92        var personaIdsUsados = bajas.Select(b => b.PersonaId).ToList();
 393        var relacionesPorPersona = await CargarRelacionesParaBajaAsync(personaIdsUsados);
 394        var fechasPorPersona = await ObtenerFechasRelacionesBatchAsync(personaIdsUsados, relacionesPorPersona);
 95
 396        var memoryStream = new MemoryStream();
 397        using (var zip = new ZipArchive(memoryStream, ZipArchiveMode.Create, leaveOpen: true))
 98        {
 1099            foreach (var baja in bajas)
 100            {
 2101                relacionesPorPersona.TryGetValue(baja.PersonaId, out var relacion);
 2102                fechasPorPersona.TryGetValue(baja.PersonaId, out var fechas);
 2103                var pdfBytes = GenerarPdfBaja(baja, relacion, fechas.FechaIngreso, fechas.FechaUltAscenso);
 2104                var entryName = $"aguinaldo_{baja.Anio}_{baja.Mes:D2}_{baja.Cedula}.pdf";
 2105                var entry = zip.CreateEntry(entryName, CompressionLevel.Fastest);
 2106                using var entryStream = entry.Open();
 2107                await entryStream.WriteAsync(pdfBytes);
 2108            }
 3109        }
 110
 3111        memoryStream.Position = 0;
 3112        var zipName = bajas.Count > 0
 3113            ? $"aguinaldos_{bajas[0].Anio}_{bajas[0].Mes:D2}.zip"
 3114            : "aguinaldos_baja.zip";
 3115        return (memoryStream, zipName);
 3116    }
 117
 118    // ── DATA LOADING ──────────────────────────────────────────────────────────
 119
 120    private async Task<(Aguinaldo aguinaldo, RelacionLaboral relacion)> CargarDatosSemestralAsync(
 121        long personaId, int anio, short semestre)
 122    {
 4123        var aguinaldo = await _context.Aguinaldos
 4124            .FirstOrDefaultAsync(a => a.PersonaId == personaId && a.Anio == anio && a.Semestre == semestre)
 4125            ?? throw new KeyNotFoundException(
 4126                $"No se encontró aguinaldo para persona {personaId}, año {anio}, semestre {semestre}.");
 127
 3128        var relacion = await CargarRelacionActivaPorPersonaIdAsync(personaId);
 2129        return (aguinaldo, relacion);
 2130    }
 131
 132    private async Task<RelacionLaboral> CargarRelacionActivaPorPersonaIdAsync(long personaId)
 133    {
 3134        return await _context.RelacionesLaborales
 3135            .Include(rl => rl.Persona)
 3136            .Include(rl => rl.Grado)
 3137            .Include(rl => rl.Escalafon)
 3138            .Include(rl => rl.Unidad)
 3139            .Where(rl => rl.PersonaId == personaId && rl.Estado == EstadoRelacion.Activo)
 3140            .FirstOrDefaultAsync()
 3141            ?? throw new KeyNotFoundException($"No se encontró relación laboral activa para persona {personaId}.");
 2142    }
 143
 144    private async Task<Dictionary<long, RelacionLaboral>> CargarRelacionesPorPersonaAsync(List<long> personaIds)
 145    {
 3146        var relaciones = await _context.RelacionesLaborales
 3147            .Include(rl => rl.Persona)
 3148            .Include(rl => rl.Grado)
 3149            .Include(rl => rl.Escalafon)
 3150            .Include(rl => rl.Unidad)
 3151            .Where(rl => personaIds.Contains(rl.PersonaId) && rl.Estado == EstadoRelacion.Activo)
 3152            .ToListAsync();
 153
 154        return relaciones.ToDictionary(rl => rl.PersonaId);
 3155    }
 156
 157    // Para bajas: activa si existe, sino la más reciente inactiva (mayor FechaFin)
 158    private async Task<RelacionLaboral?> CargarRelacionParaBajaAsync(long personaId)
 159    {
 2160        var activa = await _context.RelacionesLaborales
 2161            .Include(rl => rl.Persona)
 2162            .Include(rl => rl.Grado)
 2163            .Include(rl => rl.Escalafon)
 2164            .Include(rl => rl.Unidad)
 2165            .Where(rl => rl.PersonaId == personaId && rl.Estado == EstadoRelacion.Activo)
 2166            .FirstOrDefaultAsync();
 167
 3168        if (activa != null) return activa;
 169
 1170        return await _context.RelacionesLaborales
 1171            .Include(rl => rl.Persona)
 1172            .Include(rl => rl.Grado)
 1173            .Include(rl => rl.Escalafon)
 1174            .Include(rl => rl.Unidad)
 1175            .Where(rl => rl.PersonaId == personaId)
 1176            .OrderByDescending(rl => rl.FechaFin)
 1177            .FirstOrDefaultAsync();
 2178    }
 179
 180    // Batch para bajas: activa si existe, sino la más reciente inactiva por persona
 181    private async Task<Dictionary<long, RelacionLaboral>> CargarRelacionesParaBajaAsync(List<long> personaIds)
 182    {
 3183        if (personaIds.Count == 0)
 1184            return new Dictionary<long, RelacionLaboral>();
 185
 2186        var activas = await _context.RelacionesLaborales
 2187            .Include(rl => rl.Persona)
 2188            .Include(rl => rl.Grado)
 2189            .Include(rl => rl.Escalafon)
 2190            .Include(rl => rl.Unidad)
 2191            .Where(rl => personaIds.Contains(rl.PersonaId) && rl.Estado == EstadoRelacion.Activo)
 2192            .ToListAsync();
 193
 194        var result = activas.ToDictionary(rl => rl.PersonaId);
 195
 2196        var sinActiva = personaIds.Except(result.Keys).ToList();
 2197        if (sinActiva.Count > 0)
 198        {
 1199            var inactivas = await _context.RelacionesLaborales
 1200                .Include(rl => rl.Persona)
 1201                .Include(rl => rl.Grado)
 1202                .Include(rl => rl.Escalafon)
 1203                .Include(rl => rl.Unidad)
 1204                .Where(rl => sinActiva.Contains(rl.PersonaId))
 1205                .ToListAsync();
 206
 207            foreach (var rl in inactivas.GroupBy(r => r.PersonaId)
 208                                         .Select(g => g.OrderByDescending(r => r.FechaFin).First()))
 1209                result[rl.PersonaId] = rl;
 210        }
 211
 2212        return result;
 3213    }
 214
 215    private async Task<AguinaldoBaja> CargarAguinaldoBajaAsync(long aguinaldoBajaId)
 216    {
 3217        return await _context.AguinaldosBaja
 3218            .Include(b => b.Items)
 3219            .FirstOrDefaultAsync(b => b.Id == aguinaldoBajaId)
 3220            ?? throw new KeyNotFoundException($"No se encontró aguinaldo por baja con id {aguinaldoBajaId}.");
 2221    }
 222
 223    // Devuelve (fechaIngreso=primera relación, fechaUltAscenso=activa si es posterior)
 224    private async Task<(DateTime? FechaIngreso, DateTime? FechaUltAscenso)> ObtenerFechasRelacionAsync(
 225        long personaId, DateTime fechaInicioActivo)
 226    {
 3227        var minFecha = await _context.RelacionesLaborales
 3228            .Where(rl => rl.PersonaId == personaId)
 3229            .MinAsync(rl => rl.FechaInicio);
 230
 3231        return (minFecha, fechaInicioActivo > minFecha ? fechaInicioActivo : null);
 3232    }
 233
 234    private async Task<Dictionary<long, (DateTime? FechaIngreso, DateTime? FechaUltAscenso)>> ObtenerFechasRelacionesBat
 235        List<long> personaIds,
 236        Dictionary<long, RelacionLaboral> relacionesActivas)
 237    {
 6238        if (personaIds.Count == 0)
 3239            return new Dictionary<long, (DateTime?, DateTime?)>();
 240
 3241        var todasFechas = await _context.RelacionesLaborales
 3242            .Where(rl => personaIds.Contains(rl.PersonaId))
 3243            .Select(rl => new { rl.PersonaId, rl.FechaInicio })
 3244            .ToListAsync();
 245
 3246        var minPorPersona = todasFechas
 247            .GroupBy(r => r.PersonaId)
 248            .ToDictionary(g => g.Key, g => g.Min(r => r.FechaInicio));
 249
 3250        return personaIds.ToDictionary(
 251            id => id,
 3252            id =>
 3253            {
 3254                var activo = relacionesActivas.GetValueOrDefault(id);
 3255                if (activo == null)
 0256                    return ((DateTime?)null, (DateTime?)null);
 3257
 3258                var minFecha = minPorPersona.GetValueOrDefault(id, activo.FechaInicio);
 3259                DateTime? ultAsc = activo.FechaInicio > minFecha ? activo.FechaInicio : null;
 3260                return ((DateTime?)minFecha, ultAsc);
 3261            });
 6262    }
 263
 264    // ── PDF BUILDERS ──────────────────────────────────────────────────────────
 265
 266    private static byte[] GenerarPdfSemestral(
 267        Aguinaldo ag, RelacionLaboral relacion,
 268        DateTime? fechaIngreso, DateTime? fechaUltAscenso)
 269    {
 3270        var ci = CultureInfo.GetCultureInfo("es-UY");
 3271        var mes = ag.Semestre == 1 ? 6 : 12;
 3272        var nombreMes = ci.TextInfo.ToTitleCase(ci.DateTimeFormat.GetMonthName(mes));
 3273        var tituloPeriodo = $"MES DE : {nombreMes} {ag.Anio}";
 274
 3275        var nominal = ag.Nominal ?? 0m;
 3276        var montepio = ag.Montepio ?? 0m;
 3277        var sanidad = ag.Sanidad ?? 0m;
 3278        var totalDescuentos = ag.TotalDescuentos ?? 0m;
 3279        var liquido = ag.Liquido ?? 0m;
 3280        var redondeo = ag.Redondeo;
 281
 3282        var haberes = new List<(string Desc, decimal Monto)>
 3283        {
 3284            ("AGUINALDO MILITAR", nominal)
 3285        };
 286
 3287        var descuentos = new List<(string Desc, decimal Monto)>
 3288        {
 3289            ("MONTEPÍO", montepio),
 3290            ("D.N.S.FF.AA.", sanidad)
 3291        };
 292
 3293        return GenerarPdfAguinaldo(
 3294            tituloPeriodo, relacion,
 3295            haberes, descuentos,
 3296            totalHaberes: nominal,
 3297            totalDescuentos: totalDescuentos,
 3298            redondeo: redondeo,
 3299            aCobrar: liquido,
 3300            fechaIngreso: fechaIngreso,
 3301            fechaUltAscenso: fechaUltAscenso);
 302    }
 303
 304    private static byte[] GenerarPdfBaja(
 305        AguinaldoBaja baja, RelacionLaboral? relacion,
 306        DateTime? fechaIngreso, DateTime? fechaUltAscenso)
 307    {
 4308        var ci = CultureInfo.GetCultureInfo("es-UY");
 4309        var nombreMes = ci.TextInfo.ToTitleCase(ci.DateTimeFormat.GetMonthName(baja.Mes));
 4310        var tituloPeriodo = $"MES DE : {nombreMes} {baja.Anio}";
 311
 4312        var haberes = baja.Items
 0313            .Where(i => i.Tipo == "haber")
 0314            .Select(i => (i.NombreConcepto, Math.Abs(i.Importe)))
 4315            .ToList();
 316
 4317        var descuentos = baja.Items
 0318            .Where(i => i.Tipo == "descuento")
 0319            .Select(i => (i.NombreConcepto, Math.Abs(i.Importe)))
 4320            .ToList();
 321
 4322        if (!haberes.Any())
 4323            haberes = new List<(string, decimal)> { ("AGUINALDO MILITAR", baja.Nominal) };
 4324        if (!descuentos.Any())
 4325            descuentos = new List<(string, decimal)>
 4326            {
 4327                ("MONTEPÍO", baja.Montepio),
 4328                ("D.N.S.FF.AA.", baja.Sanidad)
 4329            };
 330
 4331        return GenerarPdfAguinaldo(
 4332            tituloPeriodo, relacion,
 4333            haberes, descuentos,
 4334            totalHaberes: baja.Nominal,
 4335            totalDescuentos: baja.TotalDescuentos,
 4336            redondeo: baja.Redondeo,
 4337            aCobrar: baja.Liquido,
 4338            fechaIngreso: fechaIngreso,
 4339            fechaUltAscenso: fechaUltAscenso,
 4340            cedula: baja.Cedula,
 4341            nombreCompleto: baja.NombreCompleto);
 342    }
 343
 344    // ── CORE PDF ──────────────────────────────────────────────────────────────
 345
 1346    private static readonly byte[]? _logoBytes = CargarLogo();
 347
 348    private static byte[]? CargarLogo()
 349    {
 1350        var asm = Assembly.GetExecutingAssembly();
 1351        var nombre = asm.GetManifestResourceNames()
 2352            .FirstOrDefault(n => n.EndsWith("fau-logo.png", StringComparison.OrdinalIgnoreCase));
 1353        if (nombre is null) return null;
 1354        using var stream = asm.GetManifestResourceStream(nombre)!;
 1355        using var ms = new MemoryStream();
 1356        stream.CopyTo(ms);
 1357        return ms.ToArray();
 1358    }
 359
 360    private static byte[] GenerarPdfAguinaldo(
 361        string tituloPeriodo,
 362        RelacionLaboral? relacion,
 363        List<(string Desc, decimal Monto)> haberes,
 364        List<(string Desc, decimal Monto)> descuentos,
 365        decimal totalHaberes,
 366        decimal totalDescuentos,
 367        decimal redondeo,
 368        decimal aCobrar,
 369        DateTime? fechaIngreso,
 370        DateTime? fechaUltAscenso = null,
 371        string? cedula = null,
 372        string? nombreCompleto = null)
 373    {
 7374        var cedulaMostrar = cedula ?? relacion?.Persona.Cedula ?? "-";
 7375        var gradoCodigo   = relacion?.Grado.Codigo ?? "-";
 7376        var escalafonCodigo = relacion?.Escalafon.Codigo ?? "-";
 7377        var unidadCodigo  = relacion?.Unidad.Codigo ?? "-";
 378
 379        string apellidoNombre;
 7380        if (nombreCompleto != null)
 381        {
 4382            apellidoNombre = nombreCompleto.ToUpper();
 383        }
 3384        else if (relacion != null)
 385        {
 3386            var p = relacion.Persona;
 3387            var apellidos = string.IsNullOrWhiteSpace(p.SegundoApellido)
 3388                ? p.PrimerApellido
 3389                : $"{p.PrimerApellido} {p.SegundoApellido}";
 3390            var nombres = string.IsNullOrWhiteSpace(p.SegundoNombre)
 3391                ? p.PrimerNombre
 3392                : $"{p.PrimerNombre} {p.SegundoNombre}";
 3393            apellidoNombre = $"{apellidos.ToUpper()}, {nombres.ToUpper()}";
 394        }
 395        else
 396        {
 0397            apellidoNombre = "-";
 398        }
 399
 7400        var fechaIngresoStr = fechaIngreso.HasValue
 7401            ? fechaIngreso.Value.ToString("dd/MM/yyyy")
 7402            : string.Empty;
 403
 7404        var fechaUltAscStr = fechaUltAscenso.HasValue
 7405            ? fechaUltAscenso.Value.ToString("dd/MM/yyyy")
 7406            : string.Empty;
 407
 7408        return Document.Create(container =>
 7409        {
 7410            container.Page(page =>
 7411            {
 7412                page.Size(PageSizes.A4);
 7413                page.MarginHorizontal(20);
 7414                page.MarginTop(15);
 7415                page.MarginBottom(15);
 14416                page.DefaultTextStyle(x => x.FontSize(7.5f).FontFamily("Courier New"));
 7417
 7418                page.Content().Column(col =>
 7419                {
 7420                    // ── ENCABEZADO ────────────────────────────────────────
 7421                    col.Item().PaddingBottom(4).Row(row =>
 7422                    {
 7423                        row.RelativeItem().Row(r =>
 7424                        {
 7425                            if (_logoBytes is not null)
 7426                                r.ConstantItem(90).Image(_logoBytes).FitArea();
 7427                            r.RelativeItem().PaddingLeft(_logoBytes is not null ? 6 : 0)
 7428                             .Column(c =>
 7429                             {
 7430                                 c.Item().Text("FUERZA AÉREA").Bold().FontSize(13).FontFamily("Arial");
 7431                                 c.Item().Text("URUGUAYA").Bold().FontSize(13).FontFamily("Arial");
 14432                             });
 14433                        });
 7434                        row.ConstantItem(165).Column(c =>
 7435                        {
 7436                            c.Item().Border(1).Padding(6).Column(inner =>
 7437                            {
 7438                                inner.Item().Text($" {tituloPeriodo}");
 7439                                inner.Item().Text(" ");
 14440                            });
 14441                        });
 14442                    });
 7443
 7444                    // ── CABECERA FUNCIONARIO ──────────────────────────────
 7445                    col.Item().Border(1).Row(row =>
 7446                    {
 7447                        row.ConstantItem(85).BorderRight(1).PaddingHorizontal(5).PaddingVertical(3)
 7448                           .Text("CEDULA").Bold();
 7449                        row.ConstantItem(80).BorderRight(1).PaddingHorizontal(5).PaddingVertical(3)
 7450                           .AlignCenter().Text("GDO").Bold();
 7451                        row.ConstantItem(32).BorderRight(1).PaddingHorizontal(5).PaddingVertical(3)
 7452                           .AlignCenter().Text("ESC").Bold();
 7453                        row.RelativeItem().BorderRight(1).PaddingHorizontal(5).PaddingVertical(3)
 7454                           .Text("APELLIDO Y NOMBRE").Bold();
 7455                        row.ConstantItem(22).BorderRight(1).PaddingHorizontal(4).PaddingVertical(3)
 7456                           .AlignCenter().Text("UN").Bold();
 7457                        row.ConstantItem(138).PaddingHorizontal(5).PaddingVertical(3)
 7458                           .AlignCenter().Text("FECHAS").Bold();
 14459                    });
 7460
 7461                    // ── DATOS FUNCIONARIO ─────────────────────────────────
 7462                    col.Item().BorderLeft(1).BorderRight(1).BorderBottom(1).Row(row =>
 7463                    {
 7464                        row.ConstantItem(85).BorderRight(1).PaddingHorizontal(5).PaddingVertical(3)
 7465                           .Text(cedulaMostrar);
 7466                        row.ConstantItem(80).BorderRight(1).PaddingHorizontal(5).PaddingVertical(3)
 7467                           .Text(gradoCodigo);
 7468                        row.ConstantItem(32).BorderRight(1).PaddingHorizontal(5).PaddingVertical(3)
 7469                           .Text(escalafonCodigo);
 7470                        row.RelativeItem().BorderRight(1).PaddingHorizontal(5).PaddingVertical(3)
 7471                           .Text(apellidoNombre);
 7472                        row.ConstantItem(22).BorderRight(1).PaddingHorizontal(4).PaddingVertical(3)
 7473                           .AlignCenter().Text(unidadCodigo);
 7474                        row.ConstantItem(138).PaddingHorizontal(5).PaddingVertical(3)
 7475                           .Column(c =>
 7476                           {
 7477                               if (!string.IsNullOrEmpty(fechaIngresoStr))
 6478                                   c.Item().Text(t =>
 6479                                   {
 6480                                       t.Span("Fecha Ingreso: ").Bold();
 6481                                       t.Span(fechaIngresoStr);
 12482                                   });
 7483                               else
 1484                                   c.Item().Text(" ");
 7485
 7486                               if (!string.IsNullOrEmpty(fechaUltAscStr))
 0487                                   c.Item().Text(t =>
 0488                                   {
 0489                                       t.Span("Fecha Ult. Asc.: ").Bold();
 0490                                       t.Span(fechaUltAscStr);
 0491                                   });
 14492                           });
 14493                    });
 7494
 7495                    // ── CUERPO DE 3 COLUMNAS ──────────────────────────────
 7496                    col.Item().Extend()
 7497                       .BorderLeft(1).BorderRight(1).BorderTop(1)
 7498                       .Row(row =>
 7499                    {
 7500                        // Columna 1: Haberes
 7501                        row.RelativeItem(40).BorderRight(1).PaddingTop(5).Column(c =>
 7502                        {
 28503                            foreach (var item in haberes)
 7504                                FilaItem(c, item.Desc, item.Monto);
 7505
 7506                            FilaSeparador(c);
 7507                            FilaTotalBold(c, "TOTAL DE HABERES", totalHaberes);
 14508                        });
 7509
 7510                        // Columna 2: Descuentos
 7511                        row.RelativeItem(35).BorderRight(1).PaddingTop(5).Column(c =>
 7512                        {
 40513                            foreach (var item in descuentos.Where(d => d.Monto != 0))
 6514                                FilaItem(c, item.Desc, item.Monto);
 7515
 7516                            FilaSeparador(c);
 7517                            FilaTotalBold(c, "TOTAL DESC.", totalDescuentos);
 14518                        });
 7519
 7520                        // Columna 3: Ajuste (redondeo)
 7521                        row.RelativeItem(25).PaddingTop(5).Column(c =>
 7522                        {
 7523                            if (redondeo != 0m)
 7524                            {
 0525                                c.Item().Row(r =>
 0526                                {
 0527                                    r.RelativeItem().PaddingHorizontal(5).Text("AJUSTE");
 0528                                    r.ConstantItem(60).AlignRight().PaddingHorizontal(5)
 0529                                     .Text(Monto(redondeo));
 0530                                });
 7531                            }
 14532                        });
 14533                    });
 14534                });
 7535
 7536                // ── PIE: NOMINAL | DESCUENTOS | A COBRAR ─────────────────
 7537                page.Footer().Border(1).Row(row =>
 7538                {
 7539                    row.RelativeItem().BorderRight(1).Padding(5).Row(r =>
 7540                    {
 14541                        r.RelativeItem().Text(t => t.Span("NOMINAL....: ").Bold());
 7542                        r.ConstantItem(75).AlignRight().Text(Monto(totalHaberes));
 14543                    });
 7544                    row.RelativeItem().BorderRight(1).Padding(5).Row(r =>
 7545                    {
 14546                        r.RelativeItem().Text(t => t.Span("DESCUENTOS.: ").Bold());
 7547                        r.ConstantItem(75).AlignRight().Text(Monto(totalDescuentos));
 14548                    });
 7549                    row.RelativeItem().Padding(5).Row(r =>
 7550                    {
 14551                        r.RelativeItem().Text(t => t.Span("A COBRAR...: ").Bold());
 7552                        r.ConstantItem(75).AlignRight().Text(Monto(aCobrar));
 14553                    });
 14554                });
 14555            });
 14556        }).GeneratePdf();
 557    }
 558
 559    private static string Monto(decimal valor) =>
 48560        valor.ToString("N2", CultureInfo.InvariantCulture);
 561
 562    private static void FilaItem(ColumnDescriptor col, string descripcion, decimal monto)
 563    {
 13564        col.Item().Row(r =>
 13565        {
 13566            r.RelativeItem().PaddingHorizontal(5).Text(descripcion);
 13567            r.ConstantItem(62).AlignRight().PaddingHorizontal(5).Text(Monto(monto));
 26568        });
 13569    }
 570
 571    private static void FilaSeparador(ColumnDescriptor col)
 572    {
 14573        col.Item().PaddingHorizontal(5).PaddingVertical(2).LineHorizontal(0.5f);
 14574    }
 575
 576    private static void FilaTotalBold(ColumnDescriptor col, string label, decimal total)
 577    {
 14578        col.Item().Row(r =>
 14579        {
 14580            r.RelativeItem().PaddingHorizontal(5).Text(label).Bold();
 14581            r.ConstantItem(62).AlignRight().PaddingHorizontal(5).Text(Monto(total)).Bold();
 28582        });
 14583    }
 584}