Instrucciones desde presupuesto:
@DispatchInstruction
}
Productos a Expedir
@if (Details.Any())
{
Producto
Nombre
Cant.
Lote
Serial
Vencimiento
Ubicación
@foreach (var item in Details)
{
@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
// 🆕 AGREGAR ESTO
if (s.StockItemId != 0 && existing.StockitemId != s.StockItemId)
{
existing.StockitemId = s.StockItemId;
}
}
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,
StockitemId = s.StockItemId
});
}
// 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,
StockitemId = d.StockitemId, // 🆕 incluir StockitemId en el snapshot
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; }
}
}