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
428 lines
17 KiB
Plaintext
428 lines
17 KiB
Plaintext
@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
|
|
|
|
<EditForm Model="Model" OnValidSubmit="HandleValidSubmit">
|
|
<DataAnnotationsValidator />
|
|
<ValidationSummary />
|
|
|
|
<div class="card mt-4" style="zoom:80%">
|
|
<div class="card-header d-flex justify-content-center align-items-center">
|
|
<h3 class="card-title m-0">Nueva Expedición</h3>
|
|
</div>
|
|
|
|
<div class="card-body">
|
|
<div class="row mb-3">
|
|
<div class="col-md-6">
|
|
<label class="form-label">Presupuesto aprobado</label>
|
|
<BlazoredTypeahead id="quotes" TItem="ELookUpItem" TValue="ELookUpItem"
|
|
SearchMethod="SearchQuotes"
|
|
Value="SelectedQuote"
|
|
ValueChanged="OnQuoteSelected"
|
|
ValueExpression="@(() => SelectedQuote)"
|
|
MaximumSuggestions="10"
|
|
Placeholder="Buscar presupuesto aprobado..."
|
|
TextProperty="Nombre">
|
|
<ResultTemplate Context="item">
|
|
@item.Nombre
|
|
</ResultTemplate>
|
|
<SelectedTemplate Context="item">
|
|
@item.Nombre
|
|
</SelectedTemplate>
|
|
</BlazoredTypeahead>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="form-label">Destinatario</label>
|
|
<InputText class="form-control" @bind-Value="Model.RecipientName" />
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label">Ticket ID</label>
|
|
<InputText class="form-control" @bind-Value="ticketIdString" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row mb-3">
|
|
<div class="col-md-4">
|
|
<label class="form-label">Profesional</label>
|
|
<InputText class="form-control" @bind-Value="ExtraInfo.Professional" />
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="form-label">Institución</label>
|
|
<InputText class="form-control" @bind-Value="ExtraInfo.Institution" />
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="form-label">Paciente</label>
|
|
<InputText class="form-control" @bind-Value="ExtraInfo.Patient" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row mb-3">
|
|
<div class="col-md-4">
|
|
<label class="form-label">Fecha de Cirugía</label>
|
|
<InputDate class="form-control" @bind-Value="ExtraInfo.SurgeryDate" />
|
|
</div>
|
|
<div class="col-md-8">
|
|
<label class="form-label">Observaciones</label>
|
|
<InputTextArea class="form-control" @bind-Value="Model.Observations" />
|
|
</div>
|
|
</div>
|
|
|
|
@if (!string.IsNullOrWhiteSpace(DispatchInstruction))
|
|
{
|
|
<div class="alert alert-info">
|
|
<strong>Instrucciones desde presupuesto:</strong><br />
|
|
@DispatchInstruction
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card mt-3">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<h5 class="card-title m-0">Productos a Expedir</h5>
|
|
<div class="btn-group">
|
|
<button class="btn btn-dark btn-sm" title="Agregar producto" @onclick="OpenStockItemSelectorModal">
|
|
<i class="fas fa-box"></i> Producto
|
|
</button>
|
|
<button class="btn btn-dark btn-sm" disabled title="Agregar set">
|
|
<i class="fas fa-th-large"></i> Set-Box
|
|
</button>
|
|
<button class="btn btn-dark btn-sm" disabled title="Escanear producto">
|
|
<i class="fas fa-barcode"></i> Scanner
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card-body p-2">
|
|
@if (Details.Any())
|
|
{
|
|
<div class="expedition-table-wrap" style="zoom:80%">
|
|
<table class="table table-sm table-bordered expedition-table">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th class="text-center align-middle">Producto</th>
|
|
<th>Nombre</th>
|
|
<th class ="text-center align-middle">Cant.</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">Ubicación</th>
|
|
<th style="width: 40px;"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var item in Details)
|
|
{
|
|
<tr>
|
|
<td class="text-center align-middle">@item.ProductId</td>
|
|
<td>@item.ProductName</td>
|
|
<td class="text-center align-middle">@item.Quantity</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?.ToString("yyyy-MM-dd")</td>
|
|
<td class ="text-center align-middle">@item.LocationId</td>
|
|
<td class="text-center align-middle">
|
|
<button class="btn btn-link p-0" style="color: red;" title="Eliminar" @onclick="() => RemoveItem(item)">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<p class="text-muted">No hay productos agregados.</p>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-3 d-flex justify-content-end">
|
|
<button class="btn btn-primary"
|
|
@onclick="SaveAsync"
|
|
disabled="@IsSaving">
|
|
@if (IsSaving)
|
|
{
|
|
<span class="spinner-border spinner-border-sm me-2"></span>
|
|
}
|
|
Guardar Expedición
|
|
</button>
|
|
</div>
|
|
|
|
</EditForm>
|
|
|
|
@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<ELSExpeditionDetail> Details = new();
|
|
private List<ProductSetItemDto> 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<IEnumerable<ELookUpItem>> 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<StockItemSelectorModal>("", parameters, options);
|
|
|
|
var result = await modal.Result;
|
|
|
|
if (!result.Cancelled && result.Data is List<StockItemSelectionDto> selectedItems)
|
|
{
|
|
MergeSelectionsByBusinessKey(selectedItems); // ← usar merge (ver abajo)
|
|
StateHasChanged();
|
|
toastService.ShowSuccess($"{selectedItems.Count} item(s) agregados/actualizados.");
|
|
}
|
|
}
|
|
|
|
/// <summary> Fusiona la lista de ítems seleccionados en el modal con la lista principal <c>Details</c>.
|
|
///
|
|
/// Reglas principales:
|
|
/// - Se construye una clave de negocio (ProductId, LocationId, Batch, Expiration, Serial) para identificar ítems únicos.
|
|
/// - Si el ítem ya existe en <c>Details</c>:
|
|
/// • 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 <c>Details</c> 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.
|
|
/// </summary>
|
|
private void MergeSelectionsByBusinessKey(List<StockItemSelectionDto> 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<StockSnapshotItem> 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; }
|
|
}
|
|
}
|