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; /// Issue d'une écriture sur une revue : la revue, ou un message pour l'utilisateur. 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> ListerAsync(CancellationToken ct = default); /// /// Crée la revue, ou rend celle qui existe déjà quand l'ISSN ou le titre la désigne. /// Task CreerOuRetrouverAsync( EnregistrementRevue saisie, string? utilisateur, CancellationToken ct = default); Task ModifierAsync( int id, EnregistrementRevue saisie, CancellationToken ct = default); Task SupprimerAsync(int id, CancellationToken ct = default); Task AjouterNumeroAsync( int revueId, AjoutNumeroRevue saisie, CancellationToken ct = default); /// /// Modifie un numéro déjà recensé : son numéro, sa parution, sa note, sa couverture et ses /// articles à la une. /// /// /// 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. /// Task ModifierNumeroAsync( int numeroId, AjoutNumeroRevue saisie, CancellationToken ct = default); Task RetirerNumeroAsync(int numeroId, CancellationToken ct = default); } /// /// Revues et magazines : une fiche par revue, les numéros à l'intérieur. /// /// /// ⚠️ Communes au foyer, comme le catalogue : aucune méthode ne filtre sur un /// utilisateur, n'étant qu'une trace. /// /// Un numéro est recensé, rien de plus — 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 à /// Livre par clé étrangère. /// /// public sealed class ServiceRevues(MaBibliDbContext db) : IServiceRevues { public async Task> 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 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 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 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 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 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)); } /// /// Remplace les articles à la une du numéro par ceux de la saisie. /// /// /// ⚠️ Remplacement et non fusion : la ligne de saisie est la liste. Un titre effacé /// du champ doit disparaître, comme pour les thèmes d'un livre. /// /// Le dédoublonnage a déjà eu lieu à la saisie (ListeSaisie), mais il est refait ici /// sur la forme normalisée : c'est l'unicité (NumeroRevueId, TitreNormalise) qui est /// en jeu, et un appel direct à l'API n'est pas passé par le champ texte. /// /// private void RemplacerArticles(NumeroRevue numero, IReadOnlyList? titres) { if (numero.Articles.Count > 0) { db.ArticlesUne.RemoveRange(numero.Articles); numero.Articles.Clear(); } var vus = new HashSet(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 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(), }; }