Signaler les doublons à l'ajout d'un livre, sans jamais les refuser

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 <noreply@anthropic.com>
This commit is contained in:
mathieu
2026-08-19 21:11:39 +02:00
co-authored by Claude Opus 5
parent 278d6579f7
commit 383ed43227
10 changed files with 600 additions and 69 deletions
+18 -3
View File
@@ -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<LivreDto>(StatusCodes.Status201Created)
.Produces<DoublonsLivre>(StatusCodes.Status409Conflict)
.Produces(StatusCodes.Status400BadRequest);
groupe.MapPut("/{id:int}", async (
@@ -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;
/// <summary>Issue d'une écriture : soit le livre, soit un message d'erreur destiné à l'utilisateur.</summary>
public readonly record struct ResultatEcriture(LivreDto? Livre, string? Erreur)
/// <summary>
/// Issue d'une écriture : le livre, un message d'erreur, ou des doublons à faire confirmer.
/// </summary>
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);
/// <summary>Ni livre ni erreur : la ressource demandée n'existe pas.</summary>
public static readonly ResultatEcriture Introuvable = new(null, null);
/// <summary>
/// L'écriture n'a pas eu lieu : elle ressemble trop à ce qui est déjà là.
/// </summary>
/// <remarks>
/// Ce n'est <b>pas</b> une erreur — la même saisie, confirmée, s'enregistrera telle quelle.
/// D'où un troisième état plutôt qu'un message dans <c>Erreur</c> : l'écran doit proposer
/// « ajouter quand même », ce qu'un texte rouge ne suggère pas.
/// </remarks>
public static ResultatEcriture Doublon(DoublonsLivre doublons) => new(null, null, doublons);
public bool EstIntrouvable => Livre is null && Erreur is null;
/// <summary>Ni livre, ni erreur, ni doublon : la ressource demandée n'existe pas.</summary>
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<LivreDto?> ObtenirAsync(int id, string? utilisateur, CancellationToken ct = default);
Task<ResultatEcriture> CreerAsync(
EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default);
EnregistrementLivre saisie,
string? utilisateur,
bool confirmerDoublon = false,
CancellationToken ct = default);
Task<ResultatEcriture> 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<ResultatEcriture> 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;
}
/// <summary>
/// Cherche ce que le catalogue contient déjà de semblable, ou <c>null</c> s'il n'y a rien.
/// </summary>
/// <remarks>
/// <b>Deux critères, et aucun n'est une clé.</b> 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 <b>avertissement</b> et non un refus : un index unique interdirait le
/// second exemplaire, qui est un cas parfaitement normal.
/// <para>
/// Le titre seul ne suffit pas : il faut aussi un auteur commun, au sens de
/// <see cref="RapprochementAuteurs"/> — sinon deux « Nouvelles » sans rapport se
/// signaleraient l'une l'autre.
/// </para>
/// </remarks>
private async Task<DoublonsLivre?> 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<int, Livre>();
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<Livre> AvecAuteurs() =>
db.Livres.AsNoTracking().Include(l => l.Auteurs).ThenInclude(la => la.Auteur);
/// <summary>
/// Un des auteurs du livre catalogué est-il l'un de ceux qu'on est en train de saisir ?
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private static bool MemeAuteur(Livre livre, IReadOnlyList<string> 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)));
}
/// <summary>Phrase d'avertissement, écrite pour être lue telle quelle par l'utilisateur.</summary>
/// <remarks>
/// Elle nomme ce qu'on possède déjà et <b>invite à passer outre</b> : 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.
/// </remarks>
private static string Avertir(IReadOnlyList<Livre> 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.";
}
/// <summary>Statut de <paramref name="utilisateur"/> pour chacun des livres demandés.</summary>
private async Task<Dictionary<int, Statut?>> StatutsAsync(
IReadOnlyList<int> livreIds, string? utilisateur, CancellationToken ct)