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,14 @@
namespace SIFinance.Modules.Finance.Dtos;
public record AvoirDto(
int Id,
string Numero,
int FournisseurId,
string Fournisseur,
decimal Montant,
decimal SoldeDisponible,
DateOnly Date,
int? FactureLieeId,
string? FactureLieeNumero);
public record CreateAvoirDto(string Numero, int FournisseurId, decimal Montant, DateOnly Date);
@@ -0,0 +1,53 @@
namespace SIFinance.Modules.Finance.Dtos;
public record FactureEcheanceDto(int Id, DateOnly Date, decimal Montant, string Statut);
public record FactureJustificatifDto(string Nom, string Url);
public record FactureDto(
int Id,
string Numero,
int FournisseurId,
string Fournisseur,
decimal MontantHt,
decimal TauxTva,
decimal MontantTva,
decimal MontantTtc,
DateOnly DateFacture,
DateOnly DateEcheance,
string Statut,
int? DemandeAchatId,
int? AvoirLieId,
string? AvoirLieNumero,
decimal? AvoirLieMontant,
string? Commentaire,
bool EstDoublonSignale,
int? ContratId,
string? ContratObjet,
List<FactureEcheanceDto> Echeances,
List<FactureJustificatifDto>? Justificatifs = null);
// Le formulaire ne saisit qu'un montant TTC global (pas de ventilation HT/TVA à la création) —
// on applique un taux de TVA par défaut de 20% et on recalcule HT/TVA à partir du TTC saisi.
public record CreateFactureDto(string Numero, int FournisseurId, decimal Montant, DateOnly DateFacture, DateOnly DateEcheance, int? DemandeAchatId, string? Commentaire, int? ContratId);
public record UpdateFactureCommentaireDto(string? Commentaire);
public record UpdateFactureStatutDto(string Statut);
public record CreateEcheanceDto(DateOnly Date, decimal Montant);
public record UpdateEcheanceDto(DateOnly Date, decimal Montant);
// Résultat de l'analyse d'un PDF Factur-X/ZUGFeRD uploadé (réception de facture
// électronique) — Conforme=false si le PDF n'a pas de XML EN16931 embarqué, auquel cas
// les autres champs sont null et la saisie reste manuelle.
public record FacturXAnalyseDto(
bool Conforme,
string? Numero,
string? FournisseurNom,
decimal? MontantHt,
decimal? MontantTva,
decimal? MontantTtc,
DateOnly? DateFacture,
DateOnly? DateEcheance,
string? Erreur);
@@ -0,0 +1,22 @@
namespace SIFinance.Modules.Finance.Dtos;
// Un "paiement" côté UI peut regrouper plusieurs factures d'un même fournisseur.
// La table fin.paiements est 1 ligne par facture : on regroupe par Reference pour l'affichage.
public record PaiementGroupeDto(
string Reference,
int FournisseurId,
string Fournisseur,
List<int> FactureIds,
List<string> FactureNumeros,
decimal MontantTotal,
int ModePaiementId,
string ModePaiement,
DateOnly DatePaiement,
string Statut,
bool Rapproche);
public record FactureDisponibleDto(int Id, int FournisseurId, string Fournisseur, string Numero, decimal Montant, DateOnly DateFacture);
public record CreatePaiementDto(List<int> FactureIds, int ModePaiementId, DateOnly DatePaiement, string Reference, string Statut, bool Rapproche);
public record UpdatePaiementDto(int ModePaiementId, DateOnly DatePaiement, string Reference, string Statut, bool Rapproche);
@@ -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;
}
}
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\SIFinance.Shared\SIFinance.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="ZUGFeRD-csharp" Version="18.0.0" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>