10 Commits

Author SHA1 Message Date
24eaf18fd9 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.
2026-05-07 12:03:26 -03:00
7e274ef114 Actualizar README.md
Some checks failed
Optimized Build and Deploy / remote-build-and-deploy (push) Has been cancelled
2026-03-25 15:00:39 +00:00
5212e31a03 Feat: Baja Lógica de Distribuidores (Selectores Dropdown)
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 8m32s
2026-03-23 14:09:26 -03:00
9201d7222b Fix: Fechas de Estado Bobinas
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 14m8s
- Las fechas de estado de las bobinas no pueden ser anterior a la fecha de remito (ingreso).
2026-02-11 14:52:58 -03:00
fc27b4b43e feat(Reportes): Ajusta cálculo de promedios en Listado de Distribución
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 3m58s
Se modifica la lógica de cálculo para la fila "General" en la tabla de promedios del reporte de Listado de Distribución para distribuidores.

**Motivación:**
Por requerimiento explícito del usuario final, el cálculo de los promedios generales (Llevados, Devueltos, Ventas y % Devolución) debe ser un promedio aritmético simple de los valores de los días de la semana mostrados en la tabla (ej. Viernes, Sábado, Domingo), en lugar del promedio ponderado que se calculaba anteriormente basado en los totales generales.

**Cambios Realizados:**

1.  **Backend (`ListadoDistribucionDistribuidoresViewModel.cs`):**
    *   Se actualizó la propiedad `PromedioGeneral` para que calcule sus valores (Promedio\_Llevados, Promedio\_Devueltos, etc.) promediando directamente los valores de la colección `PromediosPorDia`.

2.  **PDF (`ListadoDistribucionDistribuidoresDocument.cs`):**
    *   Se ajustó la lógica de renderizado de la fila "General" para que el porcentaje de devolución también se calcule como el promedio de los porcentajes de los días individuales, asegurando consistencia con el ViewModel.

3.  **Frontend (`ReporteListadoDistribucionPage.tsx`):**
    *   Se modificó el cálculo del estado `totalesPromedios` dentro de la función `handleGenerarReporte`. Ahora, en lugar de usar los totales de la tabla de detalle, suma los valores de la tabla de promedios y los divide por la cantidad de días para obtener un promedio simple.

**Resultado:**
Tanto la interfaz web como el PDF generado ahora muestran en la fila "General" un promedio simple de las filas de promedios diarios, alineándose con la lógica solicitada por el usuario.
2025-12-05 12:25:18 -03:00
35e8d803b9 Fix: Alinea cálculos del PDF y web en distribución de canillitas
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 4m16s
Este commit soluciona varias inconsistencias de cálculo y visualización que existían entre el reporte de distribución para canillitas en la web y el PDF generado.

Los cambios principales incluyen:

- **Cálculo de Promedios Generales:** Se implementa un promedio ponderado utilizando división decimal y redondeo matemático (`MidpointRounding.AwayFromZero`) en el backend. Esto soluciona las diferencias numéricas en los totales ("Prom. Llevados", "Prom. Devueltos", etc.) que eran causadas por la división de enteros.

- **Corrección de % Devolución:** Se ajusta la fórmula en todo el reporte (web y PDF) para calcular correctamente el porcentaje de devolución (`Devueltos / Llevados`) en lugar del porcentaje de venta que se mostraba erróneamente.

- **Orden de Columnas en PDF:** Se corrige el orden de las columnas "Prom. Devueltos" y "Prom. Ventas" que estaban intercambiadas en la fila "General" del PDF.

- **Precisión en Redondeo Final:** Se refina el cálculo del "% Devolución General" para que se base en los totales sin redondear, eliminando una diferencia de 0.01% y logrando una paridad exacta con la interfaz web.
2025-12-04 10:19:36 -03:00
8e1b8d2326 feat: DataGrid y filtro por Fechas en Stock Bobinas
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 2m15s
Frontend:
- Se reemplazó el componente Table por DataGrid para habilitar ordenamiento y filtrado nativo en cliente.
- Se agregó la UI para filtrar por rango de "Fecha de Estado".
- Se corrigió el tipado de columnas de fecha (`type: 'date'`) implementando un `valueGetter` personalizado que parsea año/mes/día localmente para evitar errores de filtrado por diferencia de Zona Horaria (UTC vs Local).
- Se actualizó `stockBobinaService` para enviar los parámetros `fechaEstadoDesde` y `fechaEstadoHasta`.

Backend:
- Se actualizó `StockBobinasController` para recibir los nuevos parámetros de fecha.
- Se modificó `StockBobinaRepository` implementando la lógica SQL para los nuevos filtros.
2025-11-27 13:49:46 -03:00
bc19e184aa feat: Implementar ingreso de bobinas por lote
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 2m13s
Se introduce una nueva funcionalidad para el ingreso masivo de bobinas a partir de un único remito. Esto agiliza significativamente la carga de datos y reduce errores al evitar la repetición de la planta, número y fecha de remito.

La implementación incluye:
- Un modal maestro-detalle de dos pasos que primero verifica el remito y luego permite la carga de las bobinas.
- Lógica de autocompletado de fecha y feedback al usuario si el remito ya existe.
- Un nuevo endpoint en el backend para procesar el lote de forma transaccional.
2025-11-20 09:50:54 -03:00
29109cff13 Fix Se deshabilita verificación de remito
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 3m41s
Pedido por Claudia Acosta:
Motivo: El ex canillita Sergio Mazza opera como distribuidor y no utiliza remitos.
En el campo de remito se le asigna un numero aleatorio para cumplir con el requisito del sistema.
2025-11-18 13:14:24 -03:00
7f1fadfc84 Feat Se Añade Total a Canillas Page
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 1m48s
2025-11-10 15:07:36 -03:00
109 changed files with 4628 additions and 1159 deletions

2
.gitignore vendored
View File

@@ -161,3 +161,5 @@ junit.xml
# =================================================================== # ===================================================================
# Fin del archivo .gitignore # Fin del archivo .gitignore
# =================================================================== # ===================================================================
Backend/SQL
.atl

View File

@@ -51,6 +51,7 @@ namespace GestionIntegral.Api.Controllers
private readonly IPerfilService _perfilService; private readonly IPerfilService _perfilService;
private readonly IPermisoService _permisoService; private readonly IPermisoService _permisoService;
private readonly ICambioParadaService _cambioParadaService; private readonly ICambioParadaService _cambioParadaService;
private readonly ICierreCuentaCorrienteService _cierreCcService;
private readonly ILogger<AuditoriaController> _logger; private readonly ILogger<AuditoriaController> _logger;
// Permiso general para ver cualquier auditoría. // Permiso general para ver cualquier auditoría.
@@ -86,6 +87,7 @@ namespace GestionIntegral.Api.Controllers
IPerfilService perfilService, IPerfilService perfilService,
IPermisoService permisoService, IPermisoService permisoService,
ICambioParadaService cambioParadaService, ICambioParadaService cambioParadaService,
ICierreCuentaCorrienteService cierreCcService,
ILogger<AuditoriaController> logger) ILogger<AuditoriaController> logger)
{ {
_usuarioService = usuarioService; _usuarioService = usuarioService;
@@ -116,6 +118,7 @@ namespace GestionIntegral.Api.Controllers
_perfilService = perfilService; _perfilService = perfilService;
_cambioParadaService = cambioParadaService; _cambioParadaService = cambioParadaService;
_permisoService = permisoService; _permisoService = permisoService;
_cierreCcService = cierreCcService;
_logger = logger; _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")] [HttpGet("cambios-parada-canilla")]
[ProducesResponseType(typeof(IEnumerable<CambioParadaHistorialDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(IEnumerable<CambioParadaHistorialDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetHistorialCambiosParada( public async Task<IActionResult> GetHistorialCambiosParada(

View File

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

View File

@@ -18,9 +18,8 @@ namespace GestionIntegral.Api.Controllers.Contables
private readonly ISaldoService _saldoService; private readonly ISaldoService _saldoService;
private readonly ILogger<SaldosController> _logger; private readonly ILogger<SaldosController> _logger;
// Define un permiso específico para ver saldos, y otro para ajustarlos (SuperAdmin implícito) // Permiso para ver saldos. El ajuste manual es exclusivo de SuperAdmin (no se valida un permiso asignable).
private const string PermisoVerSaldos = "CS001"; // Ejemplo: Cuentas Saldos Ver private const string PermisoVerSaldos = "CS001";
private const string PermisoAjustarSaldos = "CS002"; // Ejemplo: Cuentas Saldos Ajustar (o solo SuperAdmin)
public SaldosController(ISaldoService saldoService, ILogger<SaldosController> logger) public SaldosController(ISaldoService saldoService, ILogger<SaldosController> logger)
@@ -76,11 +75,11 @@ namespace GestionIntegral.Api.Controllers.Contables
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> AjustarSaldoManualmente([FromBody] AjusteSaldoRequestDto ajusteDto) public async Task<IActionResult> AjustarSaldoManualmente([FromBody] AjusteSaldoRequestDto ajusteDto)
{ {
// Esta operación debería ser MUY restringida. Solo SuperAdmin o un permiso muy específico. // El ajuste manual de saldo es operación crítica: solo SuperAdmin. No se admite vía permiso asignable.
if (!User.IsInRole("SuperAdmin") && !TienePermiso(PermisoAjustarSaldos)) if (!User.IsInRole("SuperAdmin"))
{ {
_logger.LogWarning("Intento no autorizado de ajustar saldo por Usuario ID {userId}", GetCurrentUserId() ?? 0); _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); if (!ModelState.IsValid) return BadRequest(ModelState);
@@ -99,6 +98,10 @@ namespace GestionIntegral.Api.Controllers.Contables
} }
return Ok(saldoActualizado); return Ok(saldoActualizado);
} }
catch (BloqueoPorPeriodoCerradoException)
{
throw;
}
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error crítico al ajustar saldo manualmente."); _logger.LogError(ex, "Error crítico al ajustar saldo manualmente.");

View File

@@ -40,19 +40,19 @@ namespace GestionIntegral.Api.Controllers.Distribucion
[HttpGet] [HttpGet]
[ProducesResponseType(typeof(IEnumerable<DistribuidorDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(IEnumerable<DistribuidorDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> GetAllDistribuidores([FromQuery] string? nombre, [FromQuery] string? nroDoc) public async Task<IActionResult> GetAllDistribuidores([FromQuery] string? nombre, [FromQuery] string? nroDoc, [FromQuery] bool? soloActivos = true)
{ {
if (!TienePermiso(PermisoVer)) return Forbid(); if (!TienePermiso(PermisoVer)) return Forbid();
var distribuidores = await _distribuidorService.ObtenerTodosAsync(nombre, nroDoc); var distribuidores = await _distribuidorService.ObtenerTodosAsync(nombre, nroDoc, soloActivos);
return Ok(distribuidores); return Ok(distribuidores);
} }
[HttpGet("dropdown")] [HttpGet("dropdown")]
[ProducesResponseType(typeof(IEnumerable<DistribuidorDropdownDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(IEnumerable<DistribuidorDropdownDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> GetAllDropdownDistribuidores() public async Task<IActionResult> GetAllDropdownDistribuidores([FromQuery] bool? soloActivos = true)
{ {
var distribuidores = await _distribuidorService.GetAllDropdownAsync(); var distribuidores = await _distribuidorService.GetAllDropdownAsync(soloActivos);
return Ok(distribuidores); return Ok(distribuidores);
} }
@@ -117,6 +117,27 @@ namespace GestionIntegral.Api.Controllers.Distribucion
return NoContent(); return NoContent();
} }
[HttpPut("{id:int}/toggle-baja")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> ToggleBajaDistribuidor(int id, [FromBody] ToggleBajaDistribuidorDto dto)
{
if (!TienePermiso(PermisoModificar)) return Forbid();
if (!ModelState.IsValid) return BadRequest(ModelState);
var userId = GetCurrentUserId();
if (userId == null) return Unauthorized();
var (exito, error) = await _distribuidorService.ToggleBajaAsync(id, dto.DarDeBaja, dto.FechaBaja, userId.Value);
if (!exito)
{
if (error == "Distribuidor no encontrado.") return NotFound(new { message = error });
return BadRequest(new { message = error });
}
return NoContent();
}
[HttpDelete("{id:int}")] [HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]

View File

@@ -2,6 +2,7 @@ using GestionIntegral.Api.Dtos.Impresion;
using GestionIntegral.Api.Services.Impresion; using GestionIntegral.Api.Services.Impresion;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
@@ -40,6 +41,7 @@ namespace GestionIntegral.Api.Controllers.Impresion
return null; return null;
} }
// GET: api/stockbobinas
// GET: api/stockbobinas // GET: api/stockbobinas
[HttpGet] [HttpGet]
[ProducesResponseType(typeof(IEnumerable<StockBobinaDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(IEnumerable<StockBobinaDto>), StatusCodes.Status200OK)]
@@ -47,12 +49,23 @@ namespace GestionIntegral.Api.Controllers.Impresion
public async Task<IActionResult> GetAllStockBobinas( public async Task<IActionResult> GetAllStockBobinas(
[FromQuery] int? idTipoBobina, [FromQuery] string? nroBobina, [FromQuery] int? idPlanta, [FromQuery] int? idTipoBobina, [FromQuery] string? nroBobina, [FromQuery] int? idPlanta,
[FromQuery] int? idEstadoBobina, [FromQuery] string? remito, [FromQuery] int? idEstadoBobina, [FromQuery] string? remito,
[FromQuery] DateTime? fechaDesde, [FromQuery] DateTime? fechaHasta) [FromQuery] DateTime? fechaDesde, [FromQuery] DateTime? fechaHasta,
[FromQuery] DateTime? fechaEstadoDesde, [FromQuery] DateTime? fechaEstadoHasta) // <--- Nuevos parámetros agregados
{ {
if (!TienePermiso(PermisoVerStock)) return Forbid(); if (!TienePermiso(PermisoVerStock)) return Forbid();
try try
{ {
var bobinas = await _stockBobinaService.ObtenerTodosAsync(idTipoBobina, nroBobina, idPlanta, idEstadoBobina, remito, fechaDesde, fechaHasta); var bobinas = await _stockBobinaService.ObtenerTodosAsync(
idTipoBobina,
nroBobina,
idPlanta,
idEstadoBobina,
remito,
fechaDesde,
fechaHasta,
fechaEstadoDesde,
fechaEstadoHasta
);
return Ok(bobinas); return Ok(bobinas);
} }
catch (Exception ex) catch (Exception ex)
@@ -172,5 +185,72 @@ namespace GestionIntegral.Api.Controllers.Impresion
} }
return NoContent(); return NoContent();
} }
// GET: api/stockbobinas/verificar-remito
[HttpGet("verificar-remito")]
[ProducesResponseType(typeof(IEnumerable<StockBobinaDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
// CAMBIO: Hacer fechaRemito opcional (nullable)
public async Task<IActionResult> VerificarRemito([FromQuery, BindRequired] int idPlanta, [FromQuery, BindRequired] string remito, [FromQuery] DateTime? fechaRemito)
{
if (!TienePermiso(PermisoIngresarBobina)) return Forbid();
try
{
// Pasamos el parámetro nullable al servicio
var bobinasExistentes = await _stockBobinaService.VerificarRemitoExistenteAsync(idPlanta, remito, fechaRemito);
return Ok(bobinasExistentes);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al verificar remito {Remito} para planta {IdPlanta}", remito, idPlanta);
return StatusCode(StatusCodes.Status500InternalServerError, "Error interno al verificar el remito.");
}
}
[HttpPut("actualizar-fecha-remito")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> ActualizarFechaRemitoLote([FromBody] UpdateFechaRemitoLoteDto dto)
{
// Reutilizamos el permiso de modificar datos, ya que es una corrección.
if (!TienePermiso(PermisoModificarDatos)) return Forbid();
if (!ModelState.IsValid) return BadRequest(ModelState);
var userId = GetCurrentUserId();
if (userId == null) return Unauthorized();
var (exito, error) = await _stockBobinaService.ActualizarFechaRemitoLoteAsync(dto, userId.Value);
if (!exito)
{
return BadRequest(new { message = error });
}
return NoContent();
}
// POST: api/stockbobinas/lote
[HttpPost("lote")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> IngresarLoteBobinas([FromBody] CreateStockBobinaLoteDto loteDto)
{
if (!TienePermiso(PermisoIngresarBobina)) return Forbid();
if (!ModelState.IsValid) return BadRequest(ModelState);
var userId = GetCurrentUserId();
if (userId == null) return Unauthorized();
var (exito, error) = await _stockBobinaService.IngresarBobinaLoteAsync(loteDto, userId.Value);
if (!exito)
{
return BadRequest(new { message = error });
}
return NoContent(); // 204 es una buena respuesta para un lote procesado exitosamente sin devolver contenido.
}
} }
} }

View File

@@ -74,7 +74,6 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
if (Model.DebitosCreditos.Any()) column.Item().Element(ComposeDebCredTable); if (Model.DebitosCreditos.Any()) column.Item().Element(ComposeDebCredTable);
column.Item().Element(ComposeResumenPeriodo); 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"); 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)) foreach (var item in Model.Movimientos.OrderBy(m => m.Fecha))
{ {
saldoAcumulado += (item.Debe - item.Haber); 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"); 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)) foreach (var item in Model.Pagos.OrderBy(p => p.Fecha).ThenBy(p => p.Recibo))
{ {
saldoAcumulado += (item.Debe - item.Haber); 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"); 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)) foreach (var item in Model.DebitosCreditos.OrderBy(dc => dc.Fecha))
{ {
saldoAcumulado += (item.Debe - item.Haber); saldoAcumulado += (item.Debe - item.Haber);
@@ -236,25 +239,17 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
row.RelativeItem().Text(label); row.RelativeItem().Text(label);
row.ConstantItem(120).AlignRight().Text(value.ToString("C", CultureAr)); 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, "Movimientos", Model.TotalMovimientos));
col.Item().Row(row => AddResumenRow(row, "Débitos/Créditos", Model.TotalDebitosCreditos)); col.Item().Row(row => AddResumenRow(row, "Débitos/Créditos", Model.TotalDebitosCreditos));
col.Item().Row(row => AddResumenRow(row, "Pagos", Model.TotalPagos)); col.Item().Row(row => AddResumenRow(row, "Pagos", Model.TotalPagos));
col.Item().BorderTop(1.5f).BorderColor(Colors.Black).PaddingTop(5).Row(row => col.Item().BorderTop(1.5f).BorderColor(Colors.Black).PaddingTop(5).Row(row =>
{ {
row.RelativeItem().Text("Total").SemiBold(); row.RelativeItem().Text("Saldo Final").SemiBold();
row.ConstantItem(120).AlignRight().Text(t => t.Span(Model.TotalPeriodo.ToString("C", CultureAr)).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);
});
}
} }
} }

View File

@@ -148,7 +148,7 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
foreach (var item in Model.PromediosPorDia.OrderBy(d => dayOrder.GetValueOrDefault(d.Dia, 99))) foreach (var item in Model.PromediosPorDia.OrderBy(d => dayOrder.GetValueOrDefault(d.Dia, 99)))
{ {
var porcDevolucion = item.Llevados > 0 ? (decimal)item.Devueltos * 100 / item.Llevados : 0; var porcDevolucion = item.Promedio_Llevados > 0 ? (decimal)item.Promedio_Devueltos * 100 / item.Promedio_Llevados : 0;
table.Cell().Border(1).Padding(3).Text(item.Dia); table.Cell().Border(1).Padding(3).Text(item.Dia);
table.Cell().Border(1).Padding(3).AlignRight().Text(item.Cant.ToString("N0")); table.Cell().Border(1).Padding(3).AlignRight().Text(item.Cant.ToString("N0"));
@@ -162,7 +162,6 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
var general = Model.PromedioGeneral; var general = Model.PromedioGeneral;
if (general != null) if (general != null)
{ {
var porcDevolucionGeneral = general.Promedio_Llevados > 0 ? (decimal)general.Promedio_Devueltos * 100 / general.Promedio_Llevados : 0;
var boldStyle = TextStyle.Default.SemiBold(); var boldStyle = TextStyle.Default.SemiBold();
table.Cell().Border(1).Padding(3).Text(text => text.Span(general.Dia).Style(boldStyle)); table.Cell().Border(1).Padding(3).Text(text => text.Span(general.Dia).Style(boldStyle));
@@ -170,7 +169,7 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
table.Cell().Border(1).Padding(3).AlignRight().Text(text => text.Span(general.Promedio_Llevados.ToString("N0")).Style(boldStyle)); table.Cell().Border(1).Padding(3).AlignRight().Text(text => text.Span(general.Promedio_Llevados.ToString("N0")).Style(boldStyle));
table.Cell().Border(1).Padding(3).AlignRight().Text(text => text.Span(general.Promedio_Devueltos.ToString("N0")).Style(boldStyle)); table.Cell().Border(1).Padding(3).AlignRight().Text(text => text.Span(general.Promedio_Devueltos.ToString("N0")).Style(boldStyle));
table.Cell().Border(1).Padding(3).AlignRight().Text(text => text.Span(general.Promedio_Ventas.ToString("N0")).Style(boldStyle)); table.Cell().Border(1).Padding(3).AlignRight().Text(text => text.Span(general.Promedio_Ventas.ToString("N0")).Style(boldStyle));
table.Cell().Border(1).Padding(3).AlignRight().Text(text => text.Span(porcDevolucionGeneral.ToString("F2") + "%").Style(boldStyle)); table.Cell().Border(1).Padding(3).AlignRight().Text(text => text.Span(Model.PorcentajeDevolucionGeneral.ToString("F2") + "%").Style(boldStyle));
} }
}); });
}); });

View File

@@ -161,16 +161,16 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
if (general != null) if (general != null)
{ {
var boldStyle = TextStyle.Default.SemiBold(); var boldStyle = TextStyle.Default.SemiBold();
var llevadosGeneral = general.Llevados ?? 0; // Usamos el total para el % var avgPercentage = Model.PromediosPorDia
var devueltosGeneral = general.Devueltos ?? 0; // Usamos el total para el % .Where(p => p.Dia != "General" && (p.Promedio_Llevados ?? 0) > 0)
var porcDevolucionGeneral = llevadosGeneral > 0 ? (decimal)devueltosGeneral * 100 / llevadosGeneral : 0; .Average(p => (decimal)(p.Promedio_Devueltos ?? 0) * 100 / (p.Promedio_Llevados ?? 1));
table.Cell().Border(1).Padding(3).Text(t => t.Span(general.Dia).Style(boldStyle)); table.Cell().Border(1).Padding(3).Text(t => t.Span(general.Dia).Style(boldStyle));
table.Cell().Border(1).Padding(3).AlignRight().Text(t => t.Span(general.Cant?.ToString("N0")).Style(boldStyle)); table.Cell().Border(1).Padding(3).AlignRight().Text(t => t.Span(general.Cant?.ToString("N0")).Style(boldStyle));
table.Cell().Border(1).Padding(3).AlignRight().Text(t => t.Span(general.Promedio_Llevados?.ToString("N0")).Style(boldStyle)); table.Cell().Border(1).Padding(3).AlignRight().Text(t => t.Span(general.Promedio_Llevados?.ToString("N0")).Style(boldStyle));
table.Cell().Border(1).Padding(3).AlignRight().Text(t => t.Span(general.Promedio_Devueltos?.ToString("N0")).Style(boldStyle)); table.Cell().Border(1).Padding(3).AlignRight().Text(t => t.Span(general.Promedio_Devueltos?.ToString("N0")).Style(boldStyle));
table.Cell().Border(1).Padding(3).AlignRight().Text(t => t.Span(general.Promedio_Ventas?.ToString("N0")).Style(boldStyle)); table.Cell().Border(1).Padding(3).AlignRight().Text(t => t.Span(general.Promedio_Ventas?.ToString("N0")).Style(boldStyle));
table.Cell().Border(1).Padding(3).AlignRight().Text(t => t.Span(porcDevolucionGeneral.ToString("F2") + "%").Style(boldStyle)); table.Cell().Border(1).Padding(3).AlignRight().Text(t => t.Span(avgPercentage.ToString("F2") + "%").Style(boldStyle));
} }
}); });
} }

View File

@@ -894,11 +894,11 @@ namespace GestionIntegral.Api.Controllers
{ {
if (!TienePermiso(PermisoVerBalanceCuentas)) return Forbid(); if (!TienePermiso(PermisoVerBalanceCuentas)) return Forbid();
var (entradasSalidas, debitosCreditos, pagos, saldos, error) = var (entradasSalidas, debitosCreditos, pagos, saldoInicial, error) =
await _reportesService.ObtenerReporteCuentasDistribuidorAsync(idDistribuidor, idEmpresa, fechaDesde, fechaHasta); await _reportesService.ObtenerReporteCuentasDistribuidorAsync(idDistribuidor, idEmpresa, fechaDesde, fechaHasta);
if (error != null) return BadRequest(new { message = error }); 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." }); 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>(), EntradasSalidas = entradasSalidas ?? Enumerable.Empty<BalanceCuentaDistDto>(),
DebitosCreditos = debitosCreditos ?? Enumerable.Empty<BalanceCuentaDebCredDto>(), DebitosCreditos = debitosCreditos ?? Enumerable.Empty<BalanceCuentaDebCredDto>(),
Pagos = pagos ?? Enumerable.Empty<BalanceCuentaPagosDto>(), Pagos = pagos ?? Enumerable.Empty<BalanceCuentaPagosDto>(),
Saldos = saldos ?? Enumerable.Empty<SaldoDto>(), SaldoInicial = saldoInicial,
NombreDistribuidor = distribuidor.Distribuidor?.Nombre, NombreDistribuidor = distribuidor.Distribuidor?.Nombre,
NombreEmpresa = empresa?.Nombre NombreEmpresa = empresa?.Nombre
}; };
@@ -932,11 +932,11 @@ namespace GestionIntegral.Api.Controllers
{ {
if (!TienePermiso(PermisoVerBalanceCuentas)) return Forbid(); if (!TienePermiso(PermisoVerBalanceCuentas)) return Forbid();
var (entradasSalidas, debitosCreditos, pagos, saldos, error) = var (entradasSalidas, debitosCreditos, pagos, saldoInicial, error) =
await _reportesService.ObtenerReporteCuentasDistribuidorAsync(idDistribuidor, idEmpresa, fechaDesde, fechaHasta); await _reportesService.ObtenerReporteCuentasDistribuidorAsync(idDistribuidor, idEmpresa, fechaDesde, fechaHasta);
if (error != null) return BadRequest(new { message = error }); 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." }); 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, Movimientos = entradasSalidas,
Pagos = pagos, Pagos = pagos,
DebitosCreditos = debitosCreditos, DebitosCreditos = debitosCreditos,
SaldoDeCuenta = saldos.FirstOrDefault()?.Monto ?? 0, // <-- Se asigna a SaldoDeCuenta SaldoInicial = saldoInicial,
NombreDistribuidor = distribuidor.Distribuidor?.Nombre ?? $"Distribuidor ID {idDistribuidor}", NombreDistribuidor = distribuidor.Distribuidor?.Nombre ?? $"Distribuidor ID {idDistribuidor}",
FechaDesde = fechaDesde.ToString("dd/MM/yyyy"), FechaDesde = fechaDesde.ToString("dd/MM/yyyy"),
FechaHasta = fechaHasta.ToString("dd/MM/yyyy"), FechaHasta = fechaHasta.ToString("dd/MM/yyyy"),

View File

@@ -0,0 +1,435 @@
using Dapper;
using GestionIntegral.Api.Dtos.Auditoria;
using GestionIntegral.Api.Dtos.Contables;
using GestionIntegral.Api.Models.Contables;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace GestionIntegral.Api.Data.Repositories.Contables
{
public class CierreCuentaCorrienteRepository : ICierreCuentaCorrienteRepository
{
private readonly DbConnectionFactory _cf;
private readonly ILogger<CierreCuentaCorrienteRepository> _log;
public CierreCuentaCorrienteRepository(DbConnectionFactory cf, ILogger<CierreCuentaCorrienteRepository> log)
{
_cf = cf;
_log = log;
}
// Aliases SELECT — mapean Id_X (SQL) → IdX (modelo C#).
private const string SelectModelBase = @"
SELECT
Id_Cierre AS IdCierre,
Id_Distribuidor AS IdDistribuidor,
Id_Empresa AS IdEmpresa,
FechaCorte,
FechaCierre,
SaldoCierre,
Estado,
Justificacion,
Id_Usuario_Cierre AS IdUsuarioCierre,
Id_Usuario_Anula AS IdUsuarioAnula,
FechaAnulacion,
Justificacion_Anulacion AS JustificacionAnulacion
FROM dbo.cue_CierresCuentaCorriente";
public async Task<CierreCuentaCorriente?> GetByIdAsync(int idCierre)
{
var sql = SelectModelBase + " WHERE Id_Cierre = @IdCierreParam;";
try
{
using var connection = _cf.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<CierreCuentaCorriente>(sql, new { IdCierreParam = idCierre });
}
catch (Exception ex)
{
_log.LogError(ex, "Error al obtener Cierre por ID: {IdCierre}", idCierre);
return null;
}
}
public async Task<CierreCuentaCorriente?> GetUltimoCierreVigenteAsync(int idDistribuidor, int idEmpresa, IDbTransaction? transaction = null)
{
var sql = @"
SELECT TOP 1
Id_Cierre AS IdCierre,
Id_Distribuidor AS IdDistribuidor,
Id_Empresa AS IdEmpresa,
FechaCorte,
FechaCierre,
SaldoCierre,
Estado,
Justificacion,
Id_Usuario_Cierre AS IdUsuarioCierre,
Id_Usuario_Anula AS IdUsuarioAnula,
FechaAnulacion,
Justificacion_Anulacion AS JustificacionAnulacion
FROM dbo.cue_CierresCuentaCorriente
WHERE Id_Distribuidor = @IdDist
AND Id_Empresa = @IdEmp
AND Estado = 'Activo'
ORDER BY FechaCorte DESC, Id_Cierre DESC;";
var parameters = new { IdDist = idDistribuidor, IdEmp = idEmpresa };
try
{
if (transaction != null)
{
return await transaction.Connection!.QuerySingleOrDefaultAsync<CierreCuentaCorriente>(sql, parameters, transaction);
}
using var connection = _cf.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<CierreCuentaCorriente>(sql, parameters);
}
catch (Exception ex)
{
_log.LogError(ex, "Error al obtener último cierre vigente Dist={IdDist} Emp={IdEmp}", idDistribuidor, idEmpresa);
if (transaction != null) throw;
return null;
}
}
public async Task<CierreCuentaCorriente?> GetCierreVigenteParaFechaAsync(int idDistribuidor, int idEmpresa, DateTime fechaOperacion)
{
// Cae en período cerrado si existe un cierre Activo cuya FechaCorte sea >= fechaOperacion (la fecha está dentro del período cerrado).
// Se devuelve el más reciente (TOP 1 ORDER BY FechaCorte DESC) — el más restrictivo desde la perspectiva de la fecha consultada.
const string sql = @"
SELECT TOP 1
Id_Cierre AS IdCierre,
Id_Distribuidor AS IdDistribuidor,
Id_Empresa AS IdEmpresa,
FechaCorte,
FechaCierre,
SaldoCierre,
Estado,
Justificacion,
Id_Usuario_Cierre AS IdUsuarioCierre,
Id_Usuario_Anula AS IdUsuarioAnula,
FechaAnulacion,
Justificacion_Anulacion AS JustificacionAnulacion
FROM dbo.cue_CierresCuentaCorriente
WHERE Id_Distribuidor = @IdDist
AND Id_Empresa = @IdEmp
AND Estado = 'Activo'
AND FechaCorte >= @FechaOp
ORDER BY FechaCorte DESC, Id_Cierre DESC;";
try
{
using var connection = _cf.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<CierreCuentaCorriente>(sql, new
{
IdDist = idDistribuidor,
IdEmp = idEmpresa,
FechaOp = fechaOperacion.Date
});
}
catch (Exception ex)
{
_log.LogError(ex, "Error en GetCierreVigenteParaFechaAsync Dist={IdDist} Emp={IdEmp} Fecha={Fecha}", idDistribuidor, idEmpresa, fechaOperacion);
return null;
}
}
public async Task<bool> ExisteCierrePosteriorVigenteAsync(int idDistribuidor, int idEmpresa, DateTime fechaCorte, int? excluirIdCierre = null, IDbTransaction? transaction = null)
{
const string sql = @"
SELECT CASE WHEN EXISTS (
SELECT 1 FROM dbo.cue_CierresCuentaCorriente
WHERE Id_Distribuidor = @IdDist
AND Id_Empresa = @IdEmp
AND Estado = 'Activo'
AND FechaCorte > @FechaCorte
AND (@Excluir IS NULL OR Id_Cierre <> @Excluir)
) THEN 1 ELSE 0 END;";
var parameters = new
{
IdDist = idDistribuidor,
IdEmp = idEmpresa,
FechaCorte = fechaCorte.Date,
Excluir = excluirIdCierre
};
if (transaction != null)
{
return await transaction.Connection!.ExecuteScalarAsync<bool>(sql, parameters, transaction);
}
using var connection = _cf.CreateConnection();
return await connection.ExecuteScalarAsync<bool>(sql, parameters);
}
public async Task<int> CreateAsync(CierreCuentaCorriente cierre, int idUsuarioMod, IDbTransaction transaction)
{
const string sqlInsertMaster = @"
INSERT INTO dbo.cue_CierresCuentaCorriente
(Id_Distribuidor, Id_Empresa, FechaCorte, FechaCierre, SaldoCierre, Estado, Justificacion, Id_Usuario_Cierre)
VALUES
(@IdDistribuidor, @IdEmpresa, @FechaCorte, @FechaCierre, @SaldoCierre, @Estado, @Justificacion, @IdUsuarioCierre);
SELECT CAST(SCOPE_IDENTITY() AS INT);";
var idCierre = await transaction.Connection!.ExecuteScalarAsync<int>(sqlInsertMaster, new
{
cierre.IdDistribuidor,
cierre.IdEmpresa,
FechaCorte = cierre.FechaCorte.Date,
cierre.FechaCierre,
cierre.SaldoCierre,
cierre.Estado,
cierre.Justificacion,
cierre.IdUsuarioCierre
}, transaction);
if (idCierre <= 0) throw new DataException("No se pudo crear el cierre — SCOPE_IDENTITY no devolvió valor.");
const string sqlInsertHist = @"
INSERT INTO dbo.cue_CierresCuentaCorriente_H
(Id_Cierre, Id_Distribuidor, Id_Empresa, FechaCorte, FechaCierre, SaldoCierre,
Estado, Justificacion, Id_Usuario_Cierre,
Id_Usuario_Anula, FechaAnulacion, Justificacion_Anulacion,
TipoMod, Id_Usuario_Mod, FechaMod)
VALUES
(@IdCierre, @IdDistribuidor, @IdEmpresa, @FechaCorte, @FechaCierre, @SaldoCierre,
@Estado, @Justificacion, @IdUsuarioCierre,
NULL, NULL, NULL,
@TipoMod, @IdUsuarioMod, @FechaMod);";
await transaction.Connection!.ExecuteAsync(sqlInsertHist, new
{
IdCierre = idCierre,
cierre.IdDistribuidor,
cierre.IdEmpresa,
FechaCorte = cierre.FechaCorte.Date,
cierre.FechaCierre,
cierre.SaldoCierre,
cierre.Estado,
cierre.Justificacion,
cierre.IdUsuarioCierre,
TipoMod = "Creacion",
IdUsuarioMod = idUsuarioMod,
FechaMod = DateTime.Now
}, transaction);
return idCierre;
}
public async Task<bool> AnularAsync(int idCierre, int idUsuarioAnula, string justificacionAnulacion, int idUsuarioMod, IDbTransaction transaction)
{
// UPDATE atómico: solo cambia Estado si está actualmente Activo. Evita doble anulación concurrente.
const string sqlUpdate = @"
UPDATE dbo.cue_CierresCuentaCorriente
SET Estado = 'Anulado',
Id_Usuario_Anula = @IdUsuarioAnula,
FechaAnulacion = @FechaAnulacion,
Justificacion_Anulacion = @JustificacionAnulacion
WHERE Id_Cierre = @IdCierre
AND Estado = 'Activo';";
var fechaAnulacion = DateTime.Now;
int affected = await transaction.Connection!.ExecuteAsync(sqlUpdate, new
{
IdCierre = idCierre,
IdUsuarioAnula = idUsuarioAnula,
FechaAnulacion = fechaAnulacion,
JustificacionAnulacion = justificacionAnulacion
}, transaction);
if (affected != 1) return false;
// Snapshot post-update para el _H. Trae los valores ya actualizados.
const string sqlSnapshot = SelectModelBase + " WHERE Id_Cierre = @IdCierre;";
var actualizado = await transaction.Connection!.QuerySingleAsync<CierreCuentaCorriente>(sqlSnapshot, new { IdCierre = idCierre }, transaction);
const string sqlInsertHist = @"
INSERT INTO dbo.cue_CierresCuentaCorriente_H
(Id_Cierre, Id_Distribuidor, Id_Empresa, FechaCorte, FechaCierre, SaldoCierre,
Estado, Justificacion, Id_Usuario_Cierre,
Id_Usuario_Anula, FechaAnulacion, Justificacion_Anulacion,
TipoMod, Id_Usuario_Mod, FechaMod)
VALUES
(@IdCierre, @IdDistribuidor, @IdEmpresa, @FechaCorte, @FechaCierre, @SaldoCierre,
@Estado, @Justificacion, @IdUsuarioCierre,
@IdUsuarioAnula, @FechaAnulacion, @JustificacionAnulacion,
@TipoMod, @IdUsuarioMod, @FechaMod);";
await transaction.Connection!.ExecuteAsync(sqlInsertHist, new
{
IdCierre = actualizado.IdCierre,
actualizado.IdDistribuidor,
actualizado.IdEmpresa,
FechaCorte = actualizado.FechaCorte.Date,
actualizado.FechaCierre,
actualizado.SaldoCierre,
actualizado.Estado,
actualizado.Justificacion,
actualizado.IdUsuarioCierre,
actualizado.IdUsuarioAnula,
actualizado.FechaAnulacion,
JustificacionAnulacion = actualizado.JustificacionAnulacion,
TipoMod = "Reapertura",
IdUsuarioMod = idUsuarioMod,
FechaMod = DateTime.Now
}, transaction);
return true;
}
public async Task<IEnumerable<CierreCuentaCorrienteDto>> GetAllAsync(
int? idDistribuidor, int? idEmpresa, string? estado,
DateTime? fechaCorteDesde, DateTime? fechaCorteHasta)
{
var sqlBuilder = new StringBuilder(@"
SELECT
c.Id_Cierre AS IdCierre,
c.Id_Distribuidor AS IdDistribuidor,
d.Nombre AS NombreDistribuidor,
c.Id_Empresa AS IdEmpresa,
e.Nombre AS NombreEmpresa,
CONVERT(varchar(10), c.FechaCorte, 23) AS FechaCorte,
c.FechaCierre,
c.SaldoCierre,
c.Estado,
c.Justificacion,
c.Id_Usuario_Cierre AS IdUsuarioCierre,
(uc.Nombre + ' ' + uc.Apellido) AS NombreUsuarioCierre,
c.Id_Usuario_Anula AS IdUsuarioAnula,
CASE WHEN ua.Id IS NULL THEN NULL ELSE (ua.Nombre + ' ' + ua.Apellido) END AS NombreUsuarioAnula,
c.FechaAnulacion,
c.Justificacion_Anulacion AS JustificacionAnulacion,
CAST(CASE WHEN c.Estado = 'Activo'
AND c.Id_Cierre = (
SELECT TOP 1 c2.Id_Cierre FROM dbo.cue_CierresCuentaCorriente c2
WHERE c2.Id_Distribuidor = c.Id_Distribuidor
AND c2.Id_Empresa = c.Id_Empresa
AND c2.Estado = 'Activo'
ORDER BY c2.FechaCorte DESC, c2.Id_Cierre DESC)
THEN 1 ELSE 0 END AS bit) AS EsUltimoVigente
FROM dbo.cue_CierresCuentaCorriente c
JOIN dbo.dist_dtDistribuidores d ON d.Id_Distribuidor = c.Id_Distribuidor
JOIN dbo.dist_dtEmpresas e ON e.Id_Empresa = c.Id_Empresa
JOIN dbo.gral_Usuarios uc ON uc.Id = c.Id_Usuario_Cierre
LEFT JOIN dbo.gral_Usuarios ua ON ua.Id = c.Id_Usuario_Anula
WHERE 1=1");
var parameters = new DynamicParameters();
if (idDistribuidor.HasValue) { sqlBuilder.Append(" AND c.Id_Distribuidor = @IdDist"); parameters.Add("IdDist", idDistribuidor.Value); }
if (idEmpresa.HasValue) { sqlBuilder.Append(" AND c.Id_Empresa = @IdEmp"); parameters.Add("IdEmp", idEmpresa.Value); }
if (!string.IsNullOrWhiteSpace(estado)) { sqlBuilder.Append(" AND c.Estado = @Estado"); parameters.Add("Estado", estado); }
if (fechaCorteDesde.HasValue) { sqlBuilder.Append(" AND c.FechaCorte >= @FechaDesde"); parameters.Add("FechaDesde", fechaCorteDesde.Value.Date); }
if (fechaCorteHasta.HasValue) { sqlBuilder.Append(" AND c.FechaCorte <= @FechaHasta"); parameters.Add("FechaHasta", fechaCorteHasta.Value.Date); }
sqlBuilder.Append(" ORDER BY c.FechaCorte DESC, c.Id_Cierre DESC;");
try
{
using var connection = _cf.CreateConnection();
return await connection.QueryAsync<CierreCuentaCorrienteDto>(sqlBuilder.ToString(), parameters);
}
catch (Exception ex)
{
_log.LogError(ex, "Error en GetAllAsync de CierresCuentaCorriente.");
return Enumerable.Empty<CierreCuentaCorrienteDto>();
}
}
public async Task<IEnumerable<CierreCuentaCorrienteHistorialDto>> GetHistorialAsync(int idCierre)
{
const string sql = @"
SELECT
h.Id_Historial,
h.Id_Cierre,
h.Id_Distribuidor,
h.Id_Empresa,
h.FechaCorte,
h.FechaCierre,
h.SaldoCierre,
h.Estado,
h.Justificacion,
h.Id_Usuario_Cierre,
h.Id_Usuario_Anula,
h.FechaAnulacion,
h.Justificacion_Anulacion,
h.TipoMod,
h.Id_Usuario_Mod,
(u.Nombre + ' ' + u.Apellido) AS NombreUsuarioModifico,
h.FechaMod
FROM dbo.cue_CierresCuentaCorriente_H h
JOIN dbo.gral_Usuarios u ON u.Id = h.Id_Usuario_Mod
WHERE h.Id_Cierre = @IdCierre
ORDER BY h.FechaMod ASC, h.Id_Historial ASC;";
try
{
using var connection = _cf.CreateConnection();
return await connection.QueryAsync<CierreCuentaCorrienteHistorialDto>(sql, new { IdCierre = idCierre });
}
catch (Exception ex)
{
_log.LogError(ex, "Error en GetHistorialAsync para Cierre ID {IdCierre}", idCierre);
return Enumerable.Empty<CierreCuentaCorrienteHistorialDto>();
}
}
public async Task<IEnumerable<CierreCuentaCorrienteHistorialDto>> ObtenerHistorialAsync(
DateTime? fechaDesde, DateTime? fechaHasta,
int? idUsuarioModifico, string? tipoModificacion,
int? idCierreAfectado)
{
// FechaMod cubre el rango inclusive: [fechaDesde 00:00, fechaHasta+1día). Patrón consistente con otros ObtenerHistorial del proyecto.
var sql = @"
SELECT
h.Id_Historial,
h.Id_Cierre,
h.Id_Distribuidor,
h.Id_Empresa,
h.FechaCorte,
h.FechaCierre,
h.SaldoCierre,
h.Estado,
h.Justificacion,
h.Id_Usuario_Cierre,
h.Id_Usuario_Anula,
h.FechaAnulacion,
h.Justificacion_Anulacion,
h.TipoMod,
h.Id_Usuario_Mod,
(u.Nombre + ' ' + u.Apellido) AS NombreUsuarioModifico,
h.FechaMod
FROM dbo.cue_CierresCuentaCorriente_H h
JOIN dbo.gral_Usuarios u ON u.Id = h.Id_Usuario_Mod
WHERE 1 = 1
AND (@FechaDesde IS NULL OR h.FechaMod >= @FechaDesde)
AND (@FechaHasta IS NULL OR h.FechaMod < DATEADD(DAY, 1, @FechaHasta))
AND (@IdUsuarioMod IS NULL OR h.Id_Usuario_Mod = @IdUsuarioMod)
AND (@TipoMod IS NULL OR h.TipoMod = @TipoMod)
AND (@IdCierre IS NULL OR h.Id_Cierre = @IdCierre)
ORDER BY h.FechaMod DESC, h.Id_Historial DESC;";
try
{
using var connection = _cf.CreateConnection();
return await connection.QueryAsync<CierreCuentaCorrienteHistorialDto>(sql, new
{
FechaDesde = fechaDesde?.Date,
FechaHasta = fechaHasta?.Date,
IdUsuarioMod = idUsuarioModifico,
TipoMod = tipoModificacion,
IdCierre = idCierreAfectado
});
}
catch (Exception ex)
{
_log.LogError(ex, "Error en ObtenerHistorialAsync (auditoría) de Cierres CC.");
return Enumerable.Empty<CierreCuentaCorrienteHistorialDto>();
}
}
}
}

View File

@@ -0,0 +1,45 @@
using GestionIntegral.Api.Dtos.Auditoria;
using GestionIntegral.Api.Dtos.Contables;
using GestionIntegral.Api.Models.Contables;
using System;
using System.Collections.Generic;
using System.Data;
using System.Threading.Tasks;
namespace GestionIntegral.Api.Data.Repositories.Contables
{
public interface ICierreCuentaCorrienteRepository
{
Task<CierreCuentaCorriente?> GetByIdAsync(int idCierre);
// Devuelve el último cierre con Estado = 'Activo' para el par (Distribuidor + Empresa).
// Acepta una transacción opcional para usarse dentro del flujo Crear/Reabrir.
Task<CierreCuentaCorriente?> GetUltimoCierreVigenteAsync(int idDistribuidor, int idEmpresa, IDbTransaction? transaction = null);
// Verifica si la fecha cae dentro de un período cerrado: existe un cierre Activo con FechaCorte >= fechaOperacion.
Task<CierreCuentaCorriente?> GetCierreVigenteParaFechaAsync(int idDistribuidor, int idEmpresa, DateTime fechaOperacion);
// True si existe otro cierre Activo con FechaCorte > fechaCorte (excluyendo opcionalmente un cierre puntual).
// Se usa al reabrir para forzar la cascada manual.
Task<bool> ExisteCierrePosteriorVigenteAsync(int idDistribuidor, int idEmpresa, DateTime fechaCorte, int? excluirIdCierre = null, IDbTransaction? transaction = null);
// Crea la fila maestra y registra la entrada inicial en _H con TipoMod='Creacion'. Devuelve el Id_Cierre generado.
Task<int> CreateAsync(CierreCuentaCorriente cierre, int idUsuarioMod, IDbTransaction transaction);
// Marca un cierre como Anulado (UPDATE atómico solo si Estado='Activo') y registra entrada en _H con TipoMod='Reapertura'.
// Devuelve true si se anuló (Estado pasó de Activo a Anulado), false si ya estaba anulado o no existía.
Task<bool> AnularAsync(int idCierre, int idUsuarioAnula, string justificacionAnulacion, int idUsuarioMod, IDbTransaction transaction);
Task<IEnumerable<CierreCuentaCorrienteDto>> GetAllAsync(
int? idDistribuidor, int? idEmpresa, string? estado,
DateTime? fechaCorteDesde, DateTime? fechaCorteHasta);
Task<IEnumerable<CierreCuentaCorrienteHistorialDto>> GetHistorialAsync(int idCierre);
// Auditoría general: filtra el historial cruzado de todos los cierres por rango de FechaMod, usuario, tipo, y opcional Id_Cierre.
Task<IEnumerable<CierreCuentaCorrienteHistorialDto>> ObtenerHistorialAsync(
DateTime? fechaDesde, DateTime? fechaHasta,
int? idUsuarioModifico, string? tipoModificacion,
int? idCierreAfectado);
}
}

View File

@@ -23,7 +23,7 @@ namespace GestionIntegral.Api.Data.Repositories.Contables
public async Task<IEnumerable<int>> GetAllDistribuidorIdsAsync() public async Task<IEnumerable<int>> GetAllDistribuidorIdsAsync()
{ {
var sql = "SELECT Id_Distribuidor FROM dbo.dist_dtDistribuidores"; var sql = "SELECT Id_Distribuidor FROM dbo.dist_dtDistribuidores WHERE Baja = 0";
try try
{ {
using (var connection = _connectionFactory.CreateConnection()) using (var connection = _connectionFactory.CreateConnection())
@@ -138,25 +138,45 @@ namespace GestionIntegral.Api.Data.Repositories.Contables
public async Task<IEnumerable<Saldo>> GetSaldosParaGestionAsync(string? destinoFilter, int? idDestinoFilter, int? idEmpresaFilter) public async Task<IEnumerable<Saldo>> GetSaldosParaGestionAsync(string? destinoFilter, int? idDestinoFilter, int? idEmpresaFilter)
{ {
var sqlBuilder = new StringBuilder("SELECT Id_Saldo AS IdSaldo, Destino, Id_Destino AS IdDestino, Monto, Id_Empresa AS IdEmpresa, FechaUltimaModificacion FROM dbo.cue_Saldos WHERE 1=1"); var sqlBuilder = new StringBuilder(@"
SELECT Id_Saldo AS IdSaldo, Destino, Id_Destino AS IdDestino, Monto, Id_Empresa AS IdEmpresa, FechaUltimaModificacion
FROM dbo.cue_Saldos s
WHERE 1=1");
var parameters = new DynamicParameters(); var parameters = new DynamicParameters();
if (!string.IsNullOrWhiteSpace(destinoFilter)) if (!string.IsNullOrWhiteSpace(destinoFilter))
{ {
sqlBuilder.Append(" AND Destino = @Destino"); sqlBuilder.Append(" AND s.Destino = @Destino");
parameters.Add("Destino", destinoFilter); parameters.Add("Destino", destinoFilter);
// Filtro para excluir distribuidores de baja si el tipo es Distribuidores
// No se aplica a Canillas por requerimiento explícito del usuario
if (destinoFilter == "Distribuidores")
{
sqlBuilder.Append(" AND EXISTS (SELECT 1 FROM dbo.dist_dtDistribuidores d WHERE d.Id_Distribuidor = s.Id_Destino AND d.Baja = 0)");
} }
}
else
{
// Si no hay filtro de destino, aplicamos el filtro de baja solo para Distribuidores
sqlBuilder.Append(@" AND (
(s.Destino = 'Distribuidores' AND EXISTS (SELECT 1 FROM dbo.dist_dtDistribuidores d WHERE d.Id_Distribuidor = s.Id_Destino AND d.Baja = 0))
OR (s.Destino != 'Distribuidores')
)");
}
if (idDestinoFilter.HasValue) if (idDestinoFilter.HasValue)
{ {
sqlBuilder.Append(" AND Id_Destino = @IdDestino"); sqlBuilder.Append(" AND s.Id_Destino = @IdDestino");
parameters.Add("IdDestino", idDestinoFilter.Value); parameters.Add("IdDestino", idDestinoFilter.Value);
} }
if (idEmpresaFilter.HasValue) if (idEmpresaFilter.HasValue)
{ {
sqlBuilder.Append(" AND Id_Empresa = @IdEmpresa"); sqlBuilder.Append(" AND s.Id_Empresa = @IdEmpresa");
parameters.Add("IdEmpresa", idEmpresaFilter.Value); parameters.Add("IdEmpresa", idEmpresaFilter.Value);
} }
sqlBuilder.Append(" ORDER BY Destino, Id_Empresa, Id_Destino;"); sqlBuilder.Append(" ORDER BY s.Destino, s.Id_Empresa, s.Id_Destino;");
using var connection = _connectionFactory.CreateConnection(); using var connection = _connectionFactory.CreateConnection();
return await connection.QueryAsync<Saldo>(sqlBuilder.ToString(), parameters); return await connection.QueryAsync<Saldo>(sqlBuilder.ToString(), parameters);

View File

@@ -22,12 +22,12 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
_logger = logger; _logger = logger;
} }
public async Task<IEnumerable<(Distribuidor Distribuidor, string? NombreZona)>> GetAllAsync(string? nombreFilter, string? nroDocFilter) public async Task<IEnumerable<(Distribuidor Distribuidor, string? NombreZona)>> GetAllAsync(string? nombreFilter, string? nroDocFilter, bool? soloActivos = true)
{ {
var sqlBuilder = new StringBuilder(@" var sqlBuilder = new StringBuilder(@"
SELECT SELECT
d.Id_Distribuidor AS IdDistribuidor, d.Nombre, d.Contacto, d.NroDoc, d.Id_Zona AS IdZona, d.Id_Distribuidor AS IdDistribuidor, d.Nombre, d.Contacto, d.NroDoc, d.Id_Zona AS IdZona,
d.Calle, d.Numero, d.Piso, d.Depto, d.Telefono, d.Email, d.Localidad, d.Calle, d.Numero, d.Piso, d.Depto, d.Telefono, d.Email, d.Localidad, d.Baja, d.FechaBaja,
z.Nombre AS NombreZona z.Nombre AS NombreZona
FROM dbo.dist_dtDistribuidores d FROM dbo.dist_dtDistribuidores d
LEFT JOIN dbo.dist_dtZonas z ON d.Id_Zona = z.Id_Zona LEFT JOIN dbo.dist_dtZonas z ON d.Id_Zona = z.Id_Zona
@@ -44,6 +44,11 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
sqlBuilder.Append(" AND d.NroDoc LIKE @NroDocParam"); sqlBuilder.Append(" AND d.NroDoc LIKE @NroDocParam");
parameters.Add("NroDocParam", $"%{nroDocFilter}%"); parameters.Add("NroDocParam", $"%{nroDocFilter}%");
} }
if (soloActivos.HasValue)
{
sqlBuilder.Append(" AND d.Baja = @BajaStatus ");
parameters.Add("BajaStatus", !soloActivos.Value);
}
sqlBuilder.Append(" ORDER BY d.Nombre;"); sqlBuilder.Append(" ORDER BY d.Nombre;");
try try
@@ -63,7 +68,7 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
} }
} }
public async Task<IEnumerable<DistribuidorDropdownDto?>> GetAllDropdownAsync() public async Task<IEnumerable<DistribuidorDropdownDto?>> GetAllDropdownAsync(bool? soloActivos = true)
{ {
var sqlBuilder = new StringBuilder(@" var sqlBuilder = new StringBuilder(@"
SELECT SELECT
@@ -71,6 +76,13 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
FROM dbo.dist_dtDistribuidores FROM dbo.dist_dtDistribuidores
WHERE 1=1"); WHERE 1=1");
var parameters = new DynamicParameters(); var parameters = new DynamicParameters();
if (soloActivos.HasValue)
{
sqlBuilder.Append(" AND Baja = @BajaStatus ");
parameters.Add("BajaStatus", !soloActivos.Value);
}
sqlBuilder.Append(" ORDER BY Nombre;"); sqlBuilder.Append(" ORDER BY Nombre;");
try try
{ {
@@ -92,7 +104,7 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
const string sql = @" const string sql = @"
SELECT SELECT
d.Id_Distribuidor AS IdDistribuidor, d.Nombre, d.Contacto, d.NroDoc, d.Id_Zona AS IdZona, d.Id_Distribuidor AS IdDistribuidor, d.Nombre, d.Contacto, d.NroDoc, d.Id_Zona AS IdZona,
d.Calle, d.Numero, d.Piso, d.Depto, d.Telefono, d.Email, d.Localidad, d.Calle, d.Numero, d.Piso, d.Depto, d.Telefono, d.Email, d.Localidad, d.Baja, d.FechaBaja,
z.Nombre AS NombreZona z.Nombre AS NombreZona
FROM dbo.dist_dtDistribuidores d FROM dbo.dist_dtDistribuidores d
LEFT JOIN dbo.dist_dtZonas z ON d.Id_Zona = z.Id_Zona LEFT JOIN dbo.dist_dtZonas z ON d.Id_Zona = z.Id_Zona
@@ -139,7 +151,7 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
const string sql = @" const string sql = @"
SELECT SELECT
Id_Distribuidor AS IdDistribuidor, Nombre, Contacto, NroDoc, Id_Zona AS IdZona, Id_Distribuidor AS IdDistribuidor, Nombre, Contacto, NroDoc, Id_Zona AS IdZona,
Calle, Numero, Piso, Depto, Telefono, Email, Localidad Calle, Numero, Piso, Depto, Telefono, Email, Localidad, Baja, FechaBaja
FROM dbo.dist_dtDistribuidores FROM dbo.dist_dtDistribuidores
WHERE Id_Distribuidor = @IdParam"; WHERE Id_Distribuidor = @IdParam";
try try
@@ -223,10 +235,10 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
public async Task<Distribuidor?> CreateAsync(Distribuidor nuevoDistribuidor, int idUsuario, IDbTransaction transaction) public async Task<Distribuidor?> CreateAsync(Distribuidor nuevoDistribuidor, int idUsuario, IDbTransaction transaction)
{ {
const string sqlInsert = @" const string sqlInsert = @"
INSERT INTO dbo.dist_dtDistribuidores (Nombre, Contacto, NroDoc, Id_Zona, Calle, Numero, Piso, Depto, Telefono, Email, Localidad) INSERT INTO dbo.dist_dtDistribuidores (Nombre, Contacto, NroDoc, Id_Zona, Calle, Numero, Piso, Depto, Telefono, Email, Localidad, Baja, FechaBaja)
OUTPUT INSERTED.Id_Distribuidor AS IdDistribuidor, INSERTED.Nombre, INSERTED.Contacto, INSERTED.NroDoc, INSERTED.Id_Zona AS IdZona, OUTPUT INSERTED.Id_Distribuidor AS IdDistribuidor, INSERTED.Nombre, INSERTED.Contacto, INSERTED.NroDoc, INSERTED.Id_Zona AS IdZona,
INSERTED.Calle, INSERTED.Numero, INSERTED.Piso, INSERTED.Depto, INSERTED.Telefono, INSERTED.Email, INSERTED.Localidad INSERTED.Calle, INSERTED.Numero, INSERTED.Piso, INSERTED.Depto, INSERTED.Telefono, INSERTED.Email, INSERTED.Localidad, INSERTED.Baja, INSERTED.FechaBaja
VALUES (@Nombre, @Contacto, @NroDoc, @IdZona, @Calle, @Numero, @Piso, @Depto, @Telefono, @Email, @Localidad);"; VALUES (@Nombre, @Contacto, @NroDoc, @IdZona, @Calle, @Numero, @Piso, @Depto, @Telefono, @Email, @Localidad, 0, NULL);";
var connection = transaction.Connection!; var connection = transaction.Connection!;
var inserted = await connection.QuerySingleAsync<Distribuidor>(sqlInsert, nuevoDistribuidor, transaction); var inserted = await connection.QuerySingleAsync<Distribuidor>(sqlInsert, nuevoDistribuidor, transaction);
@@ -234,8 +246,8 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
const string sqlInsertHistorico = @" const string sqlInsertHistorico = @"
INSERT INTO dbo.dist_dtDistribuidores_H INSERT INTO dbo.dist_dtDistribuidores_H
(Id_Distribuidor, Nombre, Contacto, NroDoc, Id_Zona, Calle, Numero, Piso, Depto, Telefono, Email, Localidad, Id_Usuario, FechaMod, TipoMod) (Id_Distribuidor, Nombre, Contacto, NroDoc, Id_Zona, Calle, Numero, Piso, Depto, Telefono, Email, Localidad, Baja, FechaBaja, Id_Usuario, FechaMod, TipoMod)
VALUES (@IdDistribuidorParam, @NombreParam, @ContactoParam, @NroDocParam, @IdZonaParam, @CalleParam, @NumeroParam, @PisoParam, @DeptoParam, @TelefonoParam, @EmailParam, @LocalidadParam, @IdUsuarioParam, @FechaModParam, @TipoModParam);"; VALUES (@IdDistribuidorParam, @NombreParam, @ContactoParam, @NroDocParam, @IdZonaParam, @CalleParam, @NumeroParam, @PisoParam, @DeptoParam, @TelefonoParam, @EmailParam, @LocalidadParam, @BajaParam, @FechaBajaParam, @IdUsuarioParam, @FechaModParam, @TipoModParam);";
await connection.ExecuteAsync(sqlInsertHistorico, new await connection.ExecuteAsync(sqlInsertHistorico, new
{ {
@@ -251,6 +263,8 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
TelefonoParam = inserted.Telefono, TelefonoParam = inserted.Telefono,
EmailParam = inserted.Email, EmailParam = inserted.Email,
LocalidadParam = inserted.Localidad, LocalidadParam = inserted.Localidad,
BajaParam = inserted.Baja,
FechaBajaParam = inserted.FechaBaja,
IdUsuarioParam = idUsuario, IdUsuarioParam = idUsuario,
FechaModParam = DateTime.Now, FechaModParam = DateTime.Now,
TipoModParam = "Creado" TipoModParam = "Creado"
@@ -263,7 +277,7 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
var connection = transaction.Connection!; var connection = transaction.Connection!;
var actual = await connection.QuerySingleOrDefaultAsync<Distribuidor>( var actual = await connection.QuerySingleOrDefaultAsync<Distribuidor>(
@"SELECT Id_Distribuidor AS IdDistribuidor, Nombre, Contacto, NroDoc, Id_Zona AS IdZona, @"SELECT Id_Distribuidor AS IdDistribuidor, Nombre, Contacto, NroDoc, Id_Zona AS IdZona,
Calle, Numero, Piso, Depto, Telefono, Email, Localidad Calle, Numero, Piso, Depto, Telefono, Email, Localidad, Baja, FechaBaja
FROM dbo.dist_dtDistribuidores WHERE Id_Distribuidor = @IdDistribuidorParam", FROM dbo.dist_dtDistribuidores WHERE Id_Distribuidor = @IdDistribuidorParam",
new { IdDistribuidorParam = distribuidorAActualizar.IdDistribuidor }, transaction); new { IdDistribuidorParam = distribuidorAActualizar.IdDistribuidor }, transaction);
if (actual == null) throw new KeyNotFoundException("Distribuidor no encontrado."); if (actual == null) throw new KeyNotFoundException("Distribuidor no encontrado.");
@@ -275,8 +289,8 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
WHERE Id_Distribuidor = @IdDistribuidor;"; WHERE Id_Distribuidor = @IdDistribuidor;";
const string sqlInsertHistorico = @" const string sqlInsertHistorico = @"
INSERT INTO dbo.dist_dtDistribuidores_H INSERT INTO dbo.dist_dtDistribuidores_H
(Id_Distribuidor, Nombre, Contacto, NroDoc, Id_Zona, Calle, Numero, Piso, Depto, Telefono, Email, Localidad, Id_Usuario, FechaMod, TipoMod) (Id_Distribuidor, Nombre, Contacto, NroDoc, Id_Zona, Calle, Numero, Piso, Depto, Telefono, Email, Localidad, Baja, FechaBaja, Id_Usuario, FechaMod, TipoMod)
VALUES (@IdDistribuidorParam, @NombreParam, @ContactoParam, @NroDocParam, @IdZonaParam, @CalleParam, @NumeroParam, @PisoParam, @DeptoParam, @TelefonoParam, @EmailParam, @LocalidadParam, @IdUsuarioParam, @FechaModParam, @TipoModParam);"; VALUES (@IdDistribuidorParam, @NombreParam, @ContactoParam, @NroDocParam, @IdZonaParam, @CalleParam, @NumeroParam, @PisoParam, @DeptoParam, @TelefonoParam, @EmailParam, @LocalidadParam, @BajaParam, @FechaBajaParam, @IdUsuarioParam, @FechaModParam, @TipoModParam);";
await connection.ExecuteAsync(sqlInsertHistorico, new await connection.ExecuteAsync(sqlInsertHistorico, new
{ {
@@ -292,6 +306,8 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
TelefonoParam = actual.Telefono, TelefonoParam = actual.Telefono,
EmailParam = actual.Email, EmailParam = actual.Email,
LocalidadParam = actual.Localidad, LocalidadParam = actual.Localidad,
BajaParam = actual.Baja,
FechaBajaParam = actual.FechaBaja,
IdUsuarioParam = idUsuario, IdUsuarioParam = idUsuario,
FechaModParam = DateTime.Now, FechaModParam = DateTime.Now,
TipoModParam = "Actualizado" TipoModParam = "Actualizado"
@@ -306,7 +322,7 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
var connection = transaction.Connection!; var connection = transaction.Connection!;
var actual = await connection.QuerySingleOrDefaultAsync<Distribuidor>( var actual = await connection.QuerySingleOrDefaultAsync<Distribuidor>(
@"SELECT Id_Distribuidor AS IdDistribuidor, Nombre, Contacto, NroDoc, Id_Zona AS IdZona, @"SELECT Id_Distribuidor AS IdDistribuidor, Nombre, Contacto, NroDoc, Id_Zona AS IdZona,
Calle, Numero, Piso, Depto, Telefono, Email, Localidad Calle, Numero, Piso, Depto, Telefono, Email, Localidad, Baja, FechaBaja
FROM dbo.dist_dtDistribuidores WHERE Id_Distribuidor = @IdParam", new { IdParam = id }, transaction); FROM dbo.dist_dtDistribuidores WHERE Id_Distribuidor = @IdParam", new { IdParam = id }, transaction);
if (actual == null) throw new KeyNotFoundException("Distribuidor no encontrado."); if (actual == null) throw new KeyNotFoundException("Distribuidor no encontrado.");
@@ -314,8 +330,8 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
const string sqlDelete = "DELETE FROM dbo.dist_dtDistribuidores WHERE Id_Distribuidor = @IdParam"; const string sqlDelete = "DELETE FROM dbo.dist_dtDistribuidores WHERE Id_Distribuidor = @IdParam";
const string sqlInsertHistorico = @" const string sqlInsertHistorico = @"
INSERT INTO dbo.dist_dtDistribuidores_H INSERT INTO dbo.dist_dtDistribuidores_H
(Id_Distribuidor, Nombre, Contacto, NroDoc, Id_Zona, Calle, Numero, Piso, Depto, Telefono, Email, Localidad, Id_Usuario, FechaMod, TipoMod) (Id_Distribuidor, Nombre, Contacto, NroDoc, Id_Zona, Calle, Numero, Piso, Depto, Telefono, Email, Localidad, Baja, FechaBaja, Id_Usuario, FechaMod, TipoMod)
VALUES (@IdDistribuidorParam, @NombreParam, @ContactoParam, @NroDocParam, @IdZonaParam, @CalleParam, @NumeroParam, @PisoParam, @DeptoParam, @TelefonoParam, @EmailParam, @LocalidadParam, @IdUsuarioParam, @FechaModParam, @TipoModParam);"; VALUES (@IdDistribuidorParam, @NombreParam, @ContactoParam, @NroDocParam, @IdZonaParam, @CalleParam, @NumeroParam, @PisoParam, @DeptoParam, @TelefonoParam, @EmailParam, @LocalidadParam, @BajaParam, @FechaBajaParam, @IdUsuarioParam, @FechaModParam, @TipoModParam);";
await connection.ExecuteAsync(sqlInsertHistorico, new await connection.ExecuteAsync(sqlInsertHistorico, new
{ {
@@ -331,6 +347,8 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
TelefonoParam = actual.Telefono, TelefonoParam = actual.Telefono,
EmailParam = actual.Email, EmailParam = actual.Email,
LocalidadParam = actual.Localidad, LocalidadParam = actual.Localidad,
BajaParam = actual.Baja,
FechaBajaParam = actual.FechaBaja,
IdUsuarioParam = idUsuario, IdUsuarioParam = idUsuario,
FechaModParam = DateTime.Now, FechaModParam = DateTime.Now,
TipoModParam = "Eliminado" TipoModParam = "Eliminado"
@@ -340,6 +358,47 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
return rowsAffected == 1; return rowsAffected == 1;
} }
public async Task<bool> ToggleBajaAsync(int id, bool darDeBaja, DateTime? fechaBaja, int idUsuario, IDbTransaction transaction)
{
var connection = transaction.Connection!;
var actual = await connection.QuerySingleOrDefaultAsync<Distribuidor>(
@"SELECT Id_Distribuidor AS IdDistribuidor, Nombre, Contacto, NroDoc, Id_Zona AS IdZona,
Calle, Numero, Piso, Depto, Telefono, Email, Localidad, Baja, FechaBaja
FROM dbo.dist_dtDistribuidores WHERE Id_Distribuidor = @IdDistribuidorParam",
new { IdDistribuidorParam = id }, transaction);
if (actual == null) throw new KeyNotFoundException("Distribuidor no encontrado para dar de baja/alta.");
const string sqlUpdate = "UPDATE dbo.dist_dtDistribuidores SET Baja = @BajaParam, FechaBaja = @FechaBajaParam WHERE Id_Distribuidor = @IdDistribuidorParam;";
const string sqlInsertHistorico = @"
INSERT INTO dbo.dist_dtDistribuidores_H
(Id_Distribuidor, Nombre, Contacto, NroDoc, Id_Zona, Calle, Numero, Piso, Depto, Telefono, Email, Localidad, Baja, FechaBaja, Id_Usuario, FechaMod, TipoMod)
VALUES (@IdDistribuidorParam, @NombreParam, @ContactoParam, @NroDocParam, @IdZonaParam, @CalleParam, @NumeroParam, @PisoParam, @DeptoParam, @TelefonoParam, @EmailParam, @LocalidadParam, @BajaNuevaParam, @FechaBajaNuevaParam, @IdUsuarioParam, @FechaModParam, @TipoModHistParam);";
await connection.ExecuteAsync(sqlInsertHistorico, new
{
IdDistribuidorParam = actual.IdDistribuidor,
NombreParam = actual.Nombre,
ContactoParam = actual.Contacto,
NroDocParam = actual.NroDoc,
IdZonaParam = actual.IdZona,
CalleParam = actual.Calle,
NumeroParam = actual.Numero,
PisoParam = actual.Piso,
DeptoParam = actual.Depto,
TelefonoParam = actual.Telefono,
EmailParam = actual.Email,
LocalidadParam = actual.Localidad,
BajaNuevaParam = darDeBaja,
FechaBajaNuevaParam = (darDeBaja ? fechaBaja : null),
IdUsuarioParam = idUsuario,
FechaModParam = DateTime.Now,
TipoModHistParam = (darDeBaja ? "Baja" : "Alta")
}, transaction);
var rowsAffected = await connection.ExecuteAsync(sqlUpdate, new { BajaParam = darDeBaja, FechaBajaParam = (darDeBaja ? fechaBaja : null), IdDistribuidorParam = id }, transaction);
return rowsAffected == 1;
}
public async Task<IEnumerable<(DistribuidorHistorico Historial, string NombreUsuarioModifico)>> GetHistorialAsync( public async Task<IEnumerable<(DistribuidorHistorico Historial, string NombreUsuarioModifico)>> GetHistorialAsync(
DateTime? fechaDesde, DateTime? fechaHasta, DateTime? fechaDesde, DateTime? fechaHasta,
int? idUsuarioModifico, string? tipoModificacion, int? idUsuarioModifico, string? tipoModificacion,

View File

@@ -72,6 +72,13 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
} }
} }
// -------------------------------------------------------------------------------
// Inhabilitada la comprobacion de existencia previa por remito y tipo de movimiento
// Pedido por Claudia Acosta el 18/11/2025
// Motivo: El ex canillita Sergio Mazza opera como distribuidor y no utiliza remitos.
// En el campo de remito se le asigna un numero aleatorio para cumplir con el requisito del sistema.
// -------------------------------------------------------------------------------
/*
public async Task<bool> ExistsByRemitoAndTipoForPublicacionAsync(int remito, string tipoMovimiento, int idPublicacion, int? excludeIdParte = null) public async Task<bool> ExistsByRemitoAndTipoForPublicacionAsync(int remito, string tipoMovimiento, int idPublicacion, int? excludeIdParte = null)
{ {
var sqlBuilder = new StringBuilder("SELECT COUNT(1) FROM dbo.dist_EntradasSalidas WHERE Remito = @RemitoParam AND TipoMovimiento = @TipoMovimientoParam AND Id_Publicacion = @IdPublicacionParam"); var sqlBuilder = new StringBuilder("SELECT COUNT(1) FROM dbo.dist_EntradasSalidas WHERE Remito = @RemitoParam AND TipoMovimiento = @TipoMovimientoParam AND Id_Publicacion = @IdPublicacionParam");
@@ -96,7 +103,7 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
return true; // Asumir que existe en caso de error para prevenir duplicados return true; // Asumir que existe en caso de error para prevenir duplicados
} }
} }
*/
public async Task<EntradaSalidaDist?> CreateAsync(EntradaSalidaDist nuevaES, int idUsuario, IDbTransaction transaction) public async Task<EntradaSalidaDist?> CreateAsync(EntradaSalidaDist nuevaES, int idUsuario, IDbTransaction transaction)
{ {

View File

@@ -8,16 +8,17 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
{ {
public interface IDistribuidorRepository public interface IDistribuidorRepository
{ {
Task<IEnumerable<(Distribuidor Distribuidor, string? NombreZona)>> GetAllAsync(string? nombreFilter, string? nroDocFilter); Task<IEnumerable<(Distribuidor Distribuidor, string? NombreZona)>> GetAllAsync(string? nombreFilter, string? nroDocFilter, bool? soloActivos = true);
Task<(Distribuidor? Distribuidor, string? NombreZona)> GetByIdAsync(int id); Task<(Distribuidor? Distribuidor, string? NombreZona)> GetByIdAsync(int id);
Task<Distribuidor?> GetByIdSimpleAsync(int id); // Para uso interno en el servicio Task<Distribuidor?> GetByIdSimpleAsync(int id); // Para uso interno en el servicio
Task<Distribuidor?> CreateAsync(Distribuidor nuevoDistribuidor, int idUsuario, IDbTransaction transaction); Task<Distribuidor?> CreateAsync(Distribuidor nuevoDistribuidor, int idUsuario, IDbTransaction transaction);
Task<bool> UpdateAsync(Distribuidor distribuidorAActualizar, int idUsuario, IDbTransaction transaction); Task<bool> UpdateAsync(Distribuidor distribuidorAActualizar, int idUsuario, IDbTransaction transaction);
Task<bool> DeleteAsync(int id, int idUsuario, IDbTransaction transaction); Task<bool> DeleteAsync(int id, int idUsuario, IDbTransaction transaction);
Task<bool> ToggleBajaAsync(int id, bool darDeBaja, DateTime? fechaBaja, int idUsuario, IDbTransaction transaction);
Task<bool> ExistsByNroDocAsync(string nroDoc, int? excludeIdDistribuidor = null); Task<bool> ExistsByNroDocAsync(string nroDoc, int? excludeIdDistribuidor = null);
Task<bool> ExistsByNameAsync(string nombre, int? excludeIdDistribuidor = null); Task<bool> ExistsByNameAsync(string nombre, int? excludeIdDistribuidor = null);
Task<bool> IsInUseAsync(int id); // Verificar en dist_EntradasSalidas, cue_PagosDistribuidor, dist_PorcPago Task<bool> IsInUseAsync(int id); // Verificar en dist_EntradasSalidas, cue_PagosDistribuidor, dist_PorcPago
Task<IEnumerable<DistribuidorDropdownDto?>> GetAllDropdownAsync(); Task<IEnumerable<DistribuidorDropdownDto?>> GetAllDropdownAsync(bool? soloActivos = true);
Task<DistribuidorLookupDto?> ObtenerLookupPorIdAsync(int id); Task<DistribuidorLookupDto?> ObtenerLookupPorIdAsync(int id);
Task<IEnumerable<(DistribuidorHistorico Historial, string NombreUsuarioModifico)>> GetHistorialAsync( Task<IEnumerable<(DistribuidorHistorico Historial, string NombreUsuarioModifico)>> GetHistorialAsync(
DateTime? fechaDesde, DateTime? fechaHasta, DateTime? fechaDesde, DateTime? fechaHasta,

View File

@@ -13,7 +13,7 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
Task<EntradaSalidaDist?> CreateAsync(EntradaSalidaDist nuevaES, int idUsuario, IDbTransaction transaction); Task<EntradaSalidaDist?> CreateAsync(EntradaSalidaDist nuevaES, int idUsuario, IDbTransaction transaction);
Task<bool> UpdateAsync(EntradaSalidaDist esAActualizar, int idUsuario, IDbTransaction transaction); Task<bool> UpdateAsync(EntradaSalidaDist esAActualizar, int idUsuario, IDbTransaction transaction);
Task<bool> DeleteAsync(int idParte, int idUsuario, IDbTransaction transaction); Task<bool> DeleteAsync(int idParte, int idUsuario, IDbTransaction transaction);
Task<bool> ExistsByRemitoAndTipoForPublicacionAsync(int remito, string tipoMovimiento, int idPublicacion, int? excludeIdParte = null); //Task<bool> ExistsByRemitoAndTipoForPublicacionAsync(int remito, string tipoMovimiento, int idPublicacion, int? excludeIdParte = null);
Task<IEnumerable<(EntradaSalidaDistHistorico Historial, string NombreUsuarioModifico)>> GetHistorialAsync( Task<IEnumerable<(EntradaSalidaDistHistorico Historial, string NombreUsuarioModifico)>> GetHistorialAsync(
DateTime? fechaDesde, DateTime? fechaHasta, DateTime? fechaDesde, DateTime? fechaHasta,
int? idUsuarioModifico, string? tipoModificacion, int? idUsuarioModifico, string? tipoModificacion,

View File

@@ -30,7 +30,7 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
d.Nombre AS NombreDistribuidor d.Nombre AS NombreDistribuidor
FROM dbo.dist_PorcPago pp FROM dbo.dist_PorcPago pp
INNER JOIN dbo.dist_dtDistribuidores d ON pp.Id_Distribuidor = d.Id_Distribuidor INNER JOIN dbo.dist_dtDistribuidores d ON pp.Id_Distribuidor = d.Id_Distribuidor
WHERE pp.Id_Publicacion = @IdPublicacionParam WHERE pp.Id_Publicacion = @IdPublicacionParam AND d.Baja = 0
ORDER BY d.Nombre, pp.VigenciaD DESC"; ORDER BY d.Nombre, pp.VigenciaD DESC";
try try
{ {

View File

@@ -15,7 +15,9 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
int? idEstadoBobina, int? idEstadoBobina,
string? remitoFilter, string? remitoFilter,
DateTime? fechaDesde, DateTime? fechaDesde,
DateTime? fechaHasta); DateTime? fechaHasta,
DateTime? fechaEstadoDesde,
DateTime? fechaEstadoHasta);
Task<StockBobina?> GetByIdAsync(int idBobina); Task<StockBobina?> GetByIdAsync(int idBobina);
Task<StockBobina?> GetByNroBobinaAsync(string nroBobina); // Para validar unicidad de NroBobina Task<StockBobina?> GetByNroBobinaAsync(string nroBobina); // Para validar unicidad de NroBobina

View File

@@ -23,7 +23,7 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
public async Task<IEnumerable<StockBobina>> GetAllAsync( public async Task<IEnumerable<StockBobina>> GetAllAsync(
int? idTipoBobina, string? nroBobinaFilter, int? idPlanta, int? idTipoBobina, string? nroBobinaFilter, int? idPlanta,
int? idEstadoBobina, string? remitoFilter, DateTime? fechaDesde, DateTime? fechaHasta) int? idEstadoBobina, string? remitoFilter, DateTime? fechaDesde, DateTime? fechaHasta, DateTime? fechaEstadoDesde, DateTime? fechaEstadoHasta)
{ {
var sqlBuilder = new StringBuilder(@" var sqlBuilder = new StringBuilder(@"
SELECT SELECT
@@ -69,6 +69,16 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
sqlBuilder.Append(" AND sb.FechaRemito <= @FechaHastaParam"); sqlBuilder.Append(" AND sb.FechaRemito <= @FechaHastaParam");
parameters.Add("FechaHastaParam", fechaHasta.Value.Date); parameters.Add("FechaHastaParam", fechaHasta.Value.Date);
} }
if (fechaEstadoDesde.HasValue)
{
sqlBuilder.Append(" AND sb.FechaEstado >= @FechaEstadoDesdeParam");
parameters.Add("FechaEstadoDesdeParam", fechaEstadoDesde.Value.Date);
}
if (fechaEstadoHasta.HasValue)
{
sqlBuilder.Append(" AND sb.FechaEstado <= @FechaEstadoHastaParam");
parameters.Add("FechaEstadoHastaParam", fechaEstadoHasta.Value.Date);
}
sqlBuilder.Append(" ORDER BY sb.FechaRemito DESC, sb.NroBobina;"); sqlBuilder.Append(" ORDER BY sb.FechaRemito DESC, sb.NroBobina;");

View File

@@ -0,0 +1,73 @@
using GestionIntegral.Api.Services.Contables;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System;
using System.Text.Json;
using System.Threading.Tasks;
namespace GestionIntegral.Api.Middleware
{
// Centraliza el mapeo de excepciones semánticas a HTTP responses con cuerpo JSON estandarizado.
// Va PRIMERO en el pipeline para catchear cualquier excepción que escape de los controllers/services.
public class ExceptionHandlerMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlerMiddleware> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public ExceptionHandlerMiddleware(RequestDelegate next, ILogger<ExceptionHandlerMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (BloqueoPorPeriodoCerradoException ex)
{
_logger.LogWarning(
"Bloqueo por período cerrado: cierre #{IdCierre} FechaCorte={FechaCorte:yyyy-MM-dd}. Path={Path}",
ex.IdCierre, ex.FechaCorte, context.Request.Path);
await WriteJsonAsync(context, StatusCodes.Status409Conflict, new
{
codigo = "PERIODO_CERRADO_BLOQUEO_OPERACION",
mensaje = ex.Message,
idCierre = ex.IdCierre,
fechaCorte = ex.FechaCorte.ToString("yyyy-MM-dd")
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Excepción no manejada. Path={Path}", context.Request.Path);
await WriteJsonAsync(context, StatusCodes.Status500InternalServerError, new
{
codigo = "ERROR_INTERNO",
mensaje = "Ocurrió un error inesperado al procesar la solicitud."
});
}
}
private static Task WriteJsonAsync(HttpContext context, int statusCode, object body)
{
if (context.Response.HasStarted)
{
// Si los headers ya se enviaron no podemos re-escribir el response. Solo loguear y salir.
return Task.CompletedTask;
}
context.Response.Clear();
context.Response.StatusCode = statusCode;
context.Response.ContentType = "application/json; charset=utf-8";
return context.Response.WriteAsync(JsonSerializer.Serialize(body, JsonOptions));
}
}
}

View File

@@ -0,0 +1,19 @@
using System;
namespace GestionIntegral.Api.Models.Contables
{
public class CierreCuentaCorriente // Corresponde a cue_CierresCuentaCorriente
{
public int IdCierre { get; set; }
public int IdDistribuidor { get; set; }
public int IdEmpresa { get; set; }
public DateTime FechaCorte { get; set; }
public DateTime FechaCierre { get; set; }
public decimal SaldoCierre { get; set; } // money en SQL, decimal en C#
public string Estado { get; set; } = "Activo"; // 'Activo' | 'Anulado'
public string? Justificacion { get; set; }
public int IdUsuarioCierre { get; set; }
public int? IdUsuarioAnula { get; set; }
public DateTime? FechaAnulacion { get; set; }
public string? JustificacionAnulacion { get; set; }
}
}

View File

@@ -0,0 +1,23 @@
using System;
namespace GestionIntegral.Api.Models.Contables
{
public class CierreCuentaCorrienteHistorico // Corresponde a cue_CierresCuentaCorriente_H
{
public int Id_Historial { get; set; }
public int Id_Cierre { get; set; }
public int Id_Distribuidor { get; set; }
public int Id_Empresa { get; set; }
public DateTime FechaCorte { get; set; }
public DateTime FechaCierre { get; set; }
public decimal SaldoCierre { get; set; }
public string Estado { get; set; } = string.Empty;
public string? Justificacion { get; set; }
public int Id_Usuario_Cierre { get; set; }
public int? Id_Usuario_Anula { get; set; }
public DateTime? FechaAnulacion { get; set; }
public string? Justificacion_Anulacion { get; set; }
public string TipoMod { get; set; } = string.Empty; // 'Creacion' | 'Reapertura' | 'Modificacion'
public int Id_Usuario_Mod { get; set; }
public DateTime FechaMod { get; set; }
}
}

View File

@@ -14,5 +14,7 @@ namespace GestionIntegral.Api.Models.Distribucion
public string? Telefono { get; set; } public string? Telefono { get; set; }
public string? Email { get; set; } public string? Email { get; set; }
public string? Localidad { get; set; } public string? Localidad { get; set; }
public bool Baja { get; set; } // Baja (bit, NOT NULL, DEFAULT 0)
public DateTime? FechaBaja { get; set; } // FechaBaja (datetime2(0), NULL)
} }
} }

View File

@@ -16,6 +16,8 @@ namespace GestionIntegral.Api.Models.Distribucion
public string? Telefono { get; set; } public string? Telefono { get; set; }
public string? Email { get; set; } public string? Email { get; set; }
public string? Localidad { get; set; } public string? Localidad { get; set; }
public bool? Baja { get; set; }
public DateTime? FechaBaja { get; set; }
public int Id_Usuario { get; set; } public int Id_Usuario { get; set; }
public DateTime FechaMod { get; set; } public DateTime FechaMod { get; set; }
public string TipoMod { get; set; } = string.Empty; public string TipoMod { get; set; } = string.Empty;

View File

@@ -0,0 +1,26 @@
using System;
namespace GestionIntegral.Api.Dtos.Auditoria
{
public class CierreCuentaCorrienteHistorialDto
{
public int Id_Historial { get; set; }
public int Id_Cierre { get; set; }
public int Id_Distribuidor { get; set; }
public int Id_Empresa { get; set; }
public DateTime FechaCorte { get; set; }
public DateTime FechaCierre { get; set; }
public decimal SaldoCierre { get; set; }
public string Estado { get; set; } = string.Empty;
public string? Justificacion { get; set; }
public int Id_Usuario_Cierre { get; set; }
public int? Id_Usuario_Anula { get; set; }
public DateTime? FechaAnulacion { get; set; }
public string? Justificacion_Anulacion { get; set; }
public string TipoMod { get; set; } = string.Empty; // 'Creacion' | 'Reapertura' | 'Modificacion'
public int Id_Usuario_Mod { get; set; }
public string NombreUsuarioModifico { get; set; } = string.Empty;
public DateTime FechaMod { get; set; }
}
}

View File

@@ -1,3 +1,4 @@
using System;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace GestionIntegral.Api.Dtos.Contables namespace GestionIntegral.Api.Dtos.Contables
@@ -24,5 +25,11 @@ namespace GestionIntegral.Api.Dtos.Contables
[Required(ErrorMessage = "La justificación del ajuste es obligatoria.")] [Required(ErrorMessage = "La justificación del ajuste es obligatoria.")]
[StringLength(250, MinimumLength = 5, ErrorMessage = "La justificación debe tener entre 5 y 250 caracteres.")] [StringLength(250, MinimumLength = 5, ErrorMessage = "La justificación debe tener entre 5 y 250 caracteres.")]
public string Justificacion { get; set; } = string.Empty; public string Justificacion { get; set; } = string.Empty;
// Fecha lógica de la operación. Se valida contra el último cierre vigente
// del par (Distribuidor + Empresa) para bloquear ajustes en períodos cerrados.
// Distinta de FechaAjuste, que es el momento de ejecución del ajuste en el sistema.
[Required(ErrorMessage = "La fecha de operación es obligatoria.")]
public DateTime FechaOperacion { get; set; }
} }
} }

View File

@@ -0,0 +1,25 @@
using System;
namespace GestionIntegral.Api.Dtos.Contables
{
public class CierreCuentaCorrienteDto
{
public int IdCierre { get; set; }
public int IdDistribuidor { get; set; }
public string NombreDistribuidor { get; set; } = string.Empty;
public int IdEmpresa { get; set; }
public string NombreEmpresa { get; set; } = string.Empty;
public string FechaCorte { get; set; } = string.Empty; // yyyy-MM-dd
public DateTime FechaCierre { get; set; }
public decimal SaldoCierre { get; set; }
public string Estado { get; set; } = string.Empty;
public string? Justificacion { get; set; }
public int IdUsuarioCierre { get; set; }
public string NombreUsuarioCierre { get; set; } = string.Empty;
public int? IdUsuarioAnula { get; set; }
public string? NombreUsuarioAnula { get; set; }
public DateTime? FechaAnulacion { get; set; }
public string? JustificacionAnulacion { get; set; }
public bool EsUltimoVigente { get; set; }
}
}

View File

@@ -0,0 +1,22 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace GestionIntegral.Api.Dtos.Contables
{
public class CrearCierreDto
{
[Required(ErrorMessage = "El distribuidor es obligatorio.")]
[Range(1, int.MaxValue, ErrorMessage = "ID de Distribuidor inválido.")]
public int IdDistribuidor { get; set; }
[Required(ErrorMessage = "La empresa es obligatoria.")]
[Range(1, int.MaxValue, ErrorMessage = "ID de Empresa inválido.")]
public int IdEmpresa { get; set; }
[Required(ErrorMessage = "La fecha de corte es obligatoria.")]
public DateTime FechaCorte { get; set; }
[StringLength(500, ErrorMessage = "La justificación no puede superar los 500 caracteres.")]
public string? Justificacion { get; set; }
}
}

View File

@@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
namespace GestionIntegral.Api.Dtos.Contables
{
public class ReabrirCierreDto
{
[Required(ErrorMessage = "La justificación es obligatoria al reabrir un cierre.")]
[StringLength(500, MinimumLength = 10, ErrorMessage = "La justificación debe tener entre 10 y 500 caracteres.")]
public string Justificacion { get; set; } = string.Empty;
}
}

View File

@@ -0,0 +1,12 @@
using System;
namespace GestionIntegral.Api.Dtos.Contables
{
public class UltimoCierreDto
{
public int IdCierre { get; set; }
public string FechaCorte { get; set; } = string.Empty; // yyyy-MM-dd
public decimal SaldoCierre { get; set; }
public string Estado { get; set; } = string.Empty;
}
}

View File

@@ -15,5 +15,7 @@ namespace GestionIntegral.Api.Dtos.Distribucion
public string? Telefono { get; set; } public string? Telefono { get; set; }
public string? Email { get; set; } public string? Email { get; set; }
public string? Localidad { get; set; } public string? Localidad { get; set; }
public bool Baja { get; set; }
public DateTime? FechaBaja { get; set; }
} }
} }

View File

@@ -0,0 +1,10 @@
using System;
namespace GestionIntegral.Api.Dtos.Distribucion
{
public class ToggleBajaDistribuidorDto
{
public bool DarDeBaja { get; set; }
public DateTime? FechaBaja { get; set; }
}
}

View File

@@ -0,0 +1,11 @@
// Backend/GestionIntegral.Api/Models/Dtos/Impresion/BobinaLoteDetalleDto.cs
namespace GestionIntegral.Api.Dtos.Impresion
{
public class BobinaLoteDetalleDto
{
public int IdTipoBobina { get; set; }
public string NroBobina { get; set; } = string.Empty;
public int Peso { get; set; }
}
}

View File

@@ -0,0 +1,23 @@
// Backend/GestionIntegral.Api/Models/Dtos/Impresion/CreateStockBobinaLoteDto.cs
using System.ComponentModel.DataAnnotations;
namespace GestionIntegral.Api.Dtos.Impresion
{
public class CreateStockBobinaLoteDto
{
[Required]
public int IdPlanta { get; set; }
[Required]
[StringLength(15)]
public string Remito { get; set; } = string.Empty;
[Required]
public DateTime FechaRemito { get; set; }
[Required]
[MinLength(1, ErrorMessage = "Debe ingresar al menos una bobina.")]
public List<BobinaLoteDetalleDto> Bobinas { get; set; } = new();
}
}

View File

@@ -0,0 +1,21 @@
// Backend/GestionIntegral.Api/Models/Dtos/Impresion/UpdateFechaRemitoLoteDto.cs
using System.ComponentModel.DataAnnotations;
namespace GestionIntegral.Api.Dtos.Impresion
{
public class UpdateFechaRemitoLoteDto
{
[Required]
public int IdPlanta { get; set; }
[Required]
public required string Remito { get; set; }
[Required]
public DateTime FechaRemitoActual { get; set; } // Para seguridad, nos aseguramos de cambiar el lote correcto
[Required]
public DateTime NuevaFechaRemito { get; set; }
}
}

View File

@@ -7,8 +7,12 @@ namespace GestionIntegral.Api.Dtos.Reportes
public IEnumerable<BalanceCuentaDistDto> EntradasSalidas { get; set; } = new List<BalanceCuentaDistDto>(); public IEnumerable<BalanceCuentaDistDto> EntradasSalidas { get; set; } = new List<BalanceCuentaDistDto>();
public IEnumerable<BalanceCuentaDebCredDto> DebitosCreditos { get; set; } = new List<BalanceCuentaDebCredDto>(); public IEnumerable<BalanceCuentaDebCredDto> DebitosCreditos { get; set; } = new List<BalanceCuentaDebCredDto>();
public IEnumerable<BalanceCuentaPagosDto> Pagos { get; set; } = new List<BalanceCuentaPagosDto>(); public IEnumerable<BalanceCuentaPagosDto> Pagos { get; set; } = new List<BalanceCuentaPagosDto>();
public IEnumerable<SaldoDto> Saldos { get; set; } = new List<SaldoDto>(); // O podría ser SaldoDto SaldoActual si siempre es uno public string? NombreDistribuidor { get; set; }
public string? NombreDistribuidor { get; set; } // Para el título del reporte public string? NombreEmpresa { get; set; }
public string? NombreEmpresa { get; set; } // Para el título del reporte
// Saldo a la fecha desde elegida en el filtro:
// - Si existe cierre con FechaCorte < FechaDesde: SaldoCierre + movimientos netos entre fechaCierre+1 y fechaDesde-1.
// - Sin cierres previos: 0.
public decimal SaldoInicial { get; set; }
} }
} }

View File

@@ -11,8 +11,9 @@ namespace GestionIntegral.Api.Dtos.Reportes.ViewModels
public IEnumerable<BalanceCuentaPagosDto> Pagos { get; set; } = new List<BalanceCuentaPagosDto>(); public IEnumerable<BalanceCuentaPagosDto> Pagos { get; set; } = new List<BalanceCuentaPagosDto>();
public IEnumerable<BalanceCuentaDebCredDto> DebitosCreditos { get; set; } = new List<BalanceCuentaDebCredDto>(); public IEnumerable<BalanceCuentaDebCredDto> DebitosCreditos { get; set; } = new List<BalanceCuentaDebCredDto>();
// Saldo real de la cuenta, se muestra al final sin usarse en cálculos intermedios. // Saldo inicial del período: snapshot del último cierre + movimientos netos hasta fechaDesde.
public decimal SaldoDeCuenta { get; set; } // 0 si no hay cierre previo.
public decimal SaldoInicial { get; set; }
// --- Parámetros del reporte --- // --- Parámetros del reporte ---
public string NombreDistribuidor { get; set; } = string.Empty; public string NombreDistribuidor { get; set; } = string.Empty;
@@ -24,6 +25,8 @@ namespace GestionIntegral.Api.Dtos.Reportes.ViewModels
public decimal TotalMovimientos => Movimientos.Sum(m => m.Debe - m.Haber); public decimal TotalMovimientos => Movimientos.Sum(m => m.Debe - m.Haber);
public decimal TotalPagos => Pagos.Sum(p => p.Debe - p.Haber); public decimal TotalPagos => Pagos.Sum(p => p.Debe - p.Haber);
public decimal TotalDebitosCreditos => DebitosCreditos.Sum(d => d.Debe - d.Haber); public decimal TotalDebitosCreditos => DebitosCreditos.Sum(d => d.Debe - d.Haber);
public decimal TotalPeriodo => TotalMovimientos + TotalPagos + TotalDebitosCreditos;
// Saldo Final = Saldo Inicial + suma neta del período (Debe - Haber por sección).
public decimal SaldoFinal => SaldoInicial + TotalMovimientos + TotalPagos + TotalDebitosCreditos;
} }
} }

View File

@@ -30,6 +30,22 @@ namespace GestionIntegral.Api.Dtos.Reportes.ViewModels
} }
} }
public decimal PorcentajeDevolucionGeneral
{
get
{
if (PromediosPorDia == null || !PromediosPorDia.Any()) return 0;
var totalPonderadoLlevados = PromediosPorDia.Sum(p => p.Promedio_Llevados * p.Cant);
var totalPonderadoDevueltos = PromediosPorDia.Sum(p => p.Promedio_Devueltos * p.Cant);
if (totalPonderadoLlevados == 0) return 0;
// Calculamos el porcentaje usando los totales ponderados para máxima precisión como lo hace el frontend.
return (decimal)totalPonderadoDevueltos * 100 / totalPonderadoLlevados;
}
}
// --- PROPIEDAD PARA LA FILA "GENERAL" --- // --- PROPIEDAD PARA LA FILA "GENERAL" ---
public ListadoDistribucionCanillasPromedioDiaDto? PromedioGeneral public ListadoDistribucionCanillasPromedioDiaDto? PromedioGeneral
{ {
@@ -37,20 +53,27 @@ namespace GestionIntegral.Api.Dtos.Reportes.ViewModels
{ {
if (PromediosPorDia == null || !PromediosPorDia.Any()) return null; if (PromediosPorDia == null || !PromediosPorDia.Any()) return null;
// Sumamos los totales, no promediamos los promedios // Sumamos los totales ponderados para cada columna
var totalLlevados = PromediosPorDia.Sum(p => p.Llevados); var totalPonderadoLlevados = PromediosPorDia.Sum(p => p.Promedio_Llevados * p.Cant);
var totalDevueltos = PromediosPorDia.Sum(p => p.Devueltos); var totalPonderadoDevueltos = PromediosPorDia.Sum(p => p.Promedio_Devueltos * p.Cant);
var totalPonderadoVentas = PromediosPorDia.Sum(p => p.Promedio_Ventas * p.Cant);
var totalDias = PromediosPorDia.Sum(p => p.Cant); var totalDias = PromediosPorDia.Sum(p => p.Cant);
if (totalDias == 0) return null; if (totalDias == 0) return null;
// Usamos Math.Round para un redondeo matemático estándar antes de la conversión.
// MidpointRounding.AwayFromZero asegura que .5 se redondee hacia arriba, igual que en JavaScript.
var promGeneralLlevados = (int)Math.Round((decimal)totalPonderadoLlevados / totalDias, MidpointRounding.AwayFromZero);
var promGeneralDevueltos = (int)Math.Round((decimal)totalPonderadoDevueltos / totalDias, MidpointRounding.AwayFromZero);
var promGeneralVentas = (int)Math.Round((decimal)totalPonderadoVentas / totalDias, MidpointRounding.AwayFromZero);
return new ListadoDistribucionCanillasPromedioDiaDto return new ListadoDistribucionCanillasPromedioDiaDto
{ {
Dia = "General", Dia = "General",
Cant = totalDias, Cant = totalDias,
Promedio_Llevados = totalLlevados / totalDias, Promedio_Llevados = promGeneralLlevados,
Promedio_Devueltos = totalDevueltos / totalDias, Promedio_Devueltos = promGeneralDevueltos,
Promedio_Ventas = (totalLlevados - totalDevueltos) / totalDias Promedio_Ventas = promGeneralVentas
}; };
} }
} }

View File

@@ -20,26 +20,26 @@ namespace GestionIntegral.Api.Dtos.Reportes.ViewModels
{ {
get get
{ {
if (DetalleDiario == null || !DetalleDiario.Any()) if (PromediosPorDia == null || !PromediosPorDia.Any())
{ {
return null; return null;
} }
var promediosValidos = PromediosPorDia.Where(p => p.Dia != "General").ToList();
var diasConDatos = DetalleDiario.Count(d => (d.Llevados ?? 0) > 0); if (!promediosValidos.Any()) return null;
if (diasConDatos == 0) return null; var countPromedios = promediosValidos.Count;
var sumPromLlevados = promediosValidos.Sum(p => p.Promedio_Llevados ?? 0);
var totalLlevados = DetalleDiario.Sum(d => d.Llevados ?? 0); var sumPromDevueltos = promediosValidos.Sum(p => p.Promedio_Devueltos ?? 0);
var totalDevueltos = DetalleDiario.Sum(d => d.Devueltos ?? 0); var sumPromVentas = promediosValidos.Sum(p => p.Promedio_Ventas ?? 0);
return new ListadoDistribucionDistPromedioDiaDto return new ListadoDistribucionDistPromedioDiaDto
{ {
Dia = "General", Dia = "General",
Cant = diasConDatos, Cant = promediosValidos.Sum(p => p.Cant ?? 0),
Promedio_Llevados = totalLlevados / diasConDatos, Promedio_Llevados = (int)Math.Round((decimal)sumPromLlevados / countPromedios, MidpointRounding.AwayFromZero),
Promedio_Devueltos = totalDevueltos / diasConDatos, Promedio_Devueltos = (int)Math.Round((decimal)sumPromDevueltos / countPromedios, MidpointRounding.AwayFromZero),
Promedio_Ventas = (totalLlevados - totalDevueltos) / diasConDatos, Promedio_Ventas = (int)Math.Round((decimal)sumPromVentas / countPromedios, MidpointRounding.AwayFromZero),
Llevados = totalLlevados, // Guardamos el total para el cálculo del % Llevados = (int)Math.Round((decimal)sumPromLlevados / countPromedios, MidpointRounding.AwayFromZero),
Devueltos = totalDevueltos // Guardamos el total para el cálculo del % Devueltos = (int)Math.Round((decimal)sumPromDevueltos / countPromedios, MidpointRounding.AwayFromZero)
}; };
} }
} }

View File

@@ -23,6 +23,7 @@ using GestionIntegral.Api.Services.Suscripciones;
using GestionIntegral.Api.Models.Comunicaciones; using GestionIntegral.Api.Models.Comunicaciones;
using GestionIntegral.Api.Services.Comunicaciones; using GestionIntegral.Api.Services.Comunicaciones;
using GestionIntegral.Api.Data.Repositories.Comunicaciones; using GestionIntegral.Api.Data.Repositories.Comunicaciones;
using GestionIntegral.Api.Middleware;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -96,6 +97,12 @@ builder.Services.AddScoped<ICambioParadaRepository, CambioParadaRepository>();
builder.Services.AddScoped<ICambioParadaService, CambioParadaService>(); builder.Services.AddScoped<ICambioParadaService, CambioParadaService>();
// Servicio de Saldos // Servicio de Saldos
builder.Services.AddScoped<ISaldoService, SaldoService>(); builder.Services.AddScoped<ISaldoService, SaldoService>();
// Cierre de Cuenta Corriente de Distribuidor
builder.Services.AddMemoryCache();
builder.Services.AddScoped<ICierreCuentaCorrienteRepository, CierreCuentaCorrienteRepository>();
builder.Services.AddScoped<ICierreCuentaCorrienteService, CierreCuentaCorrienteService>();
// Validador de período cerrado: SINGLETON porque mantiene cache en memoria de IMemoryCache que debe ser compartido entre requests.
builder.Services.AddSingleton<IPeriodoCerradoValidator, PeriodoCerradoValidator>();
// Repositorios de Reportes // Repositorios de Reportes
builder.Services.AddScoped<IReportesRepository, ReportesRepository>(); builder.Services.AddScoped<IReportesRepository, ReportesRepository>();
// Servicios de Reportes // Servicios de Reportes
@@ -269,6 +276,10 @@ if (app.Environment.IsDevelopment())
// Comenta o elimina la siguiente línea si SÓLO usas http://localhost:5183 // Comenta o elimina la siguiente línea si SÓLO usas http://localhost:5183
//app.UseHttpsRedirection(); //app.UseHttpsRedirection();
// Middleware global de excepciones — debe ir TEMPRANO en el pipeline para catchear cualquier excepción
// que escape de los controllers/services. Mapea BloqueoPorPeriodoCerradoException → 409 con cuerpo JSON estandarizado.
app.UseMiddleware<ExceptionHandlerMiddleware>();
app.UseCors(MyAllowSpecificOrigins); app.UseCors(MyAllowSpecificOrigins);
app.UseAuthentication(); // Debe ir ANTES de UseAuthorization app.UseAuthentication(); // Debe ir ANTES de UseAuthorization

View File

@@ -22,13 +22,16 @@ namespace GestionIntegral.Api.Services.Anomalia
public async Task<IEnumerable<AlertaGenericaDto>> ObtenerAlertasNoLeidasAsync() public async Task<IEnumerable<AlertaGenericaDto>> ObtenerAlertasNoLeidasAsync()
{ {
// Apunta a la nueva tabla genérica 'Sistema_Alertas' // Apunta a la nueva tabla genérica 'Sistema_Alertas'
var query = "SELECT * FROM Sistema_Alertas WHERE Leida = 0 ORDER BY FechaDeteccion DESC"; //var query = "SELECT * FROM Sistema_Alertas WHERE Leida = 0 ORDER BY FechaDeteccion DESC";
try try
{ {
using (var connection = _dbConnectionFactory.CreateConnection()) using (var connection = _dbConnectionFactory.CreateConnection())
{ {
/*
var alertas = await connection.QueryAsync<AlertaGenericaDto>(query); var alertas = await connection.QueryAsync<AlertaGenericaDto>(query);
return alertas ?? Enumerable.Empty<AlertaGenericaDto>(); return alertas ?? Enumerable.Empty<AlertaGenericaDto>();
*/
return Enumerable.Empty<AlertaGenericaDto>();
} }
} }
catch (System.Exception ex) catch (System.Exception ex)
@@ -40,17 +43,20 @@ namespace GestionIntegral.Api.Services.Anomalia
public async Task<(bool Exito, string? Error)> MarcarComoLeidaAsync(int idAlerta) public async Task<(bool Exito, string? Error)> MarcarComoLeidaAsync(int idAlerta)
{ {
var query = "UPDATE Sistema_Alertas SET Leida = 1 WHERE IdAlerta = @IdAlerta"; //var query = "UPDATE Sistema_Alertas SET Leida = 1 WHERE IdAlerta = @IdAlerta";
try try
{ {
using (var connection = _dbConnectionFactory.CreateConnection()) using (var connection = _dbConnectionFactory.CreateConnection())
{ {
/*
var result = await connection.ExecuteAsync(query, new { IdAlerta = idAlerta }); var result = await connection.ExecuteAsync(query, new { IdAlerta = idAlerta });
if (result > 0) if (result > 0)
{ {
return (true, null); return (true, null);
} }
return (false, "La alerta no fue encontrada o ya estaba marcada."); return (false, "La alerta no fue encontrada o ya estaba marcada.");
*/
return (true, null); // Retornar éxito silencioso por ahora
} }
} }
catch (System.Exception ex) catch (System.Exception ex)
@@ -62,15 +68,18 @@ namespace GestionIntegral.Api.Services.Anomalia
public async Task<(bool Exito, string? Error)> MarcarGrupoComoLeidoAsync(string tipoAlerta, int idEntidad) public async Task<(bool Exito, string? Error)> MarcarGrupoComoLeidoAsync(string tipoAlerta, int idEntidad)
{ {
var query = "UPDATE Sistema_Alertas SET Leida = 1 WHERE TipoAlerta = @TipoAlerta AND IdEntidad = @IdEntidad AND Leida = 0"; //var query = "UPDATE Sistema_Alertas SET Leida = 1 WHERE TipoAlerta = @TipoAlerta AND IdEntidad = @IdEntidad AND Leida = 0";
try try
{ {
using (var connection = _dbConnectionFactory.CreateConnection()) using (var connection = _dbConnectionFactory.CreateConnection())
{ {
/*
var result = await connection.ExecuteAsync(query, new { TipoAlerta = tipoAlerta, IdEntidad = idEntidad }); var result = await connection.ExecuteAsync(query, new { TipoAlerta = tipoAlerta, IdEntidad = idEntidad });
// No es un error si no se actualizan filas (puede que no hubiera ninguna para ese grupo) // No es un error si no se actualizan filas (puede que no hubiera ninguna para ese grupo)
_logger.LogInformation("Marcadas como leídas {Count} alertas para Tipo: {Tipo}, EntidadID: {IdEntidad}", result, tipoAlerta, idEntidad); _logger.LogInformation("Marcadas como leídas {Count} alertas para Tipo: {Tipo}, EntidadID: {IdEntidad}", result, tipoAlerta, idEntidad);
return (true, null); return (true, null);
*/
return (true, null);
} }
} }
catch (System.Exception ex) catch (System.Exception ex)

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,6 +20,7 @@ namespace GestionIntegral.Api.Services.Contables
private readonly ICanillaRepository _canillaRepo; private readonly ICanillaRepository _canillaRepo;
private readonly IEmpresaRepository _empresaRepo; private readonly IEmpresaRepository _empresaRepo;
private readonly ISaldoRepository _saldoRepo; private readonly ISaldoRepository _saldoRepo;
private readonly IPeriodoCerradoValidator _periodoCerrado;
private readonly DbConnectionFactory _connectionFactory; private readonly DbConnectionFactory _connectionFactory;
private readonly ILogger<NotaCreditoDebitoService> _logger; private readonly ILogger<NotaCreditoDebitoService> _logger;
@@ -29,6 +30,7 @@ namespace GestionIntegral.Api.Services.Contables
ICanillaRepository canillaRepo, ICanillaRepository canillaRepo,
IEmpresaRepository empresaRepo, IEmpresaRepository empresaRepo,
ISaldoRepository saldoRepo, ISaldoRepository saldoRepo,
IPeriodoCerradoValidator periodoCerrado,
DbConnectionFactory connectionFactory, DbConnectionFactory connectionFactory,
ILogger<NotaCreditoDebitoService> logger) ILogger<NotaCreditoDebitoService> logger)
{ {
@@ -37,6 +39,7 @@ namespace GestionIntegral.Api.Services.Contables
_canillaRepo = canillaRepo; _canillaRepo = canillaRepo;
_empresaRepo = empresaRepo; _empresaRepo = empresaRepo;
_saldoRepo = saldoRepo; _saldoRepo = saldoRepo;
_periodoCerrado = periodoCerrado;
_connectionFactory = connectionFactory; _connectionFactory = connectionFactory;
_logger = logger; _logger = logger;
} }
@@ -111,6 +114,11 @@ namespace GestionIntegral.Api.Services.Contables
if (await _empresaRepo.GetByIdAsync(createDto.IdEmpresa) == null) if (await _empresaRepo.GetByIdAsync(createDto.IdEmpresa) == null)
return (null, "La empresa especificada no existe."); 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 var nuevaNota = new NotaCreditoDebito
{ {
Destino = createDto.Destino, Destino = createDto.Destino,
@@ -187,6 +195,14 @@ namespace GestionIntegral.Api.Services.Contables
return (false, "Nota no encontrada."); 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 impactoOriginalSaldo = notaExistente.Tipo == "Credito" ? -notaExistente.Monto : notaExistente.Monto;
decimal impactoNuevoSaldo = notaExistente.Tipo == "Credito" ? -updateDto.Monto : updateDto.Monto; decimal impactoNuevoSaldo = notaExistente.Tipo == "Credito" ? -updateDto.Monto : updateDto.Monto;
decimal diferenciaAjusteSaldo = impactoNuevoSaldo - impactoOriginalSaldo; decimal diferenciaAjusteSaldo = impactoNuevoSaldo - impactoOriginalSaldo;
@@ -252,6 +268,14 @@ namespace GestionIntegral.Api.Services.Contables
return (false, "Nota no encontrada."); 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; decimal montoReversion = notaExistente.Tipo == "Credito" ? notaExistente.Monto : -notaExistente.Monto;
var eliminado = await _notaRepo.DeleteAsync(idNota, idUsuario, transaction); var eliminado = await _notaRepo.DeleteAsync(idNota, idUsuario, transaction);

View File

@@ -20,6 +20,7 @@ namespace GestionIntegral.Api.Services.Contables
private readonly ITipoPagoRepository _tipoPagoRepo; private readonly ITipoPagoRepository _tipoPagoRepo;
private readonly IEmpresaRepository _empresaRepo; private readonly IEmpresaRepository _empresaRepo;
private readonly ISaldoRepository _saldoRepo; private readonly ISaldoRepository _saldoRepo;
private readonly IPeriodoCerradoValidator _periodoCerrado;
private readonly DbConnectionFactory _connectionFactory; private readonly DbConnectionFactory _connectionFactory;
private readonly ILogger<PagoDistribuidorService> _logger; private readonly ILogger<PagoDistribuidorService> _logger;
@@ -29,6 +30,7 @@ namespace GestionIntegral.Api.Services.Contables
ITipoPagoRepository tipoPagoRepo, ITipoPagoRepository tipoPagoRepo,
IEmpresaRepository empresaRepo, IEmpresaRepository empresaRepo,
ISaldoRepository saldoRepo, ISaldoRepository saldoRepo,
IPeriodoCerradoValidator periodoCerrado,
DbConnectionFactory connectionFactory, DbConnectionFactory connectionFactory,
ILogger<PagoDistribuidorService> logger) ILogger<PagoDistribuidorService> logger)
{ {
@@ -37,6 +39,7 @@ namespace GestionIntegral.Api.Services.Contables
_tipoPagoRepo = tipoPagoRepo; _tipoPagoRepo = tipoPagoRepo;
_empresaRepo = empresaRepo; _empresaRepo = empresaRepo;
_saldoRepo = saldoRepo; _saldoRepo = saldoRepo;
_periodoCerrado = periodoCerrado;
_connectionFactory = connectionFactory; _connectionFactory = connectionFactory;
_logger = logger; _logger = logger;
} }
@@ -106,6 +109,11 @@ namespace GestionIntegral.Api.Services.Contables
return (null, mensajeError); 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 var nuevoPago = new PagoDistribuidor
{ {
IdDistribuidor = createDto.IdDistribuidor, IdDistribuidor = createDto.IdDistribuidor,
@@ -182,6 +190,14 @@ namespace GestionIntegral.Api.Services.Contables
return (false, "Pago no encontrado."); 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) if (await _tipoPagoRepo.GetByIdAsync(updateDto.IdTipoPago) == null)
{ {
transaction.Rollback(); transaction.Rollback();
@@ -219,6 +235,11 @@ namespace GestionIntegral.Api.Services.Contables
return (true, null); 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 (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) catch (Exception ex)
{ {
try { transaction?.Rollback(); } catch (Exception rbEx) { _logger.LogError(rbEx, "Error en Rollback de ActualizarAsync PagoDistribuidor."); } 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."); 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; decimal montoReversion = pagoExistente.TipoMovimiento == "Recibido" ? pagoExistente.Monto : -pagoExistente.Monto;
var eliminado = await _pagoRepo.DeleteAsync(idPago, idUsuario, transaction); var eliminado = await _pagoRepo.DeleteAsync(idPago, idUsuario, transaction);
@@ -266,6 +295,11 @@ namespace GestionIntegral.Api.Services.Contables
return (true, null); 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 (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) catch (Exception ex)
{ {
try { transaction?.Rollback(); } catch (Exception rbEx) { _logger.LogError(rbEx, "Error en Rollback de EliminarAsync PagoDistribuidor."); } try { transaction?.Rollback(); } catch (Exception rbEx) { _logger.LogError(rbEx, "Error en Rollback de EliminarAsync PagoDistribuidor."); }

View File

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

View File

@@ -19,6 +19,7 @@ namespace GestionIntegral.Api.Services.Contables
private readonly IDistribuidorRepository _distribuidorRepo; // Para nombres private readonly IDistribuidorRepository _distribuidorRepo; // Para nombres
private readonly ICanillaRepository _canillaRepo; // Para nombres private readonly ICanillaRepository _canillaRepo; // Para nombres
private readonly IEmpresaRepository _empresaRepo; // Para nombres private readonly IEmpresaRepository _empresaRepo; // Para nombres
private readonly IPeriodoCerradoValidator _periodoCerrado;
private readonly DbConnectionFactory _connectionFactory; private readonly DbConnectionFactory _connectionFactory;
private readonly ILogger<SaldoService> _logger; private readonly ILogger<SaldoService> _logger;
@@ -27,6 +28,7 @@ namespace GestionIntegral.Api.Services.Contables
IDistribuidorRepository distribuidorRepo, IDistribuidorRepository distribuidorRepo,
ICanillaRepository canillaRepo, ICanillaRepository canillaRepo,
IEmpresaRepository empresaRepo, IEmpresaRepository empresaRepo,
IPeriodoCerradoValidator periodoCerrado,
DbConnectionFactory connectionFactory, DbConnectionFactory connectionFactory,
ILogger<SaldoService> logger) ILogger<SaldoService> logger)
{ {
@@ -34,6 +36,7 @@ namespace GestionIntegral.Api.Services.Contables
_distribuidorRepo = distribuidorRepo; _distribuidorRepo = distribuidorRepo;
_canillaRepo = canillaRepo; _canillaRepo = canillaRepo;
_empresaRepo = empresaRepo; _empresaRepo = empresaRepo;
_periodoCerrado = periodoCerrado;
_connectionFactory = connectionFactory; _connectionFactory = connectionFactory;
_logger = logger; _logger = logger;
} }
@@ -103,6 +106,11 @@ namespace GestionIntegral.Api.Services.Contables
if (await _empresaRepo.GetByIdAsync(ajusteDto.IdEmpresa) == null) if (await _empresaRepo.GetByIdAsync(ajusteDto.IdEmpresa) == null)
return (false, "La empresa especificada no existe.", 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(); using var connection = _connectionFactory.CreateConnection();
if (connection.State != ConnectionState.Open) { if (connection is System.Data.Common.DbConnection dbConn) await dbConn.OpenAsync(); else connection.Open(); } 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); var saldoDtoActualizado = await MapToGestionDto(saldoDespuesDeModificacion);
return (true, null, saldoDtoActualizado); 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) catch (Exception ex)
{ {
try { transaction.Rollback(); } catch (Exception rbEx) { _logger.LogError(rbEx, "Error en Rollback de RealizarAjusteManualSaldoAsync."); } try { transaction.Rollback(); } catch (Exception rbEx) { _logger.LogError(rbEx, "Error en Rollback de RealizarAjusteManualSaldoAsync."); }

View File

@@ -56,20 +56,22 @@ namespace GestionIntegral.Api.Services.Distribucion
Depto = data.Distribuidor.Depto, Depto = data.Distribuidor.Depto,
Telefono = data.Distribuidor.Telefono, Telefono = data.Distribuidor.Telefono,
Email = data.Distribuidor.Email, Email = data.Distribuidor.Email,
Localidad = data.Distribuidor.Localidad Localidad = data.Distribuidor.Localidad,
Baja = data.Distribuidor.Baja,
FechaBaja = data.Distribuidor.FechaBaja
}; };
} }
public async Task<IEnumerable<DistribuidorDto>> ObtenerTodosAsync(string? nombreFilter, string? nroDocFilter) public async Task<IEnumerable<DistribuidorDto>> ObtenerTodosAsync(string? nombreFilter, string? nroDocFilter, bool? soloActivos = true)
{ {
var data = await _distribuidorRepository.GetAllAsync(nombreFilter, nroDocFilter); var data = await _distribuidorRepository.GetAllAsync(nombreFilter, nroDocFilter, soloActivos);
// Filtrar nulos y asegurar al compilador que no hay nulos en la lista final // Filtrar nulos y asegurar al compilador que no hay nulos en la lista final
return data.Select(MapToDto).Where(dto => dto != null).Select(dto => dto!); return data.Select(MapToDto).Where(dto => dto != null).Select(dto => dto!);
} }
public async Task<IEnumerable<DistribuidorDropdownDto>> GetAllDropdownAsync() public async Task<IEnumerable<DistribuidorDropdownDto>> GetAllDropdownAsync(bool? soloActivos = true)
{ {
var data = await _distribuidorRepository.GetAllDropdownAsync(); var data = await _distribuidorRepository.GetAllDropdownAsync(soloActivos);
// Asegurar que el resultado no sea nulo y no contiene elementos nulos // Asegurar que el resultado no sea nulo y no contiene elementos nulos
if (data == null) if (data == null)
{ {
@@ -223,6 +225,31 @@ namespace GestionIntegral.Api.Services.Distribucion
} }
} }
public async Task<(bool Exito, string? Error)> ToggleBajaAsync(int id, bool darDeBaja, DateTime? fechaBaja, int idUsuario)
{
var distribuidorExistente = await _distribuidorRepository.GetByIdSimpleAsync(id);
if (distribuidorExistente == null) return (false, "Distribuidor no encontrado.");
using var connection = _connectionFactory.CreateConnection();
if (connection is System.Data.Common.DbConnection dbConn) await dbConn.OpenAsync(); else connection.Open();
using var transaction = connection.BeginTransaction();
try
{
var toggled = await _distribuidorRepository.ToggleBajaAsync(id, darDeBaja, fechaBaja, idUsuario, transaction);
if (!toggled) throw new DataException("Error al cambiar estado de baja.");
transaction.Commit();
_logger.LogInformation("Distribuidor ID {IdDistribuidor} dado de {Estado} por Usuario ID {IdUsuario}.", id, darDeBaja ? "baja" : "alta", idUsuario);
return (true, null);
}
catch (KeyNotFoundException) { try { transaction.Rollback(); } catch { } return (false, "Distribuidor no encontrado."); }
catch (Exception ex)
{
try { transaction.Rollback(); } catch { }
_logger.LogError(ex, "Error ToggleBajaAsync Distribuidor ID: {IdDistribuidor}", id);
return (false, $"Error interno: {ex.Message}");
}
}
public async Task<IEnumerable<DistribuidorHistorialDto>> ObtenerHistorialAsync( public async Task<IEnumerable<DistribuidorHistorialDto>> ObtenerHistorialAsync(
DateTime? fechaDesde, DateTime? fechaHasta, DateTime? fechaDesde, DateTime? fechaHasta,
int? idUsuarioModifico, string? tipoModificacion, int? idUsuarioModifico, string? tipoModificacion,

View File

@@ -4,6 +4,7 @@ using GestionIntegral.Api.Data.Repositories.Distribucion;
using GestionIntegral.Api.Dtos.Auditoria; using GestionIntegral.Api.Dtos.Auditoria;
using GestionIntegral.Api.Dtos.Distribucion; using GestionIntegral.Api.Dtos.Distribucion;
using GestionIntegral.Api.Models.Distribucion; using GestionIntegral.Api.Models.Distribucion;
using GestionIntegral.Api.Services.Contables;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
@@ -24,6 +25,7 @@ namespace GestionIntegral.Api.Services.Distribucion
private readonly IPorcPagoRepository _porcPagoRepository; private readonly IPorcPagoRepository _porcPagoRepository;
private readonly ISaldoRepository _saldoRepository; private readonly ISaldoRepository _saldoRepository;
private readonly IEmpresaRepository _empresaRepository; // Para obtener IdEmpresa de la publicación private readonly IEmpresaRepository _empresaRepository; // Para obtener IdEmpresa de la publicación
private readonly IPeriodoCerradoValidator _periodoCerrado;
private readonly DbConnectionFactory _connectionFactory; private readonly DbConnectionFactory _connectionFactory;
private readonly ILogger<EntradaSalidaDistService> _logger; private readonly ILogger<EntradaSalidaDistService> _logger;
@@ -36,6 +38,7 @@ namespace GestionIntegral.Api.Services.Distribucion
IPorcPagoRepository porcPagoRepository, IPorcPagoRepository porcPagoRepository,
ISaldoRepository saldoRepository, ISaldoRepository saldoRepository,
IEmpresaRepository empresaRepository, IEmpresaRepository empresaRepository,
IPeriodoCerradoValidator periodoCerrado,
DbConnectionFactory connectionFactory, DbConnectionFactory connectionFactory,
ILogger<EntradaSalidaDistService> logger) ILogger<EntradaSalidaDistService> logger)
{ {
@@ -47,6 +50,7 @@ namespace GestionIntegral.Api.Services.Distribucion
_porcPagoRepository = porcPagoRepository; _porcPagoRepository = porcPagoRepository;
_saldoRepository = saldoRepository; _saldoRepository = saldoRepository;
_empresaRepository = empresaRepository; _empresaRepository = empresaRepository;
_periodoCerrado = periodoCerrado;
_connectionFactory = connectionFactory; _connectionFactory = connectionFactory;
_logger = logger; _logger = logger;
} }
@@ -167,10 +171,16 @@ namespace GestionIntegral.Api.Services.Distribucion
var distribuidor = await _distribuidorRepository.GetByIdSimpleAsync(createDto.IdDistribuidor); var distribuidor = await _distribuidorRepository.GetByIdSimpleAsync(createDto.IdDistribuidor);
if (distribuidor == null) return (null, "Distribuidor no válido."); 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)) if (await _esRepository.ExistsByRemitoAndTipoForPublicacionAsync(createDto.Remito, createDto.TipoMovimiento, createDto.IdPublicacion))
{ {
return (null, $"Ya existe un movimiento de '{createDto.TipoMovimiento}' con el remito N°{createDto.Remito} para esta publicación."); return (null, $"Ya existe un movimiento de '{createDto.TipoMovimiento}' con el remito N°{createDto.Remito} para esta publicación.");
} }*/
// Determinar IDs de Precio, Recargo y Porcentaje activos en la fecha del movimiento // Determinar IDs de Precio, Recargo y Porcentaje activos en la fecha del movimiento
var precioActivo = await _precioRepository.GetActiveByPublicacionAndDateAsync(createDto.IdPublicacion, createDto.Fecha.Date); var precioActivo = await _precioRepository.GetActiveByPublicacionAndDateAsync(createDto.IdPublicacion, createDto.Fecha.Date);
@@ -261,6 +271,14 @@ namespace GestionIntegral.Api.Services.Distribucion
var distribuidor = await _distribuidorRepository.GetByIdSimpleAsync(esExistente.IdDistribuidor); var distribuidor = await _distribuidorRepository.GetByIdSimpleAsync(esExistente.IdDistribuidor);
if (distribuidor == null) return (false, "Distribuidor asociado no encontrado."); 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) // 1. Calcular monto del movimiento original (antes de la actualización)
decimal montoOriginal = await CalcularMontoMovimiento( decimal montoOriginal = await CalcularMontoMovimiento(
@@ -306,6 +324,11 @@ namespace GestionIntegral.Api.Services.Distribucion
return (true, null); return (true, null);
} }
catch (KeyNotFoundException) { try { transaction.Rollback(); } catch { } return (false, "Movimiento no encontrado."); } catch (KeyNotFoundException) { try { transaction.Rollback(); } catch { } return (false, "Movimiento no encontrado."); }
catch (BloqueoPorPeriodoCerradoException)
{
try { transaction.Rollback(); } catch { }
throw;
}
catch (Exception ex) catch (Exception ex)
{ {
try { transaction.Rollback(); } catch { } try { transaction.Rollback(); } catch { }
@@ -329,6 +352,14 @@ namespace GestionIntegral.Api.Services.Distribucion
var distribuidor = await _distribuidorRepository.GetByIdSimpleAsync(esExistente.IdDistribuidor); var distribuidor = await _distribuidorRepository.GetByIdSimpleAsync(esExistente.IdDistribuidor);
if (distribuidor == null) return (false, "Distribuidor asociado no encontrado."); 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 // 1. Calcular el monto del movimiento a eliminar para revertir el saldo
decimal montoReversion = await CalcularMontoMovimiento( decimal montoReversion = await CalcularMontoMovimiento(
esExistente.IdPublicacion, esExistente.IdDistribuidor, esExistente.Fecha, esExistente.Cantidad, esExistente.TipoMovimiento, esExistente.IdPublicacion, esExistente.IdDistribuidor, esExistente.Fecha, esExistente.Cantidad, esExistente.TipoMovimiento,
@@ -363,6 +394,11 @@ namespace GestionIntegral.Api.Services.Distribucion
return (true, null); return (true, null);
} }
catch (KeyNotFoundException) { try { transaction.Rollback(); } catch { } return (false, "Movimiento no encontrado."); } catch (KeyNotFoundException) { try { transaction.Rollback(); } catch { } return (false, "Movimiento no encontrado."); }
catch (BloqueoPorPeriodoCerradoException)
{
try { transaction.Rollback(); } catch { }
throw;
}
catch (Exception ex) catch (Exception ex)
{ {
try { transaction.Rollback(); } catch { } try { transaction.Rollback(); } catch { }

View File

@@ -7,12 +7,13 @@ namespace GestionIntegral.Api.Services.Distribucion
{ {
public interface IDistribuidorService public interface IDistribuidorService
{ {
Task<IEnumerable<DistribuidorDto>> ObtenerTodosAsync(string? nombreFilter, string? nroDocFilter); Task<IEnumerable<DistribuidorDto>> ObtenerTodosAsync(string? nombreFilter, string? nroDocFilter, bool? soloActivos = true);
Task<DistribuidorDto?> ObtenerPorIdAsync(int id); Task<DistribuidorDto?> ObtenerPorIdAsync(int id);
Task<(DistribuidorDto? Distribuidor, string? Error)> CrearAsync(CreateDistribuidorDto createDto, int idUsuario); Task<(DistribuidorDto? Distribuidor, string? Error)> CrearAsync(CreateDistribuidorDto createDto, int idUsuario);
Task<(bool Exito, string? Error)> ActualizarAsync(int id, UpdateDistribuidorDto updateDto, int idUsuario); Task<(bool Exito, string? Error)> ActualizarAsync(int id, UpdateDistribuidorDto updateDto, int idUsuario);
Task<(bool Exito, string? Error)> EliminarAsync(int id, int idUsuario); Task<(bool Exito, string? Error)> EliminarAsync(int id, int idUsuario);
Task<IEnumerable<DistribuidorDropdownDto>> GetAllDropdownAsync(); Task<(bool Exito, string? Error)> ToggleBajaAsync(int id, bool darDeBaja, DateTime? fechaBaja, int idUsuario);
Task<IEnumerable<DistribuidorDropdownDto>> GetAllDropdownAsync(bool? soloActivos = true);
Task<DistribuidorLookupDto?> ObtenerLookupPorIdAsync(int id); Task<DistribuidorLookupDto?> ObtenerLookupPorIdAsync(int id);
Task<IEnumerable<DistribuidorHistorialDto>> ObtenerHistorialAsync( Task<IEnumerable<DistribuidorHistorialDto>> ObtenerHistorialAsync(
DateTime? fechaDesde, DateTime? fechaHasta, DateTime? fechaDesde, DateTime? fechaHasta,

View File

@@ -10,7 +10,7 @@ namespace GestionIntegral.Api.Services.Impresion
{ {
Task<IEnumerable<StockBobinaDto>> ObtenerTodosAsync( Task<IEnumerable<StockBobinaDto>> ObtenerTodosAsync(
int? idTipoBobina, string? nroBobinaFilter, int? idPlanta, int? idTipoBobina, string? nroBobinaFilter, int? idPlanta,
int? idEstadoBobina, string? remitoFilter, DateTime? fechaDesde, DateTime? fechaHasta); int? idEstadoBobina, string? remitoFilter, DateTime? fechaDesde, DateTime? fechaHasta, DateTime? fechaEstadoDesde, DateTime? fechaEstadoHasta);
Task<StockBobinaDto?> ObtenerPorIdAsync(int idBobina); Task<StockBobinaDto?> ObtenerPorIdAsync(int idBobina);
Task<(StockBobinaDto? Bobina, string? Error)> IngresarBobinaAsync(CreateStockBobinaDto createDto, int idUsuario); Task<(StockBobinaDto? Bobina, string? Error)> IngresarBobinaAsync(CreateStockBobinaDto createDto, int idUsuario);
@@ -21,5 +21,8 @@ namespace GestionIntegral.Api.Services.Impresion
DateTime? fechaDesde, DateTime? fechaHasta, DateTime? fechaDesde, DateTime? fechaHasta,
int? idUsuarioModifico, string? tipoModificacion, int? idUsuarioModifico, string? tipoModificacion,
int? idBobinaAfectada, int? idTipoBobinaFiltro, int? idPlantaFiltro, int? idEstadoBobinaFiltro); int? idBobinaAfectada, int? idTipoBobinaFiltro, int? idPlantaFiltro, int? idEstadoBobinaFiltro);
Task<IEnumerable<StockBobinaDto>> VerificarRemitoExistenteAsync(int idPlanta, string remito, DateTime? fechaRemito);
Task<(bool Exito, string? Error)> IngresarBobinaLoteAsync(CreateStockBobinaLoteDto loteDto, int idUsuario);
Task<(bool Exito, string? Error)> ActualizarFechaRemitoLoteAsync(UpdateFechaRemitoLoteDto dto, int idUsuario);
} }
} }

View File

@@ -85,9 +85,9 @@ namespace GestionIntegral.Api.Services.Impresion
public async Task<IEnumerable<StockBobinaDto>> ObtenerTodosAsync( public async Task<IEnumerable<StockBobinaDto>> ObtenerTodosAsync(
int? idTipoBobina, string? nroBobinaFilter, int? idPlanta, int? idTipoBobina, string? nroBobinaFilter, int? idPlanta,
int? idEstadoBobina, string? remitoFilter, DateTime? fechaDesde, DateTime? fechaHasta) int? idEstadoBobina, string? remitoFilter, DateTime? fechaDesde, DateTime? fechaHasta, DateTime? fechaEstadoDesde, DateTime? fechaEstadoHasta)
{ {
var bobinas = await _stockBobinaRepository.GetAllAsync(idTipoBobina, nroBobinaFilter, idPlanta, idEstadoBobina, remitoFilter, fechaDesde, fechaHasta); var bobinas = await _stockBobinaRepository.GetAllAsync(idTipoBobina, nroBobinaFilter, idPlanta, idEstadoBobina, remitoFilter, fechaDesde, fechaHasta, fechaEstadoDesde, fechaEstadoHasta);
var dtos = new List<StockBobinaDto>(); var dtos = new List<StockBobinaDto>();
foreach (var bobina in bobinas) foreach (var bobina in bobinas)
{ {
@@ -166,16 +166,16 @@ namespace GestionIntegral.Api.Services.Impresion
} }
if (await _tipoBobinaRepository.GetByIdAsync(updateDto.IdTipoBobina) == null) if (await _tipoBobinaRepository.GetByIdAsync(updateDto.IdTipoBobina) == null)
return (false, "Tipo de bobina inválido."); return (false, "Tipo de bobina inválido.");
if (await _plantaRepository.GetByIdAsync(updateDto.IdPlanta) == null) //if (await _plantaRepository.GetByIdAsync(updateDto.IdPlanta) == null)
return (false, "Planta inválida."); // return (false, "Planta inválida.");
bobinaExistente.IdTipoBobina = updateDto.IdTipoBobina; bobinaExistente.IdTipoBobina = updateDto.IdTipoBobina;
bobinaExistente.NroBobina = updateDto.NroBobina; bobinaExistente.NroBobina = updateDto.NroBobina;
bobinaExistente.Peso = updateDto.Peso; bobinaExistente.Peso = updateDto.Peso;
bobinaExistente.IdPlanta = updateDto.IdPlanta; //bobinaExistente.IdPlanta = updateDto.IdPlanta;
bobinaExistente.Remito = updateDto.Remito; //bobinaExistente.Remito = updateDto.Remito;
bobinaExistente.FechaRemito = updateDto.FechaRemito.Date; //bobinaExistente.FechaRemito = updateDto.FechaRemito.Date;
// FechaEstado se mantiene ya que el estado no cambia aquí // FechaEstado se mantiene ya que el estado no cambia aquí
var actualizado = await _stockBobinaRepository.UpdateAsync(bobinaExistente, idUsuario, transaction, "Datos Actualizados"); var actualizado = await _stockBobinaRepository.UpdateAsync(bobinaExistente, idUsuario, transaction, "Datos Actualizados");
@@ -199,13 +199,21 @@ namespace GestionIntegral.Api.Services.Impresion
using var transaction = connection.BeginTransaction(); using var transaction = connection.BeginTransaction();
try try
{ {
var bobina = await _stockBobinaRepository.GetByIdAsync(idBobina); // Obtener dentro de la transacción var bobina = await _stockBobinaRepository.GetByIdAsync(idBobina);
if (bobina == null) if (bobina == null)
{ {
try { transaction.Rollback(); } catch { } try { transaction.Rollback(); } catch { }
return (false, "Bobina no encontrada."); return (false, "Bobina no encontrada.");
} }
// Comparamos solo las fechas (sin hora) para evitar problemas de precisión.
if (cambiarEstadoDto.FechaCambioEstado.Date < bobina.FechaRemito.Date)
{
try { transaction.Rollback(); } catch { }
return (false, $"Error de integridad: La fecha del nuevo estado ({cambiarEstadoDto.FechaCambioEstado:dd/MM/yyyy}) " +
$"no puede ser anterior a la fecha de ingreso por remito ({bobina.FechaRemito:dd/MM/yyyy}).");
}
var nuevoEstado = await _estadoBobinaRepository.GetByIdAsync(cambiarEstadoDto.NuevoEstadoId); var nuevoEstado = await _estadoBobinaRepository.GetByIdAsync(cambiarEstadoDto.NuevoEstadoId);
if (nuevoEstado == null) if (nuevoEstado == null)
{ {
@@ -383,5 +391,153 @@ namespace GestionIntegral.Api.Services.Impresion
TipoMod = h.Historial.TipoMod TipoMod = h.Historial.TipoMod
}).ToList(); }).ToList();
} }
public async Task<IEnumerable<StockBobinaDto>> VerificarRemitoExistenteAsync(int idPlanta, string remito, DateTime? fechaRemito)
{
// Si la fecha tiene valor, filtramos por ese día exacto. Si no, busca en cualquier fecha.
DateTime? fechaDesde = fechaRemito?.Date;
DateTime? fechaHasta = fechaRemito?.Date;
var bobinas = await _stockBobinaRepository.GetAllAsync(null, null, idPlanta, null, remito, fechaDesde, fechaHasta, null, null);
var dtos = new List<StockBobinaDto>();
foreach (var bobina in bobinas)
{
dtos.Add(await MapToDto(bobina));
}
return dtos;
}
public async Task<(bool Exito, string? Error)> ActualizarFechaRemitoLoteAsync(UpdateFechaRemitoLoteDto dto, int idUsuario)
{
// 1. Buscar todas las bobinas que coinciden con el lote a modificar.
var bobinasAActualizar = await _stockBobinaRepository.GetAllAsync(
idTipoBobina: null,
nroBobinaFilter: null,
idPlanta: dto.IdPlanta,
idEstadoBobina: null,
remitoFilter: dto.Remito,
fechaDesde: dto.FechaRemitoActual.Date,
fechaHasta: dto.FechaRemitoActual.Date,
fechaEstadoDesde: null,
fechaEstadoHasta: null
);
if (!bobinasAActualizar.Any())
{
return (false, "No se encontraron bobinas para el remito, planta y fecha especificados. Es posible que ya hayan sido modificados.");
}
// 2. Iniciar una transacción para asegurar que todas las actualizaciones se completen o ninguna.
using var connection = _connectionFactory.CreateConnection();
if (connection is System.Data.Common.DbConnection dbConn) await dbConn.OpenAsync(); else connection.Open();
using var transaction = connection.BeginTransaction();
try
{
// 3. Iterar sobre cada bobina y actualizarla.
foreach (var bobina in bobinasAActualizar)
{
// Modificamos solo la fecha del remito.
bobina.FechaRemito = dto.NuevaFechaRemito.Date;
// Reutilizamos el método UpdateAsync que ya maneja la lógica de historial.
// Le pasamos un mensaje específico para el historial.
await _stockBobinaRepository.UpdateAsync(bobina, idUsuario, transaction, "Fecha Remito Corregida");
}
// 4. Si todo salió bien, confirmar la transacción.
transaction.Commit();
_logger.LogInformation(
"{Count} bobinas del remito {Remito} (Planta ID {IdPlanta}) actualizadas a nueva fecha {NuevaFecha} por Usuario ID {IdUsuario}.",
bobinasAActualizar.Count(), dto.Remito, dto.IdPlanta, dto.NuevaFechaRemito.Date, idUsuario
);
return (true, null);
}
catch (Exception ex)
{
// 5. Si algo falla, revertir todos los cambios.
try { transaction.Rollback(); } catch { }
_logger.LogError(ex, "Error transaccional al actualizar fecha de remito {Remito}.", dto.Remito);
return (false, $"Error interno al actualizar el lote: {ex.Message}");
}
}
public async Task<(bool Exito, string? Error)> IngresarBobinaLoteAsync(CreateStockBobinaLoteDto loteDto, int idUsuario)
{
// --- FASE 1: VALIDACIÓN PREVIA (FUERA DE LA TRANSACCIÓN) ---
// Validación de la cabecera
if (await _plantaRepository.GetByIdAsync(loteDto.IdPlanta) == null)
return (false, "La planta especificada no es válida.");
// Validación de cada bobina del lote
foreach (var bobinaDetalle in loteDto.Bobinas)
{
if (await _tipoBobinaRepository.GetByIdAsync(bobinaDetalle.IdTipoBobina) == null)
{
return (false, $"El tipo de bobina con ID {bobinaDetalle.IdTipoBobina} no es válido.");
}
// Esta es la lectura que causaba el bloqueo. Ahora se hace ANTES de la transacción.
if (await _stockBobinaRepository.GetByNroBobinaAsync(bobinaDetalle.NroBobina) != null)
{
return (false, $"El número de bobina '{bobinaDetalle.NroBobina}' ya existe en el sistema.");
}
}
// Validación de números de bobina duplicados dentro del mismo lote
var nrosBobinaEnLote = loteDto.Bobinas.Select(b => b.NroBobina.Trim()).ToList();
if (nrosBobinaEnLote.Count != nrosBobinaEnLote.Distinct().Count())
{
var duplicado = nrosBobinaEnLote.GroupBy(n => n).Where(g => g.Count() > 1).First().Key;
return (false, $"El número de bobina '{duplicado}' está duplicado en el lote que intenta ingresar.");
}
// --- FASE 2: ESCRITURA TRANSACCIONAL ---
using var connection = _connectionFactory.CreateConnection();
if (connection is System.Data.Common.DbConnection dbConn) await dbConn.OpenAsync(); else connection.Open();
using var transaction = connection.BeginTransaction();
try
{
// Ahora este bucle solo contiene operaciones de escritura. No habrá bloqueos.
foreach (var bobinaDetalle in loteDto.Bobinas)
{
var nuevaBobina = new StockBobina
{
IdTipoBobina = bobinaDetalle.IdTipoBobina,
NroBobina = bobinaDetalle.NroBobina,
Peso = bobinaDetalle.Peso,
IdPlanta = loteDto.IdPlanta,
Remito = loteDto.Remito,
FechaRemito = loteDto.FechaRemito.Date,
IdEstadoBobina = 1, // 1 = Disponible
FechaEstado = loteDto.FechaRemito.Date,
IdPublicacion = null,
IdSeccion = null,
Obs = null
};
var bobinaCreada = await _stockBobinaRepository.CreateAsync(nuevaBobina, idUsuario, transaction);
if (bobinaCreada == null)
{
throw new DataException($"No se pudo crear el registro para la bobina '{nuevaBobina.NroBobina}'.");
}
}
transaction.Commit();
_logger.LogInformation("Lote de {Count} bobinas para remito {Remito} ingresado por Usuario ID {UserId}.", loteDto.Bobinas.Count, loteDto.Remito, idUsuario);
return (true, null);
}
catch (Exception ex)
{
try { transaction.Rollback(); } catch { }
_logger.LogError(ex, "Error al ingresar lote de bobinas para remito {Remito}", loteDto.Remito);
return (false, $"Error interno al procesar el lote: {ex.Message}");
}
}
} }
} }

View File

@@ -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)> 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); 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<( Task<(
IEnumerable<BalanceCuentaDistDto> EntradasSalidas, IEnumerable<BalanceCuentaDistDto> EntradasSalidas,
IEnumerable<BalanceCuentaDebCredDto> DebitosCreditos, IEnumerable<BalanceCuentaDebCredDto> DebitosCreditos,
IEnumerable<BalanceCuentaPagosDto> Pagos, IEnumerable<BalanceCuentaPagosDto> Pagos,
IEnumerable<SaldoDto> Saldos, decimal SaldoInicial,
string? Error string? Error
)> ObtenerReporteCuentasDistribuidorAsync(int idDistribuidor, int idEmpresa, DateTime fechaDesde, DateTime fechaHasta); )> 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); Task<(IEnumerable<ListadoDistribucionDistSimpleDto> Simple, IEnumerable<ListadoDistribucionDistPromedioDiaDto> Promedios, string? Error)> ObtenerListadoDistribucionDistribuidoresAsync(int idDistribuidor, int idPublicacion, DateTime fechaDesde, DateTime fechaHasta);

View File

@@ -2,6 +2,7 @@ using GestionIntegral.Api.Data.Repositories.Distribucion;
using GestionIntegral.Api.Data.Repositories.Reportes; using GestionIntegral.Api.Data.Repositories.Reportes;
using GestionIntegral.Api.Data.Repositories.Suscripciones; using GestionIntegral.Api.Data.Repositories.Suscripciones;
using GestionIntegral.Api.Dtos.Reportes; using GestionIntegral.Api.Dtos.Reportes;
using GestionIntegral.Api.Services.Contables;
namespace GestionIntegral.Api.Services.Reportes namespace GestionIntegral.Api.Services.Reportes
{ {
@@ -14,10 +15,11 @@ namespace GestionIntegral.Api.Services.Reportes
private readonly IEmpresaRepository _empresaRepository; private readonly IEmpresaRepository _empresaRepository;
private readonly ISuscriptorRepository _suscriptorRepository; private readonly ISuscriptorRepository _suscriptorRepository;
private readonly ISuscripcionRepository _suscripcionRepository; private readonly ISuscripcionRepository _suscripcionRepository;
private readonly ICierreCuentaCorrienteService _cierreService;
private readonly ILogger<ReportesService> _logger; private readonly ILogger<ReportesService> _logger;
public ReportesService(IReportesRepository reportesRepository, IFacturaRepository facturaRepository, IFacturaDetalleRepository facturaDetalleRepository, IPublicacionRepository publicacionRepository, IEmpresaRepository empresaRepository 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; _reportesRepository = reportesRepository;
_facturaRepository = facturaRepository; _facturaRepository = facturaRepository;
@@ -26,6 +28,7 @@ namespace GestionIntegral.Api.Services.Reportes
_empresaRepository = empresaRepository; _empresaRepository = empresaRepository;
_suscriptorRepository = suscriptorRepository; _suscriptorRepository = suscriptorRepository;
_suscripcionRepository = suscripcionRepository; _suscripcionRepository = suscripcionRepository;
_cierreService = cierreService;
_logger = logger; _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<( public async Task<(
IEnumerable<BalanceCuentaDistDto> EntradasSalidas, IEnumerable<BalanceCuentaDistDto> EntradasSalidas,
IEnumerable<BalanceCuentaDebCredDto> DebitosCreditos, IEnumerable<BalanceCuentaDebCredDto> DebitosCreditos,
IEnumerable<BalanceCuentaPagosDto> Pagos, IEnumerable<BalanceCuentaPagosDto> Pagos,
IEnumerable<SaldoDto> Saldos, decimal SaldoInicial,
string? Error string? Error
)> ObtenerReporteCuentasDistribuidorAsync(int idDistribuidor, int idEmpresa, DateTime fechaDesde, DateTime fechaHasta) )> ObtenerReporteCuentasDistribuidorAsync(int idDistribuidor, int idEmpresa, DateTime fechaDesde, DateTime fechaHasta)
{ {
if (fechaDesde > 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 try
{ {
var esTask = _reportesRepository.GetBalanceCuentaDistEntradaSalidaPorEmpresaAsync(idDistribuidor, idEmpresa, fechaDesde, fechaHasta); var esTask = _reportesRepository.GetBalanceCuentaDistEntradaSalidaPorEmpresaAsync(idDistribuidor, idEmpresa, fechaDesde, fechaHasta);
var dcTask = _reportesRepository.GetBalanceCuentDistDebCredEmpresaAsync(idDistribuidor, idEmpresa, fechaDesde, fechaHasta); var dcTask = _reportesRepository.GetBalanceCuentDistDebCredEmpresaAsync(idDistribuidor, idEmpresa, fechaDesde, fechaHasta);
var paTask = _reportesRepository.GetBalanceCuentDistPagosEmpresaAsync(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 = Func<IEnumerable<BalanceCuentaDistDto>, IEnumerable<BalanceCuentaDistDto>> esToUtc =
items => items?.Select(i => { i.Fecha = DateTime.SpecifyKind(i.Fecha.Date, DateTimeKind.Utc); return i; }).ToList() 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), esToUtc(await esTask),
dcToUtc(await dcTask), dcToUtc(await dcTask),
paToUtc(await paTask), paToUtc(await paTask),
await saTask ?? Enumerable.Empty<SaldoDto>(), await siTask,
null null
); );
} }
@@ -466,7 +471,7 @@ namespace GestionIntegral.Api.Services.Reportes
Enumerable.Empty<BalanceCuentaDistDto>(), Enumerable.Empty<BalanceCuentaDistDto>(),
Enumerable.Empty<BalanceCuentaDebCredDto>(), Enumerable.Empty<BalanceCuentaDebCredDto>(),
Enumerable.Empty<BalanceCuentaPagosDto>(), Enumerable.Empty<BalanceCuentaPagosDto>(),
Enumerable.Empty<SaldoDto>(), 0m,
"Error interno al generar el reporte." "Error interno al generar el reporte."
); );
} }

View File

@@ -36,6 +36,7 @@ const AjusteSaldoModal: React.FC<AjusteSaldoModalProps> = ({
}) => { }) => {
const [montoAjuste, setMontoAjuste] = useState<string>(''); const [montoAjuste, setMontoAjuste] = useState<string>('');
const [justificacion, setJustificacion] = useState(''); const [justificacion, setJustificacion] = useState('');
const [fechaOperacion, setFechaOperacion] = useState<string>(new Date().toISOString().split('T')[0]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
@@ -43,10 +44,10 @@ const AjusteSaldoModal: React.FC<AjusteSaldoModalProps> = ({
if (open) { if (open) {
setMontoAjuste(''); setMontoAjuste('');
setJustificacion(''); setJustificacion('');
setFechaOperacion(new Date().toISOString().split('T')[0]);
setLocalErrors({}); setLocalErrors({});
clearErrorMessage();
} }
}, [open, clearErrorMessage]); }, [open]);
const validate = (): boolean => { const validate = (): boolean => {
const errors: { [key: string]: string | null } = {}; const errors: { [key: string]: string | null } = {};
@@ -65,11 +66,16 @@ const AjusteSaldoModal: React.FC<AjusteSaldoModalProps> = ({
} else if (justificacion.trim().length < 5 || justificacion.trim().length > 250) { } else if (justificacion.trim().length < 5 || justificacion.trim().length > 250) {
errors.justificacion = 'La justificación debe tener entre 5 y 250 caracteres.'; errors.justificacion = 'La justificación debe tener entre 5 y 250 caracteres.';
} }
if (!fechaOperacion) {
errors.fechaOperacion = 'La fecha de operación es obligatoria.';
}
setLocalErrors(errors); setLocalErrors(errors);
return Object.keys(errors).length === 0; return Object.keys(errors).length === 0;
}; };
const handleInputChange = (fieldName: 'montoAjuste' | 'justificacion') => { const handleInputChange = (fieldName: 'montoAjuste' | 'justificacion' | 'fechaOperacion') => {
if (localErrors[fieldName]) setLocalErrors(prev => ({ ...prev, [fieldName]: null })); if (localErrors[fieldName]) setLocalErrors(prev => ({ ...prev, [fieldName]: null }));
if (errorMessage) clearErrorMessage(); if (errorMessage) clearErrorMessage();
}; };
@@ -87,6 +93,7 @@ const AjusteSaldoModal: React.FC<AjusteSaldoModalProps> = ({
idEmpresa: saldoParaAjustar.idEmpresa, idEmpresa: saldoParaAjustar.idEmpresa,
montoAjuste: parseFloat(montoAjuste), montoAjuste: parseFloat(montoAjuste),
justificacion, justificacion,
fechaOperacion,
}; };
await onSubmit(dataToSubmit); await onSubmit(dataToSubmit);
onClose(); // Cerrar en éxito (el padre recargará) onClose(); // Cerrar en éxito (el padre recargará)
@@ -117,6 +124,19 @@ const AjusteSaldoModal: React.FC<AjusteSaldoModalProps> = ({
</Typography> </Typography>
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}> <Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}>
<TextField
label="Fecha de Operación"
type="date"
fullWidth
required
value={fechaOperacion}
onChange={(e) => { setFechaOperacion(e.target.value); handleInputChange('fechaOperacion'); }}
margin="normal"
error={!!localErrors.fechaOperacion}
helperText={localErrors.fechaOperacion || ''}
disabled={loading}
InputLabelProps={{ shrink: true }}
/>
<TextField <TextField
label="Monto de Ajuste (+/-)" label="Monto de Ajuste (+/-)"
type="number" type="number"

View File

@@ -114,9 +114,8 @@ const NotaCreditoDebitoFormModal: React.FC<NotaCreditoDebitoFormModalProps> = ({
setObservaciones(initialData?.observaciones || ''); setObservaciones(initialData?.observaciones || '');
setIdEmpresa(initialData?.idEmpresa || ''); setIdEmpresa(initialData?.idEmpresa || '');
setLocalErrors({}); setLocalErrors({});
clearErrorMessage();
} }
}, [open, initialData, clearErrorMessage, fetchEmpresas, fetchDestinatarios]); }, [open, initialData, fetchEmpresas, fetchDestinatarios]);
useEffect(() => { useEffect(() => {
if(open && !isEditing) { // Solo cambiar destinatarios si es creación y cambia el tipo de Destino if(open && !isEditing) { // Solo cambiar destinatarios si es creación y cambia el tipo de Destino

View File

@@ -0,0 +1,235 @@
import React, { useState, useEffect } from 'react';
import {
Modal, Box, Typography, TextField, Button, CircularProgress, Alert,
FormControl, InputLabel, Select, MenuItem
} from '@mui/material';
import type { CrearCierreDto } from '../../../models/dtos/Contables/CrearCierreDto';
import type { CierreCuentaCorrienteDto } from '../../../models/dtos/Contables/CierreCuentaCorrienteDto';
import type { DistribuidorDropdownDto } from '../../../models/dtos/Distribucion/DistribuidorDropdownDto';
import type { EmpresaDropdownDto } from '../../../models/dtos/Distribucion/EmpresaDropdownDto';
import distribuidorService from '../../../services/Distribucion/distribuidorService';
import empresaService from '../../../services/Distribucion/empresaService';
const modalStyle = {
position: 'absolute' as const,
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: { xs: '90%', sm: 600 },
bgcolor: 'background.paper',
border: '2px solid #000',
boxShadow: 24,
p: 4,
maxHeight: '90vh',
overflowY: 'auto'
};
interface NuevoCierreModalProps {
open: boolean;
onClose: () => void;
onSubmit: (data: CrearCierreDto) => Promise<CierreCuentaCorrienteDto>;
initialIdDistribuidor?: number | null;
initialIdEmpresa?: number | null;
errorMessage?: string | null;
clearErrorMessage: () => void;
}
const todayIso = () => new Date().toISOString().split('T')[0];
const NuevoCierreModal: React.FC<NuevoCierreModalProps> = ({
open,
onClose,
onSubmit,
initialIdDistribuidor,
initialIdEmpresa,
errorMessage,
clearErrorMessage
}) => {
const [idDistribuidor, setIdDistribuidor] = useState<number | string>('');
const [idEmpresa, setIdEmpresa] = useState<number | string>('');
const [fechaCorte, setFechaCorte] = useState<string>(todayIso());
const [justificacion, setJustificacion] = useState<string>('');
const [distribuidores, setDistribuidores] = useState<DistribuidorDropdownDto[]>([]);
const [empresas, setEmpresas] = useState<EmpresaDropdownDto[]>([]);
const [loading, setLoading] = useState(false);
const [loadingDropdowns, setLoadingDropdowns] = useState(false);
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
useEffect(() => {
const fetchDropdownData = async () => {
setLoadingDropdowns(true);
try {
const [distData, empData] = await Promise.all([
distribuidorService.getAllDistribuidoresDropdown(),
empresaService.getEmpresasDropdown()
]);
setDistribuidores(distData);
setEmpresas(empData);
} catch (err) {
console.error('Error al cargar dropdowns', err);
setLocalErrors(prev => ({ ...prev, dropdowns: 'Error al cargar distribuidores o empresas.' }));
} finally {
setLoadingDropdowns(false);
}
};
if (open) {
fetchDropdownData();
setIdDistribuidor(initialIdDistribuidor ?? '');
setIdEmpresa(initialIdEmpresa ?? '');
setFechaCorte(todayIso());
setJustificacion('');
setLocalErrors({});
clearErrorMessage();
}
}, [open, initialIdDistribuidor, initialIdEmpresa, clearErrorMessage]);
const validate = (): boolean => {
const errors: { [key: string]: string | null } = {};
if (!idDistribuidor) errors.idDistribuidor = 'Seleccione un distribuidor.';
if (!idEmpresa) errors.idEmpresa = 'Seleccione una empresa.';
if (!fechaCorte) {
errors.fechaCorte = 'La fecha de corte es obligatoria.';
} else if (new Date(fechaCorte) > new Date(todayIso())) {
errors.fechaCorte = 'La fecha de corte no puede ser futura.';
}
if (justificacion.length > 500) {
errors.justificacion = 'La justificación no puede superar los 500 caracteres.';
}
setLocalErrors(errors);
return Object.keys(errors).length === 0;
};
const handleInputChange = (fieldName: string) => {
if (localErrors[fieldName]) setLocalErrors(prev => ({ ...prev, [fieldName]: null }));
if (errorMessage) clearErrorMessage();
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
clearErrorMessage();
if (!validate()) return;
const distSeleccionado = distribuidores.find(d => d.idDistribuidor === Number(idDistribuidor));
const empSeleccionada = empresas.find(e => e.idEmpresa === Number(idEmpresa));
const confirmMsg = `Estás por cerrar el período hasta ${fechaCorte} para "${distSeleccionado?.nombre ?? ''}" en "${empSeleccionada?.nombre ?? ''}".\n\n` +
`Después de cerrar no se podrán registrar movimientos, pagos, notas ni ajustes con fecha menor o igual a esta. ¿Continuar?`;
if (!window.confirm(confirmMsg)) return;
setLoading(true);
try {
const dto: CrearCierreDto = {
idDistribuidor: Number(idDistribuidor),
idEmpresa: Number(idEmpresa),
fechaCorte,
justificacion: justificacion.trim() || null
};
await onSubmit(dto);
onClose();
} catch (err) {
console.error('Error al crear cierre:', err);
} finally {
setLoading(false);
}
};
return (
<Modal open={open} onClose={onClose}>
<Box sx={modalStyle}>
<Typography variant="h6" component="h2" gutterBottom>
Nuevo Cierre de Cuenta Corriente
</Typography>
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 1 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<FormControl fullWidth margin="dense" error={!!localErrors.idDistribuidor} required>
<InputLabel id="distribuidor-cierre-label">Distribuidor</InputLabel>
<Select
labelId="distribuidor-cierre-label"
label="Distribuidor"
value={idDistribuidor}
onChange={(e) => { setIdDistribuidor(e.target.value as number); handleInputChange('idDistribuidor'); }}
disabled={loading || loadingDropdowns}
>
<MenuItem value="" disabled><em>Seleccione</em></MenuItem>
{distribuidores.map((d) => (
<MenuItem key={d.idDistribuidor} value={d.idDistribuidor}>{d.nombre}</MenuItem>
))}
</Select>
{localErrors.idDistribuidor && <Typography color="error" variant="caption">{localErrors.idDistribuidor}</Typography>}
</FormControl>
<FormControl fullWidth margin="dense" error={!!localErrors.idEmpresa} required>
<InputLabel id="empresa-cierre-label">Empresa</InputLabel>
<Select
labelId="empresa-cierre-label"
label="Empresa"
value={idEmpresa}
onChange={(e) => { setIdEmpresa(e.target.value as number); handleInputChange('idEmpresa'); }}
disabled={loading || loadingDropdowns}
>
<MenuItem value="" disabled><em>Seleccione</em></MenuItem>
{empresas.map((e) => (
<MenuItem key={e.idEmpresa} value={e.idEmpresa}>{e.nombre}</MenuItem>
))}
</Select>
{localErrors.idEmpresa && <Typography color="error" variant="caption">{localErrors.idEmpresa}</Typography>}
</FormControl>
<TextField
label="Fecha de Corte"
type="date"
value={fechaCorte}
required
onChange={(e) => { setFechaCorte(e.target.value); handleInputChange('fechaCorte'); }}
margin="dense"
fullWidth
error={!!localErrors.fechaCorte}
helperText={localErrors.fechaCorte || 'No puede ser una fecha futura.'}
disabled={loading}
InputLabelProps={{ shrink: true }}
inputProps={{ max: todayIso() }}
/>
<Alert severity="info" sx={{ mt: 0.5 }}>
La fecha de corte es inclusive: los movimientos con fecha igual a la seleccionada se
incluyen en el cálculo del saldo de cierre.
</Alert>
<TextField
label="Justificación (opcional)"
value={justificacion}
onChange={(e) => { setJustificacion(e.target.value); handleInputChange('justificacion'); }}
margin="dense"
fullWidth
multiline
rows={3}
disabled={loading}
error={!!localErrors.justificacion}
helperText={localErrors.justificacion || `${justificacion.length}/500 caracteres`}
inputProps={{ maxLength: 500 }}
/>
</Box>
<Alert severity="warning" sx={{ mt: 2 }}>
Una vez creado el cierre, no podrán registrarse, modificarse ni eliminarse pagos, notas, ajustes
o movimientos cuya fecha de operación sea menor o igual a la fecha de corte.
</Alert>
{errorMessage && <Alert severity="error" sx={{ mt: 2, width: '100%' }}>{errorMessage}</Alert>}
{localErrors.dropdowns && <Alert severity="warning" sx={{ mt: 1 }}>{localErrors.dropdowns}</Alert>}
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<Button onClick={onClose} color="secondary" disabled={loading}>Cancelar</Button>
<Button type="submit" variant="contained" disabled={loading || loadingDropdowns}>
{loading ? <CircularProgress size={24} /> : 'Crear Cierre'}
</Button>
</Box>
</Box>
</Box>
</Modal>
);
};
export default NuevoCierreModal;

View File

@@ -95,9 +95,8 @@ const PagoDistribuidorFormModal: React.FC<PagoDistribuidorFormModalProps> = ({
setDetalle(initialData?.detalle || ''); setDetalle(initialData?.detalle || '');
setIdEmpresa(initialData?.idEmpresa || ''); setIdEmpresa(initialData?.idEmpresa || '');
setLocalErrors({}); setLocalErrors({});
clearErrorMessage();
} }
}, [open, initialData, clearErrorMessage]); }, [open, initialData]);
const validate = (): boolean => { const validate = (): boolean => {
const errors: { [key: string]: string | null } = {}; const errors: { [key: string]: string | null } = {};

View File

@@ -0,0 +1,140 @@
import React, { useState, useEffect } from 'react';
import {
Modal, Box, Typography, TextField, Button, CircularProgress, Alert
} from '@mui/material';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import type { ReabrirCierreDto } from '../../../models/dtos/Contables/ReabrirCierreDto';
import type { CierreCuentaCorrienteDto } from '../../../models/dtos/Contables/CierreCuentaCorrienteDto';
const modalStyle = {
position: 'absolute' as const,
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: { xs: '90%', sm: 560 },
bgcolor: 'background.paper',
border: '2px solid #000',
boxShadow: 24,
p: 4,
maxHeight: '90vh',
overflowY: 'auto'
};
const MIN_LEN = 10;
const MAX_LEN = 500;
interface ReabrirCierreModalProps {
open: boolean;
onClose: () => void;
onSubmit: (idCierre: number, data: ReabrirCierreDto) => Promise<CierreCuentaCorrienteDto>;
cierre: CierreCuentaCorrienteDto | null;
errorMessage?: string | null;
clearErrorMessage: () => void;
}
const ReabrirCierreModal: React.FC<ReabrirCierreModalProps> = ({
open,
onClose,
onSubmit,
cierre,
errorMessage,
clearErrorMessage
}) => {
const [justificacion, setJustificacion] = useState<string>('');
const [loading, setLoading] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
useEffect(() => {
if (open) {
setJustificacion('');
setLocalError(null);
clearErrorMessage();
}
}, [open, clearErrorMessage]);
const justifTrimLen = justificacion.trim().length;
const submitDisabled = loading || justifTrimLen < MIN_LEN || justifTrimLen > MAX_LEN;
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!cierre) return;
if (justifTrimLen < MIN_LEN) {
setLocalError(`La justificación debe tener al menos ${MIN_LEN} caracteres.`);
return;
}
if (justifTrimLen > MAX_LEN) {
setLocalError(`La justificación no puede superar ${MAX_LEN} caracteres.`);
return;
}
setLocalError(null);
clearErrorMessage();
setLoading(true);
try {
await onSubmit(cierre.idCierre, { justificacion: justificacion.trim() });
onClose();
} catch (err) {
console.error('Error al reabrir cierre:', err);
} finally {
setLoading(false);
}
};
return (
<Modal open={open} onClose={onClose}>
<Box sx={modalStyle}>
<Typography variant="h6" component="h2" gutterBottom>
Reabrir Cierre de Cuenta Corriente
</Typography>
<Alert severity="warning" icon={<WarningAmberIcon />} sx={{ mb: 2 }}>
<strong>ATENCIÓN:</strong> estás por reabrir un cierre. Esta acción quedará registrada en auditoría.
Solo se puede reabrir el último cierre vigente. Si hay cierres posteriores, primero hay que reabrirlos a ellos.
</Alert>
{cierre && (
<Box sx={{ mb: 2, p: 1.5, bgcolor: 'grey.100', borderRadius: 1 }}>
<Typography variant="body2"><strong>Distribuidor:</strong> {cierre.nombreDistribuidor}</Typography>
<Typography variant="body2"><strong>Empresa:</strong> {cierre.nombreEmpresa}</Typography>
<Typography variant="body2"><strong>Fecha de Corte:</strong> {cierre.fechaCorte}</Typography>
<Typography variant="body2">
<strong>Saldo del Cierre:</strong>{' '}
{cierre.saldoCierre.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}
</Typography>
</Box>
)}
<Box component="form" onSubmit={handleSubmit}>
<TextField
label="Justificación de la reapertura"
value={justificacion}
onChange={(e) => { setJustificacion(e.target.value); if (localError) setLocalError(null); }}
required
multiline
rows={4}
fullWidth
margin="dense"
error={!!localError}
helperText={
localError ||
`Mínimo ${MIN_LEN} caracteres — ${justifTrimLen}/${MAX_LEN}`
}
inputProps={{ maxLength: MAX_LEN }}
disabled={loading}
/>
{errorMessage && <Alert severity="error" sx={{ mt: 2 }}>{errorMessage}</Alert>}
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<Button onClick={onClose} color="secondary" disabled={loading}>Cancelar</Button>
<Button type="submit" variant="contained" color="warning" disabled={submitDisabled}>
{loading ? <CircularProgress size={24} /> : 'Confirmar Reapertura'}
</Button>
</Box>
</Box>
</Box>
</Modal>
);
};
export default ReabrirCierreModal;

View File

@@ -86,9 +86,8 @@ const EntradaSalidaDistFormModal: React.FC<EntradaSalidaDistFormModalProps> = ({
setRemito(initialData?.remito?.toString() || ''); setRemito(initialData?.remito?.toString() || '');
setObservacion(initialData?.observacion || ''); setObservacion(initialData?.observacion || '');
setLocalErrors({}); setLocalErrors({});
clearErrorMessage();
} }
}, [open, initialData, clearErrorMessage]); }, [open, initialData]);
const validate = (): boolean => { const validate = (): boolean => {
const errors: { [key: string]: string | null } = {}; const errors: { [key: string]: string | null } = {};

View File

@@ -7,7 +7,6 @@ import {
import type { StockBobinaDto } from '../../../models/dtos/Impresion/StockBobinaDto'; import type { StockBobinaDto } from '../../../models/dtos/Impresion/StockBobinaDto';
import type { CambiarEstadoBobinaDto } from '../../../models/dtos/Impresion/CambiarEstadoBobinaDto'; import type { CambiarEstadoBobinaDto } from '../../../models/dtos/Impresion/CambiarEstadoBobinaDto';
import type { EstadoBobinaDto } from '../../../models/dtos/Impresion/EstadoBobinaDto'; import type { EstadoBobinaDto } from '../../../models/dtos/Impresion/EstadoBobinaDto';
// --- CAMBIO: Importar PublicacionDropdownDto ---
import type { PublicacionDropdownDto } from '../../../models/dtos/Distribucion/PublicacionDropdownDto'; import type { PublicacionDropdownDto } from '../../../models/dtos/Distribucion/PublicacionDropdownDto';
import type { PubliSeccionDto } from '../../../models/dtos/Distribucion/PubliSeccionDto'; import type { PubliSeccionDto } from '../../../models/dtos/Distribucion/PubliSeccionDto';
import estadoBobinaService from '../../../services/Impresion/estadoBobinaService'; import estadoBobinaService from '../../../services/Impresion/estadoBobinaService';
@@ -56,7 +55,6 @@ const StockBobinaCambioEstadoModal: React.FC<StockBobinaCambioEstadoModalProps>
const [fechaCambioEstado, setFechaCambioEstado] = useState(''); const [fechaCambioEstado, setFechaCambioEstado] = useState('');
const [estadosDisponibles, setEstadosDisponibles] = useState<EstadoBobinaDto[]>([]); const [estadosDisponibles, setEstadosDisponibles] = useState<EstadoBobinaDto[]>([]);
// --- CAMBIO: Usar PublicacionDropdownDto para el estado ---
const [publicacionesDisponibles, setPublicacionesDisponibles] = useState<PublicacionDropdownDto[]>([]); const [publicacionesDisponibles, setPublicacionesDisponibles] = useState<PublicacionDropdownDto[]>([]);
const [seccionesDisponibles, setSeccionesDisponibles] = useState<PubliSeccionDto[]>([]); const [seccionesDisponibles, setSeccionesDisponibles] = useState<PubliSeccionDto[]>([]);
@@ -77,7 +75,6 @@ const StockBobinaCambioEstadoModal: React.FC<StockBobinaCambioEstadoModalProps>
} else if (bobinaActual.idEstadoBobina === ID_ESTADO_EN_USO) { } else if (bobinaActual.idEstadoBobina === ID_ESTADO_EN_USO) {
estadosFiltrados = todosLosEstados.filter(e => e.idEstadoBobina === ID_ESTADO_DANADA); estadosFiltrados = todosLosEstados.filter(e => e.idEstadoBobina === ID_ESTADO_DANADA);
} else if (bobinaActual.idEstadoBobina === ID_ESTADO_DISPONIBLE) { } else if (bobinaActual.idEstadoBobina === ID_ESTADO_DISPONIBLE) {
// --- CAMBIO: Usar ID_ESTADO_EN_USO ---
estadosFiltrados = todosLosEstados.filter( estadosFiltrados = todosLosEstados.filter(
e => e.idEstadoBobina === ID_ESTADO_EN_USO || e.idEstadoBobina === ID_ESTADO_DANADA e => e.idEstadoBobina === ID_ESTADO_EN_USO || e.idEstadoBobina === ID_ESTADO_DANADA
); );
@@ -90,7 +87,6 @@ const StockBobinaCambioEstadoModal: React.FC<StockBobinaCambioEstadoModalProps>
const sePuedePonerEnUso = estadosFiltrados.some(e => e.idEstadoBobina === ID_ESTADO_EN_USO); const sePuedePonerEnUso = estadosFiltrados.some(e => e.idEstadoBobina === ID_ESTADO_EN_USO);
if (sePuedePonerEnUso) { if (sePuedePonerEnUso) {
// --- CAMBIO: La data es PublicacionDropdownDto[] ---
const publicacionesData: PublicacionDropdownDto[] = await publicacionService.getPublicacionesForDropdown(true); const publicacionesData: PublicacionDropdownDto[] = await publicacionService.getPublicacionesForDropdown(true);
setPublicacionesDisponibles(publicacionesData); setPublicacionesDisponibles(publicacionesData);
} else { } else {
@@ -170,8 +166,16 @@ const StockBobinaCambioEstadoModal: React.FC<StockBobinaCambioEstadoModalProps>
const validate = (): boolean => { const validate = (): boolean => {
const errors: { [key: string]: string | null } = {}; const errors: { [key: string]: string | null } = {};
if (!nuevoEstadoId) errors.nuevoEstadoId = 'Seleccione un nuevo estado.'; if (!nuevoEstadoId) errors.nuevoEstadoId = 'Seleccione un nuevo estado.';
if (!fechaCambioEstado.trim()) errors.fechaCambioEstado = 'La fecha es obligatoria.'; if (!fechaCambioEstado.trim()) {
else if (!/^\d{4}-\d{2}-\d{2}$/.test(fechaCambioEstado)) errors.fechaCambioEstado = 'Formato de fecha inválido.'; errors.fechaCambioEstado = 'La fecha es obligatoria.';
} else if (!/^\d{4}-\d{2}-\d{2}$/.test(fechaCambioEstado)) {
errors.fechaCambioEstado = 'Formato de fecha inválido.';
} else if (bobinaActual) {
const fechaRemitoSimple = bobinaActual.fechaRemito.split('T')[0];
if (fechaCambioEstado < fechaRemitoSimple) {
errors.fechaCambioEstado = `La fecha no puede ser anterior al ingreso (${fechaRemitoSimple}).`;
}
}
if (Number(nuevoEstadoId) === ID_ESTADO_EN_USO) { if (Number(nuevoEstadoId) === ID_ESTADO_EN_USO) {
if (!idPublicacion) errors.idPublicacion = 'Seleccione una publicación.'; if (!idPublicacion) errors.idPublicacion = 'Seleccione una publicación.';
@@ -274,10 +278,21 @@ const StockBobinaCambioEstadoModal: React.FC<StockBobinaCambioEstadoModalProps>
</> </>
)} )}
<TextField label="Fecha Cambio de Estado" type="date" value={fechaCambioEstado} required <TextField
label="Fecha Cambio de Estado"
type="date"
value={fechaCambioEstado}
required
onChange={(e) => { setFechaCambioEstado(e.target.value); handleInputChange('fechaCambioEstado'); }} onChange={(e) => { setFechaCambioEstado(e.target.value); handleInputChange('fechaCambioEstado'); }}
margin="dense" fullWidth error={!!localErrors.fechaCambioEstado} helperText={localErrors.fechaCambioEstado || ''} margin="dense"
disabled={loading} InputLabelProps={{ shrink: true }} fullWidth
error={!!localErrors.fechaCambioEstado}
helperText={localErrors.fechaCambioEstado || ''}
disabled={loading}
InputLabelProps={{ shrink: true }}
inputProps={{
min: bobinaActual?.fechaRemito.split('T')[0]
}}
/> />
<TextField label="Observaciones (Opcional)" value={obs} <TextField label="Observaciones (Opcional)" value={obs}
onChange={(e) => setObs(e.target.value)} onChange={(e) => setObs(e.target.value)}

View File

@@ -0,0 +1,89 @@
import React, { useState, useEffect } from 'react';
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert } from '@mui/material';
import type { StockBobinaDto } from '../../../models/dtos/Impresion/StockBobinaDto';
import type { UpdateFechaRemitoLoteDto } from '../../../models/dtos/Impresion/UpdateFechaRemitoLoteDto';
const modalStyle = {
position: 'absolute' as 'absolute',
top: '50%', left: '50%',
transform: 'translate(-50%, -50%)',
width: { xs: '90%', sm: 450 },
bgcolor: 'background.paper',
border: '2px solid #000', boxShadow: 24, p: 4
};
interface Props {
open: boolean;
onClose: () => void;
onSubmit: (data: UpdateFechaRemitoLoteDto) => Promise<void>;
bobinaContexto: StockBobinaDto | null;
errorMessage: string | null;
clearErrorMessage: () => void;
}
const StockBobinaFechaRemitoModal: React.FC<Props> = ({ open, onClose, onSubmit, bobinaContexto, errorMessage, clearErrorMessage }) => {
const [nuevaFecha, setNuevaFecha] = useState('');
const [loading, setLoading] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
useEffect(() => {
if (open && bobinaContexto) {
setNuevaFecha(bobinaContexto.fechaRemito.split('T')[0]); // Iniciar con la fecha actual
setLocalError(null);
clearErrorMessage();
}
}, [open, bobinaContexto, clearErrorMessage]);
if (!bobinaContexto) return null;
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!nuevaFecha) {
setLocalError('Debe seleccionar una nueva fecha.');
return;
}
setLoading(true);
try {
const data: UpdateFechaRemitoLoteDto = {
idPlanta: bobinaContexto.idPlanta,
remito: bobinaContexto.remito,
fechaRemitoActual: bobinaContexto.fechaRemito.split('T')[0],
nuevaFechaRemito: nuevaFecha
};
await onSubmit(data);
onClose();
} catch (err) {
// El error de la API es manejado por el prop `errorMessage`
} finally {
setLoading(false);
}
};
return (
<Modal open={open} onClose={onClose}>
<Box sx={modalStyle}>
<Typography variant="h6" component="h2">Corregir Fecha de Remito</Typography>
<Typography variant="body2" sx={{ mt: 1 }}>
Esto cambiará la fecha para <strong>todas</strong> las bobinas del remito <strong>{bobinaContexto.remito}</strong> en la planta <strong>{bobinaContexto.nombrePlanta}</strong>.
</Typography>
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}>
<TextField label="Fecha Actual" value={new Date(bobinaContexto.fechaRemito).toLocaleDateString('es-AR', { timeZone: 'UTC' })} disabled fullWidth margin="normal" />
<TextField label="Nueva Fecha de Remito" type="date" value={nuevaFecha}
onChange={e => { setNuevaFecha(e.target.value); setLocalError(null); }}
required fullWidth margin="normal" InputLabelProps={{ shrink: true }}
error={!!localError} helperText={localError} autoFocus
/>
{errorMessage && <Alert severity="error" sx={{ mt: 2 }}>{errorMessage}</Alert>}
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<Button onClick={onClose} color="secondary" disabled={loading}>Cancelar</Button>
<Button type="submit" variant="contained" disabled={loading}>
{loading ? <CircularProgress size={24} /> : 'Guardar Nueva Fecha'}
</Button>
</Box>
</Box>
</Box>
</Modal>
);
};
export default StockBobinaFechaRemitoModal;

View File

@@ -0,0 +1,374 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import {
Modal, Box, Typography, TextField, Button, CircularProgress, Alert,
FormControl, InputLabel, Select, MenuItem, Stepper, Step, StepLabel,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, IconButton, Divider,
InputAdornment, Tooltip
} from '@mui/material';
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import EditIcon from '@mui/icons-material/Edit';
import CloseIcon from '@mui/icons-material/Close';
import type { StockBobinaDto } from '../../../models/dtos/Impresion/StockBobinaDto';
import type { CreateStockBobinaLoteDto } from '../../../models/dtos/Impresion/CreateStockBobinaLoteDto';
import type { BobinaLoteDetalleDto } from '../../../models/dtos/Impresion/BobinaLoteDetalleDto';
import type { PlantaDto } from '../../../models/dtos/Impresion/PlantaDto';
import type { TipoBobinaDto } from '../../../models/dtos/Impresion/TipoBobinaDto';
import stockBobinaService from '../../../services/Impresion/stockBobinaService';
import plantaService from '../../../services/Impresion/plantaService';
import tipoBobinaService from '../../../services/Impresion/tipoBobinaService';
const modalStyle = {
position: 'absolute' as 'absolute',
top: '50%', left: '50%',
transform: 'translate(-50%, -50%)',
width: { xs: '95%', sm: '80%', md: '900px' },
bgcolor: 'background.paper',
border: '2px solid #000', boxShadow: 24, p: 3,
maxHeight: '90vh', overflowY: 'auto', display: 'flex', flexDirection: 'column'
};
interface NuevaBobinaState extends BobinaLoteDetalleDto {
idTemporal: string;
}
interface StockBobinaLoteFormModalProps {
open: boolean;
onClose: (refrescar: boolean) => void;
}
const steps = ['Datos del Remito', 'Ingreso de Bobinas'];
const StockBobinaLoteFormModal: React.FC<StockBobinaLoteFormModalProps> = ({ open, onClose }) => {
const [activeStep, setActiveStep] = useState(0);
const [loading, setLoading] = useState(false);
const [apiError, setApiError] = useState<string | null>(null);
// Step 1 State
const [idPlanta, setIdPlanta] = useState<number | ''>('');
const [remito, setRemito] = useState('');
const [fechaRemito, setFechaRemito] = useState(new Date().toISOString().split('T')[0]);
const [headerErrors, setHeaderErrors] = useState<{ [key: string]: string }>({});
const [isVerifying, setIsVerifying] = useState(false);
const [remitoStatusMessage, setRemitoStatusMessage] = useState<string | null>(null);
const [remitoStatusSeverity, setRemitoStatusSeverity] = useState<'success' | 'info'>('info');
const [isDateAutocompleted, setIsDateAutocompleted] = useState(false);
// Step 2 State
const [bobinasExistentes, setBobinasExistentes] = useState<StockBobinaDto[]>([]);
const [nuevasBobinas, setNuevasBobinas] = useState<NuevaBobinaState[]>([]);
const [detalleErrors, setDetalleErrors] = useState<{ [key: string]: string }>({});
// Dropdowns data
const [plantas, setPlantas] = useState<PlantaDto[]>([]);
const [tiposBobina, setTiposBobina] = useState<TipoBobinaDto[]>([]);
const [loadingDropdowns, setLoadingDropdowns] = useState(true);
const tableContainerRef = useRef<HTMLDivElement>(null);
const resetState = useCallback(() => {
setActiveStep(0); setLoading(false); setApiError(null);
setIdPlanta(''); setRemito(''); setFechaRemito(new Date().toISOString().split('T')[0]);
setHeaderErrors({}); setBobinasExistentes([]); setNuevasBobinas([]); setDetalleErrors({});
setRemitoStatusMessage(null);
setIsDateAutocompleted(false);
}, []);
useEffect(() => {
const fetchDropdowns = async () => {
setLoadingDropdowns(true);
try {
const [plantasData, tiposData] = await Promise.all([
plantaService.getAllPlantas(),
tipoBobinaService.getAllTiposBobina()
]);
setPlantas(plantasData);
setTiposBobina(tiposData);
} catch (error) {
setApiError("Error al cargar datos necesarios (plantas, tipos).");
} finally {
setLoadingDropdowns(false);
}
};
if (open) {
fetchDropdowns();
} else {
resetState();
}
}, [open, resetState]);
useEffect(() => {
const verificarRemitoParaAutocompletar = async () => {
setRemitoStatusMessage(null);
if (remito.trim() && idPlanta) {
setIsVerifying(true);
try {
const existentes = await stockBobinaService.verificarRemitoExistente(Number(idPlanta), remito.trim());
if (existentes.length > 0) {
setFechaRemito(existentes[0].fechaRemito.split('T')[0]);
setRemitoStatusMessage("Remito existente. Se autocompletó la fecha.");
setRemitoStatusSeverity('info');
setIsDateAutocompleted(true);
} else {
setRemitoStatusMessage("Este es un remito nuevo.");
setRemitoStatusSeverity('success');
setIsDateAutocompleted(false);
}
} catch (error) {
console.error("Fallo la verificación automática de remito: ", error);
setRemitoStatusMessage("No se pudo verificar el remito.");
setRemitoStatusSeverity('info');
} finally {
setIsVerifying(false);
}
}
};
const handler = setTimeout(() => {
verificarRemitoParaAutocompletar();
}, 500);
return () => {
clearTimeout(handler);
};
}, [idPlanta, remito]);
const handleClose = () => onClose(false);
const handleNext = async () => {
const errors: { [key: string]: string } = {};
if (!remito.trim()) errors.remito = "El número de remito es obligatorio.";
if (!idPlanta) errors.idPlanta = "Seleccione una planta.";
if (!fechaRemito) errors.fechaRemito = "La fecha es obligatoria.";
if (Object.keys(errors).length > 0) {
setHeaderErrors(errors);
return;
}
setLoading(true); setApiError(null);
try {
const existentes = await stockBobinaService.verificarRemitoExistente(Number(idPlanta), remito, fechaRemito);
setBobinasExistentes(existentes);
setActiveStep(1);
} catch (error: any) {
const message = error.response?.data?.message || "Error al verificar el remito.";
setApiError(message);
} finally {
setLoading(false);
}
};
const handleBack = () => setActiveStep(0);
const handleAddBobina = () => {
setNuevasBobinas(prev => [...prev, {
idTemporal: crypto.randomUUID(), idTipoBobina: 0, nroBobina: '', peso: 0
}]);
setTimeout(() => {
tableContainerRef.current?.scrollTo({ top: tableContainerRef.current.scrollHeight, behavior: 'smooth' });
}, 100);
};
const handleRemoveBobina = (idTemporal: string) => {
setNuevasBobinas(prev => prev.filter(b => b.idTemporal !== idTemporal));
};
const handleBobinaChange = (idTemporal: string, field: keyof NuevaBobinaState, value: any) => {
setNuevasBobinas(prev => prev.map(b => b.idTemporal === idTemporal ? { ...b, [field]: value } : b));
};
const handleSubmit = async () => {
const errors: { [key: string]: string } = {};
if (nuevasBobinas.length === 0) {
setApiError("Debe agregar al menos una nueva bobina para guardar.");
return;
}
const todosNrosBobina = new Set(bobinasExistentes.map(b => b.nroBobina));
nuevasBobinas.forEach(b => {
if (!b.idTipoBobina) errors[b.idTemporal + '_tipo'] = "Requerido";
if (!b.nroBobina.trim()) errors[b.idTemporal + '_nro'] = "Requerido";
if ((b.peso || 0) <= 0) errors[b.idTemporal + '_peso'] = "Inválido";
if (todosNrosBobina.has(b.nroBobina.trim())) errors[b.idTemporal + '_nro'] = "Duplicado";
todosNrosBobina.add(b.nroBobina.trim());
});
if (Object.keys(errors).length > 0) {
setDetalleErrors(errors);
return;
}
setLoading(true); setApiError(null);
try {
const lote: CreateStockBobinaLoteDto = {
idPlanta: Number(idPlanta),
remito: remito.trim(),
fechaRemito,
bobinas: nuevasBobinas.map(({ idTipoBobina, nroBobina, peso }) => ({
idTipoBobina: Number(idTipoBobina), nroBobina: nroBobina.trim(), peso: Number(peso)
}))
};
await stockBobinaService.ingresarLoteBobinas(lote);
onClose(true);
} catch (error: any) {
const message = error.response?.data?.message || "Error al guardar el lote de bobinas.";
setApiError(message);
} finally {
setLoading(false);
}
};
const renderStepContent = (step: number) => {
switch (step) {
case 0:
return (
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="h6">Datos de Cabecera</Typography>
<TextField label="Número de Remito" value={remito}
onChange={e => {
setRemito(e.target.value);
setIsDateAutocompleted(false);
}}
required error={!!headerErrors.remito} helperText={headerErrors.remito} disabled={loading} autoFocus
/>
<FormControl fullWidth error={!!headerErrors.idPlanta}>
<InputLabel id="planta-label" required>Planta de Destino</InputLabel>
<Select labelId="planta-label" value={idPlanta} label="Planta de Destino"
onChange={e => {
setIdPlanta(e.target.value as number);
setIsDateAutocompleted(false);
}}
disabled={loading || loadingDropdowns}
endAdornment={isVerifying && (<InputAdornment position="end" sx={{ mr: 2 }}><CircularProgress size={20} /></InputAdornment>)}
>
{plantas.map(p => <MenuItem key={p.idPlanta} value={p.idPlanta}>{p.nombre}</MenuItem>)}
</Select>
{headerErrors.idPlanta && <Typography color="error" variant="caption">{headerErrors.idPlanta}</Typography>}
</FormControl>
<TextField
label="Fecha de Remito"
type="date"
value={fechaRemito}
onChange={e => setFechaRemito(e.target.value)}
InputLabelProps={{ shrink: true }}
required
error={!!headerErrors.fechaRemito}
helperText={headerErrors.fechaRemito}
disabled={loading || isDateAutocompleted}
InputProps={{
endAdornment: (
isDateAutocompleted && (
<InputAdornment position="end">
<Tooltip title="Editar fecha">
<IconButton onClick={() => setIsDateAutocompleted(false)} edge="end">
<EditIcon />
</IconButton>
</Tooltip>
</InputAdornment>
)
)
}}
/>
<Box sx={{ minHeight: 48, mt: 1 }}>
{remitoStatusMessage && !isVerifying && (
<Alert severity={remitoStatusSeverity} icon={false} variant="outlined">
{remitoStatusMessage}
</Alert>
)}
</Box>
</Box>
);
case 1:
return (
<Box sx={{ p: 2, flexGrow: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{bobinasExistentes.length > 0 && (
<>
<Typography variant="subtitle1" gutterBottom>Bobinas ya ingresadas para este remito:</Typography>
<TableContainer component={Paper} variant="outlined" sx={{ maxHeight: '150px', mb: 2 }}>
<Table size="small" stickyHeader>
<TableHead><TableRow><TableCell>Nro. Bobina</TableCell><TableCell>Tipo</TableCell><TableCell align="right">Peso (Kg)</TableCell></TableRow></TableHead>
<TableBody>
{bobinasExistentes.map(b => (
<TableRow key={b.idBobina}><TableCell>{b.nroBobina}</TableCell><TableCell>{b.nombreTipoBobina}</TableCell><TableCell align="right">{b.peso}</TableCell></TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</>
)}
<Typography variant="h6">Nuevas Bobinas a Ingresar</Typography>
<TableContainer component={Paper} variant="outlined" sx={{ flexGrow: 1, my: 1, minHeight: '150px' }} ref={tableContainerRef}>
<Table size="small" stickyHeader>
<TableHead><TableRow><TableCell sx={{ minWidth: 200 }}>Tipo Bobina</TableCell><TableCell>Nro. Bobina</TableCell><TableCell>Peso (Kg)</TableCell><TableCell></TableCell></TableRow></TableHead>
<TableBody>
{nuevasBobinas.map(bobina => (
<TableRow key={bobina.idTemporal}>
<TableCell><FormControl fullWidth size="small" error={!!detalleErrors[bobina.idTemporal + '_tipo']}><Select value={bobina.idTipoBobina} onChange={e => handleBobinaChange(bobina.idTemporal, 'idTipoBobina', e.target.value)} disabled={loadingDropdowns}><MenuItem value={0} disabled>Seleccione</MenuItem>{tiposBobina.map(t => <MenuItem key={t.idTipoBobina} value={t.idTipoBobina}>{t.denominacion}</MenuItem>)}</Select></FormControl></TableCell>
<TableCell><TextField fullWidth size="small" value={bobina.nroBobina} onChange={e => handleBobinaChange(bobina.idTemporal, 'nroBobina', e.target.value)} error={!!detalleErrors[bobina.idTemporal + '_nro']} helperText={detalleErrors[bobina.idTemporal + '_nro']} /></TableCell>
<TableCell><TextField fullWidth size="small" type="number" value={bobina.peso || ''} onChange={e => handleBobinaChange(bobina.idTemporal, 'peso', e.target.value)} error={!!detalleErrors[bobina.idTemporal + '_peso']} helperText={detalleErrors[bobina.idTemporal + '_peso']} /></TableCell>
<TableCell><IconButton size="small" color="error" onClick={() => handleRemoveBobina(bobina.idTemporal)}><DeleteOutlineIcon /></IconButton></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Button startIcon={<AddCircleOutlineIcon />} onClick={handleAddBobina} sx={{ mt: 1 }}>Agregar Bobina</Button>
</Box>
);
default:
return null;
}
};
return (
<Modal open={open} onClose={handleClose}>
<Box sx={modalStyle}>
<IconButton
aria-label="close"
onClick={handleClose}
sx={{
position: 'absolute',
right: 8,
top: 8,
color: (theme) => theme.palette.grey[500],
}}
>
<CloseIcon />
</IconButton>
<Typography variant="h5" component="h2" gutterBottom>Ingreso de Bobinas por Lote</Typography>
<Stepper activeStep={activeStep} sx={{ mb: 2 }}>
{steps.map((label) => (
<Step key={label}>
<StepLabel>{label}</StepLabel>
</Step>
))}
</Stepper>
<Box sx={{ flexGrow: 1, overflowY: 'auto' }}>
{loadingDropdowns && activeStep === 0 ? <Box sx={{ display: 'flex', justifyContent: 'center', my: 5 }}><CircularProgress /></Box> : renderStepContent(activeStep)}
</Box>
{apiError && <Alert severity="error" sx={{ mt: 2, flexShrink: 0 }}>{apiError}</Alert>}
<Divider sx={{ my: 2, flexShrink: 0 }} />
<Box sx={{ display: 'flex', justifyContent: 'space-between', pt: 1, flexShrink: 0 }}>
<Button color="inherit" disabled={activeStep === 0 || loading} onClick={handleBack}>
Atrás
</Button>
<Box>
{activeStep === 0 && <Button onClick={handleNext} variant="contained" disabled={loading || loadingDropdowns}>{loading ? <CircularProgress size={24} /> : 'Verificar y Continuar'}</Button>}
{activeStep === 1 && <Button onClick={handleSubmit} variant="contained" color="success" disabled={loading}>{loading ? <CircularProgress size={24} /> : 'Guardar Lote'}</Button>}
</Box>
</Box>
</Box>
</Modal>
);
};
export default StockBobinaLoteFormModal;

View File

@@ -41,7 +41,8 @@ const getModuloConceptualDelPermiso = (permisoModulo: string): string => {
} }
if (moduloLower.includes("cuentas pagos") || if (moduloLower.includes("cuentas pagos") ||
moduloLower.includes("cuentas notas") || moduloLower.includes("cuentas notas") ||
moduloLower.includes("cuentas tipos pagos")) { moduloLower.includes("cuentas tipos pagos") ||
moduloLower.includes("cuentas cierres")) {
return "Contables"; return "Contables";
} }
if (moduloLower.includes("impresión tiradas") || if (moduloLower.includes("impresión tiradas") ||

View File

@@ -0,0 +1,24 @@
// Refleja DTO C# Dtos.Auditoria.CierreCuentaCorrienteHistorialDto.
// La tabla _H y este DTO usan PascalCase con underscores; el JSON serializer
// default de ASP.NET Core sólo aplica camelCase a la primera letra,
// preservando los underscores subsiguientes.
export interface CierreCuentaCorrienteHistorialDto {
id_Historial: number;
id_Cierre: number;
id_Distribuidor: number;
id_Empresa: number;
fechaCorte: string; // ISO datetime
fechaCierre: string;
saldoCierre: number;
estado: 'Activo' | 'Anulado';
justificacion?: string | null;
id_Usuario_Cierre: number;
id_Usuario_Anula?: number | null;
fechaAnulacion?: string | null;
justificacion_Anulacion?: string | null;
tipoMod: 'Creacion' | 'Reapertura' | 'Modificacion';
id_Usuario_Mod: number;
nombreUsuarioModifico: string;
fechaMod: string; // ISO datetime
}

View File

@@ -4,4 +4,7 @@ export interface AjusteSaldoRequestDto {
idEmpresa: number; idEmpresa: number;
montoAjuste: number; montoAjuste: number;
justificacion: string; justificacion: string;
// Fecha lógica de la operación contable (no la del ajuste físico).
// Se valida contra períodos cerrados del distribuidor+empresa.
fechaOperacion: string; // "yyyy-MM-dd"
} }

View File

@@ -0,0 +1,19 @@
export interface CierreCuentaCorrienteDto {
idCierre: number;
idDistribuidor: number;
nombreDistribuidor: string;
idEmpresa: number;
nombreEmpresa: string;
fechaCorte: string; // "yyyy-MM-dd"
fechaCierre: string; // ISO datetime
saldoCierre: number;
estado: 'Activo' | 'Anulado';
justificacion?: string | null;
idUsuarioCierre: number;
nombreUsuarioCierre: string;
idUsuarioAnula?: number | null;
nombreUsuarioAnula?: string | null;
fechaAnulacion?: string | null; // ISO datetime
justificacionAnulacion?: string | null;
esUltimoVigente: boolean;
}

View File

@@ -0,0 +1,6 @@
export interface CrearCierreDto {
idDistribuidor: number;
idEmpresa: number;
fechaCorte: string; // "yyyy-MM-dd"
justificacion?: string | null;
}

View File

@@ -0,0 +1,4 @@
export interface ReabrirCierreDto {
// Min 10, max 500 caracteres (validado en backend con DataAnnotations).
justificacion: string;
}

View File

@@ -0,0 +1,6 @@
export interface UltimoCierreDto {
idCierre: number;
fechaCorte: string; // "yyyy-MM-dd"
saldoCierre: number;
estado: 'Activo' | 'Anulado';
}

View File

@@ -12,4 +12,6 @@ export interface DistribuidorDto {
telefono?: string | null; telefono?: string | null;
email?: string | null; email?: string | null;
localidad?: string | null; localidad?: string | null;
baja?: boolean;
fechaBaja?: string | null;
} }

View File

@@ -0,0 +1,5 @@
export interface BobinaLoteDetalleDto {
idTipoBobina: number;
nroBobina: string;
peso: number;
}

View File

@@ -0,0 +1,8 @@
import type { BobinaLoteDetalleDto } from './BobinaLoteDetalleDto';
export interface CreateStockBobinaLoteDto {
idPlanta: number;
remito: string;
fechaRemito: string; // "yyyy-MM-dd"
bobinas: BobinaLoteDetalleDto[];
}

View File

@@ -0,0 +1,6 @@
export interface UpdateFechaRemitoLoteDto {
idPlanta: number;
remito: string;
fechaRemitoActual: string; // "yyyy-MM-dd"
nuevaFechaRemito: string; // "yyyy-MM-dd"
}

View File

@@ -1,14 +1,14 @@
import type { BalanceCuentaDebCredDto } from "./BalanceCuentaDebCredDto"; import type { BalanceCuentaDebCredDto } from "./BalanceCuentaDebCredDto";
import type { BalanceCuentaDistDto } from "./BalanceCuentaDistDto"; import type { BalanceCuentaDistDto } from "./BalanceCuentaDistDto";
import type { BalanceCuentaPagosDto } from "./BalanceCuentaPagosDto"; import type { BalanceCuentaPagosDto } from "./BalanceCuentaPagosDto";
import type { SaldoDto } from "./SaldoDto";
export interface ReporteCuentasDistribuidorResponseDto { export interface ReporteCuentasDistribuidorResponseDto {
entradasSalidas: BalanceCuentaDistDto[]; entradasSalidas: BalanceCuentaDistDto[];
debitosCreditos: BalanceCuentaDebCredDto[]; debitosCreditos: BalanceCuentaDebCredDto[];
pagos: BalanceCuentaPagosDto[]; pagos: BalanceCuentaPagosDto[];
saldos: SaldoDto[]; // Aunque SP_BalanceCuentSaldos devuelve una lista, para un distribuidor/empresa debería ser 1 solo. // Saldo inicial del período: snapshot del último cierre + movimientos netos hasta fechaDesde-1.
// Se podría ajustar el DTO o el servicio para devolver solo el primer saldo. // 0 si no hay cierre previo.
nombreDistribuidor?: string; // Para el título del reporte saldoInicial: number;
nombreEmpresa?: string; // Para el título del reporte nombreDistribuidor?: string;
nombreEmpresa?: string;
} }

View File

@@ -1,13 +1,12 @@
import type { BalanceCuentaDebCredDto } from "./BalanceCuentaDebCredDto"; import type { BalanceCuentaDebCredDto } from "./BalanceCuentaDebCredDto";
import type { BalanceCuentaDistDto } from "./BalanceCuentaDistDto"; import type { BalanceCuentaDistDto } from "./BalanceCuentaDistDto";
import type { BalanceCuentaPagosDto } from "./BalanceCuentaPagosDto"; import type { BalanceCuentaPagosDto } from "./BalanceCuentaPagosDto";
import type { SaldoDto } from "./SaldoDto";
export interface ReporteCuentasDistribuidorResponseDto { export interface ReporteCuentasDistribuidorResponseDto {
entradasSalidas: BalanceCuentaDistDto[]; entradasSalidas: BalanceCuentaDistDto[];
debitosCreditos: BalanceCuentaDebCredDto[]; debitosCreditos: BalanceCuentaDebCredDto[];
pagos: BalanceCuentaPagosDto[]; pagos: BalanceCuentaPagosDto[];
saldos: SaldoDto[]; saldoInicial: number;
nombreDistribuidor?: string; nombreDistribuidor?: string;
nombreEmpresa?: string; nombreEmpresa?: string;
} }

View File

@@ -1,3 +0,0 @@
export interface SaldoDto {
monto: number;
}

View File

@@ -62,6 +62,7 @@ const TIPOS_ENTIDAD_AUDITABLES = [
{ value: "PermisoMaestro", label: "Permisos (Definición) (gral_Permisos_H)" }, { value: "PermisoMaestro", label: "Permisos (Definición) (gral_Permisos_H)" },
{ value: "PermisosPerfiles", label: "Asignación de Permisos a Perfiles (gral_PermisosPerfiles_H)" }, { value: "PermisosPerfiles", label: "Asignación de Permisos a Perfiles (gral_PermisosPerfiles_H)" },
{ value: "CambioParada", label: "Cambios de Parada (dist_CambiosParadasCanillas_H)" }, { value: "CambioParada", label: "Cambios de Parada (dist_CambiosParadasCanillas_H)" },
{ value: "CierreCC", label: "Cierres de Cuenta Corriente (cue_CierresCuentaCorriente_H)" },
].sort((a, b) => a.label.localeCompare(b.label)); ].sort((a, b) => a.label.localeCompare(b.label));
const TIPOS_MODIFICACION = [ const TIPOS_MODIFICACION = [
@@ -653,6 +654,29 @@ const AuditoriaGeneralPage: React.FC = () => {
{ field: 'vigenciaH', headerName: 'Vig. Hasta', width: 120, type: 'date', valueFormatter: (value) => formatDate(value as string) }, { field: 'vigenciaH', headerName: 'Vig. Hasta', width: 120, type: 'date', valueFormatter: (value) => formatDate(value as string) },
]; ];
break; break;
case "CierreCC":
const cierreCcHist = await auditoriaService.getHistorialCierresCC({
...commonParams,
// "ID Entidad Afectada" filtra por Id_Cierre puntual.
idCierreAfectado: filtroIdEntidad ? Number(filtroIdEntidad) : undefined
});
rawData = cierreCcHist;
cols = [
...getCommonAuditColumns(),
{ field: 'id_Cierre', headerName: 'ID Cierre', width: 100, align: 'center', headerAlign: 'center' },
{ field: 'id_Distribuidor', headerName: 'ID Dist.', width: 100, align: 'center', headerAlign: 'center' },
{ field: 'id_Empresa', headerName: 'ID Emp.', width: 100, align: 'center', headerAlign: 'center' },
{ field: 'fechaCorte', headerName: 'Fecha Corte', width: 120, type: 'date', valueFormatter: (value) => formatDate(value as string) },
{ field: 'fechaCierre', headerName: 'Fecha Cierre', width: 170, valueFormatter: (value) => formatDate(value as string) },
{ field: 'saldoCierre', headerName: 'Saldo Cierre', width: 140, type: 'number', valueFormatter: (v) => currencyFormatter(v as number) },
{ field: 'estado', headerName: 'Estado', width: 100 },
{ field: 'justificacion', headerName: 'Justif. Cierre', width: 200, renderCell: (params) => (<Tooltip title={params.value || ''}><Typography noWrap variant="body2">{params.value || '-'}</Typography></Tooltip>) },
{ field: 'id_Usuario_Cierre', headerName: 'Usr. Cierre', width: 100, align: 'center', headerAlign: 'center' },
{ field: 'id_Usuario_Anula', headerName: 'Usr. Anula', width: 100, align: 'center', headerAlign: 'center', renderCell: (p) => p.value ?? '-' },
{ field: 'fechaAnulacion', headerName: 'Fecha Anul.', width: 170, valueFormatter: (v) => v ? formatDate(v as string) : '-' },
{ field: 'justificacion_Anulacion', headerName: 'Justif. Anul.', flex: 1, minWidth: 200, renderCell: (params) => (<Tooltip title={params.value || ''}><Typography noWrap variant="body2">{params.value || '-'}</Typography></Tooltip>) },
];
break;
default: default:
setError(`La vista de auditoría para '${filtroTipoEntidad}' aún no está implementada.`); setError(`La vista de auditoría para '${filtroTipoEntidad}' aún no está implementada.`);
setDatosAuditoria([]); setDatosAuditoria([]);

View File

@@ -0,0 +1,448 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Box, Typography, Button, Paper, IconButton, Chip, Alert, CircularProgress,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TablePagination,
FormControl, InputLabel, Select, MenuItem, Tabs, Tab, Tooltip
} from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import LockOpenIcon from '@mui/icons-material/LockOpen';
import HistoryIcon from '@mui/icons-material/History';
import FilterListIcon from '@mui/icons-material/FilterList';
import cierresCcService from '../../services/Contables/cierresCcService';
import distribuidorService from '../../services/Distribucion/distribuidorService';
import empresaService from '../../services/Distribucion/empresaService';
import type { CierreCuentaCorrienteDto } from '../../models/dtos/Contables/CierreCuentaCorrienteDto';
import type { CrearCierreDto } from '../../models/dtos/Contables/CrearCierreDto';
import type { ReabrirCierreDto } from '../../models/dtos/Contables/ReabrirCierreDto';
import type { CierreCuentaCorrienteHistorialDto } from '../../models/dtos/Auditoria/CierreCuentaCorrienteHistorialDto';
import type { DistribuidorDropdownDto } from '../../models/dtos/Distribucion/DistribuidorDropdownDto';
import type { EmpresaDropdownDto } from '../../models/dtos/Distribucion/EmpresaDropdownDto';
import NuevoCierreModal from '../../components/Modals/Contables/NuevoCierreModal';
import ReabrirCierreModal from '../../components/Modals/Contables/ReabrirCierreModal';
import { usePermissions } from '../../hooks/usePermissions';
import axios from 'axios';
const formatDate = (dateString?: string | null): string => {
if (!dateString) return '-';
const datePart = dateString.split('T')[0];
const parts = datePart.split('-');
if (parts.length === 3) return `${parts[2]}/${parts[1]}/${parts[0]}`;
return datePart;
};
const formatDateTime = (dateString?: string | null): string => {
if (!dateString) return '-';
const d = new Date(dateString);
if (isNaN(d.getTime())) return dateString;
return d.toLocaleString('es-AR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
};
// Extrae el codigo de error del backend si viene en el response (PERIODO_CERRADO_BLOQUEO_OPERACION,
// CIERRE_FECHA_FUTURA, etc.) y arma un mensaje legible. Si no hay codigo, usa message generico.
const parseApiError = (err: unknown, fallback: string): string => {
if (axios.isAxiosError(err)) {
const data = err.response?.data;
if (data && typeof data === 'object') {
const codigo = (data as { codigo?: string }).codigo;
const mensaje = (data as { mensaje?: string; message?: string }).mensaje
|| (data as { mensaje?: string; message?: string }).message;
if (mensaje) return codigo ? `[${codigo}] ${mensaje}` : mensaje;
if (codigo) return `Error: ${codigo}`;
}
}
return fallback;
};
const CierresCuentaCorrientePage: React.FC = () => {
const { tienePermiso, isSuperAdmin } = usePermissions();
const puedeVer = isSuperAdmin || tienePermiso('CC003');
const puedeCrear = isSuperAdmin || tienePermiso('CC001');
// Reapertura: exclusivo SuperAdmin (CC002 no se valida — no es asignable a perfiles).
const puedeReabrir = isSuperAdmin;
const [filtroIdDistribuidor, setFiltroIdDistribuidor] = useState<number | string>('');
const [filtroIdEmpresa, setFiltroIdEmpresa] = useState<number | string>('');
const [distribuidores, setDistribuidores] = useState<DistribuidorDropdownDto[]>([]);
const [empresas, setEmpresas] = useState<EmpresaDropdownDto[]>([]);
const [loadingDropdowns, setLoadingDropdowns] = useState(false);
const [cierres, setCierres] = useState<CierreCuentaCorrienteDto[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pageApiErrorMessage, setPageApiErrorMessage] = useState<string | null>(null);
const [modalApiErrorMessage, setModalApiErrorMessage] = useState<string | null>(null);
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(25);
const [tabIndex, setTabIndex] = useState(0);
const [cierreSeleccionadoHistorial, setCierreSeleccionadoHistorial] = useState<CierreCuentaCorrienteDto | null>(null);
const [historial, setHistorial] = useState<CierreCuentaCorrienteHistorialDto[]>([]);
const [loadingHistorial, setLoadingHistorial] = useState(false);
const [nuevoModalOpen, setNuevoModalOpen] = useState(false);
const [reabrirModalOpen, setReabrirModalOpen] = useState(false);
const [cierreParaReabrir, setCierreParaReabrir] = useState<CierreCuentaCorrienteDto | null>(null);
// Carga dropdowns una sola vez al montar
useEffect(() => {
const fetchDropdowns = async () => {
setLoadingDropdowns(true);
try {
const [distData, empData] = await Promise.all([
distribuidorService.getAllDistribuidoresDropdown(),
empresaService.getEmpresasDropdown()
]);
setDistribuidores(distData);
setEmpresas(empData);
} catch (err) {
console.error(err);
setError('Error al cargar opciones de filtro.');
} finally {
setLoadingDropdowns(false);
}
};
fetchDropdowns();
}, []);
const cargarCierres = useCallback(async () => {
if (!puedeVer) {
setError('No tiene permiso.');
return;
}
if (!filtroIdDistribuidor || !filtroIdEmpresa) {
setCierres([]);
return;
}
setLoading(true);
setError(null);
setPageApiErrorMessage(null);
try {
const data = await cierresCcService.getAllCierres(
Number(filtroIdDistribuidor),
Number(filtroIdEmpresa)
);
setCierres(data);
setPage(0);
} catch (err) {
console.error(err);
setPageApiErrorMessage(parseApiError(err, 'Error al cargar los cierres.'));
} finally {
setLoading(false);
}
}, [puedeVer, filtroIdDistribuidor, filtroIdEmpresa]);
useEffect(() => { cargarCierres(); }, [cargarCierres]);
const cargarHistorial = useCallback(async (idCierre: number) => {
setLoadingHistorial(true);
try {
const data = await cierresCcService.getHistorialCierre(idCierre);
setHistorial(data);
} catch (err) {
console.error(err);
setPageApiErrorMessage(parseApiError(err, 'Error al cargar el historial.'));
} finally {
setLoadingHistorial(false);
}
}, []);
const handleSelectCierreHistorial = (cierre: CierreCuentaCorrienteDto) => {
setCierreSeleccionadoHistorial(cierre);
setTabIndex(1);
cargarHistorial(cierre.idCierre);
};
const clearModalApiErrorMessage = useCallback(() => setModalApiErrorMessage(null), []);
const handleSubmitNuevoCierre = async (data: CrearCierreDto) => {
setModalApiErrorMessage(null);
try {
const creado = await cierresCcService.crearCierre(data);
cargarCierres();
return creado;
} catch (err) {
const msg = parseApiError(err, 'Error al crear el cierre.');
setModalApiErrorMessage(msg);
throw err;
}
};
const handleOpenReabrir = (cierre: CierreCuentaCorrienteDto) => {
setCierreParaReabrir(cierre);
setReabrirModalOpen(true);
};
const handleSubmitReabrir = async (idCierre: number, data: ReabrirCierreDto) => {
setModalApiErrorMessage(null);
try {
const result = await cierresCcService.reabrirCierre(idCierre, data);
cargarCierres();
if (cierreSeleccionadoHistorial?.idCierre === idCierre) {
cargarHistorial(idCierre);
}
return result;
} catch (err) {
const msg = parseApiError(err, 'Error al reabrir el cierre.');
setModalApiErrorMessage(msg);
throw err;
}
};
const handleChangePage = (_e: unknown, newPage: number) => setPage(newPage);
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
setRowsPerPage(parseInt(event.target.value, 10));
setPage(0);
};
const displayData = cierres.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage);
if (!puedeVer) {
return (
<Box sx={{ p: 2 }}>
<Alert severity="error">No tiene permiso para acceder a esta sección.</Alert>
</Box>
);
}
return (
<Box sx={{ p: 1 }}>
<Typography variant="h5" gutterBottom>Cierres de Cuenta Corriente</Typography>
<Paper sx={{ p: 2, mb: 2 }}>
<Typography variant="h6" gutterBottom>Filtros <FilterListIcon fontSize="small" /></Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, alignItems: 'center', mb: 2 }}>
<FormControl size="small" sx={{ minWidth: 220, flexGrow: 1 }} disabled={loadingDropdowns}>
<InputLabel>Distribuidor</InputLabel>
<Select
value={filtroIdDistribuidor}
label="Distribuidor"
onChange={(e) => setFiltroIdDistribuidor(e.target.value as number | string)}
>
<MenuItem value=""><em>Seleccione</em></MenuItem>
{distribuidores.map(d => <MenuItem key={d.idDistribuidor} value={d.idDistribuidor}>{d.nombre}</MenuItem>)}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 200, flexGrow: 1 }} disabled={loadingDropdowns}>
<InputLabel>Empresa</InputLabel>
<Select
value={filtroIdEmpresa}
label="Empresa"
onChange={(e) => setFiltroIdEmpresa(e.target.value as number | string)}
>
<MenuItem value=""><em>Seleccione</em></MenuItem>
{empresas.map(e => <MenuItem key={e.idEmpresa} value={e.idEmpresa}>{e.nombre}</MenuItem>)}
</Select>
</FormControl>
</Box>
{puedeCrear && (
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => setNuevoModalOpen(true)}
disabled={loadingDropdowns}
>
Nuevo Cierre
</Button>
)}
</Paper>
<Paper sx={{ mb: 2 }}>
<Tabs value={tabIndex} onChange={(_e, v) => setTabIndex(v)}>
<Tab label="Cierres" />
<Tab
label={cierreSeleccionadoHistorial
? `Historial #${cierreSeleccionadoHistorial.idCierre}`
: 'Historial'}
disabled={!cierreSeleccionadoHistorial}
icon={<HistoryIcon fontSize="small" />}
iconPosition="start"
/>
</Tabs>
</Paper>
{error && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>}
{pageApiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{pageApiErrorMessage}</Alert>}
{tabIndex === 0 && (
<>
{loading && (
<Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}>
<CircularProgress />
</Box>
)}
{!loading && (!filtroIdDistribuidor || !filtroIdEmpresa) && (
<Alert severity="info">Seleccioná un distribuidor y una empresa para ver los cierres.</Alert>
)}
{!loading && filtroIdDistribuidor && filtroIdEmpresa && (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Fecha de Corte</TableCell>
<TableCell>Fecha de Cierre</TableCell>
<TableCell align="right">Saldo del Cierre</TableCell>
<TableCell align="center">Estado</TableCell>
<TableCell>Justificación</TableCell>
<TableCell>Usuario</TableCell>
<TableCell align="right">Acciones</TableCell>
</TableRow>
</TableHead>
<TableBody>
{displayData.length === 0 ? (
<TableRow><TableCell colSpan={7} align="center">No hay cierres registrados.</TableCell></TableRow>
) : (
displayData.map((c) => (
<TableRow key={c.idCierre} hover>
<TableCell>{formatDate(c.fechaCorte)}</TableCell>
<TableCell>{formatDateTime(c.fechaCierre)}</TableCell>
<TableCell align="right">
{c.saldoCierre.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}
</TableCell>
<TableCell align="center">
<Chip
label={c.estado === 'Activo' && c.esUltimoVigente ? 'Activo (último)' : c.estado}
color={c.estado === 'Activo' ? 'success' : 'default'}
size="small"
/>
</TableCell>
<TableCell>
<Tooltip title={c.justificacion || ''}>
<Box sx={{ maxWidth: 220, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{c.justificacion || '-'}
</Box>
</Tooltip>
</TableCell>
<TableCell>{c.nombreUsuarioCierre}</TableCell>
<TableCell align="right">
<Tooltip title="Ver historial">
<IconButton size="small" onClick={() => handleSelectCierreHistorial(c)}>
<HistoryIcon fontSize="small" />
</IconButton>
</Tooltip>
{puedeReabrir && c.estado === 'Activo' && c.esUltimoVigente && (
<Tooltip title="Reabrir cierre">
<IconButton
size="small"
color="warning"
onClick={() => handleOpenReabrir(c)}
>
<LockOpenIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
<TablePagination
rowsPerPageOptions={[25, 50, 100]}
component="div"
count={cierres.length}
rowsPerPage={rowsPerPage}
page={page}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage}
labelRowsPerPage="Filas por página:"
/>
</TableContainer>
)}
</>
)}
{tabIndex === 1 && cierreSeleccionadoHistorial && (
<>
<Paper sx={{ p: 2, mb: 2 }}>
<Typography variant="body2">
Historial del cierre <strong>#{cierreSeleccionadoHistorial.idCierre}</strong>
{' — '}{cierreSeleccionadoHistorial.nombreDistribuidor} / {cierreSeleccionadoHistorial.nombreEmpresa}
{' — '}corte: {formatDate(cierreSeleccionadoHistorial.fechaCorte)}
</Typography>
</Paper>
{loadingHistorial && (
<Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}>
<CircularProgress />
</Box>
)}
{!loadingHistorial && (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Tipo Modificación</TableCell>
<TableCell>Fecha Modificación</TableCell>
<TableCell>Usuario</TableCell>
<TableCell align="right">Saldo</TableCell>
<TableCell align="center">Estado</TableCell>
<TableCell>Justificación</TableCell>
</TableRow>
</TableHead>
<TableBody>
{historial.length === 0 ? (
<TableRow><TableCell colSpan={6} align="center">No hay registros de auditoría.</TableCell></TableRow>
) : (
historial.map((h) => (
<TableRow key={h.id_Historial}>
<TableCell>{h.tipoMod}</TableCell>
<TableCell>{formatDateTime(h.fechaMod)}</TableCell>
<TableCell>{h.nombreUsuarioModifico}</TableCell>
<TableCell align="right">
{h.saldoCierre.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}
</TableCell>
<TableCell align="center">
<Chip label={h.estado} color={h.estado === 'Activo' ? 'success' : 'default'} size="small" />
</TableCell>
<TableCell>
<Tooltip title={h.justificacion_Anulacion || h.justificacion || ''}>
<Box sx={{ maxWidth: 220, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{h.justificacion_Anulacion || h.justificacion || '-'}
</Box>
</Tooltip>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
)}
</>
)}
<NuevoCierreModal
open={nuevoModalOpen}
onClose={() => setNuevoModalOpen(false)}
onSubmit={handleSubmitNuevoCierre}
initialIdDistribuidor={filtroIdDistribuidor ? Number(filtroIdDistribuidor) : null}
initialIdEmpresa={filtroIdEmpresa ? Number(filtroIdEmpresa) : null}
errorMessage={modalApiErrorMessage}
clearErrorMessage={clearModalApiErrorMessage}
/>
<ReabrirCierreModal
open={reabrirModalOpen}
onClose={() => { setReabrirModalOpen(false); setCierreParaReabrir(null); }}
onSubmit={handleSubmitReabrir}
cierre={cierreParaReabrir}
errorMessage={modalApiErrorMessage}
clearErrorMessage={clearModalApiErrorMessage}
/>
</Box>
);
};
export default CierresCuentaCorrientePage;

View File

@@ -9,6 +9,7 @@ const contablesSubModules = [
{ label: 'Notas Crédito/Débito', path: 'notas-cd' }, { label: 'Notas Crédito/Débito', path: 'notas-cd' },
{ label: 'Gestión de Saldos', path: 'gestion-saldos' }, { label: 'Gestión de Saldos', path: 'gestion-saldos' },
{ label: 'Tipos de Pago', path: 'tipos-pago' }, { label: 'Tipos de Pago', path: 'tipos-pago' },
{ label: 'Cierres CC', path: 'cierres-cc' },
]; ];
const ContablesIndexPage: React.FC = () => { const ContablesIndexPage: React.FC = () => {

View File

@@ -24,7 +24,7 @@ import type { EmpresaDto } from '../../models/dtos/Distribucion/EmpresaDto';
import NotaCreditoDebitoFormModal from '../../components/Modals/Contables/NotaCreditoDebitoFormModal'; import NotaCreditoDebitoFormModal from '../../components/Modals/Contables/NotaCreditoDebitoFormModal';
import { usePermissions } from '../../hooks/usePermissions'; import { usePermissions } from '../../hooks/usePermissions';
import axios from 'axios'; import { parseApiError } from '../../utils/apiErrorParser';
type DestinoFiltroType = 'Distribuidores' | 'Canillas' | ''; type DestinoFiltroType = 'Distribuidores' | 'Canillas' | '';
type TipoNotaFiltroType = 'Credito' | 'Debito' | ''; type TipoNotaFiltroType = 'Credito' | 'Debito' | '';
@@ -124,7 +124,7 @@ const GestionarNotasCDPage: React.FC = () => {
} }
cargarNotas(); cargarNotas();
} catch (err: any) { } catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al guardar la nota.'; const { message } = parseApiError(err, 'Error al guardar la nota.');
setApiErrorMessage(message); throw err; setApiErrorMessage(message); throw err;
} }
}; };
@@ -133,7 +133,7 @@ const GestionarNotasCDPage: React.FC = () => {
if (window.confirm(`¿Seguro de eliminar esta nota (ID: ${idNota})? Esta acción revertirá el impacto en el saldo.`)) { if (window.confirm(`¿Seguro de eliminar esta nota (ID: ${idNota})? Esta acción revertirá el impacto en el saldo.`)) {
setApiErrorMessage(null); setApiErrorMessage(null);
try { await notaCreditoDebitoService.deleteNota(idNota); cargarNotas(); } try { await notaCreditoDebitoService.deleteNota(idNota); cargarNotas(); }
catch (err: any) { const msg = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al eliminar.'; setApiErrorMessage(msg); } catch (err: any) { const { message } = parseApiError(err, 'Error al eliminar.'); setApiErrorMessage(message); }
} }
handleMenuClose(); handleMenuClose();
}; };

View File

@@ -22,7 +22,7 @@ import type { EmpresaDto } from '../../models/dtos/Distribucion/EmpresaDto';
import PagoDistribuidorFormModal from '../../components/Modals/Contables/PagoDistribuidorFormModal'; import PagoDistribuidorFormModal from '../../components/Modals/Contables/PagoDistribuidorFormModal';
import { usePermissions } from '../../hooks/usePermissions'; import { usePermissions } from '../../hooks/usePermissions';
import axios from 'axios'; import { parseApiError } from '../../utils/apiErrorParser';
const GestionarPagosDistribuidorPage: React.FC = () => { const GestionarPagosDistribuidorPage: React.FC = () => {
const [pagos, setPagos] = useState<PagoDistribuidorDto[]>([]); const [pagos, setPagos] = useState<PagoDistribuidorDto[]>([]);
@@ -110,7 +110,7 @@ const GestionarPagosDistribuidorPage: React.FC = () => {
} }
cargarPagos(); cargarPagos();
} catch (err: any) { } catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al guardar el pago.'; const { message } = parseApiError(err, 'Error al guardar el pago.');
setModalApiErrorMessage(message); setModalApiErrorMessage(message);
throw err; throw err;
} }
@@ -121,8 +121,8 @@ const GestionarPagosDistribuidorPage: React.FC = () => {
setPageApiErrorMessage(null); setPageApiErrorMessage(null);
try { await pagoDistribuidorService.deletePagoDistribuidor(idPago); cargarPagos(); } try { await pagoDistribuidorService.deletePagoDistribuidor(idPago); cargarPagos(); }
catch (err: any) { catch (err: any) {
const msg = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al eliminar.'; const { message } = parseApiError(err, 'Error al eliminar.');
setPageApiErrorMessage(msg); setPageApiErrorMessage(message);
} }
} }
handleMenuClose(); handleMenuClose();

View File

@@ -20,7 +20,7 @@ import type { CanillaDto } from '../../models/dtos/Distribucion/CanillaDto';
import AjusteSaldoModal from '../../components/Modals/Contables/AjusteSaldoModal'; import AjusteSaldoModal from '../../components/Modals/Contables/AjusteSaldoModal';
import { usePermissions } from '../../hooks/usePermissions'; import { usePermissions } from '../../hooks/usePermissions';
import axios from 'axios'; import { parseApiError } from '../../utils/apiErrorParser';
type TipoDestinoFiltro = 'Distribuidores' | 'Canillas' | ''; type TipoDestinoFiltro = 'Distribuidores' | 'Canillas' | '';
@@ -48,7 +48,7 @@ const GestionarSaldosPage: React.FC = () => {
const { tienePermiso, isSuperAdmin } = usePermissions(); const { tienePermiso, isSuperAdmin } = usePermissions();
const puedeVerSaldos = isSuperAdmin || tienePermiso("CS001"); // Permiso para ver const puedeVerSaldos = isSuperAdmin || tienePermiso("CS001"); // Permiso para ver
const puedeAjustarSaldos = isSuperAdmin || tienePermiso("CS002"); // Permiso para ajustar const puedeAjustarSaldos = isSuperAdmin; // Ajuste manual: exclusivo SuperAdmin (no se valida CS002)
const fetchDropdownData = useCallback(async () => { const fetchDropdownData = useCallback(async () => {
@@ -128,9 +128,7 @@ const GestionarSaldosPage: React.FC = () => {
await saldoService.ajustarSaldo(data); await saldoService.ajustarSaldo(data);
cargarSaldos(); // Recargar lista para ver el saldo actualizado cargarSaldos(); // Recargar lista para ver el saldo actualizado
} catch (err: any) { } catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.message const { message } = parseApiError(err, 'Error al aplicar el ajuste de saldo.');
? err.response.data.message
: 'Error al aplicar el ajuste de saldo.';
setApiErrorMessage(message); setApiErrorMessage(message);
throw err; // Para que el modal sepa que hubo error throw err; // Para que el modal sepa que hubo error
} }

View File

@@ -1,14 +1,16 @@
// src/pages/Distribucion/GestionarDistribuidoresPage.tsx // src/pages/Distribucion/GestionarDistribuidoresPage.tsx
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { import {
Box, Typography, TextField, Button, Paper, IconButton, Menu, MenuItem, Box, Typography, TextField, Button, Paper, IconButton, Menu, MenuItem, Switch,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TablePagination, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TablePagination,
CircularProgress, Alert CircularProgress, Alert, Chip, FormControlLabel, ListItemIcon, ListItemText
} from '@mui/material'; } from '@mui/material';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import EditIcon from '@mui/icons-material/Edit'; import EditIcon from '@mui/icons-material/Edit';
import TrashIcon from '@mui/icons-material/Delete'; import TrashIcon from '@mui/icons-material/Delete';
import MoreVertIcon from '@mui/icons-material/MoreVert'; import MoreVertIcon from '@mui/icons-material/MoreVert';
import ToggleOnIcon from '@mui/icons-material/ToggleOn';
import ToggleOffIcon from '@mui/icons-material/ToggleOff';
import distribuidorService from '../../services/Distribucion/distribuidorService'; import distribuidorService from '../../services/Distribucion/distribuidorService';
import type { DistribuidorDto } from '../../models/dtos/Distribucion/DistribuidorDto'; import type { DistribuidorDto } from '../../models/dtos/Distribucion/DistribuidorDto';
import type { CreateDistribuidorDto } from '../../models/dtos/Distribucion/CreateDistribuidorDto'; import type { CreateDistribuidorDto } from '../../models/dtos/Distribucion/CreateDistribuidorDto';
@@ -24,6 +26,7 @@ const GestionarDistribuidoresPage: React.FC = () => {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [filtroNombre, setFiltroNombre] = useState(''); const [filtroNombre, setFiltroNombre] = useState('');
const [filtroNroDoc, setFiltroNroDoc] = useState(''); const [filtroNroDoc, setFiltroNroDoc] = useState('');
const [filtroSoloActivos, setFiltroSoloActivos] = useState<boolean | undefined>(true);
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [editingDistribuidor, setEditingDistribuidor] = useState<DistribuidorDto | null>(null); const [editingDistribuidor, setEditingDistribuidor] = useState<DistribuidorDto | null>(null);
@@ -49,12 +52,12 @@ const GestionarDistribuidoresPage: React.FC = () => {
} }
setLoading(true); setError(null); setApiErrorMessage(null); setLoading(true); setError(null); setApiErrorMessage(null);
try { try {
const data = await distribuidorService.getAllDistribuidores(filtroNombre, filtroNroDoc); const data = await distribuidorService.getAllDistribuidores(filtroNombre, filtroNroDoc, filtroSoloActivos);
setDistribuidores(data); setDistribuidores(data);
} catch (err) { } catch (err) {
console.error(err); setError('Error al cargar los distribuidores.'); console.error(err); setError('Error al cargar los distribuidores.');
} finally { setLoading(false); } } finally { setLoading(false); }
}, [filtroNombre, filtroNroDoc, puedeVer]); }, [filtroNombre, filtroNroDoc, filtroSoloActivos, puedeVer]);
useEffect(() => { cargarDistribuidores(); }, [cargarDistribuidores]); useEffect(() => { cargarDistribuidores(); }, [cargarDistribuidores]);
@@ -94,6 +97,21 @@ const GestionarDistribuidoresPage: React.FC = () => {
handleMenuClose(); handleMenuClose();
}; };
const handleToggleBaja = async (distribuidor: DistribuidorDto) => {
setApiErrorMessage(null);
const accion = distribuidor.baja ? "reactivar" : "dar de baja";
if (window.confirm(`¿Está seguro de que desea ${accion} a ${distribuidor.nombre}?`)) {
try {
await distribuidorService.toggleBajaDistribuidor(distribuidor.idDistribuidor, { darDeBaja: !distribuidor.baja, fechaBaja: !distribuidor.baja ? new Date().toISOString() : null });
cargarDistribuidores();
} catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : `Error al ${accion} el distribuidor.`;
setApiErrorMessage(message);
}
}
handleMenuClose();
};
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, distribuidor: DistribuidorDto) => { const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, distribuidor: DistribuidorDto) => {
setAnchorEl(event.currentTarget); setSelectedDistribuidorRow(distribuidor); setAnchorEl(event.currentTarget); setSelectedDistribuidorRow(distribuidor);
}; };
@@ -132,7 +150,17 @@ const GestionarDistribuidoresPage: React.FC = () => {
onChange={(e) => setFiltroNroDoc(e.target.value)} onChange={(e) => setFiltroNroDoc(e.target.value)}
sx={{ flexGrow: 1, minWidth: '200px' }} sx={{ flexGrow: 1, minWidth: '200px' }}
/> />
{/* <Button variant="contained" onClick={cargarDistribuidores} size="small">Buscar</Button> */} <FormControlLabel
control={
<Switch
checked={filtroSoloActivos === undefined ? true : filtroSoloActivos}
onChange={(e) => setFiltroSoloActivos(e.target.checked)}
size="small"
/>
}
label="Ver Activos"
sx={{ flexShrink: 0 }}
/>
</Box> </Box>
{puedeCrear && ( {puedeCrear && (
<Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpenModal()} sx={{ mb: 2 }}>Agregar Distribuidor</Button> <Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpenModal()} sx={{ mb: 2 }}>Agregar Distribuidor</Button>
@@ -150,6 +178,7 @@ const GestionarDistribuidoresPage: React.FC = () => {
<TableCell>Nombre</TableCell><TableCell>Nro. Doc.</TableCell> <TableCell>Nombre</TableCell><TableCell>Nro. Doc.</TableCell>
<TableCell>Contacto</TableCell><TableCell>Zona</TableCell> <TableCell>Contacto</TableCell><TableCell>Zona</TableCell>
<TableCell>Teléfono</TableCell><TableCell>Localidad</TableCell> <TableCell>Teléfono</TableCell><TableCell>Localidad</TableCell>
<TableCell>Estado</TableCell>
{(puedeModificar || puedeEliminar) && <TableCell align="right">Acciones</TableCell>} {(puedeModificar || puedeEliminar) && <TableCell align="right">Acciones</TableCell>}
</TableRow></TableHead> </TableRow></TableHead>
<TableBody> <TableBody>
@@ -157,10 +186,11 @@ const GestionarDistribuidoresPage: React.FC = () => {
<TableRow><TableCell colSpan={7} align="center">No se encontraron distribuidores.</TableCell></TableRow> <TableRow><TableCell colSpan={7} align="center">No se encontraron distribuidores.</TableCell></TableRow>
) : ( ) : (
displayData.map((d) => ( displayData.map((d) => (
<TableRow key={d.idDistribuidor} hover> <TableRow key={d.idDistribuidor} hover sx={{ backgroundColor: d.baja ? '#ffebee' : 'inherit' }}>
<TableCell>{d.nombre}</TableCell><TableCell>{d.nroDoc}</TableCell> <TableCell>{d.nombre}</TableCell><TableCell>{d.nroDoc}</TableCell>
<TableCell>{d.contacto || '-'}</TableCell><TableCell>{d.nombreZona || '-'}</TableCell> <TableCell>{d.contacto || '-'}</TableCell><TableCell>{d.nombreZona || '-'}</TableCell>
<TableCell>{d.telefono || '-'}</TableCell><TableCell>{d.localidad || '-'}</TableCell> <TableCell>{d.telefono || '-'}</TableCell><TableCell>{d.localidad || '-'}</TableCell>
<TableCell>{d.baja ? <Chip label="Baja" color="error" size="small" /> : <Chip label="Activo" color="success" size="small" />}</TableCell>
{(puedeModificar || puedeEliminar) && ( {(puedeModificar || puedeEliminar) && (
<TableCell align="right"> <TableCell align="right">
<IconButton onClick={(e) => handleMenuOpen(e, d)} disabled={!puedeModificar && !puedeEliminar}><MoreVertIcon /></IconButton> <IconButton onClick={(e) => handleMenuOpen(e, d)} disabled={!puedeModificar && !puedeEliminar}><MoreVertIcon /></IconButton>
@@ -179,8 +209,24 @@ const GestionarDistribuidoresPage: React.FC = () => {
)} )}
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}> <Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}>
{puedeModificar && (<MenuItem onClick={() => { handleOpenModal(selectedDistribuidorRow!); handleMenuClose(); }}><EditIcon fontSize="small" sx={{ mr: 1 }} />Modificar</MenuItem>)} {puedeModificar && selectedDistribuidorRow && (
{puedeEliminar && (<MenuItem onClick={() => handleDelete(selectedDistribuidorRow!.idDistribuidor)}><TrashIcon fontSize="small" sx={{ mr: 1 }} />Eliminar</MenuItem>)} <MenuItem onClick={() => { handleOpenModal(selectedDistribuidorRow); handleMenuClose(); }}>
<ListItemIcon><EditIcon fontSize="small" /></ListItemIcon>
<ListItemText>Modificar</ListItemText>
</MenuItem>
)}
{puedeEliminar && selectedDistribuidorRow && (
<MenuItem onClick={() => handleToggleBaja(selectedDistribuidorRow)}>
<ListItemIcon>{selectedDistribuidorRow.baja ? <ToggleOnIcon fontSize="small" /> : <ToggleOffIcon fontSize="small" />}</ListItemIcon>
<ListItemText>{selectedDistribuidorRow.baja ? 'Reactivar' : 'Dar de Baja'}</ListItemText>
</MenuItem>
)}
{puedeEliminar && selectedDistribuidorRow && (
<MenuItem onClick={() => handleDelete(selectedDistribuidorRow.idDistribuidor)}>
<ListItemIcon><TrashIcon fontSize="small" /></ListItemIcon>
<ListItemText>Eliminar (Físico)</ListItemText>
</MenuItem>
)}
{(!puedeModificar && !puedeEliminar) && <MenuItem disabled>Sin acciones</MenuItem>} {(!puedeModificar && !puedeEliminar) && <MenuItem disabled>Sin acciones</MenuItem>}
</Menu> </Menu>

View File

@@ -348,6 +348,10 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
displayData.filter(m => !m.liquidado).reduce((sum, item) => sum + item.montoARendir, 0) displayData.filter(m => !m.liquidado).reduce((sum, item) => sum + item.montoARendir, 0)
, [displayData]); , [displayData]);
const montoARendirAll = useMemo(() =>
movimientos.reduce((sum, item) => sum + item.montoARendir, 0)
, [movimientos]);
if (!puedeVer) { if (!puedeVer) {
return ( return (
<Box sx={{ p: 2 }}> <Box sx={{ p: 2 }}>
@@ -442,10 +446,17 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
{!loading && movimientos.length > 0 && ( {!loading && movimientos.length > 0 && (
<Paper sx={{ p: 1.5, mb: 2, mt: 1, backgroundColor: 'grey.100' }}> <Paper sx={{ p: 1.5, mb: 2, mt: 1, backgroundColor: 'grey.100' }}>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center' }}> <Box sx={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center' }}>
<Typography variant="subtitle1" sx={{mr:2}}> <Typography variant="subtitle1" sx={{ mr: 1 }}>
Total:
</Typography>
<Typography variant="h6" sx={{ paddingRight: '5px', fontWeight: 'bold', color: 'text.main' }}>
{montoARendirAll.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}
</Typography>
-
<Typography variant="subtitle1" sx={{ mr: 1, paddingLeft: '5px', }}>
Total a Liquidar: Total a Liquidar:
</Typography> </Typography>
<Typography variant="h6" sx={{fontWeight: 'bold', color: 'error.main'}}> <Typography variant="h6" sx={{ fontWeight: 'bold', color: totalARendirVisible > 0 ? 'error.main' : 'green' }}>
{totalARendirVisible.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })} {totalARendirVisible.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}
</Typography> </Typography>
</Box> </Box>

View File

@@ -22,7 +22,7 @@ import type { DistribuidorDropdownDto } from '../../models/dtos/Distribucion/Dis
import EntradaSalidaDistFormModal from '../../components/Modals/Distribucion/EntradaSalidaDistFormModal'; import EntradaSalidaDistFormModal from '../../components/Modals/Distribucion/EntradaSalidaDistFormModal';
import { usePermissions } from '../../hooks/usePermissions'; import { usePermissions } from '../../hooks/usePermissions';
import axios from 'axios'; import { parseApiError } from '../../utils/apiErrorParser';
const GestionarEntradasSalidasDistPage: React.FC = () => { const GestionarEntradasSalidasDistPage: React.FC = () => {
const [movimientos, setMovimientos] = useState<EntradaSalidaDistDto[]>([]); const [movimientos, setMovimientos] = useState<EntradaSalidaDistDto[]>([]);
@@ -115,7 +115,7 @@ const GestionarEntradasSalidasDistPage: React.FC = () => {
} }
cargarMovimientos(); cargarMovimientos();
} catch (err: any) { } catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al guardar.'; const { message } = parseApiError(err, 'Error al guardar.');
setApiErrorMessage(message); throw err; setApiErrorMessage(message); throw err;
} }
}; };
@@ -124,7 +124,7 @@ const GestionarEntradasSalidasDistPage: React.FC = () => {
if (window.confirm(`¿Seguro (ID: ${idParte})? Esto revertirá el saldo.`)) { if (window.confirm(`¿Seguro (ID: ${idParte})? Esto revertirá el saldo.`)) {
setApiErrorMessage(null); setApiErrorMessage(null);
try { await entradaSalidaDistService.deleteEntradaSalidaDist(idParte); cargarMovimientos(); } try { await entradaSalidaDistService.deleteEntradaSalidaDist(idParte); cargarMovimientos(); }
catch (err: any) { const msg = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al eliminar.'; setApiErrorMessage(msg); } catch (err: any) { const { message } = parseApiError(err, 'Error al eliminar.'); setApiErrorMessage(message); }
} }
handleMenuClose(); handleMenuClose();
}; };

View File

@@ -1,9 +1,11 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { import {
Box, Typography, TextField, Button, Paper, IconButton, Menu, MenuItem, Chip, Box, Typography, TextField, Button, Paper, IconButton, Menu, MenuItem, Chip,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TablePagination, Alert, FormControl, InputLabel, Select, FormControlLabel, Checkbox
CircularProgress, Alert, FormControl, InputLabel, Select, FormControlLabel, Checkbox
} from '@mui/material'; } from '@mui/material';
import { DataGrid, type GridColDef, type GridRenderCellParams } from '@mui/x-data-grid';
import { esES } from '@mui/x-data-grid/locales';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import MoreVertIcon from '@mui/icons-material/MoreVert'; import MoreVertIcon from '@mui/icons-material/MoreVert';
import EditIcon from '@mui/icons-material/Edit'; import EditIcon from '@mui/icons-material/Edit';
@@ -11,6 +13,7 @@ import DeleteIcon from '@mui/icons-material/Delete';
import SwapHorizIcon from '@mui/icons-material/SwapHoriz'; import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
import SearchIcon from '@mui/icons-material/Search'; import SearchIcon from '@mui/icons-material/Search';
import ClearIcon from '@mui/icons-material/Clear'; import ClearIcon from '@mui/icons-material/Clear';
import EditCalendarIcon from '@mui/icons-material/EditCalendar';
import stockBobinaService from '../../services/Impresion/stockBobinaService'; import stockBobinaService from '../../services/Impresion/stockBobinaService';
import tipoBobinaService from '../../services/Impresion/tipoBobinaService'; import tipoBobinaService from '../../services/Impresion/tipoBobinaService';
@@ -24,10 +27,13 @@ import type { CambiarEstadoBobinaDto } from '../../models/dtos/Impresion/Cambiar
import type { TipoBobinaDto } from '../../models/dtos/Impresion/TipoBobinaDto'; import type { TipoBobinaDto } from '../../models/dtos/Impresion/TipoBobinaDto';
import type { PlantaDto } from '../../models/dtos/Impresion/PlantaDto'; import type { PlantaDto } from '../../models/dtos/Impresion/PlantaDto';
import type { EstadoBobinaDto } from '../../models/dtos/Impresion/EstadoBobinaDto'; import type { EstadoBobinaDto } from '../../models/dtos/Impresion/EstadoBobinaDto';
import type { UpdateFechaRemitoLoteDto } from '../../models/dtos/Impresion/UpdateFechaRemitoLoteDto';
import StockBobinaFechaRemitoModal from '../../components/Modals/Impresion/StockBobinaFechaRemitoModal';
import StockBobinaIngresoFormModal from '../../components/Modals/Impresion/StockBobinaIngresoFormModal'; import StockBobinaIngresoFormModal from '../../components/Modals/Impresion/StockBobinaIngresoFormModal';
import StockBobinaEditFormModal from '../../components/Modals/Impresion/StockBobinaEditFormModal'; import StockBobinaEditFormModal from '../../components/Modals/Impresion/StockBobinaEditFormModal';
import StockBobinaCambioEstadoModal from '../../components/Modals/Impresion/StockBobinaCambioEstadoModal'; import StockBobinaCambioEstadoModal from '../../components/Modals/Impresion/StockBobinaCambioEstadoModal';
import StockBobinaLoteFormModal from '../../components/Modals/Impresion/StockBobinaLoteFormModal';
import { usePermissions } from '../../hooks/usePermissions'; import { usePermissions } from '../../hooks/usePermissions';
import axios from 'axios'; import axios from 'axios';
@@ -38,20 +44,27 @@ const ID_ESTADO_DANADA = 3;
const GestionarStockBobinasPage: React.FC = () => { const GestionarStockBobinasPage: React.FC = () => {
const [stock, setStock] = useState<StockBobinaDto[]>([]); const [stock, setStock] = useState<StockBobinaDto[]>([]);
const [loading, setLoading] = useState(false); // No carga al inicio const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null); const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null);
// Estados de los filtros // --- Estados de los filtros ---
const [filtroTipoBobina, setFiltroTipoBobina] = useState<number | string>(''); const [filtroTipoBobina, setFiltroTipoBobina] = useState<number | string>('');
const [filtroNroBobina, setFiltroNroBobina] = useState(''); const [filtroNroBobina, setFiltroNroBobina] = useState('');
const [filtroPlanta, setFiltroPlanta] = useState<number | string>(''); const [filtroPlanta, setFiltroPlanta] = useState<number | string>('');
const [filtroEstadoBobina, setFiltroEstadoBobina] = useState<number | string>(''); const [filtroEstadoBobina, setFiltroEstadoBobina] = useState<number | string>('');
const [filtroRemito, setFiltroRemito] = useState(''); const [filtroRemito, setFiltroRemito] = useState('');
const [filtroFechaHabilitado, setFiltroFechaHabilitado] = useState<boolean>(false); // <-- NUEVO
// Filtro Fechas Remito
const [filtroFechaHabilitado, setFiltroFechaHabilitado] = useState<boolean>(false);
const [filtroFechaDesde, setFiltroFechaDesde] = useState<string>(new Date().toISOString().split('T')[0]); const [filtroFechaDesde, setFiltroFechaDesde] = useState<string>(new Date().toISOString().split('T')[0]);
const [filtroFechaHasta, setFiltroFechaHasta] = useState<string>(new Date().toISOString().split('T')[0]); const [filtroFechaHasta, setFiltroFechaHasta] = useState<string>(new Date().toISOString().split('T')[0]);
// Nuevo Filtro: Fechas Estado
const [filtroFechaEstadoHabilitado, setFiltroFechaEstadoHabilitado] = useState<boolean>(false);
const [filtroFechaEstadoDesde, setFiltroFechaEstadoDesde] = useState<string>(new Date().toISOString().split('T')[0]);
const [filtroFechaEstadoHasta, setFiltroFechaEstadoHasta] = useState<string>(new Date().toISOString().split('T')[0]);
// Estados para datos de dropdowns // Estados para datos de dropdowns
const [tiposBobina, setTiposBobina] = useState<TipoBobinaDto[]>([]); const [tiposBobina, setTiposBobina] = useState<TipoBobinaDto[]>([]);
const [plantas, setPlantas] = useState<PlantaDto[]>([]); const [plantas, setPlantas] = useState<PlantaDto[]>([]);
@@ -62,13 +75,10 @@ const GestionarStockBobinasPage: React.FC = () => {
const [ingresoModalOpen, setIngresoModalOpen] = useState(false); const [ingresoModalOpen, setIngresoModalOpen] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false); const [editModalOpen, setEditModalOpen] = useState(false);
const [cambioEstadoModalOpen, setCambioEstadoModalOpen] = useState(false); const [cambioEstadoModalOpen, setCambioEstadoModalOpen] = useState(false);
const [loteModalOpen, setLoteModalOpen] = useState(false);
const [fechaRemitoModalOpen, setFechaRemitoModalOpen] = useState(false);
// Estado para la bobina seleccionada en un modal o menú // Menú de acciones
const [selectedBobina, setSelectedBobina] = useState<StockBobinaDto | null>(null);
// Estados para la paginación y el menú de acciones
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(25);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [selectedBobinaForRowMenu, setSelectedBobinaForRowMenu] = useState<StockBobinaDto | null>(null); const [selectedBobinaForRowMenu, setSelectedBobinaForRowMenu] = useState<StockBobinaDto | null>(null);
@@ -79,12 +89,9 @@ const GestionarStockBobinasPage: React.FC = () => {
const puedeModificarDatos = isSuperAdmin || tienePermiso("IB004"); const puedeModificarDatos = isSuperAdmin || tienePermiso("IB004");
const puedeEliminar = isSuperAdmin || tienePermiso("IB005"); const puedeEliminar = isSuperAdmin || tienePermiso("IB005");
const lastOpenedMenuButtonRef = useRef<HTMLButtonElement | null>(null);
const fetchFiltersDropdownData = useCallback(async () => { const fetchFiltersDropdownData = useCallback(async () => {
setLoadingFiltersDropdown(true); setLoadingFiltersDropdown(true);
try { try {
// Asumiendo que estos servicios existen y devuelven los DTOs correctos
const [tiposData, plantasData, estadosData] = await Promise.all([ const [tiposData, plantasData, estadosData] = await Promise.all([
tipoBobinaService.getAllTiposBobina(), tipoBobinaService.getAllTiposBobina(),
plantaService.getAllPlantas(), plantaService.getAllPlantas(),
@@ -121,13 +128,18 @@ const GestionarStockBobinasPage: React.FC = () => {
idPlanta: filtroPlanta ? Number(filtroPlanta) : null, idPlanta: filtroPlanta ? Number(filtroPlanta) : null,
idEstadoBobina: filtroEstadoBobina ? Number(filtroEstadoBobina) : null, idEstadoBobina: filtroEstadoBobina ? Number(filtroEstadoBobina) : null,
remitoFilter: filtroRemito || null, remitoFilter: filtroRemito || null,
// Fechas Remito
fechaDesde: filtroFechaHabilitado ? filtroFechaDesde : null, fechaDesde: filtroFechaHabilitado ? filtroFechaDesde : null,
fechaHasta: filtroFechaHabilitado ? filtroFechaHasta : null, fechaHasta: filtroFechaHabilitado ? filtroFechaHasta : null,
// Fechas Estado (Nuevos parametros, asegurar que el backend los reciba)
fechaEstadoDesde: filtroFechaEstadoHabilitado ? filtroFechaEstadoDesde : null,
fechaEstadoHasta: filtroFechaEstadoHabilitado ? filtroFechaEstadoHasta : null,
}; };
const data = await stockBobinaService.getAllStockBobinas(params); const data = await stockBobinaService.getAllStockBobinas(params);
setStock(data); setStock(data);
if (data.length === 0) { if (data.length === 0) {
setError("No se encontraron resultados con los filtros aplicados."); // No setteamos error bloqueante, solo aviso visual si se desea, o dejar tabla vacía.
// setError("No se encontraron resultados con los filtros aplicados.");
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -135,10 +147,14 @@ const GestionarStockBobinasPage: React.FC = () => {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [puedeVer, filtroTipoBobina, filtroNroBobina, filtroPlanta, filtroEstadoBobina, filtroRemito, filtroFechaHabilitado, filtroFechaDesde, filtroFechaHasta]); }, [
puedeVer,
filtroTipoBobina, filtroNroBobina, filtroPlanta, filtroEstadoBobina, filtroRemito,
filtroFechaHabilitado, filtroFechaDesde, filtroFechaHasta,
filtroFechaEstadoHabilitado, filtroFechaEstadoDesde, filtroFechaEstadoHasta
]);
const handleBuscarClick = () => { const handleBuscarClick = () => {
setPage(0); // Resetear la paginación al buscar
cargarStock(); cargarStock();
}; };
@@ -148,14 +164,19 @@ const GestionarStockBobinasPage: React.FC = () => {
setFiltroPlanta(''); setFiltroPlanta('');
setFiltroEstadoBobina(''); setFiltroEstadoBobina('');
setFiltroRemito(''); setFiltroRemito('');
setFiltroFechaHabilitado(false); setFiltroFechaHabilitado(false);
setFiltroFechaDesde(new Date().toISOString().split('T')[0]); setFiltroFechaDesde(new Date().toISOString().split('T')[0]);
setFiltroFechaHasta(new Date().toISOString().split('T')[0]); setFiltroFechaHasta(new Date().toISOString().split('T')[0]);
setStock([]); // Limpiar los resultados actuales
setFiltroFechaEstadoHabilitado(false);
setFiltroFechaEstadoDesde(new Date().toISOString().split('T')[0]);
setFiltroFechaEstadoHasta(new Date().toISOString().split('T')[0]);
setStock([]);
setError(null); setError(null);
}; };
const handleOpenIngresoModal = () => { setApiErrorMessage(null); setIngresoModalOpen(true); };
const handleCloseIngresoModal = () => setIngresoModalOpen(false); const handleCloseIngresoModal = () => setIngresoModalOpen(false);
const handleSubmitIngresoModal = async (data: CreateStockBobinaDto) => { const handleSubmitIngresoModal = async (data: CreateStockBobinaDto) => {
setApiErrorMessage(null); setApiErrorMessage(null);
@@ -163,92 +184,166 @@ const GestionarStockBobinasPage: React.FC = () => {
catch (err: any) { const msg = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al ingresar bobina.'; setApiErrorMessage(msg); throw err; } catch (err: any) { const msg = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al ingresar bobina.'; setApiErrorMessage(msg); throw err; }
}; };
const handleOpenEditModal = (bobina: StockBobinaDto | null) => { const handleLoteModalClose = (refrescar: boolean) => {
if (!bobina) return; setLoteModalOpen(false);
setSelectedBobina(bobina); if (refrescar) {
setApiErrorMessage(null); cargarStock();
setEditModalOpen(true);
};
const handleCloseEditModal = () => {
setEditModalOpen(false);
setSelectedBobina(null);
if (lastOpenedMenuButtonRef.current) {
setTimeout(() => { lastOpenedMenuButtonRef.current?.focus(); }, 0);
} }
}; };
const handleSubmitEditModal = async (idBobina: number, data: UpdateStockBobinaDto) => { const handleSubmitEditModal = async (idBobina: number, data: UpdateStockBobinaDto) => {
setApiErrorMessage(null); setApiErrorMessage(null);
try { await stockBobinaService.updateDatosBobinaDisponible(idBobina, data); cargarStock(); } try { await stockBobinaService.updateDatosBobinaDisponible(idBobina, data); cargarStock(); }
catch (err: any) { const msg = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al actualizar bobina.'; setApiErrorMessage(msg); throw err; } catch (err: any) { const msg = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al actualizar bobina.'; setApiErrorMessage(msg); throw err; }
}; };
const handleOpenCambioEstadoModal = (bobina: StockBobinaDto | null) => {
if (!bobina) return;
setSelectedBobina(bobina);
setApiErrorMessage(null);
setCambioEstadoModalOpen(true);
};
const handleCloseCambioEstadoModal = () => setCambioEstadoModalOpen(false);
const handleSubmitCambioEstadoModal = async (idBobina: number, data: CambiarEstadoBobinaDto) => { const handleSubmitCambioEstadoModal = async (idBobina: number, data: CambiarEstadoBobinaDto) => {
setApiErrorMessage(null); setApiErrorMessage(null);
try { await stockBobinaService.cambiarEstadoBobina(idBobina, data); cargarStock(); } try { await stockBobinaService.cambiarEstadoBobina(idBobina, data); cargarStock(); }
catch (err: any) { const msg = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al cambiar estado.'; setApiErrorMessage(msg); throw err; } catch (err: any) { const msg = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al cambiar estado.'; setApiErrorMessage(msg); throw err; }
}; };
const handleDeleteBobina = async (bobina: StockBobinaDto | null) => { const handleDeleteBobina = () => {
if (!bobina) return; if (!selectedBobinaForRowMenu) return;
if (bobina.idEstadoBobina !== ID_ESTADO_DISPONIBLE && bobina.idEstadoBobina !== ID_ESTADO_DANADA) {
if (selectedBobinaForRowMenu.idEstadoBobina !== ID_ESTADO_DISPONIBLE && selectedBobinaForRowMenu.idEstadoBobina !== ID_ESTADO_DANADA) {
alert("Solo se pueden eliminar bobinas en estado 'Disponible' o 'Dañada'."); alert("Solo se pueden eliminar bobinas en estado 'Disponible' o 'Dañada'.");
handleMenuClose(); handleMenuClose();
return; return;
} }
if (window.confirm(`¿Seguro de eliminar este ingreso de bobina (ID: ${bobina.idBobina})?`)) { if (window.confirm(`¿Seguro de eliminar este ingreso de bobina (ID: ${selectedBobinaForRowMenu.idBobina})?`)) {
setApiErrorMessage(null); setApiErrorMessage(null);
try { await stockBobinaService.deleteIngresoBobina(bobina.idBobina); cargarStock(); } stockBobinaService.deleteIngresoBobina(selectedBobinaForRowMenu.idBobina)
catch (err: any) { const msg = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al eliminar.'; setApiErrorMessage(msg); } .then(() => cargarStock())
.catch((err: any) => {
const msg = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al eliminar.';
setApiErrorMessage(msg);
});
} }
handleMenuClose(); handleMenuClose();
}; };
const handleMenuOpen = (event: React.MouseEvent<HTMLButtonElement>, bobina: StockBobinaDto) => { const handleSubmitFechaRemitoModal = async (data: UpdateFechaRemitoLoteDto) => {
setAnchorEl(event.currentTarget); setApiErrorMessage(null);
setSelectedBobinaForRowMenu(bobina); try {
lastOpenedMenuButtonRef.current = event.currentTarget; await stockBobinaService.actualizarFechaRemitoLote(data);
}; cargarStock();
const handleMenuClose = () => { } catch (err: any) {
setAnchorEl(null); const msg = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al actualizar la fecha del remito.';
setSelectedBobinaForRowMenu(null); setApiErrorMessage(msg);
if (lastOpenedMenuButtonRef.current) { throw err;
setTimeout(() => { lastOpenedMenuButtonRef.current?.focus(); }, 0);
} }
}; };
const handleChangePage = (_event: unknown, newPage: number) => setPage(newPage); // --- Handlers Menú Acciones ---
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => { const handleMenuOpen = (event: React.MouseEvent<HTMLButtonElement>, bobina: StockBobinaDto) => {
setRowsPerPage(parseInt(event.target.value, 10)); setPage(0); event.stopPropagation(); // Evitar selección de fila al abrir menú
setAnchorEl(event.currentTarget);
setSelectedBobinaForRowMenu(bobina);
}; };
const displayData = stock.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage);
const formatDate = (dateString?: string | null) => {
if (!dateString) return '-';
const date = new Date(dateString);
if (isNaN(date.getTime())) return '-';
const options: Intl.DateTimeFormatOptions = { const handleMenuClose = () => {
year: 'numeric', setAnchorEl(null);
month: '2-digit',
day: '2-digit',
timeZone: 'UTC'
};
return new Intl.DateTimeFormat('es-AR', options).format(date);
}; };
const handleOpenEditModal = () => { setEditModalOpen(true); handleMenuClose(); };
const handleOpenCambioEstadoModal = () => { setCambioEstadoModalOpen(true); handleMenuClose(); };
const handleOpenFechaRemitoModal = () => { setFechaRemitoModalOpen(true); handleMenuClose(); };
const handleCloseEditModal = () => { setEditModalOpen(false); setSelectedBobinaForRowMenu(null); };
const handleCloseCambioEstadoModal = () => { setCambioEstadoModalOpen(false); setSelectedBobinaForRowMenu(null); };
const handleCloseFechaRemitoModal = () => { setFechaRemitoModalOpen(false); setSelectedBobinaForRowMenu(null); };
// --- Definición de Columnas DataGrid ---
const columns = useMemo<GridColDef<StockBobinaDto>[]>(() => [
{ field: 'nroBobina', headerName: 'Nro. Bobina', width: 130 },
{ field: 'nombreTipoBobina', headerName: 'Tipo', width: 200, flex: 1 },
{ field: 'peso', headerName: 'Peso (Kg)', width: 100, align: 'right', headerAlign: 'right', type: 'number' },
{ field: 'nombrePlanta', headerName: 'Planta', width: 120 },
{
field: 'nombreEstadoBobina',
headerName: 'Estado',
width: 130,
renderCell: (params) => {
const idEstado = params.row.idEstadoBobina;
let color: "success" | "primary" | "error" | "default" = "default";
if (idEstado === ID_ESTADO_DISPONIBLE) color = "success";
else if (idEstado === ID_ESTADO_UTILIZADA) color = "primary";
else if (idEstado === ID_ESTADO_DANADA) color = "error";
return <Chip label={params.value} size="small" color={color} variant="outlined" />;
}
},
{ field: 'remito', headerName: 'Remito', width: 120 },
{
field: 'fechaRemito',
headerName: 'F. Remito',
width: 110,
type: 'date',
valueGetter: (value: string) => {
if (!value) return null;
const datePart = value.toString().split('T')[0];
const [year, month, day] = datePart.split('-');
return new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
},
valueFormatter: (value: Date) => {
return value ? value.toLocaleDateString('es-AR') : '-';
}
},
{
field: 'fechaEstado',
headerName: 'F. Estado',
width: 110,
type: 'date',
valueGetter: (value: string) => {
if (!value) return null;
const datePart = value.toString().split('T')[0];
const [year, month, day] = datePart.split('-');
return new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
},
valueFormatter: (value: Date) => {
return value ? value.toLocaleDateString('es-AR') : '-';
}
},
{ field: 'nombrePublicacion', headerName: 'Publicación', width: 150 },
{ field: 'nombreSeccion', headerName: 'Sección', width: 120 },
{ field: 'obs', headerName: 'Obs.', width: 200, flex: 1 },
{
field: 'acciones',
headerName: 'Acciones',
width: 80,
sortable: false,
filterable: false,
align: 'right',
renderCell: (params: GridRenderCellParams<StockBobinaDto>) => {
const b = params.row;
const disabled = !(puedeModificarDatos) &&
!(puedeCambiarEstado) &&
!((b.idEstadoBobina === ID_ESTADO_DISPONIBLE || b.idEstadoBobina === ID_ESTADO_DANADA) && puedeEliminar);
if (disabled) return null;
return (
<IconButton onClick={(e) => handleMenuOpen(e, b)} size="small">
<MoreVertIcon fontSize="small" />
</IconButton>
);
}
}
], [puedeModificarDatos, puedeCambiarEstado, puedeEliminar]);
if (!puedeVer) return <Box sx={{ p: 2 }}><Alert severity="error">{error || "Acceso denegado."}</Alert></Box>; if (!puedeVer) return <Box sx={{ p: 2 }}><Alert severity="error">{error || "Acceso denegado."}</Alert></Box>;
return ( return (
<Box sx={{ p: 1 }}> <Box sx={{ p: 2 }}>
<Typography variant="h5" gutterBottom>Stock de Bobinas</Typography> <Typography variant="h5" gutterBottom>Stock de Bobinas</Typography>
{/* Panel de Filtros */}
<Paper sx={{ p: 2, mb: 2 }}> <Paper sx={{ p: 2, mb: 2 }}>
<Typography variant="h6" gutterBottom>Filtros</Typography> <Typography variant="h6" gutterBottom>Filtros</Typography>
{/* Fila 1: Filtros generales */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, mb: 2 }}> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, mb: 2 }}>
<FormControl size="small" sx={{ minWidth: 180, flexGrow: 1 }}> <FormControl size="small" sx={{ minWidth: 180, flexGrow: 1 }}>
<InputLabel>Tipo Bobina</InputLabel> <InputLabel>Tipo Bobina</InputLabel>
@@ -274,128 +369,123 @@ const GestionarStockBobinasPage: React.FC = () => {
</FormControl> </FormControl>
<TextField label="Remito" size="small" value={filtroRemito} onChange={(e) => setFiltroRemito(e.target.value)} sx={{ minWidth: 150, flexGrow: 1 }} /> <TextField label="Remito" size="small" value={filtroRemito} onChange={(e) => setFiltroRemito(e.target.value)} sx={{ minWidth: 150, flexGrow: 1 }} />
</Box> </Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, mb: 2 }}>
{/* Fila 2: Filtros de Fechas */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 4, mb: 2, alignItems: 'center' }}>
{/* Fechas Remito */}
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', border: '1px dashed #ccc', p: 1, borderRadius: 1 }}>
<FormControlLabel <FormControlLabel
control={<Checkbox checked={filtroFechaHabilitado} onChange={(e) => setFiltroFechaHabilitado(e.target.checked)} />} control={<Checkbox checked={filtroFechaHabilitado} onChange={(e) => setFiltroFechaHabilitado(e.target.checked)} />}
label="Filtrar por Fechas de Remitos" label="Filtrar por Fecha Remito"
/> />
<TextField label="Fecha Desde" type="date" size="small" value={filtroFechaDesde} onChange={(e) => setFiltroFechaDesde(e.target.value)} InputLabelProps={{ shrink: true }} sx={{ minWidth: 170 }} disabled={!filtroFechaHabilitado} /> <TextField label="Desde" type="date" size="small" value={filtroFechaDesde} onChange={(e) => setFiltroFechaDesde(e.target.value)} InputLabelProps={{ shrink: true }} sx={{ minWidth: 140 }} disabled={!filtroFechaHabilitado} />
<TextField label="Fecha Hasta" type="date" size="small" value={filtroFechaHasta} onChange={(e) => setFiltroFechaHasta(e.target.value)} InputLabelProps={{ shrink: true }} sx={{ minWidth: 170 }} disabled={!filtroFechaHabilitado} /> <TextField label="Hasta" type="date" size="small" value={filtroFechaHasta} onChange={(e) => setFiltroFechaHasta(e.target.value)} InputLabelProps={{ shrink: true }} sx={{ minWidth: 140 }} disabled={!filtroFechaHabilitado} />
</Box> </Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
gap: 2,
mb: 2,
justifyContent: 'flex-end'
}}
>
<Button variant="contained" startIcon={<SearchIcon />} onClick={handleBuscarClick} disabled={loading}>Buscar</Button>
<Button variant="outlined" startIcon={<ClearIcon />} onClick={handleLimpiarFiltros} disabled={loading}>Limpiar Filtros</Button>
</Box>
{puedeIngresar && (<Button variant="contained" startIcon={<AddIcon />} onClick={handleOpenIngresoModal} sx={{ ml: 'auto' }}>Ingresar Bobina</Button>)}
{/* Fechas Estado (Nuevo) */}
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', border: '1px dashed #ccc', p: 1, borderRadius: 1 }}>
<FormControlLabel
control={<Checkbox checked={filtroFechaEstadoHabilitado} onChange={(e) => setFiltroFechaEstadoHabilitado(e.target.checked)} />}
label="Filtrar por Fecha Estado"
/>
<TextField label="Desde" type="date" size="small" value={filtroFechaEstadoDesde} onChange={(e) => setFiltroFechaEstadoDesde(e.target.value)} InputLabelProps={{ shrink: true }} sx={{ minWidth: 140 }} disabled={!filtroFechaEstadoHabilitado} />
<TextField label="Hasta" type="date" size="small" value={filtroFechaEstadoHasta} onChange={(e) => setFiltroFechaEstadoHasta(e.target.value)} InputLabelProps={{ shrink: true }} sx={{ minWidth: 140 }} disabled={!filtroFechaEstadoHabilitado} />
</Box>
</Box>
{/* Botones de acción del filtro */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 2, mt: 2 }}>
<Box sx={{ display: 'flex', gap: 2 }}>
<Button variant="contained" startIcon={<SearchIcon />} onClick={handleBuscarClick} disabled={loading}>
Buscar
</Button>
<Button variant="outlined" startIcon={<ClearIcon />} onClick={handleLimpiarFiltros} disabled={loading}>
Limpiar Filtros
</Button>
</Box>
{puedeIngresar && (
<Box sx={{ display: 'flex', gap: 2 }}>
<Button variant="contained" color="secondary" startIcon={<AddIcon />} onClick={() => setLoteModalOpen(true)}>
Ingreso por Remito (Lote)
</Button>
</Box>
)}
</Box>
</Paper> </Paper>
{loading && <Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}><CircularProgress /></Box>}
{error && !loading && <Alert severity="warning" sx={{ my: 2 }}>{error}</Alert>} {error && !loading && <Alert severity="warning" sx={{ my: 2 }}>{error}</Alert>}
{apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{apiErrorMessage}</Alert>} {apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{apiErrorMessage}</Alert>}
{!loading && !error && ( {/* Tabla DataGrid */}
<TableContainer component={Paper}> <Paper sx={{ width: '100%', height: 600 }}>
<Table size="small"> <DataGrid
<TableHead><TableRow> rows={stock}
<TableCell>Nro. Bobina</TableCell><TableCell>Tipo</TableCell><TableCell>Peso (Kg)</TableCell> columns={columns}
<TableCell>Planta</TableCell><TableCell>Estado</TableCell><TableCell>Remito</TableCell> getRowId={(row) => row.idBobina} // Importante: especificar el ID único
<TableCell>F. Remito</TableCell><TableCell>F. Estado</TableCell> loading={loading}
<TableCell>Publicación</TableCell><TableCell>Sección</TableCell> localeText={esES.components.MuiDataGrid.defaultProps.localeText}
<TableCell>Obs.</TableCell> density="compact"
{(puedeModificarDatos || puedeCambiarEstado || puedeEliminar) && <TableCell align="right">Acciones</TableCell>} disableRowSelectionOnClick
</TableRow></TableHead> initialState={{
<TableBody> pagination: { paginationModel: { pageSize: 25 } },
{displayData.length === 0 ? ( }}
<TableRow><TableCell colSpan={(puedeModificarDatos || puedeCambiarEstado || puedeEliminar) ? 12 : 11} align="center">No se encontraron bobinas con los filtros aplicados. Haga clic en "Buscar" para iniciar una consulta.</TableCell></TableRow> pageSizeOptions={[25, 50, 100]}
) : ( sx={{ border: 0 }}
displayData.map((b) => (
<TableRow key={b.idBobina} hover>
<TableCell>{b.nroBobina}</TableCell><TableCell>{b.nombreTipoBobina}</TableCell>
<TableCell align="right">{b.peso}</TableCell><TableCell>{b.nombrePlanta}</TableCell>
<TableCell><Chip label={b.nombreEstadoBobina} size="small" color={
b.idEstadoBobina === ID_ESTADO_DISPONIBLE ? "success" : b.idEstadoBobina === ID_ESTADO_UTILIZADA ? "primary" : b.idEstadoBobina === ID_ESTADO_DANADA ? "error" : "default"
} /></TableCell>
<TableCell>{b.remito}</TableCell><TableCell>{formatDate(b.fechaRemito)}</TableCell>
<TableCell>{formatDate(b.fechaEstado)}</TableCell>
<TableCell>{b.nombrePublicacion || '-'}</TableCell><TableCell>{b.nombreSeccion || '-'}</TableCell>
<TableCell>{b.obs || '-'}</TableCell>
{(puedeModificarDatos || puedeCambiarEstado || puedeEliminar) && (
<TableCell align="right">
<IconButton onClick={(e) => handleMenuOpen(e, b)}
disabled={
!(b.idEstadoBobina === ID_ESTADO_DISPONIBLE && puedeModificarDatos) &&
!(puedeCambiarEstado) &&
!((b.idEstadoBobina === ID_ESTADO_DISPONIBLE || b.idEstadoBobina === ID_ESTADO_DANADA) && puedeEliminar)
}
><MoreVertIcon /></IconButton>
</TableCell>
)}
</TableRow>
)))}
</TableBody>
</Table>
<TablePagination
rowsPerPageOptions={[25, 50, 100]} component="div" count={stock.length}
rowsPerPage={rowsPerPage} page={page} onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage} labelRowsPerPage="Filas por página:"
/> />
</TableContainer> </Paper>
)}
{/* Menú Contextual de Fila */}
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}> <Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}>
{selectedBobinaForRowMenu && puedeModificarDatos && (
<MenuItem onClick={handleOpenFechaRemitoModal}>
<EditCalendarIcon fontSize="small" sx={{ mr: 1 }} /> Corregir Fecha Remito
</MenuItem>
)}
{selectedBobinaForRowMenu && puedeModificarDatos && selectedBobinaForRowMenu.idEstadoBobina === ID_ESTADO_DISPONIBLE && ( {selectedBobinaForRowMenu && puedeModificarDatos && selectedBobinaForRowMenu.idEstadoBobina === ID_ESTADO_DISPONIBLE && (
<MenuItem onClick={() => { handleOpenEditModal(selectedBobinaForRowMenu); handleMenuClose(); }}> <MenuItem onClick={handleOpenEditModal}>
<EditIcon fontSize="small" sx={{ mr: 1 }} /> Editar Datos <EditIcon fontSize="small" sx={{ mr: 1 }} /> Editar Datos Bobina
</MenuItem> </MenuItem>
)} )}
{selectedBobinaForRowMenu && puedeCambiarEstado && ( {selectedBobinaForRowMenu && puedeCambiarEstado && (
<MenuItem onClick={() => { handleOpenCambioEstadoModal(selectedBobinaForRowMenu); handleMenuClose(); }}> <MenuItem onClick={handleOpenCambioEstadoModal}>
<SwapHorizIcon fontSize="small" sx={{ mr: 1 }} /> Cambiar Estado <SwapHorizIcon fontSize="small" sx={{ mr: 1 }} /> Cambiar Estado
</MenuItem> </MenuItem>
)} )}
{selectedBobinaForRowMenu && puedeEliminar && {selectedBobinaForRowMenu && puedeEliminar &&
(selectedBobinaForRowMenu.idEstadoBobina === ID_ESTADO_DISPONIBLE || selectedBobinaForRowMenu.idEstadoBobina === ID_ESTADO_DANADA) && ( (selectedBobinaForRowMenu.idEstadoBobina === ID_ESTADO_DISPONIBLE || selectedBobinaForRowMenu.idEstadoBobina === ID_ESTADO_DANADA) && (
<MenuItem onClick={() => handleDeleteBobina(selectedBobinaForRowMenu)}> <MenuItem onClick={handleDeleteBobina}>
<DeleteIcon fontSize="small" sx={{ mr: 1 }} /> Eliminar Ingreso <DeleteIcon fontSize="small" sx={{ mr: 1 }} /> Eliminar Ingreso
</MenuItem> </MenuItem>
)} )}
{selectedBobinaForRowMenu &&
!((selectedBobinaForRowMenu.idEstadoBobina === ID_ESTADO_DISPONIBLE && puedeModificarDatos)) &&
!(puedeCambiarEstado) &&
!(((selectedBobinaForRowMenu.idEstadoBobina === ID_ESTADO_DISPONIBLE || selectedBobinaForRowMenu.idEstadoBobina === ID_ESTADO_DANADA) && puedeEliminar)) &&
<MenuItem disabled>Sin acciones disponibles</MenuItem>
}
</Menu> </Menu>
{/* Modales sin cambios */} {/* Modales */}
<StockBobinaIngresoFormModal <StockBobinaIngresoFormModal
open={ingresoModalOpen} onClose={handleCloseIngresoModal} onSubmit={handleSubmitIngresoModal} open={ingresoModalOpen} onClose={handleCloseIngresoModal} onSubmit={handleSubmitIngresoModal}
errorMessage={apiErrorMessage} clearErrorMessage={() => setApiErrorMessage(null)} errorMessage={apiErrorMessage} clearErrorMessage={() => setApiErrorMessage(null)}
/> />
{editModalOpen && selectedBobina &&
<StockBobinaEditFormModal <StockBobinaEditFormModal
open={editModalOpen} onClose={handleCloseEditModal} onSubmit={handleSubmitEditModal} open={editModalOpen} onClose={handleCloseEditModal} onSubmit={handleSubmitEditModal}
initialData={selectedBobina} errorMessage={apiErrorMessage} initialData={selectedBobinaForRowMenu} errorMessage={apiErrorMessage}
clearErrorMessage={() => setApiErrorMessage(null)} clearErrorMessage={() => setApiErrorMessage(null)}
/> />
}
{cambioEstadoModalOpen && selectedBobina &&
<StockBobinaCambioEstadoModal <StockBobinaCambioEstadoModal
open={cambioEstadoModalOpen} onClose={handleCloseCambioEstadoModal} onSubmit={handleSubmitCambioEstadoModal} open={cambioEstadoModalOpen} onClose={handleCloseCambioEstadoModal} onSubmit={handleSubmitCambioEstadoModal}
bobinaActual={selectedBobina} errorMessage={apiErrorMessage} bobinaActual={selectedBobinaForRowMenu} errorMessage={apiErrorMessage}
clearErrorMessage={() => setApiErrorMessage(null)}
/>
<StockBobinaLoteFormModal
open={loteModalOpen}
onClose={handleLoteModalClose}
/>
<StockBobinaFechaRemitoModal
open={fechaRemitoModalOpen}
onClose={handleCloseFechaRemitoModal}
onSubmit={handleSubmitFechaRemitoModal}
bobinaContexto={selectedBobinaForRowMenu}
errorMessage={apiErrorMessage}
clearErrorMessage={() => setApiErrorMessage(null)} clearErrorMessage={() => setApiErrorMessage(null)}
/> />
}
</Box> </Box>
); );
}; };

View File

@@ -63,8 +63,9 @@ const ReporteCuentasDistribuidoresPage: React.FC = () => {
}); });
}; };
const movs = procesarLista(data.entradasSalidas, 'mov', 0); const baseInicial = data.saldoInicial ?? 0;
const ultimoMov = movs.length ? movs[movs.length - 1].saldoAcumulado : 0; const movs = procesarLista(data.entradasSalidas, 'mov', baseInicial);
const ultimoMov = movs.length ? movs[movs.length - 1].saldoAcumulado : baseInicial;
const notas = procesarLista(data.debitosCreditos, 'nota', ultimoMov); const notas = procesarLista(data.debitosCreditos, 'nota', ultimoMov);
const ultimoNota = notas.length ? notas[notas.length - 1].saldoAcumulado : ultimoMov; const ultimoNota = notas.length ? notas[notas.length - 1].saldoAcumulado : ultimoMov;
const pagos = procesarLista(data.pagos, 'pago', ultimoNota); const pagos = procesarLista(data.pagos, 'pago', ultimoNota);
@@ -210,7 +211,7 @@ const ReporteCuentasDistribuidoresPage: React.FC = () => {
<GridFooter sx={{ borderTop: 'none' }} /> <GridFooter sx={{ borderTop: 'none' }} />
<Box sx={{ p: 1, fontWeight: 'bold' }}> <Box sx={{ p: 1, fontWeight: 'bold' }}>
<Typography variant="subtitle2" component="span" sx={{ mr: 2 }}> <Typography variant="subtitle2" component="span" sx={{ mr: 2 }}>
TOTA DEBE: <strong>{totalDebe.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}</strong> TOTAL DEBE: <strong>{totalDebe.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}</strong>
</Typography> </Typography>
<Typography variant="subtitle2" component="span"> <Typography variant="subtitle2" component="span">
TOTAL HABER: <strong>{totalHaber.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}</strong> TOTAL HABER: <strong>{totalHaber.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}</strong>
@@ -284,39 +285,44 @@ const ReporteCuentasDistribuidoresPage: React.FC = () => {
}, []); }, []);
const handleExportToExcel = useCallback(() => { const handleExportToExcel = useCallback(() => {
if ( if (!originalReportData) {
!originalReportData || alert('No hay datos para exportar.');
(movimientosConSaldo.length === 0 &&
notasConSaldo.length === 0 &&
pagosConSaldo.length === 0)
) {
alert("No hay datos para exportar."); // O un mensaje más amigable
return; return;
} }
const wb = XLSX.utils.book_new();// Se crea un nuevo libro const sumDebe = (rows: Array<{ debe?: number }>) => rows.reduce((s, r) => s + (r.debe || 0), 0);
const sumHaber = (rows: Array<{ haber?: number }>) => rows.reduce((s, r) => s + (r.haber || 0), 0);
// Movimientos const totalDebeAll = sumDebe(movimientosConSaldo) + sumDebe(notasConSaldo) + sumDebe(pagosConSaldo);
if (movimientosConSaldo.length) { // <--- CHEQUEO 1 const totalHaberAll = sumHaber(movimientosConSaldo) + sumHaber(notasConSaldo) + sumHaber(pagosConSaldo);
// Si movimientosConSaldo está vacío, esta hoja no se añade const saldoInicialExp = originalReportData.saldoInicial ?? 0;
const saldoFinalExp = saldoInicialExp + totalDebeAll - totalHaberAll;
const wb = XLSX.utils.book_new();
// Hoja Resumen — siempre presente, con saldo inicial y final aunque no haya movimientos
const resumenRows = [
{ Concepto: 'Saldo Inicial', Monto: saldoInicialExp },
{ Concepto: 'Total Debe', Monto: totalDebeAll },
{ Concepto: 'Total Haber', Monto: totalHaberAll },
{ Concepto: 'Saldo Final', Monto: saldoFinalExp },
];
const wsResumen = XLSX.utils.json_to_sheet(resumenRows);
XLSX.utils.book_append_sheet(wb, wsResumen, 'Resumen');
if (movimientosConSaldo.length) {
const ws = XLSX.utils.json_to_sheet(movimientosConSaldo.map(({ id, ...rest }) => rest)); const ws = XLSX.utils.json_to_sheet(movimientosConSaldo.map(({ id, ...rest }) => rest));
XLSX.utils.book_append_sheet(wb, ws, 'Movimientos'); XLSX.utils.book_append_sheet(wb, ws, 'Movimientos');
} }
// Notas if (notasConSaldo.length) {
if (notasConSaldo.length) { // <--- CHEQUEO 2
// Si notasConSaldo está vacío, esta hoja no se añade
const ws = XLSX.utils.json_to_sheet(notasConSaldo.map(({ id, ...rest }) => rest)); const ws = XLSX.utils.json_to_sheet(notasConSaldo.map(({ id, ...rest }) => rest));
XLSX.utils.book_append_sheet(wb, ws, 'Notas'); XLSX.utils.book_append_sheet(wb, ws, 'Notas');
} }
// Pagos if (pagosConSaldo.length) {
if (pagosConSaldo.length) { // <--- CHEQUEO 3
// Si pagosConSaldo está vacío, esta hoja no se añade
const ws = XLSX.utils.json_to_sheet(pagosConSaldo.map(({ id, ...rest }) => rest)); const ws = XLSX.utils.json_to_sheet(pagosConSaldo.map(({ id, ...rest }) => rest));
XLSX.utils.book_append_sheet(wb, ws, 'Pagos'); XLSX.utils.book_append_sheet(wb, ws, 'Pagos');
} }
// Si ninguno de los arrays tiene datos, el libro 'wb' quedará vacío.
// Y la siguiente línea dará el error:
XLSX.writeFile(wb, `Reporte_${Date.now()}.xlsx`); XLSX.writeFile(wb, `Reporte_${Date.now()}.xlsx`);
}, [originalReportData, movimientosConSaldo, notasConSaldo, pagosConSaldo]); }, [originalReportData, movimientosConSaldo, notasConSaldo, pagosConSaldo]);
@@ -355,7 +361,8 @@ const ReporteCuentasDistribuidoresPage: React.FC = () => {
const totalMov = movimientosConSaldo.reduce((sum, r) => sum + ((r.debe || 0) - (r.haber || 0)), 0); const totalMov = movimientosConSaldo.reduce((sum, r) => sum + ((r.debe || 0) - (r.haber || 0)), 0);
const totalNot = notasConSaldo.reduce((sum, r) => sum + ((r.debe || 0) - (r.haber || 0)), 0); const totalNot = notasConSaldo.reduce((sum, r) => sum + ((r.debe || 0) - (r.haber || 0)), 0);
const totalPag = pagosConSaldo.reduce((sum, r) => sum + ((r.debe || 0) - (r.haber || 0)), 0); const totalPag = pagosConSaldo.reduce((sum, r) => sum + ((r.debe || 0) - (r.haber || 0)), 0);
const saldoInicial = originalReportData?.saldos?.[0]?.monto || 0; const saldoInicial = originalReportData?.saldoInicial ?? 0;
const saldoFinal = saldoInicial + totalMov + totalNot + totalPag;
const cols = generarColumns(); const cols = generarColumns();
@@ -385,7 +392,7 @@ const ReporteCuentasDistribuidoresPage: React.FC = () => {
<Typography>Distribuidor: <strong>{currentParams?.nombreDistribuidor}</strong></Typography> <Typography>Distribuidor: <strong>{currentParams?.nombreDistribuidor}</strong></Typography>
<Typography>Empresa: <strong>{currentParams?.nombreEmpresa}</strong></Typography> <Typography>Empresa: <strong>{currentParams?.nombreEmpresa}</strong></Typography>
<Typography>Período: <strong>{currentParams?.fechaDesde}</strong> al <strong>{currentParams?.fechaHasta}</strong></Typography> <Typography>Período: <strong>{currentParams?.fechaDesde}</strong> al <strong>{currentParams?.fechaHasta}</strong></Typography>
<Typography>Saldo a la Fecha {new Date().toLocaleDateString('es-AR')}: <strong>{saldoInicial.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}</strong></Typography> <Typography>Saldo Inicial del Período: <strong>{saldoInicial.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}</strong></Typography>
</Paper> </Paper>
<Typography variant="h6" sx={{ mt: 2 }}>Movimientos de Entrada / Salida</Typography> <Typography variant="h6" sx={{ mt: 2 }}>Movimientos de Entrada / Salida</Typography>
@@ -399,11 +406,12 @@ const ReporteCuentasDistribuidoresPage: React.FC = () => {
<Paper sx={{ p: 2, mt: 3 }}> <Paper sx={{ p: 2, mt: 3 }}>
<Typography variant="h6">Resumen Final</Typography> <Typography variant="h6">Resumen Final</Typography>
<Typography>Saldo Inicial: <strong>{saldoInicial.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}</strong></Typography>
<Typography>Movimientos (Debe - Haber): {totalMov.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}</Typography> <Typography>Movimientos (Debe - Haber): {totalMov.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}</Typography>
<Typography>Notas C/D (Debe - Haber): {totalNot.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}</Typography> <Typography>Notas C/D (Debe - Haber): {totalNot.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}</Typography>
<Typography>Pagos (Debe - Haber): {totalPag.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}</Typography> <Typography>Pagos (Debe - Haber): {totalPag.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}</Typography>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mt: 1 }}> <Typography variant="subtitle1" sx={{ fontWeight: 'bold', mt: 1 }}>
Saldo Final del Período: {(totalMov + totalNot + totalPag).toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })} Saldo Final: {saldoFinal.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}
</Typography> </Typography>
</Paper> </Paper>
</Box> </Box>

View File

@@ -123,32 +123,31 @@ const ReporteListadoDistribucionCanillasPage: React.FC = () => {
// --- Cálculos para promedios y sus totales --- // --- Cálculos para promedios y sus totales ---
const promediosCalculadoLocal = data.promediosPorDia.map((item, index) => { const promediosCalculadoLocal = data.promediosPorDia.map((item, index) => {
const promLlevados = item.promedio_Llevados || 0; const promLlevados = item.promedio_Llevados || 0;
const promDevueltos = item.promedio_Devueltos || 0;
const promVentas = item.promedio_Ventas || 0; const promVentas = item.promedio_Ventas || 0;
return { return {
...item, ...item,
id: `prom-can-${index}`, // o prom-dist-${index} id: `prom-can-${index}`,
// LA COLUMNA EN EL PDF SE LLAMA "% Devolución" PERO PARECE SER "% VENTA"
porcentajeColumnaPDF: promLlevados > 0 ? (promVentas / promLlevados) * 100 : 0, porcentajeColumnaPDF: promLlevados > 0 ? (promVentas / promLlevados) * 100 : 0,
porcentajeDevolucion: promLlevados > 0 ? (promVentas / promLlevados) * 100 : 0, porcentajeDevolucion: promLlevados > 0 ? (promDevueltos / promLlevados) * 100 : 0,
}; };
}); });
setPromediosPorDiaCalculado(promediosCalculadoLocal); setPromediosPorDiaCalculado(promediosCalculadoLocal);
const totalDiasProm = promediosCalculadoLocal.reduce((sum, item) => sum + (item.cant || 0), 0); const totalDiasProm = promediosCalculadoLocal.reduce((sum, item) => sum + (item.cant || 0), 0);
const totalPonderadoLlevados = promediosCalculadoLocal.reduce((sum, item) => sum + ((item.promedio_Llevados || 0) * (item.cant || 0)), 0); const totalPonderadoLlevados = promediosCalculadoLocal.reduce((sum, item) => sum + ((item.promedio_Llevados || 0) * (item.cant || 0)), 0);
// const totalPonderadoDevueltos = promediosCalculadoLocal.reduce((sum, item) => sum + ((item.promedio_Devueltos || 0) * (item.cant || 0)), 0); // No se usa para el % del PDF const totalPonderadoDevueltos = promediosCalculadoLocal.reduce((sum, item) => sum + ((item.promedio_Devueltos || 0) * (item.cant || 0)), 0);
const totalPonderadoVentas = promediosCalculadoLocal.reduce((sum, item) => sum + ((item.promedio_Ventas || 0) * (item.cant || 0)), 0); const totalPonderadoVentas = promediosCalculadoLocal.reduce((sum, item) => sum + ((item.promedio_Ventas || 0) * (item.cant || 0)), 0);
const promGeneralLlevados = totalDiasProm > 0 ? totalPonderadoLlevados / totalDiasProm : 0;
const promGeneralDevueltos = totalDiasProm > 0 ? totalPonderadoDevueltos / totalDiasProm : 0;
setTotalesPromedios({ setTotalesPromedios({
cantDias: totalDiasProm, cantDias: totalDiasProm,
promLlevados: totalDiasProm > 0 ? totalPonderadoLlevados / totalDiasProm : 0, promLlevados: promGeneralLlevados,
promDevueltos: totalDiasProm > 0 ? promediosCalculadoLocal.reduce((sum, item) => sum + (item.promedio_Devueltos || 0), 0) / promediosCalculadoLocal.length :0, // Promedio simple para mostrar promDevueltos: promGeneralDevueltos,
promVentas: totalDiasProm > 0 ? totalPonderadoVentas / totalDiasProm : 0, promVentas: totalDiasProm > 0 ? totalPonderadoVentas / totalDiasProm : 0,
// Para la fila "General" de promedios, el PDF usa (Total Prom. Ventas / Total Prom. Llevados) * 100 porcentajeDevolucionGeneral: promGeneralLlevados > 0 ? (promGeneralDevueltos / promGeneralLlevados) * 100 : 0,
// Usaremos los promedios generales calculados aquí
porcentajeDevolucionGeneral: (totalDiasProm > 0 && (totalPonderadoLlevados / totalDiasProm) > 0)
? ((totalPonderadoVentas / totalDiasProm) / (totalPonderadoLlevados / totalDiasProm)) * 100
: 0,
}); });
setReportData({ detalleSimple: detalleCalculadoLocal, promediosPorDia: promediosCalculadoLocal }); setReportData({ detalleSimple: detalleCalculadoLocal, promediosPorDia: promediosCalculadoLocal });

View File

@@ -121,20 +121,24 @@ const ReporteListadoDistribucionPage: React.FC = () => {
porcentajeDevolucion: item.promedio_Llevados > 0 ? (item.promedio_Devueltos / item.promedio_Llevados) * 100 : 0, porcentajeDevolucion: item.promedio_Llevados > 0 ? (item.promedio_Devueltos / item.promedio_Llevados) * 100 : 0,
})); }));
// Calcular totales para la tabla de promedios (ponderados por Cant. Días)
const totalDiasPromedios = promediosConCalculos.reduce((sum, item) => sum + (item.cant || 0), 0); const totalDiasPromedios = promediosConCalculos.reduce((sum, item) => sum + (item.cant || 0), 0);
const totalPonderadoLlevados = promediosConCalculos.reduce((sum, item) => sum + ((item.promedio_Llevados || 0) * (item.cant || 0)), 0); const countPromedios = promediosConCalculos.length;
const totalPonderadoDevueltos = promediosConCalculos.reduce((sum, item) => sum + ((item.promedio_Devueltos || 0) * (item.cant || 0)), 0);
const totalPonderadoVentas = promediosConCalculos.reduce((sum, item) => sum + ((item.promedio_Ventas || 0) * (item.cant || 0)), 0); // LÓGICA DE PROMEDIO DE PROMEDIOS
if (countPromedios > 0) {
const sumPromLlevados = promediosConCalculos.reduce((sum, item) => sum + (item.promedio_Llevados || 0), 0);
const sumPromDevueltos = promediosConCalculos.reduce((sum, item) => sum + (item.promedio_Devueltos || 0), 0);
const sumPromVentas = promediosConCalculos.reduce((sum, item) => sum + (item.promedio_Ventas || 0), 0);
const sumPorcDevolucion = promediosConCalculos.reduce((sum, item) => sum + (item.porcentajeDevolucion || 0), 0);
setTotalesPromedios({ setTotalesPromedios({
cantDias: totalDiasPromedios, cantDias: totalDiasPromedios,
promLlevados: totalDiasPromedios > 0 ? totalPonderadoLlevados / totalDiasPromedios : 0, promLlevados: sumPromLlevados / countPromedios,
promDevueltos: totalDiasPromedios > 0 ? totalPonderadoDevueltos / totalDiasPromedios : 0, promDevueltos: sumPromDevueltos / countPromedios,
promVentas: totalDiasPromedios > 0 ? totalPonderadoVentas / totalDiasPromedios : 0, promVentas: sumPromVentas / countPromedios,
porcentajeDevolucionGeneral: totalPonderadoLlevados > 0 ? (totalPonderadoDevueltos / totalPonderadoLlevados) * 100 : 0, porcentajeDevolucionGeneral: sumPorcDevolucion / countPromedios,
}); });
}
setReportData({ detalleSimple: detalleConCalculos, promediosPorDia: promediosConCalculos }); setReportData({ detalleSimple: detalleConCalculos, promediosPorDia: promediosConCalculos });

View File

@@ -1,12 +1,14 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
Box, Typography, TextField, Button, CircularProgress, Alert, Box, Typography, TextField, Button, CircularProgress, Alert,
FormControl, InputLabel, Select, MenuItem FormControl, InputLabel, Select, MenuItem, Tooltip
} from '@mui/material'; } from '@mui/material';
import EventAvailableIcon from '@mui/icons-material/EventAvailable';
import type { DistribuidorDropdownDto } from '../../models/dtos/Distribucion/DistribuidorDropdownDto'; import type { DistribuidorDropdownDto } from '../../models/dtos/Distribucion/DistribuidorDropdownDto';
import distribuidorService from '../../services/Distribucion/distribuidorService'; import distribuidorService from '../../services/Distribucion/distribuidorService';
import type { EmpresaDropdownDto } from '../../models/dtos/Distribucion/EmpresaDropdownDto'; import type { EmpresaDropdownDto } from '../../models/dtos/Distribucion/EmpresaDropdownDto';
import empresaService from '../../services/Distribucion/empresaService'; import empresaService from '../../services/Distribucion/empresaService';
import cierresCcService from '../../services/Contables/cierresCcService';
interface SeleccionaReporteCuentasDistribuidoresProps { interface SeleccionaReporteCuentasDistribuidoresProps {
onGenerarReporte: (params: { onGenerarReporte: (params: {
@@ -20,6 +22,18 @@ interface SeleccionaReporteCuentasDistribuidoresProps {
apiErrorMessage?: string | null; apiErrorMessage?: string | null;
} }
// Suma 1 día a una fecha en formato yyyy-MM-dd y devuelve la fecha resultante
// también en yyyy-MM-dd. Usado por el atajo "Desde último cierre" para arrancar
// el reporte el día siguiente al cierre.
const addOneDay = (yyyyMmDd: string): string => {
const d = new Date(yyyyMmDd + 'T00:00:00');
d.setDate(d.getDate() + 1);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const SeleccionaReporteCuentasDistribuidores: React.FC<SeleccionaReporteCuentasDistribuidoresProps> = ({ const SeleccionaReporteCuentasDistribuidores: React.FC<SeleccionaReporteCuentasDistribuidoresProps> = ({
onGenerarReporte, onGenerarReporte,
isLoading, isLoading,
@@ -35,15 +49,19 @@ const SeleccionaReporteCuentasDistribuidores: React.FC<SeleccionaReporteCuentasD
const [loadingDropdowns, setLoadingDropdowns] = useState(false); const [loadingDropdowns, setLoadingDropdowns] = useState(false);
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
const [loadingUltimoCierre, setLoadingUltimoCierre] = useState(false);
const [hayCierrePrevio, setHayCierrePrevio] = useState<boolean | null>(null);
const [infoCierre, setInfoCierre] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
setLoadingDropdowns(true); setLoadingDropdowns(true);
try { try {
const [distData, empData] = await Promise.all([ const [distData, empData] = await Promise.all([
distribuidorService.getAllDistribuidoresDropdown(), // Asume que este servicio existe distribuidorService.getAllDistribuidoresDropdown(),
empresaService.getEmpresasDropdown() // Asume que este servicio existe empresaService.getEmpresasDropdown()
]); ]);
setDistribuidores(distData.map(d => d)); // El servicio devuelve tupla setDistribuidores(distData.map(d => d));
setEmpresas(empData); setEmpresas(empData);
} catch (error) { } catch (error) {
console.error("Error al cargar datos:", error); console.error("Error al cargar datos:", error);
@@ -55,6 +73,13 @@ const SeleccionaReporteCuentasDistribuidores: React.FC<SeleccionaReporteCuentasD
fetchData(); fetchData();
}, []); }, []);
// Reset del estado del atajo cuando cambian distribuidor/empresa: el "ultimo cierre"
// depende del par (distribuidor, empresa), si alguno cambia hay que volver a chequear.
useEffect(() => {
setHayCierrePrevio(null);
setInfoCierre(null);
}, [idDistribuidor, idEmpresa]);
const validate = (): boolean => { const validate = (): boolean => {
const errors: { [key: string]: string | null } = {}; const errors: { [key: string]: string | null } = {};
if (!idDistribuidor) errors.idDistribuidor = 'Debe seleccionar un distribuidor.'; if (!idDistribuidor) errors.idDistribuidor = 'Debe seleccionar un distribuidor.';
@@ -78,6 +103,37 @@ const SeleccionaReporteCuentasDistribuidores: React.FC<SeleccionaReporteCuentasD
}); });
}; };
const handleDesdeUltimoCierre = async () => {
if (!idDistribuidor || !idEmpresa) return;
setLoadingUltimoCierre(true);
setInfoCierre(null);
try {
const ultimo = await cierresCcService.getUltimoCierre(Number(idDistribuidor), Number(idEmpresa));
if (ultimo === null) {
setHayCierrePrevio(false);
setInfoCierre('Sin cierres previos para este distribuidor y empresa.');
} else {
const nuevaFechaDesde = addOneDay(ultimo.fechaCorte);
setFechaDesde(nuevaFechaDesde);
setLocalErrors(p => ({ ...p, fechaDesde: null, fechaHasta: null }));
setHayCierrePrevio(true);
setInfoCierre(`Último cierre: ${ultimo.fechaCorte}. Fecha Desde ajustada al día siguiente.`);
}
} catch (err) {
console.error('Error al obtener último cierre:', err);
setInfoCierre('Error al consultar el último cierre.');
} finally {
setLoadingUltimoCierre(false);
}
};
const atajoDisabled = !idDistribuidor || !idEmpresa || loadingUltimoCierre || isLoading || hayCierrePrevio === false;
const atajoTooltip = !idDistribuidor || !idEmpresa
? 'Seleccioná distribuidor y empresa primero.'
: hayCierrePrevio === false
? 'Sin cierres previos.'
: 'Autocompleta Fecha Desde con el día siguiente al último cierre.';
return ( return (
<Box sx={{ p: 2, border: '1px solid #ccc', borderRadius: '4px', minWidth: 380 }}> <Box sx={{ p: 2, border: '1px solid #ccc', borderRadius: '4px', minWidth: 380 }}>
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
@@ -115,6 +171,7 @@ const SeleccionaReporteCuentasDistribuidores: React.FC<SeleccionaReporteCuentasD
{localErrors.idEmpresa && <Typography color="error" variant="caption" sx={{ml:1.5}}>{localErrors.idEmpresa}</Typography>} {localErrors.idEmpresa && <Typography color="error" variant="caption" sx={{ml:1.5}}>{localErrors.idEmpresa}</Typography>}
</FormControl> </FormControl>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
<TextField <TextField
label="Fecha Desde" label="Fecha Desde"
type="date" type="date"
@@ -128,6 +185,28 @@ const SeleccionaReporteCuentasDistribuidores: React.FC<SeleccionaReporteCuentasD
disabled={isLoading} disabled={isLoading}
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}
/> />
<Tooltip title={atajoTooltip}>
<span>
<Button
variant="outlined"
size="small"
startIcon={loadingUltimoCierre ? <CircularProgress size={16} /> : <EventAvailableIcon />}
onClick={handleDesdeUltimoCierre}
disabled={atajoDisabled}
sx={{ mt: 2, whiteSpace: 'nowrap' }}
>
Desde último cierre
</Button>
</span>
</Tooltip>
</Box>
{infoCierre && (
<Alert severity={hayCierrePrevio ? 'success' : 'info'} sx={{ mt: 1 }}>
{infoCierre}
</Alert>
)}
<TextField <TextField
label="Fecha Hasta" label="Fecha Hasta"
type="date" type="date"

View File

@@ -14,6 +14,10 @@ import PermisosChecklist from '../../components/Modals/Usuarios/PermisosChecklis
const SECCION_PERMISSIONS_PREFIX = "SS"; const SECCION_PERMISSIONS_PREFIX = "SS";
// Permisos exclusivos de SuperAdmin: no se asignan a perfiles ni se muestran en la UI de asignación.
// CS002 = Ajuste manual de saldo. CC002 = Reapertura de cierres de cuenta corriente.
const PERMISOS_SOLO_SUPERADMIN: ReadonlySet<string> = new Set(["CS002", "CC002"]);
const getModuloFromSeccionCodAcc = (codAcc: string): string | null => { const getModuloFromSeccionCodAcc = (codAcc: string): string | null => {
if (codAcc === "SS001") return "Distribución"; if (codAcc === "SS001") return "Distribución";
if (codAcc === "SS007") return "Suscripciones"; if (codAcc === "SS007") return "Suscripciones";
@@ -44,7 +48,8 @@ const getModuloConceptualDelPermiso = (permisoModulo: string): string => {
} }
if (moduloLower.includes("cuentas pagos") || if (moduloLower.includes("cuentas pagos") ||
moduloLower.includes("cuentas notas") || moduloLower.includes("cuentas notas") ||
moduloLower.includes("cuentas tipos pagos")) { moduloLower.includes("cuentas tipos pagos") ||
moduloLower.includes("cuentas cierres")) {
return "Contables"; return "Contables";
} }
if (moduloLower.includes("impresión tiradas") || if (moduloLower.includes("impresión tiradas") ||
@@ -104,9 +109,12 @@ const AsignarPermisosAPerfilPage: React.FC = () => {
perfilService.getPerfilById(idPerfilNum), perfilService.getPerfilById(idPerfilNum),
perfilService.getPermisosPorPerfil(idPerfilNum) // Esto devuelve todos los permisos con su estado 'asignado' perfilService.getPermisosPorPerfil(idPerfilNum) // Esto devuelve todos los permisos con su estado 'asignado'
]); ]);
// Filtrar permisos exclusivos de SuperAdmin (CS002, CC002) para que no aparezcan asignables.
// No se altera la asignación existente en la DB — solo se ocultan de esta UI.
const permisosVisibles = permisosData.filter(p => !PERMISOS_SOLO_SUPERADMIN.has(p.codAcc));
setPerfil(perfilData); setPerfil(perfilData);
setPermisosDisponibles(permisosData); setPermisosDisponibles(permisosVisibles);
setPermisosSeleccionados(new Set(permisosData.filter(p => p.asignado).map(p => p.id))); setPermisosSeleccionados(new Set(permisosVisibles.filter(p => p.asignado).map(p => p.id)));
} catch (err) { } catch (err) {
console.error(err); console.error(err);
setError('Error al cargar datos del perfil o permisos.'); setError('Error al cargar datos del perfil o permisos.');

View File

@@ -39,6 +39,7 @@ import GestionarTiposPagoPage from '../pages/Contables/GestionarTiposPagoPage';
import GestionarPagosDistribuidorPage from '../pages/Contables/GestionarPagosDistribuidorPage'; import GestionarPagosDistribuidorPage from '../pages/Contables/GestionarPagosDistribuidorPage';
import GestionarNotasCDPage from '../pages/Contables/GestionarNotasCDPage'; import GestionarNotasCDPage from '../pages/Contables/GestionarNotasCDPage';
import GestionarSaldosPage from '../pages/Contables/GestionarSaldosPage'; import GestionarSaldosPage from '../pages/Contables/GestionarSaldosPage';
import CierresCuentaCorrientePage from '../pages/Contables/CierresCuentaCorrientePage';
// Usuarios // Usuarios
import UsuariosIndexPage from '../pages/Usuarios/UsuariosIndexPage'; // Crear este componente import UsuariosIndexPage from '../pages/Usuarios/UsuariosIndexPage'; // Crear este componente
@@ -241,6 +242,14 @@ const AppRoutes = () => {
<Route path="pagos-distribuidores" element={<GestionarPagosDistribuidorPage />} /> <Route path="pagos-distribuidores" element={<GestionarPagosDistribuidorPage />} />
<Route path="notas-cd" element={<GestionarNotasCDPage />} /> <Route path="notas-cd" element={<GestionarNotasCDPage />} />
<Route path="gestion-saldos" element={<GestionarSaldosPage />} /> <Route path="gestion-saldos" element={<GestionarSaldosPage />} />
<Route
path="cierres-cc"
element={
<SectionProtectedRoute requiredPermission="CC003" sectionName="Cierres de Cuenta Corriente">
<CierresCuentaCorrientePage />
</SectionProtectedRoute>
}
/>
</Route> </Route>
{/* Módulo de Impresión (anidado) */} {/* Módulo de Impresión (anidado) */}

View File

@@ -29,6 +29,7 @@ import type { PerfilHistorialDto } from '../../models/dtos/Auditoria/PerfilHisto
import type { PermisoHistorialDto } from '../../models/dtos/Auditoria/PermisoHistorialDto'; import type { PermisoHistorialDto } from '../../models/dtos/Auditoria/PermisoHistorialDto';
import type { PermisosPerfilesHistorialDto } from '../../models/dtos/Auditoria/PermisosPerfilesHistorialDto'; import type { PermisosPerfilesHistorialDto } from '../../models/dtos/Auditoria/PermisosPerfilesHistorialDto';
import type { CambioParadaHistorialDto } from '../../models/dtos/Auditoria/CambioParadaHistorialDto'; import type { CambioParadaHistorialDto } from '../../models/dtos/Auditoria/CambioParadaHistorialDto';
import type { CierreCuentaCorrienteHistorialDto } from '../../models/dtos/Auditoria/CierreCuentaCorrienteHistorialDto';
interface HistorialParamsComunes { interface HistorialParamsComunes {
fechaDesde?: string; // "yyyy-MM-dd" fechaDesde?: string; // "yyyy-MM-dd"
@@ -41,6 +42,10 @@ interface HistorialCambiosParadaParams extends HistorialParamsComunes {
idCanillaAfectado?: number; idCanillaAfectado?: number;
} }
interface HistorialCierresCCParams extends HistorialParamsComunes {
idCierreAfectado?: number;
}
interface HistorialPermisosPerfilesParams extends HistorialParamsComunes { interface HistorialPermisosPerfilesParams extends HistorialParamsComunes {
idPerfilAfectado?: number; idPerfilAfectado?: number;
idPermisoAfectado?: number; idPermisoAfectado?: number;
@@ -467,6 +472,15 @@ const getHistorialCambiosParada = async (params: HistorialCambiosParadaParams):
return response.data; return response.data;
}; };
const getHistorialCierresCC = async (params: HistorialCierresCCParams): Promise<CierreCuentaCorrienteHistorialDto[]> => {
const queryParams: any = { ...params };
if (params.idUsuarioModificador) queryParams.idUsuarioModifico = params.idUsuarioModificador;
delete queryParams.idUsuarioModificador;
const response = await apiClient.get<CierreCuentaCorrienteHistorialDto[]>('/auditoria/cierres-cuenta-corriente', { params: queryParams });
return response.data;
};
const auditoriaService = { const auditoriaService = {
getHistorialUsuarios, getHistorialUsuarios,
getHistorialPagosDistribuidor, getHistorialPagosDistribuidor,
@@ -498,6 +512,7 @@ const auditoriaService = {
getHistorialPermisosMaestro, getHistorialPermisosMaestro,
getHistorialPermisosPerfiles, getHistorialPermisosPerfiles,
getHistorialCambiosParada, getHistorialCambiosParada,
getHistorialCierresCC,
}; };
export default auditoriaService; export default auditoriaService;

View File

@@ -0,0 +1,75 @@
import apiClient from '../apiClient';
import type { CierreCuentaCorrienteDto } from '../../models/dtos/Contables/CierreCuentaCorrienteDto';
import type { CrearCierreDto } from '../../models/dtos/Contables/CrearCierreDto';
import type { ReabrirCierreDto } from '../../models/dtos/Contables/ReabrirCierreDto';
import type { UltimoCierreDto } from '../../models/dtos/Contables/UltimoCierreDto';
import type { CierreCuentaCorrienteHistorialDto } from '../../models/dtos/Auditoria/CierreCuentaCorrienteHistorialDto';
const getAllCierres = async (
idDistribuidor: number,
idEmpresa: number,
): Promise<CierreCuentaCorrienteDto[]> => {
const response = await apiClient.get<CierreCuentaCorrienteDto[]>('/cierres-cc', {
params: { idDistribuidor, idEmpresa },
});
return response.data;
};
// Devuelve null si no hay cierre vigente (backend responde 404 — lo capturamos acá
// para que las páginas no tengan que manejar el error).
const getUltimoCierre = async (
idDistribuidor: number,
idEmpresa: number,
): Promise<UltimoCierreDto | null> => {
try {
const response = await apiClient.get<UltimoCierreDto>('/cierres-cc/ultimo', {
params: { idDistribuidor, idEmpresa },
});
return response.data;
} catch (error: unknown) {
if (
typeof error === 'object' &&
error !== null &&
'response' in error &&
(error as { response?: { status?: number } }).response?.status === 404
) {
return null;
}
throw error;
}
};
const crearCierre = async (data: CrearCierreDto): Promise<CierreCuentaCorrienteDto> => {
const response = await apiClient.post<CierreCuentaCorrienteDto>('/cierres-cc', data);
return response.data;
};
const reabrirCierre = async (
idCierre: number,
data: ReabrirCierreDto,
): Promise<CierreCuentaCorrienteDto> => {
const response = await apiClient.post<CierreCuentaCorrienteDto>(
`/cierres-cc/${idCierre}/reabrir`,
data,
);
return response.data;
};
const getHistorialCierre = async (
idCierre: number,
): Promise<CierreCuentaCorrienteHistorialDto[]> => {
const response = await apiClient.get<CierreCuentaCorrienteHistorialDto[]>(
`/cierres-cc/${idCierre}/historial`
);
return response.data;
};
const cierresCcService = {
getAllCierres,
getUltimoCierre,
crearCierre,
reabrirCierre,
getHistorialCierre,
};
export default cierresCcService;

View File

@@ -5,8 +5,10 @@ import type { UpdateDistribuidorDto } from '../../models/dtos/Distribucion/Updat
import type { DistribuidorDropdownDto } from '../../models/dtos/Distribucion/DistribuidorDropdownDto'; import type { DistribuidorDropdownDto } from '../../models/dtos/Distribucion/DistribuidorDropdownDto';
import type { DistribuidorLookupDto } from '../../models/dtos/Distribucion/DistribuidorLookupDto'; import type { DistribuidorLookupDto } from '../../models/dtos/Distribucion/DistribuidorLookupDto';
const getAllDistribuidores = async (nombreFilter?: string, nroDocFilter?: string): Promise<DistribuidorDto[]> => { const getAllDistribuidores = async (nombreFilter?: string, nroDocFilter?: string, soloActivos: boolean = true): Promise<DistribuidorDto[]> => {
const params: Record<string, string> = {}; const params: Record<string, string | boolean> = {
soloActivos: soloActivos
};
if (nombreFilter) params.nombre = nombreFilter; if (nombreFilter) params.nombre = nombreFilter;
if (nroDocFilter) params.nroDoc = nroDocFilter; if (nroDocFilter) params.nroDoc = nroDocFilter;
@@ -37,11 +39,15 @@ const deleteDistribuidor = async (id: number): Promise<void> => {
await apiClient.delete(`/distribuidores/${id}`); await apiClient.delete(`/distribuidores/${id}`);
}; };
const getAllDistribuidoresDropdown = async (): Promise<DistribuidorDropdownDto[]> => { const getAllDistribuidoresDropdown = async (soloActivos: boolean = true): Promise<DistribuidorDropdownDto[]> => {
const response = await apiClient.get<DistribuidorDropdownDto[]>('/distribuidores/dropdown'); const response = await apiClient.get<DistribuidorDropdownDto[]>('/distribuidores/dropdown', { params: { soloActivos } });
return response.data; return response.data;
}; };
const toggleBajaDistribuidor = async (id: number, data: { darDeBaja: boolean, fechaBaja: string | null }): Promise<void> => {
await apiClient.put(`/distribuidores/${id}/toggle-baja`, data);
};
const distribuidorService = { const distribuidorService = {
getAllDistribuidores, getAllDistribuidores,
getDistribuidorById, getDistribuidorById,
@@ -50,6 +56,7 @@ const distribuidorService = {
deleteDistribuidor, deleteDistribuidor,
getAllDistribuidoresDropdown, getAllDistribuidoresDropdown,
getDistribuidorLookupById, getDistribuidorLookupById,
toggleBajaDistribuidor,
}; };
export default distribuidorService; export default distribuidorService;

View File

@@ -3,6 +3,8 @@ import type { StockBobinaDto } from '../../models/dtos/Impresion/StockBobinaDto'
import type { CreateStockBobinaDto } from '../../models/dtos/Impresion/CreateStockBobinaDto'; import type { CreateStockBobinaDto } from '../../models/dtos/Impresion/CreateStockBobinaDto';
import type { UpdateStockBobinaDto } from '../../models/dtos/Impresion/UpdateStockBobinaDto'; import type { UpdateStockBobinaDto } from '../../models/dtos/Impresion/UpdateStockBobinaDto';
import type { CambiarEstadoBobinaDto } from '../../models/dtos/Impresion/CambiarEstadoBobinaDto'; import type { CambiarEstadoBobinaDto } from '../../models/dtos/Impresion/CambiarEstadoBobinaDto';
import type { CreateStockBobinaLoteDto } from '../../models/dtos/Impresion/CreateStockBobinaLoteDto';
import type { UpdateFechaRemitoLoteDto } from '../../models/dtos/Impresion/UpdateFechaRemitoLoteDto';
interface GetAllStockBobinasParams { interface GetAllStockBobinasParams {
idTipoBobina?: number | null; idTipoBobina?: number | null;
@@ -12,6 +14,8 @@ interface GetAllStockBobinasParams {
remitoFilter?: string | null; remitoFilter?: string | null;
fechaDesde?: string | null; // "yyyy-MM-dd" fechaDesde?: string | null; // "yyyy-MM-dd"
fechaHasta?: string | null; // "yyyy-MM-dd" fechaHasta?: string | null; // "yyyy-MM-dd"
fechaEstadoDesde?: string | null; // "yyyy-MM-dd"
fechaEstadoHasta?: string | null; // "yyyy-MM-dd"
} }
const getAllStockBobinas = async (filters: GetAllStockBobinasParams): Promise<StockBobinaDto[]> => { const getAllStockBobinas = async (filters: GetAllStockBobinasParams): Promise<StockBobinaDto[]> => {
@@ -23,6 +27,8 @@ const getAllStockBobinas = async (filters: GetAllStockBobinasParams): Promise<St
if (filters.remitoFilter) params.remito = filters.remitoFilter; // El backend espera remito if (filters.remitoFilter) params.remito = filters.remitoFilter; // El backend espera remito
if (filters.fechaDesde) params.fechaDesde = filters.fechaDesde; if (filters.fechaDesde) params.fechaDesde = filters.fechaDesde;
if (filters.fechaHasta) params.fechaHasta = filters.fechaHasta; if (filters.fechaHasta) params.fechaHasta = filters.fechaHasta;
if (filters.fechaEstadoDesde) params.fechaEstadoDesde = filters.fechaEstadoDesde;
if (filters.fechaEstadoHasta) params.fechaEstadoHasta = filters.fechaEstadoHasta;
const response = await apiClient.get<StockBobinaDto[]>('/stockbobinas', { params }); const response = await apiClient.get<StockBobinaDto[]>('/stockbobinas', { params });
return response.data; return response.data;
@@ -50,6 +56,23 @@ const deleteIngresoBobina = async (idBobina: number): Promise<void> => {
await apiClient.delete(`/stockbobinas/${idBobina}`); await apiClient.delete(`/stockbobinas/${idBobina}`);
}; };
const verificarRemitoExistente = async (idPlanta: number, remito: string, fechaRemito?: string | null): Promise<StockBobinaDto[]> => {
const params: { idPlanta: number; remito: string; fechaRemito?: string } = { idPlanta, remito };
if (fechaRemito) {
params.fechaRemito = fechaRemito;
}
const response = await apiClient.get<StockBobinaDto[]>('/stockbobinas/verificar-remito', { params });
return response.data;
};
const ingresarLoteBobinas = async (data: CreateStockBobinaLoteDto): Promise<void> => {
await apiClient.post('/stockbobinas/lote', data);
};
const actualizarFechaRemitoLote = async (data: UpdateFechaRemitoLoteDto): Promise<void> => {
await apiClient.put('/stockbobinas/actualizar-fecha-remito', data);
};
const stockBobinaService = { const stockBobinaService = {
getAllStockBobinas, getAllStockBobinas,
getStockBobinaById, getStockBobinaById,
@@ -57,6 +80,9 @@ const stockBobinaService = {
updateDatosBobinaDisponible, updateDatosBobinaDisponible,
cambiarEstadoBobina, cambiarEstadoBobina,
deleteIngresoBobina, deleteIngresoBobina,
verificarRemitoExistente,
ingresarLoteBobinas,
actualizarFechaRemitoLote,
}; };
export default stockBobinaService; export default stockBobinaService;

Some files were not shown because too many files have changed in this diff Show More