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();