389 lines
21 KiB
C#
389 lines
21 KiB
C#
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());
|
|
} |