From 6e61b7b59872e6da1ac6ef0c48d9b0371c177b81 Mon Sep 17 00:00:00 2001 From: Leandro Hernan Rojas Date: Thu, 4 Sep 2025 18:15:15 -0300 Subject: [PATCH] Update Expeditions Print --- Core/Interfaces/IQuoteDom.cs | 2 +- Core/Interfaces/Stock/IExpeditionDom.cs | 12 + Core/Services/QuoteService.cs | 1 + Core/Services/Stock/ExpeditionService.cs | 37 ++ Documents/Documents.csproj | 5 + Documents/Models/DocumentType.cs | 1 + Documents/Services/DocumentTemplateService.cs | 93 ++++-- ...erer.cs => RazorLightTemplateRenderer.csx} | 0 .../Templates/Expeditions/Template_v1.cshtml | 315 ++++++++++++++++++ Domain/Constants/ExpeditionStatus.cs | 13 + .../Constants/ExpeditionStatusExtensions.cs | 16 + Domain/Dtos/Stock/ExpeditionDto.cs | 138 ++++++++ Domain/Dtos/Stock/ExpeditionItemDto.cs | 71 ++++ .../ELSExpeditionHeader.StatusEnum.cs | 14 + Domain/Entities/ELSExpeditionHeader.cs | 5 +- Models/Interfaces/IExpeditionRepository.cs | 16 + .../Stock/PhLSMExpeditionRepository.cs | 182 ++++++++++ .../Documents/DocumentsController.cs | 22 -- .../Controllers/Sales/QuoteController.cs | 7 +- .../Controllers/Stock/ExpeditionController.cs | 80 +++++ phronCare.API/Program.cs | 12 +- .../obj/Debug/net8.0/ApiEndpoints.json | 32 ++ phronCare.API/phronCare.API.csproj | 3 + .../Stock/Expeditions/ExpeditionCreate.razor | 83 ++++- .../Stock/Expeditions/ExpeditionService.cs | 132 +++++++- 25 files changed, 1226 insertions(+), 66 deletions(-) create mode 100644 Core/Interfaces/Stock/IExpeditionDom.cs create mode 100644 Core/Services/Stock/ExpeditionService.cs rename Documents/Services/{RazorLightTemplateRenderer.cs => RazorLightTemplateRenderer.csx} (100%) create mode 100644 Documents/Templates/Expeditions/Template_v1.cshtml create mode 100644 Domain/Constants/ExpeditionStatus.cs create mode 100644 Domain/Constants/ExpeditionStatusExtensions.cs create mode 100644 Domain/Dtos/Stock/ExpeditionDto.cs create mode 100644 Domain/Dtos/Stock/ExpeditionItemDto.cs create mode 100644 Domain/Entities/ELSExpeditionHeader.StatusEnum.cs create mode 100644 Models/Interfaces/IExpeditionRepository.cs create mode 100644 Models/Repositories/Stock/PhLSMExpeditionRepository.cs delete mode 100644 phronCare.API/Controllers/Documents/DocumentsController.cs create mode 100644 phronCare.API/Controllers/Stock/ExpeditionController.cs diff --git a/Core/Interfaces/IQuoteDom.cs b/Core/Interfaces/IQuoteDom.cs index 93966de..7827437 100644 --- a/Core/Interfaces/IQuoteDom.cs +++ b/Core/Interfaces/IQuoteDom.cs @@ -2,7 +2,7 @@ using Domain.Entities; using Domain.Generics; -namespace Models.Interfaces +namespace Core.Interfaces { public interface IQuoteDom { diff --git a/Core/Interfaces/Stock/IExpeditionDom.cs b/Core/Interfaces/Stock/IExpeditionDom.cs new file mode 100644 index 0000000..44e0d47 --- /dev/null +++ b/Core/Interfaces/Stock/IExpeditionDom.cs @@ -0,0 +1,12 @@ +using Domain.Dtos.Stock; +using Domain.Entities; + +namespace Core.Interfaces.Stock +{ + // 1.2 Domain (Core) + public interface IExpeditionDom + { + Task<(int Id, string ExpeditionNumber)> CreateAndIssueAsync(ELSExpeditionHeader header, IEnumerable details, int formSeriesId); + Task GetDtoByIdAsync(int id); + } +} diff --git a/Core/Services/QuoteService.cs b/Core/Services/QuoteService.cs index ec03d76..1d5dcaa 100644 --- a/Core/Services/QuoteService.cs +++ b/Core/Services/QuoteService.cs @@ -3,6 +3,7 @@ using Domain.Constants; using Domain.Entities; using Domain.Generics; using Models.Interfaces; +using Core.Interfaces; namespace Core.Services { diff --git a/Core/Services/Stock/ExpeditionService.cs b/Core/Services/Stock/ExpeditionService.cs new file mode 100644 index 0000000..b88a4ee --- /dev/null +++ b/Core/Services/Stock/ExpeditionService.cs @@ -0,0 +1,37 @@ +using Core.Interfaces.Stock; +using Domain.Dtos.Stock; +using Domain.Entities; +using Models.Interfaces; + +namespace Core.Services.Stock +{ + public class ExpeditionService : IExpeditionDom + { + #region Declaraciones + private readonly IExpeditionRepository _repo; + public ExpeditionService(IExpeditionRepository repo) => _repo = repo; + #endregion + #region Guardado completo de expedicion (encabezado + detalles) + public async Task<(int Id, string ExpeditionNumber)> CreateAndIssueAsync( + ELSExpeditionHeader header, + IEnumerable details, + int formSeriesId) + { + if (header is null) throw new ArgumentNullException(nameof(header)); + if (details is null || !details.Any()) + throw new InvalidOperationException("Debe incluir al menos un ítem."); + if (formSeriesId <= 0) + throw new ArgumentOutOfRangeException(nameof(formSeriesId), "Serie inválida."); + + // Reemplazo directo de la colección (más claro que Clear()+Add) + header.PhLsmExpeditionDetails = details.ToList(); + + return await _repo.CreateFullExpeditionAsync(header, formSeriesId); + } + #endregion + public async Task GetDtoByIdAsync(int id) + { + return await _repo.GetDtoByIdAsync(id); + } + } +} diff --git a/Documents/Documents.csproj b/Documents/Documents.csproj index 9092bfc..fd0672c 100644 --- a/Documents/Documents.csproj +++ b/Documents/Documents.csproj @@ -15,6 +15,11 @@ PreserveNewest + + + + + diff --git a/Documents/Models/DocumentType.cs b/Documents/Models/DocumentType.cs index d5679e4..e9a06f8 100644 --- a/Documents/Models/DocumentType.cs +++ b/Documents/Models/DocumentType.cs @@ -3,6 +3,7 @@ public enum DocumentType { Quote, + Expedition, Invoice, Order, Remito, diff --git a/Documents/Services/DocumentTemplateService.cs b/Documents/Services/DocumentTemplateService.cs index 321f30f..8823c89 100644 --- a/Documents/Services/DocumentTemplateService.cs +++ b/Documents/Services/DocumentTemplateService.cs @@ -1,6 +1,8 @@ -using Documents.Interfaces; +using System.Collections.Concurrent; +using Documents.Interfaces; using Documents.Models; -using Domain.Dtos; +using Domain.Dtos; // QuoteDto +using Domain.Dtos.Stock; // ExpeditionDto using Transversal.Interfaces; public class DocumentTemplateService : IDocumentTemplateService @@ -8,6 +10,9 @@ public class DocumentTemplateService : IDocumentTemplateService private readonly ITemplateRenderer _templateRenderer; private readonly IPdfGeneratorService _pdfGeneratorService; + // Cache simple para no leer el logo del disco en cada render + private static readonly ConcurrentDictionary _imageCacheBase64 = new(); + public DocumentTemplateService(ITemplateRenderer templateRenderer, IPdfGeneratorService pdfGeneratorService) { _templateRenderer = templateRenderer; @@ -16,28 +21,76 @@ public class DocumentTemplateService : IDocumentTemplateService public async Task GenerateDocumentAsync(DocumentGenerationRequest request) { - //REFACTORIZAR PARA GENERAR DOCUMENTOS DE DIFERENTES TIPOS!! + if (request is null) throw new ArgumentNullException(nameof(request)); + if (request.Model is null) throw new ArgumentNullException(nameof(request.Model)); - // Leer logo - var logoPath = Path.Combine(Directory.GetCurrentDirectory(), "Resources", "logo.png"); - var logoBase64 = GetImageBase64(logoPath); + string? templatePath = null; - // Inyectar al modelo si corresponde - if (request.Model is QuoteDto quote) + try { - quote.LogoBase64 = logoBase64; + // 1) Elegir plantilla por tipo de documento + templatePath = ResolveTemplate(request.DocumentType); + + // 2) Inyectar logo (si el DTO lo soporta) + var logoBase64 = GetImageBase64Cached( + Path.Combine(Directory.GetCurrentDirectory(), "Resources", "logo.png")); + InjectLogoIfSupported(request.Model, logoBase64); + + // 3) Render + PDF + var html = await _templateRenderer.RenderAsync(templatePath, request.Model); + return await _pdfGeneratorService.GeneratePdfFromHtmlAsync(html); + } + catch (Exception ex) + { + // Envolvemos con contexto para facilitar el diagnóstico + var wrapped = new Exception( + $"Document generation failed (DocumentType={request.DocumentType}, Template='{templatePath ?? "?"}', ModelType={request.Model.GetType().FullName}). See inner exception.", + ex + ); + wrapped.Data["DocumentType"] = request.DocumentType.ToString(); + if (!string.IsNullOrEmpty(templatePath)) wrapped.Data["TemplatePath"] = templatePath; + wrapped.Data["ModelType"] = request.Model.GetType().FullName; + throw wrapped; + } + } + + private static string ResolveTemplate(DocumentType type) => type switch + { + DocumentType.Quote => "Quotes/Template_v1.cshtml", + DocumentType.Expedition => "Expeditions/Template_v1.cshtml", + _ => "Shared/Template_Generic.cshtml" + }; + + private static void InjectLogoIfSupported(object model, string base64) + { + // Inyección “segura”: si el modelo expone LogoBase64, lo seteamos. + switch (model) + { + case QuoteDto q: + q.LogoBase64 = base64; + break; + case ExpeditionDto e: + e.LogoBase64 = base64; + break; + default: + // Si no tiene LogoBase64, no hacemos nada. + break; + } + } + + private static string GetImageBase64Cached(string imagePath) + { + if (_imageCacheBase64.TryGetValue(imagePath, out var cached)) + return cached; + + if (!File.Exists(imagePath)) + { + _imageCacheBase64[imagePath] = ""; + return ""; } - string html = await _templateRenderer.RenderAsync("Quotes/Template_v1.cshtml", request.Model); - return await _pdfGeneratorService.GeneratePdfFromHtmlAsync(html); - } - - private static string GetImageBase64(string imagePath) - { - if (!File.Exists(imagePath)) - return ""; - - byte[] imageBytes = File.ReadAllBytes(imagePath); - return Convert.ToBase64String(imageBytes); + var base64 = Convert.ToBase64String(File.ReadAllBytes(imagePath)); + _imageCacheBase64[imagePath] = base64; + return base64; } } diff --git a/Documents/Services/RazorLightTemplateRenderer.cs b/Documents/Services/RazorLightTemplateRenderer.csx similarity index 100% rename from Documents/Services/RazorLightTemplateRenderer.cs rename to Documents/Services/RazorLightTemplateRenderer.csx diff --git a/Documents/Templates/Expeditions/Template_v1.cshtml b/Documents/Templates/Expeditions/Template_v1.cshtml new file mode 100644 index 0000000..211e3b1 --- /dev/null +++ b/Documents/Templates/Expeditions/Template_v1.cshtml @@ -0,0 +1,315 @@ +@using System +@using System.Globalization +@using System.Text.Json +@using System.Collections.Generic +@model Domain.Dtos.Stock.ExpeditionDto + +@{ + Layout = null; + + var ci = CultureInfo.GetCultureInfo("es-AR"); + CultureInfo.CurrentCulture = ci; + CultureInfo.CurrentUICulture = ci; + + // Parseo seguro del snapshot (claves EXACTAS del JSON: Professional, Institution, Patient, SurgeryDate) + SurgerySnapshot snap; + if (string.IsNullOrWhiteSpace(Model.ExtrainfoJson)) + { + snap = new SurgerySnapshot(); + } + else + { + try + { + snap = JsonSerializer.Deserialize(Model.ExtrainfoJson) ?? new SurgerySnapshot(); + } + catch + { + snap = new SurgerySnapshot(); + } + } + var reprintText = Model.Printcount > 0 ? (" — Reimpresión " + Model.Printcount) : ""; +} + +@functions { + // Mapear 1:1 con tu JSON de ExtrainfoJson MODELO ORTOPEDIA + public class SurgerySnapshot + { + public string? Professional { get; set; } // "PECHERVSKY PABLO GUSTAVO" + public string? Institution { get; set; } // "CORPORACION MEDICA LABORAL S.A." + public string? Patient { get; set; } // "ALEXIS LASTRA" + public DateTime? SurgeryDate { get; set; } // "2025-06-10T03:00:00" + } + + public static string FQty(decimal q) => q.ToString("G29", CultureInfo.InvariantCulture); + + // DateTime? + public static string FDate(DateTime? d) => d.HasValue ? d.Value.ToString("dd/MM/yyyy") : string.Empty; + + // DateOnly? (por si tus Items usan DateOnly? en Expiration) + public static string FDate(DateOnly? d) => d.HasValue ? d.Value.ToString("dd/MM/yyyy") : string.Empty; +} + + + + + + Nota de Entrega @Model.Expeditionnumber + + + + +
+ + + + + +
+
+ Estado: + @((string.IsNullOrWhiteSpace(Model.StatusLabel) ? "DESCONOCIDO" : Model.StatusLabel) + reprintText) +
+
Origen: @(Model.LocationName ?? "-")
+
+
+ + +
+ + + + + + + + + + + + + +
Institución:@snap.InstitutionProfesional:@snap.Professional
Paciente:@snap.PatientFecha CX:@FDate(snap.SurgeryDate)
+
+
+ +
+ + + + + + + + + + + + + + + + + + + @if (Model.Items != null && Model.Items.Count > 0) + { + int repeat = 100; // ← poné 1 para desactivar el stress-test + + for (int r = 0; r < repeat; r++) + { + foreach (var it in Model.Items) + { + + + + + + + + + + } + } + } + else + { + + } + +
#CódigoDescripciónLoteVenc.SerieCant.
@r@it.FactoryCode@it.ProductName@it.Batch@FDate(it.Expiration)@it.Serial@FQty(it.Quantity)
Sin ítems
+
+ + +
+
Renglones: @Model.TotalItems
+
Unidades: @FQty(Model.TotalQuantity)
+
+ +
+ Observaciones: @(Model.Observations ?? string.Empty) +
+ + + +
+ + diff --git a/Domain/Constants/ExpeditionStatus.cs b/Domain/Constants/ExpeditionStatus.cs new file mode 100644 index 0000000..f383eed --- /dev/null +++ b/Domain/Constants/ExpeditionStatus.cs @@ -0,0 +1,13 @@ +namespace Domain.Constants +{ + /// Estado de la expedición (identificadores comienzan en 1). + public enum ExpeditionStatus : byte + { + Emitida = 1, + EnTransito = 2, + EnDestino = 3, + Retorno = 4, + Cerrada = 5, + Anulada = 6 + } +} diff --git a/Domain/Constants/ExpeditionStatusExtensions.cs b/Domain/Constants/ExpeditionStatusExtensions.cs new file mode 100644 index 0000000..3f4def9 --- /dev/null +++ b/Domain/Constants/ExpeditionStatusExtensions.cs @@ -0,0 +1,16 @@ +namespace Domain.Constants +{ + public static class ExpeditionStatusExtensions + { + public static string ToLabel(this ExpeditionStatus e) => e switch + { + ExpeditionStatus.Emitida => "Emitida", + ExpeditionStatus.EnTransito => "En tránsito", + ExpeditionStatus.EnDestino => "En destino", + ExpeditionStatus.Retorno => "Retorno", + ExpeditionStatus.Cerrada => "Cerrada", + ExpeditionStatus.Anulada => "Anulada", + _ => e.ToString() + }; + } +} diff --git a/Domain/Dtos/Stock/ExpeditionDto.cs b/Domain/Dtos/Stock/ExpeditionDto.cs new file mode 100644 index 0000000..87f1cbe --- /dev/null +++ b/Domain/Dtos/Stock/ExpeditionDto.cs @@ -0,0 +1,138 @@ +namespace Domain.Dtos.Stock +{ + /// + /// DTO de lectura y de impresión para una Expedición (nota de entrega). + /// Representa la cabecera + lista de ítems listos para renderizar en UI/PDF. + /// + public class ExpeditionDto + { + // ===== Identificación / numeración ===== + + /// + /// Identificador interno de la expedición. + /// + public int Id { get; set; } + + /// + /// Número de expedición (ej.: EX-00000001). + /// Lo genera el servidor al emitir según la serie. + /// + public string Expeditionnumber { get; set; } = string.Empty; + + // ===== Fechas / estado ===== + + /// + /// Fecha de emisión de la expedición. + /// + public DateTime Issuedate { get; set; } + + /// + /// Estado numérico de la expedición (según enum del dominio). + /// + public int Status { get; set; } + + /// + /// Etiqueta amigable del estado (ej.: Emitida, En tránsito, En destino, Retorno, Cerrada, Anulada). + /// + public string StatusLabel { get; set; } = string.Empty; + + // ===== Origen (depósito) ===== + + /// + /// Id de la ubicación/depósito desde donde se despacha. + /// + public int LocationId { get; set; } + + /// + /// Nombre de la ubicación/depósito (resuelto por join). + /// + public string? LocationName { get; set; } + + /// + /// (Opcional) Dirección del depósito/origen, útil para impresión. + /// + public string? LocationAddress { get; set; } + + // ===== Destino / referencias externas ===== + + /// + /// Nombre visible del destinatario (si se define para la impresión). + /// + public string? RecipientName { get; set; } + + /// + /// Número o referencia externa asociada (si aplica). + /// + public string? ReferenceNumber { get; set; } + + /// + /// Tipo de origen externo (ej.: surgery, demo, préstamo). + /// + public string? OriginType { get; set; } + + /// + /// Referencia externa a otro módulo o sistema (ticket/orden). + /// + public string? ExternalReference { get; set; } + + /// + /// Ticket quirúrgico asociado (si corresponde). + /// + public Guid? TicketId { get; set; } + + // ===== “Foto” (snapshot) de la cirugía/paciente (Extrainfo) ===== + + /// + /// Información adicional en JSON tal como se almacenó (para trazabilidad). + /// + public string? ExtrainfoJson { get; set; } + + // ===== Observaciones / instrucciones ===== + + /// + /// Observaciones generales de la expedición (libre). + /// + public string? Observations { get; set; } + + // ===== Auditoría de impresión ===== + + /// + /// Cantidad de veces que se imprimió la nota (para mostrar "Reimpresión N"). + /// + public int Printcount { get; set; } + + /// + /// Fecha de creación del registro (trazabilidad). + /// + public DateTime Createdat { get; set; } + + /// + /// Fecha de última modificación del registro (trazabilidad). + /// + public DateTime? Modifiedat { get; set; } + + // ===== Ítems ===== + + /// + /// Detalle de los ítems/productos de la expedición. + /// + public List Items { get; set; } = new(); + + // ===== Totales de conveniencia para la impresión ===== + + /// + /// Total de renglones en la expedición (Items.Count). + /// + public int TotalItems => Items?.Count ?? 0; + + /// + /// Suma de cantidades (para pie de impresión). + /// + public decimal TotalQuantity => Items?.Sum(i => i.Quantity) ?? 0m; + + /// + /// Logo de la compañia. + /// + public string LogoBase64 { get; set; } = string.Empty; + } +} diff --git a/Domain/Dtos/Stock/ExpeditionItemDto.cs b/Domain/Dtos/Stock/ExpeditionItemDto.cs new file mode 100644 index 0000000..d24501b --- /dev/null +++ b/Domain/Dtos/Stock/ExpeditionItemDto.cs @@ -0,0 +1,71 @@ +namespace Domain.Dtos.Stock +{ + /// + /// Ítem de expedición para lectura en UI e impresión (detalle ya resuelto). + /// Contiene la información mínima y suficiente para mostrar/emitir la nota de entrega. + /// + public sealed class ExpeditionItemDto + { + /// + /// Identificador interno del renglón (detalle) de la expedición. + /// Útil para acciones sobre el ítem (eliminar, editar cantidad, etc.). + /// + public int Id { get; set; } + + /// + /// Identificador del producto en el catálogo maestro (FK a PhLSM_Product.Id). + /// No es necesariamente visible en impresión; se usa para joins y trazabilidad. + /// + public int ProductId { get; set; } + + /// + /// Código de producto definido por la fábrica o fabricante. + /// Puede variar según proveedor, presentación o país de origen. + /// Recomendado como código “principal” a mostrar en la nota. + /// + public string FactoryCode { get; set; } = string.Empty; + + /// + /// Nombre del producto tal como figura en el catálogo (técnico o comercial). + /// Se utiliza para la descripción en la tabla de ítems de la impresión. + /// + public string ProductName { get; set; } = string.Empty; + + /// + /// Cantidad a expedir para este ítem. + /// Para productos con trazabilidad por unidad/serial, suele ser 1 por línea. + /// Para productos por lote (batch), puede ser una cantidad agregada. + /// + public decimal Quantity { get; set; } + + /// + /// Lote del producto (GS1 AI 10). + /// Aplica a productos con trazabilidad por lote; puede ser null si no corresponde. + /// + public string? Batch { get; set; } + + /// + /// Serial/Número de serie del producto (GS1 AI 21). + /// Aplica a productos con trazabilidad por unidad; puede ser null si no corresponde. + /// + public string? Serial { get; set; } + + /// + /// Fecha de vencimiento (GS1 AI 17) en precisión de día. + /// Es null cuando el tipo de trazabilidad no requiere fecha. + /// + public DateOnly? Expiration { get; set; } + + /// + /// Identificador de la ubicación/depósito desde donde se despacha (FK a StockLocation). + /// Puede ser null si no aplica o no se registró en el momento de la expedición. + /// + public int? LocationId { get; set; } + + /// + /// Nombre legible de la ubicación/depósito (resuelto por join). + /// Se usa para impresión y visualización sin pedir más datos al front. + /// + public string? LocationName { get; set; } + } +} diff --git a/Domain/Entities/ELSExpeditionHeader.StatusEnum.cs b/Domain/Entities/ELSExpeditionHeader.StatusEnum.cs new file mode 100644 index 0000000..0b5ce4a --- /dev/null +++ b/Domain/Entities/ELSExpeditionHeader.StatusEnum.cs @@ -0,0 +1,14 @@ +using Domain.Constants; + +namespace Domain.Entities +{ + public partial class ELSExpeditionHeader + { + /// Acceso tipado al estado (helper; persiste en Status int). + public ExpeditionStatus StatusEnum + { + get => (ExpeditionStatus)Status; + set => Status = (int)value; + } + } +} diff --git a/Domain/Entities/ELSExpeditionHeader.cs b/Domain/Entities/ELSExpeditionHeader.cs index 29acb31..ee2cc10 100644 --- a/Domain/Entities/ELSExpeditionHeader.cs +++ b/Domain/Entities/ELSExpeditionHeader.cs @@ -1,6 +1,6 @@ namespace Domain.Entities { - public class ELSExpeditionHeader + public partial class ELSExpeditionHeader { /// /// Identificador interno de la expedición @@ -15,7 +15,7 @@ /// /// Número de expedición (formato EX-00000001) /// - public string Expeditionnumber { get; set; } = null!; + public string Expeditionnumber { get; set; } = string.Empty!; /// /// Ubicación (depósito) desde donde se despacha @@ -78,5 +78,6 @@ public DateTime? Modifiedat { get; set; } public virtual ICollection PhLsmExpeditionDetails { get; set; } = new List(); + } } diff --git a/Models/Interfaces/IExpeditionRepository.cs b/Models/Interfaces/IExpeditionRepository.cs new file mode 100644 index 0000000..a5c807d --- /dev/null +++ b/Models/Interfaces/IExpeditionRepository.cs @@ -0,0 +1,16 @@ +using Domain.Dtos.Stock; +using Domain.Entities; + +namespace Models.Interfaces +{ + // 1.1 Data (Repo) + public interface IExpeditionRepository + { + /// + /// Crea la expedición completa (encabezado + detalles) y la deja emitida con numeración de serie. + /// + Task<(int Id, string Expeditionnumber)> CreateFullExpeditionAsync(ELSExpeditionHeader expedition, int formSeriesId); + Task GetDtoByIdAsync(int id); + } + +} diff --git a/Models/Repositories/Stock/PhLSMExpeditionRepository.cs b/Models/Repositories/Stock/PhLSMExpeditionRepository.cs new file mode 100644 index 0000000..82908be --- /dev/null +++ b/Models/Repositories/Stock/PhLSMExpeditionRepository.cs @@ -0,0 +1,182 @@ +using Domain.Constants; +using Domain.Dtos.Stock; +using Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Models.Helpers; +using Models.Interfaces; +using Models.Models; + +namespace Models.Repositories.Stock +{ + public class PhLSMExpeditionRepository( + PhronCareOperationsHubContext context, + IPhSFormSeriesRepository formSeriesRepository) : IExpeditionRepository + { + private readonly PhronCareOperationsHubContext _context = context; + private readonly IPhSFormSeriesRepository _formSeriesRepository = formSeriesRepository; + + /// + /// Crea la expedición completa (header + details) con numeración de serie y estado emitido. + /// + public async Task<(int Id, string Expeditionnumber)> CreateFullExpeditionAsync( + ELSExpeditionHeader expedition, int formSeriesId) + { + using var tx = await _context.Database.BeginTransactionAsync(); + try + { + // 1) Numeración (EX-00000000) – mismo patrón que Quotes + var next = await _formSeriesRepository.GetNextInternalNumberAsync(formSeriesId); + var series = await _formSeriesRepository.GetByIdAsync(formSeriesId) + ?? throw new InvalidOperationException("Serie no encontrada"); + var number = $"{series.Letter}-{next:D8}"; + var issuedAt = DateTime.Now; + + // 2) Completar datos de emisión en el agregado de dominio + expedition.Expeditionnumber = number; + expedition.Issuedate = issuedAt; + expedition.Status = (int)ExpeditionStatus.Emitida; + + // 3) Mapear grafo completo Domain -> EF (Header + Details) + // Igual que haces en CreateFullQuoteAsync con EntityMapper.MapEntity(...) + var headerEntity = EntityMapper.MapEntity(expedition); + + // 4) Persistir de una (header + colecciones) y confirmar + _context.PhLsmExpeditionHeaders.Add(headerEntity); + await _context.SaveChangesAsync(); + await tx.CommitAsync(); + + return (headerEntity.Id, headerEntity.Expeditionnumber); + } + catch + { + await tx.RollbackAsync(); + throw; + } + } + + /// + /// Devuelve el DTO completo de Expedición (cabecera + ítems) listo para UI/impresión. + /// + public async Task GetDtoByIdAsync(int id) + { + // 1) Header + detalles + var header = await _context.PhLsmExpeditionHeaders + .AsNoTracking() + .Include(h => h.PhLsmExpeditionDetails) + .FirstOrDefaultAsync(h => h.Id == id); + + if (header is null) + return null; + + // 2) Resolver productos (un solo round-trip) + var productIds = header.PhLsmExpeditionDetails + .Select(d => d.ProductId) + .Distinct() + .ToList(); + + var productMap = productIds.Count == 0 + ? new Dictionary() + : await _context.PhLsmProducts + .Where(p => productIds.Contains(p.Id)) + .Select(p => new + { + p.Id, + p.Name, + p.Descripcion, + p.FactoryCode, // código fábrica (preferido en impresión) + p.ExternalCode, // GTIN + p.RegulatoryCode // PM + }) + .ToDictionaryAsync( + p => p.Id, + p => (p.Name, p.Descripcion, p.FactoryCode, p.ExternalCode, p.RegulatoryCode) + ); + + //// 3) Resolver nombres de ubicaciones (si corresponde) + //var locationIds = header.PhLsmExpeditionDetails + // .Select(d => d.LocationId) + // .Where(l => l.HasValue) + // .Select(l => l!.Value) + // .Distinct() + // .ToList(); + + //var locationMap = locationIds.Count == 0 + // ? new Dictionary() + // : await _context.PhLsmStockLocations + // .Where(l => locationIds.Contains(l.Id)) + // .Select(l => new { l.Id, l.Name, l.Address }) // Address opcional + // .ToDictionaryAsync(l => l.Id, l => (l.Name, l.Address)); + + // 4) Proyección a DTO (ítems) + var items = header.PhLsmExpeditionDetails.Select(d => + { + productMap.TryGetValue(d.ProductId, out var p); + var productName = !string.IsNullOrWhiteSpace(p.Name) ? p.Name + : (!string.IsNullOrWhiteSpace(p.Descripcion) ? p.Descripcion : string.Empty); + + //var locationName = (d.LocationId.HasValue && locationMap.TryGetValue(d.LocationId.Value, out var ln)) + // ? ln.Name + // : null; + + return new ExpeditionItemDto + { + Id = d.Id, + ProductId = d.ProductId, + FactoryCode = p.FactoryCode ?? string.Empty, // preferido para mostrar + ProductName = productName, + Quantity = d.Quantity, + Batch = d.Batch, + Serial = d.Serial, + Expiration = d.Expiration, + LocationId = d.LocationId, + LocationName = string.Empty, // locationName, // si lo querés mostrar + }; + }).ToList(); + + // 5) Completar cabecera del DTO + var dto = new ExpeditionDto + { + Id = header.Id, + Expeditionnumber = header.Expeditionnumber, + Issuedate = header.Issuedate, + Status = header.Status, + StatusLabel = MapStatus(header.Status), + ExtrainfoJson = header.ExtrainfoJson, // se arma en el momento de imprimir, como definiste + Observations = header.Observations, + // Opcional: si el header tiene BusinessUnitId / SeriesId, podés resolver aquí sus códigos/nombres. + Items = items + }; + + //// 6) Si todos los detalles comparten la misma ubicación, reflejarla en cabecera (útil para impresión) + //var distinctLocs = items.Select(i => i.LocationId).Where(x => x.HasValue).Distinct().ToList(); + //if (distinctLocs.Count == 1) + //{ + // dto.LocationId = distinctLocs[0]; + // if (dto.LocationId.HasValue && locationMap.TryGetValue(dto.LocationId.Value, out var ln)) + // { + // dto.LocationName = ln.Name; + // // dto.LocationAddress = ln.Address; // si tu DTO lo contempla + // } + //} + + return dto; + } + + // ----- helpers ----- + + /// + /// Mapea el estado entero a etiqueta amigable (enum: Emitida=1, EnTransito=2, EnDestino=3, Retorno=4, Cerrada=5, Anulada=6). + /// + private static string MapStatus(int status) => status switch + { + 1 => "Emitida", + 2 => "En tránsito", + 3 => "En destino", + 4 => "Retorno", + 5 => "Cerrada", + 6 => "Anulada", + _ => $"Desconocido ({status})" + }; + } + +} diff --git a/phronCare.API/Controllers/Documents/DocumentsController.cs b/phronCare.API/Controllers/Documents/DocumentsController.cs deleted file mode 100644 index c0198e7..0000000 --- a/phronCare.API/Controllers/Documents/DocumentsController.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Documents.Interfaces; -using Documents.Models; -using Domain.Dtos; -using Microsoft.AspNetCore.Mvc; -using Models.Interfaces; - -namespace phronCare.API.Controllers.Documents -{ - public class DocumentController : ControllerBase - { - private readonly IDocumentTemplateService _documentTemplateService; - private readonly IQuoteDom _quoteService; - - public DocumentController(IDocumentTemplateService documentTemplateService, IQuoteDom quoteService) - { - _documentTemplateService = documentTemplateService; - _quoteService = quoteService; - } - - - } -} diff --git a/phronCare.API/Controllers/Sales/QuoteController.cs b/phronCare.API/Controllers/Sales/QuoteController.cs index b007daa..ddfda12 100644 --- a/phronCare.API/Controllers/Sales/QuoteController.cs +++ b/phronCare.API/Controllers/Sales/QuoteController.cs @@ -3,9 +3,9 @@ using Documents.Models; using Domain.Dtos; using Domain.Entities; using Domain.Generics; -using Models.Interfaces; using System.Reflection; using Documents.Interfaces; +using Core.Interfaces; namespace phronCare.API.Controllers.Sales { @@ -114,6 +114,7 @@ namespace phronCare.API.Controllers.Sales return StatusCode(500, $"{methodName} Message: {ex.Message}"); } } + #endregion /// /// Genera y devuelve un archivo PDF correspondiente al presupuesto especificado por su ID. @@ -128,12 +129,12 @@ namespace phronCare.API.Controllers.Sales var pdfBytes = await _documentTemplateService.GenerateDocumentAsync(new DocumentGenerationRequest { - Model = quote + Model = quote, + DocumentType = DocumentType.Quote }); return File(pdfBytes, "application/pdf", $"Presupuesto_{quote.Quotenumber}.pdf"); } - #endregion #region Endpoint de emision de presupuesto (encabezado + detalles + roles + ajustes + impuestos) [HttpPost("createfull")] diff --git a/phronCare.API/Controllers/Stock/ExpeditionController.cs b/phronCare.API/Controllers/Stock/ExpeditionController.cs new file mode 100644 index 0000000..b1697ea --- /dev/null +++ b/phronCare.API/Controllers/Stock/ExpeditionController.cs @@ -0,0 +1,80 @@ +using Core.Interfaces.Stock; +using Documents.Interfaces; +using Documents.Models; +using Domain.Entities; +using Microsoft.AspNetCore.Mvc; + +namespace phronCare.API.Controllers.Stock +{ + [Route("api/[controller]")] + [ApiController] + public class ExpeditionController : ControllerBase + { + private readonly IExpeditionDom _expeditionService; // ← este es _expeditionService + private readonly IDocumentTemplateService _documentTemplateService; + + public ExpeditionController( + IDocumentTemplateService documentTemplateService, + IExpeditionDom expeditionService) + { + _documentTemplateService = documentTemplateService + ?? throw new ArgumentNullException(nameof(documentTemplateService)); + _expeditionService = expeditionService + ?? throw new ArgumentNullException(nameof(expeditionService)); + } + + #region Endpoint de emision de expedicion (encabezado + detalles) + [HttpPost("createfull")] + public async Task CreateFullExpedition([FromBody] CreateFullExpeditionRequest request) + { + try + { + if (request == null || request.Expedition == null) + return BadRequest("El payload no puede contener elementos nulos."); + + // Delegamos al service, que ahora arma el grafo y llama al repo atómico + var (id, number) = await _expeditionService.CreateAndIssueAsync( + request.Expedition, + request.Expedition.PhLsmExpeditionDetails, // detalles vienen dentro, igual que en Quotes + request.FormSeriesId); + + // <<< Simetría absoluta con QuoteController: objeto anónimo >>> + return Ok(new { Success = true, Id = id, ExpeditionNumber = number }); + } + catch (InvalidOperationException ex) + { + return BadRequest($"Error de negocio: {ex.Message}"); + } + catch (Exception ex) + { + return StatusCode(500, $"Ocurrió un error interno: {ex.Message}"); + } + } + #endregion + + /// + /// Genera y devuelve un archivo PDF correspondiente al presupuesto especificado por su ID. + /// + [HttpGet("{id}/pdf")] + public async Task GetQuotePdf(int id) + { + var expedition = await _expeditionService.GetDtoByIdAsync(id); + + if (expedition == null) + return NotFound($"Expedicion con ID {id} no encontrado."); + + var pdfBytes = await _documentTemplateService.GenerateDocumentAsync(new DocumentGenerationRequest + { + Model = expedition, + DocumentType = DocumentType.Expedition + }); + + return File(pdfBytes, "application/pdf", $"Expedicion_{expedition.Expeditionnumber}.pdf"); + } + } + public class CreateFullExpeditionRequest + { + public ELSExpeditionHeader Expedition { get; set; } = default!; + public int FormSeriesId { get; set; } + } +} diff --git a/phronCare.API/Program.cs b/phronCare.API/Program.cs index 75f9f81..046831e 100644 --- a/phronCare.API/Program.cs +++ b/phronCare.API/Program.cs @@ -185,15 +185,14 @@ app.Run(); static void RepositorysAndServices(WebApplicationBuilder builder) { // Registro servicio PDF transversal - builder.Services.AddScoped(); // RazorLight + PDF rendering builder.Services.AddScoped(); builder.Services.AddScoped(); - // DocumentTemplateService (si lo usás para orquestar) builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -238,8 +237,10 @@ static void RepositorysAndServices(WebApplicationBuilder builder) builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); //builder.Services.AddScoped(); @@ -268,10 +269,15 @@ static void RepositorysAndServices(WebApplicationBuilder builder) builder.Services.AddScoped(); builder.Services.AddScoped(); - builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + } \ No newline at end of file diff --git a/phronCare.API/obj/Debug/net8.0/ApiEndpoints.json b/phronCare.API/obj/Debug/net8.0/ApiEndpoints.json index 58dba96..ce212f5 100644 --- a/phronCare.API/obj/Debug/net8.0/ApiEndpoints.json +++ b/phronCare.API/obj/Debug/net8.0/ApiEndpoints.json @@ -627,6 +627,38 @@ } ] }, + { + "ContainingType": "phronCare.API.Controllers.Stock.ExpeditionController", + "Method": "GetQuotePdf", + "RelativePath": "api/Expedition/{id}/pdf", + "HttpMethod": "GET", + "IsController": true, + "Order": 0, + "Parameters": [ + { + "Name": "id", + "Type": "System.Int32", + "IsRequired": true + } + ], + "ReturnTypes": [] + }, + { + "ContainingType": "phronCare.API.Controllers.Stock.ExpeditionController", + "Method": "CreateFullExpedition", + "RelativePath": "api/Expedition/createfull", + "HttpMethod": "POST", + "IsController": true, + "Order": 0, + "Parameters": [ + { + "Name": "request", + "Type": "phronCare.API.Controllers.Stock.CreateFullExpeditionRequest", + "IsRequired": true + } + ], + "ReturnTypes": [] + }, { "ContainingType": "phronCare.API.Controllers.Sales.InstitutionController", "Method": "GetById", diff --git a/phronCare.API/phronCare.API.csproj b/phronCare.API/phronCare.API.csproj index f1096a3..7cb5363 100644 --- a/phronCare.API/phronCare.API.csproj +++ b/phronCare.API/phronCare.API.csproj @@ -68,5 +68,8 @@ PreserveNewest + + + diff --git a/phronCare.UIBlazor/Pages/Stock/Expeditions/ExpeditionCreate.razor b/phronCare.UIBlazor/Pages/Stock/Expeditions/ExpeditionCreate.razor index 61125ad..a731e45 100644 --- a/phronCare.UIBlazor/Pages/Stock/Expeditions/ExpeditionCreate.razor +++ b/phronCare.UIBlazor/Pages/Stock/Expeditions/ExpeditionCreate.razor @@ -3,6 +3,7 @@ @using Domain.Dtos.Stock @using Services.Lookups @using Services.Stock.Expeditions +@using System.Text.Json @using phronCare.UIBlazor.Pages.Stock.Shared @inject NavigationManager Navigation @@ -40,7 +41,11 @@ -
+
+ + +
+
@@ -145,12 +150,28 @@
- +
+ @code { - private ELSExpeditionHeader Model = new(); + 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; @@ -160,6 +181,9 @@ 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) { @@ -186,27 +210,58 @@ 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 void AddProduct() + + private string? ValidateBeforeSave() { - // TODO: abrir modal de producto individual + 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 void AddSet() + private async Task SaveAsync() { - // TODO: abrir modal de set - } + var error = ValidateBeforeSave(); + if (!string.IsNullOrEmpty(error)) { toastService.ShowError(error); return; } - private void ScanProduct() - { - // TODO: activar input de escáner + 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) diff --git a/phronCare.UIBlazor/Services/Stock/Expeditions/ExpeditionService.cs b/phronCare.UIBlazor/Services/Stock/Expeditions/ExpeditionService.cs index 295956b..e4711fa 100644 --- a/phronCare.UIBlazor/Services/Stock/Expeditions/ExpeditionService.cs +++ b/phronCare.UIBlazor/Services/Stock/Expeditions/ExpeditionService.cs @@ -1,4 +1,5 @@ using Domain.Dtos; +using Domain.Entities; using Microsoft.JSInterop; using System.Net.Http.Json; @@ -13,6 +14,7 @@ namespace phronCare.UIBlazor.Services.Stock.Expeditions _js = js; _http = http; } + /// /// Obtiene un presupuesto por QuoteNumber. /// @@ -29,5 +31,133 @@ namespace phronCare.UIBlazor.Services.Stock.Expeditions return null; } } + + /// + /// Envía el header + details de la expedición junto con el formSeriesId + /// y recibe el número de expedición generado o un mensaje de error. + /// + public async Task CreateAndIssueAsync( + ELSExpeditionHeader header, + IEnumerable details, + int formSeriesId) + { + // 1) Poner los ítems dentro del header + header.PhLsmExpeditionDetails = details?.ToList() ?? new List(); + + var request = new CreateFullExpeditionRequest + { + Expedition = header, + FormSeriesId = formSeriesId + }; + + var response = await _http.PostAsJsonAsync("/api/expedition/createfull", request); + + if (!response.IsSuccessStatusCode) + { + var serverMessage = await response.Content.ReadAsStringAsync(); + return new CreateExpeditionResult + { + Success = false, + ErrorMessage = serverMessage + }; + } + + var result = await response.Content.ReadFromJsonAsync(); + return result ?? new CreateExpeditionResult { Success = false, ErrorMessage = "Respuesta vacía del servidor." }; + } + + /// + /// Obtiene la expedición completa por ID para visualización (drawer/pantalla de detalle). + /// + public async Task GetDtoByIdAsync(int id) + { + try + { + var dto = await _http.GetFromJsonAsync($"/expedition/{id}"); + return dto; + } + catch (Exception ex) + { + Console.WriteLine($"Error al obtener ExpeditionDto por ID: {ex.Message}"); + return null; + } + } + + /// + /// Descarga el PDF de la expedición en el navegador usando saveAsFile (base64). + /// + public async Task ExportPdfAsync(int expeditionId, string expeditionNumber) + { + try + { + var response = await _http.GetAsync($"/api/expedition/{expeditionId}/pdf"); + + if (!response.IsSuccessStatusCode) + { + var error = await response.Content.ReadAsStringAsync(); + throw new Exception($"Error al generar PDF: {error}"); + } + + var bytes = await response.Content.ReadAsByteArrayAsync(); + var base64 = Convert.ToBase64String(bytes); + var fileName = $"{expeditionNumber}.pdf"; + + await _js.InvokeVoidAsync("saveAsFile", fileName, base64); + } + catch (Exception ex) + { + var message = ex.Message ?? "No message"; + throw new Exception($"ExportPdfAsync: {message}", ex); + } + } } -} + + /// + /// Contrato de request simétrico a CreateFullQuoteRequest. + /// + public class CreateFullExpeditionRequest + { + public ELSExpeditionHeader Expedition { get; set; } = default!; + public int FormSeriesId { get; set; } + } + + /// + /// Resultado del create/issue simétrico a CreateQuoteResult. + /// + public class CreateExpeditionResult + { + public bool Success { get; set; } + public int Id { get; set; } + public string ExpeditionNumber { get; set; } = string.Empty; + public string ErrorMessage { get; set; } = string.Empty; + } + + // TODO: Ajustar namespace real si es distinto + public class ExpeditionDto + { + // Estructura mínima para compilar si aún no referenciás el DTO real. + // Reemplazar por el DTO definitivo de Domain.Dtos. + public int Id { get; set; } + public string ExpeditionNumber { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public DateTime IssueDate { get; set; } + public string? CustomerName { get; set; } + public string? ProfessionalName { get; set; } + public string? InstitutionName { get; set; } + public string? PatientName { get; set; } + public List Items { get; set; } = new(); + public string? Observations { get; set; } + } + + public class ExpeditionItemDto + { + public int ProductId { get; set; } + public string ProductName { get; set; } = string.Empty; + public decimal Quantity { get; set; } + public string? Batch { get; set; } + public string? Serial { get; set; } + public DateOnly? Expiration { get; set; } + public int? LocationId { get; set; } + } + +} \ No newline at end of file