added PDF generation for quotation

This commit is contained in:
2025-12-03 17:00:02 +01:00
parent d709654410
commit bee1cfb0e3
8 changed files with 226 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
namespace PyroFetes.DTO.Quotation.Request;
public class GetQuotationPdfDto
{
public int Id { get; set; }
}

View File

@@ -0,0 +1,41 @@
using FastEndpoints;
using PyroFetes.DTO.Quotation.Request;
using PyroFetes.Models;
using PyroFetes.Repositories;
using PyroFetes.Services.Pdf;
using PyroFetes.Specifications.Quotations;
namespace PyroFetes.Endpoints.Quotations;
public class GetQuotationPdfEndpoint(
QuotationsRepository quotationRepository,
QuotationProductsRepository quotationProductRepository,
IQuotationPdfService quotationPdfService)
: Endpoint<GetQuotationPdfDto>
{
public override void Configure()
{
Get("/quotations/{@Id}/pdf", x => new {x.Id});
AllowAnonymous();
}
public override async Task HandleAsync(GetQuotationPdfDto req, CancellationToken ct)
{
Quotation? quotation = await quotationRepository
.FirstOrDefaultAsync(new GetQuotationByIdWithProductsSpec(req.Id), ct);
if (quotation == null)
{
await Send.NotFoundAsync(ct);
return;
}
var bytes = quotationPdfService.Generate(quotation, quotation.QuotationProducts!);
await Send.BytesAsync(
bytes: bytes,
contentType: "application/pdf",
fileName: $"devis-{quotation.Id}.pdf",
cancellation: ct);
}
}

View File

