Premier commit

This commit is contained in:
2026-09-11 14:55:41 +02:00
commit 8ed04aae89
134 changed files with 36627 additions and 0 deletions
@@ -0,0 +1,78 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using SIFinance.Modules.Finance.Dtos;
using SIFinance.Shared;
using SIFinance.Shared.Entities;
namespace SIFinance.Modules.Finance.Endpoints;
public static class AvoirsEndpoints
{
public static IEndpointRouteBuilder MapAvoirsEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/avoirs").WithTags("Avoirs").RequireAuthorization();
group.MapGet("", async (SIFinanceDbContext db, CancellationToken ct) =>
// Projection directe en DTO : EF ne récupère que les colonnes utiles et
// ne remonte qu'une seule affectation, au lieu de charger toute la collection.
await db.Avoirs
.AsNoTracking()
.OrderByDescending(a => a.DateCreation)
.Select(a => new AvoirDto(
a.Id,
a.NumeroAvoir,
a.FournisseurId,
a.Fournisseur.RaisonSociale,
a.MontantInitial,
a.SoldeDisponible,
a.DateAvoir,
a.AffectationsAvoirs.Select(af => (int?)af.FactureId).FirstOrDefault(),
a.AffectationsAvoirs.Select(af => af.Facture.NumeroFacture).FirstOrDefault()))
.ToListAsync(ct));
group.MapPost("", async (CreateAvoirDto input, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(input.Numero))
return Results.BadRequest(new { error = "Le numéro d'avoir est requis." });
if (input.Montant <= 0)
return Results.BadRequest(new { error = "Le montant doit être supérieur à 0." });
var avoir = new Avoir
{
FournisseurId = input.FournisseurId,
NumeroAvoir = input.Numero.Trim(),
DateAvoir = input.Date,
MontantInitial = input.Montant,
SoldeDisponible = input.Montant,
SaisieParId = await currentUser.GetOrCreateCurrentUserIdAsync(ct),
DateCreation = DateTime.UtcNow,
};
db.Avoirs.Add(avoir);
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex)
{
return Results.BadRequest(new { error = "Impossible d'enregistrer l'avoir — vérifiez le fournisseur.", detail = ex.InnerException?.Message ?? ex.Message });
}
await db.Entry(avoir).Reference(a => a.Fournisseur).LoadAsync(ct);
return Results.Created($"/api/avoirs/{avoir.Id}", ToDto(avoir));
});
return app;
}
private static AvoirDto ToDto(Avoir a)
{
var affectation = a.AffectationsAvoirs.FirstOrDefault();
return new AvoirDto(
a.Id, a.NumeroAvoir, a.FournisseurId, a.Fournisseur.RaisonSociale,
a.MontantInitial, a.SoldeDisponible, a.DateAvoir,
affectation?.FactureId, affectation?.Facture.NumeroFacture);
}
}
@@ -0,0 +1,322 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using s2industries.ZUGFeRD;
using SIFinance.Modules.Finance.Dtos;
using SIFinance.Shared;
using SIFinance.Shared.Entities;
namespace SIFinance.Modules.Finance.Endpoints;
public static class FacturesEndpoints
{
public static IEndpointRouteBuilder MapFacturesEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/factures").WithTags("Factures").RequireAuthorization();
// Réception de factures électroniques (Factur-X/ZUGFeRD, échéance réglementaire du
// 1er septembre) : le PDF fourni par le fournisseur embarque un XML EN16931 — on le
// détecte et on en extrait les données pour pré-remplir/vérifier le formulaire de
// saisie, plutôt que de forcer une ressaisie manuelle sujette aux erreurs.
group.MapPost("/analyser-facturx", async (HttpRequest request, CancellationToken ct) =>
{
if (!request.HasFormContentType)
return Results.BadRequest(new { error = "Contenu multipart attendu." });
var form = await request.ReadFormAsync(ct);
var file = form.Files.GetFile("file");
if (file is null || file.Length == 0)
return Results.BadRequest(new { error = "Fichier manquant." });
try
{
await using var stream = file.OpenReadStream();
var desc = InvoiceDescriptor.Load(stream);
var echeance = desc.GetTradePaymentTerms().FirstOrDefault()?.DueDate;
return Results.Ok(new FacturXAnalyseDto(
Conforme: true,
Numero: desc.InvoiceNo,
FournisseurNom: desc.Seller?.Name,
MontantHt: desc.TaxBasisAmount ?? desc.LineTotalAmount,
MontantTva: desc.TaxTotalAmount,
MontantTtc: desc.GrandTotalAmount,
DateFacture: desc.InvoiceDate.HasValue ? DateOnly.FromDateTime(desc.InvoiceDate.Value) : null,
DateEcheance: echeance.HasValue ? DateOnly.FromDateTime(echeance.Value) : null,
Erreur: null));
}
catch (Exception)
{
// Cas normal pour un PDF "papier scanné" sans XML embarqué — pas une erreur
// serveur, juste une facture non électronique à saisir manuellement.
return Results.Ok(new FacturXAnalyseDto(
Conforme: false, Numero: null, FournisseurNom: null,
MontantHt: null, MontantTva: null, MontantTtc: null,
DateFacture: null, DateEcheance: null,
Erreur: "Ce PDF ne contient pas de facture électronique Factur-X/ZUGFeRD reconnue — saisie manuelle requise."));
}
});
group.MapGet("", async (SIFinanceDbContext db, CancellationToken ct) =>
// Projection directe en DTO : SQL Server ne renvoie que les colonnes utilisées,
// et une seule affectation d'avoir est remontée (OUTER APPLY TOP 1) au lieu de
// charger toute la collection avec l'entité Avoir complète.
{
var factures = await db.Factures
.AsNoTracking()
.OrderByDescending(f => f.DateCreation)
.Select(f => new FactureDto(
f.Id, f.NumeroFacture, f.FournisseurId, f.Fournisseur.RaisonSociale,
f.MontantHt, f.TauxTva, f.MontantTva, f.MontantTtc,
f.DateFacture, f.DateEcheance, f.Statut, f.DemandeAchatId,
f.AffectationsAvoirs.Select(a => (int?)a.AvoirId).FirstOrDefault(),
f.AffectationsAvoirs.Select(a => a.Avoir.NumeroAvoir).FirstOrDefault(),
f.AffectationsAvoirs.Select(a => (decimal?)a.MontantAffecte).FirstOrDefault(),
f.Commentaire,
f.EstDoublonSignale ?? false,
f.ContratId,
f.Contrat != null ? f.Contrat.Objet : null,
// ToEcheanceDto n'est pas utilisable ici : EF ne sait pas traduire
// un appel de méthode C# en SQL, le constructeur est donc écrit inline.
f.Acomptes.Select(a => new FactureEcheanceDto(a.Id, a.DatePrevue, a.Montant, a.Statut)).ToList()))
.ToListAsync(ct);
var factureIds = factures.Select(f => f.Id).ToArray();
var justificatifs = await db.PiecesJointes.AsNoTracking()
.Where(p => p.EntiteType == "FACTURE" && factureIds.Contains(p.EntiteId))
.OrderBy(p => p.DateUpload)
.Select(p => new { p.EntiteId, p.NomFichier, p.SharepointUrl })
.ToListAsync(ct);
var parFacture = justificatifs.ToLookup(p => p.EntiteId);
return factures.Select(f => f with
{
Justificatifs = parFacture[f.Id]
.Where(p => !string.IsNullOrWhiteSpace(p.SharepointUrl))
.Select(p => new FactureJustificatifDto(p.NomFichier, p.SharepointUrl))
.ToList(),
});
});
group.MapPost("", async (CreateFactureDto input, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(input.Numero))
return Results.BadRequest(new { error = "Le numéro de facture est requis." });
if (input.Montant <= 0)
return Results.BadRequest(new { error = "Le montant doit être supérieur à 0." });
if (input.ContratId.HasValue)
{
var contratExiste = await db.ContratsFournisseurs
.AnyAsync(c => c.Id == input.ContratId.Value && c.FournisseurId == input.FournisseurId, ct);
if (!contratExiste)
return Results.BadRequest(new { error = "Le contrat sélectionné n'appartient pas à ce fournisseur." });
}
var montantTtc = input.Montant;
var montantHt = Math.Round(montantTtc / (1 + FinanceDefaults.DefaultTauxTva / 100m), 2);
var montantTva = Math.Round(montantTtc - montantHt, 2);
var currentUserId = await currentUser.GetOrCreateCurrentUserIdAsync(ct);
// Doublon potentiel : même fournisseur + même montant TTC dans une fenêtre de 30
// jours autour de la date de facture, indépendamment du numéro (souvent mal
// ressaisi) — signalement non bloquant, l'utilisateur peut le lever ensuite.
var fenetreDebut = input.DateFacture.AddDays(-30);
var fenetreFin = input.DateFacture.AddDays(30);
var doublonPotentiel = await db.Factures.AnyAsync(f =>
f.FournisseurId == input.FournisseurId &&
f.MontantTtc == montantTtc &&
f.DateFacture >= fenetreDebut && f.DateFacture <= fenetreFin, ct);
var facture = new Facture
{
SourceType = "MANUEL",
DemandeAchatId = input.DemandeAchatId,
ContratId = input.ContratId,
FournisseurId = input.FournisseurId,
NumeroFacture = input.Numero.Trim(),
DateFacture = input.DateFacture,
DateEcheance = input.DateEcheance,
MontantHt = montantHt,
TauxTva = FinanceDefaults.DefaultTauxTva,
MontantTva = montantTva,
MontantTtc = montantTtc,
ServiceId = FinanceDefaults.DefaultServiceId,
ModePaiementId = FinanceDefaults.DefaultModePaiementId,
Statut = "attente",
Commentaire = string.IsNullOrWhiteSpace(input.Commentaire) ? null : input.Commentaire.Trim(),
EstDoublonSignale = doublonPotentiel,
SaisieParId = currentUserId,
DateCreation = DateTime.UtcNow,
Acomptes = [new Acompte
{
Montant = montantTtc,
DatePrevue = input.DateEcheance,
Statut = "PREVU",
SaisieParId = currentUserId,
DateCreation = DateTime.UtcNow,
}],
};
db.Factures.Add(facture);
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex)
{
return Results.BadRequest(new { error = "Impossible d'enregistrer la facture — vérifiez le fournisseur.", detail = ex.InnerException?.Message ?? ex.Message });
}
await db.Entry(facture).Reference(f => f.Fournisseur).LoadAsync(ct);
if (facture.ContratId.HasValue)
await db.Entry(facture).Reference(f => f.Contrat).LoadAsync(ct);
return Results.Created($"/api/factures/{facture.Id}", ToDto(facture));
});
group.MapPatch("/{id:int}/statut", async (int id, UpdateFactureStatutDto input, SIFinanceDbContext db, CancellationToken ct) =>
{
var facture = await db.Factures.FindAsync([id], ct);
if (facture is null) return Results.NotFound();
facture.Statut = input.Statut;
facture.DateDerniereMaj = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
group.MapPatch("/{id:int}/commentaire", async (int id, UpdateFactureCommentaireDto input, SIFinanceDbContext db, CancellationToken ct) =>
{
var facture = await db.Factures.FindAsync([id], ct);
if (facture is null) return Results.NotFound();
facture.Commentaire = string.IsNullOrWhiteSpace(input.Commentaire) ? null : input.Commentaire.Trim();
facture.DateDerniereMaj = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
// Permet à l'utilisateur de lever un signalement de doublon potentiel une fois
// vérifié qu'il s'agit bien de deux factures distinctes (cf. demande métier :
// avertissement désactivable, pas un blocage).
group.MapPatch("/{id:int}/doublon", async (int id, SIFinanceDbContext db, CancellationToken ct) =>
{
var facture = await db.Factures.FindAsync([id], ct);
if (facture is null) return Results.NotFound();
facture.EstDoublonSignale = false;
facture.DateDerniereMaj = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
group.MapDelete("/{id:int}", async (int id, SIFinanceDbContext db, CancellationToken ct) =>
{
var facture = await db.Factures
.Include(f => f.Acomptes)
.Include(f => f.AffectationsAvoirs).ThenInclude(a => a.Avoir)
.FirstOrDefaultAsync(f => f.Id == id, ct);
if (facture is null) return Results.NotFound();
foreach (var affectation in facture.AffectationsAvoirs)
{
affectation.Avoir.SoldeDisponible += affectation.MontantAffecte;
}
db.AffectationsAvoirs.RemoveRange(facture.AffectationsAvoirs);
db.Acomptes.RemoveRange(facture.Acomptes);
db.Factures.Remove(facture);
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
group.MapPost("/{id:int}/echeances", async (int id, CreateEcheanceDto input, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
var facture = await db.Factures.FindAsync([id], ct);
if (facture is null) return Results.NotFound();
var echeance = new Acompte
{
FactureId = id,
Montant = input.Montant,
DatePrevue = input.Date,
Statut = "PREVU",
SaisieParId = await currentUser.GetOrCreateCurrentUserIdAsync(ct),
DateCreation = DateTime.UtcNow,
};
db.Acomptes.Add(echeance);
await db.SaveChangesAsync(ct);
return Results.Created($"/api/factures/{id}/echeances/{echeance.Id}", ToEcheanceDto(echeance));
});
group.MapPut("/{id:int}/echeances/{echeanceId:int}", async (int id, int echeanceId, UpdateEcheanceDto input, SIFinanceDbContext db, CancellationToken ct) =>
{
var echeance = await db.Acomptes.FirstOrDefaultAsync(a => a.Id == echeanceId && a.FactureId == id, ct);
if (echeance is null) return Results.NotFound();
echeance.DatePrevue = input.Date;
echeance.Montant = input.Montant;
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.Acomptes.FirstOrDefaultAsync(a => a.Id == echeanceId && a.FactureId == id, ct);
if (echeance is null) return Results.NotFound();
db.Acomptes.Remove(echeance);
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
group.MapPost("/{id:int}/avoir/{avoirId:int}", async (int id, int avoirId, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
var facture = await db.Factures.FindAsync([id], ct);
var avoir = await db.Avoirs.FindAsync([avoirId], ct);
if (facture is null || avoir is null) return Results.NotFound();
if (avoir.FournisseurId != facture.FournisseurId)
return Results.BadRequest(new { error = "L'avoir doit être du même fournisseur que la facture." });
if (avoir.SoldeDisponible <= 0)
return Results.BadRequest(new { error = "Cet avoir n'a plus de solde disponible." });
var montantAffecte = Math.Min(avoir.SoldeDisponible, facture.MontantTtc);
db.AffectationsAvoirs.Add(new AffectationsAvoir
{
AvoirId = avoirId,
FactureId = id,
MontantAffecte = montantAffecte,
AffecteParId = await currentUser.GetOrCreateCurrentUserIdAsync(ct),
DateAffectation = DateTime.UtcNow,
});
avoir.SoldeDisponible -= montantAffecte;
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
group.MapDelete("/{id:int}/avoir/{avoirId:int}", async (int id, int avoirId, SIFinanceDbContext db, CancellationToken ct) =>
{
var affectation = await db.AffectationsAvoirs.Include(a => a.Avoir)
.FirstOrDefaultAsync(a => a.FactureId == id && a.AvoirId == avoirId, ct);
if (affectation is null) return Results.NotFound();
affectation.Avoir.SoldeDisponible += affectation.MontantAffecte;
db.AffectationsAvoirs.Remove(affectation);
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
return app;
}
private static FactureEcheanceDto ToEcheanceDto(Acompte a) =>
new(a.Id, a.DatePrevue, a.Montant, a.Statut);
// Conservé pour le MapPost, qui construit le DTO à partir de l'entité fraîchement
// créée (sans affectation d'avoir à ce stade).
private static FactureDto ToDto(Facture f)
{
var affectation = f.AffectationsAvoirs.FirstOrDefault();
return new FactureDto(
f.Id, f.NumeroFacture, f.FournisseurId, f.Fournisseur.RaisonSociale,
f.MontantHt, f.TauxTva, f.MontantTva, f.MontantTtc,
f.DateFacture, f.DateEcheance, f.Statut, f.DemandeAchatId,
affectation?.AvoirId, affectation?.Avoir.NumeroAvoir, affectation?.MontantAffecte,
f.Commentaire, f.EstDoublonSignale ?? false,
f.ContratId, f.Contrat?.Objet,
f.Acomptes.Select(ToEcheanceDto).ToList());
}
}
@@ -0,0 +1,10 @@
namespace SIFinance.Modules.Finance.Endpoints;
internal static class FinanceDefaults
{
// Le formulaire de saisie facture ne demande pas encore de service/mode de paiement —
// valeurs par défaut en attendant l'ajout de ces champs côté UI.
public const int DefaultServiceId = 1;
public const int DefaultModePaiementId = 1;
public const decimal DefaultTauxTva = 20m;
}
@@ -0,0 +1,140 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using SIFinance.Modules.Finance.Dtos;
using SIFinance.Shared;
using SIFinance.Shared.Entities;
namespace SIFinance.Modules.Finance.Endpoints;
public static class PaiementsEndpoints
{
public static IEndpointRouteBuilder MapPaiementsEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/paiements").WithTags("Paiements").RequireAuthorization();
group.MapGet("/factures-disponibles", async (SIFinanceDbContext db, CancellationToken ct) =>
{
var facturesPayeesIds = db.Paiements.Select(p => p.FactureId);
return await db.Factures
.AsNoTracking()
.Include(f => f.Fournisseur)
.Where(f => f.Statut == "validee" && !facturesPayeesIds.Contains(f.Id))
.OrderBy(f => f.DateFacture)
.Select(f => new FactureDisponibleDto(f.Id, f.FournisseurId, f.Fournisseur.RaisonSociale, f.NumeroFacture, f.MontantTtc, f.DateFacture))
.ToListAsync(ct);
});
group.MapGet("", async (SIFinanceDbContext db, CancellationToken ct) =>
{
var paiements = await db.Paiements
.AsNoTracking()
.Include(p => p.Facture).ThenInclude(f => f.Fournisseur)
.Include(p => p.ModeReglement)
.ToListAsync(ct);
return paiements
.GroupBy(p => p.ReferencePaiement ?? $"PAI-{p.Id}")
.Select(g =>
{
var first = g.First();
return new PaiementGroupeDto(
g.Key, first.Facture.FournisseurId, first.Facture.Fournisseur.RaisonSociale,
g.Select(p => p.FactureId).ToList(),
g.Select(p => p.Facture.NumeroFacture).ToList(),
g.Sum(p => p.MontantPaye),
first.ModeReglementId, first.ModeReglement.Libelle,
first.DatePaiement, first.Statut, first.Rapproche);
})
.OrderByDescending(p => p.DatePaiement);
});
group.MapPost("", async (CreatePaiementDto input, SIFinanceDbContext db, CurrentUserService currentUser, CancellationToken ct) =>
{
if (input.FactureIds.Count == 0)
return Results.BadRequest(new { error = "Sélectionner au moins une facture." });
if (string.IsNullOrWhiteSpace(input.Reference))
return Results.BadRequest(new { error = "La référence de paiement est requise." });
var factures = await db.Factures.Where(f => input.FactureIds.Contains(f.Id)).ToListAsync(ct);
if (factures.Count != input.FactureIds.Count)
return Results.BadRequest(new { error = "Une ou plusieurs factures sont introuvables." });
if (factures.Select(f => f.FournisseurId).Distinct().Count() > 1)
return Results.BadRequest(new { error = "Un paiement regroupe uniquement des factures d'un même fournisseur." });
var dejaCouvertes = await db.Paiements.AnyAsync(p => input.FactureIds.Contains(p.FactureId), ct);
if (dejaCouvertes)
return Results.BadRequest(new { error = "Une ou plusieurs factures sont déjà couvertes par un paiement." });
var referenceExistante = await db.Paiements.AnyAsync(p => p.ReferencePaiement == input.Reference, ct);
if (referenceExistante)
return Results.BadRequest(new { error = "Cette référence de paiement existe déjà." });
var currentUserId = await currentUser.GetOrCreateCurrentUserIdAsync(ct);
var lignes = factures.Select(f => new Paiement
{
FactureId = f.Id,
MontantPaye = f.MontantTtc,
DatePaiement = input.DatePaiement,
ModeReglementId = input.ModePaiementId,
ReferencePaiement = input.Reference,
EnregistreParId = currentUserId,
DateSaisie = DateTime.UtcNow,
Statut = input.Statut,
Rapproche = input.Rapproche,
}).ToList();
db.Paiements.AddRange(lignes);
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex)
{
return Results.BadRequest(new { error = "Impossible d'enregistrer le paiement.", detail = ex.InnerException?.Message ?? ex.Message });
}
return Results.Created($"/api/paiements/{Uri.EscapeDataString(input.Reference)}", (object?)null);
});
group.MapPatch("/{reference}", async (string reference, UpdatePaiementDto input, SIFinanceDbContext db, CancellationToken ct) =>
{
var lignes = await db.Paiements.Where(p => p.ReferencePaiement == reference).ToListAsync(ct);
if (lignes.Count == 0) return Results.NotFound();
foreach (var p in lignes)
{
p.ModeReglementId = input.ModePaiementId;
p.DatePaiement = input.DatePaiement;
p.ReferencePaiement = input.Reference;
p.Statut = input.Statut;
p.Rapproche = input.Rapproche;
}
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
group.MapPatch("/{reference}/rapproche", async (string reference, SIFinanceDbContext db, CancellationToken ct) =>
{
var lignes = await db.Paiements.Where(p => p.ReferencePaiement == reference).ToListAsync(ct);
if (lignes.Count == 0) return Results.NotFound();
var nouvelEtat = !lignes[0].Rapproche;
foreach (var p in lignes) p.Rapproche = nouvelEtat;
await db.SaveChangesAsync(ct);
return Results.Ok(new { rapproche = nouvelEtat });
});
group.MapDelete("/{reference}", async (string reference, SIFinanceDbContext db, CancellationToken ct) =>
{
var lignes = await db.Paiements.Where(p => p.ReferencePaiement == reference).ToListAsync(ct);
if (lignes.Count == 0) return Results.NotFound();
db.Paiements.RemoveRange(lignes);
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
return app;
}
}