Files
mabibli/MaBibli.Api/Services/Series/ServiceSeries.cs
T
mathieu 42dec0dbd1 - Catalogage rapide (douchette, code-barres)
- 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é.
2026-09-09 00:05:26 +02:00

524 lines
19 KiB
C#

using MaBibli.Api.Data;
using MaBibli.Api.Services.Souhaits;
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Entites;
using MaBibli.Shared.Textes;
using Microsoft.EntityFrameworkCore;
namespace MaBibli.Api.Services.Series;
/// <summary>Issue d'une écriture sur une série : la série, ou un message pour l'utilisateur.</summary>
public readonly record struct ResultatSerie(SerieDto? Serie, string? Erreur)
{
public static ResultatSerie Ok(SerieDto serie) => new(serie, null);
public static ResultatSerie Invalide(string message) => new(null, message);
/// <summary>Ni série ni erreur : la ressource demandée n'existe pas.</summary>
public static readonly ResultatSerie Introuvable = new(null, null);
public bool EstIntrouvable => Serie is null && Erreur is null;
}
public interface IServiceSeries
{
Task<IReadOnlyList<SerieDto>> ListerAsync(CancellationToken ct = default);
Task<ResultatSerie> CreerAsync(
EnregistrementSerie saisie, string? utilisateur, CancellationToken ct = default);
Task<ResultatSerie> ModifierAsync(
int id, EnregistrementSerie saisie, CancellationToken ct = default);
Task<bool> SupprimerAsync(int id, CancellationToken ct = default);
Task<ResultatSerie> AjouterElementAsync(
int serieId, AjoutElementSerie saisie, CancellationToken ct = default);
Task<ResultatSerie> RattacherLivreAsync(int elementId, int? livreId, CancellationToken ct = default);
/// <summary>Corrige le titre et le numéro d'une place, sans toucher au livre rattaché.</summary>
Task<ResultatSerie> ModifierElementAsync(
int elementId, AjoutElementSerie saisie, CancellationToken ct = default);
Task<bool> RetirerElementAsync(int elementId, CancellationToken ct = default);
Task<ResultatSerie> ReordonnerAsync(
int serieId, IReadOnlyList<int> idsOrdonnes, CancellationToken ct = default);
Task<ResultatSerie> ReordonnerSousSeriesAsync(
int serieId, IReadOnlyList<int> idsOrdonnes, CancellationToken ct = default);
/// <summary>Bascule un tome manquant vers la liste d'envies <b>de l'appelant</b>.</summary>
Task<ResultatSouhait> MettreEnEnviesAsync(
int elementId, string? utilisateur, CancellationToken ct = default);
}
/// <summary>
/// Sagas, cycles et séries : ce qui se lit dans un ordre, et ce qui manque pour le lire.
/// </summary>
/// <remarks>
/// ⚠️ <b>Portée COMMUNE au foyer</b>, comme le catalogue et les prêts. Aucune méthode ne prend
/// d'utilisateur pour lire ou écrire une série — l'ordre de lecture d'une saga ne change pas
/// selon qui regarde. La seule exception est <see cref="MettreEnEnviesAsync"/>, qui écrit dans
/// une liste d'envies, et qui est donc <b>obligée</b> de savoir de qui elle parle : c'est là que
/// les deux portées se rencontrent, et nulle part ailleurs.
/// </remarks>
public sealed class ServiceSeries(MaBibliDbContext db, IServiceSouhaits souhaits) : IServiceSeries
{
public async Task<IReadOnlyList<SerieDto>> ListerAsync(CancellationToken ct = default)
{
// AUCUN filtre sur AjoutePar : les séries sont communes, comme le catalogue.
var series = await db.Series
.AsNoTracking()
.Include(s => s.Elements).ThenInclude(e => e.Livre).ThenInclude(l => l!.Auteurs)
.ThenInclude(la => la.Auteur)
.ToListAsync(ct);
var livreIds = series
.SelectMany(s => s.Elements)
.Select(e => e.LivreId)
.OfType<int>()
.ToList();
var prets = await PretsOuvertsAsync(livreIds, ct);
return series
.OrderBy(s => s.Position)
.ThenBy(s => s.TitreNormalise, StringComparer.Ordinal)
.Select(s => Projeter(s, prets))
.ToList();
}
public async Task<ResultatSerie> CreerAsync(
EnregistrementSerie saisie, string? utilisateur, CancellationToken ct = default)
{
var titre = saisie.Titre?.Trim();
if (string.IsNullOrWhiteSpace(titre))
{
return ResultatSerie.Invalide("Le titre de la série est obligatoire.");
}
var normalise = NormalisationTexte.Normaliser(titre);
if (await db.Series.AnyAsync(s => s.TitreNormalise == normalise, ct))
{
// Message plutôt qu'index unique brut : « UNIQUE constraint failed » ne veut rien
// dire pour l'utilisateur. L'index reste là comme garantie de dernier recours.
return ResultatSerie.Invalide($"La série « {titre} » existe déjà.");
}
if (saisie.SerieParenteId is { } parenteId
&& !await db.Series.AnyAsync(s => s.Id == parenteId, ct))
{
return ResultatSerie.Invalide("La série parente n'existe pas.");
}
var serie = new Serie
{
Titre = titre,
SerieParenteId = saisie.SerieParenteId,
DateAjout = DateTime.UtcNow,
// Trace de saisie, jamais un cloisonnement — comme Livre.AjoutePar.
AjoutePar = utilisateur,
};
serie.RecalculerFormes();
serie.Position = await ProchainePositionAsync(saisie.SerieParenteId, ct);
db.Series.Add(serie);
await db.SaveChangesAsync(ct);
return await RelireAsync(serie.Id, ct);
}
public async Task<ResultatSerie> ModifierAsync(
int id, EnregistrementSerie saisie, CancellationToken ct = default)
{
var serie = await db.Series.FirstOrDefaultAsync(s => s.Id == id, ct);
if (serie is null)
{
return ResultatSerie.Introuvable;
}
var titre = saisie.Titre?.Trim();
if (string.IsNullOrWhiteSpace(titre))
{
return ResultatSerie.Invalide("Le titre de la série est obligatoire.");
}
var normalise = NormalisationTexte.Normaliser(titre);
if (await db.Series.AnyAsync(s => s.Id != id && s.TitreNormalise == normalise, ct))
{
return ResultatSerie.Invalide($"La série « {titre} » existe déjà.");
}
if (saisie.SerieParenteId is { } parenteId)
{
if (!await db.Series.AnyAsync(s => s.Id == parenteId, ct))
{
return ResultatSerie.Invalide("La série parente n'existe pas.");
}
if (await EstDescendanteAsync(parenteId, id, ct))
{
// Sans ce garde-fou, une série deviendrait sa propre ancêtre : l'arbre se
// refermerait sur lui-même et l'affichage boucherait à l'infini.
return ResultatSerie.Invalide(
"Une série ne peut pas être rangée dans elle-même, ni dans l'une des siennes.");
}
}
if (serie.SerieParenteId != saisie.SerieParenteId)
{
serie.SerieParenteId = saisie.SerieParenteId;
serie.Position = await ProchainePositionAsync(saisie.SerieParenteId, ct);
}
serie.Titre = titre;
serie.RecalculerFormes();
await db.SaveChangesAsync(ct);
return await RelireAsync(id, ct);
}
public async Task<bool> SupprimerAsync(int id, CancellationToken ct = default)
{
var serie = await db.Series.FirstOrDefaultAsync(s => s.Id == id, ct);
if (serie is null)
{
return false;
}
// Les éléments partent en cascade — ce ne sont que des positions. Les sous-séries, elles,
// remontent à la racine (SetNull) : ce sont de vraies séries de vrais livres.
db.Series.Remove(serie);
await db.SaveChangesAsync(ct);
return true;
}
public async Task<ResultatSerie> AjouterElementAsync(
int serieId, AjoutElementSerie saisie, CancellationToken ct = default)
{
var serie = await db.Series.Include(s => s.Elements).FirstOrDefaultAsync(s => s.Id == serieId, ct);
if (serie is null)
{
return ResultatSerie.Introuvable;
}
var titre = saisie.Titre?.Trim();
if (saisie.LivreId is { } livreId)
{
var livre = await db.Livres.AsNoTracking().FirstOrDefaultAsync(l => l.Id == livreId, ct);
if (livre is null)
{
return ResultatSerie.Invalide("Ce livre n'existe plus.");
}
if (serie.Elements.Any(e => e.LivreId == livreId))
{
return ResultatSerie.Invalide($"« {livre.Titre} » est déjà dans cette série.");
}
// Le titre du tome est copié du livre : il doit survivre à sa suppression, faute de
// quoi la saga se retrouverait avec une place muette.
titre = string.IsNullOrWhiteSpace(titre) ? livre.Titre : titre;
}
if (string.IsNullOrWhiteSpace(titre))
{
return ResultatSerie.Invalide("Le titre du tome est obligatoire.");
}
var element = new ElementSerie
{
SerieId = serieId,
LivreId = saisie.LivreId,
Titre = titre,
Numero = saisie.Numero,
Position = serie.Elements.Count == 0 ? 0 : serie.Elements.Max(e => e.Position) + 1,
};
element.RecalculerFormes();
db.ElementsSerie.Add(element);
await db.SaveChangesAsync(ct);
return await RelireAsync(serieId, ct);
}
public async Task<ResultatSerie> RattacherLivreAsync(
int elementId, int? livreId, CancellationToken ct = default)
{
var element = await db.ElementsSerie.FirstOrDefaultAsync(e => e.Id == elementId, ct);
if (element is null)
{
return ResultatSerie.Introuvable;
}
if (livreId is { } id)
{
var livre = await db.Livres.AsNoTracking().FirstOrDefaultAsync(l => l.Id == id, ct);
if (livre is null)
{
return ResultatSerie.Invalide("Ce livre n'existe plus.");
}
var deja = await db.ElementsSerie.AnyAsync(
e => e.SerieId == element.SerieId && e.LivreId == id && e.Id != elementId, ct);
if (deja)
{
return ResultatSerie.Invalide($"« {livre.Titre} » occupe déjà une place dans cette série.");
}
}
// Le titre saisi est conservé : c'est celui du tome, pas celui de l'exemplaire. Détacher
// un livre laisse donc un trou nommé, et non une ligne vide.
element.LivreId = livreId;
await db.SaveChangesAsync(ct);
return await RelireAsync(element.SerieId, ct);
}
/// <remarks>
/// ⚠️ Ce point d'entrée existe pour que le <b>numéro de tome</b> puisse se saisir après coup.
/// Sans lui, il ne serait renseignable qu'à la création — c'est-à-dire jamais en pratique,
/// puisqu'on découvre souvent le numéro en ayant le livre en main. Même défaut que celui
/// rencontré sur les numéros de revue, et corrigé de la même façon.
/// <para>
/// Le livre rattaché n'est <b>pas</b> touché : c'est l'affaire de
/// <see cref="RattacherLivreAsync"/>. Un point d'entrée, un geste.
/// </para>
/// </remarks>
public async Task<ResultatSerie> ModifierElementAsync(
int elementId, AjoutElementSerie saisie, CancellationToken ct = default)
{
var element = await db.ElementsSerie.FirstOrDefaultAsync(e => e.Id == elementId, ct);
if (element is null)
{
return ResultatSerie.Introuvable;
}
var titre = (saisie.Titre ?? string.Empty).Trim();
if (titre.Length == 0)
{
// Le titre survit à la suppression du livre : une place sans titre serait un trou
// anonyme, que plus rien ne permettrait d'identifier.
return ResultatSerie.Invalide("Le titre du tome est obligatoire.");
}
element.Titre = titre;
element.Numero = saisie.Numero;
element.RecalculerFormes();
await db.SaveChangesAsync(ct);
return await RelireAsync(element.SerieId, ct);
}
public async Task<bool> RetirerElementAsync(int elementId, CancellationToken ct = default)
{
var element = await db.ElementsSerie.FirstOrDefaultAsync(e => e.Id == elementId, ct);
if (element is null)
{
return false;
}
db.ElementsSerie.Remove(element);
await db.SaveChangesAsync(ct);
return true;
}
public async Task<ResultatSerie> ReordonnerAsync(
int serieId, IReadOnlyList<int> idsOrdonnes, CancellationToken ct = default)
{
var elements = await db.ElementsSerie.Where(e => e.SerieId == serieId).ToListAsync(ct);
if (elements.Count == 0)
{
return await db.Series.AnyAsync(s => s.Id == serieId, ct)
? await RelireAsync(serieId, ct)
: ResultatSerie.Introuvable;
}
// Même prudence que pour la liste d'envies : on relit les éléments DE CETTE SÉRIE et on
// ne se fie pas à ce que le client envoie. Un identifiant étranger est ignoré, et un
// tome absent de l'ordre reçu est conservé à la suite — la liste du client peut être
// périmée, et mal classé vaut infiniment mieux que disparu.
var parId = elements.ToDictionary(e => e.Id);
var position = 0;
foreach (var id in idsOrdonnes.Distinct())
{
if (parId.Remove(id, out var element))
{
element.Position = position++;
}
}
foreach (var oublie in parId.Values.OrderBy(e => e.Position).ThenBy(e => e.Id))
{
oublie.Position = position++;
}
await db.SaveChangesAsync(ct);
return await RelireAsync(serieId, ct);
}
public async Task<ResultatSerie> ReordonnerSousSeriesAsync(
int serieId, IReadOnlyList<int> idsOrdonnes, CancellationToken ct = default)
{
if (!await db.Series.AnyAsync(s => s.Id == serieId, ct))
{
return ResultatSerie.Introuvable;
}
var enfants = await db.Series
.Where(s => s.SerieParenteId == serieId)
.ToListAsync(ct);
var parId = enfants.ToDictionary(s => s.Id);
var position = 0;
foreach (var id in idsOrdonnes.Distinct())
{
if (parId.Remove(id, out var enfant))
{
enfant.Position = position++;
}
}
foreach (var oublie in parId.Values.OrderBy(s => s.Position).ThenBy(s => s.Id))
{
oublie.Position = position++;
}
await db.SaveChangesAsync(ct);
return await RelireAsync(serieId, ct);
}
public async Task<ResultatSouhait> MettreEnEnviesAsync(
int elementId, string? utilisateur, CancellationToken ct = default)
{
var element = await db.ElementsSerie
.AsNoTracking()
.FirstOrDefaultAsync(e => e.Id == elementId, ct);
if (element is null)
{
return ResultatSouhait.Invalide("Ce tome n'existe plus.");
}
if (element.LivreId is not null)
{
// Souhaiter ce qu'on a déjà n'aurait aucun sens, et la liste d'envies le signalerait
// aussitôt comme « déjà au catalogue ».
return ResultatSouhait.Invalide($"« {element.Titre} » est déjà dans votre bibliothèque.");
}
// ⚠️ Ici et seulement ici, une série (commune) écrit dans une liste d'envies
// (personnelle). L'envie est donc celle de l'APPELANT, jamais celle du foyer : deux
// membres peuvent vouloir le même tome manquant, chacun dans sa liste.
return await souhaits.AjouterAsync(
new EnregistrementSouhait { Titre = element.Titre }, utilisateur, ct);
}
/// <summary>Prêts en cours des livres concernés. Communs au foyer, comme partout ailleurs.</summary>
private async Task<Dictionary<int, string>> 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, p => p.Emprunteur, ct);
}
private async Task<int> ProchainePositionAsync(int? parenteId, CancellationToken ct)
{
var derniere = await db.Series
.Where(s => s.SerieParenteId == parenteId)
.Select(s => (int?)s.Position)
.MaxAsync(ct);
return (derniere ?? -1) + 1;
}
/// <summary><paramref name="candidate"/> est-elle <paramref name="serieId"/> ou l'une de ses filles ?</summary>
private async Task<bool> EstDescendanteAsync(int candidate, int serieId, CancellationToken ct)
{
var courante = (int?)candidate;
// Remontée bornée par le nombre de séries : même si une boucle existait déjà en base,
// cette fonction s'arrête au lieu de tourner indéfiniment.
var garde = await db.Series.CountAsync(ct) + 1;
while (courante is { } id && garde-- > 0)
{
if (id == serieId)
{
return true;
}
courante = await db.Series
.Where(s => s.Id == id)
.Select(s => s.SerieParenteId)
.FirstOrDefaultAsync(ct);
}
return false;
}
private async Task<ResultatSerie> RelireAsync(int serieId, CancellationToken ct)
{
var serie = await db.Series
.AsNoTracking()
.Include(s => s.Elements).ThenInclude(e => e.Livre).ThenInclude(l => l!.Auteurs)
.ThenInclude(la => la.Auteur)
.FirstOrDefaultAsync(s => s.Id == serieId, ct);
if (serie is null)
{
return ResultatSerie.Introuvable;
}
var prets = await PretsOuvertsAsync(
serie.Elements.Select(e => e.LivreId).OfType<int>().ToList(), ct);
return ResultatSerie.Ok(Projeter(serie, prets));
}
private static SerieDto Projeter(Serie serie, IReadOnlyDictionary<int, string> prets) => new()
{
Id = serie.Id,
Titre = serie.Titre,
SerieParenteId = serie.SerieParenteId,
Position = serie.Position,
AjoutePar = serie.AjoutePar,
Elements = serie.Elements
.OrderBy(e => e.Position)
.ThenBy(e => e.Id)
.Select(e => new ElementSerieDto
{
Id = e.Id,
Position = e.Position,
// Le titre du livre prime quand il est là : c'est lui qui a pu être corrigé
// depuis la saisie du tome. Le titre stocké reste le filet en cas de suppression.
Titre = e.Livre?.Titre ?? e.Titre,
Numero = e.Numero,
LivreId = e.LivreId,
Auteurs = e.Livre is null || e.Livre.Auteurs.Count == 0
? null
: string.Join(", ", e.Livre.Auteurs
.OrderBy(la => la.Position)
.Select(la => la.Auteur?.Nom)
.Where(nom => !string.IsNullOrWhiteSpace(nom))),
PreteA = e.LivreId is { } id && prets.TryGetValue(id, out var qui) ? qui : null,
})
.ToList(),
};
}