phronCare/phronCare.UIBlazor/Pages/Stock/Shared/StockItemSelectorModal.razor
leandro 394c864dfa
All checks were successful
CI/CD Pipeline / Build and Deploy with Docker Compose (pull_request) Successful in 6m37s
ffeat(expeditions): persist stockitem_id in ExpeditionDetails (traceability base)
- Added stockitem_id column to PhLSM_ExpeditionDetails
- Added FK to PhLSM_StockItem
- Added indexes (StockItem and Expedition_StockItem)
- Updated scaffold models
- Updated UI merge to preserve StockItemId
- CreateFullExpeditionAsync now persists stockitem_id
- Base step to enable logistic states and double-trace prevention

Closes #3
2026-03-02 19:44:49 -03:00

348 lines
13 KiB
Plaintext

@using Blazored.Modal
@using Blazored.Modal.Services
@using Domain.Dtos.Stock
@using Microsoft.AspNetCore.Components.Web
@using System.Globalization
@inject IToastService toastService
@inject IStockScanService stockScanService
@inject IModalService Modal
@inherits LayoutComponentBase
<div class="modal-header bg-dark text-white">
<h5 class="modal-title">Seleccionar artículos de stock</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="Cancel"></button>
</div>
<div class="modal-body" style="zoom:0.9;">
<div class="mb-3">
<label for="scan" class="form-label">Escanear o ingresar código</label>
<div class="input-group">
<span class="input-group-text">
<i class="fas fa-qrcode"></i>
</span>
<!-- Input nativo: @ref es ElementReference; capturamos Enter -->
<input class="form-control"
id="scan"
@bind="InputCode"
@bind:event="oninput"
@onkeydown="HandleKeyDown"
autocomplete="off"
spellcheck="false"
@ref="scanInput" />
</div>
<button type="button" class="btn btn-secondary mt-2" @onclick="HandleScan">Buscar</button>
</div>
@if (HasLastScan)
{
var last = StockList.First();
<div class="alert alert-info small">
Último escaneado: <strong>@last.ProductName</strong> | Lote <strong>@last.Batch</strong> | Venc: <strong>@last.Expiration?.ToShortDateString()</strong>
</div>
}
<div class="stockselector-table-wrap" style="zoom:80%">
<table class="table table-sm table-bordered stockselector-table">
<thead class="table-light">
<tr>
<th class="text-left align-middle">Producto</th>
<th class="text-center align-middle">Lote</th>
<th class="text-center align-middle">Serial</th>
<th class="text-center align-middle">Vencimiento</th>
<th class="text-center align-middle">Disponible</th>
<th class="text-center align-middle">Cantidad a usar</th>
</tr>
</thead>
<tbody>
@foreach (var item in StockList)
{
<tr>
<td>@item.ProductName</td>
<td class="text-center align-middle">@item.Batch</td>
<td class="text-center align-middle">@item.Serial</td>
<td class="text-center align-middle">@item.Expiration?.ToShortDateString()</td>
<td class="text-center align-middle">@item.Available.ToString("N2", CultureInfo.CurrentCulture)</td>
<td>
<input type="number"
value="@(item.Selected.ToString("G29"))"
@onchange="@(n => item.Selected = decimal.Parse(n.Value.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture))"
min="0"
max="@(item.Available.ToString("G29"))"
class="form-control form-control-sm" />
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @onclick="Cancel">Cancelar</button>
<button class="btn btn-primary" @onclick="Confirm">Agregar a lista</button>
</div>
@code {
[CascadingParameter] BlazoredModalInstance ModalInstance { get; set; } = default!;
[Parameter] public int? ProductId { get; set; }
[Parameter] public int? LocationId { get; set; }
[Parameter] public List<ProductSetItemDto>? SetItems { get; set; }
[Parameter] public List<StockSnapshotItem> Snapshot { get; set; } = new();
// Mapa: BusinessKey -> Cantidad ya en la expedición
private Dictionary<string, decimal> _alreadyInExpedition = new();
private bool HasLastScan { get; set; }
//private readonly List<StockItemSelectionDto> SelectedItems = new();
private List<StockDisplayRow> StockList = new();
private string InputCode { get; set; } = string.Empty;
private ElementReference scanInput;
protected override void OnParametersSet()
{
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,
StockItemId = s.StockitemId,
ProductName = s.ProductName ?? "<Producto sin descripción>",
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")
{
await HandleScan();
await scanInput.FocusAsync();
}
}
private async Task HandleScan()
{
try
{
var raw = InputCode?.Trim();
if (string.IsNullOrWhiteSpace(raw))
return;
// 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ó 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;
}
// BATCH / NONE: sumar sin pasar el disponible efectivo
if (effectiveAvailable <= 0)
{
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;
}
var existingBN = StockList.FirstOrDefault(x => x.BusinessKey == row.BusinessKey);
if (existingBN is not null)
{
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 al procesar el escaneo: {ex.Message}");
await Refocus();
}
}
private async Task Refocus()
{
await Task.Yield(); // asegura que el DOM está listo
await scanInput.FocusAsync(); // deja el cursor listo para el próximo escaneo
}
private async Task Confirm()
{
var selected = StockList
.Where(x => x.Selected > 0)
.Select(x => new StockItemSelectionDto
{
StockItemId = x.StockItemId,
ProductId = x.ProductId,
ProductName = x.ProductName,
Batch = x.Batch,
Serial= x.Serial,
Expiration = x.Expiration,
TraceabilityType = x.TraceabilityType,
LocationId = x.LocationId,
Quantity = !string.IsNullOrWhiteSpace(x.Serial)
? 1m
: decimal.Round(Math.Min(x.Selected, x.Available), 2, MidpointRounding.AwayFromZero)
})
.ToList();
if (!selected.Any())
{
toastService.ShowWarning("No se seleccionó ningún producto.");
return;
}
await ModalInstance.CloseAsync(ModalResult.Ok(selected));
}
private Task Cancel() => ModalInstance.CancelAsync();
public class StockDisplayRow
{
public int StockItemId { get; set; }
public int ProductId { get; set; }
public string ProductName { get; set; } = string.Empty;
public string Batch { get; set; } = string.Empty;
public string Serial { get; set; } = string.Empty;
public DateTime? Expiration { get; set; }
public decimal Available { get; set; }
public decimal Selected { get; set; }
public int LocationId { get; set; }
public int TraceabilityType { get; set; }
/// <summary>Clave de fusión (no se persiste).</summary>
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
);
}
}