diff --git a/.gitignore b/.gitignore index cc0666d..e7fd530 100644 --- a/.gitignore +++ b/.gitignore @@ -160,4 +160,6 @@ junit.xml # =================================================================== # Fin del archivo .gitignore -# =================================================================== \ No newline at end of file +# =================================================================== +Backend/SQL +.atl diff --git a/Backend/EliminacionLogicaDistribuidores.sql b/Backend/EliminacionLogicaDistribuidores.sql deleted file mode 100644 index 7c20a58..0000000 --- a/Backend/EliminacionLogicaDistribuidores.sql +++ /dev/null @@ -1,27 +0,0 @@ --- Script para agregar borrado lógico a Distribuidores - --- 1. Agregar columnas a la tabla principal -ALTER TABLE dbo.dist_dtDistribuidores -ADD Baja bit NOT NULL DEFAULT 0; - -ALTER TABLE dbo.dist_dtDistribuidores -ADD FechaBaja datetime2(0) NULL; - --- 2. Agregar columnas a la tabla histórica -ALTER TABLE dbo.dist_dtDistribuidores_H -ADD Baja bit NULL; - -ALTER TABLE dbo.dist_dtDistribuidores_H -ADD FechaBaja datetime2(0) NULL; - --- 3. ATENCION: Actualizar Stored Procedures de Reportes --- Los siguientes Stored Procedures deben ser modificados para incluir la condicion "AND Baja = 0" --- en las consultas a "dist_dtDistribuidores": --- - SP_BalanceCuentaDistEntradaSalidaPorEmpresa --- - SP_BalanceCuentDistDebCredEmpresa --- - SP_BalanceCuentDistPagosEmpresa --- - SP_BalanceCuentSaldosEmpresas --- - SP_CantidadEntradaSalida --- - SP_CantidadEntradaSalidaCPromAgDia - -PRINT 'Se agregaron correctamente las columnas Baja y FechaBaja a dist_dtDistribuidores y dist_dtDistribuidores_H'; diff --git a/Backend/GestionIntegral.Api/Controllers/Auditoria/AuditoriaController.cs b/Backend/GestionIntegral.Api/Controllers/Auditoria/AuditoriaController.cs index befd4ce..2969a64 100644 --- a/Backend/GestionIntegral.Api/Controllers/Auditoria/AuditoriaController.cs +++ b/Backend/GestionIntegral.Api/Controllers/Auditoria/AuditoriaController.cs @@ -51,6 +51,7 @@ namespace GestionIntegral.Api.Controllers private readonly IPerfilService _perfilService; private readonly IPermisoService _permisoService; private readonly ICambioParadaService _cambioParadaService; + private readonly ICierreCuentaCorrienteService _cierreCcService; private readonly ILogger _logger; // Permiso general para ver cualquier auditoría. @@ -86,6 +87,7 @@ namespace GestionIntegral.Api.Controllers IPerfilService perfilService, IPermisoService permisoService, ICambioParadaService cambioParadaService, + ICierreCuentaCorrienteService cierreCcService, ILogger logger) { _usuarioService = usuarioService; @@ -116,6 +118,7 @@ namespace GestionIntegral.Api.Controllers _perfilService = perfilService; _cambioParadaService = cambioParadaService; _permisoService = permisoService; + _cierreCcService = cierreCcService; _logger = logger; } @@ -692,6 +695,26 @@ namespace GestionIntegral.Api.Controllers } } + [HttpGet("cierres-cuenta-corriente")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task GetHistorialCierresCuentaCorriente( + [FromQuery] DateTime? fechaDesde, [FromQuery] DateTime? fechaHasta, + [FromQuery] int? idUsuarioModifico, [FromQuery] string? tipoModificacion, + [FromQuery] int? idCierreAfectado) + { + if (!TienePermiso(PermisoVerAuditoria)) return Forbid(); + try + { + var historial = await _cierreCcService.ObtenerHistorialAsync(fechaDesde, fechaHasta, idUsuarioModifico, tipoModificacion, idCierreAfectado); + return Ok(historial ?? Enumerable.Empty()); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error obteniendo historial de Cierres de Cuenta Corriente."); + return StatusCode(500, "Error interno al obtener historial de Cierres de Cuenta Corriente."); + } + } + [HttpGet("cambios-parada-canilla")] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] public async Task GetHistorialCambiosParada( diff --git a/Backend/GestionIntegral.Api/Controllers/Contables/CierresCuentaCorrienteController.cs b/Backend/GestionIntegral.Api/Controllers/Contables/CierresCuentaCorrienteController.cs new file mode 100644 index 0000000..8c28def --- /dev/null +++ b/Backend/GestionIntegral.Api/Controllers/Contables/CierresCuentaCorrienteController.cs @@ -0,0 +1,168 @@ +using GestionIntegral.Api.Dtos.Auditoria; +using GestionIntegral.Api.Dtos.Contables; +using GestionIntegral.Api.Services.Contables; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Security.Claims; +using System.Threading.Tasks; + +namespace GestionIntegral.Api.Controllers.Contables +{ + [Route("api/cierres-cc")] + [ApiController] + [Authorize] + public class CierresCuentaCorrienteController : ControllerBase + { + private readonly ICierreCuentaCorrienteService _cierreService; + private readonly ILogger _logger; + + // Permisos asignables a perfiles. La reapertura (CC002) NO se valida acá: es exclusiva de SuperAdmin. + private const string PermisoCrear = "CC001"; + private const string PermisoVer = "CC003"; + + public CierresCuentaCorrienteController( + ICierreCuentaCorrienteService cierreService, + ILogger logger) + { + _cierreService = cierreService; + _logger = logger; + } + + private bool TienePermiso(string codAcc) => + User.IsInRole("SuperAdmin") || User.HasClaim(c => c.Type == "permission" && c.Value == codAcc); + + private bool EsSuperAdmin() => User.IsInRole("SuperAdmin"); + + private int? GetCurrentUserId() + { + if (int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"), out int userId)) + return userId; + _logger.LogWarning("No se pudo obtener el UserId del token JWT en CierresCuentaCorrienteController."); + return null; + } + + // POST: api/cierres-cc + [HttpPost] + [ProducesResponseType(typeof(CierreCuentaCorrienteDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Crear([FromBody] CrearCierreDto dto) + { + if (!TienePermiso(PermisoCrear)) return Forbid(); + if (!ModelState.IsValid) return BadRequest(ModelState); + var userId = GetCurrentUserId(); + if (userId == null) return Unauthorized(); + + var (cierre, errorCode, errorMessage) = await _cierreService.CrearCierreAsync(dto, userId.Value); + + if (errorCode != null) + { + int status = errorCode switch + { + "CIERRE_FECHA_ANTERIOR_A_ULTIMO" => StatusCodes.Status409Conflict, + "CIERRE_ERROR_INTERNO" => StatusCodes.Status500InternalServerError, + _ => StatusCodes.Status400BadRequest + }; + return StatusCode(status, new { codigo = errorCode, mensaje = errorMessage }); + } + + return StatusCode(StatusCodes.Status201Created, cierre); + } + + // POST: api/cierres-cc/{idCierre}/reabrir + [HttpPost("{idCierre:int}/reabrir")] + [ProducesResponseType(typeof(CierreCuentaCorrienteDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Reabrir(int idCierre, [FromBody] ReabrirCierreDto dto) + { + if (!EsSuperAdmin()) + return Forbid(); + if (!ModelState.IsValid) return BadRequest(ModelState); + var userId = GetCurrentUserId(); + if (userId == null) return Unauthorized(); + + var (cierre, errorCode, errorMessage) = await _cierreService.ReabrirCierreAsync(idCierre, dto, userId.Value, esSuperAdmin: true); + + if (errorCode != null) + { + int status = errorCode switch + { + "CIERRE_NO_ENCONTRADO" => StatusCodes.Status404NotFound, + "CIERRE_PERMISO_DENEGADO" => StatusCodes.Status403Forbidden, + "CIERRE_HAY_POSTERIORES_VIGENTES" => StatusCodes.Status409Conflict, + "CIERRE_YA_ANULADO" => StatusCodes.Status409Conflict, + "CIERRE_ERROR_INTERNO" => StatusCodes.Status500InternalServerError, + _ => StatusCodes.Status400BadRequest + }; + return StatusCode(status, new { codigo = errorCode, mensaje = errorMessage }); + } + + return Ok(cierre); + } + + // GET: api/cierres-cc?idDistribuidor=&idEmpresa=&estado=&fechaCorteDesde=&fechaCorteHasta= + [HttpGet] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task GetAll( + [FromQuery] int? idDistribuidor, + [FromQuery] int? idEmpresa, + [FromQuery] string? estado, + [FromQuery] DateTime? fechaCorteDesde, + [FromQuery] DateTime? fechaCorteHasta) + { + if (!TienePermiso(PermisoVer)) return Forbid(); + var cierres = await _cierreService.GetAllAsync(idDistribuidor, idEmpresa, estado, fechaCorteDesde, fechaCorteHasta); + return Ok(cierres); + } + + // GET: api/cierres-cc/{idCierre} + [HttpGet("{idCierre:int}")] + [ProducesResponseType(typeof(CierreCuentaCorrienteDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetById(int idCierre) + { + if (!TienePermiso(PermisoVer)) return Forbid(); + var cierre = await _cierreService.GetByIdAsync(idCierre); + if (cierre == null) return NotFound(new { message = $"Cierre #{idCierre} no encontrado." }); + return Ok(cierre); + } + + // GET: api/cierres-cc/ultimo?idDistribuidor=&idEmpresa= + // Atajo del frontend para autorrellenar "Desde último cierre" en filtros del reporte. + // Acepta CC003 (gestión de cierres) o RR001 (acceso al reporte que CONTIENE este atajo): + // operadores con solo el permiso del reporte deben poder usar el atajo desde la pantalla del reporte. + [HttpGet("ultimo")] + [ProducesResponseType(typeof(UltimoCierreDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetUltimoVigente( + [FromQuery] int idDistribuidor, + [FromQuery] int idEmpresa) + { + if (!TienePermiso(PermisoVer) && !TienePermiso("RR001")) return Forbid(); + var ultimo = await _cierreService.GetUltimoVigenteAsync(idDistribuidor, idEmpresa); + if (ultimo == null) return NotFound(new { message = "No hay cierres vigentes para el distribuidor en la empresa indicada." }); + return Ok(ultimo); + } + + // GET: api/cierres-cc/{idCierre}/historial + [HttpGet("{idCierre:int}/historial")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task GetHistorial(int idCierre) + { + if (!TienePermiso(PermisoVer)) return Forbid(); + var historial = await _cierreService.GetHistorialAsync(idCierre); + return Ok(historial); + } + } +} diff --git a/Backend/GestionIntegral.Api/Controllers/Contables/SaldosController.cs b/Backend/GestionIntegral.Api/Controllers/Contables/SaldosController.cs index 8ff8f25..cac4aae 100644 --- a/Backend/GestionIntegral.Api/Controllers/Contables/SaldosController.cs +++ b/Backend/GestionIntegral.Api/Controllers/Contables/SaldosController.cs @@ -18,9 +18,8 @@ namespace GestionIntegral.Api.Controllers.Contables private readonly ISaldoService _saldoService; private readonly ILogger _logger; - // Define un permiso específico para ver saldos, y otro para ajustarlos (SuperAdmin implícito) - private const string PermisoVerSaldos = "CS001"; // Ejemplo: Cuentas Saldos Ver - private const string PermisoAjustarSaldos = "CS002"; // Ejemplo: Cuentas Saldos Ajustar (o solo SuperAdmin) + // Permiso para ver saldos. El ajuste manual es exclusivo de SuperAdmin (no se valida un permiso asignable). + private const string PermisoVerSaldos = "CS001"; public SaldosController(ISaldoService saldoService, ILogger logger) @@ -76,11 +75,11 @@ namespace GestionIntegral.Api.Controllers.Contables [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task AjustarSaldoManualmente([FromBody] AjusteSaldoRequestDto ajusteDto) { - // Esta operación debería ser MUY restringida. Solo SuperAdmin o un permiso muy específico. - if (!User.IsInRole("SuperAdmin") && !TienePermiso(PermisoAjustarSaldos)) + // El ajuste manual de saldo es operación crítica: solo SuperAdmin. No se admite vía permiso asignable. + if (!User.IsInRole("SuperAdmin")) { _logger.LogWarning("Intento no autorizado de ajustar saldo por Usuario ID {userId}", GetCurrentUserId() ?? 0); - return Forbid("No tiene permisos para realizar ajustes manuales de saldo."); + return Forbid("Solo SuperAdmin puede ajustar saldos manualmente."); } if (!ModelState.IsValid) return BadRequest(ModelState); @@ -99,6 +98,10 @@ namespace GestionIntegral.Api.Controllers.Contables } return Ok(saldoActualizado); } + catch (BloqueoPorPeriodoCerradoException) + { + throw; + } catch (Exception ex) { _logger.LogError(ex, "Error crítico al ajustar saldo manualmente."); diff --git a/Backend/GestionIntegral.Api/Controllers/Reportes/PdfTemplates/CuentasDistribuidorDocument.cs b/Backend/GestionIntegral.Api/Controllers/Reportes/PdfTemplates/CuentasDistribuidorDocument.cs index 96dd4f8..6820824 100644 --- a/Backend/GestionIntegral.Api/Controllers/Reportes/PdfTemplates/CuentasDistribuidorDocument.cs +++ b/Backend/GestionIntegral.Api/Controllers/Reportes/PdfTemplates/CuentasDistribuidorDocument.cs @@ -74,7 +74,6 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates if (Model.DebitosCreditos.Any()) column.Item().Element(ComposeDebCredTable); column.Item().Element(ComposeResumenPeriodo); - column.Item().Element(ComposeSaldoFinal); }); } @@ -107,7 +106,11 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates header.Cell().Border(1).Background(Colors.Grey.Lighten3).Padding(2).AlignRight().Text("Saldo"); }); - decimal saldoAcumulado = 0; // Inicia en CERO + // Fila Saldo Inicial al inicio de la primera tabla — ancla del acumulado + table.Cell().ColumnSpan(6).Border(1).Padding(2).Text(t => t.Span("Saldo Inicial").SemiBold()); + table.Cell().Border(1).Padding(2).AlignRight().Text(t => t.Span(Model.SaldoInicial.ToString("C", CultureAr)).SemiBold()); + + decimal saldoAcumulado = Model.SaldoInicial; foreach (var item in Model.Movimientos.OrderBy(m => m.Fecha)) { saldoAcumulado += (item.Debe - item.Haber); @@ -157,7 +160,7 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates header.Cell().Border(1).Background(Colors.Grey.Lighten3).Padding(2).Text("Detalle"); }); - decimal saldoAcumulado = Model.TotalMovimientos; + decimal saldoAcumulado = Model.SaldoInicial + Model.TotalMovimientos; foreach (var item in Model.Pagos.OrderBy(p => p.Fecha).ThenBy(p => p.Recibo)) { saldoAcumulado += (item.Debe - item.Haber); @@ -204,7 +207,7 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates header.Cell().Border(1).Background(Colors.Grey.Lighten3).Padding(2).AlignRight().Text("Saldo"); }); - decimal saldoAcumulado = Model.TotalMovimientos + Model.TotalPagos; + decimal saldoAcumulado = Model.SaldoInicial + Model.TotalMovimientos + Model.TotalPagos; foreach (var item in Model.DebitosCreditos.OrderBy(dc => dc.Fecha)) { saldoAcumulado += (item.Debe - item.Haber); @@ -236,25 +239,17 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates row.RelativeItem().Text(label); row.ConstantItem(120).AlignRight().Text(value.ToString("C", CultureAr)); }; + col.Item().Row(row => AddResumenRow(row, "Saldo Inicial", Model.SaldoInicial)); col.Item().Row(row => AddResumenRow(row, "Movimientos", Model.TotalMovimientos)); col.Item().Row(row => AddResumenRow(row, "Débitos/Créditos", Model.TotalDebitosCreditos)); col.Item().Row(row => AddResumenRow(row, "Pagos", Model.TotalPagos)); col.Item().BorderTop(1.5f).BorderColor(Colors.Black).PaddingTop(5).Row(row => { - row.RelativeItem().Text("Total").SemiBold(); - row.ConstantItem(120).AlignRight().Text(t => t.Span(Model.TotalPeriodo.ToString("C", CultureAr)).SemiBold()); + row.RelativeItem().Text("Saldo Final").SemiBold(); + row.ConstantItem(120).AlignRight().Text(t => t.Span(Model.SaldoFinal.ToString("C", CultureAr)).SemiBold()); }); }); }); } - - void ComposeSaldoFinal(IContainer container) - { - container.PaddingTop(5, Unit.Millimetre).AlignLeft().Text(text => - { - text.Span($"Saldo Total del Distribuidor al {Model.FechaReporte} ").SemiBold().FontSize(12); - text.Span(Model.SaldoDeCuenta.ToString("C", CultureAr)).SemiBold().FontSize(12); - }); - } } -} \ No newline at end of file +} diff --git a/Backend/GestionIntegral.Api/Controllers/Reportes/ReportesController.cs b/Backend/GestionIntegral.Api/Controllers/Reportes/ReportesController.cs index 7ce8088..00394c5 100644 --- a/Backend/GestionIntegral.Api/Controllers/Reportes/ReportesController.cs +++ b/Backend/GestionIntegral.Api/Controllers/Reportes/ReportesController.cs @@ -894,11 +894,11 @@ namespace GestionIntegral.Api.Controllers { if (!TienePermiso(PermisoVerBalanceCuentas)) return Forbid(); - var (entradasSalidas, debitosCreditos, pagos, saldos, error) = + var (entradasSalidas, debitosCreditos, pagos, saldoInicial, error) = await _reportesService.ObtenerReporteCuentasDistribuidorAsync(idDistribuidor, idEmpresa, fechaDesde, fechaHasta); if (error != null) return BadRequest(new { message = error }); - if (!entradasSalidas.Any() && !debitosCreditos.Any() && !pagos.Any() && !saldos.Any()) + if (!entradasSalidas.Any() && !debitosCreditos.Any() && !pagos.Any() && saldoInicial == 0m) { return NotFound(new { message = "No hay datos para generar el reporte de cuenta del distribuidor." }); } @@ -911,7 +911,7 @@ namespace GestionIntegral.Api.Controllers EntradasSalidas = entradasSalidas ?? Enumerable.Empty(), DebitosCreditos = debitosCreditos ?? Enumerable.Empty(), Pagos = pagos ?? Enumerable.Empty(), - Saldos = saldos ?? Enumerable.Empty(), + SaldoInicial = saldoInicial, NombreDistribuidor = distribuidor.Distribuidor?.Nombre, NombreEmpresa = empresa?.Nombre }; @@ -932,11 +932,11 @@ namespace GestionIntegral.Api.Controllers { if (!TienePermiso(PermisoVerBalanceCuentas)) return Forbid(); - var (entradasSalidas, debitosCreditos, pagos, saldos, error) = + var (entradasSalidas, debitosCreditos, pagos, saldoInicial, error) = await _reportesService.ObtenerReporteCuentasDistribuidorAsync(idDistribuidor, idEmpresa, fechaDesde, fechaHasta); if (error != null) return BadRequest(new { message = error }); - if (!entradasSalidas.Any() && !debitosCreditos.Any() && !pagos.Any()) + if (!entradasSalidas.Any() && !debitosCreditos.Any() && !pagos.Any() && saldoInicial == 0m) { return NotFound(new { message = "No hay datos para generar el reporte de cuenta del distribuidor." }); } @@ -950,7 +950,7 @@ namespace GestionIntegral.Api.Controllers Movimientos = entradasSalidas, Pagos = pagos, DebitosCreditos = debitosCreditos, - SaldoDeCuenta = saldos.FirstOrDefault()?.Monto ?? 0, // <-- Se asigna a SaldoDeCuenta + SaldoInicial = saldoInicial, NombreDistribuidor = distribuidor.Distribuidor?.Nombre ?? $"Distribuidor ID {idDistribuidor}", FechaDesde = fechaDesde.ToString("dd/MM/yyyy"), FechaHasta = fechaHasta.ToString("dd/MM/yyyy"), diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Contables/CierreCuentaCorrienteRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Contables/CierreCuentaCorrienteRepository.cs new file mode 100644 index 0000000..80846f2 --- /dev/null +++ b/Backend/GestionIntegral.Api/Data/Repositories/Contables/CierreCuentaCorrienteRepository.cs @@ -0,0 +1,435 @@ +using Dapper; +using GestionIntegral.Api.Dtos.Auditoria; +using GestionIntegral.Api.Dtos.Contables; +using GestionIntegral.Api.Models.Contables; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace GestionIntegral.Api.Data.Repositories.Contables +{ + public class CierreCuentaCorrienteRepository : ICierreCuentaCorrienteRepository + { + private readonly DbConnectionFactory _cf; + private readonly ILogger _log; + + public CierreCuentaCorrienteRepository(DbConnectionFactory cf, ILogger log) + { + _cf = cf; + _log = log; + } + + // Aliases SELECT — mapean Id_X (SQL) → IdX (modelo C#). + private const string SelectModelBase = @" + SELECT + Id_Cierre AS IdCierre, + Id_Distribuidor AS IdDistribuidor, + Id_Empresa AS IdEmpresa, + FechaCorte, + FechaCierre, + SaldoCierre, + Estado, + Justificacion, + Id_Usuario_Cierre AS IdUsuarioCierre, + Id_Usuario_Anula AS IdUsuarioAnula, + FechaAnulacion, + Justificacion_Anulacion AS JustificacionAnulacion + FROM dbo.cue_CierresCuentaCorriente"; + + public async Task GetByIdAsync(int idCierre) + { + var sql = SelectModelBase + " WHERE Id_Cierre = @IdCierreParam;"; + try + { + using var connection = _cf.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync(sql, new { IdCierreParam = idCierre }); + } + catch (Exception ex) + { + _log.LogError(ex, "Error al obtener Cierre por ID: {IdCierre}", idCierre); + return null; + } + } + + public async Task GetUltimoCierreVigenteAsync(int idDistribuidor, int idEmpresa, IDbTransaction? transaction = null) + { + var sql = @" + SELECT TOP 1 + Id_Cierre AS IdCierre, + Id_Distribuidor AS IdDistribuidor, + Id_Empresa AS IdEmpresa, + FechaCorte, + FechaCierre, + SaldoCierre, + Estado, + Justificacion, + Id_Usuario_Cierre AS IdUsuarioCierre, + Id_Usuario_Anula AS IdUsuarioAnula, + FechaAnulacion, + Justificacion_Anulacion AS JustificacionAnulacion + FROM dbo.cue_CierresCuentaCorriente + WHERE Id_Distribuidor = @IdDist + AND Id_Empresa = @IdEmp + AND Estado = 'Activo' + ORDER BY FechaCorte DESC, Id_Cierre DESC;"; + + var parameters = new { IdDist = idDistribuidor, IdEmp = idEmpresa }; + + try + { + if (transaction != null) + { + return await transaction.Connection!.QuerySingleOrDefaultAsync(sql, parameters, transaction); + } + + using var connection = _cf.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync(sql, parameters); + } + catch (Exception ex) + { + _log.LogError(ex, "Error al obtener último cierre vigente Dist={IdDist} Emp={IdEmp}", idDistribuidor, idEmpresa); + if (transaction != null) throw; + return null; + } + } + + public async Task GetCierreVigenteParaFechaAsync(int idDistribuidor, int idEmpresa, DateTime fechaOperacion) + { + // Cae en período cerrado si existe un cierre Activo cuya FechaCorte sea >= fechaOperacion (la fecha está dentro del período cerrado). + // Se devuelve el más reciente (TOP 1 ORDER BY FechaCorte DESC) — el más restrictivo desde la perspectiva de la fecha consultada. + const string sql = @" + SELECT TOP 1 + Id_Cierre AS IdCierre, + Id_Distribuidor AS IdDistribuidor, + Id_Empresa AS IdEmpresa, + FechaCorte, + FechaCierre, + SaldoCierre, + Estado, + Justificacion, + Id_Usuario_Cierre AS IdUsuarioCierre, + Id_Usuario_Anula AS IdUsuarioAnula, + FechaAnulacion, + Justificacion_Anulacion AS JustificacionAnulacion + FROM dbo.cue_CierresCuentaCorriente + WHERE Id_Distribuidor = @IdDist + AND Id_Empresa = @IdEmp + AND Estado = 'Activo' + AND FechaCorte >= @FechaOp + ORDER BY FechaCorte DESC, Id_Cierre DESC;"; + try + { + using var connection = _cf.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync(sql, new + { + IdDist = idDistribuidor, + IdEmp = idEmpresa, + FechaOp = fechaOperacion.Date + }); + } + catch (Exception ex) + { + _log.LogError(ex, "Error en GetCierreVigenteParaFechaAsync Dist={IdDist} Emp={IdEmp} Fecha={Fecha}", idDistribuidor, idEmpresa, fechaOperacion); + return null; + } + } + + public async Task ExisteCierrePosteriorVigenteAsync(int idDistribuidor, int idEmpresa, DateTime fechaCorte, int? excluirIdCierre = null, IDbTransaction? transaction = null) + { + const string sql = @" + SELECT CASE WHEN EXISTS ( + SELECT 1 FROM dbo.cue_CierresCuentaCorriente + WHERE Id_Distribuidor = @IdDist + AND Id_Empresa = @IdEmp + AND Estado = 'Activo' + AND FechaCorte > @FechaCorte + AND (@Excluir IS NULL OR Id_Cierre <> @Excluir) + ) THEN 1 ELSE 0 END;"; + + var parameters = new + { + IdDist = idDistribuidor, + IdEmp = idEmpresa, + FechaCorte = fechaCorte.Date, + Excluir = excluirIdCierre + }; + + if (transaction != null) + { + return await transaction.Connection!.ExecuteScalarAsync(sql, parameters, transaction); + } + + using var connection = _cf.CreateConnection(); + return await connection.ExecuteScalarAsync(sql, parameters); + } + + public async Task CreateAsync(CierreCuentaCorriente cierre, int idUsuarioMod, IDbTransaction transaction) + { + const string sqlInsertMaster = @" + INSERT INTO dbo.cue_CierresCuentaCorriente + (Id_Distribuidor, Id_Empresa, FechaCorte, FechaCierre, SaldoCierre, Estado, Justificacion, Id_Usuario_Cierre) + VALUES + (@IdDistribuidor, @IdEmpresa, @FechaCorte, @FechaCierre, @SaldoCierre, @Estado, @Justificacion, @IdUsuarioCierre); + SELECT CAST(SCOPE_IDENTITY() AS INT);"; + + var idCierre = await transaction.Connection!.ExecuteScalarAsync(sqlInsertMaster, new + { + cierre.IdDistribuidor, + cierre.IdEmpresa, + FechaCorte = cierre.FechaCorte.Date, + cierre.FechaCierre, + cierre.SaldoCierre, + cierre.Estado, + cierre.Justificacion, + cierre.IdUsuarioCierre + }, transaction); + + if (idCierre <= 0) throw new DataException("No se pudo crear el cierre — SCOPE_IDENTITY no devolvió valor."); + + const string sqlInsertHist = @" + INSERT INTO dbo.cue_CierresCuentaCorriente_H + (Id_Cierre, Id_Distribuidor, Id_Empresa, FechaCorte, FechaCierre, SaldoCierre, + Estado, Justificacion, Id_Usuario_Cierre, + Id_Usuario_Anula, FechaAnulacion, Justificacion_Anulacion, + TipoMod, Id_Usuario_Mod, FechaMod) + VALUES + (@IdCierre, @IdDistribuidor, @IdEmpresa, @FechaCorte, @FechaCierre, @SaldoCierre, + @Estado, @Justificacion, @IdUsuarioCierre, + NULL, NULL, NULL, + @TipoMod, @IdUsuarioMod, @FechaMod);"; + + await transaction.Connection!.ExecuteAsync(sqlInsertHist, new + { + IdCierre = idCierre, + cierre.IdDistribuidor, + cierre.IdEmpresa, + FechaCorte = cierre.FechaCorte.Date, + cierre.FechaCierre, + cierre.SaldoCierre, + cierre.Estado, + cierre.Justificacion, + cierre.IdUsuarioCierre, + TipoMod = "Creacion", + IdUsuarioMod = idUsuarioMod, + FechaMod = DateTime.Now + }, transaction); + + return idCierre; + } + + public async Task AnularAsync(int idCierre, int idUsuarioAnula, string justificacionAnulacion, int idUsuarioMod, IDbTransaction transaction) + { + // UPDATE atómico: solo cambia Estado si está actualmente Activo. Evita doble anulación concurrente. + const string sqlUpdate = @" + UPDATE dbo.cue_CierresCuentaCorriente + SET Estado = 'Anulado', + Id_Usuario_Anula = @IdUsuarioAnula, + FechaAnulacion = @FechaAnulacion, + Justificacion_Anulacion = @JustificacionAnulacion + WHERE Id_Cierre = @IdCierre + AND Estado = 'Activo';"; + + var fechaAnulacion = DateTime.Now; + + int affected = await transaction.Connection!.ExecuteAsync(sqlUpdate, new + { + IdCierre = idCierre, + IdUsuarioAnula = idUsuarioAnula, + FechaAnulacion = fechaAnulacion, + JustificacionAnulacion = justificacionAnulacion + }, transaction); + + if (affected != 1) return false; + + // Snapshot post-update para el _H. Trae los valores ya actualizados. + const string sqlSnapshot = SelectModelBase + " WHERE Id_Cierre = @IdCierre;"; + var actualizado = await transaction.Connection!.QuerySingleAsync(sqlSnapshot, new { IdCierre = idCierre }, transaction); + + const string sqlInsertHist = @" + INSERT INTO dbo.cue_CierresCuentaCorriente_H + (Id_Cierre, Id_Distribuidor, Id_Empresa, FechaCorte, FechaCierre, SaldoCierre, + Estado, Justificacion, Id_Usuario_Cierre, + Id_Usuario_Anula, FechaAnulacion, Justificacion_Anulacion, + TipoMod, Id_Usuario_Mod, FechaMod) + VALUES + (@IdCierre, @IdDistribuidor, @IdEmpresa, @FechaCorte, @FechaCierre, @SaldoCierre, + @Estado, @Justificacion, @IdUsuarioCierre, + @IdUsuarioAnula, @FechaAnulacion, @JustificacionAnulacion, + @TipoMod, @IdUsuarioMod, @FechaMod);"; + + await transaction.Connection!.ExecuteAsync(sqlInsertHist, new + { + IdCierre = actualizado.IdCierre, + actualizado.IdDistribuidor, + actualizado.IdEmpresa, + FechaCorte = actualizado.FechaCorte.Date, + actualizado.FechaCierre, + actualizado.SaldoCierre, + actualizado.Estado, + actualizado.Justificacion, + actualizado.IdUsuarioCierre, + actualizado.IdUsuarioAnula, + actualizado.FechaAnulacion, + JustificacionAnulacion = actualizado.JustificacionAnulacion, + TipoMod = "Reapertura", + IdUsuarioMod = idUsuarioMod, + FechaMod = DateTime.Now + }, transaction); + + return true; + } + + public async Task> GetAllAsync( + int? idDistribuidor, int? idEmpresa, string? estado, + DateTime? fechaCorteDesde, DateTime? fechaCorteHasta) + { + var sqlBuilder = new StringBuilder(@" + SELECT + c.Id_Cierre AS IdCierre, + c.Id_Distribuidor AS IdDistribuidor, + d.Nombre AS NombreDistribuidor, + c.Id_Empresa AS IdEmpresa, + e.Nombre AS NombreEmpresa, + CONVERT(varchar(10), c.FechaCorte, 23) AS FechaCorte, + c.FechaCierre, + c.SaldoCierre, + c.Estado, + c.Justificacion, + c.Id_Usuario_Cierre AS IdUsuarioCierre, + (uc.Nombre + ' ' + uc.Apellido) AS NombreUsuarioCierre, + c.Id_Usuario_Anula AS IdUsuarioAnula, + CASE WHEN ua.Id IS NULL THEN NULL ELSE (ua.Nombre + ' ' + ua.Apellido) END AS NombreUsuarioAnula, + c.FechaAnulacion, + c.Justificacion_Anulacion AS JustificacionAnulacion, + CAST(CASE WHEN c.Estado = 'Activo' + AND c.Id_Cierre = ( + SELECT TOP 1 c2.Id_Cierre FROM dbo.cue_CierresCuentaCorriente c2 + WHERE c2.Id_Distribuidor = c.Id_Distribuidor + AND c2.Id_Empresa = c.Id_Empresa + AND c2.Estado = 'Activo' + ORDER BY c2.FechaCorte DESC, c2.Id_Cierre DESC) + THEN 1 ELSE 0 END AS bit) AS EsUltimoVigente + FROM dbo.cue_CierresCuentaCorriente c + JOIN dbo.dist_dtDistribuidores d ON d.Id_Distribuidor = c.Id_Distribuidor + JOIN dbo.dist_dtEmpresas e ON e.Id_Empresa = c.Id_Empresa + JOIN dbo.gral_Usuarios uc ON uc.Id = c.Id_Usuario_Cierre + LEFT JOIN dbo.gral_Usuarios ua ON ua.Id = c.Id_Usuario_Anula + WHERE 1=1"); + + var parameters = new DynamicParameters(); + if (idDistribuidor.HasValue) { sqlBuilder.Append(" AND c.Id_Distribuidor = @IdDist"); parameters.Add("IdDist", idDistribuidor.Value); } + if (idEmpresa.HasValue) { sqlBuilder.Append(" AND c.Id_Empresa = @IdEmp"); parameters.Add("IdEmp", idEmpresa.Value); } + if (!string.IsNullOrWhiteSpace(estado)) { sqlBuilder.Append(" AND c.Estado = @Estado"); parameters.Add("Estado", estado); } + if (fechaCorteDesde.HasValue) { sqlBuilder.Append(" AND c.FechaCorte >= @FechaDesde"); parameters.Add("FechaDesde", fechaCorteDesde.Value.Date); } + if (fechaCorteHasta.HasValue) { sqlBuilder.Append(" AND c.FechaCorte <= @FechaHasta"); parameters.Add("FechaHasta", fechaCorteHasta.Value.Date); } + sqlBuilder.Append(" ORDER BY c.FechaCorte DESC, c.Id_Cierre DESC;"); + + try + { + using var connection = _cf.CreateConnection(); + return await connection.QueryAsync(sqlBuilder.ToString(), parameters); + } + catch (Exception ex) + { + _log.LogError(ex, "Error en GetAllAsync de CierresCuentaCorriente."); + return Enumerable.Empty(); + } + } + + public async Task> GetHistorialAsync(int idCierre) + { + const string sql = @" + SELECT + h.Id_Historial, + h.Id_Cierre, + h.Id_Distribuidor, + h.Id_Empresa, + h.FechaCorte, + h.FechaCierre, + h.SaldoCierre, + h.Estado, + h.Justificacion, + h.Id_Usuario_Cierre, + h.Id_Usuario_Anula, + h.FechaAnulacion, + h.Justificacion_Anulacion, + h.TipoMod, + h.Id_Usuario_Mod, + (u.Nombre + ' ' + u.Apellido) AS NombreUsuarioModifico, + h.FechaMod + FROM dbo.cue_CierresCuentaCorriente_H h + JOIN dbo.gral_Usuarios u ON u.Id = h.Id_Usuario_Mod + WHERE h.Id_Cierre = @IdCierre + ORDER BY h.FechaMod ASC, h.Id_Historial ASC;"; + + try + { + using var connection = _cf.CreateConnection(); + return await connection.QueryAsync(sql, new { IdCierre = idCierre }); + } + catch (Exception ex) + { + _log.LogError(ex, "Error en GetHistorialAsync para Cierre ID {IdCierre}", idCierre); + return Enumerable.Empty(); + } + } + + public async Task> ObtenerHistorialAsync( + DateTime? fechaDesde, DateTime? fechaHasta, + int? idUsuarioModifico, string? tipoModificacion, + int? idCierreAfectado) + { + // FechaMod cubre el rango inclusive: [fechaDesde 00:00, fechaHasta+1día). Patrón consistente con otros ObtenerHistorial del proyecto. + var sql = @" + SELECT + h.Id_Historial, + h.Id_Cierre, + h.Id_Distribuidor, + h.Id_Empresa, + h.FechaCorte, + h.FechaCierre, + h.SaldoCierre, + h.Estado, + h.Justificacion, + h.Id_Usuario_Cierre, + h.Id_Usuario_Anula, + h.FechaAnulacion, + h.Justificacion_Anulacion, + h.TipoMod, + h.Id_Usuario_Mod, + (u.Nombre + ' ' + u.Apellido) AS NombreUsuarioModifico, + h.FechaMod + FROM dbo.cue_CierresCuentaCorriente_H h + JOIN dbo.gral_Usuarios u ON u.Id = h.Id_Usuario_Mod + WHERE 1 = 1 + AND (@FechaDesde IS NULL OR h.FechaMod >= @FechaDesde) + AND (@FechaHasta IS NULL OR h.FechaMod < DATEADD(DAY, 1, @FechaHasta)) + AND (@IdUsuarioMod IS NULL OR h.Id_Usuario_Mod = @IdUsuarioMod) + AND (@TipoMod IS NULL OR h.TipoMod = @TipoMod) + AND (@IdCierre IS NULL OR h.Id_Cierre = @IdCierre) + ORDER BY h.FechaMod DESC, h.Id_Historial DESC;"; + + try + { + using var connection = _cf.CreateConnection(); + return await connection.QueryAsync(sql, new + { + FechaDesde = fechaDesde?.Date, + FechaHasta = fechaHasta?.Date, + IdUsuarioMod = idUsuarioModifico, + TipoMod = tipoModificacion, + IdCierre = idCierreAfectado + }); + } + catch (Exception ex) + { + _log.LogError(ex, "Error en ObtenerHistorialAsync (auditoría) de Cierres CC."); + return Enumerable.Empty(); + } + } + } +} diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Contables/ICierreCuentaCorrienteRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Contables/ICierreCuentaCorrienteRepository.cs new file mode 100644 index 0000000..3f32cb5 --- /dev/null +++ b/Backend/GestionIntegral.Api/Data/Repositories/Contables/ICierreCuentaCorrienteRepository.cs @@ -0,0 +1,45 @@ +using GestionIntegral.Api.Dtos.Auditoria; +using GestionIntegral.Api.Dtos.Contables; +using GestionIntegral.Api.Models.Contables; +using System; +using System.Collections.Generic; +using System.Data; +using System.Threading.Tasks; + +namespace GestionIntegral.Api.Data.Repositories.Contables +{ + public interface ICierreCuentaCorrienteRepository + { + Task GetByIdAsync(int idCierre); + + // Devuelve el último cierre con Estado = 'Activo' para el par (Distribuidor + Empresa). + // Acepta una transacción opcional para usarse dentro del flujo Crear/Reabrir. + Task GetUltimoCierreVigenteAsync(int idDistribuidor, int idEmpresa, IDbTransaction? transaction = null); + + // Verifica si la fecha cae dentro de un período cerrado: existe un cierre Activo con FechaCorte >= fechaOperacion. + Task GetCierreVigenteParaFechaAsync(int idDistribuidor, int idEmpresa, DateTime fechaOperacion); + + // True si existe otro cierre Activo con FechaCorte > fechaCorte (excluyendo opcionalmente un cierre puntual). + // Se usa al reabrir para forzar la cascada manual. + Task ExisteCierrePosteriorVigenteAsync(int idDistribuidor, int idEmpresa, DateTime fechaCorte, int? excluirIdCierre = null, IDbTransaction? transaction = null); + + // Crea la fila maestra y registra la entrada inicial en _H con TipoMod='Creacion'. Devuelve el Id_Cierre generado. + Task CreateAsync(CierreCuentaCorriente cierre, int idUsuarioMod, IDbTransaction transaction); + + // Marca un cierre como Anulado (UPDATE atómico solo si Estado='Activo') y registra entrada en _H con TipoMod='Reapertura'. + // Devuelve true si se anuló (Estado pasó de Activo a Anulado), false si ya estaba anulado o no existía. + Task AnularAsync(int idCierre, int idUsuarioAnula, string justificacionAnulacion, int idUsuarioMod, IDbTransaction transaction); + + Task> GetAllAsync( + int? idDistribuidor, int? idEmpresa, string? estado, + DateTime? fechaCorteDesde, DateTime? fechaCorteHasta); + + Task> GetHistorialAsync(int idCierre); + + // Auditoría general: filtra el historial cruzado de todos los cierres por rango de FechaMod, usuario, tipo, y opcional Id_Cierre. + Task> ObtenerHistorialAsync( + DateTime? fechaDesde, DateTime? fechaHasta, + int? idUsuarioModifico, string? tipoModificacion, + int? idCierreAfectado); + } +} diff --git a/Backend/GestionIntegral.Api/Middleware/ExceptionHandlerMiddleware.cs b/Backend/GestionIntegral.Api/Middleware/ExceptionHandlerMiddleware.cs new file mode 100644 index 0000000..6159065 --- /dev/null +++ b/Backend/GestionIntegral.Api/Middleware/ExceptionHandlerMiddleware.cs @@ -0,0 +1,73 @@ +using GestionIntegral.Api.Services.Contables; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using System; +using System.Text.Json; +using System.Threading.Tasks; + +namespace GestionIntegral.Api.Middleware +{ + // Centraliza el mapeo de excepciones semánticas a HTTP responses con cuerpo JSON estandarizado. + // Va PRIMERO en el pipeline para catchear cualquier excepción que escape de los controllers/services. + public class ExceptionHandlerMiddleware + { + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public ExceptionHandlerMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + try + { + await _next(context); + } + catch (BloqueoPorPeriodoCerradoException ex) + { + _logger.LogWarning( + "Bloqueo por período cerrado: cierre #{IdCierre} FechaCorte={FechaCorte:yyyy-MM-dd}. Path={Path}", + ex.IdCierre, ex.FechaCorte, context.Request.Path); + + await WriteJsonAsync(context, StatusCodes.Status409Conflict, new + { + codigo = "PERIODO_CERRADO_BLOQUEO_OPERACION", + mensaje = ex.Message, + idCierre = ex.IdCierre, + fechaCorte = ex.FechaCorte.ToString("yyyy-MM-dd") + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Excepción no manejada. Path={Path}", context.Request.Path); + + await WriteJsonAsync(context, StatusCodes.Status500InternalServerError, new + { + codigo = "ERROR_INTERNO", + mensaje = "Ocurrió un error inesperado al procesar la solicitud." + }); + } + } + + private static Task WriteJsonAsync(HttpContext context, int statusCode, object body) + { + if (context.Response.HasStarted) + { + // Si los headers ya se enviaron no podemos re-escribir el response. Solo loguear y salir. + return Task.CompletedTask; + } + context.Response.Clear(); + context.Response.StatusCode = statusCode; + context.Response.ContentType = "application/json; charset=utf-8"; + return context.Response.WriteAsync(JsonSerializer.Serialize(body, JsonOptions)); + } + } +} diff --git a/Backend/GestionIntegral.Api/Models/Contables/CierreCuentaCorriente.cs b/Backend/GestionIntegral.Api/Models/Contables/CierreCuentaCorriente.cs new file mode 100644 index 0000000..325b6b3 --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Contables/CierreCuentaCorriente.cs @@ -0,0 +1,19 @@ +using System; +namespace GestionIntegral.Api.Models.Contables +{ + public class CierreCuentaCorriente // Corresponde a cue_CierresCuentaCorriente + { + public int IdCierre { get; set; } + public int IdDistribuidor { get; set; } + public int IdEmpresa { get; set; } + public DateTime FechaCorte { get; set; } + public DateTime FechaCierre { get; set; } + public decimal SaldoCierre { get; set; } // money en SQL, decimal en C# + public string Estado { get; set; } = "Activo"; // 'Activo' | 'Anulado' + public string? Justificacion { get; set; } + public int IdUsuarioCierre { get; set; } + public int? IdUsuarioAnula { get; set; } + public DateTime? FechaAnulacion { get; set; } + public string? JustificacionAnulacion { get; set; } + } +} diff --git a/Backend/GestionIntegral.Api/Models/Contables/CierreCuentaCorrienteHistorico.cs b/Backend/GestionIntegral.Api/Models/Contables/CierreCuentaCorrienteHistorico.cs new file mode 100644 index 0000000..89d1796 --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Contables/CierreCuentaCorrienteHistorico.cs @@ -0,0 +1,23 @@ +using System; +namespace GestionIntegral.Api.Models.Contables +{ + public class CierreCuentaCorrienteHistorico // Corresponde a cue_CierresCuentaCorriente_H + { + public int Id_Historial { get; set; } + public int Id_Cierre { get; set; } + public int Id_Distribuidor { get; set; } + public int Id_Empresa { get; set; } + public DateTime FechaCorte { get; set; } + public DateTime FechaCierre { get; set; } + public decimal SaldoCierre { get; set; } + public string Estado { get; set; } = string.Empty; + public string? Justificacion { get; set; } + public int Id_Usuario_Cierre { get; set; } + public int? Id_Usuario_Anula { get; set; } + public DateTime? FechaAnulacion { get; set; } + public string? Justificacion_Anulacion { get; set; } + public string TipoMod { get; set; } = string.Empty; // 'Creacion' | 'Reapertura' | 'Modificacion' + public int Id_Usuario_Mod { get; set; } + public DateTime FechaMod { get; set; } + } +} diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Auditoria/CierreCuentaCorrienteHistorialDto.cs b/Backend/GestionIntegral.Api/Models/Dtos/Auditoria/CierreCuentaCorrienteHistorialDto.cs new file mode 100644 index 0000000..bb18a46 --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Dtos/Auditoria/CierreCuentaCorrienteHistorialDto.cs @@ -0,0 +1,26 @@ +using System; + +namespace GestionIntegral.Api.Dtos.Auditoria +{ + public class CierreCuentaCorrienteHistorialDto + { + public int Id_Historial { get; set; } + public int Id_Cierre { get; set; } + public int Id_Distribuidor { get; set; } + public int Id_Empresa { get; set; } + public DateTime FechaCorte { get; set; } + public DateTime FechaCierre { get; set; } + public decimal SaldoCierre { get; set; } + public string Estado { get; set; } = string.Empty; + public string? Justificacion { get; set; } + public int Id_Usuario_Cierre { get; set; } + public int? Id_Usuario_Anula { get; set; } + public DateTime? FechaAnulacion { get; set; } + public string? Justificacion_Anulacion { get; set; } + + public string TipoMod { get; set; } = string.Empty; // 'Creacion' | 'Reapertura' | 'Modificacion' + public int Id_Usuario_Mod { get; set; } + public string NombreUsuarioModifico { get; set; } = string.Empty; + public DateTime FechaMod { get; set; } + } +} diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Contables/AjusteSaldoRequestDto.cs b/Backend/GestionIntegral.Api/Models/Dtos/Contables/AjusteSaldoRequestDto.cs index 13ed5b5..abd2fbf 100644 --- a/Backend/GestionIntegral.Api/Models/Dtos/Contables/AjusteSaldoRequestDto.cs +++ b/Backend/GestionIntegral.Api/Models/Dtos/Contables/AjusteSaldoRequestDto.cs @@ -1,3 +1,4 @@ +using System; using System.ComponentModel.DataAnnotations; namespace GestionIntegral.Api.Dtos.Contables @@ -19,10 +20,16 @@ namespace GestionIntegral.Api.Dtos.Contables [Required(ErrorMessage = "El monto del ajuste es obligatorio.")] // Permitir montos negativos para disminuir deuda o positivos para aumentarla // No se usa Range aquí para permitir ambos signos. La validación de que no sea cero se puede hacer en el servicio. - public decimal MontoAjuste { get; set; } + public decimal MontoAjuste { get; set; } [Required(ErrorMessage = "La justificación del ajuste es obligatoria.")] [StringLength(250, MinimumLength = 5, ErrorMessage = "La justificación debe tener entre 5 y 250 caracteres.")] public string Justificacion { get; set; } = string.Empty; + + // Fecha lógica de la operación. Se valida contra el último cierre vigente + // del par (Distribuidor + Empresa) para bloquear ajustes en períodos cerrados. + // Distinta de FechaAjuste, que es el momento de ejecución del ajuste en el sistema. + [Required(ErrorMessage = "La fecha de operación es obligatoria.")] + public DateTime FechaOperacion { get; set; } } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Contables/CierreCuentaCorrienteDto.cs b/Backend/GestionIntegral.Api/Models/Dtos/Contables/CierreCuentaCorrienteDto.cs new file mode 100644 index 0000000..e2e5b15 --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Dtos/Contables/CierreCuentaCorrienteDto.cs @@ -0,0 +1,25 @@ +using System; + +namespace GestionIntegral.Api.Dtos.Contables +{ + public class CierreCuentaCorrienteDto + { + public int IdCierre { get; set; } + public int IdDistribuidor { get; set; } + public string NombreDistribuidor { get; set; } = string.Empty; + public int IdEmpresa { get; set; } + public string NombreEmpresa { get; set; } = string.Empty; + public string FechaCorte { get; set; } = string.Empty; // yyyy-MM-dd + public DateTime FechaCierre { get; set; } + public decimal SaldoCierre { get; set; } + public string Estado { get; set; } = string.Empty; + public string? Justificacion { get; set; } + public int IdUsuarioCierre { get; set; } + public string NombreUsuarioCierre { get; set; } = string.Empty; + public int? IdUsuarioAnula { get; set; } + public string? NombreUsuarioAnula { get; set; } + public DateTime? FechaAnulacion { get; set; } + public string? JustificacionAnulacion { get; set; } + public bool EsUltimoVigente { get; set; } + } +} diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Contables/CrearCierreDto.cs b/Backend/GestionIntegral.Api/Models/Dtos/Contables/CrearCierreDto.cs new file mode 100644 index 0000000..6e04343 --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Dtos/Contables/CrearCierreDto.cs @@ -0,0 +1,22 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace GestionIntegral.Api.Dtos.Contables +{ + public class CrearCierreDto + { + [Required(ErrorMessage = "El distribuidor es obligatorio.")] + [Range(1, int.MaxValue, ErrorMessage = "ID de Distribuidor inválido.")] + public int IdDistribuidor { get; set; } + + [Required(ErrorMessage = "La empresa es obligatoria.")] + [Range(1, int.MaxValue, ErrorMessage = "ID de Empresa inválido.")] + public int IdEmpresa { get; set; } + + [Required(ErrorMessage = "La fecha de corte es obligatoria.")] + public DateTime FechaCorte { get; set; } + + [StringLength(500, ErrorMessage = "La justificación no puede superar los 500 caracteres.")] + public string? Justificacion { get; set; } + } +} diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Contables/ReabrirCierreDto.cs b/Backend/GestionIntegral.Api/Models/Dtos/Contables/ReabrirCierreDto.cs new file mode 100644 index 0000000..0c5816c --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Dtos/Contables/ReabrirCierreDto.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace GestionIntegral.Api.Dtos.Contables +{ + public class ReabrirCierreDto + { + [Required(ErrorMessage = "La justificación es obligatoria al reabrir un cierre.")] + [StringLength(500, MinimumLength = 10, ErrorMessage = "La justificación debe tener entre 10 y 500 caracteres.")] + public string Justificacion { get; set; } = string.Empty; + } +} diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Contables/UltimoCierreDto.cs b/Backend/GestionIntegral.Api/Models/Dtos/Contables/UltimoCierreDto.cs new file mode 100644 index 0000000..de66120 --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Dtos/Contables/UltimoCierreDto.cs @@ -0,0 +1,12 @@ +using System; + +namespace GestionIntegral.Api.Dtos.Contables +{ + public class UltimoCierreDto + { + public int IdCierre { get; set; } + public string FechaCorte { get; set; } = string.Empty; // yyyy-MM-dd + public decimal SaldoCierre { get; set; } + public string Estado { get; set; } = string.Empty; + } +} diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Reportes/ReporteCuentasDistribuidorResponseDto.cs b/Backend/GestionIntegral.Api/Models/Dtos/Reportes/ReporteCuentasDistribuidorResponseDto.cs index 6b67a41..803d5c5 100644 --- a/Backend/GestionIntegral.Api/Models/Dtos/Reportes/ReporteCuentasDistribuidorResponseDto.cs +++ b/Backend/GestionIntegral.Api/Models/Dtos/Reportes/ReporteCuentasDistribuidorResponseDto.cs @@ -7,8 +7,12 @@ namespace GestionIntegral.Api.Dtos.Reportes public IEnumerable EntradasSalidas { get; set; } = new List(); public IEnumerable DebitosCreditos { get; set; } = new List(); public IEnumerable Pagos { get; set; } = new List(); - public IEnumerable Saldos { get; set; } = new List(); // O podría ser SaldoDto SaldoActual si siempre es uno - public string? NombreDistribuidor { get; set; } // Para el título del reporte - public string? NombreEmpresa { get; set; } // Para el título del reporte + public string? NombreDistribuidor { get; set; } + public string? NombreEmpresa { get; set; } + + // Saldo a la fecha desde elegida en el filtro: + // - Si existe cierre con FechaCorte < FechaDesde: SaldoCierre + movimientos netos entre fechaCierre+1 y fechaDesde-1. + // - Sin cierres previos: 0. + public decimal SaldoInicial { get; set; } } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Reportes/ViewModels/CuentasDistribuidorViewModel.cs b/Backend/GestionIntegral.Api/Models/Dtos/Reportes/ViewModels/CuentasDistribuidorViewModel.cs index 1eaa0aa..02c3ab7 100644 --- a/Backend/GestionIntegral.Api/Models/Dtos/Reportes/ViewModels/CuentasDistribuidorViewModel.cs +++ b/Backend/GestionIntegral.Api/Models/Dtos/Reportes/ViewModels/CuentasDistribuidorViewModel.cs @@ -10,9 +10,10 @@ namespace GestionIntegral.Api.Dtos.Reportes.ViewModels public IEnumerable Movimientos { get; set; } = new List(); public IEnumerable Pagos { get; set; } = new List(); public IEnumerable DebitosCreditos { get; set; } = new List(); - - // Saldo real de la cuenta, se muestra al final sin usarse en cálculos intermedios. - public decimal SaldoDeCuenta { get; set; } + + // Saldo inicial del período: snapshot del último cierre + movimientos netos hasta fechaDesde. + // 0 si no hay cierre previo. + public decimal SaldoInicial { get; set; } // --- Parámetros del reporte --- public string NombreDistribuidor { get; set; } = string.Empty; @@ -24,6 +25,8 @@ namespace GestionIntegral.Api.Dtos.Reportes.ViewModels public decimal TotalMovimientos => Movimientos.Sum(m => m.Debe - m.Haber); public decimal TotalPagos => Pagos.Sum(p => p.Debe - p.Haber); public decimal TotalDebitosCreditos => DebitosCreditos.Sum(d => d.Debe - d.Haber); - public decimal TotalPeriodo => TotalMovimientos + TotalPagos + TotalDebitosCreditos; + + // Saldo Final = Saldo Inicial + suma neta del período (Debe - Haber por sección). + public decimal SaldoFinal => SaldoInicial + TotalMovimientos + TotalPagos + TotalDebitosCreditos; } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Program.cs b/Backend/GestionIntegral.Api/Program.cs index 064ad81..b75d1ac 100644 --- a/Backend/GestionIntegral.Api/Program.cs +++ b/Backend/GestionIntegral.Api/Program.cs @@ -23,6 +23,7 @@ using GestionIntegral.Api.Services.Suscripciones; using GestionIntegral.Api.Models.Comunicaciones; using GestionIntegral.Api.Services.Comunicaciones; using GestionIntegral.Api.Data.Repositories.Comunicaciones; +using GestionIntegral.Api.Middleware; var builder = WebApplication.CreateBuilder(args); @@ -96,6 +97,12 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); // Servicio de Saldos builder.Services.AddScoped(); +// Cierre de Cuenta Corriente de Distribuidor +builder.Services.AddMemoryCache(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +// Validador de período cerrado: SINGLETON porque mantiene cache en memoria de IMemoryCache que debe ser compartido entre requests. +builder.Services.AddSingleton(); // Repositorios de Reportes builder.Services.AddScoped(); // Servicios de Reportes @@ -269,6 +276,10 @@ if (app.Environment.IsDevelopment()) // Comenta o elimina la siguiente línea si SÓLO usas http://localhost:5183 //app.UseHttpsRedirection(); +// Middleware global de excepciones — debe ir TEMPRANO en el pipeline para catchear cualquier excepción +// que escape de los controllers/services. Mapea BloqueoPorPeriodoCerradoException → 409 con cuerpo JSON estandarizado. +app.UseMiddleware(); + app.UseCors(MyAllowSpecificOrigins); app.UseAuthentication(); // Debe ir ANTES de UseAuthorization diff --git a/Backend/GestionIntegral.Api/Services/Contables/BloqueoPorPeriodoCerradoException.cs b/Backend/GestionIntegral.Api/Services/Contables/BloqueoPorPeriodoCerradoException.cs new file mode 100644 index 0000000..039599c --- /dev/null +++ b/Backend/GestionIntegral.Api/Services/Contables/BloqueoPorPeriodoCerradoException.cs @@ -0,0 +1,20 @@ +using System; + +namespace GestionIntegral.Api.Services.Contables +{ + // Se lanza cuando un C/U/D intenta afectar la cuenta corriente de un distribuidor en una fecha + // que cae dentro de un período cerrado (cue_CierresCuentaCorriente con Estado='Activo'). + // Mapea al código de error PERIODO_CERRADO_BLOQUEO_OPERACION (HTTP 409). + public class BloqueoPorPeriodoCerradoException : Exception + { + public int IdCierre { get; } + public DateTime FechaCorte { get; } + + public BloqueoPorPeriodoCerradoException(int idCierre, DateTime fechaCorte) + : base($"El período está cerrado al {fechaCorte:dd/MM/yyyy} (cierre #{idCierre}). No se permiten modificaciones sobre fechas anteriores o iguales a la fecha de corte.") + { + IdCierre = idCierre; + FechaCorte = fechaCorte; + } + } +} diff --git a/Backend/GestionIntegral.Api/Services/Contables/CierreCuentaCorrienteService.cs b/Backend/GestionIntegral.Api/Services/Contables/CierreCuentaCorrienteService.cs new file mode 100644 index 0000000..e33cdd8 --- /dev/null +++ b/Backend/GestionIntegral.Api/Services/Contables/CierreCuentaCorrienteService.cs @@ -0,0 +1,351 @@ +using Dapper; +using GestionIntegral.Api.Data; +using GestionIntegral.Api.Data.Repositories.Contables; +using GestionIntegral.Api.Data.Repositories.Distribucion; +using GestionIntegral.Api.Data.Repositories.Reportes; +using GestionIntegral.Api.Dtos.Auditoria; +using GestionIntegral.Api.Dtos.Contables; +using GestionIntegral.Api.Models.Contables; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Threading.Tasks; + +namespace GestionIntegral.Api.Services.Contables +{ + public class CierreCuentaCorrienteService : ICierreCuentaCorrienteService + { + private readonly ICierreCuentaCorrienteRepository _cierreRepo; + private readonly IDistribuidorRepository _distRepo; + private readonly IEmpresaRepository _empresaRepo; + private readonly IPeriodoCerradoValidator _validator; + private readonly IReportesRepository _reportesRepo; + private readonly DbConnectionFactory _cf; + private readonly ILogger _logger; + + // Retry de deadlocks (SqlException.Number == 1205) para la transacción Serializable de Crear cierre. + private static readonly int[] DeadlockBackoffMs = { 50, 150, 400 }; + + // Sentinela para "sin límite inferior" en el cálculo histórico — anterior a cualquier dato real. + private static readonly DateTime InicioHistoria = new DateTime(1900, 1, 1); + + public CierreCuentaCorrienteService( + ICierreCuentaCorrienteRepository cierreRepo, + IDistribuidorRepository distRepo, + IEmpresaRepository empresaRepo, + IPeriodoCerradoValidator validator, + IReportesRepository reportesRepo, + DbConnectionFactory cf, + ILogger logger) + { + _cierreRepo = cierreRepo; + _distRepo = distRepo; + _empresaRepo = empresaRepo; + _validator = validator; + _reportesRepo = reportesRepo; + _cf = cf; + _logger = logger; + } + + public async Task<(CierreCuentaCorrienteDto? Cierre, string? ErrorCode, string? ErrorMessage)> CrearCierreAsync(CrearCierreDto dto, int idUsuario) + { + // Validaciones pre-transacción (no requieren lock). + if (dto.FechaCorte.Date > DateTime.Today) + return (null, "CIERRE_FECHA_FUTURA", "La fecha de corte no puede ser futura."); + + var distribuidor = await _distRepo.GetByIdSimpleAsync(dto.IdDistribuidor); + if (distribuidor == null) + return (null, "CIERRE_DISTRIBUIDOR_BAJA", "El distribuidor especificado no existe."); + if (distribuidor.Baja) + return (null, "CIERRE_DISTRIBUIDOR_BAJA", "El distribuidor está dado de baja."); + + if (await _empresaRepo.GetByIdAsync(dto.IdEmpresa) == null) + return (null, "CIERRE_EMPRESA_INEXISTENTE", "La empresa especificada no existe."); + + // Retry loop ante deadlock 1205. Los range locks de Serializable sobre las queries de movimientos + // pueden chocar con transacciones concurrentes que insertan pagos/notas/ajustes/movimientos. + for (int attempt = 0; ; attempt++) + { + try + { + return await CrearCierreInternalAsync(dto, idUsuario); + } + catch (SqlException ex) when (ex.Number == 1205 && attempt < DeadlockBackoffMs.Length) + { + _logger.LogWarning("Deadlock 1205 al crear cierre Dist={IdDist} Emp={IdEmp}, intento {Attempt}. Reintentando.", dto.IdDistribuidor, dto.IdEmpresa, attempt + 1); + await Task.Delay(DeadlockBackoffMs[attempt]); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error inesperado al crear cierre Dist={IdDist} Emp={IdEmp}.", dto.IdDistribuidor, dto.IdEmpresa); + return (null, "CIERRE_ERROR_INTERNO", $"Error interno: {ex.Message}"); + } + } + } + + private async Task<(CierreCuentaCorrienteDto? Cierre, string? ErrorCode, string? ErrorMessage)> CrearCierreInternalAsync(CrearCierreDto dto, int idUsuario) + { + using var connection = _cf.CreateConnection(); + if (connection.State != ConnectionState.Open) + { + if (connection is System.Data.Common.DbConnection dbConn) await dbConn.OpenAsync(); else connection.Open(); + } + + using var transaction = connection.BeginTransaction(IsolationLevel.Serializable); + try + { + // 1. Lookup último cierre dentro de la transacción Serializable. + var ultimo = await _cierreRepo.GetUltimoCierreVigenteAsync(dto.IdDistribuidor, dto.IdEmpresa, transaction); + if (ultimo != null && dto.FechaCorte.Date <= ultimo.FechaCorte.Date) + { + transaction.Rollback(); + return (null, "CIERRE_FECHA_ANTERIOR_A_ULTIMO", + $"Existe un cierre vigente al {ultimo.FechaCorte:dd/MM/yyyy}. La fecha de corte debe ser posterior."); + } + + // 2. Calcular SaldoCierre por suma de movimientos (la tabla cue_Saldos NO se consulta — queda paralela + // para conciliación y su lógica original es independiente). + // Si hay cierre anterior: desde (ultimo.FechaCorte + 1 día) hasta dto.FechaCorte INCLUSIVE. + // Si no hay cierre anterior: desde InicioHistoria hasta dto.FechaCorte INCLUSIVE (histórico completo). + DateTime desde = ultimo != null ? ultimo.FechaCorte.Date.AddDays(1) : InicioHistoria; + DateTime hasta = dto.FechaCorte.Date; + + decimal sumaMovimientos = await CalcularSaldoEntreFechasAsync( + dto.IdDistribuidor, dto.IdEmpresa, desde, hasta, connection, transaction); + + decimal saldoBase = ultimo?.SaldoCierre ?? 0m; + decimal saldoCierre = saldoBase + sumaMovimientos; + + // 3. Crear el cierre + entry _H ('Creacion') dentro de la misma transacción. + var nuevo = new CierreCuentaCorriente + { + IdDistribuidor = dto.IdDistribuidor, + IdEmpresa = dto.IdEmpresa, + FechaCorte = dto.FechaCorte.Date, + FechaCierre = DateTime.Now, + SaldoCierre = saldoCierre, + Estado = "Activo", + Justificacion = dto.Justificacion, + IdUsuarioCierre = idUsuario + }; + + int idCierre = await _cierreRepo.CreateAsync(nuevo, idUsuario, transaction); + + transaction.Commit(); + _logger.LogInformation("Cierre #{Id} creado Dist={IdDist} Emp={IdEmp} FechaCorte={FechaCorte} Saldo={Saldo} (base={Base}, suma={Suma})", + idCierre, dto.IdDistribuidor, dto.IdEmpresa, dto.FechaCorte, saldoCierre, saldoBase, sumaMovimientos); + + // 4. Invalidar cache del validador post-commit. + _validator.InvalidarCache(dto.IdDistribuidor, dto.IdEmpresa); + + // 5. Mapear DTO completo (con nombres) para la respuesta. + var dtoCreado = (await _cierreRepo.GetAllAsync(dto.IdDistribuidor, dto.IdEmpresa, null, null, null)) + .FirstOrDefault(c => c.IdCierre == idCierre); + + return (dtoCreado, null, null); + } + catch + { + try { transaction.Rollback(); } catch (Exception rbEx) { _logger.LogError(rbEx, "Error en Rollback de CrearCierreInternalAsync."); } + throw; + } + finally + { + if (connection.State == ConnectionState.Open) + { + if (connection is System.Data.Common.DbConnection dbConn) await dbConn.CloseAsync(); else connection.Close(); + } + } + } + + public async Task<(CierreCuentaCorrienteDto? Cierre, string? ErrorCode, string? ErrorMessage)> ReabrirCierreAsync(int idCierre, ReabrirCierreDto dto, int idUsuario, bool esSuperAdmin) + { + if (!esSuperAdmin) + return (null, "CIERRE_PERMISO_DENEGADO", "Sólo SuperAdmin puede reabrir cierres."); + + // Re-validar justificación a nivel service (defensa en profundidad — el DTO ya tiene DataAnnotations). + if (string.IsNullOrWhiteSpace(dto.Justificacion) || dto.Justificacion.Trim().Length < 10) + return (null, "CIERRE_JUSTIFICACION_OBLIGATORIA", "La justificación es obligatoria y debe tener al menos 10 caracteres."); + + using var connection = _cf.CreateConnection(); + if (connection.State != ConnectionState.Open) + { + if (connection is System.Data.Common.DbConnection dbConn) await dbConn.OpenAsync(); else connection.Open(); + } + + using var transaction = connection.BeginTransaction(IsolationLevel.Serializable); + try + { + var cierre = await _cierreRepo.GetByIdAsync(idCierre); + if (cierre == null) + { + transaction.Rollback(); + return (null, "CIERRE_NO_ENCONTRADO", "El cierre solicitado no existe."); + } + + if (cierre.Estado != "Activo") + { + transaction.Rollback(); + return (null, "CIERRE_YA_ANULADO", "El cierre ya está anulado."); + } + + bool hayPosterior = await _cierreRepo.ExisteCierrePosteriorVigenteAsync( + cierre.IdDistribuidor, cierre.IdEmpresa, cierre.FechaCorte, idCierre, transaction); + if (hayPosterior) + { + transaction.Rollback(); + return (null, "CIERRE_HAY_POSTERIORES_VIGENTES", + "Existe un cierre posterior vigente. Reabrir primero los cierres posteriores en cascada manual."); + } + + bool anulado = await _cierreRepo.AnularAsync(idCierre, idUsuario, dto.Justificacion.Trim(), idUsuario, transaction); + if (!anulado) + { + transaction.Rollback(); + // Carrera concurrente: alguien lo anuló entre el GetByIdAsync y el UPDATE atómico. + return (null, "CIERRE_YA_ANULADO", "El cierre fue anulado concurrentemente por otra operación."); + } + + transaction.Commit(); + _logger.LogInformation("Cierre #{Id} reabierto (anulado) por Usuario {IdUsuario}.", idCierre, idUsuario); + + _validator.InvalidarCache(cierre.IdDistribuidor, cierre.IdEmpresa); + + var dtoActualizado = (await _cierreRepo.GetAllAsync(cierre.IdDistribuidor, cierre.IdEmpresa, null, null, null)) + .FirstOrDefault(c => c.IdCierre == idCierre); + return (dtoActualizado, null, null); + } + catch (Exception ex) + { + try { transaction.Rollback(); } catch (Exception rbEx) { _logger.LogError(rbEx, "Error en Rollback de ReabrirCierreAsync."); } + _logger.LogError(ex, "Error inesperado al reabrir cierre #{Id}.", idCierre); + return (null, "CIERRE_ERROR_INTERNO", $"Error interno: {ex.Message}"); + } + finally + { + if (connection.State == ConnectionState.Open) + { + if (connection is System.Data.Common.DbConnection dbConn) await dbConn.CloseAsync(); else connection.Close(); + } + } + } + + public async Task CalcularSaldoInicialReporteAsync(int idDistribuidor, int idEmpresa, DateTime fechaDesde) + { + // 1. Buscar último cierre vigente con FechaCorte ESTRICTAMENTE menor a fechaDesde. + // Si no hay cierre previo, saldoInicial = 0 (comportamiento "como antes" del reporte). + const string sqlUltimoCierre = @" + SELECT TOP 1 FechaCorte, SaldoCierre + FROM dbo.cue_CierresCuentaCorriente + WHERE Id_Distribuidor = @IdDist + AND Id_Empresa = @IdEmp + AND Estado = 'Activo' + AND FechaCorte < @FechaDesde + ORDER BY FechaCorte DESC, Id_Cierre DESC;"; + + using var connection = _cf.CreateConnection(); + var ultimo = await connection.QuerySingleOrDefaultAsync<(DateTime FechaCorte, decimal SaldoCierre)?>( + sqlUltimoCierre, + new { IdDist = idDistribuidor, IdEmp = idEmpresa, FechaDesde = fechaDesde.Date }); + + if (ultimo == null) return 0m; + + // 2. Sumar movimientos netos entre (fechaCorte + 1 día) y (fechaDesde - 1 día) — INCLUSIVE en ambos extremos. + // El SaldoCierre ya incluye los movimientos hasta fechaCorte, así que arrancamos al día siguiente. + DateTime desdeMov = ultimo.Value.FechaCorte.Date.AddDays(1); + DateTime hastaMov = fechaDesde.Date.AddDays(-1); + + decimal sumaMovs = await CalcularSaldoEntreFechasAsync( + idDistribuidor, idEmpresa, desdeMov, hastaMov, connection, null); + + return ultimo.Value.SaldoCierre + sumaMovs; + } + + // Helper unificado de suma neta de movimientos en rango [desde, hasta] (ambos INCLUSIVE). + // Si se pasa una transacción activa, las queries corren bajo ese contexto y los range locks + // de Serializable cubren la consistencia con respecto a inserts concurrentes en las tablas de movimientos. + private async Task CalcularSaldoEntreFechasAsync( + int idDistribuidor, int idEmpresa, + DateTime desde, DateTime hasta, + IDbConnection connection, + IDbTransaction? transaction) + { + if (desde > hasta) return 0m; + + // Pagos + Notas C/D destino Distribuidores + Ajustes manuales destino Distribuidores en una sola query. + const string sqlSumaTransaccionales = @" + SELECT ISNULL(SUM(MontoNeto), 0) AS Total + FROM ( + SELECT CASE WHEN TipoMovimiento = 'Recibido' THEN -CAST(Monto AS DECIMAL(18,4)) ELSE CAST(Monto AS DECIMAL(18,4)) END AS MontoNeto + FROM dbo.cue_PagosDistribuidor + WHERE Id_Distribuidor = @IdDist AND Id_Empresa = @IdEmp + AND Fecha >= @Desde AND Fecha <= @Hasta + + UNION ALL + + SELECT CASE WHEN Tipo = 'Credito' THEN -CAST(Monto AS DECIMAL(18,4)) ELSE CAST(Monto AS DECIMAL(18,4)) END + FROM dbo.cue_CreditosDebitos + WHERE Destino = 'Distribuidores' AND Id_Destino = @IdDist AND Id_Empresa = @IdEmp + AND Fecha >= @Desde AND Fecha <= @Hasta + + UNION ALL + + SELECT CAST(MontoAjuste AS DECIMAL(18,4)) + FROM dbo.cue_SaldoAjustesHistorial + WHERE Destino = 'Distribuidores' AND Id_Destino = @IdDist AND Id_Empresa = @IdEmp + AND FechaOperacion >= @Desde AND FechaOperacion <= @Hasta + ) m;"; + + decimal sumaTransaccional = await connection.ExecuteScalarAsync( + sqlSumaTransaccionales, + new { IdDist = idDistribuidor, IdEmp = idEmpresa, Desde = desde, Hasta = hasta }, + transaction); + + // EntradasSalidas: SP existente (single source of truth de la fórmula CalcularMontoMovimiento que es private en EntradaSalidaDistService). + var movimientosES = await _reportesRepo.GetBalanceCuentaDistEntradaSalidaPorEmpresaAsync( + idDistribuidor, idEmpresa, desde, hasta); + + decimal sumaEntradasSalidas = movimientosES?.Sum(m => m.Debe - m.Haber) ?? 0m; + + return sumaTransaccional + sumaEntradasSalidas; + } + + public async Task GetByIdAsync(int idCierre) + { + var cierre = await _cierreRepo.GetByIdAsync(idCierre); + if (cierre == null) return null; + return (await _cierreRepo.GetAllAsync(cierre.IdDistribuidor, cierre.IdEmpresa, null, null, null)) + .FirstOrDefault(c => c.IdCierre == idCierre); + } + + public async Task GetUltimoVigenteAsync(int idDistribuidor, int idEmpresa) + { + var ultimo = await _cierreRepo.GetUltimoCierreVigenteAsync(idDistribuidor, idEmpresa); + if (ultimo == null) return null; + return new UltimoCierreDto + { + IdCierre = ultimo.IdCierre, + FechaCorte = ultimo.FechaCorte.ToString("yyyy-MM-dd"), + SaldoCierre = ultimo.SaldoCierre, + Estado = ultimo.Estado + }; + } + + public Task> GetAllAsync( + int? idDistribuidor, int? idEmpresa, string? estado, + DateTime? fechaCorteDesde, DateTime? fechaCorteHasta) + => _cierreRepo.GetAllAsync(idDistribuidor, idEmpresa, estado, fechaCorteDesde, fechaCorteHasta); + + public Task> GetHistorialAsync(int idCierre) + => _cierreRepo.GetHistorialAsync(idCierre); + + public Task> ObtenerHistorialAsync( + DateTime? fechaDesde, DateTime? fechaHasta, + int? idUsuarioModifico, string? tipoModificacion, + int? idCierreAfectado) + => _cierreRepo.ObtenerHistorialAsync(fechaDesde, fechaHasta, idUsuarioModifico, tipoModificacion, idCierreAfectado); + } +} diff --git a/Backend/GestionIntegral.Api/Services/Contables/ICierreCuentaCorrienteService.cs b/Backend/GestionIntegral.Api/Services/Contables/ICierreCuentaCorrienteService.cs new file mode 100644 index 0000000..7805d20 --- /dev/null +++ b/Backend/GestionIntegral.Api/Services/Contables/ICierreCuentaCorrienteService.cs @@ -0,0 +1,36 @@ +using GestionIntegral.Api.Dtos.Auditoria; +using GestionIntegral.Api.Dtos.Contables; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace GestionIntegral.Api.Services.Contables +{ + public interface ICierreCuentaCorrienteService + { + // Devuelve (Cierre, ErrorCode, ErrorMessage). + // ErrorCode = código semántico (CIERRE_FECHA_FUTURA, CIERRE_FECHA_ANTERIOR_A_ULTIMO, CIERRE_DISTRIBUIDOR_BAJA, CIERRE_EMPRESA_INEXISTENTE, CIERRE_ERROR_INTERNO). + Task<(CierreCuentaCorrienteDto? Cierre, string? ErrorCode, string? ErrorMessage)> CrearCierreAsync(CrearCierreDto dto, int idUsuario); + + // ErrorCode = CIERRE_NO_ENCONTRADO, CIERRE_YA_ANULADO, CIERRE_HAY_POSTERIORES_VIGENTES, CIERRE_PERMISO_DENEGADO, CIERRE_ERROR_INTERNO. + Task<(CierreCuentaCorrienteDto? Cierre, string? ErrorCode, string? ErrorMessage)> ReabrirCierreAsync(int idCierre, ReabrirCierreDto dto, int idUsuario, bool esSuperAdmin); + + // Saldo inicial del reporte de cuenta corriente para el filtro fechaDesde: + // - Sin cierre previo: 0. + // - Con cierre con FechaCorte < fechaDesde: SaldoCierre + sumaNeta(fechaCierre+1 .. fechaDesde-1) sobre 4 fuentes. + Task CalcularSaldoInicialReporteAsync(int idDistribuidor, int idEmpresa, DateTime fechaDesde); + + Task GetByIdAsync(int idCierre); + Task GetUltimoVigenteAsync(int idDistribuidor, int idEmpresa); + Task> GetAllAsync( + int? idDistribuidor, int? idEmpresa, string? estado, + DateTime? fechaCorteDesde, DateTime? fechaCorteHasta); + Task> GetHistorialAsync(int idCierre); + + // Auditoría general — filtros cruzados sobre todos los cierres. + Task> ObtenerHistorialAsync( + DateTime? fechaDesde, DateTime? fechaHasta, + int? idUsuarioModifico, string? tipoModificacion, + int? idCierreAfectado); + } +} diff --git a/Backend/GestionIntegral.Api/Services/Contables/IPeriodoCerradoValidator.cs b/Backend/GestionIntegral.Api/Services/Contables/IPeriodoCerradoValidator.cs new file mode 100644 index 0000000..ddeed12 --- /dev/null +++ b/Backend/GestionIntegral.Api/Services/Contables/IPeriodoCerradoValidator.cs @@ -0,0 +1,17 @@ +using System; +using System.Threading.Tasks; + +namespace GestionIntegral.Api.Services.Contables +{ + // Resultado del chequeo de período cerrado. + // Si EstaCerrado=true, IdCierre y FechaCorte tienen el cierre que bloquea la operación. + public record PeriodoCerradoResult(bool EstaCerrado, int? IdCierre, DateTime? FechaCorte); + + public interface IPeriodoCerradoValidator + { + Task EstaCerradoAsync(string destino, int idDestino, int idEmpresa, DateTime fechaOperacion); + + // Invalida la entrada de cache para el par (Distribuidor + Empresa). Llamar después de Crear o Anular cierre. + void InvalidarCache(int idDistribuidor, int idEmpresa); + } +} diff --git a/Backend/GestionIntegral.Api/Services/Contables/NotaCreditoDebitoService.cs b/Backend/GestionIntegral.Api/Services/Contables/NotaCreditoDebitoService.cs index 002107d..cc51a94 100644 --- a/Backend/GestionIntegral.Api/Services/Contables/NotaCreditoDebitoService.cs +++ b/Backend/GestionIntegral.Api/Services/Contables/NotaCreditoDebitoService.cs @@ -20,6 +20,7 @@ namespace GestionIntegral.Api.Services.Contables private readonly ICanillaRepository _canillaRepo; private readonly IEmpresaRepository _empresaRepo; private readonly ISaldoRepository _saldoRepo; + private readonly IPeriodoCerradoValidator _periodoCerrado; private readonly DbConnectionFactory _connectionFactory; private readonly ILogger _logger; @@ -29,6 +30,7 @@ namespace GestionIntegral.Api.Services.Contables ICanillaRepository canillaRepo, IEmpresaRepository empresaRepo, ISaldoRepository saldoRepo, + IPeriodoCerradoValidator periodoCerrado, DbConnectionFactory connectionFactory, ILogger logger) { @@ -37,6 +39,7 @@ namespace GestionIntegral.Api.Services.Contables _canillaRepo = canillaRepo; _empresaRepo = empresaRepo; _saldoRepo = saldoRepo; + _periodoCerrado = periodoCerrado; _connectionFactory = connectionFactory; _logger = logger; } @@ -111,6 +114,11 @@ namespace GestionIntegral.Api.Services.Contables if (await _empresaRepo.GetByIdAsync(createDto.IdEmpresa) == null) return (null, "La empresa especificada no existe."); + // Bloqueo por período cerrado (sólo aplica si Destino="Distribuidores"; el validator filtra internamente). + var bloqueoCrear = await _periodoCerrado.EstaCerradoAsync(createDto.Destino, createDto.IdDestino, createDto.IdEmpresa, createDto.Fecha); + if (bloqueoCrear.EstaCerrado) + throw new BloqueoPorPeriodoCerradoException(bloqueoCrear.IdCierre!.Value, bloqueoCrear.FechaCorte!.Value); + var nuevaNota = new NotaCreditoDebito { Destino = createDto.Destino, @@ -187,6 +195,14 @@ namespace GestionIntegral.Api.Services.Contables return (false, "Nota no encontrada."); } + // Bloqueo por período cerrado sobre la fecha original (el DTO de update no permite cambiar Fecha). + var bloqueoUpd = await _periodoCerrado.EstaCerradoAsync(notaExistente.Destino, notaExistente.IdDestino, notaExistente.IdEmpresa, notaExistente.Fecha); + if (bloqueoUpd.EstaCerrado) + { + transaction.Rollback(); + throw new BloqueoPorPeriodoCerradoException(bloqueoUpd.IdCierre!.Value, bloqueoUpd.FechaCorte!.Value); + } + decimal impactoOriginalSaldo = notaExistente.Tipo == "Credito" ? -notaExistente.Monto : notaExistente.Monto; decimal impactoNuevoSaldo = notaExistente.Tipo == "Credito" ? -updateDto.Monto : updateDto.Monto; decimal diferenciaAjusteSaldo = impactoNuevoSaldo - impactoOriginalSaldo; @@ -252,6 +268,14 @@ namespace GestionIntegral.Api.Services.Contables return (false, "Nota no encontrada."); } + // Bloqueo por período cerrado: no se puede eliminar una nota cuya fecha cae en un cierre vigente. + var bloqueoDel = await _periodoCerrado.EstaCerradoAsync(notaExistente.Destino, notaExistente.IdDestino, notaExistente.IdEmpresa, notaExistente.Fecha); + if (bloqueoDel.EstaCerrado) + { + transaction.Rollback(); + throw new BloqueoPorPeriodoCerradoException(bloqueoDel.IdCierre!.Value, bloqueoDel.FechaCorte!.Value); + } + decimal montoReversion = notaExistente.Tipo == "Credito" ? notaExistente.Monto : -notaExistente.Monto; var eliminado = await _notaRepo.DeleteAsync(idNota, idUsuario, transaction); diff --git a/Backend/GestionIntegral.Api/Services/Contables/PagoDistribuidorService.cs b/Backend/GestionIntegral.Api/Services/Contables/PagoDistribuidorService.cs index 9e5f3b1..c9ad0df 100644 --- a/Backend/GestionIntegral.Api/Services/Contables/PagoDistribuidorService.cs +++ b/Backend/GestionIntegral.Api/Services/Contables/PagoDistribuidorService.cs @@ -20,6 +20,7 @@ namespace GestionIntegral.Api.Services.Contables private readonly ITipoPagoRepository _tipoPagoRepo; private readonly IEmpresaRepository _empresaRepo; private readonly ISaldoRepository _saldoRepo; + private readonly IPeriodoCerradoValidator _periodoCerrado; private readonly DbConnectionFactory _connectionFactory; private readonly ILogger _logger; @@ -29,6 +30,7 @@ namespace GestionIntegral.Api.Services.Contables ITipoPagoRepository tipoPagoRepo, IEmpresaRepository empresaRepo, ISaldoRepository saldoRepo, + IPeriodoCerradoValidator periodoCerrado, DbConnectionFactory connectionFactory, ILogger logger) { @@ -37,6 +39,7 @@ namespace GestionIntegral.Api.Services.Contables _tipoPagoRepo = tipoPagoRepo; _empresaRepo = empresaRepo; _saldoRepo = saldoRepo; + _periodoCerrado = periodoCerrado; _connectionFactory = connectionFactory; _logger = logger; } @@ -106,6 +109,11 @@ namespace GestionIntegral.Api.Services.Contables return (null, mensajeError); } + // Bloqueo por período cerrado: la fecha de operación no puede caer dentro de un cierre vigente. + var bloqueoCrear = await _periodoCerrado.EstaCerradoAsync("Distribuidores", createDto.IdDistribuidor, createDto.IdEmpresa, createDto.Fecha); + if (bloqueoCrear.EstaCerrado) + throw new BloqueoPorPeriodoCerradoException(bloqueoCrear.IdCierre!.Value, bloqueoCrear.FechaCorte!.Value); + var nuevoPago = new PagoDistribuidor { IdDistribuidor = createDto.IdDistribuidor, @@ -182,6 +190,14 @@ namespace GestionIntegral.Api.Services.Contables return (false, "Pago no encontrado."); } + // Bloqueo por período cerrado sobre la fecha original del pago (el DTO de update no permite cambiar Fecha). + var bloqueoUpd = await _periodoCerrado.EstaCerradoAsync("Distribuidores", pagoExistente.IdDistribuidor, pagoExistente.IdEmpresa, pagoExistente.Fecha); + if (bloqueoUpd.EstaCerrado) + { + transaction.Rollback(); + throw new BloqueoPorPeriodoCerradoException(bloqueoUpd.IdCierre!.Value, bloqueoUpd.FechaCorte!.Value); + } + if (await _tipoPagoRepo.GetByIdAsync(updateDto.IdTipoPago) == null) { transaction.Rollback(); @@ -219,6 +235,11 @@ namespace GestionIntegral.Api.Services.Contables return (true, null); } catch (KeyNotFoundException) { try { transaction?.Rollback(); } catch (Exception rbEx) { _logger.LogError(rbEx, "Error en Rollback de ActualizarAsync PagoDistribuidor (KeyNotFound)."); } return (false, "Pago no encontrado."); } + catch (BloqueoPorPeriodoCerradoException) + { + try { transaction?.Rollback(); } catch (Exception rbEx) { _logger.LogError(rbEx, "Error en Rollback de ActualizarAsync PagoDistribuidor (Bloqueo)."); } + throw; + } catch (Exception ex) { try { transaction?.Rollback(); } catch (Exception rbEx) { _logger.LogError(rbEx, "Error en Rollback de ActualizarAsync PagoDistribuidor."); } @@ -253,6 +274,14 @@ namespace GestionIntegral.Api.Services.Contables return (false, "Pago no encontrado."); } + // Bloqueo por período cerrado: no se puede eliminar un pago cuya fecha cae en un cierre vigente. + var bloqueoDel = await _periodoCerrado.EstaCerradoAsync("Distribuidores", pagoExistente.IdDistribuidor, pagoExistente.IdEmpresa, pagoExistente.Fecha); + if (bloqueoDel.EstaCerrado) + { + transaction.Rollback(); + throw new BloqueoPorPeriodoCerradoException(bloqueoDel.IdCierre!.Value, bloqueoDel.FechaCorte!.Value); + } + decimal montoReversion = pagoExistente.TipoMovimiento == "Recibido" ? pagoExistente.Monto : -pagoExistente.Monto; var eliminado = await _pagoRepo.DeleteAsync(idPago, idUsuario, transaction); @@ -266,6 +295,11 @@ namespace GestionIntegral.Api.Services.Contables return (true, null); } catch (KeyNotFoundException) { try { transaction?.Rollback(); } catch (Exception rbEx) { _logger.LogError(rbEx, "Error en Rollback de EliminarAsync PagoDistribuidor (KeyNotFound)."); } return (false, "Pago no encontrado."); } + catch (BloqueoPorPeriodoCerradoException) + { + try { transaction?.Rollback(); } catch (Exception rbEx) { _logger.LogError(rbEx, "Error en Rollback de EliminarAsync PagoDistribuidor (Bloqueo)."); } + throw; + } catch (Exception ex) { try { transaction?.Rollback(); } catch (Exception rbEx) { _logger.LogError(rbEx, "Error en Rollback de EliminarAsync PagoDistribuidor."); } diff --git a/Backend/GestionIntegral.Api/Services/Contables/PeriodoCerradoValidator.cs b/Backend/GestionIntegral.Api/Services/Contables/PeriodoCerradoValidator.cs new file mode 100644 index 0000000..c11b93d --- /dev/null +++ b/Backend/GestionIntegral.Api/Services/Contables/PeriodoCerradoValidator.cs @@ -0,0 +1,69 @@ +using GestionIntegral.Api.Data.Repositories.Contables; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System; +using System.Threading.Tasks; + +namespace GestionIntegral.Api.Services.Contables +{ + public class PeriodoCerradoValidator : IPeriodoCerradoValidator + { + private readonly IServiceScopeFactory _scopeFactory; + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + + // TTL del cache: ventana stale aceptada por contrato (los cierres son operación rara). + private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(10); + + // Singleton consumiendo Scoped (ICierreCuentaCorrienteRepository) → resolver vía IServiceScopeFactory cada lookup. + public PeriodoCerradoValidator( + IServiceScopeFactory scopeFactory, + IMemoryCache cache, + ILogger logger) + { + _scopeFactory = scopeFactory; + _cache = cache; + _logger = logger; + } + + public async Task EstaCerradoAsync(string destino, int idDestino, int idEmpresa, DateTime fechaOperacion) + { + // Sólo aplica a Distribuidores. Canillas no tiene cierre por (Canilla + Empresa). + if (!string.Equals(destino, "Distribuidores", StringComparison.OrdinalIgnoreCase)) + return new PeriodoCerradoResult(false, null, null); + + var key = CacheKey(idDestino, idEmpresa); + + var ultimo = await _cache.GetOrCreateAsync(key, async entry => + { + entry.AbsoluteExpirationRelativeToNow = CacheTtl; + + using var scope = _scopeFactory.CreateScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + var cierre = await repo.GetUltimoCierreVigenteAsync(idDestino, idEmpresa); + + // Cacheamos sólo lo necesario para que la decisión sea rápida. + return cierre == null ? null : new CachedUltimoCierre(cierre.IdCierre, cierre.FechaCorte); + }); + + if (ultimo == null) + return new PeriodoCerradoResult(false, null, null); + + // FechaCorte es DATE; comparar en .Date para ignorar componente hora. + if (ultimo.FechaCorte.Date >= fechaOperacion.Date) + return new PeriodoCerradoResult(true, ultimo.IdCierre, ultimo.FechaCorte); + + return new PeriodoCerradoResult(false, null, null); + } + + public void InvalidarCache(int idDistribuidor, int idEmpresa) + { + _cache.Remove(CacheKey(idDistribuidor, idEmpresa)); + } + + private static string CacheKey(int idDestino, int idEmpresa) => $"cierre-ultimo:{idDestino}:{idEmpresa}"; + + private sealed record CachedUltimoCierre(int IdCierre, DateTime FechaCorte); + } +} diff --git a/Backend/GestionIntegral.Api/Services/Contables/SaldoService.cs b/Backend/GestionIntegral.Api/Services/Contables/SaldoService.cs index b867281..e28acd4 100644 --- a/Backend/GestionIntegral.Api/Services/Contables/SaldoService.cs +++ b/Backend/GestionIntegral.Api/Services/Contables/SaldoService.cs @@ -19,6 +19,7 @@ namespace GestionIntegral.Api.Services.Contables private readonly IDistribuidorRepository _distribuidorRepo; // Para nombres private readonly ICanillaRepository _canillaRepo; // Para nombres private readonly IEmpresaRepository _empresaRepo; // Para nombres + private readonly IPeriodoCerradoValidator _periodoCerrado; private readonly DbConnectionFactory _connectionFactory; private readonly ILogger _logger; @@ -27,6 +28,7 @@ namespace GestionIntegral.Api.Services.Contables IDistribuidorRepository distribuidorRepo, ICanillaRepository canillaRepo, IEmpresaRepository empresaRepo, + IPeriodoCerradoValidator periodoCerrado, DbConnectionFactory connectionFactory, ILogger logger) { @@ -34,6 +36,7 @@ namespace GestionIntegral.Api.Services.Contables _distribuidorRepo = distribuidorRepo; _canillaRepo = canillaRepo; _empresaRepo = empresaRepo; + _periodoCerrado = periodoCerrado; _connectionFactory = connectionFactory; _logger = logger; } @@ -103,6 +106,11 @@ namespace GestionIntegral.Api.Services.Contables if (await _empresaRepo.GetByIdAsync(ajusteDto.IdEmpresa) == null) return (false, "La empresa especificada no existe.", null); + // Bloqueo por período cerrado: la FechaOperacion del ajuste no puede caer dentro de un cierre vigente. + // El validator filtra internamente Destino="Distribuidores"; ajustes a Canillas no se bloquean. + var bloqueo = await _periodoCerrado.EstaCerradoAsync(ajusteDto.Destino, ajusteDto.IdDestino, ajusteDto.IdEmpresa, ajusteDto.FechaOperacion); + if (bloqueo.EstaCerrado) + throw new BloqueoPorPeriodoCerradoException(bloqueo.IdCierre!.Value, bloqueo.FechaCorte!.Value); using var connection = _connectionFactory.CreateConnection(); if (connection.State != ConnectionState.Open) { if (connection is System.Data.Common.DbConnection dbConn) await dbConn.OpenAsync(); else connection.Open(); } @@ -152,6 +160,11 @@ namespace GestionIntegral.Api.Services.Contables var saldoDtoActualizado = await MapToGestionDto(saldoDespuesDeModificacion); return (true, null, saldoDtoActualizado); } + catch (BloqueoPorPeriodoCerradoException) + { + try { transaction.Rollback(); } catch (Exception rbEx) { _logger.LogError(rbEx, "Error en Rollback de RealizarAjusteManualSaldoAsync (Bloqueo)."); } + throw; + } catch (Exception ex) { try { transaction.Rollback(); } catch (Exception rbEx) { _logger.LogError(rbEx, "Error en Rollback de RealizarAjusteManualSaldoAsync."); } diff --git a/Backend/GestionIntegral.Api/Services/Distribucion/EntradaSalidaDistService.cs b/Backend/GestionIntegral.Api/Services/Distribucion/EntradaSalidaDistService.cs index ca186e7..7856191 100644 --- a/Backend/GestionIntegral.Api/Services/Distribucion/EntradaSalidaDistService.cs +++ b/Backend/GestionIntegral.Api/Services/Distribucion/EntradaSalidaDistService.cs @@ -4,6 +4,7 @@ using GestionIntegral.Api.Data.Repositories.Distribucion; using GestionIntegral.Api.Dtos.Auditoria; using GestionIntegral.Api.Dtos.Distribucion; using GestionIntegral.Api.Models.Distribucion; +using GestionIntegral.Api.Services.Contables; using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; @@ -24,6 +25,7 @@ namespace GestionIntegral.Api.Services.Distribucion private readonly IPorcPagoRepository _porcPagoRepository; private readonly ISaldoRepository _saldoRepository; private readonly IEmpresaRepository _empresaRepository; // Para obtener IdEmpresa de la publicación + private readonly IPeriodoCerradoValidator _periodoCerrado; private readonly DbConnectionFactory _connectionFactory; private readonly ILogger _logger; @@ -36,6 +38,7 @@ namespace GestionIntegral.Api.Services.Distribucion IPorcPagoRepository porcPagoRepository, ISaldoRepository saldoRepository, IEmpresaRepository empresaRepository, + IPeriodoCerradoValidator periodoCerrado, DbConnectionFactory connectionFactory, ILogger logger) { @@ -47,6 +50,7 @@ namespace GestionIntegral.Api.Services.Distribucion _porcPagoRepository = porcPagoRepository; _saldoRepository = saldoRepository; _empresaRepository = empresaRepository; + _periodoCerrado = periodoCerrado; _connectionFactory = connectionFactory; _logger = logger; } @@ -167,6 +171,11 @@ namespace GestionIntegral.Api.Services.Distribucion var distribuidor = await _distribuidorRepository.GetByIdSimpleAsync(createDto.IdDistribuidor); if (distribuidor == null) return (null, "Distribuidor no válido."); + // Bloqueo por período cerrado: la fecha del movimiento no puede caer dentro de un cierre vigente del distribuidor en la empresa de la publicación. + var bloqueoCrear = await _periodoCerrado.EstaCerradoAsync("Distribuidores", createDto.IdDistribuidor, publicacion.IdEmpresa, createDto.Fecha); + if (bloqueoCrear.EstaCerrado) + throw new BloqueoPorPeriodoCerradoException(bloqueoCrear.IdCierre!.Value, bloqueoCrear.FechaCorte!.Value); + /* if (await _esRepository.ExistsByRemitoAndTipoForPublicacionAsync(createDto.Remito, createDto.TipoMovimiento, createDto.IdPublicacion)) { @@ -262,6 +271,14 @@ namespace GestionIntegral.Api.Services.Distribucion var distribuidor = await _distribuidorRepository.GetByIdSimpleAsync(esExistente.IdDistribuidor); if (distribuidor == null) return (false, "Distribuidor asociado no encontrado."); + // Bloqueo por período cerrado sobre la fecha original del movimiento (el DTO de update no permite cambiar Fecha). + var bloqueoUpd = await _periodoCerrado.EstaCerradoAsync("Distribuidores", esExistente.IdDistribuidor, publicacion.IdEmpresa, esExistente.Fecha); + if (bloqueoUpd.EstaCerrado) + { + transaction.Rollback(); + throw new BloqueoPorPeriodoCerradoException(bloqueoUpd.IdCierre!.Value, bloqueoUpd.FechaCorte!.Value); + } + // 1. Calcular monto del movimiento original (antes de la actualización) decimal montoOriginal = await CalcularMontoMovimiento( @@ -307,6 +324,11 @@ namespace GestionIntegral.Api.Services.Distribucion return (true, null); } catch (KeyNotFoundException) { try { transaction.Rollback(); } catch { } return (false, "Movimiento no encontrado."); } + catch (BloqueoPorPeriodoCerradoException) + { + try { transaction.Rollback(); } catch { } + throw; + } catch (Exception ex) { try { transaction.Rollback(); } catch { } @@ -330,6 +352,14 @@ namespace GestionIntegral.Api.Services.Distribucion var distribuidor = await _distribuidorRepository.GetByIdSimpleAsync(esExistente.IdDistribuidor); if (distribuidor == null) return (false, "Distribuidor asociado no encontrado."); + // Bloqueo por período cerrado: no se puede eliminar un movimiento cuya fecha cae en un cierre vigente. + var bloqueoDel = await _periodoCerrado.EstaCerradoAsync("Distribuidores", esExistente.IdDistribuidor, publicacion.IdEmpresa, esExistente.Fecha); + if (bloqueoDel.EstaCerrado) + { + transaction.Rollback(); + throw new BloqueoPorPeriodoCerradoException(bloqueoDel.IdCierre!.Value, bloqueoDel.FechaCorte!.Value); + } + // 1. Calcular el monto del movimiento a eliminar para revertir el saldo decimal montoReversion = await CalcularMontoMovimiento( esExistente.IdPublicacion, esExistente.IdDistribuidor, esExistente.Fecha, esExistente.Cantidad, esExistente.TipoMovimiento, @@ -364,6 +394,11 @@ namespace GestionIntegral.Api.Services.Distribucion return (true, null); } catch (KeyNotFoundException) { try { transaction.Rollback(); } catch { } return (false, "Movimiento no encontrado."); } + catch (BloqueoPorPeriodoCerradoException) + { + try { transaction.Rollback(); } catch { } + throw; + } catch (Exception ex) { try { transaction.Rollback(); } catch { } diff --git a/Backend/GestionIntegral.Api/Services/Reportes/IReportesService.cs b/Backend/GestionIntegral.Api/Services/Reportes/IReportesService.cs index 4432186..8b9e080 100644 --- a/Backend/GestionIntegral.Api/Services/Reportes/IReportesService.cs +++ b/Backend/GestionIntegral.Api/Services/Reportes/IReportesService.cs @@ -53,12 +53,14 @@ namespace GestionIntegral.Api.Services.Reportes Task<(IEnumerable Data, string? Error)> ObtenerComparativaConsumoBobinasAsync(DateTime fechaInicioMesA, DateTime fechaFinMesA, DateTime fechaInicioMesB, DateTime fechaFinMesB, int idPlanta); Task<(IEnumerable Data, string? Error)> ObtenerComparativaConsumoBobinasConsolidadoAsync(DateTime fechaInicioMesA, DateTime fechaFinMesA, DateTime fechaInicioMesB, DateTime fechaFinMesB); - // DTOs para ReporteCuentasDistribuidores + // DTOs para ReporteCuentasDistribuidores. + // El antiguo IEnumerable Saldos (saldo actual del distribuidor, sin sentido temporal en un reporte por período) + // se reemplazó por decimal SaldoInicial calculado en base al último cierre vigente. Task<( IEnumerable EntradasSalidas, IEnumerable DebitosCreditos, IEnumerable Pagos, - IEnumerable Saldos, + decimal SaldoInicial, string? Error )> ObtenerReporteCuentasDistribuidorAsync(int idDistribuidor, int idEmpresa, DateTime fechaDesde, DateTime fechaHasta); Task<(IEnumerable Simple, IEnumerable Promedios, string? Error)> ObtenerListadoDistribucionDistribuidoresAsync(int idDistribuidor, int idPublicacion, DateTime fechaDesde, DateTime fechaHasta); diff --git a/Backend/GestionIntegral.Api/Services/Reportes/ReportesService.cs b/Backend/GestionIntegral.Api/Services/Reportes/ReportesService.cs index 1124351..af0f6af 100644 --- a/Backend/GestionIntegral.Api/Services/Reportes/ReportesService.cs +++ b/Backend/GestionIntegral.Api/Services/Reportes/ReportesService.cs @@ -2,6 +2,7 @@ using GestionIntegral.Api.Data.Repositories.Distribucion; using GestionIntegral.Api.Data.Repositories.Reportes; using GestionIntegral.Api.Data.Repositories.Suscripciones; using GestionIntegral.Api.Dtos.Reportes; +using GestionIntegral.Api.Services.Contables; namespace GestionIntegral.Api.Services.Reportes { @@ -14,10 +15,11 @@ namespace GestionIntegral.Api.Services.Reportes private readonly IEmpresaRepository _empresaRepository; private readonly ISuscriptorRepository _suscriptorRepository; private readonly ISuscripcionRepository _suscripcionRepository; + private readonly ICierreCuentaCorrienteService _cierreService; private readonly ILogger _logger; public ReportesService(IReportesRepository reportesRepository, IFacturaRepository facturaRepository, IFacturaDetalleRepository facturaDetalleRepository, IPublicacionRepository publicacionRepository, IEmpresaRepository empresaRepository - , ISuscriptorRepository suscriptorRepository, ISuscripcionRepository suscripcionRepository, ILogger logger) + , ISuscriptorRepository suscriptorRepository, ISuscripcionRepository suscripcionRepository, ICierreCuentaCorrienteService cierreService, ILogger logger) { _reportesRepository = reportesRepository; _facturaRepository = facturaRepository; @@ -26,6 +28,7 @@ namespace GestionIntegral.Api.Services.Reportes _empresaRepository = empresaRepository; _suscriptorRepository = suscriptorRepository; _suscripcionRepository = suscripcionRepository; + _cierreService = cierreService; _logger = logger; } @@ -420,26 +423,28 @@ namespace GestionIntegral.Api.Services.Reportes } } - // Implementación para ReporteCuentasDistribuidores + // Implementación para ReporteCuentasDistribuidores. + // Reemplaza el campo legacy "Saldos" (saldo actual sin sentido temporal) por "SaldoInicial": + // saldo del último cierre + movimientos netos hasta fechaDesde-1 (0 si no hay cierre previo). public async Task<( IEnumerable EntradasSalidas, IEnumerable DebitosCreditos, IEnumerable Pagos, - IEnumerable Saldos, + decimal SaldoInicial, string? Error )> ObtenerReporteCuentasDistribuidorAsync(int idDistribuidor, int idEmpresa, DateTime fechaDesde, DateTime fechaHasta) { if (fechaDesde > fechaHasta) - return (Enumerable.Empty(), Enumerable.Empty(), Enumerable.Empty(), Enumerable.Empty(), "Fecha 'Desde' no puede ser mayor que 'Hasta'."); + return (Enumerable.Empty(), Enumerable.Empty(), Enumerable.Empty(), 0m, "Fecha 'Desde' no puede ser mayor que 'Hasta'."); try { var esTask = _reportesRepository.GetBalanceCuentaDistEntradaSalidaPorEmpresaAsync(idDistribuidor, idEmpresa, fechaDesde, fechaHasta); var dcTask = _reportesRepository.GetBalanceCuentDistDebCredEmpresaAsync(idDistribuidor, idEmpresa, fechaDesde, fechaHasta); var paTask = _reportesRepository.GetBalanceCuentDistPagosEmpresaAsync(idDistribuidor, idEmpresa, fechaDesde, fechaHasta); - var saTask = _reportesRepository.GetBalanceCuentSaldosEmpresasAsync("Distribuidores", idDistribuidor, idEmpresa); + var siTask = _cierreService.CalcularSaldoInicialReporteAsync(idDistribuidor, idEmpresa, fechaDesde); - await Task.WhenAll(esTask, dcTask, paTask, saTask); + await Task.WhenAll(esTask, dcTask, paTask, siTask); Func, IEnumerable> esToUtc = items => items?.Select(i => { i.Fecha = DateTime.SpecifyKind(i.Fecha.Date, DateTimeKind.Utc); return i; }).ToList() @@ -455,7 +460,7 @@ namespace GestionIntegral.Api.Services.Reportes esToUtc(await esTask), dcToUtc(await dcTask), paToUtc(await paTask), - await saTask ?? Enumerable.Empty(), + await siTask, null ); } @@ -466,7 +471,7 @@ namespace GestionIntegral.Api.Services.Reportes Enumerable.Empty(), Enumerable.Empty(), Enumerable.Empty(), - Enumerable.Empty(), + 0m, "Error interno al generar el reporte." ); } diff --git a/Frontend/src/components/Modals/Contables/AjusteSaldoModal.tsx b/Frontend/src/components/Modals/Contables/AjusteSaldoModal.tsx index fa05d9a..a97181a 100644 --- a/Frontend/src/components/Modals/Contables/AjusteSaldoModal.tsx +++ b/Frontend/src/components/Modals/Contables/AjusteSaldoModal.tsx @@ -36,6 +36,7 @@ const AjusteSaldoModal: React.FC = ({ }) => { const [montoAjuste, setMontoAjuste] = useState(''); const [justificacion, setJustificacion] = useState(''); + const [fechaOperacion, setFechaOperacion] = useState(new Date().toISOString().split('T')[0]); const [loading, setLoading] = useState(false); const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); @@ -43,10 +44,10 @@ const AjusteSaldoModal: React.FC = ({ if (open) { setMontoAjuste(''); setJustificacion(''); + setFechaOperacion(new Date().toISOString().split('T')[0]); setLocalErrors({}); - clearErrorMessage(); } - }, [open, clearErrorMessage]); + }, [open]); const validate = (): boolean => { const errors: { [key: string]: string | null } = {}; @@ -65,11 +66,16 @@ const AjusteSaldoModal: React.FC = ({ } else if (justificacion.trim().length < 5 || justificacion.trim().length > 250) { errors.justificacion = 'La justificación debe tener entre 5 y 250 caracteres.'; } + + if (!fechaOperacion) { + errors.fechaOperacion = 'La fecha de operación es obligatoria.'; + } + setLocalErrors(errors); return Object.keys(errors).length === 0; }; - const handleInputChange = (fieldName: 'montoAjuste' | 'justificacion') => { + const handleInputChange = (fieldName: 'montoAjuste' | 'justificacion' | 'fechaOperacion') => { if (localErrors[fieldName]) setLocalErrors(prev => ({ ...prev, [fieldName]: null })); if (errorMessage) clearErrorMessage(); }; @@ -87,6 +93,7 @@ const AjusteSaldoModal: React.FC = ({ idEmpresa: saldoParaAjustar.idEmpresa, montoAjuste: parseFloat(montoAjuste), justificacion, + fechaOperacion, }; await onSubmit(dataToSubmit); onClose(); // Cerrar en éxito (el padre recargará) @@ -117,6 +124,19 @@ const AjusteSaldoModal: React.FC = ({ + { setFechaOperacion(e.target.value); handleInputChange('fechaOperacion'); }} + margin="normal" + error={!!localErrors.fechaOperacion} + helperText={localErrors.fechaOperacion || ''} + disabled={loading} + InputLabelProps={{ shrink: true }} + /> = ({ setObservaciones(initialData?.observaciones || ''); setIdEmpresa(initialData?.idEmpresa || ''); setLocalErrors({}); - clearErrorMessage(); } - }, [open, initialData, clearErrorMessage, fetchEmpresas, fetchDestinatarios]); + }, [open, initialData, fetchEmpresas, fetchDestinatarios]); useEffect(() => { if(open && !isEditing) { // Solo cambiar destinatarios si es creación y cambia el tipo de Destino diff --git a/Frontend/src/components/Modals/Contables/NuevoCierreModal.tsx b/Frontend/src/components/Modals/Contables/NuevoCierreModal.tsx new file mode 100644 index 0000000..690ac4f --- /dev/null +++ b/Frontend/src/components/Modals/Contables/NuevoCierreModal.tsx @@ -0,0 +1,235 @@ +import React, { useState, useEffect } from 'react'; +import { + Modal, Box, Typography, TextField, Button, CircularProgress, Alert, + FormControl, InputLabel, Select, MenuItem +} from '@mui/material'; +import type { CrearCierreDto } from '../../../models/dtos/Contables/CrearCierreDto'; +import type { CierreCuentaCorrienteDto } from '../../../models/dtos/Contables/CierreCuentaCorrienteDto'; +import type { DistribuidorDropdownDto } from '../../../models/dtos/Distribucion/DistribuidorDropdownDto'; +import type { EmpresaDropdownDto } from '../../../models/dtos/Distribucion/EmpresaDropdownDto'; +import distribuidorService from '../../../services/Distribucion/distribuidorService'; +import empresaService from '../../../services/Distribucion/empresaService'; + +const modalStyle = { + position: 'absolute' as const, + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: { xs: '90%', sm: 600 }, + bgcolor: 'background.paper', + border: '2px solid #000', + boxShadow: 24, + p: 4, + maxHeight: '90vh', + overflowY: 'auto' +}; + +interface NuevoCierreModalProps { + open: boolean; + onClose: () => void; + onSubmit: (data: CrearCierreDto) => Promise; + initialIdDistribuidor?: number | null; + initialIdEmpresa?: number | null; + errorMessage?: string | null; + clearErrorMessage: () => void; +} + +const todayIso = () => new Date().toISOString().split('T')[0]; + +const NuevoCierreModal: React.FC = ({ + open, + onClose, + onSubmit, + initialIdDistribuidor, + initialIdEmpresa, + errorMessage, + clearErrorMessage +}) => { + const [idDistribuidor, setIdDistribuidor] = useState(''); + const [idEmpresa, setIdEmpresa] = useState(''); + const [fechaCorte, setFechaCorte] = useState(todayIso()); + const [justificacion, setJustificacion] = useState(''); + + const [distribuidores, setDistribuidores] = useState([]); + const [empresas, setEmpresas] = useState([]); + const [loading, setLoading] = useState(false); + const [loadingDropdowns, setLoadingDropdowns] = useState(false); + const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); + + useEffect(() => { + const fetchDropdownData = async () => { + setLoadingDropdowns(true); + try { + const [distData, empData] = await Promise.all([ + distribuidorService.getAllDistribuidoresDropdown(), + empresaService.getEmpresasDropdown() + ]); + setDistribuidores(distData); + setEmpresas(empData); + } catch (err) { + console.error('Error al cargar dropdowns', err); + setLocalErrors(prev => ({ ...prev, dropdowns: 'Error al cargar distribuidores o empresas.' })); + } finally { + setLoadingDropdowns(false); + } + }; + + if (open) { + fetchDropdownData(); + setIdDistribuidor(initialIdDistribuidor ?? ''); + setIdEmpresa(initialIdEmpresa ?? ''); + setFechaCorte(todayIso()); + setJustificacion(''); + setLocalErrors({}); + clearErrorMessage(); + } + }, [open, initialIdDistribuidor, initialIdEmpresa, clearErrorMessage]); + + const validate = (): boolean => { + const errors: { [key: string]: string | null } = {}; + if (!idDistribuidor) errors.idDistribuidor = 'Seleccione un distribuidor.'; + if (!idEmpresa) errors.idEmpresa = 'Seleccione una empresa.'; + if (!fechaCorte) { + errors.fechaCorte = 'La fecha de corte es obligatoria.'; + } else if (new Date(fechaCorte) > new Date(todayIso())) { + errors.fechaCorte = 'La fecha de corte no puede ser futura.'; + } + if (justificacion.length > 500) { + errors.justificacion = 'La justificación no puede superar los 500 caracteres.'; + } + + setLocalErrors(errors); + return Object.keys(errors).length === 0; + }; + + const handleInputChange = (fieldName: string) => { + if (localErrors[fieldName]) setLocalErrors(prev => ({ ...prev, [fieldName]: null })); + if (errorMessage) clearErrorMessage(); + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + clearErrorMessage(); + if (!validate()) return; + + const distSeleccionado = distribuidores.find(d => d.idDistribuidor === Number(idDistribuidor)); + const empSeleccionada = empresas.find(e => e.idEmpresa === Number(idEmpresa)); + const confirmMsg = `Estás por cerrar el período hasta ${fechaCorte} para "${distSeleccionado?.nombre ?? ''}" en "${empSeleccionada?.nombre ?? ''}".\n\n` + + `Después de cerrar no se podrán registrar movimientos, pagos, notas ni ajustes con fecha menor o igual a esta. ¿Continuar?`; + if (!window.confirm(confirmMsg)) return; + + setLoading(true); + try { + const dto: CrearCierreDto = { + idDistribuidor: Number(idDistribuidor), + idEmpresa: Number(idEmpresa), + fechaCorte, + justificacion: justificacion.trim() || null + }; + await onSubmit(dto); + onClose(); + } catch (err) { + console.error('Error al crear cierre:', err); + } finally { + setLoading(false); + } + }; + + return ( + + + + Nuevo Cierre de Cuenta Corriente + + + + + Distribuidor + + {localErrors.idDistribuidor && {localErrors.idDistribuidor}} + + + + Empresa + + {localErrors.idEmpresa && {localErrors.idEmpresa}} + + + { setFechaCorte(e.target.value); handleInputChange('fechaCorte'); }} + margin="dense" + fullWidth + error={!!localErrors.fechaCorte} + helperText={localErrors.fechaCorte || 'No puede ser una fecha futura.'} + disabled={loading} + InputLabelProps={{ shrink: true }} + inputProps={{ max: todayIso() }} + /> + + + La fecha de corte es inclusive: los movimientos con fecha igual a la seleccionada se + incluyen en el cálculo del saldo de cierre. + + + { setJustificacion(e.target.value); handleInputChange('justificacion'); }} + margin="dense" + fullWidth + multiline + rows={3} + disabled={loading} + error={!!localErrors.justificacion} + helperText={localErrors.justificacion || `${justificacion.length}/500 caracteres`} + inputProps={{ maxLength: 500 }} + /> + + + + Una vez creado el cierre, no podrán registrarse, modificarse ni eliminarse pagos, notas, ajustes + o movimientos cuya fecha de operación sea menor o igual a la fecha de corte. + + + {errorMessage && {errorMessage}} + {localErrors.dropdowns && {localErrors.dropdowns}} + + + + + + + + + ); +}; + +export default NuevoCierreModal; diff --git a/Frontend/src/components/Modals/Contables/PagoDistribuidorFormModal.tsx b/Frontend/src/components/Modals/Contables/PagoDistribuidorFormModal.tsx index 2e65389..4fdfac8 100644 --- a/Frontend/src/components/Modals/Contables/PagoDistribuidorFormModal.tsx +++ b/Frontend/src/components/Modals/Contables/PagoDistribuidorFormModal.tsx @@ -95,9 +95,8 @@ const PagoDistribuidorFormModal: React.FC = ({ setDetalle(initialData?.detalle || ''); setIdEmpresa(initialData?.idEmpresa || ''); setLocalErrors({}); - clearErrorMessage(); } - }, [open, initialData, clearErrorMessage]); + }, [open, initialData]); const validate = (): boolean => { const errors: { [key: string]: string | null } = {}; diff --git a/Frontend/src/components/Modals/Contables/ReabrirCierreModal.tsx b/Frontend/src/components/Modals/Contables/ReabrirCierreModal.tsx new file mode 100644 index 0000000..4cf5ca0 --- /dev/null +++ b/Frontend/src/components/Modals/Contables/ReabrirCierreModal.tsx @@ -0,0 +1,140 @@ +import React, { useState, useEffect } from 'react'; +import { + Modal, Box, Typography, TextField, Button, CircularProgress, Alert +} from '@mui/material'; +import WarningAmberIcon from '@mui/icons-material/WarningAmber'; +import type { ReabrirCierreDto } from '../../../models/dtos/Contables/ReabrirCierreDto'; +import type { CierreCuentaCorrienteDto } from '../../../models/dtos/Contables/CierreCuentaCorrienteDto'; + +const modalStyle = { + position: 'absolute' as const, + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: { xs: '90%', sm: 560 }, + bgcolor: 'background.paper', + border: '2px solid #000', + boxShadow: 24, + p: 4, + maxHeight: '90vh', + overflowY: 'auto' +}; + +const MIN_LEN = 10; +const MAX_LEN = 500; + +interface ReabrirCierreModalProps { + open: boolean; + onClose: () => void; + onSubmit: (idCierre: number, data: ReabrirCierreDto) => Promise; + cierre: CierreCuentaCorrienteDto | null; + errorMessage?: string | null; + clearErrorMessage: () => void; +} + +const ReabrirCierreModal: React.FC = ({ + open, + onClose, + onSubmit, + cierre, + errorMessage, + clearErrorMessage +}) => { + const [justificacion, setJustificacion] = useState(''); + const [loading, setLoading] = useState(false); + const [localError, setLocalError] = useState(null); + + useEffect(() => { + if (open) { + setJustificacion(''); + setLocalError(null); + clearErrorMessage(); + } + }, [open, clearErrorMessage]); + + const justifTrimLen = justificacion.trim().length; + const submitDisabled = loading || justifTrimLen < MIN_LEN || justifTrimLen > MAX_LEN; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + if (!cierre) return; + if (justifTrimLen < MIN_LEN) { + setLocalError(`La justificación debe tener al menos ${MIN_LEN} caracteres.`); + return; + } + if (justifTrimLen > MAX_LEN) { + setLocalError(`La justificación no puede superar ${MAX_LEN} caracteres.`); + return; + } + + setLocalError(null); + clearErrorMessage(); + setLoading(true); + try { + await onSubmit(cierre.idCierre, { justificacion: justificacion.trim() }); + onClose(); + } catch (err) { + console.error('Error al reabrir cierre:', err); + } finally { + setLoading(false); + } + }; + + return ( + + + + Reabrir Cierre de Cuenta Corriente + + + } sx={{ mb: 2 }}> + ATENCIÓN: estás por reabrir un cierre. Esta acción quedará registrada en auditoría. + Solo se puede reabrir el último cierre vigente. Si hay cierres posteriores, primero hay que reabrirlos a ellos. + + + {cierre && ( + + Distribuidor: {cierre.nombreDistribuidor} + Empresa: {cierre.nombreEmpresa} + Fecha de Corte: {cierre.fechaCorte} + + Saldo del Cierre:{' '} + {cierre.saldoCierre.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })} + + + )} + + + { setJustificacion(e.target.value); if (localError) setLocalError(null); }} + required + multiline + rows={4} + fullWidth + margin="dense" + error={!!localError} + helperText={ + localError || + `Mínimo ${MIN_LEN} caracteres — ${justifTrimLen}/${MAX_LEN}` + } + inputProps={{ maxLength: MAX_LEN }} + disabled={loading} + /> + + {errorMessage && {errorMessage}} + + + + + + + + + ); +}; + +export default ReabrirCierreModal; diff --git a/Frontend/src/components/Modals/Distribucion/EntradaSalidaDistFormModal.tsx b/Frontend/src/components/Modals/Distribucion/EntradaSalidaDistFormModal.tsx index 5b7e644..d5e5871 100644 --- a/Frontend/src/components/Modals/Distribucion/EntradaSalidaDistFormModal.tsx +++ b/Frontend/src/components/Modals/Distribucion/EntradaSalidaDistFormModal.tsx @@ -86,9 +86,8 @@ const EntradaSalidaDistFormModal: React.FC = ({ setRemito(initialData?.remito?.toString() || ''); setObservacion(initialData?.observacion || ''); setLocalErrors({}); - clearErrorMessage(); } - }, [open, initialData, clearErrorMessage]); + }, [open, initialData]); const validate = (): boolean => { const errors: { [key: string]: string | null } = {}; diff --git a/Frontend/src/components/Modals/Usuarios/PermisosChecklist.tsx b/Frontend/src/components/Modals/Usuarios/PermisosChecklist.tsx index 843dc78..b91bb31 100644 --- a/Frontend/src/components/Modals/Usuarios/PermisosChecklist.tsx +++ b/Frontend/src/components/Modals/Usuarios/PermisosChecklist.tsx @@ -41,7 +41,8 @@ const getModuloConceptualDelPermiso = (permisoModulo: string): string => { } if (moduloLower.includes("cuentas pagos") || moduloLower.includes("cuentas notas") || - moduloLower.includes("cuentas tipos pagos")) { + moduloLower.includes("cuentas tipos pagos") || + moduloLower.includes("cuentas cierres")) { return "Contables"; } if (moduloLower.includes("impresión tiradas") || diff --git a/Frontend/src/models/dtos/Auditoria/CierreCuentaCorrienteHistorialDto.ts b/Frontend/src/models/dtos/Auditoria/CierreCuentaCorrienteHistorialDto.ts new file mode 100644 index 0000000..3a4414a --- /dev/null +++ b/Frontend/src/models/dtos/Auditoria/CierreCuentaCorrienteHistorialDto.ts @@ -0,0 +1,24 @@ +// Refleja DTO C# Dtos.Auditoria.CierreCuentaCorrienteHistorialDto. +// La tabla _H y este DTO usan PascalCase con underscores; el JSON serializer +// default de ASP.NET Core sólo aplica camelCase a la primera letra, +// preservando los underscores subsiguientes. +export interface CierreCuentaCorrienteHistorialDto { + id_Historial: number; + id_Cierre: number; + id_Distribuidor: number; + id_Empresa: number; + fechaCorte: string; // ISO datetime + fechaCierre: string; + saldoCierre: number; + estado: 'Activo' | 'Anulado'; + justificacion?: string | null; + id_Usuario_Cierre: number; + id_Usuario_Anula?: number | null; + fechaAnulacion?: string | null; + justificacion_Anulacion?: string | null; + + tipoMod: 'Creacion' | 'Reapertura' | 'Modificacion'; + id_Usuario_Mod: number; + nombreUsuarioModifico: string; + fechaMod: string; // ISO datetime +} diff --git a/Frontend/src/models/dtos/Contables/AjusteSaldoRequestDto.ts b/Frontend/src/models/dtos/Contables/AjusteSaldoRequestDto.ts index e28a8a0..b8e6bcb 100644 --- a/Frontend/src/models/dtos/Contables/AjusteSaldoRequestDto.ts +++ b/Frontend/src/models/dtos/Contables/AjusteSaldoRequestDto.ts @@ -4,4 +4,7 @@ export interface AjusteSaldoRequestDto { idEmpresa: number; montoAjuste: number; justificacion: string; + // Fecha lógica de la operación contable (no la del ajuste físico). + // Se valida contra períodos cerrados del distribuidor+empresa. + fechaOperacion: string; // "yyyy-MM-dd" } \ No newline at end of file diff --git a/Frontend/src/models/dtos/Contables/CierreCuentaCorrienteDto.ts b/Frontend/src/models/dtos/Contables/CierreCuentaCorrienteDto.ts new file mode 100644 index 0000000..16305b3 --- /dev/null +++ b/Frontend/src/models/dtos/Contables/CierreCuentaCorrienteDto.ts @@ -0,0 +1,19 @@ +export interface CierreCuentaCorrienteDto { + idCierre: number; + idDistribuidor: number; + nombreDistribuidor: string; + idEmpresa: number; + nombreEmpresa: string; + fechaCorte: string; // "yyyy-MM-dd" + fechaCierre: string; // ISO datetime + saldoCierre: number; + estado: 'Activo' | 'Anulado'; + justificacion?: string | null; + idUsuarioCierre: number; + nombreUsuarioCierre: string; + idUsuarioAnula?: number | null; + nombreUsuarioAnula?: string | null; + fechaAnulacion?: string | null; // ISO datetime + justificacionAnulacion?: string | null; + esUltimoVigente: boolean; +} diff --git a/Frontend/src/models/dtos/Contables/CrearCierreDto.ts b/Frontend/src/models/dtos/Contables/CrearCierreDto.ts new file mode 100644 index 0000000..1750627 --- /dev/null +++ b/Frontend/src/models/dtos/Contables/CrearCierreDto.ts @@ -0,0 +1,6 @@ +export interface CrearCierreDto { + idDistribuidor: number; + idEmpresa: number; + fechaCorte: string; // "yyyy-MM-dd" + justificacion?: string | null; +} diff --git a/Frontend/src/models/dtos/Contables/ReabrirCierreDto.ts b/Frontend/src/models/dtos/Contables/ReabrirCierreDto.ts new file mode 100644 index 0000000..165ff2a --- /dev/null +++ b/Frontend/src/models/dtos/Contables/ReabrirCierreDto.ts @@ -0,0 +1,4 @@ +export interface ReabrirCierreDto { + // Min 10, max 500 caracteres (validado en backend con DataAnnotations). + justificacion: string; +} diff --git a/Frontend/src/models/dtos/Contables/UltimoCierreDto.ts b/Frontend/src/models/dtos/Contables/UltimoCierreDto.ts new file mode 100644 index 0000000..2e4b30f --- /dev/null +++ b/Frontend/src/models/dtos/Contables/UltimoCierreDto.ts @@ -0,0 +1,6 @@ +export interface UltimoCierreDto { + idCierre: number; + fechaCorte: string; // "yyyy-MM-dd" + saldoCierre: number; + estado: 'Activo' | 'Anulado'; +} diff --git a/Frontend/src/models/dtos/Reportes/ReporteCuentasDistribuidorResponseDto.ts b/Frontend/src/models/dtos/Reportes/ReporteCuentasDistribuidorResponseDto.ts index 9a2228b..c62ca07 100644 --- a/Frontend/src/models/dtos/Reportes/ReporteCuentasDistribuidorResponseDto.ts +++ b/Frontend/src/models/dtos/Reportes/ReporteCuentasDistribuidorResponseDto.ts @@ -1,14 +1,14 @@ import type { BalanceCuentaDebCredDto } from "./BalanceCuentaDebCredDto"; import type { BalanceCuentaDistDto } from "./BalanceCuentaDistDto"; import type { BalanceCuentaPagosDto } from "./BalanceCuentaPagosDto"; -import type { SaldoDto } from "./SaldoDto"; export interface ReporteCuentasDistribuidorResponseDto { entradasSalidas: BalanceCuentaDistDto[]; debitosCreditos: BalanceCuentaDebCredDto[]; pagos: BalanceCuentaPagosDto[]; - saldos: SaldoDto[]; // Aunque SP_BalanceCuentSaldos devuelve una lista, para un distribuidor/empresa debería ser 1 solo. - // Se podría ajustar el DTO o el servicio para devolver solo el primer saldo. - nombreDistribuidor?: string; // Para el título del reporte - nombreEmpresa?: string; // Para el título del reporte -} \ No newline at end of file + // Saldo inicial del período: snapshot del último cierre + movimientos netos hasta fechaDesde-1. + // 0 si no hay cierre previo. + saldoInicial: number; + nombreDistribuidor?: string; + nombreEmpresa?: string; +} diff --git a/Frontend/src/models/dtos/Reportes/ReporteCuentasDistribuidorResponseDto.tsx b/Frontend/src/models/dtos/Reportes/ReporteCuentasDistribuidorResponseDto.tsx index fb37d52..067caa5 100644 --- a/Frontend/src/models/dtos/Reportes/ReporteCuentasDistribuidorResponseDto.tsx +++ b/Frontend/src/models/dtos/Reportes/ReporteCuentasDistribuidorResponseDto.tsx @@ -1,13 +1,12 @@ import type { BalanceCuentaDebCredDto } from "./BalanceCuentaDebCredDto"; import type { BalanceCuentaDistDto } from "./BalanceCuentaDistDto"; import type { BalanceCuentaPagosDto } from "./BalanceCuentaPagosDto"; -import type { SaldoDto } from "./SaldoDto"; export interface ReporteCuentasDistribuidorResponseDto { entradasSalidas: BalanceCuentaDistDto[]; debitosCreditos: BalanceCuentaDebCredDto[]; pagos: BalanceCuentaPagosDto[]; - saldos: SaldoDto[]; + saldoInicial: number; nombreDistribuidor?: string; nombreEmpresa?: string; -} \ No newline at end of file +} diff --git a/Frontend/src/models/dtos/Reportes/SaldoDto.tsx b/Frontend/src/models/dtos/Reportes/SaldoDto.tsx deleted file mode 100644 index d1a5035..0000000 --- a/Frontend/src/models/dtos/Reportes/SaldoDto.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export interface SaldoDto { - monto: number; -} \ No newline at end of file diff --git a/Frontend/src/pages/Auditoria/AuditoriaGeneralPage.tsx b/Frontend/src/pages/Auditoria/AuditoriaGeneralPage.tsx index dfe5a8c..79b5cb7 100644 --- a/Frontend/src/pages/Auditoria/AuditoriaGeneralPage.tsx +++ b/Frontend/src/pages/Auditoria/AuditoriaGeneralPage.tsx @@ -62,6 +62,7 @@ const TIPOS_ENTIDAD_AUDITABLES = [ { value: "PermisoMaestro", label: "Permisos (Definición) (gral_Permisos_H)" }, { value: "PermisosPerfiles", label: "Asignación de Permisos a Perfiles (gral_PermisosPerfiles_H)" }, { value: "CambioParada", label: "Cambios de Parada (dist_CambiosParadasCanillas_H)" }, + { value: "CierreCC", label: "Cierres de Cuenta Corriente (cue_CierresCuentaCorriente_H)" }, ].sort((a, b) => a.label.localeCompare(b.label)); const TIPOS_MODIFICACION = [ @@ -653,6 +654,29 @@ const AuditoriaGeneralPage: React.FC = () => { { field: 'vigenciaH', headerName: 'Vig. Hasta', width: 120, type: 'date', valueFormatter: (value) => formatDate(value as string) }, ]; break; + case "CierreCC": + const cierreCcHist = await auditoriaService.getHistorialCierresCC({ + ...commonParams, + // "ID Entidad Afectada" filtra por Id_Cierre puntual. + idCierreAfectado: filtroIdEntidad ? Number(filtroIdEntidad) : undefined + }); + rawData = cierreCcHist; + cols = [ + ...getCommonAuditColumns(), + { field: 'id_Cierre', headerName: 'ID Cierre', width: 100, align: 'center', headerAlign: 'center' }, + { field: 'id_Distribuidor', headerName: 'ID Dist.', width: 100, align: 'center', headerAlign: 'center' }, + { field: 'id_Empresa', headerName: 'ID Emp.', width: 100, align: 'center', headerAlign: 'center' }, + { field: 'fechaCorte', headerName: 'Fecha Corte', width: 120, type: 'date', valueFormatter: (value) => formatDate(value as string) }, + { field: 'fechaCierre', headerName: 'Fecha Cierre', width: 170, valueFormatter: (value) => formatDate(value as string) }, + { field: 'saldoCierre', headerName: 'Saldo Cierre', width: 140, type: 'number', valueFormatter: (v) => currencyFormatter(v as number) }, + { field: 'estado', headerName: 'Estado', width: 100 }, + { field: 'justificacion', headerName: 'Justif. Cierre', width: 200, renderCell: (params) => ({params.value || '-'}) }, + { field: 'id_Usuario_Cierre', headerName: 'Usr. Cierre', width: 100, align: 'center', headerAlign: 'center' }, + { field: 'id_Usuario_Anula', headerName: 'Usr. Anula', width: 100, align: 'center', headerAlign: 'center', renderCell: (p) => p.value ?? '-' }, + { field: 'fechaAnulacion', headerName: 'Fecha Anul.', width: 170, valueFormatter: (v) => v ? formatDate(v as string) : '-' }, + { field: 'justificacion_Anulacion', headerName: 'Justif. Anul.', flex: 1, minWidth: 200, renderCell: (params) => ({params.value || '-'}) }, + ]; + break; default: setError(`La vista de auditoría para '${filtroTipoEntidad}' aún no está implementada.`); setDatosAuditoria([]); diff --git a/Frontend/src/pages/Contables/CierresCuentaCorrientePage.tsx b/Frontend/src/pages/Contables/CierresCuentaCorrientePage.tsx new file mode 100644 index 0000000..dab2439 --- /dev/null +++ b/Frontend/src/pages/Contables/CierresCuentaCorrientePage.tsx @@ -0,0 +1,448 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + Box, Typography, Button, Paper, IconButton, Chip, Alert, CircularProgress, + Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TablePagination, + FormControl, InputLabel, Select, MenuItem, Tabs, Tab, Tooltip +} from '@mui/material'; +import AddIcon from '@mui/icons-material/Add'; +import LockOpenIcon from '@mui/icons-material/LockOpen'; +import HistoryIcon from '@mui/icons-material/History'; +import FilterListIcon from '@mui/icons-material/FilterList'; + +import cierresCcService from '../../services/Contables/cierresCcService'; +import distribuidorService from '../../services/Distribucion/distribuidorService'; +import empresaService from '../../services/Distribucion/empresaService'; + +import type { CierreCuentaCorrienteDto } from '../../models/dtos/Contables/CierreCuentaCorrienteDto'; +import type { CrearCierreDto } from '../../models/dtos/Contables/CrearCierreDto'; +import type { ReabrirCierreDto } from '../../models/dtos/Contables/ReabrirCierreDto'; +import type { CierreCuentaCorrienteHistorialDto } from '../../models/dtos/Auditoria/CierreCuentaCorrienteHistorialDto'; +import type { DistribuidorDropdownDto } from '../../models/dtos/Distribucion/DistribuidorDropdownDto'; +import type { EmpresaDropdownDto } from '../../models/dtos/Distribucion/EmpresaDropdownDto'; + +import NuevoCierreModal from '../../components/Modals/Contables/NuevoCierreModal'; +import ReabrirCierreModal from '../../components/Modals/Contables/ReabrirCierreModal'; +import { usePermissions } from '../../hooks/usePermissions'; +import axios from 'axios'; + +const formatDate = (dateString?: string | null): string => { + if (!dateString) return '-'; + const datePart = dateString.split('T')[0]; + const parts = datePart.split('-'); + if (parts.length === 3) return `${parts[2]}/${parts[1]}/${parts[0]}`; + return datePart; +}; + +const formatDateTime = (dateString?: string | null): string => { + if (!dateString) return '-'; + const d = new Date(dateString); + if (isNaN(d.getTime())) return dateString; + return d.toLocaleString('es-AR', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false + }); +}; + +// Extrae el codigo de error del backend si viene en el response (PERIODO_CERRADO_BLOQUEO_OPERACION, +// CIERRE_FECHA_FUTURA, etc.) y arma un mensaje legible. Si no hay codigo, usa message generico. +const parseApiError = (err: unknown, fallback: string): string => { + if (axios.isAxiosError(err)) { + const data = err.response?.data; + if (data && typeof data === 'object') { + const codigo = (data as { codigo?: string }).codigo; + const mensaje = (data as { mensaje?: string; message?: string }).mensaje + || (data as { mensaje?: string; message?: string }).message; + if (mensaje) return codigo ? `[${codigo}] ${mensaje}` : mensaje; + if (codigo) return `Error: ${codigo}`; + } + } + return fallback; +}; + +const CierresCuentaCorrientePage: React.FC = () => { + const { tienePermiso, isSuperAdmin } = usePermissions(); + const puedeVer = isSuperAdmin || tienePermiso('CC003'); + const puedeCrear = isSuperAdmin || tienePermiso('CC001'); + // Reapertura: exclusivo SuperAdmin (CC002 no se valida — no es asignable a perfiles). + const puedeReabrir = isSuperAdmin; + + const [filtroIdDistribuidor, setFiltroIdDistribuidor] = useState(''); + const [filtroIdEmpresa, setFiltroIdEmpresa] = useState(''); + + const [distribuidores, setDistribuidores] = useState([]); + const [empresas, setEmpresas] = useState([]); + const [loadingDropdowns, setLoadingDropdowns] = useState(false); + + const [cierres, setCierres] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [pageApiErrorMessage, setPageApiErrorMessage] = useState(null); + const [modalApiErrorMessage, setModalApiErrorMessage] = useState(null); + + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(25); + + const [tabIndex, setTabIndex] = useState(0); + const [cierreSeleccionadoHistorial, setCierreSeleccionadoHistorial] = useState(null); + const [historial, setHistorial] = useState([]); + const [loadingHistorial, setLoadingHistorial] = useState(false); + + const [nuevoModalOpen, setNuevoModalOpen] = useState(false); + const [reabrirModalOpen, setReabrirModalOpen] = useState(false); + const [cierreParaReabrir, setCierreParaReabrir] = useState(null); + + // Carga dropdowns una sola vez al montar + useEffect(() => { + const fetchDropdowns = async () => { + setLoadingDropdowns(true); + try { + const [distData, empData] = await Promise.all([ + distribuidorService.getAllDistribuidoresDropdown(), + empresaService.getEmpresasDropdown() + ]); + setDistribuidores(distData); + setEmpresas(empData); + } catch (err) { + console.error(err); + setError('Error al cargar opciones de filtro.'); + } finally { + setLoadingDropdowns(false); + } + }; + fetchDropdowns(); + }, []); + + const cargarCierres = useCallback(async () => { + if (!puedeVer) { + setError('No tiene permiso.'); + return; + } + if (!filtroIdDistribuidor || !filtroIdEmpresa) { + setCierres([]); + return; + } + + setLoading(true); + setError(null); + setPageApiErrorMessage(null); + try { + const data = await cierresCcService.getAllCierres( + Number(filtroIdDistribuidor), + Number(filtroIdEmpresa) + ); + setCierres(data); + setPage(0); + } catch (err) { + console.error(err); + setPageApiErrorMessage(parseApiError(err, 'Error al cargar los cierres.')); + } finally { + setLoading(false); + } + }, [puedeVer, filtroIdDistribuidor, filtroIdEmpresa]); + + useEffect(() => { cargarCierres(); }, [cargarCierres]); + + const cargarHistorial = useCallback(async (idCierre: number) => { + setLoadingHistorial(true); + try { + const data = await cierresCcService.getHistorialCierre(idCierre); + setHistorial(data); + } catch (err) { + console.error(err); + setPageApiErrorMessage(parseApiError(err, 'Error al cargar el historial.')); + } finally { + setLoadingHistorial(false); + } + }, []); + + const handleSelectCierreHistorial = (cierre: CierreCuentaCorrienteDto) => { + setCierreSeleccionadoHistorial(cierre); + setTabIndex(1); + cargarHistorial(cierre.idCierre); + }; + + const clearModalApiErrorMessage = useCallback(() => setModalApiErrorMessage(null), []); + + const handleSubmitNuevoCierre = async (data: CrearCierreDto) => { + setModalApiErrorMessage(null); + try { + const creado = await cierresCcService.crearCierre(data); + cargarCierres(); + return creado; + } catch (err) { + const msg = parseApiError(err, 'Error al crear el cierre.'); + setModalApiErrorMessage(msg); + throw err; + } + }; + + const handleOpenReabrir = (cierre: CierreCuentaCorrienteDto) => { + setCierreParaReabrir(cierre); + setReabrirModalOpen(true); + }; + + const handleSubmitReabrir = async (idCierre: number, data: ReabrirCierreDto) => { + setModalApiErrorMessage(null); + try { + const result = await cierresCcService.reabrirCierre(idCierre, data); + cargarCierres(); + if (cierreSeleccionadoHistorial?.idCierre === idCierre) { + cargarHistorial(idCierre); + } + return result; + } catch (err) { + const msg = parseApiError(err, 'Error al reabrir el cierre.'); + setModalApiErrorMessage(msg); + throw err; + } + }; + + const handleChangePage = (_e: unknown, newPage: number) => setPage(newPage); + const handleChangeRowsPerPage = (event: React.ChangeEvent) => { + setRowsPerPage(parseInt(event.target.value, 10)); + setPage(0); + }; + + const displayData = cierres.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage); + + if (!puedeVer) { + return ( + + No tiene permiso para acceder a esta sección. + + ); + } + + return ( + + Cierres de Cuenta Corriente + + + Filtros + + + Distribuidor + + + + Empresa + + + + {puedeCrear && ( + + )} + + + + setTabIndex(v)}> + + } + iconPosition="start" + /> + + + + {error && {error}} + {pageApiErrorMessage && {pageApiErrorMessage}} + + {tabIndex === 0 && ( + <> + {loading && ( + + + + )} + {!loading && (!filtroIdDistribuidor || !filtroIdEmpresa) && ( + Seleccioná un distribuidor y una empresa para ver los cierres. + )} + {!loading && filtroIdDistribuidor && filtroIdEmpresa && ( + + + + + Fecha de Corte + Fecha de Cierre + Saldo del Cierre + Estado + Justificación + Usuario + Acciones + + + + {displayData.length === 0 ? ( + No hay cierres registrados. + ) : ( + displayData.map((c) => ( + + {formatDate(c.fechaCorte)} + {formatDateTime(c.fechaCierre)} + + {c.saldoCierre.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })} + + + + + + + + {c.justificacion || '-'} + + + + {c.nombreUsuarioCierre} + + + handleSelectCierreHistorial(c)}> + + + + {puedeReabrir && c.estado === 'Activo' && c.esUltimoVigente && ( + + handleOpenReabrir(c)} + > + + + + )} + + + )) + )} + +
+ +
+ )} + + )} + + {tabIndex === 1 && cierreSeleccionadoHistorial && ( + <> + + + Historial del cierre #{cierreSeleccionadoHistorial.idCierre} + {' — '}{cierreSeleccionadoHistorial.nombreDistribuidor} / {cierreSeleccionadoHistorial.nombreEmpresa} + {' — '}corte: {formatDate(cierreSeleccionadoHistorial.fechaCorte)} + + + {loadingHistorial && ( + + + + )} + {!loadingHistorial && ( + + + + + Tipo Modificación + Fecha Modificación + Usuario + Saldo + Estado + Justificación + + + + {historial.length === 0 ? ( + No hay registros de auditoría. + ) : ( + historial.map((h) => ( + + {h.tipoMod} + {formatDateTime(h.fechaMod)} + {h.nombreUsuarioModifico} + + {h.saldoCierre.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })} + + + + + + + + {h.justificacion_Anulacion || h.justificacion || '-'} + + + + + )) + )} + +
+
+ )} + + )} + + setNuevoModalOpen(false)} + onSubmit={handleSubmitNuevoCierre} + initialIdDistribuidor={filtroIdDistribuidor ? Number(filtroIdDistribuidor) : null} + initialIdEmpresa={filtroIdEmpresa ? Number(filtroIdEmpresa) : null} + errorMessage={modalApiErrorMessage} + clearErrorMessage={clearModalApiErrorMessage} + /> + + { setReabrirModalOpen(false); setCierreParaReabrir(null); }} + onSubmit={handleSubmitReabrir} + cierre={cierreParaReabrir} + errorMessage={modalApiErrorMessage} + clearErrorMessage={clearModalApiErrorMessage} + /> +
+ ); +}; + +export default CierresCuentaCorrientePage; diff --git a/Frontend/src/pages/Contables/ContablesIndexPage.tsx b/Frontend/src/pages/Contables/ContablesIndexPage.tsx index fc008dc..ba2b1aa 100644 --- a/Frontend/src/pages/Contables/ContablesIndexPage.tsx +++ b/Frontend/src/pages/Contables/ContablesIndexPage.tsx @@ -4,11 +4,12 @@ import { Box, Tabs, Tab, Paper, Typography } from '@mui/material'; import { Outlet, useNavigate, useLocation } from 'react-router-dom'; // Define las sub-pestañas del módulo Contables -const contablesSubModules = [ +const contablesSubModules = [ { label: 'Pagos Distribuidores', path: 'pagos-distribuidores' }, { label: 'Notas Crédito/Débito', path: 'notas-cd' }, { label: 'Gestión de Saldos', path: 'gestion-saldos' }, - { label: 'Tipos de Pago', path: 'tipos-pago' }, + { label: 'Tipos de Pago', path: 'tipos-pago' }, + { label: 'Cierres CC', path: 'cierres-cc' }, ]; const ContablesIndexPage: React.FC = () => { diff --git a/Frontend/src/pages/Contables/GestionarNotasCDPage.tsx b/Frontend/src/pages/Contables/GestionarNotasCDPage.tsx index 591b965..fc4b467 100644 --- a/Frontend/src/pages/Contables/GestionarNotasCDPage.tsx +++ b/Frontend/src/pages/Contables/GestionarNotasCDPage.tsx @@ -24,7 +24,7 @@ import type { EmpresaDto } from '../../models/dtos/Distribucion/EmpresaDto'; import NotaCreditoDebitoFormModal from '../../components/Modals/Contables/NotaCreditoDebitoFormModal'; import { usePermissions } from '../../hooks/usePermissions'; -import axios from 'axios'; +import { parseApiError } from '../../utils/apiErrorParser'; type DestinoFiltroType = 'Distribuidores' | 'Canillas' | ''; type TipoNotaFiltroType = 'Credito' | 'Debito' | ''; @@ -124,7 +124,7 @@ const GestionarNotasCDPage: React.FC = () => { } cargarNotas(); } catch (err: any) { - const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al guardar la nota.'; + const { message } = parseApiError(err, 'Error al guardar la nota.'); setApiErrorMessage(message); throw err; } }; @@ -133,7 +133,7 @@ const GestionarNotasCDPage: React.FC = () => { if (window.confirm(`¿Seguro de eliminar esta nota (ID: ${idNota})? Esta acción revertirá el impacto en el saldo.`)) { setApiErrorMessage(null); try { await notaCreditoDebitoService.deleteNota(idNota); cargarNotas(); } - catch (err: any) { const msg = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al eliminar.'; setApiErrorMessage(msg); } + catch (err: any) { const { message } = parseApiError(err, 'Error al eliminar.'); setApiErrorMessage(message); } } handleMenuClose(); }; diff --git a/Frontend/src/pages/Contables/GestionarPagosDistribuidorPage.tsx b/Frontend/src/pages/Contables/GestionarPagosDistribuidorPage.tsx index 05c95f3..db4672d 100644 --- a/Frontend/src/pages/Contables/GestionarPagosDistribuidorPage.tsx +++ b/Frontend/src/pages/Contables/GestionarPagosDistribuidorPage.tsx @@ -22,7 +22,7 @@ import type { EmpresaDto } from '../../models/dtos/Distribucion/EmpresaDto'; import PagoDistribuidorFormModal from '../../components/Modals/Contables/PagoDistribuidorFormModal'; import { usePermissions } from '../../hooks/usePermissions'; -import axios from 'axios'; +import { parseApiError } from '../../utils/apiErrorParser'; const GestionarPagosDistribuidorPage: React.FC = () => { const [pagos, setPagos] = useState([]); @@ -110,7 +110,7 @@ const GestionarPagosDistribuidorPage: React.FC = () => { } cargarPagos(); } catch (err: any) { - const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al guardar el pago.'; + const { message } = parseApiError(err, 'Error al guardar el pago.'); setModalApiErrorMessage(message); throw err; } @@ -121,8 +121,8 @@ const GestionarPagosDistribuidorPage: React.FC = () => { setPageApiErrorMessage(null); try { await pagoDistribuidorService.deletePagoDistribuidor(idPago); cargarPagos(); } catch (err: any) { - const msg = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al eliminar.'; - setPageApiErrorMessage(msg); + const { message } = parseApiError(err, 'Error al eliminar.'); + setPageApiErrorMessage(message); } } handleMenuClose(); diff --git a/Frontend/src/pages/Contables/GestionarSaldosPage.tsx b/Frontend/src/pages/Contables/GestionarSaldosPage.tsx index 96f342f..75b40f9 100644 --- a/Frontend/src/pages/Contables/GestionarSaldosPage.tsx +++ b/Frontend/src/pages/Contables/GestionarSaldosPage.tsx @@ -20,7 +20,7 @@ import type { CanillaDto } from '../../models/dtos/Distribucion/CanillaDto'; import AjusteSaldoModal from '../../components/Modals/Contables/AjusteSaldoModal'; import { usePermissions } from '../../hooks/usePermissions'; -import axios from 'axios'; +import { parseApiError } from '../../utils/apiErrorParser'; type TipoDestinoFiltro = 'Distribuidores' | 'Canillas' | ''; @@ -48,7 +48,7 @@ const GestionarSaldosPage: React.FC = () => { const { tienePermiso, isSuperAdmin } = usePermissions(); const puedeVerSaldos = isSuperAdmin || tienePermiso("CS001"); // Permiso para ver - const puedeAjustarSaldos = isSuperAdmin || tienePermiso("CS002"); // Permiso para ajustar + const puedeAjustarSaldos = isSuperAdmin; // Ajuste manual: exclusivo SuperAdmin (no se valida CS002) const fetchDropdownData = useCallback(async () => { @@ -128,10 +128,8 @@ const GestionarSaldosPage: React.FC = () => { await saldoService.ajustarSaldo(data); cargarSaldos(); // Recargar lista para ver el saldo actualizado } catch (err: any) { - const message = axios.isAxiosError(err) && err.response?.data?.message - ? err.response.data.message - : 'Error al aplicar el ajuste de saldo.'; - setApiErrorMessage(message); + const { message } = parseApiError(err, 'Error al aplicar el ajuste de saldo.'); + setApiErrorMessage(message); throw err; // Para que el modal sepa que hubo error } }; diff --git a/Frontend/src/pages/Distribucion/GestionarEntradasSalidasDistPage.tsx b/Frontend/src/pages/Distribucion/GestionarEntradasSalidasDistPage.tsx index 4329783..035e67f 100644 --- a/Frontend/src/pages/Distribucion/GestionarEntradasSalidasDistPage.tsx +++ b/Frontend/src/pages/Distribucion/GestionarEntradasSalidasDistPage.tsx @@ -22,7 +22,7 @@ import type { DistribuidorDropdownDto } from '../../models/dtos/Distribucion/Dis import EntradaSalidaDistFormModal from '../../components/Modals/Distribucion/EntradaSalidaDistFormModal'; import { usePermissions } from '../../hooks/usePermissions'; -import axios from 'axios'; +import { parseApiError } from '../../utils/apiErrorParser'; const GestionarEntradasSalidasDistPage: React.FC = () => { const [movimientos, setMovimientos] = useState([]); @@ -115,7 +115,7 @@ const GestionarEntradasSalidasDistPage: React.FC = () => { } cargarMovimientos(); } catch (err: any) { - const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al guardar.'; + const { message } = parseApiError(err, 'Error al guardar.'); setApiErrorMessage(message); throw err; } }; @@ -124,7 +124,7 @@ const GestionarEntradasSalidasDistPage: React.FC = () => { if (window.confirm(`¿Seguro (ID: ${idParte})? Esto revertirá el saldo.`)) { setApiErrorMessage(null); try { await entradaSalidaDistService.deleteEntradaSalidaDist(idParte); cargarMovimientos(); } - catch (err: any) { const msg = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al eliminar.'; setApiErrorMessage(msg); } + catch (err: any) { const { message } = parseApiError(err, 'Error al eliminar.'); setApiErrorMessage(message); } } handleMenuClose(); }; diff --git a/Frontend/src/pages/Reportes/ReporteCuentasDistribuidoresPage.tsx b/Frontend/src/pages/Reportes/ReporteCuentasDistribuidoresPage.tsx index 50e3705..07d2bc1 100644 --- a/Frontend/src/pages/Reportes/ReporteCuentasDistribuidoresPage.tsx +++ b/Frontend/src/pages/Reportes/ReporteCuentasDistribuidoresPage.tsx @@ -63,8 +63,9 @@ const ReporteCuentasDistribuidoresPage: React.FC = () => { }); }; - const movs = procesarLista(data.entradasSalidas, 'mov', 0); - const ultimoMov = movs.length ? movs[movs.length - 1].saldoAcumulado : 0; + const baseInicial = data.saldoInicial ?? 0; + const movs = procesarLista(data.entradasSalidas, 'mov', baseInicial); + const ultimoMov = movs.length ? movs[movs.length - 1].saldoAcumulado : baseInicial; const notas = procesarLista(data.debitosCreditos, 'nota', ultimoMov); const ultimoNota = notas.length ? notas[notas.length - 1].saldoAcumulado : ultimoMov; const pagos = procesarLista(data.pagos, 'pago', ultimoNota); @@ -210,7 +211,7 @@ const ReporteCuentasDistribuidoresPage: React.FC = () => { - TOTA DEBE: {totalDebe.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })} + TOTAL DEBE: {totalDebe.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })} TOTAL HABER: {totalHaber.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })} @@ -284,39 +285,44 @@ const ReporteCuentasDistribuidoresPage: React.FC = () => { }, []); const handleExportToExcel = useCallback(() => { - if ( - !originalReportData || - (movimientosConSaldo.length === 0 && - notasConSaldo.length === 0 && - pagosConSaldo.length === 0) - ) { - alert("No hay datos para exportar."); // O un mensaje más amigable - return; + if (!originalReportData) { + alert('No hay datos para exportar.'); + return; } - const wb = XLSX.utils.book_new();// Se crea un nuevo libro + const sumDebe = (rows: Array<{ debe?: number }>) => rows.reduce((s, r) => s + (r.debe || 0), 0); + const sumHaber = (rows: Array<{ haber?: number }>) => rows.reduce((s, r) => s + (r.haber || 0), 0); - // Movimientos - if (movimientosConSaldo.length) { // <--- CHEQUEO 1 - // Si movimientosConSaldo está vacío, esta hoja no se añade + const totalDebeAll = sumDebe(movimientosConSaldo) + sumDebe(notasConSaldo) + sumDebe(pagosConSaldo); + const totalHaberAll = sumHaber(movimientosConSaldo) + sumHaber(notasConSaldo) + sumHaber(pagosConSaldo); + const saldoInicialExp = originalReportData.saldoInicial ?? 0; + const saldoFinalExp = saldoInicialExp + totalDebeAll - totalHaberAll; + + const wb = XLSX.utils.book_new(); + + // Hoja Resumen — siempre presente, con saldo inicial y final aunque no haya movimientos + const resumenRows = [ + { Concepto: 'Saldo Inicial', Monto: saldoInicialExp }, + { Concepto: 'Total Debe', Monto: totalDebeAll }, + { Concepto: 'Total Haber', Monto: totalHaberAll }, + { Concepto: 'Saldo Final', Monto: saldoFinalExp }, + ]; + const wsResumen = XLSX.utils.json_to_sheet(resumenRows); + XLSX.utils.book_append_sheet(wb, wsResumen, 'Resumen'); + + if (movimientosConSaldo.length) { const ws = XLSX.utils.json_to_sheet(movimientosConSaldo.map(({ id, ...rest }) => rest)); XLSX.utils.book_append_sheet(wb, ws, 'Movimientos'); } - // Notas - if (notasConSaldo.length) { // <--- CHEQUEO 2 - // Si notasConSaldo está vacío, esta hoja no se añade + if (notasConSaldo.length) { const ws = XLSX.utils.json_to_sheet(notasConSaldo.map(({ id, ...rest }) => rest)); XLSX.utils.book_append_sheet(wb, ws, 'Notas'); } - // Pagos - if (pagosConSaldo.length) { // <--- CHEQUEO 3 - // Si pagosConSaldo está vacío, esta hoja no se añade + if (pagosConSaldo.length) { const ws = XLSX.utils.json_to_sheet(pagosConSaldo.map(({ id, ...rest }) => rest)); XLSX.utils.book_append_sheet(wb, ws, 'Pagos'); } - // Si ninguno de los arrays tiene datos, el libro 'wb' quedará vacío. - // Y la siguiente línea dará el error: XLSX.writeFile(wb, `Reporte_${Date.now()}.xlsx`); }, [originalReportData, movimientosConSaldo, notasConSaldo, pagosConSaldo]); @@ -355,7 +361,8 @@ const ReporteCuentasDistribuidoresPage: React.FC = () => { const totalMov = movimientosConSaldo.reduce((sum, r) => sum + ((r.debe || 0) - (r.haber || 0)), 0); const totalNot = notasConSaldo.reduce((sum, r) => sum + ((r.debe || 0) - (r.haber || 0)), 0); const totalPag = pagosConSaldo.reduce((sum, r) => sum + ((r.debe || 0) - (r.haber || 0)), 0); - const saldoInicial = originalReportData?.saldos?.[0]?.monto || 0; + const saldoInicial = originalReportData?.saldoInicial ?? 0; + const saldoFinal = saldoInicial + totalMov + totalNot + totalPag; const cols = generarColumns(); @@ -385,7 +392,7 @@ const ReporteCuentasDistribuidoresPage: React.FC = () => { Distribuidor: {currentParams?.nombreDistribuidor} Empresa: {currentParams?.nombreEmpresa} Período: {currentParams?.fechaDesde} al {currentParams?.fechaHasta} - Saldo a la Fecha {new Date().toLocaleDateString('es-AR')}: {saldoInicial.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })} + Saldo Inicial del Período: {saldoInicial.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })} Movimientos de Entrada / Salida @@ -399,11 +406,12 @@ const ReporteCuentasDistribuidoresPage: React.FC = () => { Resumen Final + Saldo Inicial: {saldoInicial.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })} Movimientos (Debe - Haber): {totalMov.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })} Notas C/D (Debe - Haber): {totalNot.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })} Pagos (Debe - Haber): {totalPag.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })} - Saldo Final del Período: {(totalMov + totalNot + totalPag).toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })} + Saldo Final: {saldoFinal.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })} diff --git a/Frontend/src/pages/Reportes/SeleccionaReporteCuentasDistribuidores.tsx b/Frontend/src/pages/Reportes/SeleccionaReporteCuentasDistribuidores.tsx index c70bd93..df529f2 100644 --- a/Frontend/src/pages/Reportes/SeleccionaReporteCuentasDistribuidores.tsx +++ b/Frontend/src/pages/Reportes/SeleccionaReporteCuentasDistribuidores.tsx @@ -1,12 +1,14 @@ import React, { useState, useEffect } from 'react'; import { Box, Typography, TextField, Button, CircularProgress, Alert, - FormControl, InputLabel, Select, MenuItem + FormControl, InputLabel, Select, MenuItem, Tooltip } from '@mui/material'; +import EventAvailableIcon from '@mui/icons-material/EventAvailable'; import type { DistribuidorDropdownDto } from '../../models/dtos/Distribucion/DistribuidorDropdownDto'; import distribuidorService from '../../services/Distribucion/distribuidorService'; import type { EmpresaDropdownDto } from '../../models/dtos/Distribucion/EmpresaDropdownDto'; import empresaService from '../../services/Distribucion/empresaService'; +import cierresCcService from '../../services/Contables/cierresCcService'; interface SeleccionaReporteCuentasDistribuidoresProps { onGenerarReporte: (params: { @@ -20,6 +22,18 @@ interface SeleccionaReporteCuentasDistribuidoresProps { apiErrorMessage?: string | null; } +// Suma 1 día a una fecha en formato yyyy-MM-dd y devuelve la fecha resultante +// también en yyyy-MM-dd. Usado por el atajo "Desde último cierre" para arrancar +// el reporte el día siguiente al cierre. +const addOneDay = (yyyyMmDd: string): string => { + const d = new Date(yyyyMmDd + 'T00:00:00'); + d.setDate(d.getDate() + 1); + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +}; + const SeleccionaReporteCuentasDistribuidores: React.FC = ({ onGenerarReporte, isLoading, @@ -35,15 +49,19 @@ const SeleccionaReporteCuentasDistribuidores: React.FC({}); + const [loadingUltimoCierre, setLoadingUltimoCierre] = useState(false); + const [hayCierrePrevio, setHayCierrePrevio] = useState(null); + const [infoCierre, setInfoCierre] = useState(null); + useEffect(() => { const fetchData = async () => { setLoadingDropdowns(true); try { const [distData, empData] = await Promise.all([ - distribuidorService.getAllDistribuidoresDropdown(), // Asume que este servicio existe - empresaService.getEmpresasDropdown() // Asume que este servicio existe + distribuidorService.getAllDistribuidoresDropdown(), + empresaService.getEmpresasDropdown() ]); - setDistribuidores(distData.map(d => d)); // El servicio devuelve tupla + setDistribuidores(distData.map(d => d)); setEmpresas(empData); } catch (error) { console.error("Error al cargar datos:", error); @@ -55,6 +73,13 @@ const SeleccionaReporteCuentasDistribuidores: React.FC { + setHayCierrePrevio(null); + setInfoCierre(null); + }, [idDistribuidor, idEmpresa]); + const validate = (): boolean => { const errors: { [key: string]: string | null } = {}; if (!idDistribuidor) errors.idDistribuidor = 'Debe seleccionar un distribuidor.'; @@ -78,6 +103,37 @@ const SeleccionaReporteCuentasDistribuidores: React.FC { + if (!idDistribuidor || !idEmpresa) return; + setLoadingUltimoCierre(true); + setInfoCierre(null); + try { + const ultimo = await cierresCcService.getUltimoCierre(Number(idDistribuidor), Number(idEmpresa)); + if (ultimo === null) { + setHayCierrePrevio(false); + setInfoCierre('Sin cierres previos para este distribuidor y empresa.'); + } else { + const nuevaFechaDesde = addOneDay(ultimo.fechaCorte); + setFechaDesde(nuevaFechaDesde); + setLocalErrors(p => ({ ...p, fechaDesde: null, fechaHasta: null })); + setHayCierrePrevio(true); + setInfoCierre(`Último cierre: ${ultimo.fechaCorte}. Fecha Desde ajustada al día siguiente.`); + } + } catch (err) { + console.error('Error al obtener último cierre:', err); + setInfoCierre('Error al consultar el último cierre.'); + } finally { + setLoadingUltimoCierre(false); + } + }; + + const atajoDisabled = !idDistribuidor || !idEmpresa || loadingUltimoCierre || isLoading || hayCierrePrevio === false; + const atajoTooltip = !idDistribuidor || !idEmpresa + ? 'Seleccioná distribuidor y empresa primero.' + : hayCierrePrevio === false + ? 'Sin cierres previos.' + : 'Autocompleta Fecha Desde con el día siguiente al último cierre.'; + return ( @@ -115,19 +171,42 @@ const SeleccionaReporteCuentasDistribuidores: React.FC{localErrors.idEmpresa}} - { setFechaDesde(e.target.value); setLocalErrors(p => ({ ...p, fechaDesde: null, fechaHasta: null })); }} - margin="normal" - fullWidth - required - error={!!localErrors.fechaDesde} - helperText={localErrors.fechaDesde} - disabled={isLoading} - InputLabelProps={{ shrink: true }} - /> + + { setFechaDesde(e.target.value); setLocalErrors(p => ({ ...p, fechaDesde: null, fechaHasta: null })); }} + margin="normal" + fullWidth + required + error={!!localErrors.fechaDesde} + helperText={localErrors.fechaDesde} + disabled={isLoading} + InputLabelProps={{ shrink: true }} + /> + + + + + + + + {infoCierre && ( + + {infoCierre} + + )} + = new Set(["CS002", "CC002"]); + const getModuloFromSeccionCodAcc = (codAcc: string): string | null => { if (codAcc === "SS001") return "Distribución"; if (codAcc === "SS007") return "Suscripciones"; @@ -44,7 +48,8 @@ const getModuloConceptualDelPermiso = (permisoModulo: string): string => { } if (moduloLower.includes("cuentas pagos") || moduloLower.includes("cuentas notas") || - moduloLower.includes("cuentas tipos pagos")) { + moduloLower.includes("cuentas tipos pagos") || + moduloLower.includes("cuentas cierres")) { return "Contables"; } if (moduloLower.includes("impresión tiradas") || @@ -104,9 +109,12 @@ const AsignarPermisosAPerfilPage: React.FC = () => { perfilService.getPerfilById(idPerfilNum), perfilService.getPermisosPorPerfil(idPerfilNum) // Esto devuelve todos los permisos con su estado 'asignado' ]); + // Filtrar permisos exclusivos de SuperAdmin (CS002, CC002) para que no aparezcan asignables. + // No se altera la asignación existente en la DB — solo se ocultan de esta UI. + const permisosVisibles = permisosData.filter(p => !PERMISOS_SOLO_SUPERADMIN.has(p.codAcc)); setPerfil(perfilData); - setPermisosDisponibles(permisosData); - setPermisosSeleccionados(new Set(permisosData.filter(p => p.asignado).map(p => p.id))); + setPermisosDisponibles(permisosVisibles); + setPermisosSeleccionados(new Set(permisosVisibles.filter(p => p.asignado).map(p => p.id))); } catch (err) { console.error(err); setError('Error al cargar datos del perfil o permisos.'); diff --git a/Frontend/src/routes/AppRoutes.tsx b/Frontend/src/routes/AppRoutes.tsx index f3d2c59..f21eda7 100644 --- a/Frontend/src/routes/AppRoutes.tsx +++ b/Frontend/src/routes/AppRoutes.tsx @@ -39,6 +39,7 @@ import GestionarTiposPagoPage from '../pages/Contables/GestionarTiposPagoPage'; import GestionarPagosDistribuidorPage from '../pages/Contables/GestionarPagosDistribuidorPage'; import GestionarNotasCDPage from '../pages/Contables/GestionarNotasCDPage'; import GestionarSaldosPage from '../pages/Contables/GestionarSaldosPage'; +import CierresCuentaCorrientePage from '../pages/Contables/CierresCuentaCorrientePage'; // Usuarios import UsuariosIndexPage from '../pages/Usuarios/UsuariosIndexPage'; // Crear este componente @@ -241,6 +242,14 @@ const AppRoutes = () => { } /> } /> } /> + + + + } + /> {/* Módulo de Impresión (anidado) */} diff --git a/Frontend/src/services/Auditoria/auditoriaService.ts b/Frontend/src/services/Auditoria/auditoriaService.ts index 0000d22..7ed5a02 100644 --- a/Frontend/src/services/Auditoria/auditoriaService.ts +++ b/Frontend/src/services/Auditoria/auditoriaService.ts @@ -29,6 +29,7 @@ import type { PerfilHistorialDto } from '../../models/dtos/Auditoria/PerfilHisto import type { PermisoHistorialDto } from '../../models/dtos/Auditoria/PermisoHistorialDto'; import type { PermisosPerfilesHistorialDto } from '../../models/dtos/Auditoria/PermisosPerfilesHistorialDto'; import type { CambioParadaHistorialDto } from '../../models/dtos/Auditoria/CambioParadaHistorialDto'; +import type { CierreCuentaCorrienteHistorialDto } from '../../models/dtos/Auditoria/CierreCuentaCorrienteHistorialDto'; interface HistorialParamsComunes { fechaDesde?: string; // "yyyy-MM-dd" @@ -41,6 +42,10 @@ interface HistorialCambiosParadaParams extends HistorialParamsComunes { idCanillaAfectado?: number; } +interface HistorialCierresCCParams extends HistorialParamsComunes { + idCierreAfectado?: number; +} + interface HistorialPermisosPerfilesParams extends HistorialParamsComunes { idPerfilAfectado?: number; idPermisoAfectado?: number; @@ -462,11 +467,20 @@ const getHistorialCambiosParada = async (params: HistorialCambiosParadaParams): const queryParams: any = { ...params }; if (params.idUsuarioModificador) queryParams.idUsuarioModifico = params.idUsuarioModificador; delete queryParams.idUsuarioModificador; - + const response = await apiClient.get('/auditoria/cambios-parada-canilla', { params: queryParams }); return response.data; }; +const getHistorialCierresCC = async (params: HistorialCierresCCParams): Promise => { + const queryParams: any = { ...params }; + if (params.idUsuarioModificador) queryParams.idUsuarioModifico = params.idUsuarioModificador; + delete queryParams.idUsuarioModificador; + + const response = await apiClient.get('/auditoria/cierres-cuenta-corriente', { params: queryParams }); + return response.data; +}; + const auditoriaService = { getHistorialUsuarios, getHistorialPagosDistribuidor, @@ -498,6 +512,7 @@ const auditoriaService = { getHistorialPermisosMaestro, getHistorialPermisosPerfiles, getHistorialCambiosParada, + getHistorialCierresCC, }; export default auditoriaService; \ No newline at end of file diff --git a/Frontend/src/services/Contables/cierresCcService.ts b/Frontend/src/services/Contables/cierresCcService.ts new file mode 100644 index 0000000..ecc1295 --- /dev/null +++ b/Frontend/src/services/Contables/cierresCcService.ts @@ -0,0 +1,75 @@ +import apiClient from '../apiClient'; +import type { CierreCuentaCorrienteDto } from '../../models/dtos/Contables/CierreCuentaCorrienteDto'; +import type { CrearCierreDto } from '../../models/dtos/Contables/CrearCierreDto'; +import type { ReabrirCierreDto } from '../../models/dtos/Contables/ReabrirCierreDto'; +import type { UltimoCierreDto } from '../../models/dtos/Contables/UltimoCierreDto'; +import type { CierreCuentaCorrienteHistorialDto } from '../../models/dtos/Auditoria/CierreCuentaCorrienteHistorialDto'; + +const getAllCierres = async ( + idDistribuidor: number, + idEmpresa: number, +): Promise => { + const response = await apiClient.get('/cierres-cc', { + params: { idDistribuidor, idEmpresa }, + }); + return response.data; +}; + +// Devuelve null si no hay cierre vigente (backend responde 404 — lo capturamos acá +// para que las páginas no tengan que manejar el error). +const getUltimoCierre = async ( + idDistribuidor: number, + idEmpresa: number, +): Promise => { + try { + const response = await apiClient.get('/cierres-cc/ultimo', { + params: { idDistribuidor, idEmpresa }, + }); + return response.data; + } catch (error: unknown) { + if ( + typeof error === 'object' && + error !== null && + 'response' in error && + (error as { response?: { status?: number } }).response?.status === 404 + ) { + return null; + } + throw error; + } +}; + +const crearCierre = async (data: CrearCierreDto): Promise => { + const response = await apiClient.post('/cierres-cc', data); + return response.data; +}; + +const reabrirCierre = async ( + idCierre: number, + data: ReabrirCierreDto, +): Promise => { + const response = await apiClient.post( + `/cierres-cc/${idCierre}/reabrir`, + data, + ); + return response.data; +}; + +const getHistorialCierre = async ( + idCierre: number, +): Promise => { + const response = await apiClient.get( + `/cierres-cc/${idCierre}/historial` + ); + return response.data; +}; + +const cierresCcService = { + getAllCierres, + getUltimoCierre, + crearCierre, + reabrirCierre, + getHistorialCierre, +}; + +export default cierresCcService; diff --git a/Frontend/src/utils/apiErrorParser.ts b/Frontend/src/utils/apiErrorParser.ts new file mode 100644 index 0000000..95a9caf --- /dev/null +++ b/Frontend/src/utils/apiErrorParser.ts @@ -0,0 +1,49 @@ +import axios from 'axios'; + +export interface ParsedApiError { + /** Mensaje listo para mostrar al usuario. */ + message: string; + /** True si el error es un bloqueo por período cerrado (codigo PERIODO_CERRADO_BLOQUEO_OPERACION). */ + isPeriodoCerrado: boolean; + /** Código semántico del backend, si vino. */ + codigo?: string; +} + +/** + * Parsea el body de un error de Axios siguiendo los shapes que devuelve la API: + * - ExceptionHandlerMiddleware: { codigo, mensaje, idCierre?, fechaCorte? } (period-cerrado) + * - Service tuple wrapped: { message } (legacy, inglés) + * - Service tuple nuevo: { mensaje } (español) + * Si nada matchea, devuelve el fallback recibido. + */ +export function parseApiError(err: unknown, fallback = 'Ocurrió un error inesperado.'): ParsedApiError { + if (!axios.isAxiosError(err) || !err.response?.data) { + return { message: fallback, isPeriodoCerrado: false }; + } + + const data = err.response.data as Record; + const codigo = typeof data.codigo === 'string' ? data.codigo : undefined; + + if (codigo === 'PERIODO_CERRADO_BLOQUEO_OPERACION') { + const mensajeBackend = typeof data.mensaje === 'string' ? data.mensaje : ''; + const fechaCorteRaw = typeof data.fechaCorte === 'string' ? data.fechaCorte : ''; + const fechaFormateada = fechaCorteRaw + ? new Date(fechaCorteRaw).toLocaleDateString('es-AR') + : ''; + const fallbackPeriodo = fechaFormateada + ? `El período está cerrado al ${fechaFormateada}. No se permiten modificaciones sobre fechas anteriores o iguales a la fecha de corte.` + : 'El período está cerrado. No se permiten modificaciones sobre la fecha indicada.'; + return { + message: mensajeBackend || fallbackPeriodo, + isPeriodoCerrado: true, + codigo, + }; + } + + const mensaje = + (typeof data.mensaje === 'string' && data.mensaje) || + (typeof data.message === 'string' && data.message) || + fallback; + + return { message: mensaje, isPeriodoCerrado: false, codigo }; +}