Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/manual/en/02-configuration.tex
Original file line number Diff line number Diff line change
Expand Up @@ -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}).
Expand Down
7 changes: 6 additions & 1 deletion docs/manual/fr/02-configuration.tex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions locales/de.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions locales/fr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions locales/it.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions src/routes/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProviderHealthRow>,
// Fix #214: maintenance section — "Re-fetch missing covers".
Expand Down Expand Up @@ -1392,6 +1401,11 @@ async fn render_health_panel(state: &AppState, loc: &'static str) -> Result<Stri

let providers = build_provider_rows(&state.registry, &state.provider_health, loc);

// #489 — label high-water marks (two MAX() lookups, no cache needed).
let watermarks = admin_health::label_watermarks(pool).await?;
let last_vcode_value = format_label_watermark(watermarks.highest_vcode.as_deref(), loc);
let last_lcode_value = format_label_watermark(watermarks.highest_lcode.as_deref(), loc);

// Fix #214: maintenance section data — count of titles with no
// cover + bulk-fetch status. Cheap query (single SELECT COUNT) and
// a lock-only status read; no extra DB round-trip beyond the count.
Expand Down Expand Up @@ -1451,6 +1465,12 @@ async fn render_health_panel(state: &AppState, loc: &'static str) -> Result<Stri
count_active_loans_label: rust_i18n::t!("admin.health.count_active_loans", locale = loc)
.to_string(),
count_active_loans: counts.active_loans,
labels_heading: rust_i18n::t!("admin.health.labels_heading", locale = loc).to_string(),
labels_hint: rust_i18n::t!("admin.health.labels_hint", locale = loc).to_string(),
last_vcode_label: rust_i18n::t!("admin.health.last_vcode", locale = loc).to_string(),
last_vcode_value,
last_lcode_label: rust_i18n::t!("admin.health.last_lcode", locale = loc).to_string(),
last_lcode_value,
providers_heading: rust_i18n::t!("admin.health.providers_heading", locale = loc)
.to_string(),
providers,
Expand Down Expand Up @@ -1491,6 +1511,26 @@ async fn render_health_panel(state: &AppState, loc: &'static str) -> Result<Stri
.map_err(|_| AppError::Internal("admin health panel render failed".to_string()))
}

/// #489 — render one label watermark for the Health tab: `V0142 (next: V0143)`,
/// `V9999 (none left)` when the numeric space is exhausted, or the
/// "none yet" placeholder when the catalog holds no label of that family.
fn format_label_watermark(highest: Option<&str>, 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<ProviderRegistry>,
map: &ProviderHealthMap,
Expand Down
104 changes: 104 additions & 0 deletions src/services/admin_health.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,46 @@ pub async fn trash_count(pool: &DbPool) -> Result<i64, AppError> {
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<String>,
pub highest_lcode: Option<String>,
}

/// 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<LabelWatermarks, AppError> {
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<String> {
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.
Expand Down Expand Up @@ -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")]
Expand Down
12 changes: 12 additions & 0 deletions templates/fragments/admin_health_panel.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ <h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100 mb-4">{{ cou
</tbody>
</table>

{# #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. #}
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100 mb-2">{{ labels_heading }}</h2>
<p class="text-sm text-stone-500 dark:text-stone-400 mb-4">{{ labels_hint }}</p>
<table class="min-w-full divide-y divide-stone-200 dark:divide-stone-700 mb-8">
<tbody class="divide-y divide-stone-100 dark:divide-stone-800">
<tr><th scope="row" class="px-3 py-2 text-left text-sm text-stone-500 dark:text-stone-400 font-normal">{{ last_vcode_label }}</th><td id="health-last-vcode" class="px-3 py-2 text-right text-sm font-mono text-stone-900 dark:text-stone-100">{{ last_vcode_value }}</td></tr>
<tr><th scope="row" class="px-3 py-2 text-left text-sm text-stone-500 dark:text-stone-400 font-normal">{{ last_lcode_label }}</th><td id="health-last-lcode" class="px-3 py-2 text-right text-sm font-mono text-stone-900 dark:text-stone-100">{{ last_lcode_value }}</td></tr>
</tbody>
</table>

<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100 mb-4">{{ providers_heading }}</h2>
<table class="min-w-full divide-y divide-stone-200 dark:divide-stone-700 mb-8">
<tbody class="divide-y divide-stone-100 dark:divide-stone-800">
Expand Down
Loading
Loading