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(); var currentUser = context.HttpContext.RequestServices.GetRequiredService(); 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() .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(); 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().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().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().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().FirstOrDefaultAsync(d => d.Id == documentId && d.EntiteId == id && d.EntiteType == "CONTRAT", ct); if (document is null) return Results.NotFound(); db.Set().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 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()); }