Update Expeditions Print
All checks were successful
CI/CD Pipeline / Build and Deploy with Docker Compose (push) Successful in 10m12s
All checks were successful
CI/CD Pipeline / Build and Deploy with Docker Compose (push) Successful in 10m12s
This commit is contained in:
parent
d3c1e20635
commit
6e61b7b598
@ -2,7 +2,7 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Generics;
|
||||
|
||||
namespace Models.Interfaces
|
||||
namespace Core.Interfaces
|
||||
{
|
||||
public interface IQuoteDom
|
||||
{
|
||||
|
||||
12
Core/Interfaces/Stock/IExpeditionDom.cs
Normal file
12
Core/Interfaces/Stock/IExpeditionDom.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@ using Domain.Constants;
|
||||
using Domain.Entities;
|
||||
using Domain.Generics;
|
||||
using Models.Interfaces;
|
||||
using Core.Interfaces;
|
||||
|
||||
namespace Core.Services
|
||||
{
|
||||
|
||||
37
Core/Services/Stock/ExpeditionService.cs
Normal file
37
Core/Services/Stock/ExpeditionService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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" />
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
public enum DocumentType
|
||||
{
|
||||
Quote,
|
||||
Expedition,
|
||||
Invoice,
|
||||
Order,
|
||||
Remito,
|
||||
|
||||
@ -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);
|
||||
|
||||
string html = await _templateRenderer.RenderAsync("Quotes/Template_v1.cshtml", request.Model);
|
||||
// 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);
|
||||
}
|
||||
|
||||
private static string GetImageBase64(string imagePath)
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!File.Exists(imagePath))
|
||||
return "";
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
byte[] imageBytes = File.ReadAllBytes(imagePath);
|
||||
return Convert.ToBase64String(imageBytes);
|
||||
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 "";
|
||||
}
|
||||
|
||||
var base64 = Convert.ToBase64String(File.ReadAllBytes(imagePath));
|
||||
_imageCacheBase64[imagePath] = base64;
|
||||
return base64;
|
||||
}
|
||||
}
|
||||
|
||||
315
Documents/Templates/Expeditions/Template_v1.cshtml
Normal file
315
Documents/Templates/Expeditions/Template_v1.cshtml
Normal 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>
|
||||
13
Domain/Constants/ExpeditionStatus.cs
Normal file
13
Domain/Constants/ExpeditionStatus.cs
Normal 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
|
||||
}
|
||||
}
|
||||
16
Domain/Constants/ExpeditionStatusExtensions.cs
Normal file
16
Domain/Constants/ExpeditionStatusExtensions.cs
Normal 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()
|
||||
};
|
||||
}
|
||||
}
|
||||
138
Domain/Dtos/Stock/ExpeditionDto.cs
Normal file
138
Domain/Dtos/Stock/ExpeditionDto.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
71
Domain/Dtos/Stock/ExpeditionItemDto.cs
Normal file
71
Domain/Dtos/Stock/ExpeditionItemDto.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
14
Domain/Entities/ELSExpeditionHeader.StatusEnum.cs
Normal file
14
Domain/Entities/ELSExpeditionHeader.StatusEnum.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>();
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
16
Models/Interfaces/IExpeditionRepository.cs
Normal file
16
Models/Interfaces/IExpeditionRepository.cs
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
182
Models/Repositories/Stock/PhLSMExpeditionRepository.cs
Normal file
182
Models/Repositories/Stock/PhLSMExpeditionRepository.cs
Normal 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})"
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@ -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")]
|
||||
|
||||
80
phronCare.API/Controllers/Stock/ExpeditionController.cs
Normal file
80
phronCare.API/Controllers/Stock/ExpeditionController.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
@ -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>();
|
||||
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -68,5 +68,8 @@
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="Controllers\Documents\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@ -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 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;
|
||||
}
|
||||
|
||||
private void AddSet()
|
||||
{
|
||||
// TODO: abrir modal de set
|
||||
toastService.ShowSuccess($"Expedición emitida: {result.ExpeditionNumber}");
|
||||
await expeditionService.ExportPdfAsync(result.Id, result.ExpeditionNumber);
|
||||
Navigation.NavigateTo("/");
|
||||
}
|
||||
|
||||
private void ScanProduct()
|
||||
catch (Exception ex)
|
||||
{
|
||||
// TODO: activar input de escáner
|
||||
toastService.ShowError($"Error: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveItem(ELSExpeditionDetail item)
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user