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,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());
}