3 Commits

Author SHA1 Message Date
1020555db6 Feat TestModels 2025-11-18 13:11:33 -03:00
615cf282a1 Feat Detecciones en Bobinas y Montos 2025-11-10 15:36:25 -03:00
d040099b9a Feat Control Distribuidores 2025-11-10 15:06:10 -03:00
33 changed files with 724 additions and 1283 deletions

View File

@@ -2,7 +2,6 @@ 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;
@@ -41,7 +40,6 @@ 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)]
@@ -49,23 +47,12 @@ 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( var bobinas = await _stockBobinaService.ObtenerTodosAsync(idTipoBobina, nroBobina, idPlanta, idEstadoBobina, remito, fechaDesde, fechaHasta);
idTipoBobina,
nroBobina,
idPlanta,
idEstadoBobina,
remito,
fechaDesde,
fechaHasta,
fechaEstadoDesde,
fechaEstadoHasta
);
return Ok(bobinas); return Ok(bobinas);
} }
catch (Exception ex) catch (Exception ex)
@@ -185,72 +172,5 @@ 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

@@ -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.Promedio_Llevados > 0 ? (decimal)item.Promedio_Devueltos * 100 / item.Promedio_Llevados : 0; var porcDevolucion = item.Llevados > 0 ? (decimal)item.Devueltos * 100 / item.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,6 +162,7 @@ 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));
@@ -169,7 +170,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(Model.PorcentajeDevolucionGeneral.ToString("F2") + "%").Style(boldStyle)); table.Cell().Border(1).Padding(3).AlignRight().Text(text => text.Span(porcDevolucionGeneral.ToString("F2") + "%").Style(boldStyle));
} }
}); });
}); });

View File

@@ -90,7 +90,7 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
var llevados = item.Llevados ?? 0; var llevados = item.Llevados ?? 0;
var devueltos = item.Devueltos ?? 0; var devueltos = item.Devueltos ?? 0;
var ventaNetaDia = llevados - devueltos; var ventaNetaDia = llevados - devueltos;
if (llevados > 0) if(llevados > 0)
{ {
ventaNetaAcumulada += ventaNetaDia; ventaNetaAcumulada += ventaNetaDia;
conteoDias++; conteoDias++;
@@ -123,7 +123,7 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
void ComposePromediosTable(IContainer container) void ComposePromediosTable(IContainer container)
{ {
var dayOrder = new Dictionary<string, int> { { "Lunes", 1 }, { "Martes", 2 }, { "Miércoles", 3 }, { "Jueves", 4 }, { "Viernes", 5 }, { "Sábado", 6 }, { "Domingo", 7 } }; var dayOrder = new Dictionary<string, int> { { "Lunes", 1 }, { "Martes", 2 }, { "Miércoles", 3 }, { "Jueves", 4 }, { "Viernes", 5 }, { "Sábado", 6 }, { "Domingo", 7 }};
container.Table(table => container.Table(table =>
{ {
@@ -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 avgPercentage = Model.PromediosPorDia var llevadosGeneral = general.Llevados ?? 0; // Usamos el total para el %
.Where(p => p.Dia != "General" && (p.Promedio_Llevados ?? 0) > 0) var devueltosGeneral = general.Devueltos ?? 0; // Usamos el total para el %
.Average(p => (decimal)(p.Promedio_Devueltos ?? 0) * 100 / (p.Promedio_Llevados ?? 1)); var porcDevolucionGeneral = llevadosGeneral > 0 ? (decimal)devueltosGeneral * 100 / llevadosGeneral : 0;
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(avgPercentage.ToString("F2") + "%").Style(boldStyle)); table.Cell().Border(1).Padding(3).AlignRight().Text(t => t.Span(porcDevolucionGeneral.ToString("F2") + "%").Style(boldStyle));
} }
}); });
} }

View File

@@ -72,13 +72,6 @@ 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");
@@ -103,7 +96,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

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

