using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.EntityFrameworkCore; using s2industries.ZUGFeRD; using SIFinance.Modules.Finance.Dtos; using SIFinance.Shared; using SIFinance.Shared.Entities; namespace SIFinance.Modules.Finance.Endpoints; public static class FacturesEndpoints { public static IEndpointRouteBuilder MapFacturesEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup("/api/factures").WithTags("Factures").RequireAuthorization(); // Réception de factures électroniques (Factur-X/ZUGFeRD, échéance réglementaire du // 1er septembre) : le PDF fourni par le fournisseur embarque un XML EN16931 — on le // détecte et on en extrait les données pour pré-remplir/vérifier le formulaire de // saisie, plutôt que de forcer une ressaisie manuelle sujette aux erreurs. group.MapPost("/analyser-facturx", async (HttpRequest request, CancellationToken ct) => { if (!request.HasFormContentType) return Results.BadRequest(new { error = "Contenu multipart attendu." }); var form = await request.ReadFormAsync(ct); var file = form.Files.GetFile("file"); if (file is null || file.Length == 0) return Results.BadRequest(new { error = "Fichier manquant." }); try { await using var stream = file.OpenReadStream(); var desc = InvoiceDescriptor.Load(stream); var echeance = desc.GetTradePaymentTerms().FirstOrDefault()?.DueDate; return Results.Ok(new FacturXAnalyseDto( Conforme: true, Numero: desc.InvoiceNo, FournisseurNom: desc.Seller?.Name, MontantHt: desc.TaxBasisAmount ?? desc.LineTotalAmount, MontantTva: desc.TaxTotalAmount, MontantTtc: desc.GrandTotalAmount, DateFacture: desc.InvoiceDate.HasValue ? DateOnly.FromDateTime(desc.InvoiceDate.Value) : null, DateEcheance: echeance.HasValue ? DateOnly.FromDateTime(echeance.Value) : null, Erreur: null)); } catch (Exception) { // Cas normal pour un PDF "papier scanné" sans XML embarqué — pas une erreur // serveur, juste une facture non électronique à saisir manuellement. return Results.Ok(new FacturXAnalyseDto( Conforme: false, Numero: null, FournisseurNom: null, MontantHt: null, MontantTva: null, MontantTtc: null, DateFacture: null, DateEcheance: null, Erreur: "Ce PDF ne contient pas de facture électronique Factur-X/ZUGFeRD reconnue — saisie manuelle requise.")); } }); group.MapGet("", async (SIFinanceDbContext db, CancellationToken ct) => // Projection directe en DTO : SQL Server ne renvoie que les colonnes utilisées, // et une seule affectation d'avoir est remontée (OUTER APPLY TOP 1) au lieu de // charger toute la collection avec l'entité Avoir complète. { var factures = await db.Factures .AsNoTracking() .OrderByDescending(f => f.DateCreation) .Select(f => new FactureDto( f.Id, f.NumeroFacture, f.FournisseurId, f.Fournisseur.RaisonSociale, f.MontantHt, f.TauxTva, f.MontantTva, f.MontantTtc, f.DateFacture, f.DateEcheance, f.Statut, f.DemandeAchatId, f.AffectationsAvoirs.Select(a => (int?)a.AvoirId).FirstOrDefault(), f.AffectationsAvoirs.Select(a => a.Avoir.NumeroAvoir).FirstOrDefault(), f.AffectationsAvoirs.Select(a => (decimal?)a.MontantAffecte).FirstOrDefault(), f.Commentaire, f.EstDoublonSignale ?? false, f.ContratId, f.Contrat != null ? f.Contrat.Objet : null, // ToEcheanceDto n'est pas utilisable ici : EF ne sait pas traduire // un appel de méthode C# en SQL, le constructeur est donc écrit inline. f.Acomptes.Select(a => new FactureEcheanceDto(a.Id, a.DatePrevue, a.Montant, a.Statut)).ToList())) .ToListAsync(ct); var factureIds = factures.Select(f => f.Id).ToArray(); var justificatifs = await db.PiecesJointes.AsNoTracking() .Where(p => p.EntiteType == "FACTURE" && factureIds.Contains(p.EntiteId)) .OrderBy(p => p.DateUpload) .Select(p => new { p.EntiteId, p.NomFichier, p.SharepointUrl }) .ToListAsync(ct); var parFacture = justificatifs.ToLookup(p => p.EntiteId); return factures.Select(f => f with { Justificatifs = parFacture[f.Id] .Where(p => !string.IsNullOrWhiteSpace(p.SharepointUrl)) .Select(p => new FactureJustificatifDto(p.NomFichier, p.SharepointUrl)) .ToList(), }); }); group.MapPost("", async (CreateFactureDto input, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) => { if (string.IsNullOrWhiteSpace(input.Numero)) return Results.BadRequest(new { error = "Le numéro de facture est requis." }); if (input.Montant <= 0) return Results.BadRequest(new { error = "Le montant doit être supérieur à 0." }); if (input.ContratId.HasValue) { var contratExiste = await db.ContratsFournisseurs .AnyAsync(c => c.Id == input.ContratId.Value && c.FournisseurId == input.FournisseurId, ct); if (!contratExiste) return Results.BadRequest(new { error = "Le contrat sélectionné n'appartient pas à ce fournisseur." }); } var montantTtc = input.Montant; var montantHt = Math.Round(montantTtc / (1 + FinanceDefaults.DefaultTauxTva / 100m), 2); var montantTva = Math.Round(montantTtc - montantHt, 2); var currentUserId = await currentUser.GetOrCreateCurrentUserIdAsync(ct); // Doublon potentiel : même fournisseur + même montant TTC dans une fenêtre de 30 // jours autour de la date de facture, indépendamment du numéro (souvent mal // ressaisi) — signalement non bloquant, l'utilisateur peut le lever ensuite. var fenetreDebut = input.DateFacture.AddDays(-30); var fenetreFin = input.DateFacture.AddDays(30); var doublonPotentiel = await db.Factures.AnyAsync(f => f.FournisseurId == input.FournisseurId && f.MontantTtc == montantTtc && f.DateFacture >= fenetreDebut && f.DateFacture <= fenetreFin, ct); var facture = new Facture { SourceType = "MANUEL", DemandeAchatId = input.DemandeAchatId, ContratId = input.ContratId, FournisseurId = input.FournisseurId, NumeroFacture = input.Numero.Trim(), DateFacture = input.DateFacture, DateEcheance = input.DateEcheance, MontantHt = montantHt, TauxTva = FinanceDefaults.DefaultTauxTva, MontantTva = montantTva, MontantTtc = montantTtc, ServiceId = FinanceDefaults.DefaultServiceId, ModePaiementId = FinanceDefaults.DefaultModePaiementId, Statut = "attente", Commentaire = string.IsNullOrWhiteSpace(input.Commentaire) ? null : input.Commentaire.Trim(), EstDoublonSignale = doublonPotentiel, SaisieParId = currentUserId, DateCreation = DateTime.UtcNow, Acomptes = [new Acompte { Montant = montantTtc, DatePrevue = input.DateEcheance, Statut = "PREVU", SaisieParId = currentUserId, DateCreation = DateTime.UtcNow, }], }; db.Factures.Add(facture); try { await db.SaveChangesAsync(ct); } catch (DbUpdateException ex) { return Results.BadRequest(new { error = "Impossible d'enregistrer la facture — vérifiez le fournisseur.", detail = ex.InnerException?.Message ?? ex.Message }); } await db.Entry(facture).Reference(f => f.Fournisseur).LoadAsync(ct); if (facture.ContratId.HasValue) await db.Entry(facture).Reference(f => f.Contrat).LoadAsync(ct); return Results.Created($"/api/factures/{facture.Id}", ToDto(facture)); }); group.MapPatch("/{id:int}/statut", async (int id, UpdateFactureStatutDto input, SIFinanceDbContext db, CancellationToken ct) => { var facture = await db.Factures.FindAsync([id], ct); if (facture is null) return Results.NotFound(); facture.Statut = input.Statut; facture.DateDerniereMaj = DateTime.UtcNow; await db.SaveChangesAsync(ct); return Results.NoContent(); }); group.MapPatch("/{id:int}/commentaire", async (int id, UpdateFactureCommentaireDto input, SIFinanceDbContext db, CancellationToken ct) => { var facture = await db.Factures.FindAsync([id], ct); if (facture is null) return Results.NotFound(); facture.Commentaire = string.IsNullOrWhiteSpace(input.Commentaire) ? null : input.Commentaire.Trim(); facture.DateDerniereMaj = DateTime.UtcNow; await db.SaveChangesAsync(ct); return Results.NoContent(); }); // Permet à l'utilisateur de lever un signalement de doublon potentiel une fois // vérifié qu'il s'agit bien de deux factures distinctes (cf. demande métier : // avertissement désactivable, pas un blocage). group.MapPatch("/{id:int}/doublon", async (int id, SIFinanceDbContext db, CancellationToken ct) => { var facture = await db.Factures.FindAsync([id], ct); if (facture is null) return Results.NotFound(); facture.EstDoublonSignale = false; facture.DateDerniereMaj = DateTime.UtcNow; await db.SaveChangesAsync(ct); return Results.NoContent(); }); group.MapDelete("/{id:int}", async (int id, SIFinanceDbContext db, CancellationToken ct) => { var facture = await db.Factures .Include(f => f.Acomptes) .Include(f => f.AffectationsAvoirs).ThenInclude(a => a.Avoir) .FirstOrDefaultAsync(f => f.Id == id, ct); if (facture is null) return Results.NotFound(); foreach (var affectation in facture.AffectationsAvoirs) { affectation.Avoir.SoldeDisponible += affectation.MontantAffecte; } db.AffectationsAvoirs.RemoveRange(facture.AffectationsAvoirs); db.Acomptes.RemoveRange(facture.Acomptes); db.Factures.Remove(facture); await db.SaveChangesAsync(ct); return Results.NoContent(); }); group.MapPost("/{id:int}/echeances", async (int id, CreateEcheanceDto input, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) => { var facture = await db.Factures.FindAsync([id], ct); if (facture is null) return Results.NotFound(); var echeance = new Acompte { FactureId = id, Montant = input.Montant, DatePrevue = input.Date, Statut = "PREVU", SaisieParId = await currentUser.GetOrCreateCurrentUserIdAsync(ct), DateCreation = DateTime.UtcNow, }; db.Acomptes.Add(echeance); await db.SaveChangesAsync(ct); return Results.Created($"/api/factures/{id}/echeances/{echeance.Id}", ToEcheanceDto(echeance)); }); group.MapPut("/{id:int}/echeances/{echeanceId:int}", async (int id, int echeanceId, UpdateEcheanceDto input, SIFinanceDbContext db, CancellationToken ct) => { var echeance = await db.Acomptes.FirstOrDefaultAsync(a => a.Id == echeanceId && a.FactureId == id, ct); if (echeance is null) return Results.NotFound(); echeance.DatePrevue = input.Date; echeance.Montant = input.Montant; await db.SaveChangesAsync(ct); return Results.NoContent(); }); group.MapDelete("/{id:int}/echeances/{echeanceId:int}", async (int id, int echeanceId, SIFinanceDbContext db, CancellationToken ct) => { var echeance = await db.Acomptes.FirstOrDefaultAsync(a => a.Id == echeanceId && a.FactureId == id, ct); if (echeance is null) return Results.NotFound(); db.Acomptes.Remove(echeance); await db.SaveChangesAsync(ct); return Results.NoContent(); }); group.MapPost("/{id:int}/avoir/{avoirId:int}", async (int id, int avoirId, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) => { var facture = await db.Factures.FindAsync([id], ct); var avoir = await db.Avoirs.FindAsync([avoirId], ct); if (facture is null || avoir is null) return Results.NotFound(); if (avoir.FournisseurId != facture.FournisseurId) return Results.BadRequest(new { error = "L'avoir doit être du même fournisseur que la facture." }); if (avoir.SoldeDisponible <= 0) return Results.BadRequest(new { error = "Cet avoir n'a plus de solde disponible." }); var montantAffecte = Math.Min(avoir.SoldeDisponible, facture.MontantTtc); db.AffectationsAvoirs.Add(new AffectationsAvoir { AvoirId = avoirId, FactureId = id, MontantAffecte = montantAffecte, AffecteParId = await currentUser.GetOrCreateCurrentUserIdAsync(ct), DateAffectation = DateTime.UtcNow, }); avoir.SoldeDisponible -= montantAffecte; await db.SaveChangesAsync(ct); return Results.NoContent(); }); group.MapDelete("/{id:int}/avoir/{avoirId:int}", async (int id, int avoirId, SIFinanceDbContext db, CancellationToken ct) => { var affectation = await db.AffectationsAvoirs.Include(a => a.Avoir) .FirstOrDefaultAsync(a => a.FactureId == id && a.AvoirId == avoirId, ct); if (affectation is null) return Results.NotFound(); affectation.Avoir.SoldeDisponible += affectation.MontantAffecte; db.AffectationsAvoirs.Remove(affectation); await db.SaveChangesAsync(ct); return Results.NoContent(); }); return app; } private static FactureEcheanceDto ToEcheanceDto(Acompte a) => new(a.Id, a.DatePrevue, a.Montant, a.Statut); // Conservé pour le MapPost, qui construit le DTO à partir de l'entité fraîchement // créée (sans affectation d'avoir à ce stade). private static FactureDto ToDto(Facture f) { var affectation = f.AffectationsAvoirs.FirstOrDefault(); return new FactureDto( f.Id, f.NumeroFacture, f.FournisseurId, f.Fournisseur.RaisonSociale, f.MontantHt, f.TauxTva, f.MontantTva, f.MontantTtc, f.DateFacture, f.DateEcheance, f.Statut, f.DemandeAchatId, affectation?.AvoirId, affectation?.Avoir.NumeroAvoir, affectation?.MontantAffecte, f.Commentaire, f.EstDoublonSignale ?? false, f.ContratId, f.Contrat?.Objet, f.Acomptes.Select(ToEcheanceDto).ToList()); } }