352 lines
18 KiB
C#
352 lines
18 KiB
C#
|
|
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<CierreCuentaCorrienteService> _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<CierreCuentaCorrienteService> 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<decimal> 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<decimal> 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<decimal>(
|
||
|
|
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<CierreCuentaCorrienteDto?> 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<UltimoCierreDto?> 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<IEnumerable<CierreCuentaCorrienteDto>> GetAllAsync(
|
||
|
|
int? idDistribuidor, int? idEmpresa, string? estado,
|
||
|
|
DateTime? fechaCorteDesde, DateTime? fechaCorteHasta)
|
||
|
|
=> _cierreRepo.GetAllAsync(idDistribuidor, idEmpresa, estado, fechaCorteDesde, fechaCorteHasta);
|
||
|
|
|
||
|
|
public Task<IEnumerable<CierreCuentaCorrienteHistorialDto>> GetHistorialAsync(int idCierre)
|
||
|
|
=> _cierreRepo.GetHistorialAsync(idCierre);
|
||
|
|
|
||
|
|
public Task<IEnumerable<CierreCuentaCorrienteHistorialDto>> ObtenerHistorialAsync(
|
||
|
|
DateTime? fechaDesde, DateTime? fechaHasta,
|
||
|
|
int? idUsuarioModifico, string? tipoModificacion,
|
||
|
|
int? idCierreAfectado)
|
||
|
|
=> _cierreRepo.ObtenerHistorialAsync(fechaDesde, fechaHasta, idUsuarioModifico, tipoModificacion, idCierreAfectado);
|
||
|
|
}
|
||
|
|
}
|