Premier commit

This commit is contained in:
2026-09-11 14:55:41 +02:00
commit 8ed04aae89
134 changed files with 36627 additions and 0 deletions
@@ -0,0 +1,709 @@
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<PiecesJointe>()
.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<PiecesJointe>().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<PiecesJointe>();
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<PiecesJointe>().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 = $"<p>Bonjour {responsable.Prenom},</p>" +
$"<p><strong>{demandeurAvecResponsable.Prenom} {demandeurAvecResponsable.Nom}</strong> a soumis une nouvelle demande d'achat :</p>" +
"<ul>" +
$"<li>Référence : {demande.Reference}</li>" +
$"<li>Objet : {demande.Objet}</li>" +
$"<li>Montant TTC : {demande.MontantTtc:N2} €</li>" +
"</ul>" +
"<p>Merci de la consulter dans SI Finance pour la valider ou la refuser.</p>";
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<PiecesJointe>().AddRange(nouveaux);
await db.SaveChangesAsync(ct);
}
}
var justificatifsActuels = await db.Set<PiecesJointe>().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 = $"<p>Bonjour {demandeur.Prenom},</p>" +
$"<p>Votre demande d'achat <strong>{demande.Reference} — {demande.Objet}</strong> a été " +
(valide
? "<strong style=\"color:#2e7d32\">validée</strong>"
: "<strong style=\"color:#c62828\">refusée</strong>") +
$" par {responsable.Prenom} {responsable.Nom}.</p>" +
"<p>Consulte le détail dans SI Finance.</p>";
await notification.EnvoyerAsync(responsable.Email, demandeur.Email, sujet, corps, ct);
}
}
var justificatifs = await db.Set<PiecesJointe>().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 = $"<p>La demande <strong>{demande.Reference} — {demande.Objet}</strong> est passée au statut <strong>{nouveauStatut}</strong>.</p>" +
$"<p>Action déclarée par {auteur.Prenom} {auteur.Nom}.</p>" +
(string.IsNullOrWhiteSpace(input.Commentaire) ? "" : $"<p>Commentaire : {input.Commentaire.Trim()}</p>");
await notification.EnvoyerAsync(auteur.Email, emailFinance, sujet, corps, ct);
}
}
var justificatifs = await db.Set<PiecesJointe>().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<PiecesJointe> 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<string> 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}";
}
}
@@ -0,0 +1,110 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using SIFinance.Modules.Achats.Dtos;
using SIFinance.Shared;
using SIFinance.Shared.Entities;
namespace SIFinance.Modules.Achats.Endpoints;
public static class BudgetsEndpoints
{
public static IEndpointRouteBuilder MapBudgetsEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/budgets").WithTags("Budgets").RequireAuthorization();
// exercice optionnel : sans filtre, remonte tous les exercices connus (utile pour
// un historique) — Pilotage passera l'exercice courant pour ne charger que l'utile.
group.MapGet("", async (int? exercice, SIFinanceDbContext db, CancellationToken ct) =>
{
var query = db.BudgetsServices
.AsNoTracking()
.Include(b => b.Service)
.Include(b => b.Revisions).ThenInclude(r => r.ReviseParUtilisateur)
.AsQueryable();
if (exercice.HasValue)
query = query.Where(b => b.ExerciceAnnee == exercice.Value);
var budgets = await query.OrderBy(b => b.Service.Libelle).ToListAsync(ct);
return budgets.Select(ToDto);
});
group.MapPost("", async (CreateBudgetServiceDto input, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
if (!await currentUser.HasRoleAsync("FINANCE", db, ct))
return Results.Forbid();
if (input.MontantInitial <= 0)
return Results.BadRequest(new { error = "Le montant initial doit être supérieur à 0." });
var existeDeja = await db.BudgetsServices
.AnyAsync(b => b.ServiceId == input.ServiceId && b.ExerciceAnnee == input.ExerciceAnnee, ct);
if (existeDeja)
return Results.BadRequest(new { error = "Un budget existe déjà pour ce service sur cet exercice." });
var budget = new BudgetService
{
ServiceId = input.ServiceId,
ExerciceAnnee = input.ExerciceAnnee,
MontantInitial = input.MontantInitial,
SaisieParId = await currentUser.GetOrCreateCurrentUserIdAsync(ct),
DateCreation = DateTime.UtcNow,
};
db.BudgetsServices.Add(budget);
await db.SaveChangesAsync(ct);
await db.Entry(budget).Reference(b => b.Service).LoadAsync(ct);
return Results.Created($"/api/budgets/{budget.Id}", ToDto(budget));
});
// Réservé à FINANCE : seul le responsable Finance qui gère le budget peut
// l'augmenter en cours d'exercice. Chaque appel ajoute une ligne à l'historique
// (budgets_service_revisions) plutôt que d'écraser une valeur unique.
group.MapPost("/{id:int}/revisions", async (int id, CreateBudgetRevisionDto input, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
if (!await currentUser.HasRoleAsync("FINANCE", db, ct))
return Results.Forbid();
var budget = await db.BudgetsServices
.Include(b => b.Service)
.Include(b => b.Revisions).ThenInclude(r => r.ReviseParUtilisateur)
.FirstOrDefaultAsync(b => b.Id == id, ct);
if (budget is null) return Results.NotFound();
if (input.MontantRevise <= 0)
return Results.BadRequest(new { error = "Le montant révisé doit être supérieur à 0." });
var revision = new BudgetServiceRevision
{
BudgetServiceId = id,
MontantRevise = input.MontantRevise,
Commentaire = string.IsNullOrWhiteSpace(input.Commentaire) ? null : input.Commentaire.Trim(),
RevisePar = await currentUser.GetOrCreateCurrentUserIdAsync(ct),
DateRevision = DateTime.UtcNow,
};
db.BudgetServiceRevisions.Add(revision);
budget.DateDerniereMaj = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
await db.Entry(revision).Reference(r => r.ReviseParUtilisateur).LoadAsync(ct);
budget.Revisions.Add(revision);
return Results.Created($"/api/budgets/{id}/revisions/{revision.Id}", ToDto(budget));
});
return app;
}
private static BudgetServiceDto ToDto(BudgetService b)
{
var revisions = b.Revisions
.OrderByDescending(r => r.DateRevision)
.Select(r => new BudgetRevisionDto(
r.Id, r.MontantRevise, r.Commentaire,
$"{r.ReviseParUtilisateur.Prenom} {r.ReviseParUtilisateur.Nom}".Trim(),
r.DateRevision))
.ToList();
return new BudgetServiceDto(
b.Id, b.ServiceId, b.Service.Libelle, b.ExerciceAnnee,
b.MontantInitial, revisions.Count > 0 ? revisions[0].MontantRevise : null,
revisions);
}
}
@@ -0,0 +1,389 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SIFinance.Shared;
using SIFinance.Modules.Achats.Dtos;
using SIFinance.Shared.Entities;
namespace SIFinance.Modules.Achats.Endpoints;
public record ContratAlerteDto(int ContratId, string Fournisseur, string Objet, string Type, string Severite, string Message, DateTime DateCalcul);
public static class ContratsEndpoints
{
private const int SeuilRenouvellementJours = 60;
public static IEndpointRouteBuilder MapContratsEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/contrats").WithTags("Contrats").RequireAuthorization();
// Les contrats sont gérés par les responsables de service. Finance conserve
// une visibilité transverse en lecture seule, sans droit de modification.
group.AddEndpointFilter(async (context, next) =>
{
var db = context.HttpContext.RequestServices.GetRequiredService<SIFinanceDbContext>();
var currentUser = context.HttpContext.RequestServices.GetRequiredService<CurrentUserService>();
var isFinance = await currentUser.HasRoleAsync("FINANCE", db, context.HttpContext.RequestAborted);
var userId = await currentUser.GetOrCreateCurrentUserIdAsync(context.HttpContext.RequestAborted);
var hasResponsableRole = await currentUser.HasRoleAsync("RESPONSABLE", db, context.HttpContext.RequestAborted);
var aDesCollaborateurs = await db.Utilisateurs.AsNoTracking()
.AnyAsync(u => u.Actif && u.ResponsableId == userId, context.HttpContext.RequestAborted);
var estResponsable = aDesCollaborateurs || (hasResponsableRole && await db.Utilisateurs.AsNoTracking()
.AnyAsync(u => u.Actif && u.Id == userId && u.ServiceId != null, context.HttpContext.RequestAborted));
if (!isFinance && !estResponsable) return Results.Forbid();
// Un profil Finance seul consulte uniquement. Un responsable qui possède aussi
// ce rôle conserve la gestion de ses propres contrats de service.
if (isFinance && !estResponsable && !HttpMethods.IsGet(context.HttpContext.Request.Method)) return Results.Forbid();
if (!HttpMethods.IsGet(context.HttpContext.Request.Method)
&& context.HttpContext.Request.RouteValues.TryGetValue("id", out var routeId)
&& int.TryParse(routeId?.ToString(), out var contratId))
{
var serviceId = await db.ContratsFournisseurs.AsNoTracking()
.Where(c => c.Id == contratId).Select(c => c.ServiceId).FirstOrDefaultAsync(context.HttpContext.RequestAborted);
var peutGerer = serviceId.HasValue && await db.Utilisateurs.AsNoTracking()
.AnyAsync(u => u.Actif && u.ServiceId == serviceId && (u.ResponsableId == userId || (hasResponsableRole && u.Id == userId)), context.HttpContext.RequestAborted);
if (!peutGerer) return Results.Forbid();
}
return await next(context);
});
group.MapGet("", async (SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
var isFinance = await currentUser.HasRoleAsync("FINANCE", db, ct);
var currentUserId = await currentUser.GetOrCreateCurrentUserIdAsync(ct);
var query = db.ContratsFournisseurs.AsNoTracking().AsQueryable();
if (!isFinance)
{
var servicesGerés = db.Utilisateurs.Where(u => u.ResponsableId == currentUserId).Select(u => u.ServiceId);
query = query.Where(c => c.ServiceId.HasValue && servicesGerés.Contains(c.ServiceId));
}
if (!isFinance && await currentUser.HasRoleAsync("RESPONSABLE", db, ct))
{
var monService = db.Utilisateurs.Where(u => u.Id == currentUserId && u.Actif).Select(u => u.ServiceId);
query = db.ContratsFournisseurs.AsNoTracking()
.Where(c => c.ServiceId.HasValue && monService.Contains(c.ServiceId));
}
var contrats = await query
// Plusieurs Include de collections dans une seule requête produisent un
// produit cartésien (lignes dupliquées) : on découpe en requêtes séparées.
.AsSplitQuery()
.Include(c => c.Fournisseur)
.Include(c => c.Service)
.Include(c => c.Campus)
.Include(c => c.Echeances).ThenInclude(e => e.Justificatif)
.Include(c => c.Factures)
.OrderByDescending(c => c.DateCreation)
.ToListAsync(ct);
// Filtrer par EntiteType suffit (cf. même correctif sur /api/demandes) :
// un filtre .Contains() sur une liste d'ids en mémoire forcerait EF à générer
// un paramètre SQL par contrat, ce qui grossit avec la table et empêche
// SQL Server de réutiliser un plan de requête en cache.
var documents = await db.Set<PiecesJointe>()
.AsNoTracking()
.Where(p => p.EntiteType == "CONTRAT")
.ToListAsync(ct);
// Lookup groupé par contrat : accès O(1) au lieu d'un Where qui re-parcourt
// toute la liste des documents pour chaque contrat.
var documentsParContrat = documents.ToLookup(d => d.EntiteId);
return contrats.Select(c => ToDto(c, documentsParContrat[c.Id]));
});
// Alertes calculées à la volée (pas de table dédiée, pas de job planifié) :
// même filtrage par service que MapGet("") ci-dessus, réappliqué ici plutôt
// que factorisé, pour ne pas modifier la signature de la requête existante.
group.MapGet("/alertes", async (SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
var isFinance = await currentUser.HasRoleAsync("FINANCE", db, ct);
var currentUserId = await currentUser.GetOrCreateCurrentUserIdAsync(ct);
var query = db.ContratsFournisseurs.AsNoTracking().AsQueryable();
if (!isFinance)
{
var servicesGerés = db.Utilisateurs.Where(u => u.ResponsableId == currentUserId).Select(u => u.ServiceId);
query = query.Where(c => c.ServiceId.HasValue && servicesGerés.Contains(c.ServiceId));
}
if (!isFinance && await currentUser.HasRoleAsync("RESPONSABLE", db, ct))
{
var monService = db.Utilisateurs.Where(u => u.Id == currentUserId && u.Actif).Select(u => u.ServiceId);
query = db.ContratsFournisseurs.AsNoTracking()
.Where(c => c.ServiceId.HasValue && monService.Contains(c.ServiceId));
}
var contrats = await query
.Where(c => c.Statut != "RESILIE" && c.Statut != "EXPIRE" && c.Statut != "ARCHIVE")
.Include(c => c.Fournisseur)
.Include(c => c.Echeances)
.ToListAsync(ct);
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var alertes = new List<ContratAlerteDto>();
var maintenant = DateTime.UtcNow;
foreach (var c in contrats)
{
var joursAvantFin = c.DateFin.DayNumber - today.DayNumber;
if (joursAvantFin <= SeuilRenouvellementJours)
{
var expire = joursAvantFin < 0;
alertes.Add(new ContratAlerteDto(
c.Id, c.Fournisseur.RaisonSociale, c.Objet,
expire ? "CONTRAT_EXPIRE" : "CONTRAT_A_RENOUVELER",
expire ? "danger" : joursAvantFin <= 30 ? "warning" : "info",
expire
? $"Expiré depuis le {c.DateFin:dd/MM/yyyy}"
: $"Échéance dans {joursAvantFin} j ({c.DateFin:dd/MM/yyyy})",
maintenant));
}
var echeancesEnRetard = c.Echeances.Where(e => e.Statut == "A_VENIR" && e.DateEcheance < today).ToList();
if (echeancesEnRetard.Count > 0)
{
var montant = echeancesEnRetard.Sum(e => e.Montant);
alertes.Add(new ContratAlerteDto(
c.Id, c.Fournisseur.RaisonSociale, c.Objet,
"ECHEANCE_CONTRAT_RETARD", "danger",
$"{echeancesEnRetard.Count} échéance(s) en retard · {montant:N0} €",
maintenant));
}
}
return Results.Ok(alertes.OrderBy(a => a.Severite == "danger" ? 0 : a.Severite == "warning" ? 1 : 2));
});
group.MapPost("", async (CreateContratDto input, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(input.Objet))
return Results.BadRequest(new { error = "L'objet du contrat est requis." });
if (input.ServiceId is null || input.CampusId is null)
return Results.BadRequest(new { error = "Le service responsable et le campus sont requis." });
if (input.Echeances.Count == 0)
return Results.BadRequest(new { error = "Renseigner au moins une échéance." });
var saisieParId = await currentUser.GetOrCreateCurrentUserIdAsync(ct);
var hasResponsableRole = await currentUser.HasRoleAsync("RESPONSABLE", db, ct);
var estResponsableDuService = await db.Utilisateurs.AsNoTracking()
.AnyAsync(u => u.Actif && u.ServiceId == input.ServiceId &&
(u.ResponsableId == saisieParId || (hasResponsableRole && u.Id == saisieParId)), ct);
if (!estResponsableDuService) return Results.Forbid();
var contrat = new ContratFournisseur
{
FournisseurId = input.FournisseurId,
ServiceId = input.ServiceId,
CampusId = input.CampusId,
Objet = input.Objet.Trim(),
Reference = string.IsNullOrWhiteSpace(input.Reference) ? null : input.Reference.Trim(),
DateSignature = input.DateSignature,
DateDebut = input.DateDebut,
DateFin = input.DateFin,
MontantTotal = input.MontantTotal,
Periodicite = input.Periodicite,
Statut = "ACTIF",
SaisieParId = saisieParId,
DateCreation = DateTime.UtcNow,
Echeances = input.Echeances.Select(e => new ContratEcheance
{
DateEcheance = e.Date,
Montant = e.Montant,
Statut = "A_VENIR",
DateCreation = DateTime.UtcNow,
}).ToList(),
};
db.ContratsFournisseurs.Add(contrat);
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex)
{
return Results.BadRequest(new { error = "Impossible d'enregistrer le contrat — vérifiez le fournisseur.", detail = ex.InnerException?.Message ?? ex.Message });
}
db.ContratsFournisseursHistorique.Add(new ContratFournisseurHistorique
{
ContratId = contrat.Id,
NouveauStatut = "ACTIF",
Commentaire = "Contrat cr\u00e9\u00e9",
UtilisateurId = contrat.SaisieParId,
DateAction = DateTime.UtcNow,
});
await db.SaveChangesAsync(ct);
await db.Entry(contrat).Reference(c => c.Fournisseur).LoadAsync(ct);
return Results.Created($"/api/contrats/{contrat.Id}", ToDto(contrat, []));
});
group.MapGet("/{id:int}/historique", async (int id, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
var contrat = await db.ContratsFournisseurs.AsNoTracking().FirstOrDefaultAsync(c => c.Id == id, ct);
if (contrat is null) return Results.NotFound();
var isFinance = await currentUser.HasRoleAsync("FINANCE", db, ct);
if (!isFinance)
{
var userId = await currentUser.GetOrCreateCurrentUserIdAsync(ct);
var hasResponsableRole = await currentUser.HasRoleAsync("RESPONSABLE", db, ct);
var estResponsableDuService = await db.Utilisateurs.AsNoTracking()
.AnyAsync(u => u.Actif && u.ServiceId == contrat.ServiceId &&
(u.ResponsableId == userId || (hasResponsableRole && u.Id == userId)), ct);
if (!estResponsableDuService) return Results.Forbid();
}
var historique = await db.ContratsFournisseursHistorique.AsNoTracking()
.Where(h => h.ContratId == id).Include(h => h.Utilisateur)
.OrderBy(h => h.DateAction).Select(h => new ContratHistoriqueDto(h.Id, h.AncienStatut, h.NouveauStatut, h.Commentaire,
h.Utilisateur.Prenom + " " + h.Utilisateur.Nom, h.DateAction)).ToListAsync(ct);
return Results.Ok(historique);
});
group.MapPatch("/{id:int}/statut", async (int id, ChangerStatutContratDto input, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
var contrat = await db.ContratsFournisseurs.FirstOrDefaultAsync(c => c.Id == id, ct);
if (contrat is null) return Results.NotFound();
var userId = await currentUser.GetOrCreateCurrentUserIdAsync(ct);
var hasResponsableRole = await currentUser.HasRoleAsync("RESPONSABLE", db, ct);
var estResponsableDuService = await db.Utilisateurs.AsNoTracking()
.AnyAsync(u => u.Actif && u.ServiceId == contrat.ServiceId &&
(u.ResponsableId == userId || (hasResponsableRole && u.Id == userId)), ct);
if (!estResponsableDuService) return Results.Forbid();
var statut = input.Statut.Trim().ToUpperInvariant();
if (statut is not ("BROUILLON" or "ACTIF" or "A_RENOUVELER" or "SUSPENDU" or "RESILIE" or "EXPIRE" or "ARCHIVE"))
return Results.BadRequest(new { error = "Statut de contrat invalide." });
if (statut == "RESILIE" && (input.DateResiliation is null || string.IsNullOrWhiteSpace(input.MotifResiliation)))
return Results.BadRequest(new { error = "La date et le motif de résiliation sont requis." });
var ancienStatut = contrat.Statut;
contrat.Statut = statut;
contrat.DateResiliation = statut == "RESILIE" ? input.DateResiliation : null;
contrat.MotifResiliation = statut == "RESILIE" ? input.MotifResiliation!.Trim() : null;
contrat.DateDerniereMaj = DateTime.UtcNow;
db.ContratsFournisseursHistorique.Add(new ContratFournisseurHistorique
{
ContratId = contrat.Id,
AncienStatut = ancienStatut,
NouveauStatut = statut,
Commentaire = string.IsNullOrWhiteSpace(input.Commentaire) ? null : input.Commentaire.Trim(),
UtilisateurId = await currentUser.GetOrCreateCurrentUserIdAsync(ct),
DateAction = DateTime.UtcNow,
});
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
group.MapPost("/{id:int}/echeances", async (int id, CreateContratEcheanceDto input, SIFinanceDbContext db, CancellationToken ct) =>
{
var contrat = await db.ContratsFournisseurs.FindAsync([id], ct);
if (contrat is null) return Results.NotFound();
var echeance = new ContratEcheance { ContratId = id, DateEcheance = input.Date, Montant = input.Montant, Statut = "A_VENIR", DateCreation = DateTime.UtcNow };
db.ContratEcheances.Add(echeance);
await db.SaveChangesAsync(ct);
return Results.Created($"/api/contrats/{id}/echeances/{echeance.Id}", new ContratEcheanceDto(echeance.Id, echeance.DateEcheance, echeance.Montant, echeance.Statut, null));
});
group.MapPut("/{id:int}/echeances/{echeanceId:int}", async (int id, int echeanceId, UpdateEcheanceDto input, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
var echeance = await db.ContratEcheances.Include(e => e.Justificatif)
.FirstOrDefaultAsync(e => e.Id == echeanceId && e.ContratId == id, ct);
if (echeance is null) return Results.NotFound();
echeance.DateEcheance = input.Date;
echeance.Montant = input.Montant;
echeance.Statut = input.Statut;
if (!string.IsNullOrWhiteSpace(input.JustificatifNom))
{
if (echeance.Justificatif is not null)
{
echeance.Justificatif.NomFichier = input.JustificatifNom;
}
else
{
// Pas encore de stockage SharePoint branché : on garde une trace du nom de
// fichier, l'URL/ID SharePoint seront renseignés quand l'upload sera en place.
var justificatif = new PiecesJointe
{
EntiteType = "CONTRAT_ECHEANCE",
EntiteId = echeance.Id,
TypePiece = "Justificatif",
NomFichier = input.JustificatifNom,
SharepointUrl = "",
SharepointItemId = "",
TailleOctets = 0,
UploaderId = await currentUser.GetOrCreateCurrentUserIdAsync(ct),
DateUpload = DateTime.UtcNow,
};
db.Set<PiecesJointe>().Add(justificatif);
echeance.Justificatif = justificatif;
}
}
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.ContratEcheances.FirstOrDefaultAsync(e => e.Id == echeanceId && e.ContratId == id, ct);
if (echeance is null) return Results.NotFound();
db.ContratEcheances.Remove(echeance);
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
group.MapPost("/{id:int}/documents", async (int id, AddDocumentDto input, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
var contrat = await db.ContratsFournisseurs.FindAsync([id], ct);
if (contrat is null) return Results.NotFound();
var document = new PiecesJointe
{
EntiteType = "CONTRAT",
EntiteId = id,
TypePiece = input.Tag,
NomFichier = input.Nom,
SharepointUrl = "",
SharepointItemId = "",
TailleOctets = 0,
UploaderId = await currentUser.GetOrCreateCurrentUserIdAsync(ct),
DateUpload = input.Date.HasValue ? input.Date.Value.ToDateTime(TimeOnly.MinValue) : DateTime.UtcNow,
};
db.Set<PiecesJointe>().Add(document);
await db.SaveChangesAsync(ct);
return Results.Created($"/api/contrats/{id}/documents/{document.Id}", ToDocumentDto(document));
});
group.MapPut("/{id:int}/documents/{documentId:int}", async (int id, int documentId, UpdateDocumentDto input, SIFinanceDbContext db, CancellationToken ct) =>
{
var document = await db.Set<PiecesJointe>().FirstOrDefaultAsync(d => d.Id == documentId && d.EntiteId == id && d.EntiteType == "CONTRAT", ct);
if (document is null) return Results.NotFound();
document.TypePiece = input.Tag;
document.DateUpload = input.Date.ToDateTime(TimeOnly.MinValue);
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
group.MapDelete("/{id:int}/documents/{documentId:int}", async (int id, int documentId, SIFinanceDbContext db, CancellationToken ct) =>
{
var document = await db.Set<PiecesJointe>().FirstOrDefaultAsync(d => d.Id == documentId && d.EntiteId == id && d.EntiteType == "CONTRAT", ct);
if (document is null) return Results.NotFound();
db.Set<PiecesJointe>().Remove(document);
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
return app;
}
private static ContratDocumentDto ToDocumentDto(PiecesJointe p) =>
new(p.Id, DateOnly.FromDateTime(p.DateUpload ?? DateTime.UtcNow), p.TypePiece, p.NomFichier);
private static ContratDto ToDto(ContratFournisseur c, IEnumerable<PiecesJointe> documents) => new(
c.Id, c.FournisseurId, c.Fournisseur.RaisonSociale, c.ServiceId, c.Service?.Libelle, c.CampusId, c.Campus?.Libelle, c.Reference, c.Objet,
c.DateSignature, c.DateDebut, c.DateFin, c.DateResiliation, c.MotifResiliation, c.MontantTotal, c.Periodicite, c.Statut,
c.Echeances.Select(e => new ContratEcheanceDto(e.Id, e.DateEcheance, e.Montant, e.Statut, e.Justificatif?.NomFichier)).ToList(),
documents.Select(ToDocumentDto).ToList(),
c.Factures.Select(f => new ContratFactureLieeDto(f.Id, f.NumeroFacture, f.MontantTtc, f.DateFacture, f.Statut)).ToList());
}