All checks were successful
CI/CD Pipeline / Build and Deploy with Docker Compose (pull_request) Successful in 6m37s
- 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
348 lines
13 KiB
Plaintext
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
|
|
);
|
|
}
|
|
}
|