Update Expeditions Print
All checks were successful
CI/CD Pipeline / Build and Deploy with Docker Compose (push) Successful in 10m12s

This commit is contained in:
Leandro Hernan Rojas 2025-09-04 18:15:15 -03:00
parent d3c1e20635
commit 6e61b7b598
25 changed files with 1226 additions and 66 deletions

View File

@ -2,7 +2,7 @@
using Domain.Entities;
using Domain.Generics;
namespace Models.Interfaces
namespace Core.Interfaces
{
public interface IQuoteDom
{

View File

@ -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<ELSExpeditionDetail> details, int formSeriesId);
Task<ExpeditionDto?> GetDtoByIdAsync(int id);
}
}

View File

@ -3,6 +3,7 @@ using Domain.Constants;
using Domain.Entities;
using Domain.Generics;
using Models.Interfaces;
using Core.Interfaces;
namespace Core.Services
{

View File

@ -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<ELSExpeditionDetail> 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<ExpeditionDto?> GetDtoByIdAsync(int id)
{
return await _repo.GetDtoByIdAsync(id);
}
}
}

View File

@ -15,6 +15,11 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<Compile Include="Services\RazorLightTemplateRenderer.csx" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.1" />
<PackageReference Include="RazorLight" Version="2.3.1" />

View File

@ -3,6 +3,7 @@
public enum DocumentType
{
Quote,
Expedition,
Invoice,
Order,
Remito,

View File

@ -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<string, string> _imageCacheBase64 = new();
public DocumentTemplateService(ITemplateRenderer templateRenderer, IPdfGeneratorService pdfGeneratorService)
{
_templateRenderer = templateRenderer;
@ -16,28 +21,76 @@ public class DocumentTemplateService : IDocumentTemplateService
public async Task<byte[]> 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;
}
}

View File