@@ -15,9 +15,7 @@ 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, DateTime? fechaEstadoDesde, DateTime? fechaEstadoHasta) int? idEstadoBobina, string? remitoFilter, DateTime? fechaDesde, DateTime? fechaHasta)
{ {
var sqlBuilder = new StringBuilder(@" var sqlBuilder = new StringBuilder(@"
SELECT SELECT
@@ -69,16 +69,6 @@ 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

@@ -1,11 +0,0 @@
// 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

@@ -1,23 +0,0 @@
// 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

@@ -1,21 +0,0 @@
// 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

@@ -30,22 +30,6 @@ 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
{ {
@@ -53,27 +37,20 @@ namespace GestionIntegral.Api.Dtos.Reportes.ViewModels
{ {
if (PromediosPorDia == null || !PromediosPorDia.Any()) return null; if (PromediosPorDia == null || !PromediosPorDia.Any()) return null;
// Sumamos los totales ponderados para cada columna // Sumamos los totales, no promediamos los promedios
var totalPonderadoLlevados = PromediosPorDia.Sum(p => p.Promedio_Llevados * p.Cant); var totalLlevados = PromediosPorDia.Sum(p => p.Llevados);
var totalPonderadoDevueltos = PromediosPorDia.Sum(p => p.Promedio_Devueltos * p.Cant); var totalDevueltos = PromediosPorDia.Sum(p => p.Devueltos);
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 = promGeneralLlevados, Promedio_Llevados = totalLlevados / totalDias,
Promedio_Devueltos = promGeneralDevueltos, Promedio_Devueltos = totalDevueltos / totalDias,
Promedio_Ventas = promGeneralVentas Promedio_Ventas = (totalLlevados - totalDevueltos) / totalDias
}; };
} }
} }

View File

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

View File

@@ -187,7 +187,7 @@ builder.Services.AddCors(options =>
policy => policy =>
{ {
policy.WithOrigins( policy.WithOrigins(
"http://localhost:5175", // Para desarrollo local "http://localhost:5173", // Para desarrollo local
"https://gestion.eldiaservicios.com" // Para producción "https://gestion.eldiaservicios.com" // Para producción
) )
.AllowAnyHeader() .AllowAnyHeader()

View File

@@ -167,11 +167,10 @@ 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.");
/*
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);

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, DateTime? fechaEstadoDesde, DateTime? fechaEstadoHasta); int? idEstadoBobina, string? remitoFilter, DateTime? fechaDesde, DateTime? fechaHasta);
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,8 +21,5 @@ 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, DateTime? fechaEstadoDesde, DateTime? fechaEstadoHasta) int? idEstadoBobina, string? remitoFilter, DateTime? fechaDesde, DateTime? fechaHasta)
{ {
var bobinas = await _stockBobinaRepository.GetAllAsync(idTipoBobina, nroBobinaFilter, idPlanta, idEstadoBobina, remitoFilter, fechaDesde, fechaHasta, fechaEstadoDesde, fechaEstadoHasta); var bobinas = await _stockBobinaRepository.GetAllAsync(idTipoBobina, nroBobinaFilter, idPlanta, idEstadoBobina, remitoFilter, fechaDesde, fechaHasta);
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");
@@ -383,153 +383,5 @@ 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

@@ -1,89 +0,0 @@
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

@@ -1,374 +0,0 @@
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

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

View File

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

View File

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

View File

@@ -257,7 +257,7 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
} }
}); });
if (fechaMovimientoMasReciente !== null && fechaLiquidacionDate.getTime() < (fechaMovimientoMasReciente as Date).getTime()) { if (fechaMovimientoMasReciente !== null && fechaLiquidacionDate.getTime() < (fechaMovimientoMasReciente as Date).getTime()) {
setApiErrorMessage(`La fecha de liquidación (${fechaLiquidacionDate.toLocaleDateString('es-AR', { timeZone: 'UTC' })}) no puede ser inferior a la fecha del movimiento más reciente a liquidar (${(fechaMovimientoMasReciente as Date).toLocaleDateString('es-AR', { timeZone: 'UTC' })}).`); setApiErrorMessage(`La fecha de liquidación (${fechaLiquidacionDate.toLocaleDateString('es-AR', {timeZone: 'UTC'})}) no puede ser inferior a la fecha del movimiento más reciente a liquidar (${(fechaMovimientoMasReciente as Date).toLocaleDateString('es-AR', {timeZone: 'UTC'})}).`);
return; return;
} }
setApiErrorMessage(null); setApiErrorMessage(null);
@@ -348,10 +348,6 @@ 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 }}>
@@ -402,9 +398,9 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
onChange={(e) => setFiltroIdCanillitaSeleccionado(e.target.value as number | string)} onChange={(e) => setFiltroIdCanillitaSeleccionado(e.target.value as number | string)}
> >
<MenuItem value=""><em>Seleccione uno</em></MenuItem> <MenuItem value=""><em>Seleccione uno</em></MenuItem>
{destinatariosDropdown.map(c => <MenuItem key={c.idCanilla} value={c.idCanilla}>{c.nomApe} {c.legajo ? `(Leg: ${c.legajo})` : ''}</MenuItem>)} {destinatariosDropdown.map(c => <MenuItem key={c.idCanilla} value={c.idCanilla}>{c.nomApe} {c.legajo ? `(Leg: ${c.legajo})`: ''}</MenuItem>)}
</Select> </Select>
{!filtroIdCanillitaSeleccionado && <Typography component="p" color="error" variant="caption" sx={{ ml: 1.5, fontSize: '0.65rem' }}>Selección obligatoria</Typography>} {!filtroIdCanillitaSeleccionado && <Typography component="p" color="error" variant="caption" sx={{ml:1.5, fontSize:'0.65rem'}}>Selección obligatoria</Typography>}
</FormControl> </FormControl>
<FormControl size="small" sx={{ minWidth: 180, flexGrow: 1 }} disabled={loadingFiltersDropdown}> <FormControl size="small" sx={{ minWidth: 180, flexGrow: 1 }} disabled={loadingFiltersDropdown}>
@@ -415,7 +411,7 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
</Select> </Select>
</FormControl> </FormControl>
</Box> </Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 2 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap:2 }}>
{puedeCrear && ( {puedeCrear && (
<Button <Button
variant="contained" variant="contained"
@@ -434,29 +430,22 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
</Box> </Box>
</Paper> </Paper>
{!filtroFecha && <Alert severity="info" sx={{ my: 1 }}>Por favor, seleccione una fecha.</Alert>} {!filtroFecha && <Alert severity="info" sx={{my:1}}>Por favor, seleccione una fecha.</Alert>}
{filtroFecha && !filtroIdCanillitaSeleccionado && <Alert severity="info" sx={{ my: 1 }}>Por favor, seleccione un {filtroTipoDestinatario === 'canillitas' ? 'canillita' : 'accionista'}.</Alert>} {filtroFecha && !filtroIdCanillitaSeleccionado && <Alert severity="info" sx={{my:1}}>Por favor, seleccione un {filtroTipoDestinatario === 'canillitas' ? 'canillita' : 'accionista'}.</Alert>}
{loading && <Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}><CircularProgress /></Box>} {loading && <Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}><CircularProgress /></Box>}
{error && !loading && !apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>} {error && !loading && !apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>}
{apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{apiErrorMessage}</Alert>} {apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{apiErrorMessage}</Alert>}
{loadingTicketPdf && (<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', my: 2 }}> <CircularProgress size={20} sx={{ mr: 1 }} /> <Typography variant="body2">Cargando ticket...</Typography> </Box>)} {loadingTicketPdf && ( <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', my: 2 }}> <CircularProgress size={20} sx={{ mr: 1 }} /> <Typography variant="body2">Cargando ticket...</Typography> </Box> )}
{!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: 1 }}> <Typography variant="subtitle1" sx={{mr:2}}>
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: totalARendirVisible > 0 ? 'error.main' : 'green' }}> <Typography variant="h6" sx={{fontWeight: 'bold', color: 'error.main'}}>
{totalARendirVisible.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })} {totalARendirVisible.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}
</Typography> </Typography>
</Box> </Box>
@@ -583,7 +572,7 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
{selectedRow && ( {selectedRow && (
((!selectedRow.liquidado && puedeEliminar) || (selectedRow.liquidado && puedeEliminarLiquidados)) ((!selectedRow.liquidado && puedeEliminar) || (selectedRow.liquidado && puedeEliminarLiquidados))
) && ( ) && (
<MenuItem onClick={() => { if (selectedRow) handleDelete(selectedRow.idParte); }}> <MenuItem onClick={() => {if (selectedRow) handleDelete(selectedRow.idParte);}}>
<DeleteIcon fontSize="small" sx={{ mr: 1 }} /> <DeleteIcon fontSize="small" sx={{ mr: 1 }} />
Eliminar Eliminar
</MenuItem> </MenuItem>

View File

@@ -1,11 +1,9 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { import {
Box, Typography, TextField, Button, Paper, IconButton, Menu, MenuItem, Chip, Box, Typography, TextField, Button, Paper, IconButton, Menu, MenuItem, Chip,
Alert, FormControl, InputLabel, Select, FormControlLabel, Checkbox Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TablePagination,
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';
@@ -13,7 +11,6 @@ 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';
@@ -27,13 +24,10 @@ 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';
@@ -44,27 +38,20 @@ 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); const [loading, setLoading] = useState(false); // No carga al inicio
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[]>([]);
@@ -75,10 +62,13 @@ 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);
// Menú de acciones // Estado para la bobina seleccionada en un modal o menú
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);
@@ -89,9 +79,12 @@ 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(),
@@ -128,18 +121,13 @@ 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) {
// No setteamos error bloqueante, solo aviso visual si se desea, o dejar tabla vacía. setError("No se encontraron resultados con los filtros aplicados.");
// setError("No se encontraron resultados con los filtros aplicados.");
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -147,14 +135,10 @@ 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();
}; };
@@ -164,19 +148,14 @@ 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);
@@ -184,166 +163,92 @@ 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 handleLoteModalClose = (refrescar: boolean) => { const handleOpenEditModal = (bobina: StockBobinaDto | null) => {
setLoteModalOpen(false); if (!bobina) return;
if (refrescar) { setSelectedBobina(bobina);
cargarStock(); setApiErrorMessage(null);
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 = () => { const handleDeleteBobina = async (bobina: StockBobinaDto | null) => {
if (!selectedBobinaForRowMenu) return; if (!bobina) 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: ${selectedBobinaForRowMenu.idBobina})?`)) { if (window.confirm(`¿Seguro de eliminar este ingreso de bobina (ID: ${bobina.idBobina})?`)) {
setApiErrorMessage(null); setApiErrorMessage(null);
stockBobinaService.deleteIngresoBobina(selectedBobinaForRowMenu.idBobina) try { await stockBobinaService.deleteIngresoBobina(bobina.idBobina); cargarStock(); }
.then(() => cargarStock()) 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 msg = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al eliminar.';
setApiErrorMessage(msg);
});
} }
handleMenuClose(); handleMenuClose();
}; };
const handleSubmitFechaRemitoModal = async (data: UpdateFechaRemitoLoteDto) => {
setApiErrorMessage(null);
try {
await stockBobinaService.actualizarFechaRemitoLote(data);
cargarStock();
} catch (err: any) {
const msg = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al actualizar la fecha del remito.';
setApiErrorMessage(msg);
throw err;
}
};
// --- Handlers Menú Acciones ---
const handleMenuOpen = (event: React.MouseEvent<HTMLButtonElement>, bobina: StockBobinaDto) => { const handleMenuOpen = (event: React.MouseEvent<HTMLButtonElement>, bobina: StockBobinaDto) => {
event.stopPropagation(); // Evitar selección de fila al abrir menú
setAnchorEl(event.currentTarget); setAnchorEl(event.currentTarget);
setSelectedBobinaForRowMenu(bobina); setSelectedBobinaForRowMenu(bobina);
lastOpenedMenuButtonRef.current = event.currentTarget;
}; };
const handleMenuClose = () => { const handleMenuClose = () => {
setAnchorEl(null); setAnchorEl(null);
setSelectedBobinaForRowMenu(null);
if (lastOpenedMenuButtonRef.current) {
setTimeout(() => { lastOpenedMenuButtonRef.current?.focus(); }, 0);
}
}; };
const handleOpenEditModal = () => { setEditModalOpen(true); handleMenuClose(); }; const handleChangePage = (_event: unknown, newPage: number) => setPage(newPage);
const handleOpenCambioEstadoModal = () => { setCambioEstadoModalOpen(true); handleMenuClose(); }; const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
const handleOpenFechaRemitoModal = () => { setFechaRemitoModalOpen(true); handleMenuClose(); }; setRowsPerPage(parseInt(event.target.value, 10)); setPage(0);
};
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 handleCloseEditModal = () => { setEditModalOpen(false); setSelectedBobinaForRowMenu(null); }; const options: Intl.DateTimeFormatOptions = {
const handleCloseCambioEstadoModal = () => { setCambioEstadoModalOpen(false); setSelectedBobinaForRowMenu(null); }; year: 'numeric',
const handleCloseFechaRemitoModal = () => { setFechaRemitoModalOpen(false); setSelectedBobinaForRowMenu(null); }; month: '2-digit',
day: '2-digit',
// --- Definición de Columnas DataGrid --- timeZone: 'UTC'
const columns = useMemo<GridColDef<StockBobinaDto>[]>(() => [ };
{ field: 'nroBobina', headerName: 'Nro. Bobina', width: 130 }, return new Intl.DateTimeFormat('es-AR', options).format(date);
{ 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: 2 }}> <Box sx={{ p: 1 }}>
<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>
@@ -369,123 +274,128 @@ 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 Fecha Remito" label="Filtrar por Fechas de Remitos"
/> />
<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 Desde" type="date" size="small" value={filtroFechaDesde} onChange={(e) => setFiltroFechaDesde(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} /> <TextField label="Fecha Hasta" type="date" size="small" value={filtroFechaHasta} onChange={(e) => setFiltroFechaHasta(e.target.value)} InputLabelProps={{ shrink: true }} sx={{ minWidth: 170 }} 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>}
{/* Tabla DataGrid */} {!loading && !error && (
<Paper sx={{ width: '100%', height: 600 }}> <TableContainer component={Paper}>
<DataGrid <Table size="small">
rows={stock} <TableHead><TableRow>
columns={columns} <TableCell>Nro. Bobina</TableCell><TableCell>Tipo</TableCell><TableCell>Peso (Kg)</TableCell>
getRowId={(row) => row.idBobina} // Importante: especificar el ID único <TableCell>Planta</TableCell><TableCell>Estado</TableCell><TableCell>Remito</TableCell>
loading={loading} <TableCell>F. Remito</TableCell><TableCell>F. Estado</TableCell>
localeText={esES.components.MuiDataGrid.defaultProps.localeText} <TableCell>Publicación</TableCell><TableCell>Sección</TableCell>
density="compact" <TableCell>Obs.</TableCell>
disableRowSelectionOnClick {(puedeModificarDatos || puedeCambiarEstado || puedeEliminar) && <TableCell align="right">Acciones</TableCell>}
initialState={{ </TableRow></TableHead>
pagination: { paginationModel: { pageSize: 25 } }, <TableBody>
}} {displayData.length === 0 ? (
pageSizeOptions={[25, 50, 100]} <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>
sx={{ border: 0 }} ) : (
/> displayData.map((b) => (
</Paper> <TableRow key={b.idBobina} hover>
<TableCell>{b.nroBobina}</TableCell><TableCell>{b.nombreTipoBobina}</TableCell>
{/* Menú Contextual de Fila */} <TableCell align="right">{b.peso}</TableCell><TableCell>{b.nombrePlanta}</TableCell>
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}> <TableCell><Chip label={b.nombreEstadoBobina} size="small" color={
{selectedBobinaForRowMenu && puedeModificarDatos && ( b.idEstadoBobina === ID_ESTADO_DISPONIBLE ? "success" : b.idEstadoBobina === ID_ESTADO_UTILIZADA ? "primary" : b.idEstadoBobina === ID_ESTADO_DANADA ? "error" : "default"
<MenuItem onClick={handleOpenFechaRemitoModal}> } /></TableCell>
<EditCalendarIcon fontSize="small" sx={{ mr: 1 }} /> Corregir Fecha Remito <TableCell>{b.remito}</TableCell><TableCell>{formatDate(b.fechaRemito)}</TableCell>
</MenuItem> <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>
)}
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}>
{selectedBobinaForRowMenu && puedeModificarDatos && selectedBobinaForRowMenu.idEstadoBobina === ID_ESTADO_DISPONIBLE && ( {selectedBobinaForRowMenu && puedeModificarDatos && selectedBobinaForRowMenu.idEstadoBobina === ID_ESTADO_DISPONIBLE && (
<MenuItem onClick={handleOpenEditModal}> <MenuItem onClick={() => { handleOpenEditModal(selectedBobinaForRowMenu); handleMenuClose(); }}>
<EditIcon fontSize="small" sx={{ mr: 1 }} /> Editar Datos Bobina <EditIcon fontSize="small" sx={{ mr: 1 }} /> Editar Datos
</MenuItem> </MenuItem>
)} )}
{selectedBobinaForRowMenu && puedeCambiarEstado && ( {selectedBobinaForRowMenu && puedeCambiarEstado && (
<MenuItem onClick={handleOpenCambioEstadoModal}> <MenuItem onClick={() => { handleOpenCambioEstadoModal(selectedBobinaForRowMenu); handleMenuClose(); }}>
<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}> <MenuItem onClick={() => handleDeleteBobina(selectedBobinaForRowMenu)}>
<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 */} {/* Modales sin cambios */}
<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={selectedBobinaForRowMenu} errorMessage={apiErrorMessage} initialData={selectedBobina} 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={selectedBobinaForRowMenu} errorMessage={apiErrorMessage} bobinaActual={selectedBobina} 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

@@ -69,8 +69,8 @@ const ReporteListadoDistribucionCanillasPage: React.FC = () => {
setReportData(null); setReportData(null);
setDetalleDiarioCalculado([]); setDetalleDiarioCalculado([]);
setPromediosPorDiaCalculado([]); setPromediosPorDiaCalculado([]);
setTotalesDetalle({ llevados: 0, devueltos: 0, ventaNeta: 0, promedioGeneralVentaNeta: 0, porcentajeDevolucionGeneral: 0 }); setTotalesDetalle({ llevados:0, devueltos:0, ventaNeta:0, promedioGeneralVentaNeta:0, porcentajeDevolucionGeneral:0 });
setTotalesPromedios({ cantDias: 0, promLlevados: 0, promDevueltos: 0, promVentas: 0, porcentajeDevolucionGeneral: 0 }); setTotalesPromedios({ cantDias:0, promLlevados:0, promDevueltos:0, promVentas:0, porcentajeDevolucionGeneral:0});
const pubService = (await import('../../services/Distribucion/publicacionService')).default; const pubService = (await import('../../services/Distribucion/publicacionService')).default;
@@ -123,31 +123,32 @@ 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}`, id: `prom-can-${index}`, // o prom-dist-${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 ? (promDevueltos / promLlevados) * 100 : 0, porcentajeDevolucion: promLlevados > 0 ? (promVentas / 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); // const totalPonderadoDevueltos = promediosCalculadoLocal.reduce((sum, item) => sum + ((item.promedio_Devueltos || 0) * (item.cant || 0)), 0); // No se usa para el % del PDF
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: promGeneralLlevados, promLlevados: totalDiasProm > 0 ? totalPonderadoLlevados / totalDiasProm : 0,
promDevueltos: promGeneralDevueltos, promDevueltos: totalDiasProm > 0 ? promediosCalculadoLocal.reduce((sum, item) => sum + (item.promedio_Devueltos || 0), 0) / promediosCalculadoLocal.length :0, // Promedio simple para mostrar
promVentas: totalDiasProm > 0 ? totalPonderadoVentas / totalDiasProm : 0, promVentas: totalDiasProm > 0 ? totalPonderadoVentas / totalDiasProm : 0,
porcentajeDevolucionGeneral: promGeneralLlevados > 0 ? (promGeneralDevueltos / promGeneralLlevados) * 100 : 0, // Para la fila "General" de promedios, el PDF usa (Total Prom. Ventas / Total Prom. Llevados) * 100
// 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 });
@@ -279,11 +280,11 @@ const ReporteListadoDistribucionCanillasPage: React.FC = () => {
</Box> </Box>
{/* Contenedor para tus totales */} {/* Contenedor para tus totales */}
<Box sx={{ p: 1, display: 'flex', alignItems: 'center', fontWeight: 'bold', whiteSpace: 'nowrap', overflowX: 'auto' }}> <Box sx={{ p: 1, display: 'flex', alignItems: 'center', fontWeight: 'bold', whiteSpace: 'nowrap', overflowX: 'auto' }}>
<Typography variant="subtitle2" sx={{ minWidth: columnsPromedios[0].width || 'auto', textAlign: 'right', fontWeight: 'bold' }}>General:</Typography> <Typography variant="subtitle2" sx={{ minWidth: columnsPromedios[0].width || 'auto', textAlign: 'right', fontWeight: 'bold'}}>General:</Typography>
<Typography variant="subtitle2" sx={{ minWidth: columnsDetalle[1].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', mr: 1 }}>{totalesDetalle.llevados.toLocaleString('es-AR')}</Typography> <Typography variant="subtitle2" sx={{ minWidth: columnsDetalle[1].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', mr:1 }}>{totalesDetalle.llevados.toLocaleString('es-AR')}</Typography>
<Typography variant="subtitle2" sx={{ minWidth: columnsDetalle[2].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', mr: 1 }}>{totalesDetalle.devueltos.toLocaleString('es-AR')}</Typography> <Typography variant="subtitle2" sx={{ minWidth: columnsDetalle[2].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', mr:1 }}>{totalesDetalle.devueltos.toLocaleString('es-AR')}</Typography>
<Typography variant="subtitle2" sx={{ minWidth: columnsDetalle[3].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', mr: 1 }}>{totalesDetalle.ventaNeta.toLocaleString('es-AR')}</Typography> <Typography variant="subtitle2" sx={{ minWidth: columnsDetalle[3].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', mr:1 }}>{totalesDetalle.ventaNeta.toLocaleString('es-AR')}</Typography>
<Typography variant="subtitle2" sx={{ minWidth: columnsDetalle[4].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', mr: 1 }}>{totalesDetalle.promedioGeneralVentaNeta.toLocaleString('es-AR', { maximumFractionDigits: 0 })}</Typography> <Typography variant="subtitle2" sx={{ minWidth: columnsDetalle[4].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', mr:1 }}>{totalesDetalle.promedioGeneralVentaNeta.toLocaleString('es-AR', { maximumFractionDigits: 0 })}</Typography>
<Typography variant="subtitle2" sx={{ minWidth: columnsDetalle[5].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold' }}>{totalesDetalle.porcentajeDevolucionGeneral.toLocaleString('es-AR', { maximumFractionDigits: 2 })}%</Typography> <Typography variant="subtitle2" sx={{ minWidth: columnsDetalle[5].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold' }}>{totalesDetalle.porcentajeDevolucionGeneral.toLocaleString('es-AR', { maximumFractionDigits: 2 })}%</Typography>
</Box> </Box>
</GridFooterContainer> </GridFooterContainer>
@@ -296,11 +297,11 @@ const ReporteListadoDistribucionCanillasPage: React.FC = () => {
<GridFooter sx={{ borderTop: 'none' }} /> <GridFooter sx={{ borderTop: 'none' }} />
</Box> </Box>
<Box sx={{ p: 1, display: 'flex', alignItems: 'center', fontWeight: 'bold', marginLeft: 'auto', whiteSpace: 'nowrap', overflowX: 'auto' }}> <Box sx={{ p: 1, display: 'flex', alignItems: 'center', fontWeight: 'bold', marginLeft: 'auto', whiteSpace: 'nowrap', overflowX: 'auto' }}>
<Typography variant="subtitle2" sx={{ minWidth: columnsPromedios[0].width || 'auto', textAlign: 'right', fontWeight: 'bold' }}>General:</Typography> <Typography variant="subtitle2" sx={{ minWidth: columnsPromedios[0].width || 'auto', textAlign: 'right', fontWeight: 'bold'}}>General:</Typography>
<Typography variant="subtitle2" sx={{ width: columnsPromedios[1].width || 'auto', textAlign: 'right', fontWeight: 'bold', pr: 1 }}>{totalesPromedios.cantDias.toLocaleString('es-AR')}</Typography> <Typography variant="subtitle2" sx={{ width: columnsPromedios[1].width || 'auto', textAlign: 'right', fontWeight: 'bold', pr:1 }}>{totalesPromedios.cantDias.toLocaleString('es-AR')}</Typography>
<Typography variant="subtitle2" sx={{ minWidth: columnsPromedios[2].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', pr: 1 }}>{totalesPromedios.promLlevados.toLocaleString('es-AR', { maximumFractionDigits: 0 })}</Typography> <Typography variant="subtitle2" sx={{ minWidth: columnsPromedios[2].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', pr:1 }}>{totalesPromedios.promLlevados.toLocaleString('es-AR', { maximumFractionDigits: 0 })}</Typography>
<Typography variant="subtitle2" sx={{ minWidth: columnsPromedios[3].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', pr: 1 }}>{totalesPromedios.promDevueltos.toLocaleString('es-AR', { maximumFractionDigits: 0 })}</Typography> <Typography variant="subtitle2" sx={{ minWidth: columnsPromedios[3].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', pr:1 }}>{totalesPromedios.promDevueltos.toLocaleString('es-AR', { maximumFractionDigits: 0 })}</Typography>
<Typography variant="subtitle2" sx={{ minWidth: columnsPromedios[4].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', pr: 1 }}>{totalesPromedios.promVentas.toLocaleString('es-AR', { maximumFractionDigits: 0 })}</Typography> <Typography variant="subtitle2" sx={{ minWidth: columnsPromedios[4].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold', pr:1 }}>{totalesPromedios.promVentas.toLocaleString('es-AR', { maximumFractionDigits: 0 })}</Typography>
<Typography variant="subtitle2" sx={{ minWidth: columnsPromedios[5].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold' }}>{totalesPromedios.porcentajeDevolucionGeneral.toLocaleString('es-AR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</Typography> <Typography variant="subtitle2" sx={{ minWidth: columnsPromedios[5].minWidth || 'auto', textAlign: 'right', fontWeight: 'bold' }}>{totalesPromedios.porcentajeDevolucionGeneral.toLocaleString('es-AR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</Typography>
</Box> </Box>
</GridFooterContainer> </GridFooterContainer>

View File

@@ -121,24 +121,20 @@ 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 countPromedios = promediosConCalculos.length; const totalPonderadoLlevados = promediosConCalculos.reduce((sum, item) => sum + ((item.promedio_Llevados || 0) * (item.cant || 0)), 0);
const totalPonderadoDevueltos = promediosConCalculos.reduce((sum, item) => sum + ((item.promedio_Devueltos || 0) * (item.cant || 0)), 0);
// LÓGICA DE PROMEDIO DE PROMEDIOS const totalPonderadoVentas = promediosConCalculos.reduce((sum, item) => sum + ((item.promedio_Ventas || 0) * (item.cant || 0)), 0);
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: sumPromLlevados / countPromedios, promLlevados: totalDiasPromedios > 0 ? totalPonderadoLlevados / totalDiasPromedios : 0,
promDevueltos: sumPromDevueltos / countPromedios, promDevueltos: totalDiasPromedios > 0 ? totalPonderadoDevueltos / totalDiasPromedios : 0,
promVentas: sumPromVentas / countPromedios, promVentas: totalDiasPromedios > 0 ? totalPonderadoVentas / totalDiasPromedios : 0,
porcentajeDevolucionGeneral: sumPorcDevolucion / countPromedios, porcentajeDevolucionGeneral: totalPonderadoLlevados > 0 ? (totalPonderadoDevueltos / totalPonderadoLlevados) * 100 : 0,
}); });
}
setReportData({ detalleSimple: detalleConCalculos, promediosPorDia: promediosConCalculos }); setReportData({ detalleSimple: detalleConCalculos, promediosPorDia: promediosConCalculos });

View File

@@ -3,8 +3,6 @@ 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;
@@ -14,8 +12,6 @@ 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[]> => {
@@ -27,8 +23,6 @@ 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;
@@ -56,23 +50,6 @@ 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,
@@ -80,9 +57,6 @@ const stockBobinaService = {
updateDatosBobinaDisponible, updateDatosBobinaDisponible,
cambiarEstadoBobina, cambiarEstadoBobina,
deleteIngresoBobina, deleteIngresoBobina,
verificarRemitoExistente,
ingresarLoteBobinas,
actualizarFechaRemitoLote,
}; };
export default stockBobinaService; export default stockBobinaService;

View File

@@ -13,7 +13,6 @@ def insertar_alerta_en_db(cursor, tipo_alerta, id_entidad, entidad, mensaje, fec
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
""" """
try: try:
# Asegurarse de que los valores numéricos opcionales sean None si no se proporcionan
p_dev = float(porc_devolucion) if porc_devolucion is not None else None p_dev = float(porc_devolucion) if porc_devolucion is not None else None
c_env = int(cant_enviada) if cant_enviada is not None else None c_env = int(cant_enviada) if cant_enviada is not None else None
c_dev = int(cant_devuelta) if cant_devuelta is not None else None c_dev = int(cant_devuelta) if cant_devuelta is not None else None
@@ -31,6 +30,9 @@ DB_DATABASE = 'SistemaGestion'
CONNECTION_STRING = f'DRIVER={{ODBC Driver 18 for SQL Server}};SERVER={DB_SERVER};DATABASE={DB_DATABASE};Trusted_Connection=yes;TrustServerCertificate=yes;' CONNECTION_STRING = f'DRIVER={{ODBC Driver 18 for SQL Server}};SERVER={DB_SERVER};DATABASE={DB_DATABASE};Trusted_Connection=yes;TrustServerCertificate=yes;'
MODEL_INDIVIDUAL_FILE = 'modelo_anomalias.joblib' MODEL_INDIVIDUAL_FILE = 'modelo_anomalias.joblib'
MODEL_SISTEMA_FILE = 'modelo_sistema_anomalias.joblib' MODEL_SISTEMA_FILE = 'modelo_sistema_anomalias.joblib'
MODEL_DIST_FILE = 'modelo_anomalias_dist.joblib'
MODEL_DANADAS_FILE = 'modelo_danadas.joblib'
MODEL_MONTOS_FILE = 'modelo_montos.joblib'
# --- 2. Determinar Fecha --- # --- 2. Determinar Fecha ---
if len(sys.argv) > 1: if len(sys.argv) > 1:
@@ -46,11 +48,12 @@ except Exception as e:
print(f"CRITICAL: No se pudo conectar a la base de datos. Error: {e}") print(f"CRITICAL: No se pudo conectar a la base de datos. Error: {e}")
exit() exit()
# --- 3. DETECCIÓN INDIVIDUAL (CANILLITAS) --- # --- FASE 1: Detección de Anomalías Individuales (Canillitas) ---
print("\n--- FASE 1: Detección de Anomalías Individuales (Canillitas) ---") print("\n--- FASE 1: Detección de Anomalías Individuales (Canillitas) ---")
if not os.path.exists(MODEL_INDIVIDUAL_FILE): if not os.path.exists(MODEL_INDIVIDUAL_FILE):
print(f"ADVERTENCIA: Modelo individual '{MODEL_INDIVIDUAL_FILE}' no encontrado.") print(f"ADVERTENCIA: Modelo individual '{MODEL_INDIVIDUAL_FILE}' no encontrado.")
else: else:
# ... (esta sección se mantiene exactamente igual que antes) ...
model_individual = joblib.load(MODEL_INDIVIDUAL_FILE) model_individual = joblib.load(MODEL_INDIVIDUAL_FILE)
query_individual = f""" query_individual = f"""
SELECT esc.Id_Canilla AS id_canilla, esc.Fecha AS fecha, esc.CantSalida AS cantidad_enviada, esc.CantEntrada AS cantidad_devuelta, c.NomApe AS nombre_canilla SELECT esc.Id_Canilla AS id_canilla, esc.Fecha AS fecha, esc.CantSalida AS cantidad_enviada, esc.CantEntrada AS cantidad_devuelta, c.NomApe AS nombre_canilla
@@ -81,15 +84,16 @@ else:
cant_devuelta=row['cantidad_devuelta'], cant_devuelta=row['cantidad_devuelta'],
porc_devolucion=row['porcentaje_devolucion']) porc_devolucion=row['porcentaje_devolucion'])
else: else:
print("INFO: No se encontraron anomalías individuales significativas.") print("INFO: No se encontraron anomalías individuales significativas en canillitas.")
else: else:
print("INFO: No hay datos de canillitas para analizar en la fecha seleccionada.") print("INFO: No hay datos de canillitas para analizar en la fecha seleccionada.")
# --- 4. DETECCIÓN DE SISTEMA --- # --- FASE 2: Detección de Anomalías de Sistema ---
print("\n--- FASE 2: Detección de Anomalías de Sistema ---") print("\n--- FASE 2: Detección de Anomalías de Sistema ---")
if not os.path.exists(MODEL_SISTEMA_FILE): if not os.path.exists(MODEL_SISTEMA_FILE):
print(f"ADVERTENCIA: Modelo de sistema '{MODEL_SISTEMA_FILE}' no encontrado.") print(f"ADVERTENCIA: Modelo de sistema '{MODEL_SISTEMA_FILE}' no encontrado.")
else: else:
# ... (esta sección se mantiene exactamente igual que antes) ...
model_sistema = joblib.load(MODEL_SISTEMA_FILE) model_sistema = joblib.load(MODEL_SISTEMA_FILE)
query_agregada = f""" query_agregada = f"""
SELECT CAST(Fecha AS DATE) AS fecha_dia, DATEPART(weekday, Fecha) as dia_semana, SELECT CAST(Fecha AS DATE) AS fecha_dia, DATEPART(weekday, Fecha) as dia_semana,
@@ -128,7 +132,161 @@ else:
mensaje=mensaje, mensaje=mensaje,
fecha_anomalia=target_date.date()) fecha_anomalia=target_date.date())
# --- 5. Finalización --- # --- FASE 3: Detección de Anomalías Individuales (Distribuidores) ---
print("\n--- FASE 3: Detección de Anomalías Individuales (Distribuidores) ---")
if not os.path.exists(MODEL_DIST_FILE):
print(f"ADVERTENCIA: Modelo de distribuidores '{MODEL_DIST_FILE}' no encontrado.")
else:
model_dist = joblib.load(MODEL_DIST_FILE)
query_dist = f"""
SELECT
es.Id_Distribuidor AS id_distribuidor,
d.Nombre AS nombre_distribuidor,
CAST(es.Fecha AS DATE) AS fecha,
SUM(CASE WHEN es.TipoMovimiento = 'Salida' THEN es.Cantidad ELSE 0 END) as cantidad_enviada,
SUM(CASE WHEN es.TipoMovimiento = 'Entrada' THEN es.Cantidad ELSE 0 END) as cantidad_devuelta
FROM
dist_EntradasSalidas es
JOIN
dist_dtDistribuidores d ON es.Id_Distribuidor = d.Id_Distribuidor
WHERE
CAST(es.Fecha AS DATE) = '{target_date.strftime('%Y-%m-%d')}'
GROUP BY
es.Id_Distribuidor, d.Nombre, CAST(es.Fecha AS DATE)
HAVING
SUM(CASE WHEN es.TipoMovimiento = 'Salida' THEN es.Cantidad ELSE 0 END) > 0
"""
df_dist_new = pd.read_sql(query_dist, cnxn)
if not df_dist_new.empty:
df_dist_new['porcentaje_devolucion'] = (df_dist_new['cantidad_devuelta'] / df_dist_new['cantidad_enviada']).fillna(0) * 100
df_dist_new['dia_semana'] = pd.to_datetime(df_dist_new['fecha']).dt.dayofweek
features_dist = ['id_distribuidor', 'porcentaje_devolucion', 'dia_semana']
X_dist_new = df_dist_new[features_dist]
df_dist_new['anomalia'] = model_dist.predict(X_dist_new)
anomalias_dist_detectadas = df_dist_new[df_dist_new['anomalia'] == -1]
if not anomalias_dist_detectadas.empty:
for index, row in anomalias_dist_detectadas.iterrows():
mensaje = f"Devolución inusual del {row['porcentaje_devolucion']:.2f}% para el distribuidor '{row['nombre_distribuidor']}'."
insertar_alerta_en_db(cursor,
tipo_alerta='DevolucionAnomalaDist',
id_entidad=row['id_distribuidor'],
entidad='Distribuidor',
mensaje=mensaje,
fecha_anomalia=row['fecha'],
cant_enviada=row['cantidad_enviada'],
cant_devuelta=row['cantidad_devuelta'],
porc_devolucion=row['porcentaje_devolucion'])
else:
print("INFO: No se encontraron anomalías individuales significativas en distribuidores.")
else:
print("INFO: No hay datos de distribuidores para analizar en la fecha seleccionada.")
# --- FASE 4: Detección de Anomalías en Bobinas Dañadas ---
print("\n--- FASE 4: Detección de Anomalías en Bobinas Dañadas ---")
if not os.path.exists(MODEL_DANADAS_FILE):
print(f"ADVERTENCIA: Modelo de bobinas dañadas '{MODEL_DANADAS_FILE}' no encontrado.")
else:
model_danadas = joblib.load(MODEL_DANADAS_FILE)
query_danadas = f"""
SELECT
h.Id_Planta as id_planta,
p.Nombre as nombre_planta,
DATEPART(weekday, h.FechaMod) as dia_semana,
COUNT(DISTINCT h.Id_Bobina) as cantidad_danadas
FROM
bob_StockBobinas_H h
JOIN
bob_dtPlantas p ON h.Id_Planta = p.Id_Planta
WHERE
h.Id_EstadoBobina = 3 -- Asumiendo ID 3 para 'Dañada'
AND h.TipoMod = 'Estado: Dañada'
AND CAST(h.FechaMod AS DATE) = '{target_date.strftime('%Y-%m-%d')}'
GROUP BY
h.Id_Planta, p.Nombre, DATEPART(weekday, h.FechaMod)
"""
df_danadas_new = pd.read_sql(query_danadas, cnxn)
if not df_danadas_new.empty:
for index, row in df_danadas_new.iterrows():
features_danadas = ['id_planta', 'dia_semana', 'cantidad_danadas']
X_danadas_new = row[features_danadas].to_frame().T
prediction = model_danadas.predict(X_danadas_new)
if prediction[0] == -1:
mensaje = f"Se registraron {row['cantidad_danadas']} bobina(s) dañada(s) en la Planta '{row['nombre_planta']}', un valor inusualmente alto."
insertar_alerta_en_db(cursor,
tipo_alerta='ExcesoBobinasDañadas',
id_entidad=row['id_planta'],
entidad='Planta',
mensaje=mensaje,
fecha_anomalia=target_date.date())
print(f"INFO: Análisis de {len(df_danadas_new)} planta(s) con bobinas dañadas completado.")
else:
print("INFO: No se registraron bobinas dañadas en la fecha seleccionada.")
# --- FASE 5: Detección de Anomalías en Montos Contables ---
print("\n--- FASE 5: Detección de Anomalías en Montos Contables ---")
if not os.path.exists(MODEL_MONTOS_FILE):
print(f"ADVERTENCIA: Modelo de montos contables '{MODEL_MONTOS_FILE}' no encontrado.")
else:
model_montos = joblib.load(MODEL_MONTOS_FILE)
# Consulta unificada para obtener todas las transacciones del día
query_transacciones = f"""
SELECT 'Distribuidor' AS entidad, p.Id_Distribuidor AS id_entidad, d.Nombre as nombre_entidad, p.Id_Empresa as id_empresa, p.Fecha as fecha, p.TipoMovimiento as tipo_transaccion, p.Monto as monto
FROM cue_PagosDistribuidor p JOIN dist_dtDistribuidores d ON p.Id_Distribuidor = d.Id_Distribuidor
WHERE CAST(p.Fecha AS DATE) = '{target_date.strftime('%Y-%m-%d')}'
UNION ALL
SELECT
CASE WHEN cd.Destino = 'Distribuidores' THEN 'Distribuidor' ELSE 'Canillita' END AS entidad,
cd.Id_Destino AS id_entidad,
COALESCE(d.Nombre, c.NomApe) as nombre_entidad,
cd.Id_Empresa as id_empresa,
cd.Fecha as fecha,
cd.Tipo as tipo_transaccion,
cd.Monto as monto
FROM cue_CreditosDebitos cd
LEFT JOIN dist_dtDistribuidores d ON cd.Id_Destino = d.Id_Distribuidor AND cd.Destino = 'Distribuidores'
LEFT JOIN dist_dtCanillas c ON cd.Id_Destino = c.Id_Canilla AND cd.Destino = 'Canillas'
WHERE CAST(cd.Fecha AS DATE) = '{target_date.strftime('%Y-%m-%d')}'
"""
df_transacciones_new = pd.read_sql(query_transacciones, cnxn)
if not df_transacciones_new.empty:
# Aplicar exactamente el mismo pre-procesamiento que en el entrenamiento
df_transacciones_new['tipo_transaccion_cat'] = pd.Categorical(df_transacciones_new['tipo_transaccion']).codes
df_transacciones_new['dia_semana'] = pd.to_datetime(df_transacciones_new['fecha']).dt.dayofweek
features = ['id_entidad', 'id_empresa', 'tipo_transaccion_cat', 'dia_semana', 'monto']
X_new = df_transacciones_new[features]
df_transacciones_new['anomalia'] = model_montos.predict(X_new)
anomalias_detectadas = df_transacciones_new[df_transacciones_new['anomalia'] == -1]
if not anomalias_detectadas.empty:
for index, row in anomalias_detectadas.iterrows():
tipo_alerta = 'MontoInusualPago' if row['tipo_transaccion'] in ['Recibido', 'Realizado'] else 'MontoInusualNota'
mensaje = f"Se registró un '{row['tipo_transaccion']}' de ${row['monto']:,} para '{row['nombre_entidad']}', un valor atípico."
insertar_alerta_en_db(cursor,
tipo_alerta=tipo_alerta,
id_entidad=row['id_entidad'],
entidad=row['entidad'],
mensaje=mensaje,
fecha_anomalia=row['fecha'].date())
else:
print("INFO: No se encontraron anomalías en los montos contables registrados.")
else:
print("INFO: No hay transacciones contables para analizar en la fecha seleccionada.")
# --- Finalización ---
cnxn.commit() cnxn.commit()
cnxn.close() cnxn.close()
print("\n--- DETECCIÓN COMPLETA ---") print("\n--- DETECCIÓN COMPLETA ---")

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,63 @@
import pandas as pd
from sklearn.ensemble import IsolationForest
import joblib
import pyodbc
from datetime import datetime, timedelta
print("--- INICIANDO SCRIPT DE ENTRENAMIENTO (BOBINAS DAÑADAS) ---")
# --- 1. Configuración ---
DB_SERVER = 'TECNICA3'
DB_DATABASE = 'SistemaGestion'
CONNECTION_STRING = f'DRIVER={{ODBC Driver 18 for SQL Server}};SERVER={DB_SERVER};DATABASE={DB_DATABASE};Trusted_Connection=yes;TrustServerCertificate=yes;'
MODEL_FILE = 'modelo_danadas.joblib'
CONTAMINATION_RATE = 0.02 # Un 2% de los días podrían tener una cantidad anómala de bobinas dañadas (ajustable)
# --- 2. Carga de Datos desde SQL Server ---
try:
print(f"Conectando a la base de datos '{DB_DATABASE}' en '{DB_SERVER}'...")
cnxn = pyodbc.connect(CONNECTION_STRING)
fecha_limite = datetime.now() - timedelta(days=730)
query = f"""
SELECT
CAST(h.FechaMod AS DATE) as fecha,
DATEPART(weekday, h.FechaMod) as dia_semana,
h.Id_Planta as id_planta,
COUNT(DISTINCT h.Id_Bobina) as cantidad_danadas
FROM
bob_StockBobinas_H h
WHERE
h.Id_EstadoBobina = 3 -- 3 es el ID del estado 'Dañada'
AND h.FechaMod >= '{fecha_limite.strftime('%Y-%m-%d')}'
GROUP BY
CAST(h.FechaMod AS DATE),
DATEPART(weekday, h.FechaMod),
h.Id_Planta
"""
print("Ejecutando consulta para obtener historial de bobinas dañadas...")
df = pd.read_sql(query, cnxn)
cnxn.close()
except Exception as e:
print(f"Error al conectar o consultar la base de datos: {e}")
exit()
if df.empty:
print("No se encontraron datos de entrenamiento de bobinas dañadas en el último año. Saliendo.")
exit()
# --- 3. Preparación de Datos ---
print(f"Preparando {len(df)} registros agregados para el entrenamiento...")
# Las características serán la planta, el día de la semana y la cantidad de bobinas dañadas ese día
features = ['id_planta', 'dia_semana', 'cantidad_danadas']
X = df[features]
# --- 4. Entrenamiento y Guardado ---
print(f"Entrenando el modelo de bobinas dañadas con tasa de contaminación de {CONTAMINATION_RATE}...")
model = IsolationForest(n_estimators=100, contamination=CONTAMINATION_RATE, random_state=42)
model.fit(X)
joblib.dump(model, MODEL_FILE)
print(f"--- ENTRENAMIENTO DE BOBINAS DAÑADAS COMPLETADO. Modelo guardado en '{MODEL_FILE}' ---")

View File

@@ -0,0 +1,68 @@
import pandas as pd
from sklearn.ensemble import IsolationForest
import joblib
import pyodbc
from datetime import datetime, timedelta
print("--- INICIANDO SCRIPT DE ENTRENAMIENTO (DISTRIBUIDORES) ---")
# --- 1. Configuración ---
DB_SERVER = 'TECNICA3'
DB_DATABASE = 'SistemaGestion'
CONNECTION_STRING = f'DRIVER={{ODBC Driver 18 for SQL Server}};SERVER={DB_SERVER};DATABASE={DB_DATABASE};Trusted_Connection=yes;TrustServerCertificate=yes;'
MODEL_FILE = 'modelo_anomalias_dist.joblib'
CONTAMINATION_RATE = 0.01 # Un 1% de contaminación
# --- 2. Carga de Datos desde SQL Server ---
try:
print(f"Conectando a la base de datos '{DB_DATABASE}' en '{DB_SERVER}'...")
cnxn = pyodbc.connect(CONNECTION_STRING)
fecha_limite = datetime.now() - timedelta(days=730)
query = f"""
SELECT
Id_Distribuidor AS id_distribuidor,
CAST(Fecha AS DATE) AS fecha,
SUM(CASE WHEN TipoMovimiento = 'Salida' THEN Cantidad ELSE 0 END) as cantidad_enviada,
SUM(CASE WHEN TipoMovimiento = 'Entrada' THEN Cantidad ELSE 0 END) as cantidad_devuelta
FROM
dist_EntradasSalidas
WHERE
Fecha >= '{fecha_limite.strftime('%Y-%m-%d')}'
GROUP BY
Id_Distribuidor, CAST(Fecha AS DATE)
HAVING
SUM(CASE WHEN TipoMovimiento = 'Salida' THEN Cantidad ELSE 0 END) > 0
"""
print("Ejecutando consulta para obtener datos de entrenamiento de distribuidores...")
df = pd.read_sql(query, cnxn)
cnxn.close()
except Exception as e:
print(f"Error al conectar o consultar la base de datos: {e}")
exit()
if df.empty:
print("No se encontraron datos de entrenamiento de distribuidores en el último año. Saliendo.")
exit()
# --- 3. Preparación de Datos ---
print(f"Preparando {len(df)} registros para el entrenamiento del modelo de distribuidores...")
# Se usa (df['cantidad_enviada'] + 0.001) para evitar división por cero
df['porcentaje_devolucion'] = (df['cantidad_devuelta'] / (df['cantidad_enviada'] + 0.001)) * 100
df.fillna(0, inplace=True)
df['porcentaje_devolucion'] = df['porcentaje_devolucion'].clip(0, 100)
df['dia_semana'] = pd.to_datetime(df['fecha']).dt.dayofweek
features = ['id_distribuidor', 'porcentaje_devolucion', 'dia_semana']
X = df[features]
# --- 4. Entrenamiento y Guardado ---
print(f"Entrenando el modelo con tasa de contaminación de {CONTAMINATION_RATE}...")
model = IsolationForest(n_estimators=100, contamination=CONTAMINATION_RATE, random_state=42)
model.fit(X)
joblib.dump(model, MODEL_FILE)
print(f"--- ENTRENAMIENTO DE DISTRIBUIDORES COMPLETADO. Modelo guardado en '{MODEL_FILE}' ---")

View File

@@ -0,0 +1,92 @@
import pandas as pd
from sklearn.ensemble import IsolationForest
import joblib
import pyodbc
from datetime import datetime, timedelta
print("--- INICIANDO SCRIPT DE ENTRENAMIENTO (MONTOS CONTABLES) ---")
# --- 1. Configuración ---
DB_SERVER = 'TECNICA3'
DB_DATABASE = 'SistemaGestion'
CONNECTION_STRING = f'DRIVER={{ODBC Driver 18 for SQL Server}};SERVER={DB_SERVER};DATABASE={DB_DATABASE};Trusted_Connection=yes;TrustServerCertificate=yes;'
MODEL_FILE = 'modelo_montos.joblib'
CONTAMINATION_RATE = 0.002
# --- 2. Carga de Datos de Múltiples Tablas ---
try:
print(f"Conectando a la base de datos '{DB_DATABASE}' en '{DB_SERVER}'...")
cnxn = pyodbc.connect(CONNECTION_STRING)
fecha_limite = datetime.now() - timedelta(days=730) # Usamos 2 años de datos para tener más contexto financiero
# Query para Pagos a Distribuidores
query_pagos = f"""
SELECT
'Distribuidor' AS entidad,
Id_Distribuidor AS id_entidad,
Id_Empresa AS id_empresa,
Fecha AS fecha,
TipoMovimiento AS tipo_transaccion,
Monto AS monto
FROM
cue_PagosDistribuidor
WHERE
Fecha >= '{fecha_limite.strftime('%Y-%m-%d')}'
"""
# Query para Notas de Crédito/Débito
query_notas = f"""
SELECT
CASE
WHEN Destino = 'Distribuidores' THEN 'Distribuidor'
WHEN Destino = 'Canillas' THEN 'Canillita'
ELSE 'Desconocido'
END AS entidad,
Id_Destino AS id_entidad,
Id_Empresa AS id_empresa,
Fecha AS fecha,
Tipo AS tipo_transaccion,
Monto AS monto
FROM
cue_CreditosDebitos
WHERE
Fecha >= '{fecha_limite.strftime('%Y-%m-%d')}'
"""
print("Ejecutando consultas para obtener datos de pagos y notas...")
df_pagos = pd.read_sql(query_pagos, cnxn)
df_notas = pd.read_sql(query_notas, cnxn)
cnxn.close()
except Exception as e:
print(f"Error al conectar o consultar la base de datos: {e}")
exit()
# --- 3. Unificación y Preparación de Datos ---
if df_pagos.empty and df_notas.empty:
print("No se encontraron datos de entrenamiento en el período seleccionado. Saliendo.")
exit()
# Combinamos ambos dataframes
df = pd.concat([df_pagos, df_notas], ignore_index=True)
print(f"Preparando {len(df)} registros contables para el entrenamiento...")
# Feature Engineering: Convertir textos a números categóricos
# Esto ayuda al modelo a entender "Recibido", "Credito", etc., como categorías distintas.
df['tipo_transaccion_cat'] = pd.Categorical(df['tipo_transaccion']).codes
df['dia_semana'] = df['fecha'].dt.dayofweek
# Las características para el modelo serán el contexto de la transacción y su monto
features = ['id_entidad', 'id_empresa', 'tipo_transaccion_cat', 'dia_semana', 'monto']
X = df[features]
# --- 4. Entrenamiento y Guardado ---
print(f"Entrenando el modelo de montos contables con tasa de contaminación de {CONTAMINATION_RATE}...")
model = IsolationForest(n_estimators=100, contamination=CONTAMINATION_RATE, random_state=42)
model.fit(X)
joblib.dump(model, MODEL_FILE)
print(f"--- ENTRENAMIENTO DE MONTOS COMPLETADO. Modelo guardado en '{MODEL_FILE}' ---")