Merge pull request 'feat(expeditions): permitir transición Emitida → EnTransito desde la consulta' (#8) from feature/leandro/7-expedition-mark-in-transit into master
All checks were successful
CI/CD Pipeline / Build and Deploy with Docker Compose (push) Successful in 2m13s
All checks were successful
CI/CD Pipeline / Build and Deploy with Docker Compose (push) Successful in 2m13s
Reviewed-on: http://saludlab.com.ar:3000/leandro/phronCare/pulls/8
This commit is contained in:
commit
b2aebafe55
@ -24,5 +24,6 @@ namespace Core.Interfaces.Stock
|
|||||||
IEnumerable<ELSExpeditionDetail> details,
|
IEnumerable<ELSExpeditionDetail> details,
|
||||||
int formSeriesId);
|
int formSeriesId);
|
||||||
Task<byte[]> ExportFilteredToExcelAsync(ExpeditionSearchParams searchParams);
|
Task<byte[]> ExportFilteredToExcelAsync(ExpeditionSearchParams searchParams);
|
||||||
|
Task MarkInTransitAsync(int expeditionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -220,5 +220,12 @@ namespace Core.Services.Stock
|
|||||||
throw new Exception($"{ex.Message}", ex);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,6 +23,8 @@ namespace Models.Interfaces
|
|||||||
Task<(int Id, string Expeditionnumber)> CreateFullExpeditionAsync(ELSExpeditionHeader expedition, int formSeriesId);
|
Task<(int Id, string Expeditionnumber)> CreateFullExpeditionAsync(ELSExpeditionHeader expedition, int formSeriesId);
|
||||||
Task<ExpeditionDto?> GetDtoByIdAsync(int id);
|
Task<ExpeditionDto?> GetDtoByIdAsync(int id);
|
||||||
Task<PagedResult<ExpeditionDto>> SearchAsync(string? expeditionNumber, string? status, DateTime? issueDateFrom, DateTime? issueDateTo, int? locationId, int page, int pageSize);
|
Task<PagedResult<ExpeditionDto>> SearchAsync(string? expeditionNumber, string? status, DateTime? issueDateFrom, DateTime? issueDateTo, int? locationId, int page, int pageSize);
|
||||||
|
Task MarkInTransitAsync(int expeditionId);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -404,7 +404,22 @@ namespace Models.Repositories.Stock
|
|||||||
sb.Append(ch);
|
sb.Append(ch);
|
||||||
return sb.ToString().Normalize(NormalizationForm.FormC).Replace(" ", "");
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="EPPlus" Version="7.5.2" />
|
<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" />
|
<PackageReference Include="NETCore.MailKit" Version="2.1.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@ -144,6 +144,32 @@ namespace phronCare.API.Controllers.Stock
|
|||||||
return BadRequest(ex.Message);
|
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
|
public class CreateFullExpeditionRequest
|
||||||
{
|
{
|
||||||
|
|||||||
@ -37,6 +37,7 @@
|
|||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.15" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.15" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.6" />
|
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.6" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" 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.SqlServer" Version="8.0.10" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.6">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.6">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
|||||||
@ -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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -3,12 +3,13 @@
|
|||||||
@using Domain.Dtos.Stock
|
@using Domain.Dtos.Stock
|
||||||
@using Domain.Generics
|
@using Domain.Generics
|
||||||
@using System.Text.Json
|
@using System.Text.Json
|
||||||
|
@using phronCare.UIBlazor.Shared.Modals
|
||||||
@using phronCare.UIBlazor.Services.Stock.Expeditions
|
@using phronCare.UIBlazor.Services.Stock.Expeditions
|
||||||
@inject IExpeditionService expeditionService
|
@inject IExpeditionService expeditionService
|
||||||
|
|
||||||
@inject NavigationManager Nav
|
@inject NavigationManager Nav
|
||||||
@inject IToastService Toast
|
@inject IToastService Toast
|
||||||
|
@inject IModalService Modal
|
||||||
|
|
||||||
<div class="card shadow-sm mb-3" style="zoom: 0.8;">
|
<div class="card shadow-sm mb-3" style="zoom: 0.8;">
|
||||||
<div class="card-header py-2">
|
<div class="card-header py-2">
|
||||||
@ -122,6 +123,14 @@
|
|||||||
<i class="fas fa-eye"></i>
|
<i class="fas fa-eye"></i>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -187,6 +196,7 @@
|
|||||||
private async Task OpenDetailAsync(ExpeditionDto dto)
|
private async Task OpenDetailAsync(ExpeditionDto dto)
|
||||||
{
|
{
|
||||||
// Abro el drawer con lo que ya tengo (encabezado)
|
// Abro el drawer con lo que ya tengo (encabezado)
|
||||||
|
loadingDetail = true;
|
||||||
drawerOpen = true;
|
drawerOpen = true;
|
||||||
selected = dto;
|
selected = dto;
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
@ -345,4 +355,52 @@
|
|||||||
if (DateTime.TryParse(s, out var dt)) return dt;
|
if (DateTime.TryParse(s, out var dt)) return dt;
|
||||||
return null;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -179,6 +179,23 @@ namespace phronCare.UIBlazor.Services.Stock.Expeditions
|
|||||||
throw new Exception($"{message}", ex);
|
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>
|
/// <summary>
|
||||||
|
|||||||
@ -11,5 +11,6 @@ namespace phronCare.UIBlazor.Services.Stock.Expeditions
|
|||||||
Task<QuoteDto?> GetQuoteByNumberAsync(string quoteNumber);
|
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<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 ExportFilteredAsync(LSProductSearchParams searchParams);
|
||||||
|
Task MarkInTransitAsync(int expeditionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user