From ffd9eb0a1ea6e257cb256f32b53e4fa5c0b5d6d0 Mon Sep 17 00:00:00 2001 From: Guy Corbaz Date: Wed, 23 Sep 2026 20:47:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(#489):=20show=20the=20last=20V-code=20/=20?= =?UTF-8?q?L-code=20in=20use=20on=20Admin=20=E2=86=92=20Health,=20with=20t?= =?UTF-8?q?he=20next=20free=20number?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The librarian prints sheets of pre-generated barcode labels in advance and needs to know where the occupied range ends. Since #428 the answer lived only on the /catalog info line; the Health tab, which already aggregates catalog counters, now carries it too, each value paired with the next free number ("V0142 (next: V0143)"). - services/admin_health: `LabelWatermarks` + `label_watermarks()` reuse the two `highest_label_any` model lookups (soft-deleted rows included, so a printed sticker is never reissued) and `next_label()` increments within the fixed X9999 width, returning None once the space is exhausted or the input is malformed. - routes/admin + fragment: a "Barcode labels" section under the entity counters, with a "none yet" / "none left" placeholder per family. - locales: seven keys in en/fr/de/it. - manual en/fr: the Health tab bullet now mentions the rows. - tests: pure unit tests for `next_label`, sqlx tests for the service (empty catalog, trashed rows still count), and an E2E smoke journey that creates a shelf and a volume, reads both values, then trashes the volume and checks the watermark does not drop. Co-Authored-By: Claude Fable 5.1 --- docs/manual/en/02-configuration.tex | 6 +- docs/manual/fr/02-configuration.tex | 7 +- locales/de.yml | 7 + locales/en.yml | 7 + locales/fr.yml | 7 + locales/it.yml | 7 + src/routes/admin.rs | 40 ++++++ src/services/admin_health.rs | 104 ++++++++++++++ templates/fragments/admin_health_panel.html | 12 ++ .../admin-health-label-watermarks.spec.ts | 132 ++++++++++++++++++ 10 files changed, 327 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/specs/journeys/admin-health-label-watermarks.spec.ts diff --git a/docs/manual/en/02-configuration.tex b/docs/manual/en/02-configuration.tex index f95cd5cf..c8bc0f5a 100644 --- a/docs/manual/en/02-configuration.tex +++ b/docs/manual/en/02-configuration.tex @@ -13,7 +13,11 @@ \chapter{Configuration} configuration file. The panel has six tabs: \begin{itemize} - \item \textbf{Health} — read-only status dashboard. + \item \textbf{Health} — read-only status dashboard. Besides versions, + entity counts and provider status, it shows the last volume + number (V-code) and last shelf number (L-code) ever printed, + trashed items included, each with the next free number — the + place to look before printing a new sheet of barcode labels. \item \textbf{Users} — create / deactivate users, change roles. \item \textbf{Reference data} — the taxonomy tables, and the management-label vocabulary (section~\ref{sec:management-labels}). diff --git a/docs/manual/fr/02-configuration.tex b/docs/manual/fr/02-configuration.tex index 1bc6745c..09d29ba4 100644 --- a/docs/manual/fr/02-configuration.tex +++ b/docs/manual/fr/02-configuration.tex @@ -14,7 +14,12 @@ \chapter{Configuration} configuration séparé. Le panneau a six onglets : \begin{itemize} - \item \textbf{Health} — tableau de bord en lecture seule. + \item \textbf{Health} — tableau de bord en lecture seule. Outre les + versions, les compteurs d'entités et l'état des fournisseurs, + il affiche le dernier numéro de volume (code V) et le dernier + numéro d'étagère (code L) jamais imprimés, corbeille comprise, + chacun avec le prochain numéro libre — à consulter avant + d'imprimer une nouvelle planche d'étiquettes. \item \textbf{Users} — créer/désactiver des utilisateurs, changer de rôle. \item \textbf{Reference data} — les tables taxonomiques, et le diff --git a/locales/de.yml b/locales/de.yml index c74a64ff..f65934e9 100644 --- a/locales/de.yml +++ b/locales/de.yml @@ -852,6 +852,13 @@ admin: count_contributors: Mitwirkende count_borrowers: Entleiher count_active_loans: Aktive Ausleihen + labels_heading: Barcode-Etiketten + labels_hint: "Höchste je gedruckte Nummer pro Etikettenfamilie, Papierkorb eingeschlossen, und die nächste freie Nummer für einen neuen Etikettenbogen." + last_vcode: "Letzte Bandnummer (V-Code)" + last_lcode: "Letzte Regalnummer (L-Code)" + label_with_next: "%{highest} (nächste: %{next})" + label_exhausted: "%{highest} (keine mehr frei)" + label_none_yet: "Noch keine" providers_heading: Metadaten-Anbieter provider_status_up: Erreichbar provider_status_down: Nicht erreichbar diff --git a/locales/en.yml b/locales/en.yml index 58d8b919..693de1a8 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -867,6 +867,13 @@ admin: count_contributors: Contributors count_borrowers: Borrowers count_active_loans: Active loans + labels_heading: Barcode labels + labels_hint: "Highest number ever printed for each label family, trashed items included, and the next free number to start a new label sheet from." + last_vcode: "Last volume number (V-code)" + last_lcode: "Last shelf number (L-code)" + label_with_next: "%{highest} (next: %{next})" + label_exhausted: "%{highest} (none left)" + label_none_yet: "None yet" providers_heading: Metadata providers provider_status_up: Reachable provider_status_down: Unreachable diff --git a/locales/fr.yml b/locales/fr.yml index 0898cca9..044a8c78 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -864,6 +864,13 @@ admin: count_contributors: Contributeurs count_borrowers: Emprunteurs count_active_loans: "Prêts actifs" + labels_heading: "Étiquettes à code-barres" + labels_hint: "Numéro le plus élevé jamais imprimé pour chaque famille d'étiquettes, corbeille comprise, et prochain numéro libre pour démarrer une nouvelle planche." + last_vcode: "Dernier numéro de volume (code V)" + last_lcode: "Dernier numéro d'étagère (code L)" + label_with_next: "%{highest} (prochain : %{next})" + label_exhausted: "%{highest} (plus aucun disponible)" + label_none_yet: "Aucun pour l'instant" providers_heading: "Fournisseurs de métadonnées" provider_status_up: Accessible provider_status_down: Inaccessible diff --git a/locales/it.yml b/locales/it.yml index 4df0ce75..996297a0 100644 --- a/locales/it.yml +++ b/locales/it.yml @@ -840,6 +840,13 @@ admin: count_contributors: Collaboratori count_borrowers: Lettori count_active_loans: Prestiti attivi + labels_heading: Etichette con codice a barre + labels_hint: "Numero più alto mai stampato per ogni famiglia di etichette, cestino incluso, e prossimo numero libero da cui iniziare un nuovo foglio." + last_vcode: "Ultimo numero di volume (codice V)" + last_lcode: "Ultimo numero di scaffale (codice L)" + label_with_next: "%{highest} (prossimo: %{next})" + label_exhausted: "%{highest} (nessuno disponibile)" + label_none_yet: "Nessuno per ora" providers_heading: Fornitori di metadati provider_status_up: Raggiungibile provider_status_down: Non raggiungibile diff --git a/src/routes/admin.rs b/src/routes/admin.rs index 3cb8ac6f..e1ea915e 100644 --- a/src/routes/admin.rs +++ b/src/routes/admin.rs @@ -149,6 +149,15 @@ struct AdminHealthPanel { count_borrowers: i64, count_active_loans_label: String, count_active_loans: i64, + // #489 — label high-water marks: "V0142 (next: V0143)" per family, + // or the "none yet" placeholder. Pre-formatted so the template only + // prints two strings. + labels_heading: String, + labels_hint: String, + last_vcode_label: String, + last_vcode_value: String, + last_lcode_label: String, + last_lcode_value: String, providers_heading: String, providers: Vec, // Fix #214: maintenance section — "Re-fetch missing covers". @@ -1392,6 +1401,11 @@ async fn render_health_panel(state: &AppState, loc: &'static str) -> Result Result Result, loc: &str) -> String { + match highest { + None => rust_i18n::t!("admin.health.label_none_yet", locale = loc).to_string(), + Some(h) => match admin_health::next_label(h) { + Some(next) => rust_i18n::t!( + "admin.health.label_with_next", + locale = loc, + highest = h, + next = next + ) + .to_string(), + None => rust_i18n::t!("admin.health.label_exhausted", locale = loc, highest = h) + .to_string(), + }, + } +} + fn build_provider_rows( registry: &Arc, map: &ProviderHealthMap, diff --git a/src/services/admin_health.rs b/src/services/admin_health.rs index a2a68052..fe36ad0a 100644 --- a/src/services/admin_health.rs +++ b/src/services/admin_health.rs @@ -76,6 +76,46 @@ pub async fn trash_count(pool: &DbPool) -> Result { Ok(total) } +/// #489 — the label high-water marks shown on the Health tab: the highest +/// V-code / L-code ever printed and the next free number after each. +/// `None` for a code family means the catalog holds no label of that +/// family yet. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct LabelWatermarks { + pub highest_vcode: Option, + pub highest_lcode: Option, +} + +/// Highest V-code / L-code across the whole catalog, soft-deleted rows +/// INCLUDED — a printed sticker outlives the row's trash state, so its +/// number must never be reissued. Delegates to the same two model lookups +/// that feed the `/catalog` info line (#428) so both surfaces always agree. +pub async fn label_watermarks(pool: &DbPool) -> Result { + let highest_vcode = crate::models::volume::VolumeModel::highest_label_any(pool).await?; + let highest_lcode = crate::models::location::LocationModel::highest_label_any(pool).await?; + Ok(LabelWatermarks { + highest_vcode, + highest_lcode, + }) +} + +/// The label that follows `highest` in the fixed-width `X9999` scheme +/// (`V0142` → `V0143`). Returns `None` when the numeric space is exhausted +/// (`V9999`) or when the input is not a prefix + exactly four digits — the +/// caller renders a "none left" placeholder either way rather than +/// inventing a five-digit code the scanner path would reject. +pub fn next_label(highest: &str) -> Option { + let (prefix, digits) = highest.split_at(highest.len().checked_sub(4)?); + if prefix.len() != 1 || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let n: u32 = digits.parse().ok()?; + if n >= 9999 { + return None; + } + Some(format!("{prefix}{:04}", n + 1)) +} + /// Cached MariaDB `VERSION()` string. The version never changes at runtime; /// the 60-second cache amortizes the round-trip across back-to-back Health /// loads without making the handler dependent on a DB read for correctness. @@ -197,6 +237,70 @@ mod tests { assert!(used <= total); } + // ─── #489 label watermarks ─────────────────────────────────── + + #[test] + fn next_label_increments_within_the_fixed_width() { + assert_eq!(next_label("V0142").as_deref(), Some("V0143")); + assert_eq!(next_label("L0037").as_deref(), Some("L0038")); + assert_eq!(next_label("V0009").as_deref(), Some("V0010")); + assert_eq!(next_label("V0999").as_deref(), Some("V1000")); + assert_eq!(next_label("V0000").as_deref(), Some("V0001")); + } + + #[test] + fn next_label_is_none_when_the_space_is_exhausted() { + assert_eq!(next_label("V9999"), None); + assert_eq!(next_label("L9999"), None); + } + + #[test] + fn next_label_rejects_malformed_input() { + assert_eq!(next_label(""), None); + assert_eq!(next_label("V"), None); + assert_eq!(next_label("V12"), None); + assert_eq!(next_label("V00001"), None, "five digits is not a label"); + assert_eq!(next_label("V00a1"), None); + assert_eq!(next_label("0142"), None, "no prefix"); + } + + #[sqlx::test(migrations = "./migrations")] + async fn label_watermarks_empty_catalog_is_all_none(pool: DbPool) { + let w = label_watermarks(&pool).await.unwrap(); + assert_eq!(w, LabelWatermarks::default()); + } + + #[sqlx::test(migrations = "./migrations")] + async fn label_watermarks_report_highest_including_soft_deleted(pool: DbPool) { + let title_id: u64 = sqlx::query( + "INSERT INTO titles (title, media_type, genre_id) VALUES ('T489', 'book', 1)", + ) + .execute(&pool) + .await + .unwrap() + .last_insert_id(); + sqlx::query( + "INSERT INTO volumes (title_id, label, deleted_at) VALUES \ + (?, 'V0007', NULL), (?, 'V0042', NOW())", + ) + .bind(title_id) + .bind(title_id) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO storage_locations (name, node_type, label, deleted_at) VALUES \ + ('Shelf A', 'shelf', 'L0003', NULL), ('Shelf B', 'shelf', 'L0011', NOW())", + ) + .execute(&pool) + .await + .unwrap(); + + let w = label_watermarks(&pool).await.unwrap(); + assert_eq!(w.highest_vcode.as_deref(), Some("V0042"), "trashed V-code still counts"); + assert_eq!(w.highest_lcode.as_deref(), Some("L0011"), "trashed L-code still counts"); + } + // ─── DB-backed tests ───────────────────────────────────────── #[sqlx::test(migrations = "./migrations")] diff --git a/templates/fragments/admin_health_panel.html b/templates/fragments/admin_health_panel.html index 01f75fa9..980b771e 100644 --- a/templates/fragments/admin_health_panel.html +++ b/templates/fragments/admin_health_panel.html @@ -28,6 +28,18 @@

{{ cou + {# #489 — label high-water marks: highest V-code / L-code ever printed + (soft-deleted rows included) and the next free number, so a fresh + label sheet starts right after the occupied range. #} +

{{ labels_heading }}

+

{{ labels_hint }}

+ + + + + +
{{ last_vcode_label }}{{ last_vcode_value }}
{{ last_lcode_label }}{{ last_lcode_value }}
+

{{ providers_heading }}

diff --git a/tests/e2e/specs/journeys/admin-health-label-watermarks.spec.ts b/tests/e2e/specs/journeys/admin-health-label-watermarks.spec.ts new file mode 100644 index 00000000..2511f389 --- /dev/null +++ b/tests/e2e/specs/journeys/admin-health-label-watermarks.spec.ts @@ -0,0 +1,132 @@ +import { test, expect, type Page } from "@playwright/test"; +import { loginAs } from "../../helpers/auth"; +import { specIsbn } from "../../helpers/isbn"; +import { scanTitleAndVolume } from "../../helpers/loans"; +import { createLocation } from "../../helpers/locations"; + +/** + * Issue #489 — Admin → Health shows the last V-code / L-code in use and + * the next free number, so a fresh sheet of barcode labels starts right + * after the occupied range. + * + * Smoke journey (Foundation Rule #7): blank browser → real login → + * create a shelf and a volume → open Admin → Health → read both values. + * + * Parallel-safe by design: other specs create volumes and locations at + * the same time, so the spec never asserts that ITS code is the highest. + * It asserts the two invariants that hold whatever the neighbours do: + * the displayed highest is at least the code it just created, and the + * displayed "next" is exactly highest + 1. + */ +const ISBN = specIsbn("HL", 1); +// Unique across the suite (grep before changing): V-codes in the 66xx +// range and L-codes in the 7xxx bucket are used by no other spec. +const VCODE = "V6689"; +const LCODE = "L7489"; + +const WATERMARK = + /^([VL]\d{4}) \((?:next|prochain|nächste|prossimo)\s?: ([VL]\d{4})\)$/; + +function codeNumber(code: string): number { + return Number.parseInt(code.slice(1), 10); +} + +/** + * Create the spec's shelf once. A second run against the same stack (the + * local dev loop, not CI) finds the L-code already taken and the create + * form answers "already in use" — the watermark assertions only need the + * shelf to EXIST, so skip creation when its name is already listed. + */ +async function ensureLocation(page: Page, name: string, lcode: string) { + await page.goto("/locations"); + if (await page.getByText(name, { exact: true }).count()) { + return; + } + await createLocation(page, name, lcode); +} + +async function readWatermark( + page: Page, + id: string, +): Promise<{ highest: string; next: string }> { + const text = (await page.locator(`#${id}`).innerText()).trim(); + const m = WATERMARK.exec(text); + expect(m, `${id} should read "X0000 (next: X0001)", got "${text}"`).not.toBeNull(); + return { highest: m![1]!, next: m![2]! }; +} + +test.describe("Issue #489 — Health tab label watermarks", () => { + test.beforeEach(async ({ page }) => { + await loginAs(page); + }); + + test("shows the highest V-code and L-code with the next free number", async ({ + page, + }) => { + await ensureLocation(page, "HL-Watermark", LCODE); + await scanTitleAndVolume(page, ISBN, VCODE); + + await page.goto("/admin?tab=health"); + await expect( + page.getByRole("heading", { + name: /Barcode labels|Étiquettes à code-barres|Barcode-Etiketten|Etichette con codice a barre/i, + }), + ).toBeVisible(); + + const v = await readWatermark(page, "health-last-vcode"); + expect(v.highest.startsWith("V")).toBe(true); + expect(codeNumber(v.highest)).toBeGreaterThanOrEqual(codeNumber(VCODE)); + expect(codeNumber(v.next)).toBe(codeNumber(v.highest) + 1); + + const l = await readWatermark(page, "health-last-lcode"); + expect(l.highest.startsWith("L")).toBe(true); + expect(codeNumber(l.highest)).toBeGreaterThanOrEqual(codeNumber(LCODE)); + expect(codeNumber(l.next)).toBe(codeNumber(l.highest) + 1); + }); + + test("the watermark survives the volume going to the trash", async ({ + page, + }) => { + // A printed sticker outlives the row: once the volume is soft-deleted, + // the reported highest must not drop below the code it carried. + const isbn = specIsbn("HL", 2); + const vcode = "V6690"; + await scanTitleAndVolume(page, isbn, vcode); + + await page.goto("/admin?tab=health"); + const before = await readWatermark(page, "health-last-vcode"); + expect(codeNumber(before.highest)).toBeGreaterThanOrEqual(codeNumber(vcode)); + + // Trash the volume from its title page (same journey as #442). + await page.goto(`/?q=${isbn}`); + const titleLink = page + .locator('#browse-results table.browse-table tbody tr td a[href^="/title/"]') + .first(); + await expect(titleLink).toBeVisible({ timeout: 15000 }); + await page.goto((await titleLink.getAttribute("href"))!); + await page.waitForURL(/\/title\/\d+/); + const volumeRow = page + .locator("#title-volumes table tbody tr") + .filter({ hasText: vcode }) + .first(); + await expect(volumeRow).toBeVisible(); + await volumeRow.getByRole("button", { name: /Delete|Supprimer/i }).click(); + await Promise.all([ + page.waitForLoadState("load"), + (async () => { + const dialog = page.locator("#modal-slot dialog[open]"); + await expect(dialog).toBeVisible(); + await dialog + .getByRole("button", { name: /Delete|Supprimer|Confirm|Confirmer/i }) + .last() + .click(); + })(), + ]); + await expect(page.locator("#title-volumes")).not.toContainText(vcode); + + await page.goto("/admin?tab=health"); + const after = await readWatermark(page, "health-last-vcode"); + expect(codeNumber(after.highest)).toBeGreaterThanOrEqual(codeNumber(vcode)); + expect(codeNumber(after.highest)).toBeGreaterThanOrEqual(codeNumber(before.highest)); + }); +});