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); } }