@ -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<SurgerySnapshot>(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;
}
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8" />
<title>Nota de Entrega @Model.Expeditionnumber</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
@@page { size: A4; margin: 10mm 9mm 10mm 9mm; }
html, body { font-family: Arial, Helvetica, sans-serif; font-size: 12px; color: #000; }
.sheet { width: 100%; }
.header-legal { display: grid; grid-template-columns: 1.6fr 1fr; gap: 8px; align-items: start; }
.company { line-height: 1.15; }
.doc-title { text-align: right; }
.doc-title h1 { font-size: 20px; margin: 0 0 2px 0; letter-spacing: .5px; }
.doc-title .num { font-weight: bold; }
.doc-title .date { margin-top: 2px; }
.status-line {
display: flex;
justify-content: space-between; /* izquierda / derecha en la misma línea */
align-items: baseline;
margin: 6px 0 8px;
font-weight: 600;
}
.status-line .left {
font-weight: 600;
}
.status-line .right {
font-weight: 400;
text-align: right;
}
.hr { border-bottom: 1px solid #000; margin: 6px 0; }
.snapshot { page-break-inside: avoid; margin-top: 6px; }
.snapshot table { width: 100%; border-collapse: collapse; table-layout: fixed; }
.snapshot td { padding: 2px 4px; vertical-align: top; }
.snapshot .lbl { width: 16%; font-weight: 600; }
.snapshot .val { width: 34%; word-break: break-word; }
.items { margin-top: 8px; }
.items table { width: 100%; border-collapse: collapse; table-layout: fixed; }
.items thead th { border-bottom: 1px solid #000; padding: 4px 4px; text-align: center; }
.items tbody td { padding: 3px 4px; vertical-align: top; }
.col-reg { width: 4%; text-align: center; }
.col-code { width: 18%; text-align: center; }
.col-desc { width: 36%; text-align: left; }
.col-batch { width: 12%; text-align: center; }
.col-exp { width: 10%; text-align: center; }
.col-serial { width: 12%; word-break: break-word; text-align: center; }
.col-qty { width: 8%; text-align: right; text-align: center; }
.totals { margin-top: 6px; display: flex; justify-content: space-between; }
.observ { margin-top: 8px; min-height: 36px; border: 1px dashed #999; padding: 6px; }
.footer { margin-top: 10px; font-size: 11px; text-align: center; border-top: 1px solid #000; padding-top: 6px; }
.muted { color: #111; }
/* ===== MODO COMPACT ===== */
.compact {
font-size: 11px;
}
/* texto general un toque más chico */
.compact .status-line {
margin: 4px 0 6px;
}
.compact .doc-title h1 {
font-size: 18px;
margin: 0 0 0;
}
.compact .snapshot td {
padding: 1px 3px;
}
/* bloque clínico más apretado */
.compact .items table {
font-size: 10px;
}
/* texto de filas más chico */
.compact .items thead th {
padding: 2px 3px;
line-height: 1.05;
background: #eaeaea;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.compact .items tbody td {
padding: 2px 3px; /* ↓ padding vertical */
line-height: 1.05; /* ↓ altura de línea */
}
/* Bordes más finos para no sumar alto visual (algunos engines aceptan 0.6px) */
.compact .items thead th,
.compact .items tbody td {
border: 0.6px solid #000;
}
/* Tabla con bordes tipo grilla */
.items table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.items thead th, .items tbody td {
border: 1px solid #000;
}
/* Repetir encabezado y pie en cada página */
.items thead {
display: table-header-group;
}
.items tfoot {
display: table-footer-group;
}
/* Fila “marco” en el pie (línea inferior por página) */
.items tfoot .page-frame-row td {
border: 1px solid #000; /* dibuja línea inferior y laterales */
height: 0;
padding: 0;
line-height: 0; /* que no coma espacio visual */
}
/* Encabezados con fondo gris */
.items thead th {
background: #eaeaea; /* gris suave */
color: #000;
font-weight: 700;
}
.avoid-break { page-break-inside: avoid; }
</style>
</head>
<body>
<div class="sheet compact">
<!-- Encabezado / identidad -->
<div class="header-legal">
<div class="company">
@if (!string.IsNullOrWhiteSpace(Model.LogoBase64))
{
<img src="data:image/png;base64,@Model.LogoBase64" alt="Logo" style="height:48px; margin-bottom:0;" />
}
<div style="font-size: 10px; margin-top: 0;">
Segurola 1885<br />
C.P. (C1407AOK) - Capital Federal<br/>
Email: ventas@biodec.net<br />
Urgencias: 15-2155-9380 * 15-5909-4987 * 15-5909-4892
</div><br />
<div class="muted">Mercadería en préstamo</div><br />
</div>
<div class="doc-title">
<h1>Nota de Expedición </h1>
<div class="num">EXPEDICIÓN: @Model.Expeditionnumber</div>
<div class="date">Fecha: @Model.Issuedate.ToString("dd/MM/yyyy")</div>
</div>
</div>
<!-- Estado / auditoría -->
<div class="status-line">
<div class="left">
Estado:
@((string.IsNullOrWhiteSpace(Model.StatusLabel) ? "DESCONOCIDO" : Model.StatusLabel) + reprintText)
</div>
<div class="right">Origen: @(Model.LocationName ?? "-")</div>
</div>
<div class="hr"></div>
<!-- Institución / Profesional / Paciente / Fecha de cirugía (desde ExtrainfoJson) -->
<section class="snapshot">
<table>
<tr>
<td class="lbl">Institución:</td>
<td class="val">@snap.Institution</td>
<td class="lbl">Profesional:</td>
<td class="val">@snap.Professional</td>
</tr>
<tr>
<td class="lbl">Paciente:</td>
<td class="val">@snap.Patient</td>
<td class="lbl">Fecha CX:</td>
<td class="val">@FDate(snap.SurgeryDate)</td>
</tr>
</table>
</section>
<div class="hr"></div>
<!-- Detalle de ítems -->
<section class="items">
<table>
<thead>
<tr>
<th class="col-reg">#</th>
<th class="col-code">Código</th>
<th class="col-desc">Descripción</th>
<th class="col-batch">Lote</th>
<th class="col-exp">Venc.</th>
<th class="col-serial">Serie</th>
<th class="col-qty">Cant.</th>
</tr>
</thead>
<tfoot>
<tr class="page-frame-row">
<td colspan="7"></td>
</tr>
</tfoot>
<tbody>
@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)
{
<tr>
<td class="col-reg">@r</td>
<td class="col-code">@it.FactoryCode</td>
<td class="col-desc">@it.ProductName</td>
<td class="col-batch">@it.Batch</td>
<td class="col-exp">@FDate(it.Expiration)</td>
<td class="col-serial">@it.Serial</td>
<td class="col-qty">@FQty(it.Quantity)</td>
</tr>
}
}
}
else
{
<tr><td colspan="7" style="text-align:center; padding:6px;">Sin ítems</td></tr>
}
</tbody>
</table>
</section>
<!-- Totales y observaciones -->
<div class="totals">
<div><strong>Renglones:</strong> @Model.TotalItems</div>
<div><strong>Unidades:</strong> @FQty(Model.TotalQuantity)</div>
</div>
<div class="observ">
<strong>Observaciones:</strong> @(Model.Observations ?? string.Empty)
</div>
<!-- Pie -->
<div class="footer">
La mercadería viaja por cuenta y riesgo del comprador.
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,13 @@
namespace Domain.Constants
{
/// <summary>Estado de la expedición (identificadores comienzan en 1).</summary>
public enum ExpeditionStatus : byte
{
Emitida = 1,
EnTransito = 2,
EnDestino = 3,
Retorno = 4,
Cerrada = 5,
Anulada = 6
}
}

View File

@ -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()
};
}
}

