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