@page "/expeditions/create" @using Blazored.Typeahead @using Domain.Dtos.Stock @using Services.Lookups @using Services.Stock.Expeditions @using System.Text.Json @using phronCare.UIBlazor.Pages.Stock.Shared @inject NavigationManager Navigation @inject ExpeditionService expeditionService @inject ISalesLookupService lookUpService @inject IToastService toastService @inject IModalService Modal

Nueva Expedición

@item.Nombre @item.Nombre
@if (!string.IsNullOrWhiteSpace(DispatchInstruction)) {
Instrucciones desde presupuesto:
@DispatchInstruction
}
Productos a Expedir
@if (Details.Any()) {
@foreach (var item in Details) { }
Producto Nombre Cant. Lote Serial Vencimiento Ubicación
@item.ProductId @item.ProductName @item.Quantity @item.Batch @item.Serial @item.Expiration?.ToString("yyyy-MM-dd") @item.LocationId
} else {

No hay productos agregados.

}
@code { private ELSExpeditionHeader Model = new() { Issuedate = DateTime.Today, LocationId = 1, // Depósito por defecto OriginType = "surgery", // Tipo de origen por defecto Printcount = 0 // mapear otros campos de cabecera si aplica (BU, moneda, etc.) }; private ExtraInfoModel ExtraInfo = new(); private ELookUpItem? SelectedQuote; private List Details = new(); private List ProductSetItems = new(); private string DispatchInstruction = string.Empty; private string ticketIdString = string.Empty; //private int? FormSeriesId; public const int ExpeditionSeriesId = 13; // Serie de comprobante para presupuestos (talonario Q). private bool IsSaving; private async Task> SearchQuotes(string filter) { return await lookUpService.SearchApprovedQuotesAsync(filter); } private async Task OnQuoteSelected(ELookUpItem? selected) { if (selected is null || string.IsNullOrWhiteSpace(selected.Nombre)) { SelectedQuote = null; ExtraInfo = new(); // Limpiar datos cargados DispatchInstruction = ""; return; } SelectedQuote = selected; var quoteNumber = selected.Nombre.Split(" - ")[0]; var quote = await expeditionService.GetQuoteByNumberAsync(quoteNumber); if (quote is null) { toastService.ShowError("No se pudo cargar el presupuesto."); return; } Model.ExternalReference = quote.Quotenumber; Model.RecipientName = quote.InstitutionName; Model.TicketId = quote.TicketId; ExtraInfo.Professional = quote.ProfessionalName; ExtraInfo.Institution = quote.InstitutionName; ExtraInfo.Patient = quote.PatientName; ExtraInfo.SurgeryDate = quote.EstimatedDate; DispatchInstruction = quote.Observations ?? ""; } private string? ValidateBeforeSave() { if (Details.Count == 0) return "Debe incluir al menos un ítem."; if (Details.Any(x => x.Quantity <= 0)) return "Hay ítems con cantidad inválida."; return null; } private async Task SaveAsync() { var error = ValidateBeforeSave(); if (!string.IsNullOrEmpty(error)) { toastService.ShowError(error); return; } try { IsSaving = true; // Mapear ExtraInfoModel → ExtrainfoJson Model.ExtrainfoJson = JsonSerializer.Serialize(ExtraInfo); if (!string.IsNullOrWhiteSpace(ticketIdString) && Guid.TryParse(ticketIdString, out var tid)) Model.TicketId = tid; // si el header lo tiene var result = await expeditionService.CreateAndIssueAsync(Model, Details, ExpeditionSeriesId); if (result is null || !result.Success) { toastService.ShowError(result?.ErrorMessage ?? "No se pudo emitir la expedición."); return; } toastService.ShowSuccess($"Expedición emitida: {result.ExpeditionNumber}"); await expeditionService.ExportPdfAsync(result.Id, result.ExpeditionNumber); Navigation.NavigateTo("/"); } catch (Exception ex) { toastService.ShowError($"Error: {ex.Message}"); } finally { IsSaving = false; } } private void RemoveItem(ELSExpeditionDetail item) { Details.Remove(item); } private async Task HandleValidSubmit() { // TODO: Lógica de guardado de la expedición completa } private async Task OpenStockItemSelectorModal() { var parameters = new ModalParameters(); parameters.Add(nameof(StockItemSelectorModal.SetItems), ProductSetItems); parameters.Add(nameof(StockItemSelectorModal.Snapshot), BuildSnapshotFromDetails()); // ← clave 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) { MergeSelectionsByBusinessKey(selectedItems); // ← usar merge (ver abajo) StateHasChanged(); toastService.ShowSuccess($"{selectedItems.Count} item(s) agregados/actualizados."); } } /// Fusiona la lista de ítems seleccionados en el modal con la lista principal Details. /// /// Reglas principales: /// - Se construye una clave de negocio (ProductId, LocationId, Batch, Expiration, Serial) para identificar ítems únicos. /// - Si el ítem ya existe en Details: /// • Si la cantidad recibida es > 0 → se actualiza la fila existente (SET exacto, no suma). /// • Si la cantidad recibida es 0 y la fila tenía cantidad previa > 0 → se interpreta como /// "snapshot sin intención de borrar" y se conserva la fila (no se elimina). /// • Si la cantidad recibida es 0 y no había cantidad previa → se elimina la fila. /// - Si el ítem no existe en Details y la cantidad recibida es > 0 → se agrega como nueva fila. /// - Si el ítem no existe y la cantidad es 0 → no se hace nada. /// - Si el ítem tiene número de serie → la cantidad se fuerza siempre a 1. /// /// Esta lógica evita el bug en el que, al reabrir el modal con un snapshot, los ítems volvían con /// cantidad = 0 y eran eliminados indebidamente de la grilla principal. /// 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 ); // Normalización de quantity (no negativa) var newQty = s.Quantity < 0 ? 0 : s.Quantity; // Serial ⇒ siempre 1 (ignora lo que venga del modal) if (!string.IsNullOrWhiteSpace(s.Serial)) newQty = 1; if (existing is not null) { if (newQty == 0) { // ⚠️ Si ya había cantidad y el modal devolvió 0, // lo tratamos como "snapshot sin intención de borrar" → no remover. if (existing.Quantity > 0) continue; // 0 explícito válido (no había cantidad previa): eliminar. Details.Remove(existing); continue; } // SET exacto (no sumar): mantener coherencia con el modal existing.Quantity = newQty; // Actualizaciones mínimas necesarias (evitar sobreescrituras innecesarias): if (!string.IsNullOrWhiteSpace(s.ProductName)) existing.ProductName = s.ProductName; if (s.Batch is not null) existing.Batch = s.Batch; if (s.Serial is not null) existing.Serial = s.Serial; existing.Expiration = exp; existing.LocationId = s.LocationId; existing.TraceabilityType = s.TraceabilityType; // UI only } else { 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 hacemos nada } } } 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(); } private class ExtraInfoModel { public string? Professional { get; set; } public string? Institution { get; set; } public string? Patient { get; set; } public DateTime? SurgeryDate { get; set; } } }