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