Clôt la décision du premier lot, qui écartait les périodiques faute de modèle. Douze numéros d'un magazine ne font plus douze fiches identiques. Table à part, pour la troisième fois et pour le même motif que la liste d'envies et les séries : logée dans Livres, une revue entrerait dans le catalogue, ses compteurs, les doublons et les bibliographies, et il faudrait écrire « et qui n'est pas une revue » à chaque lecture. Un numéro est recensé, rien de plus — ni prêt ni statut, tous deux liés à Livre par clé étrangère. Réversible. « Créer ou retrouver » et non « créer » : scanner le numéro suivant du même magazine retombe forcément sur la même revue. L'unicité de l'ISSN est un index partiel, sans quoi les revues sans ISSN se bloqueraient entre elles. Le flux 977 mène désormais à la fiche de la revue, et ne pré-remplit plus le formulaire d'un livre. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
256 lines
9.0 KiB
C#
256 lines
9.0 KiB
C#
using MaBibli.Api.Data;
|
|
using MaBibli.Shared.Dtos;
|
|
using MaBibli.Shared.Entites;
|
|
using MaBibli.Shared.Textes;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace MaBibli.Api.Services.Revues;
|
|
|
|
/// <summary>Issue d'une écriture sur une revue : la revue, ou un message pour l'utilisateur.</summary>
|
|
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<IReadOnlyList<RevueDto>> ListerAsync(CancellationToken ct = default);
|
|
|
|
/// <summary>
|
|
/// Crée la revue, ou <b>rend celle qui existe déjà</b> quand l'ISSN ou le titre la désigne.
|
|
/// </summary>
|
|
Task<ResultatRevue> CreerOuRetrouverAsync(
|
|
EnregistrementRevue saisie, string? utilisateur, CancellationToken ct = default);
|
|
|
|
Task<ResultatRevue> ModifierAsync(
|
|
int id, EnregistrementRevue saisie, CancellationToken ct = default);
|
|
|
|
Task<bool> SupprimerAsync(int id, CancellationToken ct = default);
|
|
|
|
Task<ResultatRevue> AjouterNumeroAsync(
|
|
int revueId, AjoutNumeroRevue saisie, CancellationToken ct = default);
|
|
|
|
Task<bool> RetirerNumeroAsync(int numeroId, CancellationToken ct = default);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Revues et magazines : une fiche par revue, les numéros à l'intérieur.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// ⚠️ <b>Communes au foyer</b>, comme le catalogue : aucune méthode ne filtre sur un
|
|
/// utilisateur, <see cref="Revue.AjoutePar"/> n'étant qu'une trace.
|
|
/// <para>
|
|
/// Un numéro est <b>recensé, rien de plus</b> — 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 à
|
|
/// <c>Livre</c> par clé étrangère.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class ServiceRevues(MaBibliDbContext db) : IServiceRevues
|
|
{
|
|
public async Task<IReadOnlyList<RevueDto>> 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<ResultatRevue> 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<ResultatRevue> 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<bool> 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<ResultatRevue> 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<bool> 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(),
|
|
};
|
|
}
|