< 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
0%
Covered lines: 0
Uncovered lines: 411
Coverable lines: 411
Total lines: 584
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 124
Branch coverage: 0%
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
 017    public AguinaldoReciboService(ApplicationDbContext context)
 18    {
 019        _context = context;
 020    }
 21
 22    // ── SEMESTRAL ────────────────────────────────────────────────────────────
 23
 24    public async Task<(byte[] Pdf, string FileName)> GenerarPdfSemestralAsync(long personaId, int anio, short semestre)
 25    {
 026        var (aguinaldo, relacion) = await CargarDatosSemestralAsync(personaId, anio, semestre);
 027        var (fechaIngreso, fechaUltAsc) = await ObtenerFechasRelacionAsync(personaId, relacion.FechaInicio);
 028        var mes = semestre == 1 ? 6 : 12;
 029        var fileName = $"aguinaldo_{anio}_{mes:D2}_{relacion.Persona.Cedula}.pdf";
 030        return (GenerarPdfSemestral(aguinaldo, relacion, fechaIngreso, fechaUltAsc), fileName);
 031    }
 32
 33    public async Task<(Stream Zip, string FileName)> GenerarLoteSemestralZipAsync(int anio, short semestre, IEnumerable<
 34    {
 035        var query = _context.Aguinaldos
 036            .Where(a => a.Anio == anio && a.Semestre == semestre);
 37
 038        if (personaIds != null)
 39        {
 040            var ids = personaIds.ToList();
 041            query = query.Where(a => ids.Contains(a.PersonaId));
 42        }
 43
 044        var aguinaldos = await query.ToListAsync();
 45        var personaIdsUsados = aguinaldos.Select(a => a.PersonaId).ToList();
 046        var relacionesPorPersona = await CargarRelacionesPorPersonaAsync(personaIdsUsados);
 047        var fechasPorPersona = await ObtenerFechasRelacionesBatchAsync(personaIdsUsados, relacionesPorPersona);
 48
 049        var mes = semestre == 1 ? 6 : 12;
 050        var memoryStream = new MemoryStream();
 051        using (var zip = new ZipArchive(memoryStream, ZipArchiveMode.Create, leaveOpen: true))
 52        {
 053            foreach (var aguinaldo in aguinaldos)
 54            {
 055                if (!relacionesPorPersona.TryGetValue(aguinaldo.PersonaId, out var relacion))
 56                    continue;
 57
 058                fechasPorPersona.TryGetValue(aguinaldo.PersonaId, out var fechas);
 059                var pdfBytes = GenerarPdfSemestral(aguinaldo, relacion, fechas.FechaIngreso, fechas.FechaUltAscenso);
 060                var entryName = $"aguinaldo_{anio}_{mes:D2}_{relacion.Persona.Cedula}.pdf";
 061                var entry = zip.CreateEntry(entryName, CompressionLevel.Fastest);
 062                using var entryStream = entry.Open();
 063                await entryStream.WriteAsync(pdfBytes);
 064            }
 065        }
 66
 067        memoryStream.Position = 0;
 068        return (memoryStream, $"aguinaldos_{anio}_{mes:D2}.zip");
 069    }
 70
 71    // ── BAJA ─────────────────────────────────────────────────────────────────
 72
 73    public async Task<(byte[] Pdf, string FileName)> GenerarPdfBajaAsync(long aguinaldoBajaId)
 74    {
 075        var baja = await CargarAguinaldoBajaAsync(aguinaldoBajaId);
 076        var relacion = await CargarRelacionParaBajaAsync(baja.PersonaId);
 077        var (fechaIngreso, fechaUltAsc) = relacion != null
 078            ? await ObtenerFechasRelacionAsync(baja.PersonaId, relacion.FechaInicio)
 079            : ((DateTime?)null, (DateTime?)null);
 080        var fileName = $"aguinaldo_{baja.Anio}_{baja.Mes:D2}_{baja.Cedula}.pdf";
 081        return (GenerarPdfBaja(baja, relacion, fechaIngreso, fechaUltAsc), fileName);
 082    }
 83
 84    public async Task<(Stream Zip, string FileName)> GenerarLoteBajaZipAsync(IEnumerable<long> aguinaldoBajaIds)
 85    {
 086        var ids = aguinaldoBajaIds.ToList();
 087        var bajas = await _context.AguinaldosBaja
 088            .Include(b => b.Items)
 089            .Where(b => ids.Contains(b.Id))
 090            .ToListAsync();
 91
 92        var personaIdsUsados = bajas.Select(b => b.PersonaId).ToList();
 093        var relacionesPorPersona = await CargarRelacionesParaBajaAsync(personaIdsUsados);
 094        var fechasPorPersona = await ObtenerFechasRelacionesBatchAsync(personaIdsUsados, relacionesPorPersona);
 95
 096        var memoryStream = new MemoryStream();
 097        using (var zip = new ZipArchive(memoryStream, ZipArchiveMode.Create, leaveOpen: true))
 98        {
 099            foreach (var baja in bajas)
 100            {
 0101                relacionesPorPersona.TryGetValue(baja.PersonaId, out var relacion);
 0102                fechasPorPersona.TryGetValue(baja.PersonaId, out var fechas);
 0103                var pdfBytes = GenerarPdfBaja(baja, relacion, fechas.FechaIngreso, fechas.FechaUltAscenso);
 0104                var entryName = $"aguinaldo_{baja.Anio}_{baja.Mes:D2}_{baja.Cedula}.pdf";
 0105                var entry = zip.CreateEntry(entryName, CompressionLevel.Fastest);
 0106                using var entryStream = entry.Open();
 0107                await entryStream.WriteAsync(pdfBytes);
 0108            }
 0109        }
 110
 0111        memoryStream.Position = 0;
 0112        var zipName = bajas.Count > 0
 0113            ? $"aguinaldos_{bajas[0].Anio}_{bajas[0].Mes:D2}.zip"
 0114            : "aguinaldos_baja.zip";
 0115        return (memoryStream, zipName);
 0116    }
 117
 118    // ── DATA LOADING ──────────────────────────────────────────────────────────
 119
 120    private async Task<(Aguinaldo aguinaldo, RelacionLaboral relacion)> CargarDatosSemestralAsync(
 121        long personaId, int anio, short semestre)
 122    {
 0123        var aguinaldo = await _context.Aguinaldos
 0124            .FirstOrDefaultAsync(a => a.PersonaId == personaId && a.Anio == anio && a.Semestre == semestre)
 0125            ?? throw new KeyNotFoundException(
 0126                $"No se encontró aguinaldo para persona {personaId}, año {anio}, semestre {semestre}.");
 127
 0128        var relacion = await CargarRelacionActivaPorPersonaIdAsync(personaId);
 0129        return (aguinaldo, relacion);
 0130    }
 131
 132    private async Task<RelacionLaboral> CargarRelacionActivaPorPersonaIdAsync(long personaId)
 133    {
 0134        return await _context.RelacionesLaborales
 0135            .Include(rl => rl.Persona)
 0136            .Include(rl => rl.Grado)
 0137            .Include(rl => rl.Escalafon)
 0138            .Include(rl => rl.Unidad)
 0139            .Where(rl => rl.PersonaId == personaId && rl.Estado == EstadoRelacion.Activo)
 0140            .FirstOrDefaultAsync()
 0141            ?? throw new KeyNotFoundException($"No se encontró relación laboral activa para persona {personaId}.");
 0142    }
 143
 144    private async Task<Dictionary<long, RelacionLaboral>> CargarRelacionesPorPersonaAsync(List<long> personaIds)
 145    {
 0146        var relaciones = await _context.RelacionesLaborales
 0147            .Include(rl => rl.Persona)
 0148            .Include(rl => rl.Grado)
 0149            .Include(rl => rl.Escalafon)
 0150            .Include(rl => rl.Unidad)
 0151            .Where(rl => personaIds.Contains(rl.PersonaId) && rl.Estado == EstadoRelacion.Activo)
 0152            .ToListAsync();
 153
 154        return relaciones.ToDictionary(rl => rl.PersonaId);
 0155    }
 156
 157    // Para bajas: activa si existe, sino la más reciente inactiva (mayor FechaFin)
 158    private async Task<RelacionLaboral?> CargarRelacionParaBajaAsync(long personaId)
 159    {
 0160        var activa = await _context.RelacionesLaborales
 0161            .Include(rl => rl.Persona)
 0162            .Include(rl => rl.Grado)
 0163            .Include(rl => rl.Escalafon)
 0164            .Include(rl => rl.Unidad)
 0165            .Where(rl => rl.PersonaId == personaId && rl.Estado == EstadoRelacion.Activo)
 0166            .FirstOrDefaultAsync();
 167
 0168        if (activa != null) return activa;
 169
 0170        return await _context.RelacionesLaborales
 0171            .Include(rl => rl.Persona)
 0172            .Include(rl => rl.Grado)
 0173            .Include(rl => rl.Escalafon)
 0174            .Include(rl => rl.Unidad)
 0175            .Where(rl => rl.PersonaId == personaId)
 0176            .OrderByDescending(rl => rl.FechaFin)
 0177            .FirstOrDefaultAsync();
 0178    }
 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    {
 0183        if (personaIds.Count == 0)
 0184            return new Dictionary<long, RelacionLaboral>();
 185
 0186        var activas = await _context.RelacionesLaborales
 0187            .Include(rl => rl.Persona)
 0188            .Include(rl => rl.Grado)
 0189            .Include(rl => rl.Escalafon)
 0190            .Include(rl => rl.Unidad)
 0191            .Where(rl => personaIds.Contains(rl.PersonaId) && rl.Estado == EstadoRelacion.Activo)
 0192            .ToListAsync();
 193
 194        var result = activas.ToDictionary(rl => rl.PersonaId);
 195
 0196        var sinActiva = personaIds.Except(result.Keys).ToList();
 0197        if (sinActiva.Count > 0)
 198        {
 0199            var inactivas = await _context.RelacionesLaborales
 0200                .Include(rl => rl.Persona)
 0201                .Include(rl => rl.Grado)
 0202                .Include(rl => rl.Escalafon)
 0203                .Include(rl => rl.Unidad)
 0204                .Where(rl => sinActiva.Contains(rl.PersonaId))
 0205                .ToListAsync();
 206
 207            foreach (var rl in inactivas.GroupBy(r => r.PersonaId)
 208                                         .Select(g => g.OrderByDescending(r => r.FechaFin).First()))
 0209                result[rl.PersonaId] = rl;
 210        }
 211
 0212        return result;
 0213    }
 214
 215    private async Task<AguinaldoBaja> CargarAguinaldoBajaAsync(long aguinaldoBajaId)
 216    {
 0217        return await _context.AguinaldosBaja
 0218            .Include(b => b.Items)
 0219            .FirstOrDefaultAsync(b => b.Id == aguinaldoBajaId)
 0220            ?? throw new KeyNotFoundException($"No se encontró aguinaldo por baja con id {aguinaldoBajaId}.");
 0221    }
 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    {
 0227        var minFecha = await _context.RelacionesLaborales
 0228            .Where(rl => rl.PersonaId == personaId)
 0229            .MinAsync(rl => rl.FechaInicio);
 230
 0231        return (minFecha, fechaInicioActivo > minFecha ? fechaInicioActivo : null);
 0232    }
 233
 234    private async Task<Dictionary<long, (DateTime? FechaIngreso, DateTime? FechaUltAscenso)>> ObtenerFechasRelacionesBat
 235        List<long> personaIds,
 236        Dictionary<long, RelacionLaboral> relacionesActivas)
 237    {
 0238        if (personaIds.Count == 0)
 0239            return new Dictionary<long, (DateTime?, DateTime?)>();
 240
 0241        var todasFechas = await _context.RelacionesLaborales
 0242            .Where(rl => personaIds.Contains(rl.PersonaId))
 0243            .Select(rl => new { rl.PersonaId, rl.FechaInicio })
 0244            .ToListAsync();
 245
 0246        var minPorPersona = todasFechas
 247            .GroupBy(r => r.PersonaId)
 248            .ToDictionary(g => g.Key, g => g.Min(r => r.FechaInicio));
 249
 0250        return personaIds.ToDictionary(
 251            id => id,
 0252            id =>
 0253            {
 0254                var activo = relacionesActivas.GetValueOrDefault(id);
 0255                if (activo == null)
 0256                    return ((DateTime?)null, (DateTime?)null);
 0257
 0258                var minFecha = minPorPersona.GetValueOrDefault(id, activo.FechaInicio);
 0259                DateTime? ultAsc = activo.FechaInicio > minFecha ? activo.FechaInicio : null;
 0260                return ((DateTime?)minFecha, ultAsc);
 0261            });
 0262    }
 263
 264    // ── PDF BUILDERS ──────────────────────────────────────────────────────────
 265
 266    private static byte[] GenerarPdfSemestral(
 267        Aguinaldo ag, RelacionLaboral relacion,
 268        DateTime? fechaIngreso, DateTime? fechaUltAscenso)
 269    {
 0270        var ci = CultureInfo.GetCultureInfo("es-UY");
 0271        var mes = ag.Semestre == 1 ? 6 : 12;
 0272        var nombreMes = ci.TextInfo.ToTitleCase(ci.DateTimeFormat.GetMonthName(mes));
 0273        var tituloPeriodo = $"MES DE : {nombreMes} {ag.Anio}";
 274
 0275        var nominal = ag.Nominal ?? 0m;
 0276        var montepio = ag.Montepio ?? 0m;
 0277        var sanidad = ag.Sanidad ?? 0m;
 0278        var totalDescuentos = ag.TotalDescuentos ?? 0m;
 0279        var liquido = ag.Liquido ?? 0m;
 0280        var redondeo = ag.Redondeo;
 281
 0282        var haberes = new List<(string Desc, decimal Monto)>
 0283        {
 0284            ("AGUINALDO MILITAR", nominal)
 0285        };
 286
 0287        var descuentos = new List<(string Desc, decimal Monto)>
 0288        {
 0289            ("MONTEPÍO", montepio),
 0290            ("D.N.S.FF.AA.", sanidad)
 0291        };
 292
 0293        return GenerarPdfAguinaldo(
 0294            tituloPeriodo, relacion,
 0295            haberes, descuentos,
 0296            totalHaberes: nominal,
 0297            totalDescuentos: totalDescuentos,
 0298            redondeo: redondeo,
 0299            aCobrar: liquido,
 0300            fechaIngreso: fechaIngreso,
 0301            fechaUltAscenso: fechaUltAscenso);
 302    }
 303
 304    private static byte[] GenerarPdfBaja(
 305        AguinaldoBaja baja, RelacionLaboral? relacion,
 306        DateTime? fechaIngreso, DateTime? fechaUltAscenso)
 307    {
 0308        var ci = CultureInfo.GetCultureInfo("es-UY");
 0309        var nombreMes = ci.TextInfo.ToTitleCase(ci.DateTimeFormat.GetMonthName(baja.Mes));
 0310        var tituloPeriodo = $"MES DE : {nombreMes} {baja.Anio}";
 311
 0312        var haberes = baja.Items
 0313            .Where(i => i.Tipo == "haber")
 0314            .Select(i => (i.NombreConcepto, Math.Abs(i.Importe)))
 0315            .ToList();
 316
 0317        var descuentos = baja.Items
 0318            .Where(i => i.Tipo == "descuento")
 0319            .Select(i => (i.NombreConcepto, Math.Abs(i.Importe)))
 0320            .ToList();
 321
 0322        if (!haberes.Any())
 0323            haberes = new List<(string, decimal)> { ("AGUINALDO MILITAR", baja.Nominal) };
 0324        if (!descuentos.Any())
 0325            descuentos = new List<(string, decimal)>
 0326            {
 0327                ("MONTEPÍO", baja.Montepio),
 0328                ("D.N.S.FF.AA.", baja.Sanidad)
 0329            };
 330
 0331        return GenerarPdfAguinaldo(
 0332            tituloPeriodo, relacion,
 0333            haberes, descuentos,
 0334            totalHaberes: baja.Nominal,
 0335            totalDescuentos: baja.TotalDescuentos,
 0336            redondeo: baja.Redondeo,
 0337            aCobrar: baja.Liquido,
 0338            fechaIngreso: fechaIngreso,
 0339            fechaUltAscenso: fechaUltAscenso,
 0340            cedula: baja.Cedula,
 0341            nombreCompleto: baja.NombreCompleto);
 342    }
 343
 344    // ── CORE PDF ──────────────────────────────────────────────────────────────
 345
 0346    private static readonly byte[]? _logoBytes = CargarLogo();
 347
 348    private static byte[]? CargarLogo()
 349    {
 0350        var asm = Assembly.GetExecutingAssembly();
 0351        var nombre = asm.GetManifestResourceNames()
 0352            .FirstOrDefault(n => n.EndsWith("fau-logo.png", StringComparison.OrdinalIgnoreCase));
 0353        if (nombre is null) return null;
 0354        using var stream = asm.GetManifestResourceStream(nombre)!;
 0355        using var ms = new MemoryStream();
 0356        stream.CopyTo(ms);
 0357        return ms.ToArray();
 0358    }
 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    {
 0374        var cedulaMostrar = cedula ?? relacion?.Persona.Cedula ?? "-";
 0375        var gradoCodigo   = relacion?.Grado.Codigo ?? "-";
 0376        var escalafonCodigo = relacion?.Escalafon.Codigo ?? "-";
 0377        var unidadCodigo  = relacion?.Unidad.Codigo ?? "-";
 378
 379        string apellidoNombre;
 0380        if (nombreCompleto != null)
 381        {
 0382            apellidoNombre = nombreCompleto.ToUpper();
 383        }
 0384        else if (relacion != null)
 385        {
 0386            var p = relacion.Persona;
 0387            var apellidos = string.IsNullOrWhiteSpace(p.SegundoApellido)
 0388                ? p.PrimerApellido
 0389                : $"{p.PrimerApellido} {p.SegundoApellido}";
 0390            var nombres = string.IsNullOrWhiteSpace(p.SegundoNombre)
 0391                ? p.PrimerNombre
 0392                : $"{p.PrimerNombre} {p.SegundoNombre}";
 0393            apellidoNombre = $"{apellidos.ToUpper()}, {nombres.ToUpper()}";
 394        }
 395        else
 396        {
 0397            apellidoNombre = "-";
 398        }
 399
 0400        var fechaIngresoStr = fechaIngreso.HasValue
 0401            ? fechaIngreso.Value.ToString("dd/MM/yyyy")
 0402            : string.Empty;
 403
 0404        var fechaUltAscStr = fechaUltAscenso.HasValue
 0405            ? fechaUltAscenso.Value.ToString("dd/MM/yyyy")
 0406            : string.Empty;
 407
 0408        return Document.Create(container =>
 0409        {
 0410            container.Page(page =>
 0411            {
 0412                page.Size(PageSizes.A4);
 0413                page.MarginHorizontal(20);
 0414                page.MarginTop(15);
 0415                page.MarginBottom(15);
 0416                page.DefaultTextStyle(x => x.FontSize(7.5f).FontFamily("Courier New"));
 0417
 0418                page.Content().Column(col =>
 0419                {
 0420                    // ── ENCABEZADO ────────────────────────────────────────
 0421                    col.Item().PaddingBottom(4).Row(row =>
 0422                    {
 0423                        row.RelativeItem().Row(r =>
 0424                        {
 0425                            if (_logoBytes is not null)
 0426                                r.ConstantItem(90).Image(_logoBytes).FitArea();
 0427                            r.RelativeItem().PaddingLeft(_logoBytes is not null ? 6 : 0)
 0428                             .Column(c =>
 0429                             {
 0430                                 c.Item().Text("FUERZA AÉREA").Bold().FontSize(13).FontFamily("Arial");
 0431                                 c.Item().Text("URUGUAYA").Bold().FontSize(13).FontFamily("Arial");
 0432                             });
 0433                        });
 0434                        row.ConstantItem(165).Column(c =>
 0435                        {
 0436                            c.Item().Border(1).Padding(6).Column(inner =>
 0437                            {
 0438                                inner.Item().Text($" {tituloPeriodo}");
 0439                                inner.Item().Text(" ");
 0440                            });
 0441                        });
 0442                    });
 0443
 0444                    // ── CABECERA FUNCIONARIO ──────────────────────────────
 0445                    col.Item().Border(1).Row(row =>
 0446                    {
 0447                        row.ConstantItem(85).BorderRight(1).PaddingHorizontal(5).PaddingVertical(3)
 0448                           .Text("CEDULA").Bold();
 0449                        row.ConstantItem(80).BorderRight(1).PaddingHorizontal(5).PaddingVertical(3)
 0450                           .AlignCenter().Text("GDO").Bold();
 0451                        row.ConstantItem(32).BorderRight(1).PaddingHorizontal(5).PaddingVertical(3)
 0452                           .AlignCenter().Text("ESC").Bold();
 0453                        row.RelativeItem().BorderRight(1).PaddingHorizontal(5).PaddingVertical(3)
 0454                           .Text("APELLIDO Y NOMBRE").Bold();
 0455                        row.ConstantItem(22).BorderRight(1).PaddingHorizontal(4).PaddingVertical(3)
 0456                           .AlignCenter().Text("UN").Bold();
 0457                        row.ConstantItem(138).PaddingHorizontal(5).PaddingVertical(3)
 0458                           .AlignCenter().Text("FECHAS").Bold();
 0459                    });
 0460
 0461                    // ── DATOS FUNCIONARIO ─────────────────────────────────
 0462                    col.Item().BorderLeft(1).BorderRight(1).BorderBottom(1).Row(row =>
 0463                    {
 0464                        row.ConstantItem(85).BorderRight(1).PaddingHorizontal(5).PaddingVertical(3)
 0465                           .Text(cedulaMostrar);
 0466                        row.ConstantItem(80).BorderRight(1).PaddingHorizontal(5).PaddingVertical(3)
 0467                           .Text(gradoCodigo);
 0468                        row.ConstantItem(32).BorderRight(1).PaddingHorizontal(5).PaddingVertical(3)
 0469                           .Text(escalafonCodigo);
 0470                        row.RelativeItem().BorderRight(1).PaddingHorizontal(5).PaddingVertical(3)
 0471                           .Text(apellidoNombre);
 0472                        row.ConstantItem(22).BorderRight(1).PaddingHorizontal(4).PaddingVertical(3)
 0473                           .AlignCenter().Text(unidadCodigo);
 0474                        row.ConstantItem(138).PaddingHorizontal(5).PaddingVertical(3)
 0475                           .Column(c =>
 0476                           {
 0477                               if (!string.IsNullOrEmpty(fechaIngresoStr))
 0478                                   c.Item().Text(t =>
 0479                                   {
 0480                                       t.Span("Fecha Ingreso: ").Bold();
 0481                                       t.Span(fechaIngresoStr);
 0482                                   });
 0483                               else
 0484                                   c.Item().Text(" ");
 0485
 0486                               if (!string.IsNullOrEmpty(fechaUltAscStr))
 0487                                   c.Item().Text(t =>
 0488                                   {
 0489                                       t.Span("Fecha Ult. Asc.: ").Bold();
 0490                                       t.Span(fechaUltAscStr);
 0491                                   });
 0492                           });
 0493                    });
 0494
 0495                    // ── CUERPO DE 3 COLUMNAS ──────────────────────────────
 0496                    col.Item().Extend()
 0497                       .BorderLeft(1).BorderRight(1).BorderTop(1)
 0498                       .Row(row =>
 0499                    {
 0500                        // Columna 1: Haberes
 0501                        row.RelativeItem(40).BorderRight(1).PaddingTop(5).Column(c =>
 0502                        {
 0503                            foreach (var item in haberes)
 0504                                FilaItem(c, item.Desc, item.Monto);
 0505
 0506                            FilaSeparador(c);
 0507                            FilaTotalBold(c, "TOTAL DE HABERES", totalHaberes);
 0508                        });
 0509
 0510                        // Columna 2: Descuentos
 0511                        row.RelativeItem(35).BorderRight(1).PaddingTop(5).Column(c =>
 0512                        {
 0513                            foreach (var item in descuentos.Where(d => d.Monto != 0))
 0514                                FilaItem(c, item.Desc, item.Monto);
 0515
 0516                            FilaSeparador(c);
 0517                            FilaTotalBold(c, "TOTAL DESC.", totalDescuentos);
 0518                        });
 0519
 0520                        // Columna 3: Ajuste (redondeo)
 0521                        row.RelativeItem(25).PaddingTop(5).Column(c =>
 0522                        {
 0523                            if (redondeo != 0m)
 0524                            {
 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                                });
 0531                            }
 0532                        });
 0533                    });
 0534                });
 0535
 0536                // ── PIE: NOMINAL | DESCUENTOS | A COBRAR ─────────────────
 0537                page.Footer().Border(1).Row(row =>
 0538                {
 0539                    row.RelativeItem().BorderRight(1).Padding(5).Row(r =>
 0540                    {
 0541                        r.RelativeItem().Text(t => t.Span("NOMINAL....: ").Bold());
 0542                        r.ConstantItem(75).AlignRight().Text(Monto(totalHaberes));
 0543                    });
 0544                    row.RelativeItem().BorderRight(1).Padding(5).Row(r =>
 0545                    {
 0546                        r.RelativeItem().Text(t => t.Span("DESCUENTOS.: ").Bold());
 0547                        r.ConstantItem(75).AlignRight().Text(Monto(totalDescuentos));
 0548                    });
 0549                    row.RelativeItem().Padding(5).Row(r =>
 0550                    {
 0551                        r.RelativeItem().Text(t => t.Span("A COBRAR...: ").Bold());
 0552                        r.ConstantItem(75).AlignRight().Text(Monto(aCobrar));
 0553                    });
 0554                });
 0555            });
 0556        }).GeneratePdf();
 557    }
 558
 559    private static string Monto(decimal valor) =>
 0560        valor.ToString("N2", CultureInfo.InvariantCulture);
 561
 562    private static void FilaItem(ColumnDescriptor col, string descripcion, decimal monto)
 563    {
 0564        col.Item().Row(r =>
 0565        {
 0566            r.RelativeItem().PaddingHorizontal(5).Text(descripcion);
 0567            r.ConstantItem(62).AlignRight().PaddingHorizontal(5).Text(Monto(monto));
 0568        });
 0569    }
 570
 571    private static void FilaSeparador(ColumnDescriptor col)
 572    {
 0573        col.Item().PaddingHorizontal(5).PaddingVertical(2).LineHorizontal(0.5f);
 0574    }
 575
 576    private static void FilaTotalBold(ColumnDescriptor col, string label, decimal total)
 577    {
 0578        col.Item().Row(r =>
 0579        {
 0580            r.RelativeItem().PaddingHorizontal(5).Text(label).Bold();
 0581            r.ConstantItem(62).AlignRight().PaddingHorizontal(5).Text(Monto(total)).Bold();
 0582        });
 0583    }
 584}