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:
@@ -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<AuditoriaController> _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<AuditoriaController> 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<CierreCuentaCorrienteHistorialDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> 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<CierreCuentaCorrienteHistorialDto>());
|
||||
}
|
||||
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<CambioParadaHistorialDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetHistorialCambiosParada(
|
||||
|
||||
@@ -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<CierresCuentaCorrienteController> _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<CierresCuentaCorrienteController> 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<IActionResult> 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<IActionResult> 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<CierreCuentaCorrienteDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<CierreCuentaCorrienteHistorialDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<IActionResult> GetHistorial(int idCierre)
|
||||
{
|
||||
if (!TienePermiso(PermisoVer)) return Forbid();
|
||||
var historial = await _cierreService.GetHistorialAsync(idCierre);
|
||||
return Ok(historial);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,8 @@ namespace GestionIntegral.Api.Controllers.Contables
|
||||
private readonly ISaldoService _saldoService;
|
||||
private readonly ILogger<SaldosController> _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<SaldosController> logger)
|
||||
@@ -76,11 +75,11 @@ namespace GestionIntegral.Api.Controllers.Contables
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> 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.");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<BalanceCuentaDistDto>(),
|
||||
DebitosCreditos = debitosCreditos ?? Enumerable.Empty<BalanceCuentaDebCredDto>(),
|
||||
Pagos = pagos ?? Enumerable.Empty<BalanceCuentaPagosDto>(),
|
||||
Saldos = saldos ?? Enumerable.Empty<SaldoDto>(),
|
||||
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"),
|
||||
|
||||
Reference in New Issue
Block a user