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>
363 lines
13 KiB
C#
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(),
|
|
};
|
|
}
|