View File

@ -0,0 +1,138 @@
namespace Domain.Dtos.Stock
{
/// <summary>
/// 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.
/// </summary>
public class ExpeditionDto
{
// ===== Identificación / numeración =====
/// <summary>
/// Identificador interno de la expedición.
/// </summary>
public int Id { get; set; }
/// <summary>
/// Número de expedición (ej.: EX-00000001).
/// Lo genera el servidor al emitir según la serie.
/// </summary>
public string Expeditionnumber { get; set; } = string.Empty;
// ===== Fechas / estado =====
/// <summary>
/// Fecha de emisión de la expedición.
/// </summary>
public DateTime Issuedate { get; set; }
/// <summary>
/// Estado numérico de la expedición (según enum del dominio).
/// </summary>
public int Status { get; set; }
/// <summary>
/// Etiqueta amigable del estado (ej.: Emitida, En tránsito, En destino, Retorno, Cerrada, Anulada).
/// </summary>
public string StatusLabel { get; set; } = string.Empty;
// ===== Origen (depósito) =====
/// <summary>
/// Id de la ubicación/depósito desde donde se despacha.
/// </summary>
public int LocationId { get; set; }
/// <summary>
/// Nombre de la ubicación/depósito (resuelto por join).
/// </summary>
public string? LocationName { get; set; }
/// <summary>
/// (Opcional) Dirección del depósito/origen, útil para impresión.
/// </summary>
public string? LocationAddress { get; set; }
// ===== Destino / referencias externas =====
/// <summary>
/// Nombre visible del destinatario (si se define para la impresión).
/// </summary>
public string? RecipientName { get; set; }
/// <summary>
/// Número o referencia externa asociada (si aplica).
/// </summary>
public string? ReferenceNumber { get; set; }
/// <summary>
/// Tipo de origen externo (ej.: surgery, demo, préstamo).
/// </summary>
public string? OriginType { get; set; }
/// <summary>
/// Referencia externa a otro módulo o sistema (ticket/orden).
/// </summary>
public string? ExternalReference { get; set; }
/// <summary>
/// Ticket quirúrgico asociado (si corresponde).
/// </summary>
public Guid? TicketId { get; set; }
// ===== “Foto” (snapshot) de la cirugía/paciente (Extrainfo) =====
/// <summary>
/// Información adicional en JSON tal como se almacenó (para trazabilidad).
/// </summary>
public string? ExtrainfoJson { get; set; }
// ===== Observaciones / instrucciones =====
/// <summary>
/// Observaciones generales de la expedición (libre).
/// </summary>
public string? Observations { get; set; }
// ===== Auditoría de impresión =====
/// <summary>
/// Cantidad de veces que se imprimió la nota (para mostrar "Reimpresión N").
/// </summary>
public int Printcount { get; set; }
/// <summary>
/// Fecha de creación del registro (trazabilidad).
/// </summary>
public DateTime Createdat { get; set; }
/// <summary>
/// Fecha de última modificación del registro (trazabilidad).
/// </summary>
public DateTime? Modifiedat { get; set; }
// ===== Ítems =====
/// <summary>
/// Detalle de los ítems/productos de la expedición.
/// </summary>
public List<ExpeditionItemDto> Items { get; set; } = new();
// ===== Totales de conveniencia para la impresión =====
/// <summary>
/// Total de renglones en la expedición (Items.Count).
/// </summary>
public int TotalItems => Items?.Count ?? 0;
/// <summary>
/// Suma de cantidades (para pie de impresión).
/// </summary>
public decimal TotalQuantity => Items?.Sum(i => i.Quantity) ?? 0m;
/// <summary>
/// Logo de la compañia.
/// </summary>
public string LogoBase64 { get; set; } = string.Empty;
}
}

View File

