Fusionner la gestion des prets
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -62,9 +62,13 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
|
||||
|
||||
var livres = await requete.ToListAsync(ct);
|
||||
|
||||
var statuts = await StatutsAsync(livres.Select(l => l.Id).ToList(), utilisateur, ct);
|
||||
var ids = livres.Select(l => l.Id).ToList();
|
||||
var statuts = await StatutsAsync(ids, utilisateur, ct);
|
||||
var prets = await PretsOuvertsAsync(ids, ct);
|
||||
|
||||
return livres.Select(l => Projeter(l, statuts.GetValueOrDefault(l.Id))).ToList();
|
||||
return livres
|
||||
.Select(l => Projeter(l, statuts.GetValueOrDefault(l.Id), prets.GetValueOrDefault(l.Id)))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<LivreDto?> ObtenirAsync(int id, string? utilisateur, CancellationToken ct = default)
|
||||
@@ -80,7 +84,8 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
|
||||
}
|
||||
|
||||
var statuts = await StatutsAsync([id], utilisateur, ct);
|
||||
return Projeter(livre, statuts.GetValueOrDefault(id));
|
||||
var prets = await PretsOuvertsAsync([id], ct);
|
||||
return Projeter(livre, statuts.GetValueOrDefault(id), prets.GetValueOrDefault(id));
|
||||
}
|
||||
|
||||
public async Task<ResultatEcriture> CreerAsync(
|
||||
@@ -198,6 +203,32 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
|
||||
.ToDictionaryAsync(s => s.LivreId, s => (Statut?)s.Statut, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prêt en cours de chacun des livres demandés, quand il y en a un.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Aucun paramètre « utilisateur » ici, volontairement</b> : contrairement au statut de
|
||||
/// lecture, un livre absent l'est pour tout le foyer. La question « où est ce livre ? » a une
|
||||
/// seule réponse, la même pour tout le monde.
|
||||
/// <para>
|
||||
/// L'index unique partiel sur <c>Prets</c> garantit qu'il n'y a qu'un prêt ouvert par livre :
|
||||
/// ce dictionnaire ne peut donc pas avoir à départager deux candidats.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<Dictionary<int, Pret>> PretsOuvertsAsync(
|
||||
IReadOnlyList<int> livreIds, CancellationToken ct)
|
||||
{
|
||||
if (livreIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return await db.Prets
|
||||
.AsNoTracking()
|
||||
.Where(p => p.DateRetour == null && livreIds.Contains(p.LivreId))
|
||||
.ToDictionaryAsync(p => p.LivreId, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pose, met à jour ou retire le statut de lecture d'une personne pour un livre.
|
||||
/// </summary>
|
||||
@@ -303,7 +334,7 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
|
||||
private static string? Vide(string? valeur) =>
|
||||
string.IsNullOrWhiteSpace(valeur) ? null : valeur.Trim();
|
||||
|
||||
private static LivreDto Projeter(Livre livre, Statut? statut) => new()
|
||||
private static LivreDto Projeter(Livre livre, Statut? statut, Pret? pretOuvert = null) => new()
|
||||
{
|
||||
Id = livre.Id,
|
||||
Isbn = livre.Isbn,
|
||||
@@ -319,5 +350,7 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
|
||||
CoverUrl = livre.CoverUrl,
|
||||
DateAjout = livre.DateAjout,
|
||||
AjoutePar = livre.AjoutePar,
|
||||
PreteA = pretOuvert?.Emprunteur,
|
||||
PreteDepuis = pretOuvert?.DatePret,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
using MaBibli.Api.Data;
|
||||
using MaBibli.Shared.Dtos;
|
||||
using MaBibli.Shared.Entites;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MaBibli.Api.Services.Prets;
|
||||
|
||||
/// <summary>Issue d'une écriture de prêt : soit le prêt, soit un message destiné à l'utilisateur.</summary>
|
||||
public readonly record struct ResultatPret(PretDto? Pret, string? Erreur)
|
||||
{
|
||||
public static ResultatPret Ok(PretDto pret) => new(pret, null);
|
||||
|
||||
public static ResultatPret Invalide(string message) => new(null, message);
|
||||
|
||||
/// <summary>Ni prêt ni erreur : la ressource demandée n'existe pas.</summary>
|
||||
public static readonly ResultatPret Introuvable = new(null, null);
|
||||
|
||||
public bool EstIntrouvable => Pret is null && Erreur is null;
|
||||
}
|
||||
|
||||
public interface IServicePrets
|
||||
{
|
||||
/// <summary>Tous les livres actuellement sortis, du plus ancien prêt au plus récent.</summary>
|
||||
Task<IReadOnlyList<PretDto>> EnCoursAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Historique complet d'un livre, du plus récent au plus ancien. <c>null</c> si le livre n'existe pas.</summary>
|
||||
Task<IReadOnlyList<PretDto>?> HistoriqueAsync(int livreId, CancellationToken ct = default);
|
||||
|
||||
Task<ResultatPret> PreterAsync(int livreId, EnregistrementPret saisie, CancellationToken ct = default);
|
||||
|
||||
Task<ResultatPret> RendreAsync(int pretId, RetourPret retour, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gestion des prêts. Un prêt est <b>ouvert</b> tant que sa date de retour est nulle ; le rendre
|
||||
/// ne supprime rien, il pose la date. C'est ce qui conserve l'historique.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Aucune lecture ni écriture ne filtre sur l'utilisateur courant.</b> La bibliothèque est
|
||||
/// commune : tout le foyer doit voir qui a emprunté quoi, et n'importe qui doit pouvoir noter le
|
||||
/// retour d'un livre qu'il a récupéré. C'est aussi pourquoi ce service ne reçoit pas d'identité
|
||||
/// du tout — il n'a rien à en faire, et ne peut donc pas s'en servir par inadvertance.
|
||||
/// </remarks>
|
||||
public sealed class ServicePrets(MaBibliDbContext db) : IServicePrets
|
||||
{
|
||||
/// <summary>
|
||||
/// Tolérance sur une date de prêt future.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Le client convertit une date locale en UTC avant de l'envoyer, donc « aujourd'hui » ne
|
||||
/// devrait jamais dépasser l'instant courant. Un décalage d'horloge d'un fuseau à l'autre
|
||||
/// suffirait pourtant à faire rejeter une saisie parfaitement légitime : une journée de marge
|
||||
/// absorbe ce cas sans laisser passer de vraie date future.
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan MargeFutur = TimeSpan.FromDays(1);
|
||||
|
||||
public async Task<IReadOnlyList<PretDto>> EnCoursAsync(CancellationToken ct = default)
|
||||
{
|
||||
var prets = await Requete()
|
||||
.Where(p => p.DateRetour == null)
|
||||
|
||||
// Du plus ancien au plus récent : ce qu'on cherche dans cette vue, c'est le livre
|
||||
// parti depuis six mois qu'on avait oublié, pas celui prêté hier.
|
||||
.OrderBy(p => p.DatePret)
|
||||
.ThenBy(p => p.Id)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return prets.Select(Projeter).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PretDto>?> HistoriqueAsync(int livreId, CancellationToken ct = default)
|
||||
{
|
||||
// Distinguer « livre inconnu » de « livre jamais prêté » : le premier est un 404, le
|
||||
// second une liste vide parfaitement normale.
|
||||
if (!await db.Livres.AnyAsync(l => l.Id == livreId, ct))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var prets = await Requete()
|
||||
.Where(p => p.LivreId == livreId)
|
||||
.OrderByDescending(p => p.DatePret)
|
||||
.ThenByDescending(p => p.Id)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return prets.Select(Projeter).ToList();
|
||||
}
|
||||
|
||||
public async Task<ResultatPret> PreterAsync(
|
||||
int livreId, EnregistrementPret saisie, CancellationToken ct = default)
|
||||
{
|
||||
var livre = await db.Livres.AsNoTracking().FirstOrDefaultAsync(l => l.Id == livreId, ct);
|
||||
if (livre is null)
|
||||
{
|
||||
return ResultatPret.Introuvable;
|
||||
}
|
||||
|
||||
// Un ebook n'est qu'une fiche : il n'y a pas d'objet à confier à quelqu'un (CLAUDE.md).
|
||||
// Refusé côté serveur et pas seulement grisé dans l'interface, sans quoi la règle ne
|
||||
// vaudrait que pour ceux qui passent par l'écran.
|
||||
if (livre.Format == Format.Numerique)
|
||||
{
|
||||
return ResultatPret.Invalide(
|
||||
"Un livre numérique ne se prête pas : ce n'est qu'une fiche, il n'y a pas d'exemplaire à confier.");
|
||||
}
|
||||
|
||||
var emprunteur = saisie.Emprunteur?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(emprunteur))
|
||||
{
|
||||
return ResultatPret.Invalide("Indiquez à qui vous prêtez ce livre.");
|
||||
}
|
||||
|
||||
var ouvert = await db.Prets
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(p => p.LivreId == livreId && p.DateRetour == null, ct);
|
||||
|
||||
if (ouvert is not null)
|
||||
{
|
||||
return ResultatPret.Invalide(
|
||||
$"Ce livre est déjà prêté à {ouvert.Emprunteur}. Marquez-le rendu avant de le prêter à quelqu'un d'autre.");
|
||||
}
|
||||
|
||||
var datePret = saisie.DatePret ?? DateTime.UtcNow;
|
||||
if (datePret > DateTime.UtcNow + MargeFutur)
|
||||
{
|
||||
return ResultatPret.Invalide("La date de prêt ne peut pas être dans le futur.");
|
||||
}
|
||||
|
||||
var pret = new Pret
|
||||
{
|
||||
LivreId = livreId,
|
||||
Emprunteur = emprunteur,
|
||||
DatePret = datePret,
|
||||
DateRetour = null,
|
||||
};
|
||||
|
||||
db.Prets.Add(pret);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return ResultatPret.Ok((await ObtenirAsync(pret.Id, ct))!);
|
||||
}
|
||||
|
||||
public async Task<ResultatPret> RendreAsync(
|
||||
int pretId, RetourPret retour, CancellationToken ct = default)
|
||||
{
|
||||
var pret = await db.Prets.FirstOrDefaultAsync(p => p.Id == pretId, ct);
|
||||
if (pret is null)
|
||||
{
|
||||
return ResultatPret.Introuvable;
|
||||
}
|
||||
|
||||
if (pret.DateRetour is not null)
|
||||
{
|
||||
// Idempotent en apparence, mais on refuse quand même : réécrire la date écraserait
|
||||
// une information exacte par une approximative, et deux personnes qui rangent le même
|
||||
// livre ne doivent pas se marcher dessus en silence.
|
||||
return ResultatPret.Invalide("Ce prêt est déjà clos : le livre a été rendu.");
|
||||
}
|
||||
|
||||
var dateRetour = retour.DateRetour ?? DateTime.UtcNow;
|
||||
|
||||
if (dateRetour > DateTime.UtcNow + MargeFutur)
|
||||
{
|
||||
return ResultatPret.Invalide("La date de retour ne peut pas être dans le futur.");
|
||||
}
|
||||
|
||||
if (dateRetour < pret.DatePret)
|
||||
{
|
||||
return ResultatPret.Invalide("Le livre ne peut pas être revenu avant d'être parti.");
|
||||
}
|
||||
|
||||
pret.DateRetour = dateRetour;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return ResultatPret.Ok((await ObtenirAsync(pret.Id, ct))!);
|
||||
}
|
||||
|
||||
private async Task<PretDto?> ObtenirAsync(int pretId, CancellationToken ct)
|
||||
{
|
||||
var pret = await Requete().FirstOrDefaultAsync(p => p.Id == pretId, ct);
|
||||
return pret is null ? null : Projeter(pret);
|
||||
}
|
||||
|
||||
private IQueryable<Pret> Requete() =>
|
||||
db.Prets.AsNoTracking().Include(p => p.Livre!).ThenInclude(l => l.Auteurs).ThenInclude(la => la.Auteur);
|
||||
|
||||
/// <summary>
|
||||
/// Mise en forme d'un prêt déjà chargé — la projection se fait en mémoire, après la requête :
|
||||
/// la concaténation des auteurs n'a pas d'équivalent traduisible en SQL.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Le livre est recopié à plat plutôt qu'imbriqué : la vue des prêts en cours n'a besoin que
|
||||
/// de l'identifier visuellement, et un <see cref="LivreDto"/> complet traînerait derrière lui
|
||||
/// le statut de lecture, qui est personnel et n'a aucun sens ici.
|
||||
/// </remarks>
|
||||
private static PretDto Projeter(Pret pret)
|
||||
{
|
||||
var auteurs = pret.Livre?.Auteurs
|
||||
.OrderBy(la => la.Position)
|
||||
.Where(la => la.Auteur is not null)
|
||||
.Select(la => la.Auteur!.Nom)
|
||||
.ToList() ?? [];
|
||||
|
||||
return new PretDto
|
||||
{
|
||||
Id = pret.Id,
|
||||
LivreId = pret.LivreId,
|
||||
Emprunteur = pret.Emprunteur,
|
||||
DatePret = pret.DatePret,
|
||||
DateRetour = pret.DateRetour,
|
||||
LivreTitre = pret.Livre?.Titre,
|
||||
LivreCoverUrl = pret.Livre?.CoverUrl,
|
||||
LivreAuteurs = auteurs.Count == 0 ? null : string.Join(", ", auteurs),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user