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:
@@ -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à.
|
||||
*@
|
||||
<div class="message-avertissement" role="alert">@Doublons.Message</div>
|
||||
|
||||
<ul class="liste-livres">
|
||||
@foreach (var livre in Doublons.Livres)
|
||||
{
|
||||
<li class="carte-livre" @key="livre.Id">
|
||||
<Couverture Url="@livre.CoverUrl" Titre="@livre.Titre" Classe="couverture-petite" />
|
||||
|
||||
<div class="carte-corps">
|
||||
<a class="carte-titre" href="@($"livres/{livre.Id}")">@livre.Titre</a>
|
||||
|
||||
@if (livre.Auteurs.Count > 0)
|
||||
{
|
||||
<p class="carte-auteur">@string.Join(", ", livre.Auteurs.Select(a => a.Nom))</p>
|
||||
}
|
||||
|
||||
<p class="carte-details">
|
||||
@if (livre.Format == Format.Numerique)
|
||||
{
|
||||
<span class="etiquette">@Libelles.Format(livre.Format)</span>
|
||||
}
|
||||
|
||||
@* Un exemplaire dehors est justement une raison d'en vouloir un second. *@
|
||||
@if (livre.PreteA is { } emprunteur)
|
||||
{
|
||||
<span class="etiquette etiquette-prete">Prêté à @emprunteur</span>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(livre.Editeur))
|
||||
{
|
||||
<span>@livre.Editeur</span>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(livre.Isbn))
|
||||
{
|
||||
<span class="code-isbn">@FormatageIsbn.Afficher(livre.Isbn)</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-principal" disabled="@EnCours" @onclick="OnConfirmer">
|
||||
@(EnCours ? "Ajout…" : "Ajouter quand même")
|
||||
</button>
|
||||
<button type="button" class="bouton bouton-discret" disabled="@EnCours" @onclick="OnRenoncer">
|
||||
Ne pas ajouter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public DoublonsLivre Doublons { get; set; } = default!;
|
||||
|
||||
/// <summary>Ajout confirmé : second exemplaire, autre édition, ou rapprochement à côté.</summary>
|
||||
[Parameter] public EventCallback OnConfirmer { get; set; }
|
||||
|
||||
/// <summary>Retour en arrière : le livre était bien déjà là.</summary>
|
||||
[Parameter] public EventCallback OnRenoncer { get; set; }
|
||||
|
||||
[Parameter] public bool EnCours { get; set; }
|
||||
}
|
||||
@@ -145,17 +145,30 @@
|
||||
|
||||
@if (_etape == Etape.Formulaire)
|
||||
{
|
||||
<p class="message-discret">
|
||||
Vérifiez et complétez la fiche : tout reste modifiable.
|
||||
</p>
|
||||
@* 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)
|
||||
{
|
||||
<AvertissementDoublon Doublons="doublons"
|
||||
EnCours="_enregistrement"
|
||||
OnConfirmer="ConfirmerAsync"
|
||||
OnRenoncer="() => _doublons = null" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="message-discret">
|
||||
Vérifiez et complétez la fiche : tout reste modifiable.
|
||||
</p>
|
||||
|
||||
<FormulaireLivre Saisie="_saisie"
|
||||
LibelleValidation="Ajouter au catalogue"
|
||||
Erreur="@_erreurFormulaire"
|
||||
EnCours="_enregistrement"
|
||||
MessageBlocage="@MotifBlocage"
|
||||
OnValider="EnregistrerAsync"
|
||||
OnAnnuler="Recommencer" />
|
||||
<FormulaireLivre Saisie="_saisie"
|
||||
LibelleValidation="Ajouter au catalogue"
|
||||
Erreur="@_erreurFormulaire"
|
||||
EnCours="_enregistrement"
|
||||
MessageBlocage="@MotifBlocage"
|
||||
OnValider="EnregistrerAsync"
|
||||
OnAnnuler="Recommencer" />
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
@@ -177,6 +190,7 @@
|
||||
private IReadOnlyList<CandidatLivre> _candidats = [];
|
||||
private IReadOnlyList<string> _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);
|
||||
|
||||
/// <summary>Ajout maintenu après avoir vu ce que le catalogue contenait déjà.</summary>
|
||||
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;
|
||||
|
||||
@@ -12,13 +12,28 @@
|
||||
Pour les livres sans ISBN, ou dont aucune notice ne convient. L'ISBN reste facultatif.
|
||||
</p>
|
||||
|
||||
<FormulaireLivre Saisie="_saisie"
|
||||
LibelleValidation="Ajouter au catalogue"
|
||||
Erreur="@_erreur"
|
||||
EnCours="_enregistrement"
|
||||
MessageBlocage="@(Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne)"
|
||||
OnValider="EnregistrerAsync"
|
||||
OnAnnuler="Retour" />
|
||||
@*
|
||||
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)
|
||||
{
|
||||
<AvertissementDoublon Doublons="doublons"
|
||||
EnCours="_enregistrement"
|
||||
OnConfirmer="ConfirmerAsync"
|
||||
OnRenoncer="() => _doublons = null" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<FormulaireLivre Saisie="_saisie"
|
||||
LibelleValidation="Ajouter au catalogue"
|
||||
Erreur="@_erreur"
|
||||
EnCours="_enregistrement"
|
||||
MessageBlocage="@(Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne)"
|
||||
OnValider="EnregistrerAsync"
|
||||
OnAnnuler="Retour" />
|
||||
}
|
||||
|
||||
@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);
|
||||
|
||||
/// <summary>Ajout maintenu après avoir vu ce que le catalogue contenait déjà.</summary>
|
||||
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;
|
||||
|
||||
@@ -127,8 +127,62 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
|
||||
return instantane?.Donnees.FirstOrDefault(l => l.Id == id);
|
||||
}
|
||||
|
||||
public async Task<ResultatApi<LivreDto>> CreerAsync(EnregistrementLivre saisie, CancellationToken ct = default) =>
|
||||
await EcrireAsync<LivreDto>(() => http.PostAsJsonAsync("api/livres", saisie, Json, ct), ct);
|
||||
/// <summary>
|
||||
/// Ajoute un livre, en rapportant le cas échéant ce que le catalogue a déjà de semblable.
|
||||
/// </summary>
|
||||
/// <param name="confirmerDoublon">
|
||||
/// 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.
|
||||
/// </param>
|
||||
public async Task<ResultatCreation> 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<LivreDto>(reponse, ct);
|
||||
|
||||
if (resultat.EstOk)
|
||||
{
|
||||
_ = RafraichirEnFondAsync();
|
||||
}
|
||||
|
||||
return new ResultatCreation(resultat.Valeur, resultat.Erreur, null);
|
||||
}
|
||||
|
||||
/// <summary>Lit le corps d'un 409, ou <c>null</c> s'il est illisible.</summary>
|
||||
private static async Task<DoublonsLivre?> LireDoublonsAsync(
|
||||
HttpResponseMessage reponse, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var doublons = await reponse.Content.ReadFromJsonAsync<DoublonsLivre>(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<ResultatApi<LivreDto>> ModifierAsync(int id, EnregistrementLivre saisie, CancellationToken ct = default) =>
|
||||
await EcrireAsync<LivreDto>(() => http.PutAsJsonAsync($"api/livres/{id}", saisie, Json, ct), ct);
|
||||
@@ -399,31 +453,52 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
|
||||
/// </remarks>
|
||||
private async Task<ResultatApi<T>> EcrireAsync<T>(
|
||||
Func<Task<HttpResponseMessage>> appel, CancellationToken ct)
|
||||
{
|
||||
var (reponse, motif) = await EnvoyerAsync(appel);
|
||||
|
||||
if (reponse is null)
|
||||
{
|
||||
return new ResultatApi<T>(default, motif);
|
||||
}
|
||||
|
||||
var resultat = await InterpreterAsync<T>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Envoie une écriture : soit une réponse à interpréter, soit le motif de son impossibilité.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Séparé de <see cref="EcrireAsync{T}"/> 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.
|
||||
/// </remarks>
|
||||
private async Task<(HttpResponseMessage? Reponse, string? Motif)> EnvoyerAsync(
|
||||
Func<Task<HttpResponseMessage>> appel)
|
||||
{
|
||||
if (!reseau.EnLigne)
|
||||
{
|
||||
return new ResultatApi<T>(default, EtatReseau.MotifHorsLigne);
|
||||
return (null, EtatReseau.MotifHorsLigne);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var reponse = await appel();
|
||||
reseau.SignalerSuccesReseau();
|
||||
var resultat = await InterpreterAsync<T>(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<T>(default, EtatReseau.MotifHorsLigne);
|
||||
return (null, EtatReseau.MotifHorsLigne);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -665,3 +740,16 @@ public readonly record struct ResultatApi<T>(T? Valeur, string? Erreur)
|
||||
{
|
||||
public bool EstOk => Erreur is null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Résultat d'un ajout au catalogue : le livre, une erreur, ou des doublons à confirmer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Trois issues et non deux : un doublon signalé n'est <b>pas</b> 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.
|
||||
/// </remarks>
|
||||
public readonly record struct ResultatCreation(LivreDto? Livre, string? Erreur, DoublonsLivre? Doublons)
|
||||
{
|
||||
public bool EstOk => Livre is not null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user