L'ISSN est désormais rangé sous sa forme à tiret, et pas seulement affiché ainsi. C'est le seul endroit où le projet s'écarte de « la valeur stockée reste nue », et il y a une raison : le code-barres produit déjà un ISSN à tiret, et la BnF interroge `bib.issn` avec le tiret. Un ISSN tapé « 24666718 » ne se rapprochait donc de rien, et le scan suivant créait une seconde fiche à côté. La canonisation a lieu avant la recherche, pas seulement à l'écriture. Les champs de saisie, eux, gardent la valeur tapée : découper à la frappe se battrait avec le curseur. `ServiceRenormalisation` rattrape les ISSN existants — par un passage dédié, le mécanisme générique confondant tous les NULL en une seule clé, alors que l'unicité est ici un index partiel. Une fiche dont la forme canonique est déjà prise garde la sienne : le serveur doit démarrer. La couverture d'un numéro est une URL collée, jamais une photo : aucune source ne peut la fournir, l'ISSN désignant la revue et non la parution, et le projet ne stocke aucun fichier. Le garde du relais de couvertures a donc été étendu aux numéros — l'oublier n'aurait produit aucune erreur visible, seulement une image présente en ligne et absente hors-ligne. Les articles à la une vivent dans une table à part et non dans `Theme` : un thème est un vocabulaire qu'on réutilise, un titre d'article est unique à sa parution. Rien ne se partage, donc pas de n-n — une simple clé étrangère vers le numéro. Ils se saisissent au point-virgule seul : « Ukraine, deux ans après » serait coupé en deux par la virgule. Un PUT sur le numéro était indispensable : on note un numéro le jour où on le range et on en recopie le sommaire plus tard. Sans lui, couverture et une n'auraient existé qu'à la création, c'est-à-dire jamais. 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(),
|
|
};
|
|
}
|