using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.EntityFrameworkCore; using SIFinance.Shared; using SIFinance.Modules.Achats.Dtos; using SIFinance.Shared.Entities; namespace SIFinance.Modules.Achats.Endpoints; public static class AchatsEndpoints { public static IEndpointRouteBuilder MapAchatsEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup("/api").WithTags("Achats").RequireAuthorization(); group.MapGet("/services", async (SIFinanceDbContext db, CancellationToken ct) => await db.Services .AsNoTracking() .Where(s => s.Actif) .OrderBy(s => s.Libelle) .Select(s => new ServiceDto(s.Id, s.Libelle)) .ToListAsync(ct)); group.MapGet("/campus", async (SIFinanceDbContext db, CancellationToken ct) => await db.Campus.AsNoTracking().OrderBy(c => c.Libelle) .Select(c => new { c.Id, c.Libelle }).ToListAsync(ct)); group.MapGet("/types-depense", async (SIFinanceDbContext db, CancellationToken ct) => await db.TypesDepenses .AsNoTracking() .Where(t => t.Actif) .OrderBy(t => t.Libelle) .Select(t => new TypeDepenseDto(t.Id, t.Libelle)) .ToListAsync(ct)); group.MapGet("/modes-paiement", async (SIFinanceDbContext db, CancellationToken ct) => await db.ModesPaiements .AsNoTracking() .Where(m => m.Actif) .OrderBy(m => m.Libelle) .Select(m => new ModePaiementDto(m.Id, m.Libelle, m.NecessiteXml)) .ToListAsync(ct)); group.MapGet("/fournisseurs", async (SIFinanceDbContext db, CancellationToken ct) => await db.Fournisseurs .AsNoTracking() .Where(f => f.Statut == "ACTIF") .OrderBy(f => f.RaisonSociale) .Select(f => new FournisseurDto(f.Id, f.RaisonSociale, f.Siret, f.Statut)) .ToListAsync(ct)); group.MapGet("/fournisseurs/all", async (SIFinanceDbContext db, CancellationToken ct) => await db.Fournisseurs .AsNoTracking() .OrderBy(f => f.RaisonSociale) .Select(f => new FournisseurDetailDto(f.Id, f.RaisonSociale, f.Siret, f.Email, f.Telephone, f.Adresse, f.Statut, f.DateCreation)) .ToListAsync(ct)); group.MapPost("/fournisseurs", async (FournisseurSaveDto input, SIFinanceDbContext db, CancellationToken ct) => { if (string.IsNullOrWhiteSpace(input.RaisonSociale)) return Results.BadRequest(new { error = "La raison sociale est requise." }); var fournisseur = new Fournisseur { RaisonSociale = input.RaisonSociale.Trim(), Siret = string.IsNullOrWhiteSpace(input.Siret) ? null : input.Siret.Trim(), Email = string.IsNullOrWhiteSpace(input.Email) ? null : input.Email.Trim(), Telephone = string.IsNullOrWhiteSpace(input.Telephone) ? null : input.Telephone.Trim(), Adresse = string.IsNullOrWhiteSpace(input.Adresse) ? null : input.Adresse.Trim(), Statut = input.Statut, DateCreation = DateTime.UtcNow, }; db.Fournisseurs.Add(fournisseur); await db.SaveChangesAsync(ct); return Results.Created($"/api/fournisseurs/{fournisseur.Id}", new FournisseurDetailDto(fournisseur.Id, fournisseur.RaisonSociale, fournisseur.Siret, fournisseur.Email, fournisseur.Telephone, fournisseur.Adresse, fournisseur.Statut, fournisseur.DateCreation)); }); group.MapPut("/fournisseurs/{id:int}", async (int id, FournisseurSaveDto input, SIFinanceDbContext db, CancellationToken ct) => { if (string.IsNullOrWhiteSpace(input.RaisonSociale)) return Results.BadRequest(new { error = "La raison sociale est requise." }); var fournisseur = await db.Fournisseurs.FirstOrDefaultAsync(f => f.Id == id, ct); if (fournisseur is null) return Results.NotFound(); fournisseur.RaisonSociale = input.RaisonSociale.Trim(); fournisseur.Siret = string.IsNullOrWhiteSpace(input.Siret) ? null : input.Siret.Trim(); fournisseur.Email = string.IsNullOrWhiteSpace(input.Email) ? null : input.Email.Trim(); fournisseur.Telephone = string.IsNullOrWhiteSpace(input.Telephone) ? null : input.Telephone.Trim(); fournisseur.Adresse = string.IsNullOrWhiteSpace(input.Adresse) ? null : input.Adresse.Trim(); fournisseur.Statut = input.Statut; await db.SaveChangesAsync(ct); return Results.NoContent(); }); group.MapDelete("/fournisseurs/{id:int}", async (int id, SIFinanceDbContext db, CancellationToken ct) => { var fournisseur = await db.Fournisseurs.FirstOrDefaultAsync(f => f.Id == id, ct); if (fournisseur is null) return Results.NotFound(); db.Fournisseurs.Remove(fournisseur); try { await db.SaveChangesAsync(ct); } catch (DbUpdateException) { return Results.BadRequest(new { error = "Impossible de supprimer ce fournisseur : des demandes, factures, avoirs ou contrats y sont rattachés." }); } return Results.NoContent(); }); group.MapGet("/demandes", async (string? scope, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) => { var currentUserId = await currentUser.GetOrCreateCurrentUserIdAsync(ct); var isFinance = await currentUser.HasRoleAsync("FINANCE", db, ct); var query = db.DemandesAchats.AsNoTracking().Include(d => d.Demandeur).Include(d => d.Valideur).AsQueryable(); if (scope == "mine") { // Forcé explicitement (ex. page Archives, ou un utilisateur Finance+Responsable // basculé en espace Collaborateur) : strictement ses propres demandes, rien d'autre. query = query.Where(d => d.DemandeurId == currentUserId); } else if (!isFinance) { // Par défaut, sans le rôle FINANCE : ses propres demandes + celles de ses // collaborateurs directs (il en est le responsable) — sans ça, un responsable // qui n'a pas aussi le rôle FINANCE ne verrait jamais les demandes à valider. query = query.Where(d => d.DemandeurId == currentUserId || d.Demandeur.ResponsableId == currentUserId); } // FINANCE sans scope=mine : visibilité totale (lecture seule — ne valide plus // rien, cf. PATCH /demandes/{id}/statut). var demandes = await query .OrderByDescending(d => d.DateCreation) .ToListAsync(ct); var demandeIds = demandes.Select(d => d.Id).ToHashSet(); var justificatifs = await db.Set() .AsNoTracking() .Where(p => p.EntiteType == "DEMANDE") .ToListAsync(ct); var parDemande = justificatifs .Where(j => demandeIds.Contains(j.EntiteId)) .ToLookup(j => j.EntiteId); var factureParDemande = await (from f in db.Factures.AsNoTracking() join p in db.PiecesJointes.AsNoTracking() on f.Id equals p.EntiteId where f.DemandeAchatId.HasValue && demandeIds.Contains(f.DemandeAchatId.Value) && p.EntiteType == "FACTURE" orderby p.DateUpload descending select new { DemandeId = f.DemandeAchatId!.Value, p.SharepointUrl, p.NomFichier }) .GroupBy(x => x.DemandeId) .ToDictionaryAsync(g => g.Key, g => g.First(), ct); return demandes.Select(d => factureParDemande.TryGetValue(d.Id, out var facture) ? ToDto(d, parDemande[d.Id], currentUserId, true, facture.SharepointUrl, facture.NomFichier) : ToDto(d, parDemande[d.Id], currentUserId)); }); group.MapGet("/demandes/{id:int}", async (int id, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) => { var demande = await db.DemandesAchats.AsNoTracking().Include(d => d.Demandeur).Include(d => d.Valideur).FirstOrDefaultAsync(d => d.Id == id, ct); if (demande is null) return Results.NotFound(); var currentUserId = await currentUser.GetOrCreateCurrentUserIdAsync(ct); var isFinance = await currentUser.HasRoleAsync("FINANCE", db, ct); if (!isFinance && demande.DemandeurId != currentUserId) return Results.Forbid(); var justificatifs = await db.Set().AsNoTracking().Where(p => p.EntiteType == "DEMANDE" && p.EntiteId == id).ToListAsync(ct); var facture = await (from f in db.Factures.AsNoTracking() join p in db.PiecesJointes.AsNoTracking() on f.Id equals p.EntiteId where f.DemandeAchatId == id && p.EntiteType == "FACTURE" orderby p.DateUpload descending select new { p.SharepointUrl, p.NomFichier }).FirstOrDefaultAsync(ct); return Results.Ok(facture is null ? ToDto(demande, justificatifs, currentUserId) : ToDto(demande, justificatifs, currentUserId, true, facture.SharepointUrl, facture.NomFichier)); }); // Journal réel des transitions : il permet à Finance comme au demandeur de // suivre toutes les étapes déjà effectuées, avec leur auteur et commentaire. group.MapGet("/demandes/{id:int}/historique", async (int id, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) => { var demande = await db.DemandesAchats.AsNoTracking().FirstOrDefaultAsync(d => d.Id == id, ct); if (demande is null) return Results.NotFound(); var currentUserId = await currentUser.GetOrCreateCurrentUserIdAsync(ct); if (!await currentUser.HasRoleAsync("FINANCE", db, ct) && demande.DemandeurId != currentUserId) return Results.Forbid(); return Results.Ok(await db.HistoriqueActions.AsNoTracking() .Include(h => h.Utilisateur) .Where(h => h.EntiteType == "DEMANDE" && h.EntiteId == id) .OrderBy(h => h.DateAction) .Select(h => new DemandeWorkflowHistoriqueDto(h.Action, h.Commentaire, h.AncienStatut, h.NouveauStatut, $"{h.Utilisateur.Prenom} {h.Utilisateur.Nom}".Trim(), h.DateAction)) .ToListAsync(ct)); }); // Le demandeur peut déclarer la facture réellement reçue, quel que soit le // type d'achat. La facture et son PDF sont créés dans la même transaction. group.MapPost("/demandes/{id:int}/facture", async (int id, AjouterFactureDemandeDto input, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) => { var demande = await db.DemandesAchats.FirstOrDefaultAsync(d => d.Id == id, ct); if (demande is null) return Results.NotFound(); var currentUserId = await currentUser.GetOrCreateCurrentUserIdAsync(ct); if (demande.DemandeurId != currentUserId) return Results.Forbid(); if (demande.FournisseurId is null) return Results.BadRequest(new { error = "Un fournisseur référencé est requis pour ajouter une facture." }); if (string.IsNullOrWhiteSpace(input.Numero) || input.Montant <= 0 || string.IsNullOrWhiteSpace(input.DocumentNom) || string.IsNullOrWhiteSpace(input.DocumentSharepointUrl) || string.IsNullOrWhiteSpace(input.DocumentSharepointItemId) || input.DocumentTailleOctets <= 0) return Results.BadRequest(new { error = "Les informations et le document de facture sont requis." }); const decimal tauxTva = 20m; var montantHt = Math.Round(input.Montant / (1 + tauxTva / 100m), 2); var facture = new Facture { SourceType = "MANUEL", DemandeAchatId = demande.Id, FournisseurId = demande.FournisseurId.Value, NumeroFacture = input.Numero.Trim(), DateFacture = input.DateFacture, DateEcheance = input.DateEcheance, MontantHt = montantHt, TauxTva = tauxTva, MontantTva = input.Montant - montantHt, MontantTtc = input.Montant, ServiceId = demande.ServiceId, ModePaiementId = demande.ModePaiementId, Statut = "attente", Commentaire = $"Ajoutée par le demandeur depuis {demande.Reference}.", SaisieParId = currentUserId, DateCreation = DateTime.UtcNow, Acomptes = [new Acompte { Montant = input.Montant, DatePrevue = input.DateEcheance, Statut = "PREVU", SaisieParId = currentUserId, DateCreation = DateTime.UtcNow }], }; db.Factures.Add(facture); await db.SaveChangesAsync(ct); db.PiecesJointes.Add(new PiecesJointe { EntiteType = "FACTURE", EntiteId = facture.Id, TypePiece = "Facture fournisseur", NomFichier = input.DocumentNom.Trim(), SharepointUrl = input.DocumentSharepointUrl.Trim(), SharepointItemId = input.DocumentSharepointItemId.Trim(), TailleOctets = input.DocumentTailleOctets, UploaderId = currentUserId, DateUpload = DateTime.UtcNow, }); await db.SaveChangesAsync(ct); return Results.Created($"/api/factures/{facture.Id}", new { facture.Id }); }); group.MapPost("/demandes", async (DemandeAchatCreateDto input, SIFinanceDbContext db, CurrentUserService currentUser, NotificationEmailService notification, CancellationToken ct) => { if (string.IsNullOrWhiteSpace(input.Objet)) return Results.BadRequest(new { error = "L'objet est requis." }); if (input.MontantHt <= 0) return Results.BadRequest(new { error = "Le montant HT doit être supérieur à 0." }); if (string.IsNullOrWhiteSpace(input.FournisseurLibre) && input.FournisseurId is null) return Results.BadRequest(new { error = "Un fournisseur (référencé ou en saisie libre) est requis." }); if (!string.IsNullOrWhiteSpace(input.FournisseurLibre) && string.IsNullOrWhiteSpace(input.CategorieFournisseurLibre)) return Results.BadRequest(new { error = "La catégorie du fournisseur est requise en saisie libre." }); var montantTva = Math.Round(input.MontantHt * (input.TauxTva / 100m), 2); var montantTtc = Math.Round(input.MontantHt + montantTva, 2); var currentUserId = await currentUser.GetOrCreateCurrentUserIdAsync(ct); var fournisseurId = input.FournisseurId; // La saisie libre devient un véritable fournisseur référencé. On évite les // doublons en réutilisant une raison sociale existante (insensible à la // casse) et on conserve la catégorie saisie dans la demande pour le suivi. if (!string.IsNullOrWhiteSpace(input.FournisseurLibre)) { var nomFournisseur = input.FournisseurLibre.Trim(); var nomNormalise = nomFournisseur.ToUpper(); var fournisseurExistant = await db.Fournisseurs .FirstOrDefaultAsync(f => f.RaisonSociale.ToUpper() == nomNormalise, ct); if (fournisseurExistant is null) { fournisseurExistant = new Fournisseur { RaisonSociale = nomFournisseur, Statut = "ACTIF", DateCreation = DateTime.UtcNow, }; db.Fournisseurs.Add(fournisseurExistant); await db.SaveChangesAsync(ct); } fournisseurId = fournisseurExistant.Id; } var demande = new DemandesAchat { Reference = await GenererReferenceAsync(db, ct), ServiceId = input.ServiceId, Objet = input.Objet.Trim(), Description = string.IsNullOrWhiteSpace(input.Description) ? null : input.Description.Trim(), FournisseurId = fournisseurId, FournisseurLibre = null, CategorieFournisseurLibre = string.IsNullOrWhiteSpace(input.CategorieFournisseurLibre) ? null : input.CategorieFournisseurLibre.Trim(), TypeDepenseId = input.TypeDepenseId, MontantHt = input.MontantHt, TauxTva = input.TauxTva, MontantTva = montantTva, MontantTtc = montantTtc, DatePrevisionnelle = input.DatePrevisionnelle, ModePaiementId = input.ModePaiementId, Statut = "SOUMISE", DemandeurId = currentUserId, SeuilApplicable = CalculerSeuil(input.MontantHt), DateCreation = DateTime.UtcNow, DateSoumission = DateTime.UtcNow, }; db.DemandesAchats.Add(demande); try { await db.SaveChangesAsync(ct); } catch (DbUpdateException ex) { return Results.BadRequest(new { error = "Impossible d'enregistrer la demande — vérifiez le service, le fournisseur, le type de dépense et le mode de paiement.", detail = ex.InnerException?.Message ?? ex.Message, }); } var justificatifs = new List(); if (input.Justificatifs is { Count: > 0 }) { foreach (var j in input.Justificatifs) { if (string.IsNullOrWhiteSpace(j.Nom)) continue; justificatifs.Add(new PiecesJointe { EntiteType = "DEMANDE", EntiteId = demande.Id, TypePiece = string.IsNullOrWhiteSpace(j.Libelle) ? "Justificatif" : j.Libelle.Trim(), NomFichier = j.Nom.Trim(), SharepointUrl = j.SharepointUrl ?? "", SharepointItemId = j.SharepointItemId ?? "", TailleOctets = j.TailleOctets ?? 0, UploaderId = currentUserId, DateUpload = DateTime.UtcNow, }); } if (justificatifs.Count > 0) { db.Set().AddRange(justificatifs); await db.SaveChangesAsync(ct); } } // Notifie le responsable direct du demandeur par email — best-effort : un // échec d'envoi (mailbox introuvable, Graph indisponible...) ne doit jamais // faire échouer la création de la demande, déjà enregistrée à ce stade. var demandeurAvecResponsable = await db.Utilisateurs.AsNoTracking() .Include(u => u.Responsable) .FirstOrDefaultAsync(u => u.Id == currentUserId, ct); if (demandeurAvecResponsable?.Responsable is { Actif: true } responsable && !string.IsNullOrWhiteSpace(responsable.Email)) { var sujet = $"Nouvelle demande d'achat à valider — {demande.Reference}"; var corps = $"

Bonjour {responsable.Prenom},

" + $"

{demandeurAvecResponsable.Prenom} {demandeurAvecResponsable.Nom} a soumis une nouvelle demande d'achat :

" + "
    " + $"
  • Référence : {demande.Reference}
  • " + $"
  • Objet : {demande.Objet}
  • " + $"
  • Montant TTC : {demande.MontantTtc:N2} €
  • " + "
" + "

Merci de la consulter dans SI Finance pour la valider ou la refuser.

"; await notification.EnvoyerAsync(demandeurAvecResponsable.Email, responsable.Email, sujet, corps, ct); } // Le demandeur est toujours l'utilisateur courant ici : pas besoin de charger // la navigation Demandeur, ToDto affichera "Moi" via la comparaison d'id. return Results.Created($"/api/demandes/{demande.Id}", ToDto(demande, justificatifs, currentUserId)); }); group.MapPut("/demandes/{id:int}", async (int id, DemandeAchatCreateDto input, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) => { var currentUserId = await currentUser.GetOrCreateCurrentUserIdAsync(ct); var demande = await db.DemandesAchats.FirstOrDefaultAsync(d => d.Id == id, ct); if (demande is null) return Results.NotFound(); // Seul l'auteur de la demande peut la modifier, et seulement tant qu'elle n'a // pas encore été tranchée — les mêmes statuts que ceux où un responsable peut // encore la valider/refuser (cf. PATCH /demandes/{id}/statut). if (demande.DemandeurId != currentUserId) return Results.Forbid(); if (demande.Statut is not ("SOUMISE" or "EN_VALIDATION" or "COMPLEMENT_REQUIS")) return Results.BadRequest(new { error = "Cette demande ne peut plus être modifiée." }); if (string.IsNullOrWhiteSpace(input.Objet)) return Results.BadRequest(new { error = "L'objet est requis." }); if (input.MontantHt <= 0) return Results.BadRequest(new { error = "Le montant HT doit être supérieur à 0." }); if (string.IsNullOrWhiteSpace(input.FournisseurLibre) && input.FournisseurId is null) return Results.BadRequest(new { error = "Un fournisseur (référencé ou en saisie libre) est requis." }); if (!string.IsNullOrWhiteSpace(input.FournisseurLibre) && string.IsNullOrWhiteSpace(input.CategorieFournisseurLibre)) return Results.BadRequest(new { error = "La catégorie du fournisseur est requise en saisie libre." }); var montantTva = Math.Round(input.MontantHt * (input.TauxTva / 100m), 2); var montantTtc = Math.Round(input.MontantHt + montantTva, 2); demande.ServiceId = input.ServiceId; demande.Objet = input.Objet.Trim(); demande.Description = string.IsNullOrWhiteSpace(input.Description) ? null : input.Description.Trim(); demande.FournisseurId = input.FournisseurId; demande.FournisseurLibre = string.IsNullOrWhiteSpace(input.FournisseurLibre) ? null : input.FournisseurLibre.Trim(); demande.CategorieFournisseurLibre = string.IsNullOrWhiteSpace(input.CategorieFournisseurLibre) ? null : input.CategorieFournisseurLibre.Trim(); demande.TypeDepenseId = input.TypeDepenseId; demande.MontantHt = input.MontantHt; demande.TauxTva = input.TauxTva; demande.MontantTva = montantTva; demande.MontantTtc = montantTtc; demande.DatePrevisionnelle = input.DatePrevisionnelle; demande.ModePaiementId = input.ModePaiementId; demande.SeuilApplicable = CalculerSeuil(input.MontantHt); // Une modification remet la demande au début du circuit de validation — un // responsable ayant déjà commencé à l'examiner (EN_VALIDATION) ou demandé un // complément doit la revoir depuis le début, le contenu ayant changé. demande.Statut = "SOUMISE"; demande.ValideurId = null; demande.DateDerniereMaj = DateTime.UtcNow; try { await db.SaveChangesAsync(ct); } catch (DbUpdateException ex) { return Results.BadRequest(new { error = "Impossible d'enregistrer la modification — vérifiez le service, le fournisseur, le type de dépense et le mode de paiement.", detail = ex.InnerException?.Message ?? ex.Message, }); } // Le frontend ne pré-remplit jamais les justificatifs déjà existants dans le // formulaire d'édition (affichés à part, en lecture seule) — tout ce qui arrive // ici dans input.Justificatifs est donc forcément une pièce ajoutée pendant // cette modification, à insérer sans toucher aux justificatifs déjà en base. if (input.Justificatifs is { Count: > 0 }) { var nouveaux = input.Justificatifs .Where(j => !string.IsNullOrWhiteSpace(j.Nom)) .Select(j => new PiecesJointe { EntiteType = "DEMANDE", EntiteId = id, TypePiece = string.IsNullOrWhiteSpace(j.Libelle) ? "Justificatif" : j.Libelle.Trim(), NomFichier = j.Nom.Trim(), SharepointUrl = j.SharepointUrl ?? "", SharepointItemId = j.SharepointItemId ?? "", TailleOctets = j.TailleOctets ?? 0, UploaderId = currentUserId, DateUpload = DateTime.UtcNow, }) .ToList(); if (nouveaux.Count > 0) { db.Set().AddRange(nouveaux); await db.SaveChangesAsync(ct); } } var justificatifsActuels = await db.Set().AsNoTracking().Where(p => p.EntiteType == "DEMANDE" && p.EntiteId == id).ToListAsync(ct); return Results.Ok(ToDto(demande, justificatifsActuels, currentUserId)); }); group.MapPatch("/demandes/{id:int}/annuler", async (int id, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) => { var currentUserId = await currentUser.GetOrCreateCurrentUserIdAsync(ct); var demande = await db.DemandesAchats.FirstOrDefaultAsync(d => d.Id == id, ct); if (demande is null) return Results.NotFound(); if (demande.DemandeurId != currentUserId) return Results.Forbid(); if (demande.Statut is not ("SOUMISE" or "EN_VALIDATION" or "COMPLEMENT_REQUIS")) return Results.BadRequest(new { error = "Cette demande ne peut plus être annulée." }); demande.Statut = "ANNULEE"; demande.DateDerniereMaj = DateTime.UtcNow; await db.SaveChangesAsync(ct); return Results.NoContent(); }); group.MapPatch("/demandes/{id:int}/statut", async (int id, UpdateDemandeStatutDto input, SIFinanceDbContext db, CurrentUserService currentUser, NotificationEmailService notification, CancellationToken ct) => { if (input.Statut is not ("VALIDEE" or "REFUSEE")) return Results.BadRequest(new { error = "Statut invalide — seuls VALIDEE et REFUSEE sont autorisés depuis cet endpoint." }); var demande = await db.DemandesAchats.Include(d => d.Demandeur).Include(d => d.Valideur).FirstOrDefaultAsync(d => d.Id == id, ct); if (demande is null) return Results.NotFound(); var currentUserId = await currentUser.GetOrCreateCurrentUserIdAsync(ct); // Ce n'est plus le rôle FINANCE qui donne le droit de valider, mais le fait // d'être le responsable direct (manager Entra ID) du demandeur — Finance garde // seulement un accès en lecture à toutes les demandes (cf. GET /demandes). if (demande.Demandeur?.ResponsableId != currentUserId) return Results.Forbid(); if (demande.Statut is not ("SOUMISE" or "EN_VALIDATION" or "COMPLEMENT_REQUIS")) return Results.BadRequest(new { error = "Cette demande n'est plus en attente de validation." }); // Compatibilité avec le bouton historique « Valider » : une validation // manager ouvre désormais le nouveau circuit au lieu de clôturer la demande. demande.Statut = input.Statut == "VALIDEE" ? "VALIDEE_MANAGER" : input.Statut; demande.ValideurId = currentUserId; demande.DateDerniereMaj = DateTime.UtcNow; await db.SaveChangesAsync(ct); // Notifie le demandeur de la décision — best-effort, comme pour la notification // de création : un échec d'envoi ne doit pas faire échouer la décision déjà // enregistrée à ce stade. if (demande.Demandeur is { Actif: true } demandeur && !string.IsNullOrWhiteSpace(demandeur.Email)) { var responsable = await db.Utilisateurs.AsNoTracking().FirstOrDefaultAsync(u => u.Id == currentUserId, ct); if (responsable is not null && !string.IsNullOrWhiteSpace(responsable.Email)) { var valide = input.Statut == "VALIDEE"; var sujet = $"Demande d'achat {(valide ? "validée" : "refusée")} — {demande.Reference}"; var corps = $"

Bonjour {demandeur.Prenom},

" + $"

Votre demande d'achat {demande.Reference} — {demande.Objet} a été " + (valide ? "validée" : "refusée") + $" par {responsable.Prenom} {responsable.Nom}.

" + "

Consulte le détail dans SI Finance.

"; await notification.EnvoyerAsync(responsable.Email, demandeur.Email, sujet, corps, ct); } } var justificatifs = await db.Set().AsNoTracking().Where(p => p.EntiteType == "DEMANDE" && p.EntiteId == id).ToListAsync(ct); return Results.Ok(ToDto(demande, justificatifs, currentUserId)); }); // Circuit dédié aux demandes de matériel. Les actions sont volontairement // contrôlées côté serveur : un utilisateur ne peut pas forcer un statut en // contournant l'écran, et chaque passage est inscrit dans l'historique. group.MapPatch("/demandes/{id:int}/workflow", async (int id, WorkflowDemandeDto input, SIFinanceDbContext db, CurrentUserService currentUser, NotificationEmailService notification, CancellationToken ct) => { var demande = await db.DemandesAchats .Include(d => d.Demandeur) .Include(d => d.Valideur) .Include(d => d.TypeDepense) .FirstOrDefaultAsync(d => d.Id == id, ct); if (demande is null) return Results.NotFound(); var currentUserId = await currentUser.GetOrCreateCurrentUserIdAsync(ct); var action = input.Action.Trim().ToUpperInvariant(); var estDemandeur = demande.DemandeurId == currentUserId; var estManager = demande.Demandeur.ResponsableId == currentUserId; var estFinance = await currentUser.HasRoleAsync("FINANCE", db, ct); var estDemandeMateriel = demande.TypeDepense.EstMateriel; var ancienStatut = demande.Statut; string? nouveauStatut = action switch { "VALIDER_MANAGER" when estManager && demande.Statut is "SOUMISE" or "EN_VALIDATION" or "COMPLEMENT_REQUIS" => "VALIDEE_MANAGER", // Après l'accord du manager, seul le collaborateur à l'origine de la // demande la transmet au circuit matériel. Finance la consulte alors // en lecture seule et ne peut pas avancer cette étape. "TRANSMETTRE_MATERIEL" when estDemandeur && demande.Statut == "VALIDEE_MANAGER" && estDemandeMateriel => "EN_VALIDATION_MATERIEL", "VALIDER_MATERIEL" when (estDemandeur || estFinance) && demande.Statut == "EN_VALIDATION_MATERIEL" => "VALIDEE_MATERIEL", "TRANSMETTRE_FINANCE" when (estDemandeur || estFinance) && demande.Statut is "VALIDEE_MATERIEL" or "VALIDEE_MANAGER" => "EN_VALIDATION_FINANCE", "APPROUVER_FINANCE" when estFinance && demande.Statut == "EN_VALIDATION_FINANCE" => "APPROUVEE", "EMETTRE_COMMANDE" when estDemandeur && demande.Statut == "APPROUVEE" => "COMMANDE_EMISE", "CONFIRMER_COMMANDE" when estDemandeur && demande.Statut == "COMMANDE_EMISE" => "COMMANDE_CONFIRMEE", "MARQUER_EN_LIVRAISON" when estDemandeur && demande.Statut == "COMMANDE_CONFIRMEE" => "EN_ATTENTE_LIVRAISON", "DEMARRER_CONTROLE_RECEPTION" when estDemandeur && demande.Statut == "EN_ATTENTE_LIVRAISON" => "EN_CONTROLE_RECEPTION", "CONFIRMER_RECEPTION_CONFORME" when estDemandeur && demande.Statut == "EN_CONTROLE_RECEPTION" => "RECEPTION_CONFORME", "SIGNALER_NON_CONFORMITE" when estDemandeur && demande.Statut == "EN_CONTROLE_RECEPTION" => "VERIFIEE_NON_CONFORME", "VALIDER_RECEPTION" when estDemandeur && demande.Statut == "RECEPTION_CONFORME" => "RECEPTION_VALIDEE", "ENVOYER_RAPPROCHEMENT" when estDemandeur && demande.Statut == "RECEPTION_VALIDEE" => "FACTURE_EN_RAPPROCHEMENT", "CLOTURER" when estDemandeur && demande.Statut == "FACTURE_EN_RAPPROCHEMENT" => "CLOTURE", "DEMANDER_COMPLEMENT" when PeutTraiterEtape(demande.Statut, estManager, estFinance) => "COMPLEMENT_REQUIS", "REJETER" when PeutTraiterEtape(demande.Statut, estManager, estFinance) => "REFUSEE", _ => null, }; if (nouveauStatut is null) return Results.BadRequest(new { error = "Action non autorisée pour votre rôle ou pour le statut actuel de la demande." }); if ((action is "REJETER" or "DEMANDER_COMPLEMENT") && string.IsNullOrWhiteSpace(input.Commentaire)) return Results.BadRequest(new { error = "Un motif est requis pour un refus ou une demande de complément." }); if (action == "VALIDER_MATERIEL" && (string.IsNullOrWhiteSpace(input.DocumentNom) || string.IsNullOrWhiteSpace(input.DocumentSharepointUrl) || string.IsNullOrWhiteSpace(input.DocumentSharepointItemId) || input.DocumentTailleOctets is null)) return Results.BadRequest(new { error = "Un bon de commande ou document matériel est requis pour valider le matériel." }); demande.Statut = nouveauStatut; demande.ValideurId = currentUserId; demande.DateDerniereMaj = DateTime.UtcNow; if (action == "VALIDER_MATERIEL") { db.PiecesJointes.Add(new PiecesJointe { EntiteType = "DEMANDE", EntiteId = demande.Id, TypePiece = "Bon de commande matériel", NomFichier = input.DocumentNom!.Trim(), SharepointUrl = input.DocumentSharepointUrl!.Trim(), SharepointItemId = input.DocumentSharepointItemId!.Trim(), TailleOctets = input.DocumentTailleOctets!.Value, UploaderId = currentUserId, DateUpload = DateTime.UtcNow, }); } db.HistoriqueActions.Add(new HistoriqueAction { EntiteType = "DEMANDE", EntiteId = demande.Id, UtilisateurId = currentUserId, Action = action, Commentaire = string.IsNullOrWhiteSpace(input.Commentaire) ? null : input.Commentaire.Trim(), AncienStatut = ancienStatut, NouveauStatut = nouveauStatut, DateAction = DateTime.UtcNow, }); await db.SaveChangesAsync(ct); // Finance reste informée de l'avancement opérationnel sans avoir à porter // les rôles de matériel/réception. L'envoi est best-effort : la transition, // déjà historisée, ne doit jamais échouer à cause d'un email indisponible. var auteur = await db.Utilisateurs.AsNoTracking().FirstOrDefaultAsync(u => u.Id == currentUserId, ct); var finances = await (from u in db.Utilisateurs.AsNoTracking() join r in db.UtilisateurRoles.AsNoTracking() on u.AzureOid equals r.AzureOid where u.Actif && r.Role == "FINANCE" && !string.IsNullOrEmpty(u.Email) select u.Email).Distinct().ToListAsync(ct); if (auteur is not null && !string.IsNullOrEmpty(auteur.Email)) { foreach (var emailFinance in finances.Where(email => !string.Equals(email, auteur.Email, StringComparison.OrdinalIgnoreCase))) { var sujet = $"Mise à jour de commande — {demande.Reference}"; var corps = $"

La demande {demande.Reference} — {demande.Objet} est passée au statut {nouveauStatut}.

" + $"

Action déclarée par {auteur.Prenom} {auteur.Nom}.

" + (string.IsNullOrWhiteSpace(input.Commentaire) ? "" : $"

Commentaire : {input.Commentaire.Trim()}

"); await notification.EnvoyerAsync(auteur.Email, emailFinance, sujet, corps, ct); } } var justificatifs = await db.Set().AsNoTracking().Where(p => p.EntiteType == "DEMANDE" && p.EntiteId == id).ToListAsync(ct); return Results.Ok(ToDto(demande, justificatifs, currentUserId)); }); return app; } private static bool PeutTraiterEtape(string statut, bool estManager, bool estFinance) => statut switch { "SOUMISE" or "EN_VALIDATION" or "COMPLEMENT_REQUIS" => estManager, "VALIDEE_MANAGER" or "EN_VALIDATION_MATERIEL" or "VALIDEE_MATERIEL" => estFinance, "EN_VALIDATION_FINANCE" => estFinance, _ => false, }; private static DemandeAchatDto ToDto(DemandesAchat d, IEnumerable justificatifs, int currentUserId, bool factureAjoutee = false, string? factureUrl = null, string? factureNom = null) => new( d.Id, d.Reference, d.ServiceId, d.Objet, d.Description, d.FournisseurId, d.FournisseurLibre, d.CategorieFournisseurLibre, d.TypeDepenseId, d.MontantHt, d.TauxTva, d.MontantTva, d.MontantTtc, d.DatePrevisionnelle, d.ModePaiementId, d.Statut, d.DemandeurId, $"{d.Demandeur?.Prenom} {d.Demandeur?.Nom}".Trim(), d.ValideurId, d.ValideurId is null ? null : $"{d.Valideur?.Prenom} {d.Valideur?.Nom}".Trim(), d.SeuilApplicable, d.DateCreation, d.DateSoumission, d.DateDerniereMaj, justificatifs.Select(j => new DemandeJustificatifDto(j.Id, j.TypePiece, j.NomFichier, j.DateUpload, string.IsNullOrEmpty(j.SharepointUrl) ? null : j.SharepointUrl)).ToList(), d.Demandeur?.ResponsableId == currentUserId, factureAjoutee, factureUrl, factureNom); private static decimal CalculerSeuil(decimal montant) => montant switch { < 1000 => 1000, < 5000 => 5000, _ => 20000, }; private static async Task GenererReferenceAsync(SIFinanceDbContext db, CancellationToken ct) { var prefix = $"DA-{DateTime.UtcNow.Year}-"; var last = await db.DemandesAchats .Where(d => d.Reference.StartsWith(prefix)) .OrderByDescending(d => d.Reference) .Select(d => d.Reference) .FirstOrDefaultAsync(ct); var nextSeq = 1; if (last is not null && int.TryParse(last.AsSpan(prefix.Length), out var lastSeq)) nextSeq = lastSeq + 1; return $"{prefix}{nextSeq:D4}"; } }