feat(expeditions): permitir transición Emitida → EnTransito desde la consulta #8

Merged
leandro merged 1 commits from feature/leandro/7-expedition-mark-in-transit into master 2026-03-15 00:30:34 +00:00
11 changed files with 136 additions and 150 deletions

View File

@ -24,5 +24,6 @@ namespace Core.Interfaces.Stock
IEnumerable<ELSExpeditionDetail> details,
int formSeriesId);
Task<byte[]> ExportFilteredToExcelAsync(ExpeditionSearchParams searchParams);
Task MarkInTransitAsync(int expeditionId);
}
}

View File

@ -220,5 +220,12 @@ namespace Core.Services.Stock
throw new Exception($"{ex.Message}", ex);
}
}
public async Task MarkInTransitAsync(int expeditionId)
{
if (expeditionId <= 0)
throw new ArgumentException("El identificador de la expedición no es válido.");
await _repo.MarkInTransitAsync(expeditionId);
}
}
}

View File

@ -23,6 +23,8 @@ namespace Models.Interfaces
Task<(int Id, string Expeditionnumber)> CreateFullExpeditionAsync(ELSExpeditionHeader expedition, int formSeriesId);
Task<ExpeditionDto?> GetDtoByIdAsync(int id);
Task<PagedResult<ExpeditionDto>> SearchAsync(string? expeditionNumber, string? status, DateTime? issueDateFrom, DateTime? issueDateTo, int? locationId, int page, int pageSize);
Task MarkInTransitAsync(int expeditionId);
}
}

View File

@ -404,7 +404,22 @@ namespace Models.Repositories.Stock
sb.Append(ch);
return sb.ToString().Normalize(NormalizationForm.FormC).Replace(" ", "");
}
public async Task MarkInTransitAsync(int expeditionId)
{
var header = await _context.PhLsmExpeditionHeaders
.FirstOrDefaultAsync(x => x.Id == expeditionId);
if (header == null)
throw new KeyNotFoundException($"No se encontró la expedición con ID {expeditionId}.");
if (header.Status != (int)ExpeditionStatus.Emitida)
throw new InvalidOperationException("Solo las expediciones en estado 'Emitida' pueden pasar a 'En tránsito'.");
header.Status = (int)ExpeditionStatus.EnTransito;
header.Modifiedat = DateTime.Now;
await _context.SaveChangesAsync();
}
}
}

View File

@ -8,7 +8,7 @@
<ItemGroup>
<PackageReference Include="EPPlus" Version="7.5.2" />
<PackageReference Include="MimeKit" Version="4.8.0" />
<PackageReference Include="MimeKit" Version="4.15.1" />
<PackageReference Include="NETCore.MailKit" Version="2.1.0" />
</ItemGroup>
</Project>

View File

