feat(contables): cierre mensual de cuenta corriente de distribuidor
Permite congelar el saldo de un distribuidor por empresa a una fecha de corte y bloquear modificaciones retroactivas sobre el período cerrado. El saldo se calcula sumando movimientos en rango (sin tocar cue_Saldos). Incluye reapertura controlada exclusivamente por SuperAdmin, reporte con saldo inicial, atajo "Desde último cierre", y auditoría del ciclo de vida _H. Permisos CC001/CC002/CC003. Middleware global mapea bloqueos por período cerrado a HTTP 409.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<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);
|
||||
}
|
||||
}
|
||||
@@ -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<decimal> CalcularSaldoInicialReporteAsync(int idDistribuidor, int idEmpresa, DateTime fechaDesde);
|
||||
|
||||
Task<CierreCuentaCorrienteDto?> GetByIdAsync(int idCierre);
|
||||
Task<UltimoCierreDto?> GetUltimoVigenteAsync(int idDistribuidor, int idEmpresa);
|
||||
Task<IEnumerable<CierreCuentaCorrienteDto>> GetAllAsync(
|
||||
int? idDistribuidor, int? idEmpresa, string? estado,
|
||||
DateTime? fechaCorteDesde, DateTime? fechaCorteHasta);
|
||||
Task<IEnumerable<CierreCuentaCorrienteHistorialDto>> GetHistorialAsync(int idCierre);
|
||||
|
||||
// Auditoría general — filtros cruzados sobre todos los cierres.
|
||||
Task<IEnumerable<CierreCuentaCorrienteHistorialDto>> ObtenerHistorialAsync(
|
||||
DateTime? fechaDesde, DateTime? fechaHasta,
|
||||
int? idUsuarioModifico, string? tipoModificacion,
|
||||
int? idCierreAfectado);
|
||||
}
|
||||
}
|
||||
@@ -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<PeriodoCerradoResult> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<NotaCreditoDebitoService> _logger;
|
||||
|
||||
@@ -29,6 +30,7 @@ namespace GestionIntegral.Api.Services.Contables
|
||||
ICanillaRepository canillaRepo,
|
||||
IEmpresaRepository empresaRepo,
|
||||
ISaldoRepository saldoRepo,
|
||||
IPeriodoCerradoValidator periodoCerrado,
|
||||
DbConnectionFactory connectionFactory,
|
||||
ILogger<NotaCreditoDebitoService> 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);
|
||||
|
||||
@@ -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<PagoDistribuidorService> _logger;
|
||||
|
||||
@@ -29,6 +30,7 @@ namespace GestionIntegral.Api.Services.Contables
|
||||
ITipoPagoRepository tipoPagoRepo,
|
||||
IEmpresaRepository empresaRepo,
|
||||
ISaldoRepository saldoRepo,
|
||||
IPeriodoCerradoValidator periodoCerrado,
|
||||
DbConnectionFactory connectionFactory,
|
||||
ILogger<PagoDistribuidorService> 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."); }
|
||||
|
||||
@@ -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<PeriodoCerradoValidator> _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<PeriodoCerradoValidator> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<PeriodoCerradoResult> 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<ICierreCuentaCorrienteRepository>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<SaldoService> _logger;
|
||||
|
||||
@@ -27,6 +28,7 @@ namespace GestionIntegral.Api.Services.Contables
|
||||
IDistribuidorRepository distribuidorRepo,
|
||||
ICanillaRepository canillaRepo,
|
||||
IEmpresaRepository empresaRepo,
|
||||
IPeriodoCerradoValidator periodoCerrado,
|
||||
DbConnectionFactory connectionFactory,
|
||||
ILogger<SaldoService> 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."); }
|
||||
|
||||
@@ -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<EntradaSalidaDistService> _logger;
|
||||
|
||||
@@ -36,6 +38,7 @@ namespace GestionIntegral.Api.Services.Distribucion
|
||||
IPorcPagoRepository porcPagoRepository,
|
||||
ISaldoRepository saldoRepository,
|
||||
IEmpresaRepository empresaRepository,
|
||||
IPeriodoCerradoValidator periodoCerrado,
|
||||
DbConnectionFactory connectionFactory,
|
||||
ILogger<EntradaSalidaDistService> 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 { }
|
||||
|
||||
@@ -53,12 +53,14 @@ namespace GestionIntegral.Api.Services.Reportes
|
||||
Task<(IEnumerable<ComparativaConsumoBobinasDto> Data, string? Error)> ObtenerComparativaConsumoBobinasAsync(DateTime fechaInicioMesA, DateTime fechaFinMesA, DateTime fechaInicioMesB, DateTime fechaFinMesB, int idPlanta);
|
||||
Task<(IEnumerable<ComparativaConsumoBobinasDto> Data, string? Error)> ObtenerComparativaConsumoBobinasConsolidadoAsync(DateTime fechaInicioMesA, DateTime fechaFinMesA, DateTime fechaInicioMesB, DateTime fechaFinMesB);
|
||||
|
||||
// DTOs para ReporteCuentasDistribuidores
|
||||
// DTOs para ReporteCuentasDistribuidores.
|
||||
// El antiguo IEnumerable<SaldoDto> 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<BalanceCuentaDistDto> EntradasSalidas,
|
||||
IEnumerable<BalanceCuentaDebCredDto> DebitosCreditos,
|
||||
IEnumerable<BalanceCuentaPagosDto> Pagos,
|
||||
IEnumerable<SaldoDto> Saldos,
|
||||
decimal SaldoInicial,
|
||||
string? Error
|
||||
)> ObtenerReporteCuentasDistribuidorAsync(int idDistribuidor, int idEmpresa, DateTime fechaDesde, DateTime fechaHasta);
|
||||
Task<(IEnumerable<ListadoDistribucionDistSimpleDto> Simple, IEnumerable<ListadoDistribucionDistPromedioDiaDto> Promedios, string? Error)> ObtenerListadoDistribucionDistribuidoresAsync(int idDistribuidor, int idPublicacion, DateTime fechaDesde, DateTime fechaHasta);
|
||||
|
||||
@@ -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<ReportesService> _logger;
|
||||
|
||||
public ReportesService(IReportesRepository reportesRepository, IFacturaRepository facturaRepository, IFacturaDetalleRepository facturaDetalleRepository, IPublicacionRepository publicacionRepository, IEmpresaRepository empresaRepository
|
||||
, ISuscriptorRepository suscriptorRepository, ISuscripcionRepository suscripcionRepository, ILogger<ReportesService> logger)
|
||||
, ISuscriptorRepository suscriptorRepository, ISuscripcionRepository suscripcionRepository, ICierreCuentaCorrienteService cierreService, ILogger<ReportesService> 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<BalanceCuentaDistDto> EntradasSalidas,
|
||||
IEnumerable<BalanceCuentaDebCredDto> DebitosCreditos,
|
||||
IEnumerable<BalanceCuentaPagosDto> Pagos,
|
||||
IEnumerable<SaldoDto> Saldos,
|
||||
decimal SaldoInicial,
|
||||
string? Error
|
||||
)> ObtenerReporteCuentasDistribuidorAsync(int idDistribuidor, int idEmpresa, DateTime fechaDesde, DateTime fechaHasta)
|
||||
{
|
||||
if (fechaDesde > fechaHasta)
|
||||
return (Enumerable.Empty<BalanceCuentaDistDto>(), Enumerable.Empty<BalanceCuentaDebCredDto>(), Enumerable.Empty<BalanceCuentaPagosDto>(), Enumerable.Empty<SaldoDto>(), "Fecha 'Desde' no puede ser mayor que 'Hasta'.");
|
||||
return (Enumerable.Empty<BalanceCuentaDistDto>(), Enumerable.Empty<BalanceCuentaDebCredDto>(), Enumerable.Empty<BalanceCuentaPagosDto>(), 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<BalanceCuentaDistDto>, IEnumerable<BalanceCuentaDistDto>> 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<SaldoDto>(),
|
||||
await siTask,
|
||||
null
|
||||
);
|
||||
}
|
||||
@@ -466,7 +471,7 @@ namespace GestionIntegral.Api.Services.Reportes
|
||||
Enumerable.Empty<BalanceCuentaDistDto>(),
|
||||
Enumerable.Empty<BalanceCuentaDebCredDto>(),
|
||||
Enumerable.Empty<BalanceCuentaPagosDto>(),
|
||||
Enumerable.Empty<SaldoDto>(),
|
||||
0m,
|
||||
"Error interno al generar el reporte."
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user