Premier commit
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user