From ca0a9dc18cc68b2ffd4525db652320969a52a3c4 Mon Sep 17 00:00:00 2001 From: mathieu Date: Wed, 19 Aug 2026 22:19:08 +0200 Subject: [PATCH] =?UTF-8?q?Cataloguer=20les=20revues=20:=20une=20fiche,=20?= =?UTF-8?q?ses=20num=C3=A9ros=20=C3=A0=20l'int=C3=A9rieur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clôt la décision du premier lot, qui écartait les périodiques faute de modèle. Douze numéros d'un magazine ne font plus douze fiches identiques. Table à part, pour la troisième fois et pour le même motif que la liste d'envies et les séries : logée dans Livres, une revue entrerait dans le catalogue, ses compteurs, les doublons et les bibliographies, et il faudrait écrire « et qui n'est pas une revue » à chaque lecture. Un numéro est recensé, rien de plus — ni prêt ni statut, tous deux liés à Livre par clé étrangère. Réversible. « Créer ou retrouver » et non « créer » : scanner le numéro suivant du même magazine retombe forcément sur la même revue. L'unicité de l'ISSN est un index partiel, sans quoi les revues sans ISSN se bloqueraient entre elles. Le flux 977 mène désormais à la fiche de la revue, et ne pré-remplit plus le formulaire d'un livre. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 87 +++ IDEES.md | 35 +- MaBibli.Api/Data/MaBibliDbContext.cs | 40 ++ .../20260819201008_Revues.Designer.cs | 498 ++++++++++++++++++ .../Data/Migrations/20260819201008_Revues.cs | 86 +++ .../MaBibliDbContextModelSnapshot.cs | 88 ++++ MaBibli.Api/Endpoints/RevuesEndpoints.cs | 96 ++++ MaBibli.Api/Program.cs | 3 + MaBibli.Api/Services/Revues/ServiceRevues.cs | 255 +++++++++ MaBibli.Client/Layout/MainLayout.razor | 5 +- MaBibli.Client/Pages/AjoutIsbn.razor | 84 ++- MaBibli.Client/Pages/Revue.razor | 302 +++++++++++ MaBibli.Client/Pages/Revues.razor | 134 +++++ MaBibli.Client/Services/CacheHorsLigne.cs | 9 + MaBibli.Client/Services/ServiceLivresApi.cs | 49 ++ MaBibli.Client/wwwroot/css/app.css | 29 + MaBibli.Shared/Dtos/PeriodiqueDetecte.cs | 11 +- MaBibli.Shared/Dtos/RevueDto.cs | 58 ++ MaBibli.Shared/Entites/Revue.cs | 103 ++++ MaBibli.Tests/ServiceRevuesTests.cs | 180 +++++++ 20 files changed, 2110 insertions(+), 42 deletions(-) create mode 100644 MaBibli.Api/Data/Migrations/20260819201008_Revues.Designer.cs create mode 100644 MaBibli.Api/Data/Migrations/20260819201008_Revues.cs create mode 100644 MaBibli.Api/Endpoints/RevuesEndpoints.cs create mode 100644 MaBibli.Api/Services/Revues/ServiceRevues.cs create mode 100644 MaBibli.Client/Pages/Revue.razor create mode 100644 MaBibli.Client/Pages/Revues.razor create mode 100644 MaBibli.Shared/Dtos/RevueDto.cs create mode 100644 MaBibli.Shared/Entites/Revue.cs create mode 100644 MaBibli.Tests/ServiceRevuesTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 531fbf4..35b6999 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -717,6 +717,25 @@ RapprochementRefuse (mémoire des « non » de l'utilisateur) ├── AuteurAId ─┐ UNIQUE, couple toujours rangé (petit Id d'abord) └── AuteurBId ─┘ pour qu'un refus vaille dans les deux sens +Revue (magazines — COMMUNS au foyer ; PAS des livres) +├── Id +├── Titre +├── TitreNormalise (index UNIQUE — une revue, une fiche) +├── Issn (index UNIQUE PARTIEL, WHERE Issn IS NOT NULL) +├── Editeur +├── DateAjout +└── AjoutePar (trace, pas une frontière) + +NumeroRevue (recensé seulement : ni prêt, ni statut de lecture) +├── Id +├── RevueId (FK Revue, cascade — un numéro n'existe que par sa revue) +├── Numero (obligatoire : « 43 », « hors-série 7 ») +├── NumeroNormalise +├── DateParution (UTC, nullable) +├── Note +├── DateAjout +└── UNIQUE (RevueId, NumeroNormalise) + Serie (sagas et cycles — COMMUNS au foyer, comme le catalogue) ├── Id ├── Titre @@ -1290,6 +1309,74 @@ l'œil. piste, avec sa limite connue — les œuvres remontent en langue originale et ne se rapprochent pas du catalogue par `CleOeuvre`. +## Revues et magazines — migration `Revues` (2026-08-19) + +Rouvre — et clôt — la décision du premier lot, qui écartait le catalogage des périodiques faute +de modèle. **Une fiche par revue, les numéros à l'intérieur** : douze numéros d'un même magazine +ne font pas douze fiches identiques, puisqu'ils partagent la fiche de leur revue. + +### Une table à part, pour la troisième fois — et c'est le même argument + +Comme `LivreSouhaite` et comme `Serie` : logée dans `Livres`, une revue entrerait +**mécaniquement** dans le catalogue, ses compteurs, la détection de doublons, les séries et les +bibliographies par auteur — et il faudrait ajouter « et qui n'est pas une revue » à **chaque** +lecture. Un invariant qu'on réécrit partout finit par être oublié quelque part. + +Vérifié en exécution : la revue *Médor* n'apparaît ni au catalogue, ni dans une recherche sur +son nom. + +⚠️ L'`Issn` vit sur `Revue`, **jamais** dans `Livre.Isbn` : c'était déjà la règle du flux `977`, +elle a maintenant un endroit où se ranger. + +### Un numéro est recensé, rien de plus (décidé avec l'utilisateur le 2026-08-19) + +Ni prêt, ni statut de lecture. Ces deux mécanismes sont attachés à `Livre` **par clé étrangère** : +les étendre demanderait une seconde table de prêts ou une parenté commune entre livres et +numéros, pour un usage non confirmé. Le choix est **réversible** — les ajouter plus tard ne +détruit rien de ce qui aura été saisi. + +Deux détails qui ne sont pas des détails : + +- **`Numero` est obligatoire.** Sans lui, deux numéros de la même revue seraient indiscernables, + et l'unicité `(RevueId, NumeroNormalise)` ne voudrait rien dire. +- **Les numéros s'affichent du plus récent au plus ancien**, à l'inverse d'une série qui suit son + ordre de lecture : devant un kiosque, la question est « ai-je le dernier ? ». ⚠️ Les numéros + **sans date ferment la liste** au lieu de passer pour les plus vieux — une date inconnue n'est + pas une date ancienne. + +### « Créer ou retrouver », et non « créer » + +`POST /api/revues` rend la revue existante quand l'ISSN **ou** le titre la désigne. C'est ce +qu'exige le flux du code-barres : scanner le numéro suivant du même magazine retombe forcément +sur la même revue, et le geste de l'utilisateur est toujours « voici un numéro de plus ». +Refuser le doublon l'obligerait à comprendre qu'il doit aller chercher la fiche lui-même. + +Corollaire testé : **une revue saisie à la main apprend son ISSN au premier scan**, ce qui permet +de la retrouver ensuite. L'unicité de l'ISSN est un index **partiel** (`WHERE Issn IS NOT NULL`), +sans quoi les revues sans ISSN se bloqueraient les unes les autres — deux `NULL` étant distincts +pour SQLite, c'est exactement le piège déjà rencontré sur `LivreSouhaite.AuteurNormalise`. + +### Le flux `977` ne remplit plus un formulaire de livre + +Avant, un code de périodique basculait sur la saisie manuelle d'un **livre**, titre pré-rempli. +C'était le moins mauvais choix tant que les revues n'avaient pas de modèle. L'écran propose +maintenant **« Ouvrir la fiche de cette revue »**, qui la crée au besoin et y mène. + +⚠️ **Le numéro se saisit à la main, et ce n'est pas un oubli** : les deux chiffres de parution du +code-barres ne sont pas un numéro fiable (déjà acté), et l'add-on **EAN-2** qui le porterait +vraiment n'a pas pu être vérifié — il faudrait un magazine réel devant la caméra. La piste reste +ouverte dans IDEES.md. + +Vérifié en exécution sur `9772466671438` : la BnF nomme « Médor », la fiche est créée avec son +ISSN, deux numéros s'y ajoutent, et **rescanner le même code retombe sur la même fiche**. + +### Un sixième onglet, mesuré + +`Catalogue / Auteurs / Séries / Revues / Prêts / Envies`. À 320 px — le plus étroit des +téléphones réalistes — les six libellés occupent **312 px sans troncature**, huit de marge. +⚠️ C'est la limite : un septième onglet, ou un libellé plus long que « Catalogue », imposera de +regrouper plutôt que d'ajouter. + ## Sagas et cycles — migration `SagasEtCycles` (2026-08-19) Cas donné : *La Légende de Drizzt*. C'est la première notion du projet qui porte sur une diff --git a/IDEES.md b/IDEES.md index 01535c1..be8af52 100644 --- a/IDEES.md +++ b/IDEES.md @@ -80,32 +80,23 @@ rendu en canvas, pas sur une image de caméra. # Retours d'usage du 2026-08-19 (3ᵉ série) -## Les magazines : une fiche par revue, les numéros à l'intérieur +## Ce qui reste des magazines : le numéro scanné -⚠️ La demande jumelle, **les BD**, est traitée : `TypeDocument` existe (voir `CLAUDE.md`). -Restent les champs propres à la BD — série, tome, scénariste/dessinateur — à ne faire que si le -besoin se confirme, le modèle mettant aujourd'hui tous les auteurs dans `LivreAuteur` sans -distinguer les rôles. La question des **séries** est traitée (voir « Sagas et cycles » dans `CLAUDE.md`) : une BD -d'une série s'y range comme un roman. +Les revues sont implémentées (voir `CLAUDE.md`) : fiche par revue, numéros à l'intérieur, et le +code-barres `977` mène désormais à la fiche de la revue. -⚠️ Rouvre la décision du lot 1, qui écartait le catalogage des périodiques faute de modèle. -Le modèle est maintenant choisi. +⚠️ **Le numéro, lui, se saisit à la main.** Les deux chiffres de parution du code-barres ne sont +pas fiables ; l'**add-on EAN-2** imprimé à côté du code principal est le bon candidat, et zbar +sait le décoder — mais `decoder()` l'écarte aujourd'hui, et rien n'a pu être vérifié faute d'un +magazine réel devant la caméra. À reprendre avec un exemplaire en main : lire l'EAN-2, le +comparer au numéro imprimé sur la couverture, et ne le pré-remplir que si les deux concordent +sur plusieurs numéros. -**Décidé le 2026-08-19 : une fiche par REVUE**, et dans sa page, la sélection d'un numéro -précis. C'est exactement ce qui évite le défaut redouté — douze numéros d'un même magazine ne -font plus douze fiches identiques, puisqu'ils partagent la fiche de leur revue. +Autres pistes non traitées, sans urgence : -Ce que cela demande : - -- un **ISSN** sur la fiche de revue, distinct de l'ISBN. ⚠️ Ranger un code `977` dans - `Livre.Isbn` casserait tout lookup ultérieur sur cette fiche ; -- une table de **numéros** rattachés à la revue, portant au minimum un numéro et une date de - parution ; -- reprendre le flux `977` : il nomme aujourd'hui la revue et bascule sur la saisie manuelle, il - devra créer ou compléter la fiche puis proposer d'ajouter le numéro scanné. ⚠️ Les deux - chiffres de parution du code-barres **ne sont pas un numéro fiable** ; l'add-on **EAN-2**, que - zbar sait maintenant décoder, est le bon candidat — à vérifier sur des magazines réels ; -- décider ce qu'un **prêt** signifie pour une revue : on prête un numéro, pas un abonnement. +- **Champs propres à la BD** (série, tome, scénariste/dessinateur). Le regroupement en série est + couvert par les sagas ; il reste la distinction des **rôles**, le modèle mettant tous les + auteurs dans `LivreAuteur` sans les distinguer. ## Auteurs et bibliographie diff --git a/MaBibli.Api/Data/MaBibliDbContext.cs b/MaBibli.Api/Data/MaBibliDbContext.cs index b6bde0b..243f471 100644 --- a/MaBibli.Api/Data/MaBibliDbContext.cs +++ b/MaBibli.Api/Data/MaBibliDbContext.cs @@ -32,6 +32,14 @@ public class MaBibliDbContext(DbContextOptions options) : DbCo public DbSet ElementsSerie => Set(); + /// + /// Revues et magazines. Table séparée des : une revue n'est pas + /// un livre, et n'a rien à faire dans le catalogue, ses compteurs ou ses doublons. + /// + public DbSet Revues => Set(); + + public DbSet NumerosRevue => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); @@ -177,6 +185,38 @@ public class MaBibliDbContext(DbContextOptions options) : DbCo .HasFilter("\"LivreId\" IS NOT NULL"); }); + modelBuilder.Entity(revue => + { + revue.Property(r => r.Titre).IsRequired(); + revue.Property(r => r.TitreNormalise).IsRequired(); + + revue.HasIndex(r => r.TitreNormalise).IsUnique(); + + // Index PARTIEL : une revue peut n'avoir aucun ISSN (saisie à la main), et deux + // NULL étant distincts pour SQLite, une unicité simple ne dirait rien. Filtré, il + // garantit qu'un ISSN connu ne désigne qu'une fiche — ce qui permet au flux « 977 » + // de retrouver la revue déjà créée au lieu d'en faire une seconde. + revue.HasIndex(r => r.Issn, "IX_Revues_Issn") + .IsUnique() + .HasFilter("\"Issn\" IS NOT NULL"); + }); + + modelBuilder.Entity(numero => + { + numero.Property(n => n.Numero).IsRequired(); + numero.Property(n => n.NumeroNormalise).IsRequired(); + + numero.HasOne(n => n.Revue) + .WithMany(r => r.Numeros) + .HasForeignKey(n => n.RevueId) + .OnDelete(DeleteBehavior.Cascade); + + // Un numéro ne se possède qu'une fois par revue : rescanner le même exemplaire ne + // doit pas allonger la liste. Contrairement aux livres, un second exemplaire d'un + // numéro de magazine n'a pas d'usage identifié — et l'index se retire si un jour si. + numero.HasIndex(n => new { n.RevueId, n.NumeroNormalise }).IsUnique(); + }); + modelBuilder.Entity(pret => { pret.Property(p => p.Emprunteur).IsRequired(); diff --git a/MaBibli.Api/Data/Migrations/20260819201008_Revues.Designer.cs b/MaBibli.Api/Data/Migrations/20260819201008_Revues.Designer.cs new file mode 100644 index 0000000..fe45520 --- /dev/null +++ b/MaBibli.Api/Data/Migrations/20260819201008_Revues.Designer.cs @@ -0,0 +1,498 @@ +// +using System; +using MaBibli.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace MaBibli.Api.Data.Migrations +{ + [DbContext(typeof(MaBibliDbContext))] + [Migration("20260819201008_Revues")] + partial class Revues + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.11"); + + modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CleRegroupement") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Nom") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NomNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CleRegroupement") + .IsUnique(); + + b.HasIndex("NomNormalise"); + + b.ToTable("Auteurs"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("SerieId") + .HasColumnType("INTEGER"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LivreId"); + + b.HasIndex(new[] { "SerieId" }, "IX_ElementsSerie_SerieId"); + + b.HasIndex(new[] { "SerieId", "LivreId" }, "IX_ElementsSerie_SerieId_LivreId") + .IsUnique() + .HasFilter("\"LivreId\" IS NOT NULL"); + + b.ToTable("ElementsSerie"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AjoutePar") + .HasColumnType("TEXT"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Editeur") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("Isbn") + .HasColumnType("TEXT"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TypeDocument") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Isbn"); + + b.HasIndex("TitreNormalise"); + + b.ToTable("Livres"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b => + { + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.Property("AuteurId") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.HasKey("LivreId", "AuteurId"); + + b.HasIndex("AuteurId"); + + b.ToTable("LivreAuteurs"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.LivreSouhaite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Annee") + .HasColumnType("TEXT"); + + b.Property("Auteur") + .HasColumnType("TEXT"); + + b.Property("AuteurNormalise") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Editeur") + .HasColumnType("TEXT"); + + b.Property("Isbn") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("Rang") + .HasColumnType("INTEGER"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Utilisateur") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "Utilisateur" }, "IX_LivresSouhaites_Utilisateur"); + + b.HasIndex(new[] { "Utilisateur", "TitreNormalise", "AuteurNormalise" }, "IX_LivresSouhaites_Utilisateur_Oeuvre") + .IsUnique(); + + b.ToTable("LivresSouhaites"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("DateParution") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("Numero") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NumeroNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevueId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RevueId", "NumeroNormalise") + .IsUnique(); + + b.ToTable("NumerosRevue"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DatePret") + .HasColumnType("TEXT"); + + b.Property("DateRetour") + .HasColumnType("TEXT"); + + b.Property("Emprunteur") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId"); + + b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId_EnCours") + .IsUnique() + .HasFilter("\"DateRetour\" IS NULL"); + + b.ToTable("Prets"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.RapprochementRefuse", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuteurAId") + .HasColumnType("INTEGER"); + + b.Property("AuteurBId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AuteurAId", "AuteurBId") + .IsUnique(); + + b.ToTable("RapprochementsRefuses"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AjoutePar") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Editeur") + .HasColumnType("TEXT"); + + b.Property("Issn") + .HasColumnType("TEXT"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TitreNormalise") + .IsUnique(); + + b.HasIndex(new[] { "Issn" }, "IX_Revues_Issn") + .IsUnique() + .HasFilter("\"Issn\" IS NOT NULL"); + + b.ToTable("Revues"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AjoutePar") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("SerieParenteId") + .HasColumnType("INTEGER"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SerieParenteId"); + + b.HasIndex("TitreNormalise") + .IsUnique(); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateMaj") + .HasColumnType("TEXT"); + + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.Property("Statut") + .HasColumnType("INTEGER"); + + b.Property("Utilisateur") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Utilisateur"); + + b.HasIndex("LivreId", "Utilisateur") + .IsUnique(); + + b.ToTable("StatutsLecture"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b => + { + b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") + .WithMany() + .HasForeignKey("LivreId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("MaBibli.Shared.Entites.Serie", "Serie") + .WithMany("Elements") + .HasForeignKey("SerieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Livre"); + + b.Navigation("Serie"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b => + { + b.HasOne("MaBibli.Shared.Entites.Auteur", "Auteur") + .WithMany("Livres") + .HasForeignKey("AuteurId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") + .WithMany("Auteurs") + .HasForeignKey("LivreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Auteur"); + + b.Navigation("Livre"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b => + { + b.HasOne("MaBibli.Shared.Entites.Revue", "Revue") + .WithMany("Numeros") + .HasForeignKey("RevueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Revue"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b => + { + b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") + .WithMany("Prets") + .HasForeignKey("LivreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Livre"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b => + { + b.HasOne("MaBibli.Shared.Entites.Serie", "SerieParente") + .WithMany("SousSeries") + .HasForeignKey("SerieParenteId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("SerieParente"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b => + { + b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") + .WithMany("Statuts") + .HasForeignKey("LivreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Livre"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b => + { + b.Navigation("Livres"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b => + { + b.Navigation("Auteurs"); + + b.Navigation("Prets"); + + b.Navigation("Statuts"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b => + { + b.Navigation("Numeros"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b => + { + b.Navigation("Elements"); + + b.Navigation("SousSeries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/MaBibli.Api/Data/Migrations/20260819201008_Revues.cs b/MaBibli.Api/Data/Migrations/20260819201008_Revues.cs new file mode 100644 index 0000000..aafbcee --- /dev/null +++ b/MaBibli.Api/Data/Migrations/20260819201008_Revues.cs @@ -0,0 +1,86 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MaBibli.Api.Data.Migrations +{ + /// + public partial class Revues : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Revues", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Titre = table.Column(type: "TEXT", nullable: false), + TitreNormalise = table.Column(type: "TEXT", nullable: false), + Issn = table.Column(type: "TEXT", nullable: true), + Editeur = table.Column(type: "TEXT", nullable: true), + DateAjout = table.Column(type: "TEXT", nullable: false), + AjoutePar = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Revues", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "NumerosRevue", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + RevueId = table.Column(type: "INTEGER", nullable: false), + Numero = table.Column(type: "TEXT", nullable: false), + NumeroNormalise = table.Column(type: "TEXT", nullable: false), + DateParution = table.Column(type: "TEXT", nullable: true), + Note = table.Column(type: "TEXT", nullable: true), + DateAjout = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_NumerosRevue", x => x.Id); + table.ForeignKey( + name: "FK_NumerosRevue_Revues_RevueId", + column: x => x.RevueId, + principalTable: "Revues", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_NumerosRevue_RevueId_NumeroNormalise", + table: "NumerosRevue", + columns: new[] { "RevueId", "NumeroNormalise" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Revues_Issn", + table: "Revues", + column: "Issn", + unique: true, + filter: "\"Issn\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_Revues_TitreNormalise", + table: "Revues", + column: "TitreNormalise", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "NumerosRevue"); + + migrationBuilder.DropTable( + name: "Revues"); + } + } +} diff --git a/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs b/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs index bede4e6..19711a2 100644 --- a/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs +++ b/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs @@ -197,6 +197,40 @@ namespace MaBibli.Api.Data.Migrations b.ToTable("LivresSouhaites"); }); + modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("DateParution") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("Numero") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NumeroNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevueId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RevueId", "NumeroNormalise") + .IsUnique(); + + b.ToTable("NumerosRevue"); + }); + modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b => { b.Property("Id") @@ -247,6 +281,44 @@ namespace MaBibli.Api.Data.Migrations b.ToTable("RapprochementsRefuses"); }); + modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AjoutePar") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Editeur") + .HasColumnType("TEXT"); + + b.Property("Issn") + .HasColumnType("TEXT"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TitreNormalise") + .IsUnique(); + + b.HasIndex(new[] { "Issn" }, "IX_Revues_Issn") + .IsUnique() + .HasFilter("\"Issn\" IS NOT NULL"); + + b.ToTable("Revues"); + }); + modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b => { b.Property("Id") @@ -349,6 +421,17 @@ namespace MaBibli.Api.Data.Migrations b.Navigation("Livre"); }); + modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b => + { + b.HasOne("MaBibli.Shared.Entites.Revue", "Revue") + .WithMany("Numeros") + .HasForeignKey("RevueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Revue"); + }); + modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b => { b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") @@ -395,6 +478,11 @@ namespace MaBibli.Api.Data.Migrations b.Navigation("Statuts"); }); + modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b => + { + b.Navigation("Numeros"); + }); + modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b => { b.Navigation("Elements"); diff --git a/MaBibli.Api/Endpoints/RevuesEndpoints.cs b/MaBibli.Api/Endpoints/RevuesEndpoints.cs new file mode 100644 index 0000000..9fa5a89 --- /dev/null +++ b/MaBibli.Api/Endpoints/RevuesEndpoints.cs @@ -0,0 +1,96 @@ +using MaBibli.Api.Services.Identite; +using MaBibli.Api.Services.Revues; +using MaBibli.Shared.Dtos; + +namespace MaBibli.Api.Endpoints; + +public static class RevuesEndpoints +{ + /// + /// Revues et magazines : une fiche par revue, les numéros à l'intérieur. + /// + /// + /// Communes au foyer, comme le catalogue : aucun point d'entrée ne prend d'identité, + /// et « ajoutePar » n'est qu'une trace. La lecture est unique — GET /api/revues rend + /// les revues avec leurs numéros, ce qui donne un seul instantané hors-ligne. + /// + public static IEndpointRouteBuilder MapRevuesEndpoints(this IEndpointRouteBuilder routes) + { + var groupe = routes.MapGroup("/api/revues").WithTags("Revues"); + + groupe.MapGet("/", async (IServiceRevues service, CancellationToken ct) => + Results.Ok(await service.ListerAsync(ct))) + .WithName("ListerRevues") + .WithSummary("Toutes les revues, avec les numéros possédés, du plus récent au plus ancien.") + .Produces>(); + + groupe.MapPost("/", async ( + EnregistrementRevue saisie, + IServiceRevues service, + IFournisseurUtilisateur utilisateurs, + CancellationToken ct) => + { + var resultat = await service.CreerOuRetrouverAsync( + saisie, utilisateurs.Obtenir().Identifiant, ct); + + return resultat.Erreur is not null + ? Results.BadRequest(new { erreur = resultat.Erreur }) + : Results.Ok(resultat.Revue); + }) + .WithName("CreerRevue") + .WithSummary("Crée la revue, ou rend celle qui existe déjà (même ISSN, ou même titre).") + .WithDescription( + "« Créer ou retrouver » et non « créer » : scanner le code-barres d'un second " + + "numéro retombe forcément sur la même revue, et le geste de l'utilisateur est " + + "toujours « voici un numéro de plus ».") + .Produces() + .Produces(StatusCodes.Status400BadRequest); + + groupe.MapPut("/{id:int}", async ( + int id, EnregistrementRevue saisie, IServiceRevues service, CancellationToken ct) => + Repondre(await service.ModifierAsync(id, saisie, ct))) + .WithName("ModifierRevue") + .Produces() + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status404NotFound); + + groupe.MapDelete("/{id:int}", async (int id, IServiceRevues service, CancellationToken ct) => + await service.SupprimerAsync(id, ct) ? Results.NoContent() : Results.NotFound()) + .WithName("SupprimerRevue") + .WithSummary("Supprime la revue et les numéros qu'elle porte : ils n'existent que par elle.") + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status404NotFound); + + groupe.MapPost("/{id:int}/numeros", async ( + int id, AjoutNumeroRevue saisie, IServiceRevues service, CancellationToken ct) => + Repondre(await service.AjouterNumeroAsync(id, saisie, ct))) + .WithName("AjouterNumeroRevue") + .WithSummary("Ajoute un numéro possédé. Le numéro est obligatoire et unique dans la revue.") + .Produces() + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status404NotFound); + + groupe.MapDelete("/numeros/{numeroId:int}", async ( + int numeroId, IServiceRevues service, CancellationToken ct) => + await service.RetirerNumeroAsync(numeroId, ct) + ? Results.NoContent() + : Results.NotFound()) + .WithName("RetirerNumeroRevue") + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status404NotFound); + + return routes; + } + + private static IResult Repondre(ResultatRevue resultat) + { + if (resultat.EstIntrouvable) + { + return Results.NotFound(); + } + + return resultat.Erreur is not null + ? Results.BadRequest(new { erreur = resultat.Erreur }) + : Results.Ok(resultat.Revue); + } +} diff --git a/MaBibli.Api/Program.cs b/MaBibli.Api/Program.cs index cad3fbb..38357ea 100644 --- a/MaBibli.Api/Program.cs +++ b/MaBibli.Api/Program.cs @@ -4,6 +4,7 @@ using MaBibli.Api.Services.Catalogue; using MaBibli.Api.Services.Identite; using MaBibli.Api.Services.Isbn; using MaBibli.Api.Services.Prets; +using MaBibli.Api.Services.Revues; using MaBibli.Api.Services.Series; using MaBibli.Api.Services.Souhaits; using Microsoft.EntityFrameworkCore; @@ -21,6 +22,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // Identité : lue dans les en-têtes injectés par SSOwat, avec repli sur un utilisateur simulé // configuré en développement (il n'y a pas de portail YunoHost en local). @@ -80,6 +82,7 @@ app.MapAuteursEndpoints(); app.MapPretsEndpoints(); app.MapSouhaitsEndpoints(); app.MapSeriesEndpoints(); +app.MapRevuesEndpoints(); app.MapBibliographieEndpoints(); app.MapIdentiteEndpoints(); diff --git a/MaBibli.Api/Services/Revues/ServiceRevues.cs b/MaBibli.Api/Services/Revues/ServiceRevues.cs new file mode 100644 index 0000000..54da00d --- /dev/null +++ b/MaBibli.Api/Services/Revues/ServiceRevues.cs @@ -0,0 +1,255 @@ +using MaBibli.Api.Data; +using MaBibli.Shared.Dtos; +using MaBibli.Shared.Entites; +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); + + 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) + .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."); + } + + var issn = Vide(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) + .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).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 = Vide(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).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), + DateAjout = DateTime.UtcNow, + }; + + ligne.RecalculerFormes(); + db.NumerosRevue.Add(ligne); + await db.SaveChangesAsync(ct); + + var relue = await db.Revues.AsNoTracking().Include(r => r.Numeros) + .FirstAsync(r => r.Id == revueId, ct); + + return ResultatRevue.Ok(Projeter(relue)); + } + + 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, + }) + .ToList(), + }; +} diff --git a/MaBibli.Client/Layout/MainLayout.razor b/MaBibli.Client/Layout/MainLayout.razor index e288018..7643e00 100644 --- a/MaBibli.Client/Layout/MainLayout.razor +++ b/MaBibli.Client/Layout/MainLayout.razor @@ -44,7 +44,7 @@ @* - Navigation permanente, en bas de l'écran : cinq onglets, toujours au même endroit. + Navigation permanente, en bas de l'écran : six onglets, toujours au même endroit. Elle remplace les listes de liens que chaque page portait dans sa barre d'actions — cinq boutons y passaient sur deux ou trois lignes sur un téléphone, et l'entrée « Envies » ne figurait pas partout. Les barres d'actions restantes ne portent plus que des ACTIONS. @@ -53,13 +53,14 @@ qu'un rang d'onglets sous le bandeau. Les actions de page se placent juste au-dessus (variable --mb-onglets-hauteur, partagée avec .actions-flottantes). - Les onglets restent actifs hors-ligne : les cinq écrans se consultent depuis leurs + Les onglets restent actifs hors-ligne : les six écrans se consultent depuis leurs instantanés. Ce sont les écritures qui se désactivent, pas la navigation. *@ diff --git a/MaBibli.Client/Pages/AjoutIsbn.razor b/MaBibli.Client/Pages/AjoutIsbn.razor index 2095131..15a1459 100644 --- a/MaBibli.Client/Pages/AjoutIsbn.razor +++ b/MaBibli.Client/Pages/AjoutIsbn.razor @@ -86,6 +86,20 @@ @if (_messagePeriodique is not null) {

@_messagePeriodique

+ + @* Une revue a désormais sa place : sa fiche, où l'on ajoute le numéro qu'on vient de + scanner. Avant, ce code retombait sur la saisie manuelle d'un LIVRE — ce qu'un magazine + n'est pas. *@ + @if (_periodique is not null) + { +
+ +
+ } } @foreach (var avertissement in _avertissements) @@ -183,6 +197,7 @@ private bool _focusAFaire = true; private string? _messagePeriodique; + private PeriodiqueDetecte? _periodique; private bool _chargement; private bool _enregistrement; private string? _erreur; @@ -232,6 +247,7 @@ { _erreur = null; _messagePeriodique = null; + _periodique = null; _avertissements = []; _etape = Etape.Scan; } @@ -258,6 +274,7 @@ _chargement = true; _erreur = null; _messagePeriodique = null; + _periodique = null; _avertissements = []; _etape = Etape.Recherche; @@ -314,33 +331,69 @@ } /// - /// Dit ce qu'est le code scanné, puis bascule sur la saisie manuelle. + /// Dit ce qu'est le code scanné, et propose la fiche de revue correspondante. /// /// - /// MaBibli ne gère pas les périodiques : ni numéro, ni date de parution. Le titre est - /// pré-rempli, mais pas l'ISBN — le code-barres d'une revue n'en est pas un, et le - /// ranger dans Livre.Isbn ferait échouer tout lookup ultérieur sur cette fiche. + /// ⚠️ Ne remplit surtout pas le formulaire d'un livre, ce qu'il faisait tant que les + /// revues n'avaient pas de modèle : un magazine n'est pas un livre, et ranger un code + /// 977 dans Livre.Isbn ferait échouer tout lookup ultérieur sur la fiche. + /// + /// Le numéro se saisit ensuite à la main : les deux chiffres de parution du + /// code-barres ne sont pas un numéro fiable, et l'add-on EAN-2 qui le porterait vraiment + /// n'a pas pu être vérifié sur un magazine réel. + /// /// private void DecrireLeMagazine(PeriodiqueDetecte revue) { var nom = revue.Titre is null ? "un magazine" : $"le magazine « {revue.Titre} »"; + _periodique = revue; _messagePeriodique = $"Ce code-barres désigne {nom} (ISSN {revue.Issn}), pas un livre : " + "il commence par 977, réservé aux publications en série. " - + "MaBibli ne gère pas les revues — vous pouvez tout de même créer une fiche à la main, " - + "sans ISBN."; - - _saisie = new EnregistrementLivre - { - Titre = revue.Titre ?? string.Empty, - Editeur = revue.Editeur, - Format = Format.Physique, - Statut = Statut.ALire, - }; + + "Ouvrez sa fiche pour y ajouter le numéro que vous venez de scanner."; _erreur = null; - _etape = Etape.Formulaire; + RevenirALaSaisie(); + } + + /// + /// Ouvre la fiche de la revue scannée, en la créant si elle n'existe pas encore. + /// + /// + /// Le serveur fait « créer ou retrouver » : scanner le numéro suivant du même magazine + /// retombe sur la même fiche, ce qui est exactement le geste attendu. + /// + /// Sans titre connu de la BnF, on n'invente rien : l'ISSN sert de nom provisoire, que + /// l'utilisateur corrigera sur la fiche. + /// + /// + private async Task OuvrirLaRevueAsync() + { + if (_periodique is not { } revue) + { + return; + } + + _enregistrement = true; + _erreur = null; + + var resultat = await Api.CreerRevueAsync(new EnregistrementRevue + { + Titre = revue.Titre ?? $"Revue ISSN {revue.Issn}", + Issn = revue.Issn, + Editeur = revue.Editeur, + }); + + _enregistrement = false; + + if (!resultat.EstOk) + { + _erreur = resultat.Erreur; + return; + } + + Navigation.NavigateTo($"revues/{resultat.Valeur!.Id}"); } /// Ramène l'écran à la saisie, champ ISBN de nouveau actif pour la douchette. @@ -371,6 +424,7 @@ { _erreur = null; _messagePeriodique = null; + _periodique = null; _erreurFormulaire = null; _doublons = null; _candidats = []; diff --git a/MaBibli.Client/Pages/Revue.razor b/MaBibli.Client/Pages/Revue.razor new file mode 100644 index 0000000..3eeeb1b --- /dev/null +++ b/MaBibli.Client/Pages/Revue.razor @@ -0,0 +1,302 @@ +@page "/revues/{Id:int}" +@inject ServiceLivresApi Api +@inject NavigationManager Navigation +@inject EtatReseau Reseau +@implements IDisposable + +MaBibli — @(_revue?.Titre ?? "revue") + +@if (_revue is null) +{ +

+ @(_chargement ? "Chargement…" : "Cette revue n'est pas dans les données enregistrées sur cet appareil.") +

+} +else +{ +

@_revue.Titre

+ +

+ @if (!string.IsNullOrWhiteSpace(_revue.Issn)) + { + ISSN @_revue.Issn + } + @if (!string.IsNullOrWhiteSpace(_revue.Editeur)) + { + @_revue.Editeur + } +

+ + @if (_erreur is not null) + { + + } + +

Numéros possédés

+ + @if (_revue.Numeros.Count == 0) + { +

+ Aucun numéro recensé pour l'instant. +

+ } + else + { + @* Du plus récent au plus ancien : devant un kiosque, la question est « ai-je le + dernier ? », pas « par quoi ai-je commencé ». C'est l'inverse d'une série, qui suit + son ordre de lecture. *@ +
    + @foreach (var numero in _revue.Numeros) + { +
  • +
    +

    N° @numero.Numero

    + +

    + @if (numero.DateParution is { } parution) + { + @parution.ToLocalTime().ToString("MMMM yyyy") + } + else + { + Date de parution inconnue + } +

    + + @if (!string.IsNullOrWhiteSpace(numero.Note)) + { +

    @numero.Note

    + } +
    + + +
  • + } +
+ } + +

Ajouter un numéro

+ + @* + ⚠️ Le numéro se saisit à la main, et ce n'est pas un oubli : les deux chiffres de + parution du code-barres ne sont PAS un numéro fiable (CLAUDE.md), et l'add-on EAN-2 qui + le porterait vraiment n'a pas pu être vérifié sur un magazine réel. + *@ +
+ + + + + +
+ +
+ +
+ +

La revue

+ + + + + + + +
+ + + +
+ + @if (_confirmeSuppression) + { +

+ Les @_revue.Numeros.Count numéro(s) recensés disparaîtront avec elle : ils n'existent + que par leur revue. +

+ } +} + +@code { + [Parameter] public int Id { get; set; } + + private RevueDto? _revue; + private bool _chargement = true; + private bool _enCours; + private string? _erreur; + private bool _confirmeSuppression; + + private string _titre = string.Empty; + private string? _issn; + private string? _editeur; + + private string _numero = string.Empty; + private DateTime? _parution; + private string? _note; + + private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne; + + protected override void OnInitialized() => Reseau.Change += SurChangementReseau; + + private void SurChangementReseau() => _ = InvokeAsync(async () => + { + await ChargerAsync(); + StateHasChanged(); + }); + + public void Dispose() => Reseau.Change -= SurChangementReseau; + + protected override Task OnParametersSetAsync() => ChargerAsync(); + + private async Task ChargerAsync() + { + _chargement = true; + _revue = (await Api.ListerRevuesAsync()).FirstOrDefault(r => r.Id == Id); + _chargement = false; + + if (_revue is not null) + { + _titre = _revue.Titre; + _issn = _revue.Issn; + _editeur = _revue.Editeur; + } + } + + private void Appliquer(RevueDto revue) + { + _revue = revue; + _titre = revue.Titre; + _issn = revue.Issn; + _editeur = revue.Editeur; + } + + private async Task SurTouche(KeyboardEventArgs e) + { + if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_numero) && Reseau.EnLigne) + { + await AjouterAsync(); + } + } + + private async Task AjouterAsync() + { + _enCours = true; + _erreur = null; + + // Comme pour les prêts : le produit une date SANS fuseau. Elle est + // déclarée locale avant conversion, sans quoi la parution se décalerait d'un jour pour + // la moitié du globe. + var parution = _parution is { } date + ? DateTime.SpecifyKind(date, DateTimeKind.Local).ToUniversalTime() + : (DateTime?)null; + + var resultat = await Api.AjouterNumeroAsync(Id, new AjoutNumeroRevue + { + Numero = _numero, + DateParution = parution, + Note = _note, + }); + + _enCours = false; + + if (!resultat.EstOk) + { + _erreur = resultat.Erreur; + return; + } + + Appliquer(resultat.Valeur!); + _numero = string.Empty; + _parution = null; + _note = null; + } + + private async Task RetirerAsync(NumeroRevueDto numero) + { + _enCours = true; + _erreur = await Api.RetirerNumeroAsync(numero.Id); + _enCours = false; + + if (_erreur is null) + { + await ChargerAsync(); + } + } + + private async Task EnregistrerAsync() + { + _enCours = true; + _erreur = null; + + var resultat = await Api.ModifierRevueAsync( + Id, new EnregistrementRevue { Titre = _titre, Issn = _issn, Editeur = _editeur }); + + _enCours = false; + + if (!resultat.EstOk) + { + _erreur = resultat.Erreur; + return; + } + + Appliquer(resultat.Valeur!); + } + + private async Task SupprimerAsync() + { + if (!_confirmeSuppression) + { + _confirmeSuppression = true; + return; + } + + _enCours = true; + _erreur = await Api.SupprimerRevueAsync(Id); + _enCours = false; + + if (_erreur is null) + { + Navigation.NavigateTo("revues"); + } + } +} diff --git a/MaBibli.Client/Pages/Revues.razor b/MaBibli.Client/Pages/Revues.razor new file mode 100644 index 0000000..80a73b7 --- /dev/null +++ b/MaBibli.Client/Pages/Revues.razor @@ -0,0 +1,134 @@ +@page "/revues" +@inject ServiceLivresApi Api +@inject EtatReseau Reseau +@implements IDisposable + +MaBibli — revues + +

Revues

+ +

+ Une fiche par revue, les numéros à l'intérieur — douze numéros d'un magazine ne font pas + douze fiches. Les revues ne sont pas au catalogue : ce ne sont pas des livres. +

+ +@if (_erreur is not null) +{ + +} + +
+ +
+ +
+ +
+ +@if (_revues is null) +{ +

Chargement…

+} +else if (_revues.Count == 0) +{ +

+ Aucune revue. Scannez le code-barres d'un magazine, ou saisissez son nom ci-dessus : + le code d'une revue commence par 977 et porte un ISSN, pas un ISBN. +

+} +else +{ +
    + @foreach (var revue in _revues) + { +
  • + @revue.Titre + +

    + + @(revue.Numeros.Count == 0 + ? "Aucun numéro" + : $"{revue.Numeros.Count} numéro{(revue.Numeros.Count > 1 ? "s" : "")}") + + + @if (!string.IsNullOrWhiteSpace(revue.Issn)) + { + ISSN @revue.Issn + } + + @if (!string.IsNullOrWhiteSpace(revue.Editeur)) + { + @revue.Editeur + } +

    + + @* Le dernier numéro reçu répond seul à « est-ce que je l'ai déjà ? », qui est la + question qu'on se pose devant un kiosque. *@ + @if (revue.Numeros.FirstOrDefault() is { } dernier) + { +

    Dernier : n° @dernier.Numero @Quand(dernier)

    + } +
  • + } +
+} + +@code { + private IReadOnlyList? _revues; + private string _nouvelle = string.Empty; + private bool _enCours; + private string? _erreur; + + private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne; + + private static string Quand(NumeroRevueDto numero) => + numero.DateParution is { } date ? $"({date.ToLocalTime():MM/yyyy})" : string.Empty; + + protected override void OnInitialized() => Reseau.Change += SurChangementReseau; + + private void SurChangementReseau() => _ = InvokeAsync(async () => + { + await ChargerAsync(); + StateHasChanged(); + }); + + public void Dispose() => Reseau.Change -= SurChangementReseau; + + protected override Task OnInitializedAsync() => ChargerAsync(); + + private async Task ChargerAsync() => _revues = await Api.ListerRevuesAsync(); + + private async Task SurTouche(KeyboardEventArgs e) + { + if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_nouvelle) && Reseau.EnLigne) + { + await CreerAsync(); + } + } + + private async Task CreerAsync() + { + _enCours = true; + _erreur = null; + + var resultat = await Api.CreerRevueAsync(new EnregistrementRevue { Titre = _nouvelle }); + + _enCours = false; + + if (!resultat.EstOk) + { + _erreur = resultat.Erreur; + return; + } + + _nouvelle = string.Empty; + await ChargerAsync(); + } +} diff --git a/MaBibli.Client/Services/CacheHorsLigne.cs b/MaBibli.Client/Services/CacheHorsLigne.cs index 03e6058..46e0835 100644 --- a/MaBibli.Client/Services/CacheHorsLigne.cs +++ b/MaBibli.Client/Services/CacheHorsLigne.cs @@ -48,6 +48,15 @@ public static class ClesCache /// /// public const string Series = "series"; + + /// + /// Les revues, avec les numéros possédés. Communes au foyer, comme les séries. + /// + /// + /// Sa raison d'être est la même que celle de la liste d'envies : savoir si l'on a déjà + /// ce numéro se demande devant le présentoir d'un kiosque, là où le réseau manque. + /// + public const string Revues = "revues"; } /// Un instantané relu du cache, avec la date de la synchronisation qui l'a produit. diff --git a/MaBibli.Client/Services/ServiceLivresApi.cs b/MaBibli.Client/Services/ServiceLivresApi.cs index 57417bd..327c09c 100644 --- a/MaBibli.Client/Services/ServiceLivresApi.cs +++ b/MaBibli.Client/Services/ServiceLivresApi.cs @@ -48,6 +48,7 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat await ListerPretsEnCoursAsync(ct); await ListerSouhaitsAsync(ct); await ListerSeriesAsync(ct); + await ListerRevuesAsync(ct); await ObtenirUtilisateurAsync(ct); return reseau.EnLigne; } @@ -518,6 +519,7 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat await ListerAuteursAsync(); await ListerSouhaitsAsync(); await ListerSeriesAsync(); + await ListerRevuesAsync(); } catch (Exception) { @@ -807,6 +809,53 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat public Task SupprimerSerieAsync(int id, CancellationToken ct = default) => SupprimerAsync($"api/series/{id}", ct); + // ───────────────────────────────────────────────────────────────────────── + // Revues et magazines. Communes au foyer ; une revue n'est pas un livre et ne passe donc + // jamais par le catalogue. + // ───────────────────────────────────────────────────────────────────────── + + public async Task> ListerRevuesAsync(CancellationToken ct = default) + { + var (ok, revues) = await EssayerAsync( + () => http.GetFromJsonAsync>("api/revues", Json, ct)); + + if (ok) + { + await MemoriserAsync(ClesCache.Revues, revues ?? []); + return revues ?? []; + } + + var instantane = await LireCacheAsync>(ClesCache.Revues); + return instantane?.Donnees ?? []; + } + + /// + /// Crée la revue, ou rend celle qui existe déjà — même ISSN, ou même titre. + /// + /// + /// C'est ce que réclame le scan d'un code-barres 977 : le second numéro d'un + /// magazine retombe forcément sur la même revue, et l'utilisateur ne fait jamais qu'un + /// geste, « voici un numéro de plus ». + /// + public async Task> CreerRevueAsync( + EnregistrementRevue saisie, CancellationToken ct = default) => + await EcrireAsync(() => http.PostAsJsonAsync("api/revues", saisie, Json, ct), ct); + + public async Task> ModifierRevueAsync( + int id, EnregistrementRevue saisie, CancellationToken ct = default) => + await EcrireAsync(() => http.PutAsJsonAsync($"api/revues/{id}", saisie, Json, ct), ct); + + public async Task> AjouterNumeroAsync( + int revueId, AjoutNumeroRevue saisie, CancellationToken ct = default) => + await EcrireAsync( + () => http.PostAsJsonAsync($"api/revues/{revueId}/numeros", saisie, Json, ct), ct); + + public Task RetirerNumeroAsync(int numeroId, CancellationToken ct = default) => + SupprimerAsync($"api/revues/numeros/{numeroId}", ct); + + public Task SupprimerRevueAsync(int id, CancellationToken ct = default) => + SupprimerAsync($"api/revues/{id}", ct); + /// Suppression générique : refus hors-ligne, 204 attendu, instantanés rafraîchis. private async Task SupprimerAsync(string url, CancellationToken ct) { diff --git a/MaBibli.Client/wwwroot/css/app.css b/MaBibli.Client/wwwroot/css/app.css index bb35559..032cd7f 100644 --- a/MaBibli.Client/wwwroot/css/app.css +++ b/MaBibli.Client/wwwroot/css/app.css @@ -1015,3 +1015,32 @@ body { font-size: 0.9rem; color: var(--mb-texte-doux); } + +/* --- Revues et numéros --- */ + +.liste-numeros { + list-style: none; + margin: 0.5rem 0; + padding: 0; +} + +.carte-numero { + display: flex; + gap: 0.6rem; + align-items: flex-start; + justify-content: space-between; + padding: 0.6rem 0; + border-bottom: 1px solid var(--mb-bord); +} + +/* Numéro, date et note tiennent sur une ligne dès qu'il y a la place, et se replient sinon : + la saisie d'un numéro est répétitive, on ne veut pas la faire défiler. */ +.champs-numero { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.champs-numero .champ { + flex: 1 1 8rem; +} diff --git a/MaBibli.Shared/Dtos/PeriodiqueDetecte.cs b/MaBibli.Shared/Dtos/PeriodiqueDetecte.cs index ef31890..e547b15 100644 --- a/MaBibli.Shared/Dtos/PeriodiqueDetecte.cs +++ b/MaBibli.Shared/Dtos/PeriodiqueDetecte.cs @@ -5,10 +5,15 @@ namespace MaBibli.Shared.Dtos; /// (préfixe 977) et non d'un livre. /// /// -/// Le modèle de données ne gère pas les périodiques : ni numéro, ni date de parution. -/// Cet objet n'existe donc pas pour cataloguer une revue, mais pour dire à l'utilisateur ce -/// qu'il vient de scanner au lieu de lui répondre « aucun résultat » — ce qui l'enverrait +/// Cet objet décrit un code-barres, il ne catalogue rien : il dit à l'utilisateur ce +/// qu'il vient de scanner au lieu de lui répondre « aucun résultat » — ce qui l'enverrait /// chercher un défaut là où il n'y en a pas. +/// +/// Depuis le 2026-08-19, les revues ont leur modèle () : l'écran +/// enchaîne donc sur la fiche de la revue, où s'ajoute le numéro scanné. ⚠️ Le numéro ne +/// figure pas ici : les deux chiffres de parution du code-barres ne sont pas fiables, et +/// l'add-on EAN-2 qui le porterait vraiment n'a pas été vérifié sur un magazine réel. +/// /// public record PeriodiqueDetecte { diff --git a/MaBibli.Shared/Dtos/RevueDto.cs b/MaBibli.Shared/Dtos/RevueDto.cs new file mode 100644 index 0000000..e59ae85 --- /dev/null +++ b/MaBibli.Shared/Dtos/RevueDto.cs @@ -0,0 +1,58 @@ +namespace MaBibli.Shared.Dtos; + +/// +/// Une revue et les numéros qu'on en possède. +/// +/// +/// Comme pour les séries, une seule lecture rend tout : la liste des revues sert aussi +/// le détail de chacune, ce qui donne un unique instantané hors-ligne à tenir à jour. +/// +public record RevueDto +{ + public required int Id { get; init; } + + public required string Titre { get; init; } + + /// ISSN à tiret, quand il est connu. Identifiant de la revue, jamais un ISBN. + public string? Issn { get; init; } + + public string? Editeur { get; init; } + + public string? AjoutePar { get; init; } + + /// Numéros possédés, du plus récent au plus ancien. + public IReadOnlyList Numeros { get; init; } = []; +} + +/// Un numéro possédé. Recensé seulement : ni prêt, ni statut de lecture. +public record NumeroRevueDto +{ + public required int Id { get; init; } + + public required string Numero { get; init; } + + public DateTime? DateParution { get; init; } + + public string? Note { get; init; } +} + +/// Charge utile de création ou de modification d'une revue. +public record EnregistrementRevue +{ + public string Titre { get; set; } = string.Empty; + + public string? Issn { get; set; } + + public string? Editeur { get; set; } +} + +/// Charge utile d'ajout d'un numéro. +public record AjoutNumeroRevue +{ + /// Obligatoire : deux numéros sans numéro seraient indiscernables. + public string Numero { get; set; } = string.Empty; + + public DateTime? DateParution { get; set; } + + public string? Note { get; set; } +} diff --git a/MaBibli.Shared/Entites/Revue.cs b/MaBibli.Shared/Entites/Revue.cs new file mode 100644 index 0000000..7c90f33 --- /dev/null +++ b/MaBibli.Shared/Entites/Revue.cs @@ -0,0 +1,103 @@ +using MaBibli.Shared.Textes; + +namespace MaBibli.Shared.Entites; + +/// +/// Une revue — magazine, périodique —, et non l'un de ses numéros. +/// +/// +/// Pourquoi une table à part et non une ligne de Livres ? Même raisonnement que +/// pour et , et il vaut d'être répété : logée +/// dans Livres, une revue entrerait mécaniquement dans le catalogue, ses +/// compteurs, la détection de doublons, les séries et les bibliographies par auteur — et il +/// faudrait écrire « et qui n'est pas une revue » à chaque lecture. Un invariant qu'on réécrit +/// partout finit par être oublié quelque part. +/// +/// Une fiche par revue, les numéros à l'intérieur. C'est ce qui évite le défaut redouté +/// dès le premier lot : douze numéros d'un même magazine ne font pas douze fiches identiques, +/// puisqu'ils partagent la fiche de leur revue. +/// +/// +/// ⚠️ L' est un identifiant de revue, jamais un ISBN. Ranger un code +/// 977 dans Livre.Isbn ferait échouer tout lookup ultérieur sur cette fiche — +/// c'est précisément ce que cette table évite. +/// +/// +public class Revue +{ + public int Id { get; set; } + + public string Titre { get; set; } = string.Empty; + + /// Titre mis à plat. Index unique : une revue, une fiche. + public string TitreNormalise { get; set; } = string.Empty; + + /// + /// ISSN sous sa forme canonique à tiret (2466-6718), quand on le connaît. + /// + /// + /// Facultatif : une revue se catalogue à la main sans que personne n'ait scanné son + /// code-barres. Son unicité est portée par un index partiel — deux NULL sont + /// distincts pour SQLite, une unicité simple laisserait donc passer autant de revues sans + /// ISSN qu'on veut, ce qui est justement le comportement voulu. + /// + public string? Issn { get; set; } + + public string? Editeur { get; set; } + + public DateTime DateAjout { get; set; } + + /// Trace de saisie. Pas une frontière : les revues sont communes au foyer. + public string? AjoutePar { get; set; } + + public List Numeros { get; set; } = []; + + public void RecalculerFormes() + { + Titre = Titre.Trim(); + TitreNormalise = NormalisationTexte.Normaliser(Titre); + Issn = string.IsNullOrWhiteSpace(Issn) ? null : Issn.Trim(); + } +} + +/// +/// Un numéro possédé d'une revue. +/// +/// +/// Décidé le 2026-08-19 : un numéro est recensé, rien de plus. Ni prêt, ni statut de +/// lecture — ces deux mécanismes sont attachés à par clé étrangère, et les +/// rouvrir demanderait une seconde table de prêts ou une parenté commune, pour un usage non +/// confirmé. Le choix est réversible : les ajouter plus tard ne détruit rien de ce qui +/// est enregistré ici. +/// +public class NumeroRevue +{ + public int Id { get; set; } + + public int RevueId { get; set; } + + public Revue? Revue { get; set; } + + /// + /// Le numéro tel qu'il est imprimé (« 43 », « hors-série 7 »). Obligatoire : + /// sans lui, deux numéros de la même revue seraient indiscernables. + /// + public string Numero { get; set; } = string.Empty; + + /// Forme mise à plat, porteuse de l'unicité au sein de la revue. + public string NumeroNormalise { get; set; } = string.Empty; + + /// Date de parution, quand elle est connue. En UTC comme toutes les dates. + public DateTime? DateParution { get; set; } + + /// Note libre : « dossier sur l'eau », « prêté à Paul », « acheté en gare ». + public string? Note { get; set; } + + public DateTime DateAjout { get; set; } + + public void RecalculerFormes() + { + Numero = Numero.Trim(); + NumeroNormalise = NormalisationTexte.Normaliser(Numero); + } +} diff --git a/MaBibli.Tests/ServiceRevuesTests.cs b/MaBibli.Tests/ServiceRevuesTests.cs new file mode 100644 index 0000000..4168471 --- /dev/null +++ b/MaBibli.Tests/ServiceRevuesTests.cs @@ -0,0 +1,180 @@ +using MaBibli.Api.Data; +using MaBibli.Api.Services.Revues; +using MaBibli.Shared.Dtos; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace MaBibli.Tests; + +/// +/// Revues et numéros, sur une base SQLite en mémoire : l'unicité d'un ISSN passe par un +/// index partiel, qui n'existe qu'en base. +/// +/// +/// L'invariant central : une fiche par revue, les numéros à l'intérieur. C'est ce qui +/// évite douze fiches identiques pour douze numéros d'un même magazine. +/// +public class ServiceRevuesTests : IDisposable +{ + private readonly SqliteConnection _connexion; + private readonly MaBibliDbContext _db; + private readonly ServiceRevues _revues; + + private const string Mathieu = "mathieu"; + + public ServiceRevuesTests() + { + _connexion = new SqliteConnection("Data Source=:memory:"); + _connexion.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(_connexion) + .Options; + + _db = new MaBibliDbContext(options); + _db.Database.EnsureCreated(); + + _revues = new ServiceRevues(_db); + } + + private async Task RevueAsync(string titre = "Médor", string? issn = "2466-6718") + { + var resultat = await _revues.CreerOuRetrouverAsync( + new EnregistrementRevue { Titre = titre, Issn = issn }, Mathieu); + + Assert.Null(resultat.Erreur); + return resultat.Revue!; + } + + [Fact] + public async Task Rescanner_la_meme_revue_ne_cree_pas_une_seconde_fiche() + { + var premiere = await RevueAsync(); + + // Le second numéro du même magazine porte le même ISSN : c'est le cas NORMAL du flux + // « 977 », et il ne doit surtout pas produire une fiche de plus. + var seconde = await RevueAsync("Médor (Bruxelles)"); + + Assert.Equal(premiere.Id, seconde.Id); + Assert.Single(await _revues.ListerAsync()); + } + + [Fact] + public async Task Une_revue_saisie_a_la_main_apprend_son_issn_au_premier_scan() + { + var aLaMain = await RevueAsync("Médor", issn: null); + + var scannee = await RevueAsync("Médor"); + + Assert.Equal(aLaMain.Id, scannee.Id); + Assert.Equal("2466-6718", scannee.Issn); + } + + [Fact] + public async Task Les_numeros_vivent_dans_la_revue_et_ne_font_pas_de_fiches() + { + var revue = await RevueAsync(); + + await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue + { + Numero = "43", + DateParution = new DateTime(2024, 6, 1, 0, 0, 0, DateTimeKind.Utc), + }); + + var apres = (await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue + { + Numero = "44", + DateParution = new DateTime(2024, 9, 1, 0, 0, 0, DateTimeKind.Utc), + })).Revue!; + + Assert.Single(await _revues.ListerAsync()); + + // Du plus récent au plus ancien : sur un magazine, c'est le dernier reçu qu'on cherche. + Assert.Equal(["44", "43"], apres.Numeros.Select(n => n.Numero)); + } + + [Fact] + public async Task Un_numero_sans_numero_est_refuse() + { + var revue = await RevueAsync(); + + var resultat = await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue { Numero = " " }); + + Assert.NotNull(resultat.Erreur); + } + + [Fact] + public async Task Le_meme_numero_ne_sajoute_pas_deux_fois() + { + var revue = await RevueAsync(); + await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue { Numero = "43" }); + + var doublon = await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue { Numero = "43" }); + + Assert.NotNull(doublon.Erreur); + } + + [Fact] + public async Task Un_numero_a_paraitre_est_refuse() + { + var revue = await RevueAsync(); + + var resultat = await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue + { + Numero = "99", + DateParution = DateTime.UtcNow.AddMonths(2), + }); + + Assert.NotNull(resultat.Erreur); + } + + [Fact] + public async Task Les_numeros_sans_date_ferment_la_liste() + { + var revue = await RevueAsync(); + await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue { Numero = "hors-série 1" }); + + var apres = (await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue + { + Numero = "43", + DateParution = new DateTime(2024, 6, 1, 0, 0, 0, DateTimeKind.Utc), + })).Revue!; + + // Une date inconnue ne doit pas faire passer un numéro pour le plus ancien. + Assert.Equal(["43", "hors-série 1"], apres.Numeros.Select(n => n.Numero)); + } + + [Fact] + public async Task Supprimer_la_revue_emporte_ses_numeros() + { + var revue = await RevueAsync(); + await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue { Numero = "43" }); + + Assert.True(await _revues.SupprimerAsync(revue.Id)); + + // Contrairement aux livres d'une série, un numéro n'existe que par sa revue. + Assert.Empty(await _db.NumerosRevue.ToListAsync()); + } + + [Fact] + public async Task Deux_revues_ne_partagent_ni_titre_ni_issn() + { + await RevueAsync(); + var autre = await RevueAsync("Socialter", issn: "2270-4426"); + + var titrePris = await _revues.ModifierAsync( + autre.Id, new EnregistrementRevue { Titre = "médor" }); + var issnPris = await _revues.ModifierAsync( + autre.Id, new EnregistrementRevue { Titre = "Socialter", Issn = "2466-6718" }); + + Assert.NotNull(titrePris.Erreur); + Assert.NotNull(issnPris.Erreur); + } + + public void Dispose() + { + _db.Dispose(); + _connexion.Dispose(); + GC.SuppressFinalize(this); + } +}