@ -0,0 +1,71 @@
namespace Domain.Dtos.Stock
{
/// <summary>
/// Í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.
/// </summary>
public sealed class ExpeditionItemDto
{
/// <summary>
/// Identificador interno del renglón (detalle) de la expedición.
/// Útil para acciones sobre el ítem (eliminar, editar cantidad, etc.).
/// </summary>
public int Id { get; set; }
/// <summary>
/// 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.
/// </summary>
public int ProductId { get; set; }
/// <summary>
/// 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.
/// </summary>
public string FactoryCode { get; set; } = string.Empty;
/// <summary>
/// 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.
/// </summary>
public string ProductName { get; set; } = string.Empty;
/// <summary>
/// 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.
/// </summary>
public decimal Quantity { get; set; }
/// <summary>
/// Lote del producto (GS1 AI 10).
/// Aplica a productos con trazabilidad por lote; puede ser null si no corresponde.
/// </summary>
public string? Batch { get; set; }
/// <summary>
/// Serial/Número de serie del producto (GS1 AI 21).
/// Aplica a productos con trazabilidad por unidad; puede ser null si no corresponde.
/// </summary>
public string? Serial { get; set; }
/// <summary>
/// Fecha de vencimiento (GS1 AI 17) en precisión de día.
/// Es null cuando el tipo de trazabilidad no requiere fecha.
/// </summary>
public DateOnly? Expiration { get; set; }
/// <summary>
/// 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.
/// </summary>
public int? LocationId { get; set; }
/// <summary>
/// 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.
/// </summary>
public string? LocationName { get; set; }
}
}

View File

@ -0,0 +1,14 @@
using Domain.Constants;
namespace Domain.Entities
{
public partial class ELSExpeditionHeader
{
/// <summary>Acceso tipado al estado (helper; persiste en Status int).</summary>
public ExpeditionStatus StatusEnum
{
get => (ExpeditionStatus)Status;
set => Status = (int)value;
}
}
}

View File

@ -1,6 +1,6 @@
namespace Domain.Entities
{
public class ELSExpeditionHeader
public partial class ELSExpeditionHeader
{
/// <summary>
/// Identificador interno de la expedición
@ -15,7 +15,7 @@
/// <summary>
/// Número de expedición (formato EX-00000001)
/// </summary>
public string Expeditionnumber { get; set; } = null!;
public string Expeditionnumber { get; set; } = string.Empty!;
/// <summary>
/// Ubicación (depósito) desde donde se despacha
@ -78,5 +78,6 @@
public DateTime? Modifiedat { get; set; }
public virtual ICollection<ELSExpeditionDetail> PhLsmExpeditionDetails { get; set; } = new List<ELSExpeditionDetail>();
}
}

View File

@ -0,0 +1,16 @@
using Domain.Dtos.Stock;
using Domain.Entities;
namespace Models.Interfaces
{
// 1.1 Data (Repo)
public interface IExpeditionRepository
{
/// <summary>
/// Crea la expedición completa (encabezado + detalles) y la deja emitida con numeración de serie.
/// </summary>
Task<(int Id, string Expeditionnumber)> CreateFullExpeditionAsync(ELSExpeditionHeader expedition, int formSeriesId);
Task<ExpeditionDto?> GetDtoByIdAsync(int id);
}
}

View File

@ -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;
/// <summary>
/// Crea la expedición completa (header + details) con numeración de serie y estado emitido.
/// </summary>
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<ELSExpeditionHeader, PhLsmExpeditionHeader>(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;
}
}
/// <summary>
/// Devuelve el DTO completo de Expedición (cabecera + ítems) listo para UI/impresión.
/// </summary>
public async Task<ExpeditionDto?> 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<int, (string? Name, string? Descripcion, string? FactoryCode, string? ExternalCode, string? RegulatoryCode)>()
: 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<int, (string Name, string? Address)>()
// : 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 -----
/// <summary>
/// Mapea el estado entero a etiqueta amigable (enum: Emitida=1, EnTransito=2, EnDestino=3, Retorno=4, Cerrada=5, Anulada=6).
/// </summary>
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})"
};
}
}

View File

@ -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;
}
}
}

View File

@ -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
/// <summary>
/// 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")]

View File

@ -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<IActionResult> 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
/// <summary>
/// Genera y devuelve un archivo PDF correspondiente al presupuesto especificado por su ID.
/// </summary>
[HttpGet("{id}/pdf")]
public async Task<IActionResult> 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; }
}
}

View File

