diff --git a/Domain/Dtos/Stock/StockItemSelectionDto.cs b/Domain/Dtos/Stock/StockItemSelectionDto.cs index 3491e94..d4e3fea 100644 --- a/Domain/Dtos/Stock/StockItemSelectionDto.cs +++ b/Domain/Dtos/Stock/StockItemSelectionDto.cs @@ -20,7 +20,7 @@ /// /// Nombre o descripción corta del producto (solo para visualización en UI) /// - public string ProductName { get; set; } = string.Empty; + public string? ProductName { get; set; } = string.Empty; /// /// Lote del stock seleccionado @@ -37,6 +37,10 @@ /// public DateTime? Expiration { get; set; } + /// + /// Tipo de trazabilidad: 1=No aplica, 2=Por cantidad, 3=Por lote y vencimiento + /// + public int TraceabilityType { get; set; } /// /// Cantidad que el usuario desea usar (puede ser decimal si es por peso o volumen) diff --git a/Domain/Dtos/Stock/StockSnapshotItem.cs b/Domain/Dtos/Stock/StockSnapshotItem.cs new file mode 100644 index 0000000..ed52659 --- /dev/null +++ b/Domain/Dtos/Stock/StockSnapshotItem.cs @@ -0,0 +1,44 @@ +namespace Domain.Dtos.Stock +{ + /// + /// Contexto de lo ya agregado en la expedición (no se persiste). + /// Sirve para evitar duplicados y calcular disponible efectivo. + /// + public class StockSnapshotItem + { + public int ProductId { get; set; } + public string? ProductName { get; set; } = string.Empty; + public int LocationId { get; set; } + public string Batch { get; set; } = string.Empty; + public DateOnly? Expiration { get; set; } + public string Serial { get; set; } = string.Empty; + + /// 1=No aplica, 2=Por cantidad, 3=Por lote y vencimiento (solo UI/validación; no se persiste). + public int TraceabilityType { get; set; } + + /// Cantidad ya agregada en la expedición para esta clave de negocio. + public decimal Quantity { get; set; } + + /// Clave de fusión para idempotencia (no se persiste). + public string BusinessKey { get; set; } = string.Empty; + } + + public static class StockKeys + { + public static string BuildBusinessKey(int productId, int locationId, string batch, DateOnly? expiration, string serial) + { + if (!string.IsNullOrWhiteSpace(serial)) + return $"P{productId}-S{serial.Trim()}"; + + batch = batch?.Trim() ?? string.Empty; + + if (!string.IsNullOrEmpty(batch) && expiration.HasValue) + return $"P{productId}-L{locationId}-B{batch}-E{expiration.Value:yyyyMMdd}"; + + if (!string.IsNullOrEmpty(batch)) + return $"P{productId}-L{locationId}-B{batch}"; + + return $"P{productId}-L{locationId}"; + } + } +} diff --git a/Domain/Entities/ELSExpeditionDetail.cs b/Domain/Entities/ELSExpeditionDetail.cs index 26cfbf4..5f0a530 100644 --- a/Domain/Entities/ELSExpeditionDetail.cs +++ b/Domain/Entities/ELSExpeditionDetail.cs @@ -47,6 +47,19 @@ /// public string? Description { get; set; } + /// + /// Tipo de trazabilidad: 1=No aplica, 2=Por cantidad, 3=Por lote y vencimiento + /// ATENCION: Campo auxiliar de UI/servicio para aplicar reglas de trazabilidad. + /// No se persiste: la DB no tiene esta columna. + /// + public int TraceabilityType { get; set; } + /// + /// Nombre del producto: Nombre descriptivo de la tabla Products + /// ATENCION: Campo auxiliar de UI/servicio para aplicar reglas de trazabilidad. + /// No se persiste: la DB no tiene esta columna. + /// + public string? ProductName { get; set; } = string.Empty; + /// /// Precio estimado unitario del producto (sin efecto contable) /// diff --git a/phronCare.UIBlazor/Pages/Stock/Expeditions/ExpeditionCreate.razor b/phronCare.UIBlazor/Pages/Stock/Expeditions/ExpeditionCreate.razor index a02977c..a210aa5 100644 --- a/phronCare.UIBlazor/Pages/Stock/Expeditions/ExpeditionCreate.razor +++ b/phronCare.UIBlazor/Pages/Stock/Expeditions/ExpeditionCreate.razor @@ -6,17 +6,12 @@ @using Services.Stock.Expeditions @using phronCare.UIBlazor.Pages.Stock.Shared -@* @using static phronCare.UIBlazor.Pages.Stock.Shared.StockItemSelectorModal - *@ @inject NavigationManager Navigation @inject ExpeditionService expeditionService -@* @inject Lookup lookUpService *@ @inject ISalesLookupService lookUpService @inject IToastService toastService @inject IModalService Modal - - @@ -154,12 +149,12 @@ @code { private ELSExpeditionHeader Model = new(); private ExtraInfoModel ExtraInfo = new(); - private string DispatchInstruction = string.Empty; private ELookUpItem? SelectedQuote; - private List Details = new(); + private List Details = new(); private List ProductSetItems = new(); + private string DispatchInstruction = string.Empty; private string ticketIdString { @@ -224,47 +219,133 @@ { // TODO: Lógica de guardado de la expedición completa } + + // private async Task OpenStockItemSelectorModal() + // { + + // var parameters = new ModalParameters(); + // parameters.Add(nameof(StockItemSelectorModal.SetItems), ProductSetItems); // o null + // //parameters.Add(nameof(StockItemSelectorModal.LocationId), SelectedLocationId); + + // var options = new ModalOptions() + // { + // Size = ModalSize.Large, + // HideHeader = true + // }; + + // var modal = Modal.Show("", parameters, options); + + // var result = await modal.Result; + + // if (!result.Cancelled && result.Data is List selectedItems) + // { + // foreach (var s in selectedItems) + // { + // var detail = new ELSExpeditionDetail + // { + // ProductId = s.ProductId, + // Quantity = s.Quantity, // si es Serial*, probablemente 1 + // Batch = s.Batch, + // Expiration = s.Expiration.HasValue + // ? DateOnly.FromDateTime(s.Expiration.Value) + // : (DateOnly?)null, + // TraceabilityType=s.TraceabilityType, //agregado al model pero no es registrable en la entidad + // Serial = s.Serial, // si es Serial*, probablemente null + // LocationId = s.LocationId // si tu detalle lo maneja + // }; + + // Details.Add(detail); + // } + + // StateHasChanged(); + // toastService.ShowSuccess($"{selectedItems.Count} item(s) agregados a la expedición."); + // } + + // } private async Task OpenStockItemSelectorModal() { - var parameters = new ModalParameters(); - parameters.Add(nameof(StockItemSelectorModal.SetItems), ProductSetItems); // o null - //parameters.Add(nameof(StockItemSelectorModal.LocationId), SelectedLocationId); + parameters.Add(nameof(StockItemSelectorModal.SetItems), ProductSetItems); + parameters.Add(nameof(StockItemSelectorModal.Snapshot), BuildSnapshotFromDetails()); // ← clave - var options = new ModalOptions() - { - Size = ModalSize.Large, - HideHeader = true - }; + var options = new ModalOptions { Size = ModalSize.Large, HideHeader = true }; var modal = Modal.Show("", parameters, options); var result = await modal.Result; if (!result.Cancelled && result.Data is List selectedItems) { - foreach (var s in selectedItems) - { - var detail = new ELSExpeditionDetail - { - ProductId = s.ProductId, - Quantity = s.Quantity, // si es Serial*, probablemente 1 - Batch = s.Batch, - Expiration = s.Expiration.HasValue - ? DateOnly.FromDateTime(s.Expiration.Value) - : (DateOnly?)null, - Serial = s.Serial, // si es Serial*, probablemente null - LocationId = s.LocationId // si tu detalle lo maneja - }; - - Details.Add(detail); - } - + MergeSelectionsByBusinessKey(selectedItems); // ← usar merge (ver abajo) StateHasChanged(); - toastService.ShowSuccess($"{selectedItems.Count} item(s) agregados a la expedición."); + toastService.ShowSuccess($"{selectedItems.Count} item(s) agregados/actualizados."); } - } + private void MergeSelectionsByBusinessKey(List selected) + { + foreach (var s in selected) + { + var exp = s.Expiration.HasValue ? DateOnly.FromDateTime(s.Expiration.Value) : (DateOnly?)null; + + var key = StockKeys.BuildBusinessKey( + s.ProductId, + s.LocationId, + s.Batch ?? string.Empty, + exp, + s.Serial ?? string.Empty + ); + + var existing = Details.FirstOrDefault(d => + StockKeys.BuildBusinessKey(d.ProductId, d.LocationId, d.Batch ?? string.Empty, d.Expiration, d.Serial ?? string.Empty) == key + ); + + // Normalizo cantidad pedida desde el modal (total final) + var newQty = s.Quantity < 0 ? 0 : s.Quantity; + + // Serial ⇒ siempre 1 (ignora lo que venga) + if (!string.IsNullOrWhiteSpace(s.Serial)) + newQty = 1; + + if (existing is not null) + { + if (newQty == 0) + { + // Si el modal dejó en 0, se elimina la fila + Details.Remove(existing); + } + else + { + // SET (no sumar): que quede exactamente como en el modal + existing.Quantity = newQty; + existing.ProductName = s.ProductName; // opcional, por si viene actualizado + existing.Batch = s.Batch; + existing.Serial = s.Serial; + existing.Expiration = exp; + existing.LocationId = s.LocationId; + existing.TraceabilityType = s.TraceabilityType; // UI only + } + } + else + { + // Si no existía y la cantidad es > 0, crear nueva fila + if (newQty > 0) + { + Details.Add(new ELSExpeditionDetail + { + ProductId = s.ProductId, + ProductName = s.ProductName, + Quantity = newQty, + Batch = s.Batch, + Expiration = exp, + TraceabilityType = s.TraceabilityType, // UI only (no DB) + Serial = s.Serial, + LocationId = s.LocationId + }); + } + // Si newQty == 0 y no existía, no hay nada que hacer + } + } + } private class ExtraInfoModel { @@ -273,4 +354,32 @@ public string? Patient { get; set; } public DateTime? SurgeryDate { get; set; } } + + private List BuildSnapshotFromDetails() + { + return Details.Select(d => + { + var key = StockKeys.BuildBusinessKey( + d.ProductId, + d.LocationId, + d.Batch ?? string.Empty, + d.Expiration, + d.Serial ?? string.Empty + ); + + return new StockSnapshotItem + { + ProductId = d.ProductId, + ProductName = d.ProductName, + LocationId = d.LocationId, + Batch = d.Batch ?? string.Empty, + Expiration = d.Expiration, + Serial = d.Serial ?? string.Empty, + TraceabilityType = d.TraceabilityType, // UI only (no DB) + Quantity = d.Quantity, + BusinessKey = key + }; + }).ToList(); + } + } diff --git a/phronCare.UIBlazor/Pages/Stock/Shared/StockItemSelectorModal.razor b/phronCare.UIBlazor/Pages/Stock/Shared/StockItemSelectorModal.razor index 6b7ce43..cfb070a 100644 --- a/phronCare.UIBlazor/Pages/Stock/Shared/StockItemSelectorModal.razor +++ b/phronCare.UIBlazor/Pages/Stock/Shared/StockItemSelectorModal.razor @@ -2,6 +2,7 @@ @using Blazored.Modal.Services @using Domain.Dtos.Stock @using Microsoft.AspNetCore.Components.Web +@using System.Globalization @inject IToastService toastService @inject IStockScanService stockScanService @@ -24,26 +25,25 @@ + id="scan" + @bind="InputCode" + @bind:event="oninput" + @onkeydown="HandleKeyDown" + autocomplete="off" + spellcheck="false" + @ref="scanInput" /> - @if (StockList.Any()) + @if (HasLastScan) { var last = StockList.First();
Último escaneado: @last.ProductName | Lote @last.Batch | Venc: @last.Expiration?.ToShortDateString()
} - @@ -63,9 +63,14 @@ - + } @@ -83,28 +88,53 @@ [Parameter] public int? ProductId { get; set; } [Parameter] public int? LocationId { get; set; } - [Parameter] public List? SetItems { get; set; } + //[Parameter] public List? SetItems { get; set; } + [Parameter] public List Snapshot { get; set; } = new(); + // Mapa: BusinessKey -> Cantidad ya en la expedición + private Dictionary _alreadyInExpedition = new(); + private bool HasLastScan { get; set; } + + //private readonly List SelectedItems = new(); + private List StockList = new(); private string InputCode { get; set; } = string.Empty; private ElementReference scanInput; - private readonly List SelectedItems = new(); - private List StockList = new(); - - protected override async Task OnInitializedAsync() + protected override void OnParametersSet() { - await LoadMockStock(); + HasLastScan = false; + _alreadyInExpedition = Snapshot + .GroupBy(x => x.BusinessKey) + .ToDictionary(g => g.Key, g => g.Sum(x => x.Quantity)); + + // Precargar la tabla del modal con lo que ya estaba en la expedición + StockList = Snapshot.Select(s => new StockDisplayRow + { + ProductId = s.ProductId, + ProductName = s.ProductName ?? "", + Batch = s.Batch, + Serial = s.Serial, + Expiration = s.Expiration?.ToDateTime(TimeOnly.MinValue), + LocationId = s.LocationId, + TraceabilityType = s.TraceabilityType, + BusinessKey = s.BusinessKey, + Selected = s.Quantity, // cantidad actual en la expedición + Available = 0 // opcional: si querés recalcular, traer de BD + }).ToList(); } + protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) await scanInput.FocusAsync(); } + private async Task OnKeyDown(KeyboardEventArgs e) { if (e.Key == "Enter") await HandleScan(); } + private async Task HandleKeyDown(KeyboardEventArgs e) { if (e.Key is "Enter" or "NumpadEnter") @@ -113,56 +143,136 @@ await scanInput.FocusAsync(); } } + private async Task HandleScan() { - var code = (InputCode ?? string.Empty).Trim(); // limpia CR/LF del lector - if (string.IsNullOrWhiteSpace(code)) - { - toastService.ShowWarning("Ingrese un código válido."); - await Refocus(); - return; - } - try { - var matchedItem = await stockScanService.ParseAndMatchAsync(code, LocationId ?? 1); + var raw = InputCode?.Trim(); + if (string.IsNullOrWhiteSpace(raw)) + return; - // 3) BusinessKey (clave de fusión) y contexto + // Parsear y resolver candidato desde backend + var s = await stockScanService.ParseAndMatchAsync(raw, LocationId ?? 1); + + // limpiar input y devolver foco + InputCode = string.Empty; + //wait Refocus(); + + if (s is null) { - toastService.ShowWarning("No se encontró el producto en stock."); + toastService.ShowWarning("No se encontró un artículo para ese código."); + return; + } + HasLastScan = true; + + // Mapear a fila del modal + var row = new StockDisplayRow + { + StockItemId = s.StockItemId, + ProductId = s.ProductId, + ProductName = s.ProductName ?? string.Empty, + Batch = s.Batch ?? string.Empty, + Serial = s.Serial ?? string.Empty, + Expiration = s.Expiration, + LocationId = s.LocationId, + Available = s.Quantity, + TraceabilityType = s.TraceabilityType, + Selected = 0 + }; + + // Clave de negocio + contexto del snapshot + row.BusinessKey = BuildKeyFromRow(row); + + var alreadyInExpedition = _alreadyInExpedition.TryGetValue(row.BusinessKey, out var q) + ? q + : 0m; + + var selectedInModal = StockList + .Where(x => x.BusinessKey == row.BusinessKey) + .Sum(x => x.Selected); + + var effectiveAvailable = row.Available - selectedInModal; + + // Reglas por trazabilidad + // SERIAL*: nunca duplicar ni incrementar + if (!string.IsNullOrWhiteSpace(row.Serial)) + { + // buscar si ya existe la misma BusinessKey en la tabla del modal + var existingSerial = StockList.FirstOrDefault(x => x.BusinessKey == row.BusinessKey); + + // 🔄 REFRESH siempre que tengamos la fila (si existe), antes de cualquier return + if (existingSerial is not null) + { + existingSerial.Available = row.Available ; // actualizar disponible con el del re-escaneo + } + + var existsInModal = existingSerial is not null; + + //var existsInModal = StockList.Any(x => x.BusinessKey == row.BusinessKey); + if (alreadyInExpedition > 0 || existsInModal) + { + toastService.ShowWarning("Este serial ya está agregado."); + await Refocus(); + return; + } + + if (effectiveAvailable <= 0) + { + toastService.ShowWarning("No hay disponible para este serial."); + await Refocus(); + return; + } + + row.Selected = 1; // serial siempre = 1 + StockList.Insert(0, row); await Refocus(); return; } - if (StockList.Any(s => s.StockItemId == matchedItem.StockItemId)) + // BATCH / NONE: sumar sin pasar el disponible efectivo + if (effectiveAvailable <= 0) { - toastService.ShowInfo("Este ítem ya está listado."); + var existingSerial = StockList.FirstOrDefault(x => x.BusinessKey == row.BusinessKey); + + // 🔄 REFRESH siempre que tengamos la fila (si existe), antes de cualquier return + if (existingSerial is not null) + { + existingSerial.Available = row.Available; // actualizar disponible con el del re-escaneo + } + toastService.ShowWarning("No hay más disponible para esta combinación."); await Refocus(); return; } - StockList.Insert(0, new StockDisplayRow + var existingBN = StockList.FirstOrDefault(x => x.BusinessKey == row.BusinessKey); + if (existingBN is not null) { - StockItemId = matchedItem.StockItemId, - ProductId = matchedItem.ProductId, - ProductName = matchedItem.ProductName, - Batch = matchedItem.Batch, - Serial = matchedItem.Serial, - Expiration = matchedItem.Expiration, - Available = matchedItem.Quantity, - Selected = 1, - LocationId = matchedItem.LocationId - }); + existingBN.Available = s.Quantity; + // suma +1 cap al disponible efectivo + var add = 1m; + var maxAdd = Math.Min(add, effectiveAvailable); + if (maxAdd <= 0) + { + toastService.ShowWarning("Se alcanzó el máximo disponible."); + await Refocus(); + return; + } + + existingBN.Selected += maxAdd; + await Refocus(); + return; + } + + // Nueva fila (otra ubicación/lote/etc.) + row.Selected = 1; + StockList.Insert(0, row); + await Refocus(); } catch (Exception ex) { - toastService.ShowError($"Error en escaneo: {ex.Message}"); - } - finally - { - InputCode = string.Empty; + toastService.ShowError($"Error al procesar el escaneo: {ex.Message}"); await Refocus(); - StateHasChanged(); } } @@ -172,8 +282,6 @@ await scanInput.FocusAsync(); // deja el cursor listo para el próximo escaneo } - private Task Cancel() => ModalInstance.CancelAsync(); - private async Task Confirm() { var selected = StockList @@ -186,6 +294,7 @@ Batch = x.Batch, Serial= x.Serial, Expiration = x.Expiration, + TraceabilityType = x.TraceabilityType, Quantity = x.Selected, LocationId = x.LocationId }) @@ -196,20 +305,10 @@ toastService.ShowWarning("No se seleccionó ningún producto."); return; } - await ModalInstance.CloseAsync(ModalResult.Ok(selected)); } - private async Task LoadMockStock() - { - StockList = new List - { - new StockDisplayRow { StockItemId = 101, ProductId = 1, ProductName = "Tornillo 4mm x 20mm", Batch = "LOTE001", Expiration = DateTime.Today.AddMonths(12), Available = 5, Selected = 0, LocationId = LocationId ?? 1 }, - new StockDisplayRow { StockItemId = 102, ProductId = 1, ProductName = "Tornillo 4mm x 20mm", Batch = "LOTE002", Expiration = DateTime.Today.AddMonths(18), Available = 10, Selected = 0, LocationId = LocationId ?? 1 }, - new StockDisplayRow { StockItemId = 103, ProductId = 2, ProductName = "Placa LCP 6 orificios", Batch = "PL001-A", Expiration = DateTime.Today.AddYears(2), Available = 3, Selected = 0, LocationId = LocationId ?? 1 } - }; - await Task.CompletedTask; - } + private Task Cancel() => ModalInstance.CancelAsync(); public class StockDisplayRow { @@ -222,5 +321,20 @@ public decimal Available { get; set; } public decimal Selected { get; set; } public int LocationId { get; set; } + public int TraceabilityType { get; set; } + + /// Clave de fusión (no se persiste). + public string BusinessKey { get; set; } = string.Empty; + } + + private static string BuildKeyFromRow(StockDisplayRow r) + { + return Domain.Dtos.Stock.StockKeys.BuildBusinessKey( + r.ProductId, + r.LocationId, + r.Batch, + r.Expiration.HasValue ? DateOnly.FromDateTime(r.Expiration.Value) : (DateOnly?)null, + r.Serial + ); } } diff --git a/phronCare.UIBlazor/Program.cs b/phronCare.UIBlazor/Program.cs index 342ef1c..0fa1273 100644 --- a/phronCare.UIBlazor/Program.cs +++ b/phronCare.UIBlazor/Program.cs @@ -1,23 +1,26 @@ -using phronCare.UIBlazor; -using phronCare.UIBlazor.Services.UI; -using phronCare.UIBlazor.Services.Sales; -using phronCare.UIBlazor.Services.Lookups; -using phronCare.UIBlazor.Services.Tickets; -using phronCare.UIBlazor.Services.Authorization; -using phronCare.UIBlazor.Services.Sales.Quotes; -using phronCare.UIBlazor.Services.Integrations; -using Microsoft.AspNetCore.Components.Web; -using Microsoft.AspNetCore.Components.Authorization; -using Microsoft.AspNetCore.Components.WebAssembly.Hosting; - using Blazored.Modal; using Blazored.Toast; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using phronCare.UIBlazor; +using phronCare.UIBlazor.Services.Authorization; +using phronCare.UIBlazor.Services.Integrations; +using phronCare.UIBlazor.Services.Lookups; +using phronCare.UIBlazor.Services.Sales; +using phronCare.UIBlazor.Services.Sales.Quotes; using phronCare.UIBlazor.Services.Stock; using phronCare.UIBlazor.Services.Stock.Expeditions; +using phronCare.UIBlazor.Services.Tickets; +using phronCare.UIBlazor.Services.UI; +using System.Globalization; var builder = WebAssemblyHostBuilder.CreateDefault(args); builder.RootComponents.Add("#app"); builder.RootComponents.Add("head::after"); +var culture = new CultureInfo("es-AR"); +CultureInfo.DefaultThreadCurrentCulture = culture; +CultureInfo.DefaultThreadCurrentUICulture = culture; #region Proveedor de Autorizacion builder.Services.AddScoped(); builder.Services.AddScoped(p => p.GetRequiredService()); @@ -70,6 +73,4 @@ static void InjectDependencies(WebAssemblyHostBuilder builder) builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); - - } \ No newline at end of file diff --git a/phronCare.UIBlazor/Services/Stock/StockScanService.cs b/phronCare.UIBlazor/Services/Stock/StockScanService.cs index f2ae4dd..ebfcb48 100644 --- a/phronCare.UIBlazor/Services/Stock/StockScanService.cs +++ b/phronCare.UIBlazor/Services/Stock/StockScanService.cs @@ -37,6 +37,7 @@ public class StockScanService : IStockScanService Expiration = first.Expiration?.ToDateTime(TimeOnly.MinValue), Quantity = first.AvailableQty, LocationId = first.LocationId ?? locationId, + TraceabilityType = first.TraceabilityType, Serial = first.Serial // si lo devolvés en el DTO de scan }; } diff --git a/phronCare.UIBlazor/phronCare.UIBlazor.csproj b/phronCare.UIBlazor/phronCare.UIBlazor.csproj index 92a394c..89da6f4 100644 --- a/phronCare.UIBlazor/phronCare.UIBlazor.csproj +++ b/phronCare.UIBlazor/phronCare.UIBlazor.csproj @@ -5,8 +5,13 @@ enable enable + + + false + true + - +
@item.Batch @item.Serial @item.Expiration?.ToShortDateString()@item.Available@item.Available.ToString("N2", CultureInfo.CurrentCulture) - +