@ -144,6 +144,32 @@ namespace phronCare.API.Controllers.Stock
return BadRequest(ex.Message);
}
}
[HttpPost("{id:int}/mark-in-transit")]
public async Task<IActionResult> MarkInTransit(int id)
{
try
{
await _expeditionService.MarkInTransitAsync(id);
return Ok(new { Success = true, Message = "La expedición pasó a estado 'En tránsito' correctamente." });
}
catch (KeyNotFoundException ex)
{
return NotFound(ex.Message);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
return StatusCode(500, $"Ocurrió un error interno: {ex.Message}");
}
}
}
public class CreateFullExpeditionRequest
{

View File

@ -37,6 +37,7 @@
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.15" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.6" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.6" />
<PackageReference Include="Microsoft.Bcl.Memory" Version="9.0.14" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.6">
<PrivateAssets>all</PrivateAssets>

View File

@ -1,142 +0,0 @@
using Transversal.Services;
using Transversal.Models;
namespace phronCare.Test
{
[TestFixture]
public class PdfGeneratorServiceTests
{
private PuppeteerPdfGeneratorService _pdfService;
[OneTimeSetUp]
public void Setup()
{
// Instancia real del servicio (recomendado para test manual/local)
_pdfService = new PuppeteerPdfGeneratorService();
}
[OneTimeTearDown]
public async Task TearDown()
{
// Liberar Chromium al finalizar los tests
if (_pdfService != null)
await _pdfService.DisposeAsync();
}
[Test]
public async Task GeneratePdfFromHtml_ShouldCreatePdfFile()
{
// Arrange
string html = @"
<html>
<head>
<style>
body { font-family: Arial, sans-serif; font-size: 12px; margin: 20px; }
.header { text-align: center; border-bottom: 2px solid #4CAF50; padding-bottom: 10px; margin-bottom: 20px; }
.header h1 { color: #4CAF50; }
.info { margin-bottom: 20px; }
.info table { width: 100%; }
.info td { padding: 5px; }
.details table { width: 100%; border-collapse: collapse; }
.details th, .details td { border: 1px solid #dddddd; text-align: center; padding: 8px; }
.details th { background-color: #4CAF50; color: white; }
.totals { margin-top: 20px; float: right; width: 300px; }
.totals table { width: 100%; border-collapse: collapse; }
.totals th, .totals td { border: 1px solid #dddddd; text-align: right; padding: 8px; }
.totals th { background-color: #4CAF50; color: white; }
.footer { position: fixed; bottom: 20px; left: 0; right: 0; text-align: center; font-size: 10px; color: gray; }
</style>
</head>
<body>
<div class='header'>
<h1>PhronCare Ortopedia</h1>
<p>Presupuesto Médico</p>
</div>
<div class='info'>
<table>
<tr>
<td><strong>Cliente:</strong> Juan Pérez</td>
<td><strong>Presupuesto N°:</strong> 000123</td>
</tr>
<tr>
<td><strong>Fecha:</strong> 13/05/2025</td>
<td><strong>Profesional:</strong> Dr. Carlos López</td>
</tr>
</table>
</div>
<div class='details'>
<table>
<tr>
<th>Cantidad</th>
<th>Producto</th>
<th>Precio Unitario</th>
<th>Subtotal</th>
</tr>
<tr>
<td>2</td>
<td>Rodillera ortopédica</td>
<td>$5.000</td>
<td>$10.000</td>
</tr>
<tr>
<td>1</td>
<td>Férula de inmovilización</td>
<td>$8.000</td>
<td>$8.000</td>
</tr>
</table>
</div>
<div class='totals'>
<table>
<tr>
<th>Subtotal</th>
<td>$18.000</td>
</tr>
<tr>
<th>IVA (21%)</th>
<td>$3.780</td>
</tr>
<tr>
<th>Total</th>
<td><strong>$21.780</strong></td>
</tr>
</table>
</div>
<div class='footer'>
Presupuesto generado automáticamente por PhronCare - No válido como factura.
</div>
</body>
</html>";
string outputFolder = @"C:\temp";
if (!Directory.Exists(outputFolder))
Directory.CreateDirectory(outputFolder);
string outputPath = Path.Combine(outputFolder, "DemoTest_Puppeteer.pdf");
// Opcional: podés probar pasando o no opciones
var options = new PdfGenerationOptions
{
Format = PuppeteerSharp.Media.PaperFormat.A4,
Landscape = false,
PrintBackground = true,
Scale = 1.0m,
HeaderTemplate = "<div style='font-size:10px; text-align:center;'>Presupuesto</div>",
FooterTemplate = "<div style='font-size:10px; text-align:center;'>Página <span class='pageNumber'></span> de <span class='totalPages'></span></div>"
};
// Act
byte[] pdfBytes = await _pdfService.GeneratePdfFromHtmlAsync(html, options);
await File.WriteAllBytesAsync(outputPath, pdfBytes);
// Assert
Assert.IsTrue(File.Exists(outputPath));
Assert.IsTrue(new FileInfo(outputPath).Length > 0);
TestContext.WriteLine($"PDF generado correctamente en: {outputPath}");
}
}
}

View File

@ -3,12 +3,13 @@
@using Domain.Dtos.Stock
@using Domain.Generics
@using System.Text.Json
@using phronCare.UIBlazor.Shared.Modals
@using phronCare.UIBlazor.Services.Stock.Expeditions
@inject IExpeditionService expeditionService
@inject NavigationManager Nav
@inject IToastService Toast
@inject IModalService Modal
<div class="card shadow-sm mb-3" style="zoom: 0.8;">
<div class="card-header py-2">
@ -115,13 +116,21 @@
<td class="text-truncate" style="max-width: 420px">@e.Observations</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-primary rounded-pill" title="PDF" @onclick="() => ViewPdf(e.Id, e.Expeditionnumber)">
<i class="fas fa-file-pdf"></i>
</button>
<button class="btn btn-outline-secondary rounded-pill" title="Ver" @onclick="() => OpenDetailAsync(e)">
<i class="fas fa-eye"></i>
</button>
<button class="btn btn-outline-primary rounded-pill" title="PDF" @onclick="() => ViewPdf(e.Id, e.Expeditionnumber)">
<i class="fas fa-file-pdf"></i>
</button>
<button class="btn btn-outline-secondary rounded-pill" title="Ver" @onclick="() => OpenDetailAsync(e)">
<i class="fas fa-eye"></i>
</button>
@if (CanMarkInTransit(e))
{
<button class="btn btn-outline-warning rounded-pill"
title="Pasar a En tránsito"
@onclick="() => ConfirmMarkInTransitAsync(e)">
<i class="fas fa-truck"></i>
</button>
}
</div>
</td>
</tr>
@ -187,6 +196,7 @@
private async Task OpenDetailAsync(ExpeditionDto dto)
{
// Abro el drawer con lo que ya tengo (encabezado)
loadingDetail = true;
drawerOpen = true;
selected = dto;
StateHasChanged();
@ -345,4 +355,52 @@
if (DateTime.TryParse(s, out var dt)) return dt;
return null;
}
// private bool CanMarkInTransit(ExpeditionDto expedition)
// {
// if (expedition == null)
// return false;
// if (!string.IsNullOrWhiteSpace(expedition.StatusLabel))
// return string.Equals(expedition.StatusLabel, "Emitida", StringComparison.OrdinalIgnoreCase);
// return expedition.Status == 1;
// }
private bool CanMarkInTransit(ExpeditionDto expedition)
{
return expedition is not null && expedition.Status == 1;
}
private async Task ConfirmMarkInTransitAsync(ExpeditionDto expedition)
{
var parameters = new ModalParameters();
parameters.Add(nameof(ConfirmModal.Title), "Confirmar cambio de estado");
parameters.Add(nameof(ConfirmModal.Message),
$"La expedición '{expedition.Expeditionnumber}' pasará a estado 'En tránsito'. ¿Desea continuar?");
var modal = Modal.Show<ConfirmModal>("Confirmar", parameters);
var resultModal = await modal.Result;
if (resultModal.Cancelled)
return;
try
{
await expeditionService.MarkInTransitAsync(expedition.Id);
Toast.ShowSuccess($"La expedición '{expedition.Expeditionnumber}' pasó a 'En tránsito' correctamente.");
await Search();
if (selected is not null && selected.Id == expedition.Id)
{
selected = await expeditionService.GetDtoByIdAsync(expedition.Id);
StateHasChanged();
}
}
catch (Exception ex)
{
Toast.ShowError(string.IsNullOrWhiteSpace(ex.Message)
? "No se pudo pasar la expedición a 'En tránsito'."
: ex.Message);
}
}
}

View File

@ -179,6 +179,23 @@ namespace phronCare.UIBlazor.Services.Stock.Expeditions
throw new Exception($"{message}", ex);
}
}
public async Task MarkInTransitAsync(int expeditionId)
{
if (expeditionId <= 0)
throw new ArgumentException("El identificador de la expedición no es válido.");
var response = await _http.PostAsync($"api/expedition/{expeditionId}/mark-in-transit", null);
if (!response.IsSuccessStatusCode)
{
var message = await response.Content.ReadAsStringAsync();
if (string.IsNullOrWhiteSpace(message))
message = "No se pudo pasar la expedición a 'En tránsito'.";
throw new Exception(message);
}
}
}
/// <summary>

View File

@ -11,5 +11,6 @@ namespace phronCare.UIBlazor.Services.Stock.Expeditions
Task<QuoteDto?> GetQuoteByNumberAsync(string quoteNumber);
Task<PagedResult<ExpeditionDto>> SearchAsync(string? expeditionNumber = null, string? status = null, DateTime? issueDateFrom = null, DateTime? issueDateTo = null, int? locationId = null, int page = 1, int pageSize = 10);
Task ExportFilteredAsync(LSProductSearchParams searchParams);
Task MarkInTransitAsync(int expeditionId);
}
}