From 383ed43227742527ee448ab5b2edc44b4bc79226 Mon Sep 17 00:00:00 2001
From: mathieu
Date: Wed, 19 Aug 2026 21:11:39 +0200
Subject: [PATCH] =?UTF-8?q?Signaler=20les=20doublons=20=C3=A0=20l'ajout=20?=
=?UTF-8?q?d'un=20livre,=20sans=20jamais=20les=20refuser?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Rien n'empêchait de rescanner un livre déjà catalogué. Plutôt qu'un index
unique — qui interdirait le second exemplaire, cas parfaitement légitime —
la création répond 409 avec les fiches semblables, et « confirmerDoublon »
enregistre la même saisie.
Deux critères, dont aucun n'est une clé : ISBN identique, ou clé d'œuvre
et auteur communs. La clé d'œuvre étant un préfixe de TitreNormalise, SQL
dégrossit sur la colonne indexée et l'égalité exacte se vérifie ensuite en
mémoire.
Vérifié en exécution : avertissement, retour au formulaire intact, et ajout
confirmé créant bien un second exemplaire.
Co-Authored-By: Claude Opus 5
---
CLAUDE.md | 59 +++++++
IDEES.md | 22 ---
MaBibli.Api/Endpoints/LivresEndpoints.cs | 21 ++-
.../Services/Catalogue/ServiceCatalogue.cs | 156 +++++++++++++++++-
.../Composants/AvertissementDoublon.razor | 74 +++++++++
MaBibli.Client/Pages/AjoutIsbn.razor | 54 ++++--
MaBibli.Client/Pages/AjoutManuel.razor | 49 +++++-
MaBibli.Client/Services/ServiceLivresApi.cs | 116 +++++++++++--
MaBibli.Shared/Dtos/DoublonsLivre.cs | 24 +++
MaBibli.Tests/ServiceCatalogueTests.cs | 94 +++++++++++
10 files changed, 600 insertions(+), 69 deletions(-)
create mode 100644 MaBibli.Client/Composants/AvertissementDoublon.razor
create mode 100644 MaBibli.Shared/Dtos/DoublonsLivre.cs
diff --git a/CLAUDE.md b/CLAUDE.md
index 899773b..25c3553 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -835,6 +835,65 @@ livre sorti depuis six mois qu'on avait oublié, pas celui prêté hier.
(`ServiceLivresApi.EnUtc`). Sans cela le prêt se décalerait d'un jour pour la moitié du globe.
Vérifié en exécution : saisie du 12/08 → `2026-08-11 22:00` en base (CEST).
+## Doublons du catalogue — avertir, jamais refuser (2026-08-19)
+
+Défaut remonté en usage : rien n'empêchait de rescanner un livre déjà catalogué, et
+`ServiceCatalogue.CreerAsync` ne faisait aucun contrôle.
+
+**Décidé : un avertissement confirmable**, pas un index unique. `HasIndex(l => l.Isbn)` reste
+délibérément **non unique**, et il ne faut pas le rendre unique « pour bien faire » :
+
+- **posséder deux exemplaires est légitime** — on garde le sien et on prête l'autre. C'est
+ précisément le cas qu'un index unique rendrait impossible, sans recours ;
+- **l'ISBN est facultatif** (livres anciens, tirages sans ISBN) : il ne peut pas porter à lui
+ seul l'unicité, et SQLite tient deux `NULL` pour distincts de toute façon.
+
+⚠️ Le contraste avec la liste d'envies est voulu : `LivreSouhaite` **refuse** le doublon par
+index unique. Une envie décrit une œuvre, dont on ne souhaite pas deux exemplaires ; un livre
+décrit un **objet**, qu'on peut posséder en double. Ne pas uniformiser les deux.
+
+### Deux critères, dont aucun n'est une clé
+
+| Critère | Attrape | Rate, ou signale à tort |
+|---|---|---|
+| **ISBN identique** | le rescan du même livre, cas visé | rien quand l'ISBN manque |
+| **`CleOeuvre` + un auteur commun** | la saisie manuelle sans ISBN, le sous-titre, l'ordre `Nom, Prénom` | le poche à côté du grand format — deux exemplaires bien réels |
+
+L'auteur est **exigé en plus du titre** (au sens de `RapprochementAuteurs`) : sans lui, deux
+recueils « Nouvelles » sans rapport se signaleraient l'un l'autre. Deux fiches **sans aucun**
+auteur concordent, le titre étant alors tout ce dont on dispose.
+
+⚠️ **La clé d'œuvre est un préfixe de `TitreNormalise`** — la troncature du sous-titre précède
+la normalisation, qui préserve l'ordre des caractères. C'est ce qui permet de dégrossir en SQL
+sur la colonne indexée (`== cle` ou `StartsWith(cle + " ")`) puis de vérifier l'égalité exacte
+des clés en mémoire. Sans cette seconde passe, « Germinal les années noires » serait signalé
+comme doublon de « Germinal » ; sans la première, il faudrait charger tout le catalogue à
+chaque ajout.
+
+### 409, et surtout pas 400
+
+`POST /api/livres` répond **409 Conflict** avec `DoublonsLivre` (message + les fiches en cause),
+et `?confirmerDoublon=true` enregistre la même saisie sans plus rien demander.
+
+⚠️ **Un doublon n'est pas une erreur**, et le code de statut n'est pas un détail : la saisie est
+valide, c'est le catalogue qui contient déjà quelque chose. D'où un **troisième état** dans
+`ResultatEcriture` et dans `ResultatCreation` côté client, au lieu d'un message logé dans
+`Erreur` — un texte rouge dirait « c'est raté » là où l'écran doit proposer « ajouter quand
+même ». Le 409 traverse donc `EnvoyerAsync` sans passer par le traitement d'erreur générique,
+qui l'aurait aplati en « L'enregistrement a échoué (409) ».
+
+### L'écran montre les fiches, il ne les décrit pas
+
+`AvertissementDoublon` remplace le formulaire tant que la question n'est pas tranchée (la saisie
+attend dans `_saisie` et revient intacte si l'on renonce), et affiche chaque livre en cause
+**en lien vers sa fiche** : la vraie question est « est-ce bien le même livre ? », et on n'y
+répond qu'en regardant celui qui est déjà là. Le prêt en cours y figure — un exemplaire dehors
+est justement une raison d'en vouloir un second.
+
+**Ce qui n'est délibérément pas fait** : le contrôle ne porte que sur la **création**. Une
+édition qui rendrait deux fiches identiques est un geste délibéré sur une fiche existante, pas
+un scan répété par mégarde.
+
## Liste d'envies — implémentée le 2026-08-18
### Une table à part, et non un statut de plus
diff --git a/IDEES.md b/IDEES.md
index 5b0060d..a79180e 100644
--- a/IDEES.md
+++ b/IDEES.md
@@ -80,28 +80,6 @@ rendu en canvas, pas sur une image de caméra.
# Retours d'usage du 2026-08-19 (3ᵉ série)
-## Bug — un livre déjà catalogué peut être réencodé
-
-Vérifié dans le code : `ServiceCatalogue.CreerAsync` ne fait **aucun** contrôle de doublon, et
-`HasIndex(l => l.Isbn)` n'est **pas** unique. Scanner deux fois le même livre crée deux fiches.
-
-⚠️ **À trancher avant de coder : refuser, ou avertir ?** Refuser tout net serait un piège, car
-posséder deux exemplaires est légitime — on garde le sien et on prête l'autre. Et l'ISBN est
-facultatif, donc il ne peut pas être le seul critère.
-
-Trois critères candidats, de plus en plus large :
-
-| Critère | Attrape | Rate / gêne |
-|---|---|---|
-| **ISBN identique** | le rescan du même livre, cas visé | rien si l'ISBN manque ; refuse un second exemplaire |
-| **Clé d'œuvre + auteur** (`CleOeuvre`, comme la liste d'envies) | la saisie manuelle sans ISBN | confond le poche et le grand format, qui sont deux éditions possédées |
-| **les deux, en avertissement** | tout ce qui précède | demande une confirmation de plus |
-
-Piste privilégiée : **avertir et laisser confirmer** (« Vous avez déjà *Germinal* — ajouter
-quand même ? »), plutôt que l'index unique qui interdirait le second exemplaire. La liste
-d'envies, elle, refuse — mais elle décrit une envie, pas un objet, et deux exemplaires d'une
-envie n'ont pas de sens.
-
## À l'ajout d'un livre, signaler l'envie correspondante
**Décidé le 2026-08-19 : on ne supprime rien, on signale.** La liste d'envies marque l'entrée
diff --git a/MaBibli.Api/Endpoints/LivresEndpoints.cs b/MaBibli.Api/Endpoints/LivresEndpoints.cs
index b418683..4dc5d95 100644
--- a/MaBibli.Api/Endpoints/LivresEndpoints.cs
+++ b/MaBibli.Api/Endpoints/LivresEndpoints.cs
@@ -69,21 +69,36 @@ public static class LivresEndpoints
groupe.MapPost("/", async (
EnregistrementLivre saisie,
+ bool? confirmerDoublon,
IServiceCatalogue service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{
// AjoutePar vient du serveur, pas de la charge utile.
var utilisateur = utilisateurs.Obtenir();
- var resultat = await service.CreerAsync(saisie, utilisateur.Identifiant, ct);
+ var resultat = await service.CreerAsync(
+ saisie, utilisateur.Identifiant, confirmerDoublon ?? false, ct);
- return resultat.Erreur is not null
- ? Results.BadRequest(new { erreur = resultat.Erreur })
+ if (resultat.Erreur is not null)
+ {
+ return Results.BadRequest(new { erreur = resultat.Erreur });
+ }
+
+ // 409 et non 400 : la saisie est valide, c'est le catalogue qui contient déjà
+ // quelque chose de semblable. Le même appel avec « confirmerDoublon » aboutira.
+ return resultat.Doublons is { } doublons
+ ? Results.Conflict(doublons)
: Results.Created($"/api/livres/{resultat.Livre!.Id}", resultat.Livre);
})
.WithName("CreerLivre")
.WithSummary("Ajoute un livre. « AjoutePar » est déterminé par le serveur.")
+ .WithDescription(
+ "Répond 409 quand le catalogue contient déjà un livre de même ISBN, ou de même "
+ + "titre et même auteur, en donnant les fiches en cause. Ce n'est pas un refus : "
+ + "posséder deux exemplaires est légitime, et « confirmerDoublon=true » "
+ + "enregistre la même saisie sans plus rien demander.")
.Produces(StatusCodes.Status201Created)
+ .Produces(StatusCodes.Status409Conflict)
.Produces(StatusCodes.Status400BadRequest);
groupe.MapPut("/{id:int}", async (
diff --git a/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs b/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs
index ae2d970..6d541b9 100644
--- a/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs
+++ b/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs
@@ -3,21 +3,34 @@ using MaBibli.Shared.Catalogue;
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Entites;
using MaBibli.Shared.Isbn;
+using MaBibli.Shared.Textes;
using Microsoft.EntityFrameworkCore;
namespace MaBibli.Api.Services.Catalogue;
-/// Issue d'une écriture : soit le livre, soit un message d'erreur destiné à l'utilisateur.
-public readonly record struct ResultatEcriture(LivreDto? Livre, string? Erreur)
+///
+/// Issue d'une écriture : le livre, un message d'erreur, ou des doublons à faire confirmer.
+///
+public readonly record struct ResultatEcriture(LivreDto? Livre, string? Erreur, DoublonsLivre? Doublons)
{
- public static ResultatEcriture Ok(LivreDto livre) => new(livre, null);
+ public static ResultatEcriture Ok(LivreDto livre) => new(livre, null, null);
- public static ResultatEcriture Invalide(string message) => new(null, message);
+ public static ResultatEcriture Invalide(string message) => new(null, message, null);
- /// Ni livre ni erreur : la ressource demandée n'existe pas.
- public static readonly ResultatEcriture Introuvable = new(null, null);
+ ///
+ /// L'écriture n'a pas eu lieu : elle ressemble trop à ce qui est déjà là.
+ ///
+ ///
+ /// Ce n'est pas une erreur — la même saisie, confirmée, s'enregistrera telle quelle.
+ /// D'où un troisième état plutôt qu'un message dans Erreur : l'écran doit proposer
+ /// « ajouter quand même », ce qu'un texte rouge ne suggère pas.
+ ///
+ public static ResultatEcriture Doublon(DoublonsLivre doublons) => new(null, null, doublons);
- public bool EstIntrouvable => Livre is null && Erreur is null;
+ /// Ni livre, ni erreur, ni doublon : la ressource demandée n'existe pas.
+ public static readonly ResultatEcriture Introuvable = new(null, null, null);
+
+ public bool EstIntrouvable => Livre is null && Erreur is null && Doublons is null;
}
public interface IServiceCatalogue
@@ -28,7 +41,10 @@ public interface IServiceCatalogue
Task ObtenirAsync(int id, string? utilisateur, CancellationToken ct = default);
Task CreerAsync(
- EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default);
+ EnregistrementLivre saisie,
+ string? utilisateur,
+ bool confirmerDoublon = false,
+ CancellationToken ct = default);
Task ModifierAsync(
int id, EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default);
@@ -89,13 +105,25 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
}
public async Task CreerAsync(
- EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default)
+ EnregistrementLivre saisie,
+ string? utilisateur,
+ bool confirmerDoublon = false,
+ CancellationToken ct = default)
{
if (Valider(saisie, out var isbn, out var erreur) is false)
{
return ResultatEcriture.Invalide(erreur!);
}
+ if (!confirmerDoublon)
+ {
+ var deja = await DoublonsAsync(isbn, saisie, utilisateur, ct);
+ if (deja is not null)
+ {
+ return ResultatEcriture.Doublon(deja);
+ }
+ }
+
var livre = new Livre
{
Isbn = isbn,
@@ -184,6 +212,116 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
return true;
}
+ ///
+ /// Cherche ce que le catalogue contient déjà de semblable, ou null s'il n'y a rien.
+ ///
+ ///
+ /// Deux critères, et aucun n'est une clé. L'ISBN identique attrape le cas visé — le
+ /// même livre scanné deux fois — mais il manque à beaucoup de fiches, puisqu'il est
+ /// facultatif. La clé d'œuvre attrape la saisie manuelle sans ISBN, au prix de confondre le
+ /// poche et le grand format. C'est précisément parce qu'aucun des deux ne tranche que le
+ /// résultat est un avertissement et non un refus : un index unique interdirait le
+ /// second exemplaire, qui est un cas parfaitement normal.
+ ///
+ /// Le titre seul ne suffit pas : il faut aussi un auteur commun, au sens de
+ /// — sinon deux « Nouvelles » sans rapport se
+ /// signaleraient l'une l'autre.
+ ///
+ ///
+ private async Task DoublonsAsync(
+ string? isbn, EnregistrementLivre saisie, string? utilisateur, CancellationToken ct)
+ {
+ // Dictionnaire et non liste : un livre trouvé par son ISBN ET par son titre ne doit
+ // apparaître qu'une fois.
+ var candidats = new Dictionary();
+
+ if (isbn is not null)
+ {
+ foreach (var livre in await AvecAuteurs().Where(l => l.Isbn == isbn).ToListAsync(ct))
+ {
+ candidats[livre.Id] = livre;
+ }
+ }
+
+ var cle = CleOeuvre.Cle(saisie.Titre);
+ if (cle.Length > 0)
+ {
+ // La clé d'œuvre est un PRÉFIXE de TitreNormalise : la troncature du sous-titre
+ // précède la normalisation, qui préserve l'ordre des caractères. SQLite dégrossit
+ // donc avec la colonne indexée, et l'égalité des clés se vérifie ensuite en mémoire
+ // — sans quoi il faudrait charger tout le catalogue à chaque ajout.
+ var parTitre = await AvecAuteurs()
+ .Where(l => l.TitreNormalise == cle || l.TitreNormalise.StartsWith(cle + " "))
+ .ToListAsync(ct);
+
+ foreach (var livre in parTitre)
+ {
+ if (CleOeuvre.Cle(livre.Titre) == cle && MemeAuteur(livre, saisie.Auteurs))
+ {
+ candidats[livre.Id] = livre;
+ }
+ }
+ }
+
+ if (candidats.Count == 0)
+ {
+ return null;
+ }
+
+ var trouves = candidats.Values.OrderBy(l => l.Id).ToList();
+ var ids = trouves.Select(l => l.Id).ToList();
+ var statuts = await StatutsAsync(ids, utilisateur, ct);
+ var prets = await PretsOuvertsAsync(ids, ct);
+
+ return new DoublonsLivre
+ {
+ Message = Avertir(trouves),
+ Livres = trouves
+ .Select(l => Projeter(l, statuts.GetValueOrDefault(l.Id), prets.GetValueOrDefault(l.Id)))
+ .ToList(),
+ };
+ }
+
+ private IQueryable AvecAuteurs() =>
+ db.Livres.AsNoTracking().Include(l => l.Auteurs).ThenInclude(la => la.Auteur);
+
+ ///
+ /// Un des auteurs du livre catalogué est-il l'un de ceux qu'on est en train de saisir ?
+ ///
+ ///
+ /// Deux fiches sans aucun auteur comptent comme concordantes : le titre est alors la seule
+ /// information disponible, et refuser de le signaler reviendrait à ne rien dire du tout.
+ ///
+ private static bool MemeAuteur(Livre livre, IReadOnlyList saisis)
+ {
+ var connus = livre.Auteurs
+ .Select(la => la.Auteur?.Nom)
+ .Where(nom => !string.IsNullOrWhiteSpace(nom))
+ .ToList();
+
+ if (connus.Count == 0 && saisis.Count == 0)
+ {
+ return true;
+ }
+
+ return connus.Any(connu => saisis.Any(saisi => RapprochementAuteurs.SontLeMemeAuteur(connu, saisi)));
+ }
+
+ /// Phrase d'avertissement, écrite pour être lue telle quelle par l'utilisateur.
+ ///
+ /// Elle nomme ce qu'on possède déjà et invite à passer outre : c'est un doute soumis à
+ /// l'utilisateur, pas un reproche. Les fiches elles-mêmes sont affichées à côté, donc le
+ /// texte n'a pas à les décrire.
+ ///
+ private static string Avertir(IReadOnlyList trouves)
+ {
+ var debut = trouves.Count == 1
+ ? $"« {trouves[0].Titre} » est déjà au catalogue."
+ : $"{trouves.Count} livres du catalogue ressemblent à celui-ci.";
+
+ return debut + " Ajoutez quand même s'il s'agit d'un autre exemplaire ou d'une autre édition.";
+ }
+
/// Statut de pour chacun des livres demandés.
private async Task> StatutsAsync(
IReadOnlyList livreIds, string? utilisateur, CancellationToken ct)
diff --git a/MaBibli.Client/Composants/AvertissementDoublon.razor b/MaBibli.Client/Composants/AvertissementDoublon.razor
new file mode 100644
index 0000000..9972764
--- /dev/null
+++ b/MaBibli.Client/Composants/AvertissementDoublon.razor
@@ -0,0 +1,74 @@
+@*
+ Le catalogue contient déjà quelque chose de semblable.
+
+ ⚠️ C'est un AVERTISSEMENT, jamais un refus : posséder deux exemplaires est légitime — on
+ garde le sien et on prête l'autre — et le rapprochement se trompe dans les deux sens (il
+ confond le poche et le grand format, il rate une réédition retitrée). L'écran montre donc
+ les fiches en cause plutôt que de les décrire, et laisse la décision à l'utilisateur.
+
+ Les fiches sont des LIENS : la question qui se pose vraiment est « est-ce bien le même
+ livre ? », et on n'y répond qu'en regardant celui qui est déjà là.
+*@
+
+ @if (livre.Format == Format.Numerique)
+ {
+ @Libelles.Format(livre.Format)
+ }
+
+ @* Un exemplaire dehors est justement une raison d'en vouloir un second. *@
+ @if (livre.PreteA is { } emprunteur)
+ {
+ Prêté à @emprunteur
+ }
+
+ @if (!string.IsNullOrWhiteSpace(livre.Editeur))
+ {
+ @livre.Editeur
+ }
+
+ @if (!string.IsNullOrWhiteSpace(livre.Isbn))
+ {
+ @FormatageIsbn.Afficher(livre.Isbn)
+ }
+
+
+
+ }
+
+
+
+
+
+
+
+@code {
+ [Parameter, EditorRequired] public DoublonsLivre Doublons { get; set; } = default!;
+
+ /// Ajout confirmé : second exemplaire, autre édition, ou rapprochement à côté.
+ [Parameter] public EventCallback OnConfirmer { get; set; }
+
+ /// Retour en arrière : le livre était bien déjà là.
+ [Parameter] public EventCallback OnRenoncer { get; set; }
+
+ [Parameter] public bool EnCours { get; set; }
+}
diff --git a/MaBibli.Client/Pages/AjoutIsbn.razor b/MaBibli.Client/Pages/AjoutIsbn.razor
index c3bf0f9..2095131 100644
--- a/MaBibli.Client/Pages/AjoutIsbn.razor
+++ b/MaBibli.Client/Pages/AjoutIsbn.razor
@@ -145,17 +145,30 @@
@if (_etape == Etape.Formulaire)
{
-
- Vérifiez et complétez la fiche : tout reste modifiable.
-
+ @* Le doublon prend toute la place tant qu'il n'est pas tranché : la fiche revient intacte
+ si l'on renonce. C'est ici que le cas se présente le plus — scanner deux fois le même
+ livre est précisément ce qui crée les fiches en double. *@
+ @if (_doublons is { } doublons)
+ {
+
+ }
+ else
+ {
+
+ Vérifiez et complétez la fiche : tout reste modifiable.
+
-
+
+ }
}
@code {
@@ -177,6 +190,7 @@
private IReadOnlyList _candidats = [];
private IReadOnlyList _avertissements = [];
private EnregistrementLivre _saisie = new();
+ private DoublonsLivre? _doublons;
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
@@ -358,19 +372,35 @@
_erreur = null;
_messagePeriodique = null;
_erreurFormulaire = null;
+ _doublons = null;
_candidats = [];
RevenirALaSaisie();
}
- private async Task EnregistrerAsync()
+ private Task EnregistrerAsync() => AjouterAsync(false);
+
+ /// Ajout maintenu après avoir vu ce que le catalogue contenait déjà.
+ private Task ConfirmerAsync() => AjouterAsync(true);
+
+ private async Task AjouterAsync(bool confirmerDoublon)
{
_enregistrement = true;
_erreurFormulaire = null;
- var resultat = await Api.CreerAsync(_saisie);
+ var resultat = await Api.CreerAsync(_saisie, confirmerDoublon);
_enregistrement = false;
+ // Rien n'a été écrit : le catalogue contient déjà quelque chose de semblable, et c'est
+ // à l'utilisateur de dire s'il s'agit du même livre. Voir AvertissementDoublon.
+ if (resultat.Doublons is { } doublons)
+ {
+ _doublons = doublons;
+ return;
+ }
+
+ _doublons = null;
+
if (!resultat.EstOk)
{
_erreurFormulaire = resultat.Erreur;
diff --git a/MaBibli.Client/Pages/AjoutManuel.razor b/MaBibli.Client/Pages/AjoutManuel.razor
index 4cf27ef..3721c6c 100644
--- a/MaBibli.Client/Pages/AjoutManuel.razor
+++ b/MaBibli.Client/Pages/AjoutManuel.razor
@@ -12,13 +12,28 @@
Pour les livres sans ISBN, ou dont aucune notice ne convient. L'ISBN reste facultatif.
-
+@*
+ Le formulaire s'efface pendant qu'on tranche le doublon : la saisie n'est pas perdue pour
+ autant, elle attend dans « _saisie » et revient telle quelle si l'on renonce. Laisser les
+ deux à l'écran donnerait deux boutons d'ajout, dont un seul confirme.
+*@
+@if (_doublons is { } doublons)
+{
+
+}
+else
+{
+
+}
@code {
private readonly EnregistrementLivre _saisie = new();
@@ -31,16 +46,32 @@
private bool _enregistrement;
private string? _erreur;
+ private DoublonsLivre? _doublons;
- private async Task EnregistrerAsync()
+ private Task EnregistrerAsync() => AjouterAsync(false);
+
+ /// Ajout maintenu après avoir vu ce que le catalogue contenait déjà.
+ private Task ConfirmerAsync() => AjouterAsync(true);
+
+ private async Task AjouterAsync(bool confirmerDoublon)
{
_enregistrement = true;
_erreur = null;
- var resultat = await Api.CreerAsync(_saisie);
+ var resultat = await Api.CreerAsync(_saisie, confirmerDoublon);
_enregistrement = false;
+ // Un doublon signalé n'est pas un échec : rien n'a été écrit, et la même saisie
+ // confirmée s'enregistrera telle quelle.
+ if (resultat.Doublons is { } doublons)
+ {
+ _doublons = doublons;
+ return;
+ }
+
+ _doublons = null;
+
if (!resultat.EstOk)
{
_erreur = resultat.Erreur;
diff --git a/MaBibli.Client/Services/ServiceLivresApi.cs b/MaBibli.Client/Services/ServiceLivresApi.cs
index 32b917a..ac12094 100644
--- a/MaBibli.Client/Services/ServiceLivresApi.cs
+++ b/MaBibli.Client/Services/ServiceLivresApi.cs
@@ -127,8 +127,62 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
return instantane?.Donnees.FirstOrDefault(l => l.Id == id);
}
- public async Task> CreerAsync(EnregistrementLivre saisie, CancellationToken ct = default) =>
- await EcrireAsync(() => http.PostAsJsonAsync("api/livres", saisie, Json, ct), ct);
+ ///
+ /// Ajoute un livre, en rapportant le cas échéant ce que le catalogue a déjà de semblable.
+ ///
+ ///
+ /// Vrai quand l'utilisateur a vu l'avertissement et veut ajouter le livre malgré tout —
+ /// second exemplaire, autre édition, ou rapprochement à côté de la plaque.
+ ///
+ public async Task CreerAsync(
+ EnregistrementLivre saisie, bool confirmerDoublon = false, CancellationToken ct = default)
+ {
+ var url = "api/livres" + (confirmerDoublon ? "?confirmerDoublon=true" : string.Empty);
+ var (reponse, motif) = await EnvoyerAsync(() => http.PostAsJsonAsync(url, saisie, Json, ct));
+
+ if (reponse is null)
+ {
+ return new ResultatCreation(null, motif, null);
+ }
+
+ // Le 409 n'est PAS une erreur : le serveur demande une confirmation, et l'écran doit
+ // proposer d'ajouter quand même. Le faire passer par le message d'erreur générique le
+ // transformerait en échec, avec le livre perdu au passage.
+ if (reponse.StatusCode == HttpStatusCode.Conflict)
+ {
+ var doublons = await LireDoublonsAsync(reponse, ct);
+ if (doublons is not null)
+ {
+ return new ResultatCreation(null, null, doublons);
+ }
+ }
+
+ var resultat = await InterpreterAsync(reponse, ct);
+
+ if (resultat.EstOk)
+ {
+ _ = RafraichirEnFondAsync();
+ }
+
+ return new ResultatCreation(resultat.Valeur, resultat.Erreur, null);
+ }
+
+ /// Lit le corps d'un 409, ou null s'il est illisible.
+ private static async Task LireDoublonsAsync(
+ HttpResponseMessage reponse, CancellationToken ct)
+ {
+ try
+ {
+ var doublons = await reponse.Content.ReadFromJsonAsync(Json, ct);
+ return string.IsNullOrWhiteSpace(doublons?.Message) ? null : doublons;
+ }
+ catch (Exception)
+ {
+ // Corps inattendu : on retombe sur le traitement d'erreur ordinaire, qui dira au
+ // moins que l'enregistrement n'a pas eu lieu.
+ return null;
+ }
+ }
public async Task> ModifierAsync(int id, EnregistrementLivre saisie, CancellationToken ct = default) =>
await EcrireAsync(() => http.PutAsJsonAsync($"api/livres/{id}", saisie, Json, ct), ct);
@@ -399,31 +453,52 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
///
private async Task> EcrireAsync(
Func> appel, CancellationToken ct)
+ {
+ var (reponse, motif) = await EnvoyerAsync(appel);
+
+ if (reponse is null)
+ {
+ return new ResultatApi(default, motif);
+ }
+
+ var resultat = await InterpreterAsync(reponse, ct);
+
+ if (resultat.EstOk)
+ {
+ // La base a changé : l'instantané doit suivre, sinon une coupure juste après une
+ // modification montrerait un catalogue d'avant.
+ _ = RafraichirEnFondAsync();
+ }
+
+ return resultat;
+ }
+
+ ///
+ /// Envoie une écriture : soit une réponse à interpréter, soit le motif de son impossibilité.
+ ///
+ ///
+ /// Séparé de parce que toutes les écritures ne s'interprètent
+ /// pas de la même façon : la création doit reconnaître le 409 des doublons, qui n'est pas un
+ /// échec. Le refus hors-ligne et le signalement de l'état du réseau, eux, sont communs.
+ ///
+ private async Task<(HttpResponseMessage? Reponse, string? Motif)> EnvoyerAsync(
+ Func> appel)
{
if (!reseau.EnLigne)
{
- return new ResultatApi(default, EtatReseau.MotifHorsLigne);
+ return (null, EtatReseau.MotifHorsLigne);
}
try
{
var reponse = await appel();
reseau.SignalerSuccesReseau();
- var resultat = await InterpreterAsync(reponse, ct);
-
- if (resultat.EstOk)
- {
- // La base a changé : l'instantané doit suivre, sinon une coupure juste après une
- // modification montrerait un catalogue d'avant.
- _ = RafraichirEnFondAsync();
- }
-
- return resultat;
+ return (reponse, null);
}
catch (HttpRequestException)
{
reseau.SignalerEchecReseau();
- return new ResultatApi(default, EtatReseau.MotifHorsLigne);
+ return (null, EtatReseau.MotifHorsLigne);
}
}
@@ -665,3 +740,16 @@ public readonly record struct ResultatApi(T? Valeur, string? Erreur)
{
public bool EstOk => Erreur is null;
}
+
+///
+/// Résultat d'un ajout au catalogue : le livre, une erreur, ou des doublons à confirmer.
+///
+///
+/// Trois issues et non deux : un doublon signalé n'est pas une erreur. La même saisie,
+/// confirmée, s'enregistrera telle quelle — l'écran doit donc offrir « Ajouter quand même »
+/// plutôt qu'un message rouge qui laisserait croire à un refus.
+///
+public readonly record struct ResultatCreation(LivreDto? Livre, string? Erreur, DoublonsLivre? Doublons)
+{
+ public bool EstOk => Livre is not null;
+}
diff --git a/MaBibli.Shared/Dtos/DoublonsLivre.cs b/MaBibli.Shared/Dtos/DoublonsLivre.cs
new file mode 100644
index 0000000..994b3bd
--- /dev/null
+++ b/MaBibli.Shared/Dtos/DoublonsLivre.cs
@@ -0,0 +1,24 @@
+namespace MaBibli.Shared.Dtos;
+
+///
+/// Livres déjà catalogués qui ressemblent à celui qu'on est en train d'ajouter.
+///
+///
+/// C'est un avertissement, jamais un refus. Posséder deux exemplaires du même livre est
+/// parfaitement légitime — on garde le sien et on prête l'autre — et l'ISBN est facultatif, donc
+/// il ne peut pas servir de clé unique. Un index unique en base interdirait le second exemplaire
+/// au lieu de rendre service ; ici l'utilisateur voit ce qu'il a déjà, et tranche.
+///
+/// Le rapprochement est faillible dans les deux sens : il rate une réédition retitrée
+/// () et il signale le poche à côté du grand format, qui sont deux
+/// exemplaires bien distincts. C'est exactement pourquoi la décision revient à l'utilisateur.
+///
+///
+public record DoublonsLivre
+{
+ /// Phrase destinée à l'utilisateur, nommant ce qu'il possède déjà.
+ public string Message { get; set; } = string.Empty;
+
+ /// Les fiches en cause, pour que l'écran les montre plutôt que de les décrire.
+ public List Livres { get; set; } = [];
+}
diff --git a/MaBibli.Tests/ServiceCatalogueTests.cs b/MaBibli.Tests/ServiceCatalogueTests.cs
index 3dc800a..f196f36 100644
--- a/MaBibli.Tests/ServiceCatalogueTests.cs
+++ b/MaBibli.Tests/ServiceCatalogueTests.cs
@@ -348,6 +348,100 @@ public class ServiceCatalogueTests : IDisposable
Assert.Equal(["Guy de Maupassant"], relu.Auteurs.Select(a => a.Nom));
}
+ // ─────────────────────────────────────────────────────────────────────────
+ // Doublons : un AVERTISSEMENT, jamais un refus. Posséder deux exemplaires est légitime —
+ // on garde le sien et on prête l'autre — et l'ISBN est facultatif, donc il ne peut pas
+ // servir de clé unique. Ces tests verrouillent les deux moitiés : ce qui est signalé, et
+ // le fait qu'une confirmation suffise toujours à passer outre.
+ // ─────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task Le_meme_isbn_est_signale_sans_rien_ecrire()
+ {
+ await _service.CreerAsync(Saisie("Le Horla", "Guy de Maupassant", "9782080704092"), "mathieu");
+
+ var second = await _service.CreerAsync(
+ Saisie("Le Horla", "Guy de Maupassant", "9782080704092"), "mathieu");
+
+ Assert.Null(second.Livre);
+ Assert.Null(second.Erreur);
+ var doublons = second.Doublons!;
+ Assert.Contains("Le Horla", doublons.Message);
+ Assert.Single(doublons.Livres);
+ Assert.Equal(1, await _db.Livres.CountAsync());
+ }
+
+ [Fact]
+ public async Task Le_meme_isbn_confirme_cree_bien_un_second_exemplaire()
+ {
+ await _service.CreerAsync(Saisie("Le Horla", "Guy de Maupassant", "9782080704092"), "mathieu");
+
+ var second = await _service.CreerAsync(
+ Saisie("Le Horla", "Guy de Maupassant", "9782080704092"), "mathieu", confirmerDoublon: true);
+
+ Assert.Null(second.Doublons);
+ Assert.NotNull(second.Livre);
+ Assert.Equal(2, await _db.Livres.CountAsync());
+ }
+
+ [Fact]
+ public async Task Sans_isbn_le_titre_et_lauteur_suffisent_a_signaler()
+ {
+ await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu");
+
+ // Sous-titre, accents et ordre du nom diffèrent : c'est exactement ce que CleOeuvre et
+ // RapprochementAuteurs sont là pour absorber.
+ var second = await _service.CreerAsync(Saisie("Germinal : roman", "Zola, Emile"), "camille");
+
+ Assert.NotNull(second.Doublons);
+ Assert.Single(second.Doublons!.Livres);
+ }
+
+ [Fact]
+ public async Task Un_titre_identique_dun_autre_auteur_nest_pas_un_doublon()
+ {
+ await _service.CreerAsync(Saisie("Nouvelles", "Guy de Maupassant"), "mathieu");
+
+ var autre = await _service.CreerAsync(Saisie("Nouvelles", "Anton Tchekhov"), "mathieu");
+
+ Assert.Null(autre.Doublons);
+ Assert.NotNull(autre.Livre);
+ }
+
+ [Fact]
+ public async Task Un_titre_seulement_commence_pareil_nest_pas_un_doublon()
+ {
+ await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu");
+
+ // Le préfixe attrape le candidat en SQL, la clé d'œuvre le rejette ensuite en mémoire :
+ // sans cette seconde passe, tout ce qui commence par « Germinal » serait signalé.
+ var autre = await _service.CreerAsync(Saisie("Germinal les années noires", "Émile Zola"), "mathieu");
+
+ Assert.Null(autre.Doublons);
+ Assert.NotNull(autre.Livre);
+ }
+
+ [Fact]
+ public async Task Un_livre_trouve_par_son_isbn_ET_son_titre_nest_signale_quune_fois()
+ {
+ await _service.CreerAsync(Saisie("Germinal", "Émile Zola", "9782253004226"), "mathieu");
+
+ var second = await _service.CreerAsync(Saisie("Germinal", "Émile Zola", "9782253004226"), "mathieu");
+
+ Assert.Single(second.Doublons!.Livres);
+ }
+
+ [Fact]
+ public async Task Une_saisie_invalide_est_une_erreur_avant_detre_un_doublon()
+ {
+ await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu");
+
+ var invalide = await _service.CreerAsync(Saisie("Germinal", "Émile Zola", "1234567890123"), "mathieu");
+
+ Assert.NotNull(invalide.Erreur);
+ Assert.Null(invalide.Doublons);
+ }
+
public void Dispose()
{
_db.Dispose();