Files
Mathieu LimonierandClaude Opus 5 6a6d745af4 MaBibli 1.0.0
Gestion de bibliothèque personnelle auto-hébergée : catalogue, prêts,
scan de code-barres, consultation hors-ligne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 22:36:16 +02:00

363 lines
13 KiB
C#

using MaBibli.Api.Data;
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Entites;
using MaBibli.Shared.Isbn;
using MaBibli.Shared.Textes;
using Microsoft.EntityFrameworkCore;
namespace MaBibli.Api.Services.Revues;
/// <summary>Issue d'une écriture sur une revue : la revue, ou un message pour l'utilisateur.</summary>
public readonly record struct ResultatRevue(RevueDto? Revue, string? Erreur)
{
public static ResultatRevue Ok(RevueDto revue) => new(revue, null);
public static ResultatRevue Invalide(string message) => new(null, message);
public static readonly ResultatRevue Introuvable = new(null, null);
public bool EstIntrouvable => Revue is null && Erreur is null;
}
public interface IServiceRevues
{
Task<IReadOnlyList<RevueDto>> ListerAsync(CancellationToken ct = default);
/// <summary>
/// Crée la revue, ou <b>rend celle qui existe déjà</b> quand l'ISSN ou le titre la désigne.
/// </summary>
Task<ResultatRevue> CreerOuRetrouverAsync(
EnregistrementRevue saisie, string? utilisateur, CancellationToken ct = default);
Task<ResultatRevue> ModifierAsync(
int id, EnregistrementRevue saisie, CancellationToken ct = default);
Task<bool> SupprimerAsync(int id, CancellationToken ct = default);
Task<ResultatRevue> AjouterNumeroAsync(
int revueId, AjoutNumeroRevue saisie, CancellationToken ct = default);
/// <summary>
/// Modifie un numéro déjà recensé : son numéro, sa parution, sa note, sa couverture et ses
/// articles à la une.
/// </summary>
/// <remarks>
/// Sans ce point d'entrée, la couverture et les articles ne se seraient saisis qu'à la
/// création — c'est-à-dire jamais, puisqu'on ajoute un numéro le jour où on l'achète et
/// qu'on en recopie la une plus tard.
/// </remarks>
Task<ResultatRevue> ModifierNumeroAsync(
int numeroId, AjoutNumeroRevue saisie, CancellationToken ct = default);
Task<bool> RetirerNumeroAsync(int numeroId, CancellationToken ct = default);
}
/// <summary>
/// Revues et magazines : une fiche par revue, les numéros à l'intérieur.
/// </summary>
/// <remarks>
/// ⚠️ <b>Communes au foyer</b>, comme le catalogue : aucune méthode ne filtre sur un
/// utilisateur, <see cref="Revue.AjoutePar"/> n'étant qu'une trace.
/// <para>
/// Un numéro est <b>recensé, rien de plus</b> — ni prêt, ni statut de lecture (décidé le
/// 2026-08-19). C'est ce qui garde cette table à l'écart de deux mécanismes attachés à
/// <c>Livre</c> par clé étrangère.
/// </para>
/// </remarks>
public sealed class ServiceRevues(MaBibliDbContext db) : IServiceRevues
{
public async Task<IReadOnlyList<RevueDto>> ListerAsync(CancellationToken ct = default)
{
var revues = await db.Revues
.AsNoTracking()
.Include(r => r.Numeros).ThenInclude(n => n.Articles)
.OrderBy(r => r.TitreNormalise)
.ToListAsync(ct);
return revues.Select(Projeter).ToList();
}
public async Task<ResultatRevue> CreerOuRetrouverAsync(
EnregistrementRevue saisie, string? utilisateur, CancellationToken ct = default)
{
var titre = saisie.Titre?.Trim();
if (string.IsNullOrWhiteSpace(titre))
{
return ResultatRevue.Invalide("Le titre de la revue est obligatoire.");
}
// ⚠️ Canonisé AVANT la recherche, pas seulement à l'écriture : « 24666718 » et
// « 2466-6718 » désignent la même revue, et sans cela le scan suivant en créerait une
// seconde à côté de celle saisie à la main.
var issn = FormatageIssn.Canonique(saisie.Issn);
var normalise = NormalisationTexte.Normaliser(titre);
// ⚠️ « Créer OU retrouver » et non « créer » : le flux du code-barres 977 rescanne
// forcément la même revue à chaque numéro. Refuser le doublon obligerait l'utilisateur
// à comprendre qu'il doit aller chercher la fiche lui-même — alors que le geste est
// toujours le même : « voici un numéro de plus ».
var existante = await db.Revues
.Include(r => r.Numeros).ThenInclude(n => n.Articles)
.FirstOrDefaultAsync(
r => (issn != null && r.Issn == issn) || r.TitreNormalise == normalise, ct);
if (existante is not null)
{
// Un ISSN appris après coup complète la fiche créée à la main : c'est ce qui
// permettra de la retrouver au prochain scan.
if (existante.Issn is null && issn is not null)
{
existante.Issn = issn;
await db.SaveChangesAsync(ct);
}
return ResultatRevue.Ok(Projeter(existante));
}
var revue = new Revue
{
Titre = titre,
Issn = issn,
Editeur = Vide(saisie.Editeur),
DateAjout = DateTime.UtcNow,
AjoutePar = utilisateur,
};
revue.RecalculerFormes();
db.Revues.Add(revue);
await db.SaveChangesAsync(ct);
return ResultatRevue.Ok(Projeter(revue));
}
public async Task<ResultatRevue> ModifierAsync(
int id, EnregistrementRevue saisie, CancellationToken ct = default)
{
var revue = await db.Revues.Include(r => r.Numeros).ThenInclude(n => n.Articles).FirstOrDefaultAsync(r => r.Id == id, ct);
if (revue is null)
{
return ResultatRevue.Introuvable;
}
var titre = saisie.Titre?.Trim();
if (string.IsNullOrWhiteSpace(titre))
{
return ResultatRevue.Invalide("Le titre de la revue est obligatoire.");
}
var normalise = NormalisationTexte.Normaliser(titre);
if (await db.Revues.AnyAsync(r => r.Id != id && r.TitreNormalise == normalise, ct))
{
return ResultatRevue.Invalide($"La revue « {titre} » existe déjà.");
}
var issn = FormatageIssn.Canonique(saisie.Issn);
if (issn is not null && await db.Revues.AnyAsync(r => r.Id != id && r.Issn == issn, ct))
{
return ResultatRevue.Invalide($"L'ISSN {issn} est déjà celui d'une autre revue.");
}
revue.Titre = titre;
revue.Issn = issn;
revue.Editeur = Vide(saisie.Editeur);
revue.RecalculerFormes();
await db.SaveChangesAsync(ct);
return ResultatRevue.Ok(Projeter(revue));
}
public async Task<bool> SupprimerAsync(int id, CancellationToken ct = default)
{
var revue = await db.Revues.FirstOrDefaultAsync(r => r.Id == id, ct);
if (revue is null)
{
return false;
}
// Les numéros partent en cascade : ils n'existent que par leur revue, contrairement aux
// livres d'une série, qui vivent leur vie au catalogue.
db.Revues.Remove(revue);
await db.SaveChangesAsync(ct);
return true;
}
public async Task<ResultatRevue> AjouterNumeroAsync(
int revueId, AjoutNumeroRevue saisie, CancellationToken ct = default)
{
var revue = await db.Revues.Include(r => r.Numeros).ThenInclude(n => n.Articles).FirstOrDefaultAsync(r => r.Id == revueId, ct);
if (revue is null)
{
return ResultatRevue.Introuvable;
}
var numero = saisie.Numero?.Trim();
if (string.IsNullOrWhiteSpace(numero))
{
return ResultatRevue.Invalide("Le numéro est obligatoire.");
}
var normalise = NormalisationTexte.Normaliser(numero);
if (revue.Numeros.Any(n => n.NumeroNormalise == normalise))
{
return ResultatRevue.Invalide($"Le numéro {numero} est déjà dans votre collection.");
}
if (saisie.DateParution is { } parution && parution.Date > DateTime.UtcNow.Date.AddDays(1))
{
// Comme pour les prêts : une date future est une faute de frappe, pas une intention.
// Un jour de marge, les revues datées du mois suivant étant courantes en kiosque.
return ResultatRevue.Invalide("La date de parution ne peut pas être dans le futur.");
}
var ligne = new NumeroRevue
{
RevueId = revueId,
Numero = numero,
DateParution = saisie.DateParution,
Note = Vide(saisie.Note),
CoverUrl = Vide(saisie.CoverUrl),
DateAjout = DateTime.UtcNow,
};
ligne.RecalculerFormes();
RemplacerArticles(ligne, saisie.Articles);
db.NumerosRevue.Add(ligne);
await db.SaveChangesAsync(ct);
var relue = await db.Revues.AsNoTracking().Include(r => r.Numeros).ThenInclude(n => n.Articles)
.FirstAsync(r => r.Id == revueId, ct);
return ResultatRevue.Ok(Projeter(relue));
}
public async Task<ResultatRevue> ModifierNumeroAsync(
int numeroId, AjoutNumeroRevue saisie, CancellationToken ct = default)
{
var ligne = await db.NumerosRevue
.Include(n => n.Articles)
.FirstOrDefaultAsync(n => n.Id == numeroId, ct);
if (ligne is null)
{
return ResultatRevue.Introuvable;
}
var numero = saisie.Numero?.Trim();
if (string.IsNullOrWhiteSpace(numero))
{
return ResultatRevue.Invalide("Le numéro est obligatoire.");
}
var normalise = NormalisationTexte.Normaliser(numero);
if (await db.NumerosRevue.AnyAsync(
n => n.Id != numeroId && n.RevueId == ligne.RevueId && n.NumeroNormalise == normalise, ct))
{
return ResultatRevue.Invalide($"Le numéro {numero} est déjà dans votre collection.");
}
if (saisie.DateParution is { } parution && parution.Date > DateTime.UtcNow.Date.AddDays(1))
{
return ResultatRevue.Invalide("La date de parution ne peut pas être dans le futur.");
}
ligne.Numero = numero;
ligne.DateParution = saisie.DateParution;
ligne.Note = Vide(saisie.Note);
ligne.CoverUrl = Vide(saisie.CoverUrl);
ligne.RecalculerFormes();
RemplacerArticles(ligne, saisie.Articles);
await db.SaveChangesAsync(ct);
var relue = await db.Revues.AsNoTracking().Include(r => r.Numeros).ThenInclude(n => n.Articles)
.FirstAsync(r => r.Id == ligne.RevueId, ct);
return ResultatRevue.Ok(Projeter(relue));
}
/// <summary>
/// Remplace les articles à la une du numéro par ceux de la saisie.
/// </summary>
/// <remarks>
/// ⚠️ Remplacement et non fusion : la ligne de saisie <b>est</b> la liste. Un titre effacé
/// du champ doit disparaître, comme pour les thèmes d'un livre.
/// <para>
/// Le dédoublonnage a déjà eu lieu à la saisie (<c>ListeSaisie</c>), mais il est refait ici
/// sur la forme normalisée : c'est l'unicité <c>(NumeroRevueId, TitreNormalise)</c> qui est
/// en jeu, et un appel direct à l'API n'est pas passé par le champ texte.
/// </para>
/// </remarks>
private void RemplacerArticles(NumeroRevue numero, IReadOnlyList<string>? titres)
{
if (numero.Articles.Count > 0)
{
db.ArticlesUne.RemoveRange(numero.Articles);
numero.Articles.Clear();
}
var vus = new HashSet<string>(StringComparer.Ordinal);
var position = 0;
foreach (var titre in titres ?? [])
{
var article = new ArticleUne { NumeroRevue = numero, Titre = titre ?? string.Empty };
article.RecalculerFormes();
if (article.TitreNormalise.Length == 0 || !vus.Add(article.TitreNormalise))
{
continue;
}
article.Position = position++;
numero.Articles.Add(article);
}
}
public async Task<bool> RetirerNumeroAsync(int numeroId, CancellationToken ct = default)
{
var numero = await db.NumerosRevue.FirstOrDefaultAsync(n => n.Id == numeroId, ct);
if (numero is null)
{
return false;
}
db.NumerosRevue.Remove(numero);
await db.SaveChangesAsync(ct);
return true;
}
private static string? Vide(string? valeur) =>
string.IsNullOrWhiteSpace(valeur) ? null : valeur.Trim();
private static RevueDto Projeter(Revue revue) => new()
{
Id = revue.Id,
Titre = revue.Titre,
Issn = revue.Issn,
Editeur = revue.Editeur,
AjoutePar = revue.AjoutePar,
// Du plus récent au plus ancien : sur un magazine, c'est le dernier reçu qu'on cherche,
// à l'inverse d'une série dont on suit l'ordre de lecture. Les numéros sans date de
// parution ferment la liste plutôt que de passer pour les plus vieux.
Numeros = revue.Numeros
.OrderByDescending(n => n.DateParution.HasValue)
.ThenByDescending(n => n.DateParution)
.ThenByDescending(n => n.NumeroNormalise, StringComparer.Ordinal)
.Select(n => new NumeroRevueDto
{
Id = n.Id,
Numero = n.Numero,
DateParution = n.DateParution,
Note = n.Note,
CoverUrl = n.CoverUrl,
Articles = n.Articles
.OrderBy(a => a.Position)
.ThenBy(a => a.Id)
.Select(a => a.Titre)
.ToList(),
})
.ToList(),
};
}