@@ -6,9 +6,14 @@ using FastEndpoints.Swagger;
using FastEndpoints.Security; using FastEndpoints.Security;
using PyroFetes.MappingProfiles; using PyroFetes.MappingProfiles;
using PyroFetes.Repositories; using PyroFetes.Repositories;
using PyroFetes.Services.Pdf;
using QuestPDF.Infrastructure;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args); WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// Configurer la licence QuestPDF
QuestPDF.Settings.License = LicenseType.Community;
// On ajoute ici FastEndpoints, un framework REPR et Swagger aux services disponibles dans le projet // On ajoute ici FastEndpoints, un framework REPR et Swagger aux services disponibles dans le projet
builder.Services builder.Services
.AddAuthenticationJwtBearer(s => s.SigningKey = "ThisIsASuperSecretJwtKeyThatIsAtLeast32CharsLong") .AddAuthenticationJwtBearer(s => s.SigningKey = "ThisIsASuperSecretJwtKeyThatIsAtLeast32CharsLong")
@@ -46,6 +51,8 @@ builder.Services.AddScoped<SettingsRepository>();
builder.Services.AddScoped<UsersRepository>(); builder.Services.AddScoped<UsersRepository>();
builder.Services.AddScoped<WarehouseProductsRepository>(); builder.Services.AddScoped<WarehouseProductsRepository>();
builder.Services.AddScoped<IQuotationPdfService, QuotationPdfService>();
MapperConfiguration mappingConfig = new(mc => MapperConfiguration mappingConfig = new(mc =>
{ {
mc.AddCollectionMappers(); mc.AddCollectionMappers();

View File

@@ -23,7 +23,21 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.20" /> <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.20" />
<PackageReference Include="PasswordGenerator" Version="2.1.0" /> <PackageReference Include="PasswordGenerator" Version="2.1.0" />
<PackageReference Include="Plainquire.Page" Version="6.5.0" /> <PackageReference Include="Plainquire.Page" Version="6.5.0" />
<PackageReference Include="QuestPDF" Version="2025.7.4" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/>
</ItemGroup> </ItemGroup>
<ItemGroup>
<_ContentIncludedByDefault Remove="wwwroot\Images\logo.jpg" />
</ItemGroup>
<ItemGroup>
<Content Update="wwwroot\Images\logo.jpg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Update="wwwroot\Images\signature.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,143 @@
using PyroFetes.Models;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
namespace PyroFetes.Services.Pdf;
public interface IQuotationPdfService
{
byte[] Generate(Quotation quotation, List<QuotationProduct> lignes);
}
public class QuotationPdfService : IQuotationPdfService
{
public byte[] Generate(Quotation quotation, List<QuotationProduct> lignes)
{
var logoPath = Path.Combine(AppContext.BaseDirectory, "wwwroot", "Images", "logo.jpg");
var signaturePath = Path.Combine(AppContext.BaseDirectory, "wwwroot", "Images", "signature.png");
int total = 0;
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(30);
page.DefaultTextStyle(x => x.FontSize(11));
page.Header().Row(row =>
{
// Client à gauche
row.RelativeItem().Column(col =>
{
col.Item().Text("");
col.Item().Text("");
col.Item().Text("");
col.Item().Text("");
col.Item().Text("Client").SemiBold().FontSize(12);
col.Item().Text($"{quotation.Customer}");
});
// Logo + société à droite
row.ConstantItem(200).Column(col =>
{
col.Item().AlignRight().Height(70).Image(logoPath, ImageScaling.FitArea);
col.Item().Height(20);
col.Item().AlignRight().Text("Pyro-Fêtes").SemiBold();
col.Item().Height(5);
col.Item().AlignRight().Text("24, rue La Fosse Mardeau\n41700 Le Controis-en-Sologne");
col.Item().Height(5);
col.Item().AlignRight().Text("Téléphone: 02 54 78 77 66");
col.Item().Height(5);
col.Item().AlignRight().Text("SIRET: 82031463100012");
col.Item().Height(40);
});
});
page.Content().Column(col =>
{
// Titre + date
col.Item().Row(row =>
{
row.RelativeItem().Text($"Devis n° {quotation.Id}")
.FontSize(16).SemiBold();
row.ConstantItem(200).AlignRight().Text(
$"Le {DateTime.Now:dd/MM/yyyy}");
});
col.Item().Height(20);
col.Item().LineHorizontal(1);
// Tableau des lignes
col.Item().Table(table =>
{
table.ColumnsDefinition(columns =>
{
columns.RelativeColumn(4); // Produit
columns.RelativeColumn(1); // Qté
columns.RelativeColumn(2); // PU
columns.RelativeColumn(2); // Total
});
// En-têtes
table.Header(header =>
{
header.Cell().Element(CellHeader).Text("Produit");
header.Cell().Element(CellHeader).AlignRight().Text("Qté");
header.Cell().Element(CellHeader).AlignRight().Text("PU");
header.Cell().Element(CellHeader).AlignRight().Text("Total");
});
foreach (var l in lignes)
{
table.Cell().Element(CellBody).Text(l.Product?.Name);
table.Cell().Element(CellBody).AlignRight().Text(l.Quantity.ToString());
table.Cell().Element(CellBody).AlignRight().Text($"{l.Quantity:n2} €");
table.Cell().Element(CellBody).AlignRight().Text($"{l.Quantity * l.Quantity:n2} €");
total = total + l.Quantity * l.Quantity;
}
IContainer CellHeader(IContainer c) =>
c.BorderBottom(1).PaddingVertical(5).DefaultTextStyle(x => x.SemiBold());
IContainer CellBody(IContainer c) =>
c.PaddingVertical(2);
});
col.Item().LineHorizontal(1);
col.Item().Height(30);
col.Item().Row(row =>
{
// Colonne gauche : conditions de vente
row.RelativeItem().Column(left =>
{
left.Item().Text("Conditions de vente")
.SemiBold().FontSize(12);
left.Item().Text(quotation.ConditionsSale)
.FontSize(9);
});
// Colonne droite : totaux
row.ConstantItem(180).Column(right =>
{
right.Item().AlignRight().Text($"Total HT : {total:n2} €");
right.Item().AlignRight().Text("Taxe : 20 %");
right.Item().AlignRight().Text($"Total TTC : {(total * 1.2):n2} €");
});
});
});
// Signature en bas à droite
page.Footer().AlignRight().Column(col =>
{
col.Item().AlignRight().Height(100).Image(signaturePath, ImageScaling.FitArea);
});
});
});
return document.GeneratePdf();
}
}

View File

@@ -0,0 +1,15 @@
using Ardalis.Specification;
using PyroFetes.Models;
namespace PyroFetes.Specifications.Quotations;
public class GetQuotationByIdWithProductsSpec : Specification<Quotation>
{
public GetQuotationByIdWithProductsSpec(int quotationId)
{
Query
.Where(q => q.Id == quotationId)
.Include(q => q.QuotationProducts!)
.ThenInclude(qp => qp.Product);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB