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); + } +}