diff --git a/PyroFetes/DTO/Quotation/Request/GetQuotationPdfDto.cs b/PyroFetes/DTO/Quotation/Request/GetQuotationPdfDto.cs new file mode 100644 index 0000000..0c74cd7 --- /dev/null +++ b/PyroFetes/DTO/Quotation/Request/GetQuotationPdfDto.cs @@ -0,0 +1,6 @@ +namespace PyroFetes.DTO.Quotation.Request; + +public class GetQuotationPdfDto +{ + public int Id { get; set; } +} \ No newline at end of file diff --git a/PyroFetes/Endpoints/Quotations/GetQuotationPdfEndpoint.cs b/PyroFetes/Endpoints/Quotations/GetQuotationPdfEndpoint.cs new file mode 100644 index 0000000..d0d015a --- /dev/null +++ b/PyroFetes/Endpoints/Quotations/GetQuotationPdfEndpoint.cs @@ -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 +{ + 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); + } +} \ No newline at end of file diff --git a/PyroFetes/Program.cs b/PyroFetes/Program.cs index e1a719b..9e62ec9 100644 --- a/PyroFetes/Program.cs +++ b/PyroFetes/Program.cs @@ -6,9 +6,14 @@ using FastEndpoints.Swagger; using FastEndpoints.Security; using PyroFetes.MappingProfiles; using PyroFetes.Repositories; +using PyroFetes.Services.Pdf; +using QuestPDF.Infrastructure; 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 builder.Services .AddAuthenticationJwtBearer(s => s.SigningKey = "ThisIsASuperSecretJwtKeyThatIsAtLeast32CharsLong") @@ -46,6 +51,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); + MapperConfiguration mappingConfig = new(mc => { mc.AddCollectionMappers(); diff --git a/PyroFetes/PyroFetes.csproj b/PyroFetes/PyroFetes.csproj index cd211f5..3e77db0 100644 --- a/PyroFetes/PyroFetes.csproj +++ b/PyroFetes/PyroFetes.csproj @@ -23,7 +23,21 @@ + + + <_ContentIncludedByDefault Remove="wwwroot\Images\logo.jpg" /> + + + + + PreserveNewest + + + PreserveNewest + + + diff --git a/PyroFetes/Services/Pdf/QuotationPdfService.cs b/PyroFetes/Services/Pdf/QuotationPdfService.cs new file mode 100644 index 0000000..4e3591d --- /dev/null +++ b/PyroFetes/Services/Pdf/QuotationPdfService.cs @@ -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 lignes); +} + +public class QuotationPdfService : IQuotationPdfService +{ + public byte[] Generate(Quotation quotation, List 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(); + } +} diff --git a/PyroFetes/Specifications/Quotations/GetQuotationByIdWithProductsSpec.cs b/PyroFetes/Specifications/Quotations/GetQuotationByIdWithProductsSpec.cs new file mode 100644 index 0000000..3e8d724 --- /dev/null +++ b/PyroFetes/Specifications/Quotations/GetQuotationByIdWithProductsSpec.cs @@ -0,0 +1,15 @@ +using Ardalis.Specification; +using PyroFetes.Models; + +namespace PyroFetes.Specifications.Quotations; + +public class GetQuotationByIdWithProductsSpec : Specification +{ + public GetQuotationByIdWithProductsSpec(int quotationId) + { + Query + .Where(q => q.Id == quotationId) + .Include(q => q.QuotationProducts!) + .ThenInclude(qp => qp.Product); + } +} \ No newline at end of file diff --git a/PyroFetes/wwwroot/Images/logo.jpg b/PyroFetes/wwwroot/Images/logo.jpg new file mode 100644 index 0000000..e459459 Binary files /dev/null and b/PyroFetes/wwwroot/Images/logo.jpg differ diff --git a/PyroFetes/wwwroot/Images/signature.png b/PyroFetes/wwwroot/Images/signature.png new file mode 100644 index 0000000..c5d1668 Binary files /dev/null and b/PyroFetes/wwwroot/Images/signature.png differ