Compare commits
3 Commits
main
...
Dev-Anomal
| Author | SHA1 | Date | |
|---|---|---|---|
| 1020555db6 | |||
| 615cf282a1 | |||
| d040099b9a |
@@ -2,7 +2,6 @@ using GestionIntegral.Api.Dtos.Impresion;
|
||||
using GestionIntegral.Api.Services.Impresion;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
@@ -41,7 +40,6 @@ namespace GestionIntegral.Api.Controllers.Impresion
|
||||
return null;
|
||||
}
|
||||
|
||||
// GET: api/stockbobinas
|
||||
// GET: api/stockbobinas
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IEnumerable<StockBobinaDto>), StatusCodes.Status200OK)]
|
||||
@@ -49,23 +47,12 @@ namespace GestionIntegral.Api.Controllers.Impresion
|
||||
public async Task<IActionResult> GetAllStockBobinas(
|
||||
[FromQuery] int? idTipoBobina, [FromQuery] string? nroBobina, [FromQuery] int? idPlanta,
|
||||
[FromQuery] int? idEstadoBobina, [FromQuery] string? remito,
|
||||
[FromQuery] DateTime? fechaDesde, [FromQuery] DateTime? fechaHasta,
|
||||
[FromQuery] DateTime? fechaEstadoDesde, [FromQuery] DateTime? fechaEstadoHasta) // <--- Nuevos parámetros agregados
|
||||
[FromQuery] DateTime? fechaDesde, [FromQuery] DateTime? fechaHasta)
|
||||
{
|
||||
if (!TienePermiso(PermisoVerStock)) return Forbid();
|
||||
try
|
||||
{
|
||||
var bobinas = await _stockBobinaService.ObtenerTodosAsync(
|
||||
idTipoBobina,
|
||||
nroBobina,
|
||||
idPlanta,
|
||||
idEstadoBobina,
|
||||
remito,
|
||||
fechaDesde,
|
||||
fechaHasta,
|
||||
fechaEstadoDesde,
|
||||
fechaEstadoHasta
|
||||
);
|
||||
var bobinas = await _stockBobinaService.ObtenerTodosAsync(idTipoBobina, nroBobina, idPlanta, idEstadoBobina, remito, fechaDesde, fechaHasta);
|
||||
return Ok(bobinas);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -185,72 +172,5 @@ namespace GestionIntegral.Api.Controllers.Impresion
|
||||
}
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,7 +148,7 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
|
||||
|
||||
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).AlignRight().Text(item.Cant.ToString("N0"));
|
||||
@@ -162,6 +162,7 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
|
||||
var general = Model.PromedioGeneral;
|
||||
if (general != null)
|
||||
{
|
||||
var porcDevolucionGeneral = general.Promedio_Llevados > 0 ? (decimal)general.Promedio_Devueltos * 100 / general.Promedio_Llevados : 0;
|
||||
var boldStyle = TextStyle.Default.SemiBold();
|
||||
|
||||
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_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(Model.PorcentajeDevolucionGeneral.ToString("F2") + "%").Style(boldStyle));
|
||||
table.Cell().Border(1).Padding(3).AlignRight().Text(text => text.Span(porcDevolucionGeneral.ToString("F2") + "%").Style(boldStyle));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,7 +90,7 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
|
||||
var llevados = item.Llevados ?? 0;
|
||||
var devueltos = item.Devueltos ?? 0;
|
||||
var ventaNetaDia = llevados - devueltos;
|
||||
if (llevados > 0)
|
||||
if(llevados > 0)
|
||||
{
|
||||
ventaNetaAcumulada += ventaNetaDia;
|
||||
conteoDias++;
|
||||
@@ -123,7 +123,7 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
|
||||
|
||||
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 =>
|
||||
{
|
||||
@@ -161,16 +161,16 @@ namespace GestionIntegral.Api.Controllers.Reportes.PdfTemplates
|
||||
if (general != null)
|
||||
{
|
||||
var boldStyle = TextStyle.Default.SemiBold();
|
||||
var avgPercentage = Model.PromediosPorDia
|
||||
.Where(p => p.Dia != "General" && (p.Promedio_Llevados ?? 0) > 0)
|
||||
.Average(p => (decimal)(p.Promedio_Devueltos ?? 0) * 100 / (p.Promedio_Llevados ?? 1));
|
||||
var llevadosGeneral = general.Llevados ?? 0; // Usamos el total para el %
|
||||
var devueltosGeneral = general.Devueltos ?? 0; // Usamos el total para el %
|
||||
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).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_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(avgPercentage.ToString("F2") + "%").Style(boldStyle));
|
||||
table.Cell().Border(1).Padding(3).AlignRight().Text(t => t.Span(porcDevolucionGeneral.ToString("F2") + "%").Style(boldStyle));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
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
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
|
||||
public async Task<EntradaSalidaDist?> CreateAsync(EntradaSalidaDist nuevaES, int idUsuario, IDbTransaction transaction)
|
||||
{
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
|
||||
Task<EntradaSalidaDist?> CreateAsync(EntradaSalidaDist nuevaES, int idUsuario, IDbTransaction transaction);
|
||||
Task<bool> UpdateAsync(EntradaSalidaDist esAActualizar, 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(
|
||||
DateTime? fechaDesde, DateTime? fechaHasta,
|
||||
int? idUsuarioModifico, string? tipoModificacion,
|
||||
|
||||
@@ -15,9 +15,7 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
|
||||
int? idEstadoBobina,
|
||||
string? remitoFilter,
|
||||
DateTime? fechaDesde,
|
||||
DateTime? fechaHasta,
|
||||
DateTime? fechaEstadoDesde,
|
||||
DateTime? fechaEstadoHasta);
|
||||
DateTime? fechaHasta);
|
||||
|
||||
Task<StockBobina?> GetByIdAsync(int idBobina);
|
||||
Task<StockBobina?> GetByNroBobinaAsync(string nroBobina); // Para validar unicidad de NroBobina
|
||||
|
||||
@@ -23,7 +23,7 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
|
||||
|
||||
public async Task<IEnumerable<StockBobina>> GetAllAsync(
|
||||
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(@"
|
||||
SELECT
|
||||
@@ -69,16 +69,6 @@ namespace GestionIntegral.Api.Data.Repositories.Distribucion
|
||||
sqlBuilder.Append(" AND sb.FechaRemito <= @FechaHastaParam");
|
||||
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;");
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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" ---
|
||||
public ListadoDistribucionCanillasPromedioDiaDto? PromedioGeneral
|
||||
{
|
||||
@@ -53,27 +37,20 @@ namespace GestionIntegral.Api.Dtos.Reportes.ViewModels
|
||||
{
|
||||
if (PromediosPorDia == null || !PromediosPorDia.Any()) return null;
|
||||
|
||||
// Sumamos los totales ponderados para cada columna
|
||||
var totalPonderadoLlevados = PromediosPorDia.Sum(p => p.Promedio_Llevados * p.Cant);
|
||||
var totalPonderadoDevueltos = PromediosPorDia.Sum(p => p.Promedio_Devueltos * p.Cant);
|
||||
var totalPonderadoVentas = PromediosPorDia.Sum(p => p.Promedio_Ventas * p.Cant);
|
||||
// Sumamos los totales, no promediamos los promedios
|
||||
var totalLlevados = PromediosPorDia.Sum(p => p.Llevados);
|
||||
var totalDevueltos = PromediosPorDia.Sum(p => p.Devueltos);
|
||||
var totalDias = PromediosPorDia.Sum(p => p.Cant);
|
||||
|
||||
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
|
||||
{
|
||||
Dia = "General",
|
||||
Cant = totalDias,
|
||||
Promedio_Llevados = promGeneralLlevados,
|
||||
Promedio_Devueltos = promGeneralDevueltos,
|
||||
Promedio_Ventas = promGeneralVentas
|
||||
Promedio_Llevados = totalLlevados / totalDias,
|
||||
Promedio_Devueltos = totalDevueltos / totalDias,
|
||||
Promedio_Ventas = (totalLlevados - totalDevueltos) / totalDias
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,26 +20,26 @@ namespace GestionIntegral.Api.Dtos.Reportes.ViewModels
|
||||
{
|
||||
get
|
||||
{
|
||||
if (PromediosPorDia == null || !PromediosPorDia.Any())
|
||||
if (DetalleDiario == null || !DetalleDiario.Any())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var promediosValidos = PromediosPorDia.Where(p => p.Dia != "General").ToList();
|
||||
if (!promediosValidos.Any()) return null;
|
||||
var countPromedios = promediosValidos.Count;
|
||||
var sumPromLlevados = promediosValidos.Sum(p => p.Promedio_Llevados ?? 0);
|
||||
var sumPromDevueltos = promediosValidos.Sum(p => p.Promedio_Devueltos ?? 0);
|
||||
var sumPromVentas = promediosValidos.Sum(p => p.Promedio_Ventas ?? 0);
|
||||
|
||||
var diasConDatos = DetalleDiario.Count(d => (d.Llevados ?? 0) > 0);
|
||||
if (diasConDatos == 0) return null;
|
||||
|
||||
var totalLlevados = DetalleDiario.Sum(d => d.Llevados ?? 0);
|
||||
var totalDevueltos = DetalleDiario.Sum(d => d.Devueltos ?? 0);
|
||||
|
||||
return new ListadoDistribucionDistPromedioDiaDto
|
||||
{
|
||||
Dia = "General",
|
||||
Cant = promediosValidos.Sum(p => p.Cant ?? 0),
|
||||
Promedio_Llevados = (int)Math.Round((decimal)sumPromLlevados / countPromedios, MidpointRounding.AwayFromZero),
|
||||
Promedio_Devueltos = (int)Math.Round((decimal)sumPromDevueltos / countPromedios, MidpointRounding.AwayFromZero),
|
||||
Promedio_Ventas = (int)Math.Round((decimal)sumPromVentas / countPromedios, MidpointRounding.AwayFromZero),
|
||||
Llevados = (int)Math.Round((decimal)sumPromLlevados / countPromedios, MidpointRounding.AwayFromZero),
|
||||
Devueltos = (int)Math.Round((decimal)sumPromDevueltos / countPromedios, MidpointRounding.AwayFromZero)
|
||||
Cant = diasConDatos,
|
||||
Promedio_Llevados = totalLlevados / diasConDatos,
|
||||
Promedio_Devueltos = totalDevueltos / diasConDatos,
|
||||
Promedio_Ventas = (totalLlevados - totalDevueltos) / diasConDatos,
|
||||
Llevados = totalLlevados, // Guardamos el total para el cálculo del %
|
||||
Devueltos = totalDevueltos // Guardamos el total para el cálculo del %
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ builder.Services.AddCors(options =>
|
||||
policy =>
|
||||
{
|
||||
policy.WithOrigins(
|
||||
"http://localhost:5175", // Para desarrollo local
|
||||
"http://localhost:5173", // Para desarrollo local
|
||||
"https://gestion.eldiaservicios.com" // Para producción
|
||||
)
|
||||
.AllowAnyHeader()
|
||||
|
||||
@@ -167,11 +167,10 @@ namespace GestionIntegral.Api.Services.Distribucion
|
||||
var distribuidor = await _distribuidorRepository.GetByIdSimpleAsync(createDto.IdDistribuidor);
|
||||
if (distribuidor == null) return (null, "Distribuidor no válido.");
|
||||
|
||||
/*
|
||||
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.");
|
||||
}*/
|
||||
}
|
||||
|
||||
// Determinar IDs de Precio, Recargo y Porcentaje activos en la fecha del movimiento
|
||||
var precioActivo = await _precioRepository.GetActiveByPublicacionAndDateAsync(createDto.IdPublicacion, createDto.Fecha.Date);
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace GestionIntegral.Api.Services.Impresion
|
||||
{
|
||||
Task<IEnumerable<StockBobinaDto>> ObtenerTodosAsync(
|
||||
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? Bobina, string? Error)> IngresarBobinaAsync(CreateStockBobinaDto createDto, int idUsuario);
|
||||
@@ -21,8 +21,5 @@ namespace GestionIntegral.Api.Services.Impresion
|
||||
DateTime? fechaDesde, DateTime? fechaHasta,
|
||||
int? idUsuarioModifico, string? tipoModificacion,
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -85,9 +85,9 @@ namespace GestionIntegral.Api.Services.Impresion
|
||||
|
||||
public async Task<IEnumerable<StockBobinaDto>> ObtenerTodosAsync(
|
||||
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>();
|
||||
foreach (var bobina in bobinas)
|
||||
{
|
||||
@@ -166,16 +166,16 @@ namespace GestionIntegral.Api.Services.Impresion
|
||||
}
|
||||
if (await _tipoBobinaRepository.GetByIdAsync(updateDto.IdTipoBobina) == null)
|
||||
return (false, "Tipo de bobina inválido.");
|
||||
//if (await _plantaRepository.GetByIdAsync(updateDto.IdPlanta) == null)
|
||||
// return (false, "Planta inválida.");
|
||||
if (await _plantaRepository.GetByIdAsync(updateDto.IdPlanta) == null)
|
||||
return (false, "Planta inválida.");
|
||||
|
||||
|
||||
bobinaExistente.IdTipoBobina = updateDto.IdTipoBobina;
|
||||
bobinaExistente.NroBobina = updateDto.NroBobina;
|
||||
bobinaExistente.Peso = updateDto.Peso;
|
||||
//bobinaExistente.IdPlanta = updateDto.IdPlanta;
|
||||
//bobinaExistente.Remito = updateDto.Remito;
|
||||
//bobinaExistente.FechaRemito = updateDto.FechaRemito.Date;
|
||||
bobinaExistente.IdPlanta = updateDto.IdPlanta;
|
||||
bobinaExistente.Remito = updateDto.Remito;
|
||||
bobinaExistente.FechaRemito = updateDto.FechaRemito.Date;
|
||||
// FechaEstado se mantiene ya que el estado no cambia aquí
|
||||
|
||||
var actualizado = await _stockBobinaRepository.UpdateAsync(bobinaExistente, idUsuario, transaction, "Datos Actualizados");
|
||||
@@ -383,153 +383,5 @@ namespace GestionIntegral.Api.Services.Impresion
|
||||
TipoMod = h.Historial.TipoMod
|
||||
}).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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,5 +0,0 @@
|
||||
export interface BobinaLoteDetalleDto {
|
||||
idTipoBobina: number;
|
||||
nroBobina: string;
|
||||
peso: number;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { BobinaLoteDetalleDto } from './BobinaLoteDetalleDto';
|
||||
|
||||
export interface CreateStockBobinaLoteDto {
|
||||
idPlanta: number;
|
||||
remito: string;
|
||||
fechaRemito: string; // "yyyy-MM-dd"
|
||||
bobinas: BobinaLoteDetalleDto[];
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export interface UpdateFechaRemitoLoteDto {
|
||||
idPlanta: number;
|
||||
remito: string;
|
||||
fechaRemitoActual: string; // "yyyy-MM-dd"
|
||||
nuevaFechaRemito: string; // "yyyy-MM-dd"
|
||||
}
|
||||
@@ -135,9 +135,9 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
if (!filtroFecha || !filtroIdCanillitaSeleccionado) {
|
||||
if (loading) setLoading(false);
|
||||
setMovimientos([]);
|
||||
return;
|
||||
if (loading) setLoading(false);
|
||||
setMovimientos([]);
|
||||
return;
|
||||
}
|
||||
setLoading(true); setError(null); setApiErrorMessage(null);
|
||||
try {
|
||||
@@ -164,37 +164,37 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (filtroFecha && filtroIdCanillitaSeleccionado) {
|
||||
cargarMovimientos();
|
||||
cargarMovimientos();
|
||||
} else {
|
||||
setMovimientos([]);
|
||||
if (loading) setLoading(false);
|
||||
setMovimientos([]);
|
||||
if (loading) setLoading(false);
|
||||
}
|
||||
}, [cargarMovimientos, filtroFecha, filtroIdCanillitaSeleccionado]);
|
||||
|
||||
const handleOpenModal = (item?: EntradaSalidaCanillaDto) => {
|
||||
if (!puedeCrear && !item) {
|
||||
setApiErrorMessage("No tiene permiso para registrar nuevos movimientos.");
|
||||
return;
|
||||
setApiErrorMessage("No tiene permiso para registrar nuevos movimientos.");
|
||||
return;
|
||||
}
|
||||
if (item && !puedeModificar) {
|
||||
setApiErrorMessage("No tiene permiso para modificar movimientos.");
|
||||
return;
|
||||
setApiErrorMessage("No tiene permiso para modificar movimientos.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (item) {
|
||||
setEditingMovimiento(item);
|
||||
setPrefillModalData(null);
|
||||
setEditingMovimiento(item);
|
||||
setPrefillModalData(null);
|
||||
} else {
|
||||
const canillitaSeleccionado = destinatariosDropdown.find(
|
||||
c => c.idCanilla === Number(filtroIdCanillitaSeleccionado)
|
||||
);
|
||||
setEditingMovimiento(null);
|
||||
setPrefillModalData({
|
||||
fecha: filtroFecha,
|
||||
idCanilla: filtroIdCanillitaSeleccionado,
|
||||
nombreCanilla: canillitaSeleccionado?.nomApe,
|
||||
idPublicacion: filtroIdPublicacion
|
||||
});
|
||||
const canillitaSeleccionado = destinatariosDropdown.find(
|
||||
c => c.idCanilla === Number(filtroIdCanillitaSeleccionado)
|
||||
);
|
||||
setEditingMovimiento(null);
|
||||
setPrefillModalData({
|
||||
fecha: filtroFecha,
|
||||
idCanilla: filtroIdCanillitaSeleccionado,
|
||||
nombreCanilla: canillitaSeleccionado?.nomApe,
|
||||
idPublicacion: filtroIdPublicacion
|
||||
});
|
||||
}
|
||||
setApiErrorMessage(null);
|
||||
setModalOpen(true);
|
||||
@@ -224,7 +224,7 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
|
||||
});
|
||||
};
|
||||
const handleSelectAllForLiquidar = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (event.target.checked) {
|
||||
if (event.target.checked) {
|
||||
const newSelectedIds = new Set(movimientos.filter(m => !m.liquidado).map(m => m.idParte));
|
||||
setSelectedIdsParaLiquidar(newSelectedIds);
|
||||
} else {
|
||||
@@ -248,17 +248,17 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
|
||||
const fechaLiquidacionDate = new Date(fechaLiquidacionDialog + 'T00:00:00Z');
|
||||
let fechaMovimientoMasReciente: Date | null = null;
|
||||
selectedIdsParaLiquidar.forEach(idParte => {
|
||||
const movimiento = movimientos.find(m => m.idParte === idParte);
|
||||
if (movimiento && movimiento.fecha) {
|
||||
const movFecha = new Date(movimiento.fecha.split('T')[0] + 'T00:00:00Z');
|
||||
if (fechaMovimientoMasReciente === null || movFecha.getTime() > (fechaMovimientoMasReciente as Date).getTime()) {
|
||||
fechaMovimientoMasReciente = movFecha;
|
||||
const movimiento = movimientos.find(m => m.idParte === idParte);
|
||||
if (movimiento && movimiento.fecha) {
|
||||
const movFecha = new Date(movimiento.fecha.split('T')[0] + 'T00:00:00Z');
|
||||
if (fechaMovimientoMasReciente === null || movFecha.getTime() > (fechaMovimientoMasReciente as Date).getTime()) {
|
||||
fechaMovimientoMasReciente = movFecha;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
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' })}).`);
|
||||
return;
|
||||
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;
|
||||
}
|
||||
setApiErrorMessage(null);
|
||||
setLoading(true);
|
||||
@@ -277,9 +277,9 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
|
||||
if (movimientoParaTicket && !movimientoParaTicket.canillaEsAccionista) {
|
||||
console.log("Liquidación exitosa, generando ticket para canillita NO accionista:", movimientoParaTicket.idCanilla);
|
||||
await handleImprimirTicketLiquidacion(
|
||||
movimientoParaTicket.idCanilla,
|
||||
movimientoParaTicket.fecha,
|
||||
false
|
||||
movimientoParaTicket.idCanilla,
|
||||
movimientoParaTicket.fecha,
|
||||
false
|
||||
);
|
||||
} else if (movimientoParaTicket && movimientoParaTicket.canillaEsAccionista) {
|
||||
console.log("Liquidación exitosa para accionista. No se genera ticket automáticamente.");
|
||||
@@ -346,11 +346,7 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
|
||||
|
||||
const totalARendirVisible = useMemo(() =>
|
||||
displayData.filter(m => !m.liquidado).reduce((sum, item) => sum + item.montoARendir, 0)
|
||||
, [displayData]);
|
||||
|
||||
const montoARendirAll = useMemo(() =>
|
||||
movimientos.reduce((sum, item) => sum + item.montoARendir, 0)
|
||||
, [movimientos]);
|
||||
, [displayData]);
|
||||
|
||||
if (!puedeVer) {
|
||||
return (
|
||||
@@ -371,7 +367,7 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>Filtros <FilterListIcon fontSize="small" /></Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, alignItems: 'center', mb: 2 }}>
|
||||
<TextField label="Fecha" type="date" size="small" value={filtroFecha}
|
||||
<TextField label="Fecha" type="date" size="small" value={filtroFecha}
|
||||
onChange={(e) => setFiltroFecha(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }} sx={{ minWidth: 170 }}
|
||||
required
|
||||
@@ -402,9 +398,9 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
|
||||
onChange={(e) => setFiltroIdCanillitaSeleccionado(e.target.value as number | string)}
|
||||
>
|
||||
<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>
|
||||
{!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 size="small" sx={{ minWidth: 180, flexGrow: 1 }} disabled={loadingFiltersDropdown}>
|
||||
@@ -415,15 +411,15 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
|
||||
</Select>
|
||||
</FormControl>
|
||||
</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 && (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => handleOpenModal()}
|
||||
disabled={!filtroFecha || !filtroIdCanillitaSeleccionado}
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => handleOpenModal()}
|
||||
disabled={!filtroFecha || !filtroIdCanillitaSeleccionado}
|
||||
>
|
||||
Registrar Movimiento
|
||||
Registrar Movimiento
|
||||
</Button>
|
||||
)}
|
||||
{puedeLiquidar && numSelectedToLiquidate > 0 && movimientos.some(m => selectedIdsParaLiquidar.has(m.idParte) && !m.liquidado) && (
|
||||
@@ -434,38 +430,31 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{!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 && <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>}
|
||||
|
||||
{loading && <Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}><CircularProgress /></Box>}
|
||||
{error && !loading && !apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{error}</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 && (
|
||||
<Paper sx={{ p: 1.5, mb: 2, mt: 1, backgroundColor: 'grey.100' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center' }}>
|
||||
<Typography variant="subtitle1" sx={{ mr: 1 }}>
|
||||
Total:
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ paddingRight: '5px', fontWeight: 'bold', color: 'text.main' }}>
|
||||
{montoARendirAll.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}
|
||||
</Typography>
|
||||
-
|
||||
<Typography variant="subtitle1" sx={{ mr: 1, paddingLeft: '5px', }}>
|
||||
Total a Liquidar:
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold', color: totalARendirVisible > 0 ? 'error.main' : 'green' }}>
|
||||
{totalARendirVisible.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Paper sx={{ p: 1.5, mb: 2, mt:1, backgroundColor: 'grey.100' }}>
|
||||
<Box sx={{display: 'flex', justifyContent: 'flex-end', alignItems: 'center'}}>
|
||||
<Typography variant="subtitle1" sx={{mr:2}}>
|
||||
Total a Liquidar:
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{fontWeight: 'bold', color: 'error.main'}}>
|
||||
{totalARendirVisible.toLocaleString('es-AR', { style: 'currency', currency: 'ARS' })}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{!loading && !error && puedeVer && filtroFecha && filtroIdCanillitaSeleccionado && (
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{puedeLiquidar && (
|
||||
@@ -556,7 +545,7 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}>
|
||||
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}>
|
||||
{puedeModificar && selectedRow && !selectedRow.liquidado && (
|
||||
<MenuItem onClick={() => { handleOpenModal(selectedRow); handleMenuClose(); }}><EditIcon fontSize="small" sx={{ mr: 1 }} /> Modificar</MenuItem>)}
|
||||
|
||||
@@ -571,7 +560,7 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
|
||||
selectedRow.canillaEsAccionista
|
||||
);
|
||||
}
|
||||
handleMenuClose();
|
||||
handleMenuClose();
|
||||
}}
|
||||
disabled={loadingTicketPdf}
|
||||
>
|
||||
@@ -583,9 +572,9 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
|
||||
{selectedRow && (
|
||||
((!selectedRow.liquidado && puedeEliminar) || (selectedRow.liquidado && puedeEliminarLiquidados))
|
||||
) && (
|
||||
<MenuItem onClick={() => { if (selectedRow) handleDelete(selectedRow.idParte); }}>
|
||||
<DeleteIcon fontSize="small" sx={{ mr: 1 }} />
|
||||
Eliminar
|
||||
<MenuItem onClick={() => {if (selectedRow) handleDelete(selectedRow.idParte);}}>
|
||||
<DeleteIcon fontSize="small" sx={{ mr: 1 }} />
|
||||
Eliminar
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
@@ -600,7 +589,7 @@ const GestionarEntradasSalidasCanillaPage: React.FC = () => {
|
||||
clearErrorMessage={() => setApiErrorMessage(null)}
|
||||
/>
|
||||
|
||||
<Dialog open={openLiquidarDialog} onClose={handleCloseLiquidarDialog}>
|
||||
<Dialog open={openLiquidarDialog} onClose={handleCloseLiquidarDialog}>
|
||||
<DialogTitle>Confirmar Liquidación</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
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';
|
||||
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 MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
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 SearchIcon from '@mui/icons-material/Search';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import EditCalendarIcon from '@mui/icons-material/EditCalendar';
|
||||
|
||||
import stockBobinaService from '../../services/Impresion/stockBobinaService';
|
||||
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 { PlantaDto } from '../../models/dtos/Impresion/PlantaDto';
|
||||
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 StockBobinaEditFormModal from '../../components/Modals/Impresion/StockBobinaEditFormModal';
|
||||
import StockBobinaCambioEstadoModal from '../../components/Modals/Impresion/StockBobinaCambioEstadoModal';
|
||||
import StockBobinaLoteFormModal from '../../components/Modals/Impresion/StockBobinaLoteFormModal';
|
||||
|
||||
import { usePermissions } from '../../hooks/usePermissions';
|
||||
import axios from 'axios';
|
||||
@@ -44,27 +38,20 @@ const ID_ESTADO_DANADA = 3;
|
||||
|
||||
const GestionarStockBobinasPage: React.FC = () => {
|
||||
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 [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null);
|
||||
|
||||
// --- Estados de los filtros ---
|
||||
// Estados de los filtros
|
||||
const [filtroTipoBobina, setFiltroTipoBobina] = useState<number | string>('');
|
||||
const [filtroNroBobina, setFiltroNroBobina] = useState('');
|
||||
const [filtroPlanta, setFiltroPlanta] = useState<number | string>('');
|
||||
const [filtroEstadoBobina, setFiltroEstadoBobina] = useState<number | string>('');
|
||||
const [filtroRemito, setFiltroRemito] = useState('');
|
||||
|
||||
// Filtro Fechas Remito
|
||||
const [filtroFechaHabilitado, setFiltroFechaHabilitado] = useState<boolean>(false);
|
||||
const [filtroFechaHabilitado, setFiltroFechaHabilitado] = useState<boolean>(false); // <-- NUEVO
|
||||
const [filtroFechaDesde, setFiltroFechaDesde] = 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
|
||||
const [tiposBobina, setTiposBobina] = useState<TipoBobinaDto[]>([]);
|
||||
const [plantas, setPlantas] = useState<PlantaDto[]>([]);
|
||||
@@ -75,10 +62,13 @@ const GestionarStockBobinasPage: React.FC = () => {
|
||||
const [ingresoModalOpen, setIngresoModalOpen] = useState(false);
|
||||
const [editModalOpen, setEditModalOpen] = 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 [selectedBobinaForRowMenu, setSelectedBobinaForRowMenu] = useState<StockBobinaDto | null>(null);
|
||||
|
||||
@@ -89,9 +79,12 @@ const GestionarStockBobinasPage: React.FC = () => {
|
||||
const puedeModificarDatos = isSuperAdmin || tienePermiso("IB004");
|
||||
const puedeEliminar = isSuperAdmin || tienePermiso("IB005");
|
||||
|
||||
const lastOpenedMenuButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const fetchFiltersDropdownData = useCallback(async () => {
|
||||
setLoadingFiltersDropdown(true);
|
||||
try {
|
||||
// Asumiendo que estos servicios existen y devuelven los DTOs correctos
|
||||
const [tiposData, plantasData, estadosData] = await Promise.all([
|
||||
tipoBobinaService.getAllTiposBobina(),
|
||||
plantaService.getAllPlantas(),
|
||||
@@ -128,18 +121,13 @@ const GestionarStockBobinasPage: React.FC = () => {
|
||||
idPlanta: filtroPlanta ? Number(filtroPlanta) : null,
|
||||
idEstadoBobina: filtroEstadoBobina ? Number(filtroEstadoBobina) : null,
|
||||
remitoFilter: filtroRemito || null,
|
||||
// Fechas Remito
|
||||
fechaDesde: filtroFechaHabilitado ? filtroFechaDesde : 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);
|
||||
setStock(data);
|
||||
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) {
|
||||
console.error(err);
|
||||
@@ -147,14 +135,10 @@ const GestionarStockBobinasPage: React.FC = () => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [
|
||||
puedeVer,
|
||||
filtroTipoBobina, filtroNroBobina, filtroPlanta, filtroEstadoBobina, filtroRemito,
|
||||
filtroFechaHabilitado, filtroFechaDesde, filtroFechaHasta,
|
||||
filtroFechaEstadoHabilitado, filtroFechaEstadoDesde, filtroFechaEstadoHasta
|
||||
]);
|
||||
}, [puedeVer, filtroTipoBobina, filtroNroBobina, filtroPlanta, filtroEstadoBobina, filtroRemito, filtroFechaHabilitado, filtroFechaDesde, filtroFechaHasta]);
|
||||
|
||||
const handleBuscarClick = () => {
|
||||
setPage(0); // Resetear la paginación al buscar
|
||||
cargarStock();
|
||||
};
|
||||
|
||||
@@ -164,19 +148,14 @@ const GestionarStockBobinasPage: React.FC = () => {
|
||||
setFiltroPlanta('');
|
||||
setFiltroEstadoBobina('');
|
||||
setFiltroRemito('');
|
||||
|
||||
setFiltroFechaHabilitado(false);
|
||||
setFiltroFechaDesde(new Date().toISOString().split('T')[0]);
|
||||
setFiltroFechaHasta(new Date().toISOString().split('T')[0]);
|
||||
|
||||
setFiltroFechaEstadoHabilitado(false);
|
||||
setFiltroFechaEstadoDesde(new Date().toISOString().split('T')[0]);
|
||||
setFiltroFechaEstadoHasta(new Date().toISOString().split('T')[0]);
|
||||
|
||||
setStock([]);
|
||||
setStock([]); // Limpiar los resultados actuales
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleOpenIngresoModal = () => { setApiErrorMessage(null); setIngresoModalOpen(true); };
|
||||
const handleCloseIngresoModal = () => setIngresoModalOpen(false);
|
||||
const handleSubmitIngresoModal = async (data: CreateStockBobinaDto) => {
|
||||
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; }
|
||||
};
|
||||
|
||||
const handleLoteModalClose = (refrescar: boolean) => {
|
||||
setLoteModalOpen(false);
|
||||
if (refrescar) {
|
||||
cargarStock();
|
||||
const handleOpenEditModal = (bobina: StockBobinaDto | null) => {
|
||||
if (!bobina) return;
|
||||
setSelectedBobina(bobina);
|
||||
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) => {
|
||||
setApiErrorMessage(null);
|
||||
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; }
|
||||
};
|
||||
|
||||
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) => {
|
||||
setApiErrorMessage(null);
|
||||
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; }
|
||||
};
|
||||
|
||||
const handleDeleteBobina = () => {
|
||||
if (!selectedBobinaForRowMenu) return;
|
||||
|
||||
if (selectedBobinaForRowMenu.idEstadoBobina !== ID_ESTADO_DISPONIBLE && selectedBobinaForRowMenu.idEstadoBobina !== ID_ESTADO_DANADA) {
|
||||
const handleDeleteBobina = async (bobina: StockBobinaDto | null) => {
|
||||
if (!bobina) return;
|
||||
if (bobina.idEstadoBobina !== ID_ESTADO_DISPONIBLE && bobina.idEstadoBobina !== ID_ESTADO_DANADA) {
|
||||
alert("Solo se pueden eliminar bobinas en estado 'Disponible' o 'Dañada'.");
|
||||
handleMenuClose();
|
||||
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);
|
||||
stockBobinaService.deleteIngresoBobina(selectedBobinaForRowMenu.idBobina)
|
||||
.then(() => cargarStock())
|
||||
.catch((err: any) => {
|
||||
const msg = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al eliminar.';
|
||||
setApiErrorMessage(msg);
|
||||
});
|
||||
try { await stockBobinaService.deleteIngresoBobina(bobina.idBobina); cargarStock(); }
|
||||
catch (err: any) { const msg = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al eliminar.'; setApiErrorMessage(msg); }
|
||||
}
|
||||
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) => {
|
||||
event.stopPropagation(); // Evitar selección de fila al abrir menú
|
||||
setAnchorEl(event.currentTarget);
|
||||
setSelectedBobinaForRowMenu(bobina);
|
||||
lastOpenedMenuButtonRef.current = event.currentTarget;
|
||||
};
|
||||
|
||||
const handleMenuClose = () => {
|
||||
setAnchorEl(null);
|
||||
setSelectedBobinaForRowMenu(null);
|
||||
if (lastOpenedMenuButtonRef.current) {
|
||||
setTimeout(() => { lastOpenedMenuButtonRef.current?.focus(); }, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenEditModal = () => { setEditModalOpen(true); handleMenuClose(); };
|
||||
const handleOpenCambioEstadoModal = () => { setCambioEstadoModalOpen(true); handleMenuClose(); };
|
||||
const handleOpenFechaRemitoModal = () => { setFechaRemitoModalOpen(true); handleMenuClose(); };
|
||||
const handleChangePage = (_event: unknown, newPage: number) => setPage(newPage);
|
||||
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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 handleCloseCambioEstadoModal = () => { setCambioEstadoModalOpen(false); setSelectedBobinaForRowMenu(null); };
|
||||
const handleCloseFechaRemitoModal = () => { setFechaRemitoModalOpen(false); setSelectedBobinaForRowMenu(null); };
|
||||
|
||||
// --- Definición de Columnas DataGrid ---
|
||||
const columns = useMemo<GridColDef<StockBobinaDto>[]>(() => [
|
||||
{ field: 'nroBobina', headerName: 'Nro. Bobina', width: 130 },
|
||||
{ field: 'nombreTipoBobina', headerName: 'Tipo', width: 200, flex: 1 },
|
||||
{ field: 'peso', headerName: 'Peso (Kg)', width: 100, align: 'right', headerAlign: 'right', type: 'number' },
|
||||
{ field: 'nombrePlanta', headerName: 'Planta', width: 120 },
|
||||
{
|
||||
field: 'nombreEstadoBobina',
|
||||
headerName: 'Estado',
|
||||
width: 130,
|
||||
renderCell: (params) => {
|
||||
const idEstado = params.row.idEstadoBobina;
|
||||
let color: "success" | "primary" | "error" | "default" = "default";
|
||||
if (idEstado === ID_ESTADO_DISPONIBLE) color = "success";
|
||||
else if (idEstado === ID_ESTADO_UTILIZADA) color = "primary";
|
||||
else if (idEstado === ID_ESTADO_DANADA) color = "error";
|
||||
|
||||
return <Chip label={params.value} size="small" color={color} variant="outlined" />;
|
||||
}
|
||||
},
|
||||
{ field: 'remito', headerName: 'Remito', width: 120 },
|
||||
{
|
||||
field: 'fechaRemito',
|
||||
headerName: 'F. Remito',
|
||||
width: 110,
|
||||
type: 'date',
|
||||
valueGetter: (value: string) => {
|
||||
if (!value) return null;
|
||||
const datePart = value.toString().split('T')[0];
|
||||
const [year, month, day] = datePart.split('-');
|
||||
return new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
|
||||
},
|
||||
valueFormatter: (value: Date) => {
|
||||
return value ? value.toLocaleDateString('es-AR') : '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'fechaEstado',
|
||||
headerName: 'F. Estado',
|
||||
width: 110,
|
||||
type: 'date',
|
||||
valueGetter: (value: string) => {
|
||||
if (!value) return null;
|
||||
const datePart = value.toString().split('T')[0];
|
||||
const [year, month, day] = datePart.split('-');
|
||||
return new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
|
||||
},
|
||||
valueFormatter: (value: Date) => {
|
||||
return value ? value.toLocaleDateString('es-AR') : '-';
|
||||
}
|
||||
},
|
||||
|
||||
{ field: 'nombrePublicacion', headerName: 'Publicación', width: 150 },
|
||||
{ field: 'nombreSeccion', headerName: 'Sección', width: 120 },
|
||||
{ field: 'obs', headerName: 'Obs.', width: 200, flex: 1 },
|
||||
{
|
||||
field: 'acciones',
|
||||
headerName: 'Acciones',
|
||||
width: 80,
|
||||
sortable: false,
|
||||
filterable: false,
|
||||
align: 'right',
|
||||
renderCell: (params: GridRenderCellParams<StockBobinaDto>) => {
|
||||
const b = params.row;
|
||||
const disabled = !(puedeModificarDatos) &&
|
||||
!(puedeCambiarEstado) &&
|
||||
!((b.idEstadoBobina === ID_ESTADO_DISPONIBLE || b.idEstadoBobina === ID_ESTADO_DANADA) && puedeEliminar);
|
||||
|
||||
if (disabled) return null;
|
||||
|
||||
return (
|
||||
<IconButton onClick={(e) => handleMenuOpen(e, b)} size="small">
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
}
|
||||
], [puedeModificarDatos, puedeCambiarEstado, puedeEliminar]);
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
timeZone: 'UTC'
|
||||
};
|
||||
return new Intl.DateTimeFormat('es-AR', options).format(date);
|
||||
};
|
||||
|
||||
if (!puedeVer) return <Box sx={{ p: 2 }}><Alert severity="error">{error || "Acceso denegado."}</Alert></Box>;
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="h5" gutterBottom>Stock de Bobinas</Typography>
|
||||
|
||||
{/* Panel de Filtros */}
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>Filtros</Typography>
|
||||
|
||||
{/* Fila 1: Filtros generales */}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, mb: 2 }}>
|
||||
<FormControl size="small" sx={{ minWidth: 180, flexGrow: 1 }}>
|
||||
<InputLabel>Tipo Bobina</InputLabel>
|
||||
@@ -369,123 +274,128 @@ const GestionarStockBobinasPage: React.FC = () => {
|
||||
</FormControl>
|
||||
<TextField label="Remito" size="small" value={filtroRemito} onChange={(e) => setFiltroRemito(e.target.value)} sx={{ minWidth: 150, flexGrow: 1 }} />
|
||||
</Box>
|
||||
|
||||
{/* 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
|
||||
control={<Checkbox checked={filtroFechaHabilitado} onChange={(e) => setFiltroFechaHabilitado(e.target.checked)} />}
|
||||
label="Filtrar por Fecha Remito"
|
||||
/>
|
||||
<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="Hasta" type="date" size="small" value={filtroFechaHasta} onChange={(e) => setFiltroFechaHasta(e.target.value)} InputLabelProps={{ shrink: true }} sx={{ minWidth: 140 }} disabled={!filtroFechaHabilitado} />
|
||||
</Box>
|
||||
|
||||
{/* 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 sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, mb: 2 }}>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={filtroFechaHabilitado} onChange={(e) => setFiltroFechaHabilitado(e.target.checked)} />}
|
||||
label="Filtrar por Fechas de Remitos"
|
||||
/>
|
||||
<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="Fecha Hasta" type="date" size="small" value={filtroFechaHasta} onChange={(e) => setFiltroFechaHasta(e.target.value)} InputLabelProps={{ shrink: true }} sx={{ minWidth: 170 }} disabled={!filtroFechaHabilitado} />
|
||||
</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
|
||||
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>)}
|
||||
|
||||
</Paper>
|
||||
|
||||
{loading && <Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}><CircularProgress /></Box>}
|
||||
{error && !loading && <Alert severity="warning" sx={{ my: 2 }}>{error}</Alert>}
|
||||
{apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{apiErrorMessage}</Alert>}
|
||||
|
||||
{/* Tabla DataGrid */}
|
||||
<Paper sx={{ width: '100%', height: 600 }}>
|
||||
<DataGrid
|
||||
rows={stock}
|
||||
columns={columns}
|
||||
getRowId={(row) => row.idBobina} // Importante: especificar el ID único
|
||||
loading={loading}
|
||||
localeText={esES.components.MuiDataGrid.defaultProps.localeText}
|
||||
density="compact"
|
||||
disableRowSelectionOnClick
|
||||
initialState={{
|
||||
pagination: { paginationModel: { pageSize: 25 } },
|
||||
}}
|
||||
pageSizeOptions={[25, 50, 100]}
|
||||
sx={{ border: 0 }}
|
||||
/>
|
||||
</Paper>
|
||||
{!loading && !error && (
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead><TableRow>
|
||||
<TableCell>Nro. Bobina</TableCell><TableCell>Tipo</TableCell><TableCell>Peso (Kg)</TableCell>
|
||||
<TableCell>Planta</TableCell><TableCell>Estado</TableCell><TableCell>Remito</TableCell>
|
||||
<TableCell>F. Remito</TableCell><TableCell>F. Estado</TableCell>
|
||||
<TableCell>Publicación</TableCell><TableCell>Sección</TableCell>
|
||||
<TableCell>Obs.</TableCell>
|
||||
{(puedeModificarDatos || puedeCambiarEstado || puedeEliminar) && <TableCell align="right">Acciones</TableCell>}
|
||||
</TableRow></TableHead>
|
||||
<TableBody>
|
||||
{displayData.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={(puedeModificarDatos || puedeCambiarEstado || puedeEliminar) ? 12 : 11} align="center">No se encontraron bobinas con los filtros aplicados. Haga clic en "Buscar" para iniciar una consulta.</TableCell></TableRow>
|
||||
) : (
|
||||
displayData.map((b) => (
|
||||
<TableRow key={b.idBobina} hover>
|
||||
<TableCell>{b.nroBobina}</TableCell><TableCell>{b.nombreTipoBobina}</TableCell>
|
||||
<TableCell align="right">{b.peso}</TableCell><TableCell>{b.nombrePlanta}</TableCell>
|
||||
<TableCell><Chip label={b.nombreEstadoBobina} size="small" color={
|
||||
b.idEstadoBobina === ID_ESTADO_DISPONIBLE ? "success" : b.idEstadoBobina === ID_ESTADO_UTILIZADA ? "primary" : b.idEstadoBobina === ID_ESTADO_DANADA ? "error" : "default"
|
||||
} /></TableCell>
|
||||
<TableCell>{b.remito}</TableCell><TableCell>{formatDate(b.fechaRemito)}</TableCell>
|
||||
<TableCell>{formatDate(b.fechaEstado)}</TableCell>
|
||||
<TableCell>{b.nombrePublicacion || '-'}</TableCell><TableCell>{b.nombreSeccion || '-'}</TableCell>
|
||||
<TableCell>{b.obs || '-'}</TableCell>
|
||||
{(puedeModificarDatos || puedeCambiarEstado || puedeEliminar) && (
|
||||
<TableCell align="right">
|
||||
<IconButton onClick={(e) => handleMenuOpen(e, b)}
|
||||
disabled={
|
||||
!(b.idEstadoBobina === ID_ESTADO_DISPONIBLE && puedeModificarDatos) &&
|
||||
!(puedeCambiarEstado) &&
|
||||
!((b.idEstadoBobina === ID_ESTADO_DISPONIBLE || b.idEstadoBobina === ID_ESTADO_DANADA) && puedeEliminar)
|
||||
}
|
||||
><MoreVertIcon /></IconButton>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
)))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TablePagination
|
||||
rowsPerPageOptions={[25, 50, 100]} component="div" count={stock.length}
|
||||
rowsPerPage={rowsPerPage} page={page} onPageChange={handleChangePage}
|
||||
onRowsPerPageChange={handleChangeRowsPerPage} labelRowsPerPage="Filas por página:"
|
||||
/>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
{/* Menú Contextual de Fila */}
|
||||
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}>
|
||||
{selectedBobinaForRowMenu && puedeModificarDatos && (
|
||||
<MenuItem onClick={handleOpenFechaRemitoModal}>
|
||||
<EditCalendarIcon fontSize="small" sx={{ mr: 1 }} /> Corregir Fecha Remito
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedBobinaForRowMenu && puedeModificarDatos && selectedBobinaForRowMenu.idEstadoBobina === ID_ESTADO_DISPONIBLE && (
|
||||
<MenuItem onClick={handleOpenEditModal}>
|
||||
<EditIcon fontSize="small" sx={{ mr: 1 }} /> Editar Datos Bobina
|
||||
<MenuItem onClick={() => { handleOpenEditModal(selectedBobinaForRowMenu); handleMenuClose(); }}>
|
||||
<EditIcon fontSize="small" sx={{ mr: 1 }} /> Editar Datos
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedBobinaForRowMenu && puedeCambiarEstado && (
|
||||
<MenuItem onClick={handleOpenCambioEstadoModal}>
|
||||
<MenuItem onClick={() => { handleOpenCambioEstadoModal(selectedBobinaForRowMenu); handleMenuClose(); }}>
|
||||
<SwapHorizIcon fontSize="small" sx={{ mr: 1 }} /> Cambiar Estado
|
||||
</MenuItem>
|
||||
)}
|
||||
{selectedBobinaForRowMenu && puedeEliminar &&
|
||||
(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
|
||||
</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>
|
||||
|
||||
{/* Modales */}
|
||||
{/* Modales sin cambios */}
|
||||
<StockBobinaIngresoFormModal
|
||||
open={ingresoModalOpen} onClose={handleCloseIngresoModal} onSubmit={handleSubmitIngresoModal}
|
||||
errorMessage={apiErrorMessage} clearErrorMessage={() => setApiErrorMessage(null)}
|
||||
/>
|
||||
<StockBobinaEditFormModal
|
||||
open={editModalOpen} onClose={handleCloseEditModal} onSubmit={handleSubmitEditModal}
|
||||
initialData={selectedBobinaForRowMenu} errorMessage={apiErrorMessage}
|
||||
clearErrorMessage={() => setApiErrorMessage(null)}
|
||||
/>
|
||||
<StockBobinaCambioEstadoModal
|
||||
open={cambioEstadoModalOpen} onClose={handleCloseCambioEstadoModal} onSubmit={handleSubmitCambioEstadoModal}
|
||||
bobinaActual={selectedBobinaForRowMenu} errorMessage={apiErrorMessage}
|
||||
clearErrorMessage={() => setApiErrorMessage(null)}
|
||||
/>
|
||||
<StockBobinaLoteFormModal
|
||||
open={loteModalOpen}
|
||||
onClose={handleLoteModalClose}
|
||||
/>
|
||||
<StockBobinaFechaRemitoModal
|
||||
open={fechaRemitoModalOpen}
|
||||
onClose={handleCloseFechaRemitoModal}
|
||||
onSubmit={handleSubmitFechaRemitoModal}
|
||||
bobinaContexto={selectedBobinaForRowMenu}
|
||||
errorMessage={apiErrorMessage}
|
||||
clearErrorMessage={() => setApiErrorMessage(null)}
|
||||
/>
|
||||
{editModalOpen && selectedBobina &&
|
||||
<StockBobinaEditFormModal
|
||||
open={editModalOpen} onClose={handleCloseEditModal} onSubmit={handleSubmitEditModal}
|
||||
initialData={selectedBobina} errorMessage={apiErrorMessage}
|
||||
clearErrorMessage={() => setApiErrorMessage(null)}
|
||||
/>
|
||||
}
|
||||
{cambioEstadoModalOpen && selectedBobina &&
|
||||
<StockBobinaCambioEstadoModal
|
||||
open={cambioEstadoModalOpen} onClose={handleCloseCambioEstadoModal} onSubmit={handleSubmitCambioEstadoModal}
|
||||
bobinaActual={selectedBobina} errorMessage={apiErrorMessage}
|
||||
clearErrorMessage={() => setApiErrorMessage(null)}
|
||||
/>
|
||||
}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -69,8 +69,8 @@ const ReporteListadoDistribucionCanillasPage: React.FC = () => {
|
||||
setReportData(null);
|
||||
setDetalleDiarioCalculado([]);
|
||||
setPromediosPorDiaCalculado([]);
|
||||
setTotalesDetalle({ llevados: 0, devueltos: 0, ventaNeta: 0, promedioGeneralVentaNeta: 0, porcentajeDevolucionGeneral: 0 });
|
||||
setTotalesPromedios({ cantDias: 0, promLlevados: 0, promDevueltos: 0, promVentas: 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});
|
||||
|
||||
|
||||
const pubService = (await import('../../services/Distribucion/publicacionService')).default;
|
||||
@@ -112,42 +112,43 @@ const ReporteListadoDistribucionCanillasPage: React.FC = () => {
|
||||
const totalVentaNetaDetalle = totalLlevadosDetalle - totalDevueltosDetalle;
|
||||
|
||||
setTotalesDetalle({
|
||||
llevados: totalLlevadosDetalle,
|
||||
devueltos: totalDevueltosDetalle,
|
||||
ventaNeta: totalVentaNetaDetalle,
|
||||
promedioGeneralVentaNeta: ultimoPromedioDetalle,
|
||||
porcentajeDevolucionGeneral: totalLlevadosDetalle > 0 ? (totalDevueltosDetalle / totalLlevadosDetalle) * 100 : 0
|
||||
llevados: totalLlevadosDetalle,
|
||||
devueltos: totalDevueltosDetalle,
|
||||
ventaNeta: totalVentaNetaDetalle,
|
||||
promedioGeneralVentaNeta: ultimoPromedioDetalle,
|
||||
porcentajeDevolucionGeneral: totalLlevadosDetalle > 0 ? (totalDevueltosDetalle / totalLlevadosDetalle) * 100 : 0
|
||||
});
|
||||
|
||||
|
||||
// --- Cálculos para promedios y sus totales ---
|
||||
const promediosCalculadoLocal = data.promediosPorDia.map((item, index) => {
|
||||
const promLlevados = item.promedio_Llevados || 0;
|
||||
const promDevueltos = item.promedio_Devueltos || 0;
|
||||
const promVentas = item.promedio_Ventas || 0;
|
||||
return {
|
||||
...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,
|
||||
porcentajeDevolucion: promLlevados > 0 ? (promDevueltos / promLlevados) * 100 : 0,
|
||||
porcentajeDevolucion: promLlevados > 0 ? (promVentas / promLlevados) * 100 : 0,
|
||||
};
|
||||
});
|
||||
setPromediosPorDiaCalculado(promediosCalculadoLocal);
|
||||
|
||||
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 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 promGeneralLlevados = totalDiasProm > 0 ? totalPonderadoLlevados / totalDiasProm : 0;
|
||||
const promGeneralDevueltos = totalDiasProm > 0 ? totalPonderadoDevueltos / totalDiasProm : 0;
|
||||
|
||||
setTotalesPromedios({
|
||||
cantDias: totalDiasProm,
|
||||
promLlevados: promGeneralLlevados,
|
||||
promDevueltos: promGeneralDevueltos,
|
||||
promLlevados: totalDiasProm > 0 ? totalPonderadoLlevados / totalDiasProm : 0,
|
||||
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,
|
||||
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 });
|
||||
@@ -279,11 +280,11 @@ const ReporteListadoDistribucionCanillasPage: React.FC = () => {
|
||||
</Box>
|
||||
{/* Contenedor para tus totales */}
|
||||
<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: 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[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: 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[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[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>
|
||||
</Box>
|
||||
</GridFooterContainer>
|
||||
@@ -296,11 +297,11 @@ const ReporteListadoDistribucionCanillasPage: React.FC = () => {
|
||||
<GridFooter sx={{ borderTop: 'none' }} />
|
||||
</Box>
|
||||
<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={{ 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[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[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={{ 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[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>
|
||||
</Box>
|
||||
</GridFooterContainer>
|
||||
@@ -371,10 +372,10 @@ const ReporteListadoDistribucionCanillasPage: React.FC = () => {
|
||||
density="compact"
|
||||
slots={{ footer: CustomFooterPromedios }}
|
||||
hideFooterSelectedRowCount
|
||||
sx={{
|
||||
'& .MuiTablePagination-root': { // Oculta el paginador por defecto
|
||||
display: 'none',
|
||||
},
|
||||
sx={{
|
||||
'& .MuiTablePagination-root': { // Oculta el paginador por defecto
|
||||
display: 'none',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
@@ -121,24 +121,20 @@ const ReporteListadoDistribucionPage: React.FC = () => {
|
||||
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 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);
|
||||
const totalPonderadoVentas = promediosConCalculos.reduce((sum, item) => sum + ((item.promedio_Ventas || 0) * (item.cant || 0)), 0);
|
||||
|
||||
// LÓGICA DE PROMEDIO DE PROMEDIOS
|
||||
if (countPromedios > 0) {
|
||||
const sumPromLlevados = promediosConCalculos.reduce((sum, item) => sum + (item.promedio_Llevados || 0), 0);
|
||||
const sumPromDevueltos = promediosConCalculos.reduce((sum, item) => sum + (item.promedio_Devueltos || 0), 0);
|
||||
const sumPromVentas = promediosConCalculos.reduce((sum, item) => sum + (item.promedio_Ventas || 0), 0);
|
||||
const sumPorcDevolucion = promediosConCalculos.reduce((sum, item) => sum + (item.porcentajeDevolucion || 0), 0);
|
||||
setTotalesPromedios({
|
||||
cantDias: totalDiasPromedios,
|
||||
promLlevados: totalDiasPromedios > 0 ? totalPonderadoLlevados / totalDiasPromedios : 0,
|
||||
promDevueltos: totalDiasPromedios > 0 ? totalPonderadoDevueltos / totalDiasPromedios : 0,
|
||||
promVentas: totalDiasPromedios > 0 ? totalPonderadoVentas / totalDiasPromedios : 0,
|
||||
porcentajeDevolucionGeneral: totalPonderadoLlevados > 0 ? (totalPonderadoDevueltos / totalPonderadoLlevados) * 100 : 0,
|
||||
});
|
||||
|
||||
setTotalesPromedios({
|
||||
cantDias: totalDiasPromedios,
|
||||
promLlevados: sumPromLlevados / countPromedios,
|
||||
promDevueltos: sumPromDevueltos / countPromedios,
|
||||
promVentas: sumPromVentas / countPromedios,
|
||||
porcentajeDevolucionGeneral: sumPorcDevolucion / countPromedios,
|
||||
});
|
||||
}
|
||||
|
||||
setReportData({ detalleSimple: detalleConCalculos, promediosPorDia: promediosConCalculos });
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@ import type { StockBobinaDto } from '../../models/dtos/Impresion/StockBobinaDto'
|
||||
import type { CreateStockBobinaDto } from '../../models/dtos/Impresion/CreateStockBobinaDto';
|
||||
import type { UpdateStockBobinaDto } from '../../models/dtos/Impresion/UpdateStockBobinaDto';
|
||||
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 {
|
||||
idTipoBobina?: number | null;
|
||||
@@ -14,8 +12,6 @@ interface GetAllStockBobinasParams {
|
||||
remitoFilter?: string | null;
|
||||
fechaDesde?: 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[]> => {
|
||||
@@ -27,8 +23,6 @@ const getAllStockBobinas = async (filters: GetAllStockBobinasParams): Promise<St
|
||||
if (filters.remitoFilter) params.remito = filters.remitoFilter; // El backend espera remito
|
||||
if (filters.fechaDesde) params.fechaDesde = filters.fechaDesde;
|
||||
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 });
|
||||
return response.data;
|
||||
@@ -56,23 +50,6 @@ const deleteIngresoBobina = async (idBobina: number): Promise<void> => {
|
||||
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 = {
|
||||
getAllStockBobinas,
|
||||
getStockBobinaById,
|
||||
@@ -80,9 +57,6 @@ const stockBobinaService = {
|
||||
updateDatosBobinaDisponible,
|
||||
cambiarEstadoBobina,
|
||||
deleteIngresoBobina,
|
||||
verificarRemitoExistente,
|
||||
ingresarLoteBobinas,
|
||||
actualizarFechaRemitoLote,
|
||||
};
|
||||
|
||||
export default stockBobinaService;
|
||||
@@ -13,7 +13,6 @@ def insertar_alerta_en_db(cursor, tipo_alerta, id_entidad, entidad, mensaje, fec
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||
"""
|
||||
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
|
||||
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
|
||||
@@ -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;'
|
||||
MODEL_INDIVIDUAL_FILE = 'modelo_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 ---
|
||||
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}")
|
||||
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) ---")
|
||||
if not os.path.exists(MODEL_INDIVIDUAL_FILE):
|
||||
print(f"ADVERTENCIA: Modelo individual '{MODEL_INDIVIDUAL_FILE}' no encontrado.")
|
||||
else:
|
||||
# ... (esta sección se mantiene exactamente igual que antes) ...
|
||||
model_individual = joblib.load(MODEL_INDIVIDUAL_FILE)
|
||||
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
|
||||
@@ -81,15 +84,16 @@ else:
|
||||
cant_devuelta=row['cantidad_devuelta'],
|
||||
porc_devolucion=row['porcentaje_devolucion'])
|
||||
else:
|
||||
print("INFO: No se encontraron anomalías individuales significativas.")
|
||||
print("INFO: No se encontraron anomalías individuales significativas en canillitas.")
|
||||
else:
|
||||
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 ---")
|
||||
if not os.path.exists(MODEL_SISTEMA_FILE):
|
||||
print(f"ADVERTENCIA: Modelo de sistema '{MODEL_SISTEMA_FILE}' no encontrado.")
|
||||
else:
|
||||
# ... (esta sección se mantiene exactamente igual que antes) ...
|
||||
model_sistema = joblib.load(MODEL_SISTEMA_FILE)
|
||||
query_agregada = f"""
|
||||
SELECT CAST(Fecha AS DATE) AS fecha_dia, DATEPART(weekday, Fecha) as dia_semana,
|
||||
@@ -128,7 +132,161 @@ else:
|
||||
mensaje=mensaje,
|
||||
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.close()
|
||||
print("\n--- DETECCIÓN COMPLETA ---")
|
||||
BIN
ProyectoIA_Gestion/modelo_anomalias_dist.joblib
Normal file
BIN
ProyectoIA_Gestion/modelo_anomalias_dist.joblib
Normal file
Binary file not shown.
BIN
ProyectoIA_Gestion/modelo_danadas.joblib
Normal file
BIN
ProyectoIA_Gestion/modelo_danadas.joblib
Normal file
Binary file not shown.
BIN
ProyectoIA_Gestion/modelo_montos.joblib
Normal file
BIN
ProyectoIA_Gestion/modelo_montos.joblib
Normal file
Binary file not shown.
63
ProyectoIA_Gestion/train_danadas.py
Normal file
63
ProyectoIA_Gestion/train_danadas.py
Normal 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}' ---")
|
||||
68
ProyectoIA_Gestion/train_distribuidores.py
Normal file
68
ProyectoIA_Gestion/train_distribuidores.py
Normal 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}' ---")
|
||||
92
ProyectoIA_Gestion/train_montos.py
Normal file
92
ProyectoIA_Gestion/train_montos.py
Normal 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}' ---")
|
||||
Reference in New Issue
Block a user