@ -185,15 +185,14 @@ app.Run();
static void RepositorysAndServices(WebApplicationBuilder builder)
{
// Registro servicio PDF transversal
builder.Services.AddScoped<HtmlRenderer>();
// RazorLight + PDF rendering
builder.Services.AddScoped<ITemplateRenderer, RazorTemplateEngine>();
builder.Services.AddScoped<IPdfGeneratorService, PuppeteerPdfGeneratorService>();
// DocumentTemplateService (si lo usás para orquestar)
builder.Services.AddScoped<IDocumentTemplateService, DocumentTemplateService>();
builder.Services.AddScoped<ITicketDom, TicketService>();
builder.Services.AddScoped<ITicketRepository, TicketRepository>();
@ -238,8 +237,10 @@ static void RepositorysAndServices(WebApplicationBuilder builder)
builder.Services.AddScoped<IAdjustmentReasonDom, AdjustmentReasonService>();
builder.Services.AddScoped<IPhSAdjustmentReasonRepository, PhSAdjustmentReasonRepository>();
builder.Services.AddScoped<ITaxTypeDom, TaxTypeService>();
builder.Services.AddScoped<IPhOhArcataxTypeRepository, PhOhArcataxTypeRepository>();
builder.Services.AddScoped<IQuoteDom, QuoteService>();
//builder.Services.AddScoped<IPhSQuoteHeaderRepository, PhSQuoteHeaderRepository>();
@ -268,10 +269,15 @@ static void RepositorysAndServices(WebApplicationBuilder builder)
builder.Services.AddScoped<ILSUnitOfMeasureDom, LSUnitOfMeasureService>();
builder.Services.AddScoped<IPhLSMUnitOfMeasureRepository, PhLSMUnitOfMeasureRepository>();
builder.Services.AddScoped<IPhLSMLookUpRepository, PhLSMLookUpRepository>();
builder.Services.AddScoped<ILSMLookUpDom, LSMLookUpService>();
builder.Services.AddScoped<IPhLSMLookUpRepository, PhLSMLookUpRepository>();
builder.Services.AddScoped<IPhLSMUnitOfMeasureRepository, PhLSMUnitOfMeasureRepository>();
builder.Services.AddScoped<ILSStockScanDom, LSStockScanService>();
builder.Services.AddScoped<IPhLSMStockItemRepository, PhLSMStockItemRepository>();
builder.Services.AddScoped<IExpeditionDom, ExpeditionService>();
builder.Services.AddScoped<IExpeditionRepository, PhLSMExpeditionRepository>();
}

View File

@ -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",

View File

@ -68,5 +68,8 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<Folder Include="Controllers\Documents\" />
</ItemGroup>
</Project>

View File

@ -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 @@
</SelectedTemplate>
</BlazoredTypeahead>
</div>
<div class="col-md-6">
<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>
@ -145,12 +150,28 @@
</div>
<div class="mt-3 d-flex justify-content-end">
<button type="submit" class="btn btn-primary">Guardar Expedición</button>
<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();
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<IEnumerable<ELookUpItem>> SearchQuotes(string filter)
{
@ -186,7 +210,9 @@
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;
@ -194,19 +220,48 @@
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)

View File

@ -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;
}
/// <summary>
/// Obtiene un presupuesto por QuoteNumber.
/// </summary>
@ -29,5 +31,133 @@ namespace phronCare.UIBlazor.Services.Stock.Expeditions
return null;
}
}
/// <summary>
/// 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.
/// </summary>
public async Task<CreateExpeditionResult> CreateAndIssueAsync(
ELSExpeditionHeader header,
IEnumerable<ELSExpeditionDetail> details,
int formSeriesId)
{
// 1) Poner los ítems dentro del header
header.PhLsmExpeditionDetails = details?.ToList() ?? new List<ELSExpeditionDetail>();
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<CreateExpeditionResult>();
return result ?? new CreateExpeditionResult { Success = false, ErrorMessage = "Respuesta vacía del servidor." };
}
/// <summary>
/// Obtiene la expedición completa por ID para visualización (drawer/pantalla de detalle).
/// </summary>
public async Task<ExpeditionDto?> GetDtoByIdAsync(int id)
{
try
{
var dto = await _http.GetFromJsonAsync<ExpeditionDto>($"/expedition/{id}");
return dto;
}
catch (Exception ex)
{
Console.WriteLine($"Error al obtener ExpeditionDto por ID: {ex.Message}");
return null;
}
}
/// <summary>
/// Descarga el PDF de la expedición en el navegador usando saveAsFile (base64).
/// </summary>
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);
}
}
}
/// <summary>
/// Contrato de request simétrico a CreateFullQuoteRequest.
/// </summary>
public class CreateFullExpeditionRequest
{
public ELSExpeditionHeader Expedition { get; set; } = default!;
public int FormSeriesId { get; set; }
}
/// <summary>
/// Resultado del create/issue simétrico a CreateQuoteResult.
/// </summary>
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<ExpeditionItemDto> 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; }
}
}