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,21 @@
namespace SIFinance.Modules.Achats.Dtos;
public record BudgetRevisionDto(int Id, decimal MontantRevise, string? Commentaire, string RevisePar, DateTime DateRevision);
public record BudgetServiceDto(
int Id,
int ServiceId,
string Service,
int ExerciceAnnee,
decimal MontantInitial,
decimal? MontantRevise,
List<BudgetRevisionDto> Revisions);
// L'exercice n'est pas calendaire : ExerciceAnnee=2026 désigne la période du 01/09/2026
// au 31/08/2027 (cf. BudgetService.cs). Un budget initial est unique par service et par
// exercice — la création échoue s'il en existe déjà un (contrainte d'unicité en base).
public record CreateBudgetServiceDto(int ServiceId, int ExerciceAnnee, decimal MontantInitial);
// Chaque révision est ajoutée à l'historique, jamais écrasée — MontantRevise doit
// représenter le nouveau plafond total, pas un delta à ajouter.
public record CreateBudgetRevisionDto(decimal MontantRevise, string? Commentaire);
@@ -0,0 +1,57 @@
namespace SIFinance.Modules.Achats.Dtos;
public record ContratEcheanceDto(int Id, DateOnly Date, decimal Montant, string Statut, string? JustificatifNom);
public record ContratDocumentDto(int Id, DateOnly Date, string Tag, string Nom);
// Rapprochement facture ↔ contrat : permet de voir, sur la fiche contrat, quelles factures
// ont déjà été saisies contre lui (sans lien automatique aux échéances — le rapprochement
// avec une échéance précise reste une vérification humaine).
public record ContratFactureLieeDto(int Id, string Numero, decimal MontantTtc, DateOnly DateFacture, string Statut);
public record ContratDto(
int Id,
int FournisseurId,
string Fournisseur,
int? ServiceId,
string? Service,
int? CampusId,
string? Campus,
string? Reference,
string Objet,
DateOnly? DateSignature,
DateOnly DateDebut,
DateOnly DateFin,
DateOnly? DateResiliation,
string? MotifResiliation,
decimal MontantTotal,
string Periodicite,
string Statut,
List<ContratEcheanceDto> Echeances,
List<ContratDocumentDto> Documents,
List<ContratFactureLieeDto> FacturesLiees);
public record CreateContratEcheanceDto(DateOnly Date, decimal Montant);
public record CreateContratDto(
int FournisseurId,
int? ServiceId,
int? CampusId,
string Objet,
string? Reference,
DateOnly? DateSignature,
DateOnly DateDebut,
DateOnly DateFin,
decimal MontantTotal,
string Periodicite,
List<CreateContratEcheanceDto> Echeances);
public record ChangerStatutContratDto(string Statut, DateOnly? DateResiliation, string? MotifResiliation, string? Commentaire);
public record ContratHistoriqueDto(int Id, string? AncienStatut, string NouveauStatut, string? Commentaire, string Utilisateur, DateTime DateAction);
public record UpdateEcheanceDto(DateOnly Date, decimal Montant, string Statut, string? JustificatifNom);
public record AddDocumentDto(string Tag, string Nom, DateOnly? Date);
public record UpdateDocumentDto(string Tag, DateOnly Date);
@@ -0,0 +1,127 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using SIFinance.Shared.Entities;
namespace SIFinance.Modules.Achats.Endpoints;
// Endpoint agrégé du tableau de bord : tous les compteurs et sommes sont calculés
// par SQL Server (COUNT/SUM), et seules les quelques lignes réellement affichées
// sont transférées. Le temps de réponse ne dépend donc plus du volume en base.
public static class DashboardEndpoints
{
private static readonly string[] StatutsEnAttente = ["SOUMISE", "EN_VALIDATION", "COMPLEMENT_REQUIS"];
private static readonly string[] StatutsEngages = ["VALIDEE", "FACTUREE"];
public static IEndpointRouteBuilder MapDashboardEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/dashboard").WithTags("Dashboard").RequireAuthorization();
group.MapGet("", async (SIFinanceDbContext db, CancellationToken ct) =>
{
var today = DateTime.UtcNow.Date;
// DateEcheance est un DateOnly côté entité : les comparaisons doivent se
// faire avec un DateOnly pour être traduites en SQL.
var todayDate = DateOnly.FromDateTime(today);
// Bornes du trimestre courant et du précédent, calculées côté C# pour que
// la comparaison reste une simple comparaison de dates traduisible en SQL.
var trimCourant = (today.Month - 1) / 3;
var debutTrimestre = new DateTime(today.Year, trimCourant * 3 + 1, 1);
var debutTrimestrePrecedent = debutTrimestre.AddMonths(-3);
var demandes = db.DemandesAchats.AsNoTracking();
var factures = db.Factures.AsNoTracking();
// --- KPI demandes ---
var enAttente = demandes.Where(d => StatutsEnAttente.Contains(d.Statut));
var nbAValider = await enAttente.CountAsync(ct);
var montantAValider = await enAttente.SumAsync(d => (decimal?)d.MontantTtc, ct) ?? 0m;
var engagees = demandes.Where(d => StatutsEngages.Contains(d.Statut));
var engageCourant = await engagees
.Where(d => d.DateCreation >= debutTrimestre)
.SumAsync(d => (decimal?)d.MontantTtc, ct) ?? 0m;
var engagePrecedent = await engagees
.Where(d => d.DateCreation >= debutTrimestrePrecedent && d.DateCreation < debutTrimestre)
.SumAsync(d => (decimal?)d.MontantTtc, ct) ?? 0m;
// --- KPI factures ---
var aPayer = factures.Where(f => f.Statut == "validee");
var nbAPayer = await aPayer.CountAsync(ct);
var montantAPayer = await aPayer.SumAsync(f => (decimal?)f.MontantTtc, ct) ?? 0m;
var enCours = factures.Where(f => f.Statut != "payee");
var nbEchues = await enCours.CountAsync(f => f.DateEcheance < todayDate, ct);
var nbProches = await enCours.CountAsync(f => f.DateEcheance >= todayDate && f.DateEcheance <= todayDate.AddDays(7), ct);
// --- Listes affichées (volumes bornés par Take) ---
var demandesRecentes = await demandes
.OrderByDescending(d => d.DateCreation)
.Take(4)
.Select(d => new DashboardDemandeDto(d.Id, d.Reference, d.Objet, d.MontantTtc, d.Statut, d.TypeDepenseId))
.ToListAsync(ct);
var demandesAValiderApercu = await enAttente
.OrderByDescending(d => d.DateCreation)
.Take(1)
.Select(d => new DashboardDemandeDto(d.Id, d.Reference, d.Objet, d.MontantTtc, d.Statut, d.TypeDepenseId))
.ToListAsync(ct);
var echeances = await enCours
.OrderBy(f => f.DateEcheance)
.Take(5)
.Select(f => new DashboardEcheanceDto(f.Id, f.NumeroFacture, f.Fournisseur.RaisonSociale, f.MontantTtc, f.DateEcheance))
.ToListAsync(ct);
// Alertes : uniquement les premières lignes réellement affichées.
var facturesEchues = await enCours
.Where(f => f.DateEcheance < todayDate)
.OrderBy(f => f.DateEcheance)
.Take(5)
.Select(f => new DashboardEcheanceDto(f.Id, f.NumeroFacture, f.Fournisseur.RaisonSociale, f.MontantTtc, f.DateEcheance))
.ToListAsync(ct);
var avoirsDisponibles = await db.Avoirs
.AsNoTracking()
.Where(a => a.SoldeDisponible > 0)
.OrderByDescending(a => a.DateCreation)
.Take(5)
.Select(a => new DashboardAvoirDto(a.Id, a.NumeroAvoir, a.Fournisseur.RaisonSociale, a.SoldeDisponible))
.ToListAsync(ct);
return Results.Ok(new DashboardDto(
engageCourant, engagePrecedent,
nbAValider, montantAValider,
nbAPayer, montantAPayer,
nbProches, nbEchues,
demandesRecentes,
demandesAValiderApercu.FirstOrDefault(),
facturesEchues,
avoirsDisponibles,
echeances));
});
return app;
}
}
public record DashboardDemandeDto(int Id, string Reference, string Objet, decimal MontantTtc, string Statut, int TypeDepenseId);
public record DashboardEcheanceDto(int Id, string Numero, string Fournisseur, decimal MontantTtc, DateOnly DateEcheance);
public record DashboardAvoirDto(int Id, string Numero, string Fournisseur, decimal SoldeDisponible);
public record DashboardDto(
decimal EngageCourant,
decimal EngagePrecedent,
int NbAValider,
decimal MontantAValider,
int NbAPayer,
decimal MontantAPayer,
int NbEcheancesProches,
int NbFacturesEchues,
IReadOnlyList<DashboardDemandeDto> DemandesRecentes,
DashboardDemandeDto? PremiereAValider,
IReadOnlyList<DashboardEcheanceDto> FacturesEchues,
IReadOnlyList<DashboardAvoirDto> AvoirsDisponibles,
IReadOnlyList<DashboardEcheanceDto> Echeances);
@@ -0,0 +1,41 @@
namespace SIFinance.Modules.Achats.Dtos;
public record CreateJustificatifDto(
string Libelle, string Nom,
string? SharepointUrl, string? SharepointItemId, long? TailleOctets);
public record DemandeAchatCreateDto(
int ServiceId,
string Objet,
string? Description,
int? FournisseurId,
string? FournisseurLibre,
string? CategorieFournisseurLibre,
int TypeDepenseId,
decimal MontantHt,
decimal TauxTva,
DateOnly DatePrevisionnelle,
int ModePaiementId,
List<CreateJustificatifDto>? Justificatifs);
public record UpdateDemandeStatutDto(string Statut);
// Le workflow matériel utilise des actions, plutôt qu'un statut libre envoyé par le
// navigateur : l'API conserve ainsi le contrôle des transitions et des rôles autorisés.
public record WorkflowDemandeDto(
string Action,
string? Commentaire,
string? DocumentNom = null,
string? DocumentSharepointUrl = null,
string? DocumentSharepointItemId = null,
long? DocumentTailleOctets = null);
public record AjouterFactureDemandeDto(
string Numero,
decimal Montant,
DateOnly DateFacture,
DateOnly DateEcheance,
string DocumentNom,
string DocumentSharepointUrl,
string DocumentSharepointItemId,
long DocumentTailleOctets);
@@ -0,0 +1,35 @@
namespace SIFinance.Modules.Achats.Dtos;
public record DemandeJustificatifDto(int Id, string Libelle, string Nom, DateTime? DateUpload, string? SharepointUrl);
public record DemandeWorkflowHistoriqueDto(string Action, string? Commentaire, string? AncienStatut, string? NouveauStatut, string Auteur, DateTime? DateAction);
public record DemandeAchatDto(
int Id,
string Reference,
int ServiceId,
string Objet,
string? Description,
int? FournisseurId,
string? FournisseurLibre,
string? CategorieFournisseurLibre,
int TypeDepenseId,
decimal MontantHt,
decimal TauxTva,
decimal MontantTva,
decimal MontantTtc,
DateOnly DatePrevisionnelle,
int ModePaiementId,
string Statut,
int DemandeurId,
string DemandeurNom,
int? ValideurId,
string? ValideurNom,
decimal SeuilApplicable,
DateTime? DateCreation,
DateTime? DateSoumission,
DateTime? DateDerniereMaj,
List<DemandeJustificatifDto> Justificatifs,
bool PeutValider,
bool FactureAjoutee = false,
string? FactureUrl = null,
string? FactureNom = null);
@@ -0,0 +1,17 @@
namespace SIFinance.Modules.Achats.Dtos;
public record ServiceDto(int Id, string Libelle);
public record TypeDepenseDto(int Id, string Libelle);
public record ModePaiementDto(int Id, string Libelle, bool NecessiteXml);
public record FournisseurDto(int Id, string RaisonSociale, string? Siret, string Statut);
public record FournisseurDetailDto(
int Id, string RaisonSociale, string? Siret, string? Email, string? Telephone,
string? Adresse, string Statut, DateTime? DateCreation);
public record FournisseurSaveDto(
string RaisonSociale, string? Siret, string? Email, string? Telephone,
string? Adresse, string Statut);
@@ -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());
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\SIFinance.Shared\SIFinance.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>