- Cataloguer en rafale — nouvel écran /ajout/rafale : on scanne une pile de livres à la suite dans une zone de texte, chaque code est traité (BnF puis OpenLibrary), les doublons connus sont passés automatiquement. La collecte marche même hors-ligne. Le compte rendu liste maintenant les livres créés, en lien vers leur fiche, et reste consultable en revenant sur l'écran même après une rafale entièrement réussie. Le catalogue reconnaît un ISBN dans sa barre de recherche (13 ou 10 chiffres, avec ou sans tirets) : scanner un livre en main ouvre directement sa fiche s'il n'y en a qu'un. Un bouton « Scanner » l'alimente, actif hors-ligne. - Ajouter un tome à une série accepte aussi un ISBN dans le champ manuel : le catalogue est cherché d'abord (rattachement direct si un seul exemplaire), sinon la BnF prend le relais. Séries et sagas - Numéro de tome distinct de la position de lecture : on peut indiquer « c'est le tome 7 » même si on ne possède pas les six premiers ; l'ordre de lecture reste un réglage séparé (utile pour les préquelles). - Tri par numéro en plus du tri par ordre de lecture quand des tomes en portent un. - Panneau « Ajouter » regroupé et repliable sur la fiche d'une série (manuellement / en rafale / depuis le catalogue / depuis les envies), au lieu de quatre formulaires ouverts en permanence. - Filtre catalogue « sans couverture » pour repérer les livres à illustrer. Le catalogue groupe les tomes d'une même série sous un bloc repliable, avec un décompte plus clair (affichés / possédés / total). - Corrections directes sur la fiche - Effacer un prêt saisi par erreur (bouton ✕ sur chaque ligne, avec confirmation), sans passer par « rendre ». - Corriger une couverture manquante ou cassée en cliquant dessus : le champ d'adresse s'ouvre focalisé, Entrée enregistre. Étendu aux numéros de revue dans la dernière modification. - Les thèmes déjà utilisés dans la bibliothèque sont proposés à la frappe. - La recherche d'un livre à rattacher montre des suggestions dès le focus, sans attendre de taper. - Visuel : un rendu manquant après une écriture asynchrone dans le formulaire de livre, une bascule de rôle cassée, des débordements à 320 px, et le style d'un bouton-lien qui restait souligné.
244 lines
9.5 KiB
C#
244 lines
9.5 KiB
C#
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>
|
|
/// Efface un prêt, en cours ou clos. <c>false</c> s'il n'existe pas.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// ⚠️ <b>Ce n'est pas « rendre »</b>, et les deux ne doivent jamais être confondus. Rendre
|
|
/// pose une date et conserve la ligne : c'est l'histoire du livre, et c'est la raison d'être
|
|
/// de la table. Effacer <b>défait une saisie</b> — mauvais emprunteur, mauvaise date, prêt
|
|
/// noté deux fois. Un historique juste vaut mieux qu'un historique complet mais faux.
|
|
/// </remarks>
|
|
Task<bool> SupprimerAsync(int pretId, 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))!);
|
|
}
|
|
|
|
public async Task<bool> SupprimerAsync(int pretId, CancellationToken ct = default)
|
|
{
|
|
var pret = await db.Prets.FirstOrDefaultAsync(p => p.Id == pretId, ct);
|
|
if (pret is null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Aucune identité n'entre ici, comme partout dans ce service : le prêt est commun au
|
|
// foyer, et celui qui constate l'erreur n'est pas forcément celui qui l'a faite.
|
|
db.Prets.Remove(pret);
|
|
await db.SaveChangesAsync(ct);
|
|
|
|
return true;
|
|
}
|
|
|
|
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),
|
|
};
|
|
}
|
|
}
|