diff --git a/app/Controllers/PrestitiController.php b/app/Controllers/PrestitiController.php
index 087b8d14f..effa3e129 100644
--- a/app/Controllers/PrestitiController.php
+++ b/app/Controllers/PrestitiController.php
@@ -755,8 +755,12 @@ public function update(Request $request, Response $response, mysqli $db, int $id
// giornata singola) è lecita: createReservation accetta end == start,
// quindi un rifiuto strettamente esclusivo renderebbe immodificabili
// i prestiti a giornata nati dal calendario utente.
- if (strtotime($newScadenza) === false || strtotime($newPrestito) === false
- || strtotime($newScadenza) < strtotime($newPrestito)) {
+ // Formato STRETTO Y-m-d (niente strtotime): valori ambigui o date di
+ // calendario inesistenti (2026-02-30) vanno rifiutati, non normalizzati
+ // — i confronti lessicografici a valle e il match con i valori salvati
+ // presuppongono stringhe canoniche (CodeRabbit, PR #337).
+ if (!self::isStrictIsoDate($newPrestito) || !self::isStrictIsoDate($newScadenza)
+ || $newScadenza < $newPrestito) {
return $response->withHeader('Location', url('/admin/loans') . '?error=invalid_dates')->withStatus(302);
}
@@ -774,7 +778,11 @@ public function update(Request $request, Response $response, mysqli $db, int $id
// Lock del prestito e ri-verifica sotto lock: stato aperto invariato e
// libro_id non cambiato (TOCTOU sulla lettura non bloccante iniziale).
- $lockLoan = $db->prepare('SELECT attivo, data_restituzione, libro_id, utente_id FROM prestiti WHERE id=? FOR UPDATE');
+ // Rilegge anche le DATE correnti: un update concorrente tra la lettura
+ // iniziale e questo lock le può aver cambiate, e la finestra "vecchia"
+ // del check di capacità qui sotto deve basarsi sui valori realmente
+ // salvati, non su quelli pre-transazione (CodeRabbit, PR #337).
+ $lockLoan = $db->prepare('SELECT attivo, data_restituzione, libro_id, copia_id, utente_id, data_prestito, data_scadenza FROM prestiti WHERE id=? FOR UPDATE');
$lockLoan->bind_param('i', $id);
$lockLoan->execute();
$locked = $lockLoan->get_result()->fetch_assoc();
@@ -788,6 +796,19 @@ public function update(Request $request, Response $response, mysqli $db, int $id
return $response->withHeader('Location', url('/admin/loans') . '?error=loan_update_failed')->withStatus(302);
}
+ // Ricostruisci i valori effettivi dai dati LOCKATI: i campi non inviati
+ // dal form devono completarsi con lo stato corrente reale della riga.
+ // Ri-valida il range con gli stessi criteri del pre-check (che resta
+ // come fast-fail senza aprire la transazione).
+ $newUserId = isset($updateData['utente_id']) ? (int) $updateData['utente_id'] : (int) $locked['utente_id'];
+ $newPrestito = (string) ($updateData['data_prestito'] ?? $locked['data_prestito']);
+ $newScadenza = (string) ($updateData['data_scadenza'] ?? $locked['data_scadenza']);
+ if (!self::isStrictIsoDate($newPrestito) || !self::isStrictIsoDate($newScadenza)
+ || $newScadenza < $newPrestito) {
+ $db->rollback();
+ return $response->withHeader('Location', url('/admin/loans') . '?error=invalid_dates')->withStatus(302);
+ }
+
// Se l'utente cambia, ri-esegui i controlli di store() (M6b): il campo
// arriva da hidden field e senza ricontrolli permetterebbe di aggirare
// idoneità e dup-check assegnando il prestito a un altro utente.
@@ -858,15 +879,68 @@ public function update(Request $request, Response $response, mysqli $db, int $id
}
}
- // #11: if the loan is being RESCHEDULED, re-check the new window against
+ // #11: if the loan is being RESCHEDULED, re-check the new dates against
// overlapping loans + queue reservations vs capacity (renew() does this — update()
// used to accept any new dates and only recalc counters, silently extending a loan
// over a queued reservation). Only when the dates actually change.
- if ($newPrestito !== (string) $current['data_prestito'] || $newScadenza !== (string) $current['data_scadenza']) {
+ // #336: check ONLY the newly-claimed segments — the EXACT set difference
+ // new window ∖ old window. Checking the WHOLE new window re-counted
+ // commitments that already coexist with the current period (e.g. a
+ // queued reservation overlapping the loan), so on a 1-copy book ANY
+ // date edit — even shortening the loan — bounced with
+ // no_copies_available. Days inside the old window (boundary days
+ // included: they are already held by this loan) need no re-check;
+ // only genuinely added days need free capacity.
+ // Old window from the LOCKED row, not the pre-transaction read.
+ $oldPrestito = (string) $locked['data_prestito'];
+ $oldScadenza = (string) $locked['data_scadenza'];
+ if ($newPrestito !== $oldPrestito || $newScadenza !== $oldScadenza) {
+ // Y-m-d strings compare correctly lexicographically (validated
+ // strict above); ±1 day via DateTimeImmutable, no TZ ambiguity.
+ $dayBefore = static fn (string $ymd): string => (new \DateTimeImmutable($ymd))->modify('-1 day')->format('Y-m-d');
+ $dayAfter = static fn (string $ymd): string => (new \DateTimeImmutable($ymd))->modify('+1 day')->format('Y-m-d');
+ $claimedWindows = [];
+ if ($newPrestito < $oldPrestito) {
+ $claimedWindows[] = [$newPrestito, min($dayBefore($oldPrestito), $newScadenza)];
+ }
+ if ($newScadenza > $oldScadenza) {
+ $claimedWindows[] = [max($dayAfter($oldScadenza), $newPrestito), $newScadenza];
+ }
$capacity = new \App\Services\CapacityService($db);
- if (!$capacity->hasFreeCapacity($libroId, $newPrestito, $newScadenza, excludePrestitoId: $id)) {
- $db->rollback();
- return $response->withHeader('Location', url('/admin/loans') . '?error=no_copies_available')->withStatus(302);
+ foreach ($claimedWindows as [$claimStart, $claimEnd]) {
+ if (!$capacity->hasFreeCapacity($libroId, $claimStart, $claimEnd, excludePrestitoId: $id)) {
+ $db->rollback();
+ return $response->withHeader('Location', url('/admin/loans') . '?error=no_copies_available')->withStatus(302);
+ }
+ }
+
+ // CapacityService decides at BOOK level. With multiple copies it
+ // can report spare capacity even when the physical copy assigned
+ // to this loan has another future loan on the added days. The DB
+ // trigger would reject the UPDATE later, but only as the generic
+ // loan_update_failed. Mirror renew()/bulkExtend() here so the
+ // conflict is detected before the write and reported truthfully.
+ $copyId = $locked['copia_id'] !== null ? (int) $locked['copia_id'] : null;
+ if ($copyId !== null && $claimedWindows !== []) {
+ $copyOverlap = $db->prepare(
+ "SELECT 1 FROM prestiti
+ WHERE copia_id = ? AND id <> ?
+ AND data_prestito <= ?
+ AND (stato = 'in_ritardo' OR data_scadenza >= ?)
+ AND ((attivo = 1 AND stato IN ('prenotato','da_ritirare','in_corso','in_ritardo'))
+ OR (attivo = 0 AND stato = 'pendente' AND copia_id IS NOT NULL))
+ LIMIT 1"
+ );
+ foreach ($claimedWindows as [$claimStart, $claimEnd]) {
+ $copyOverlap->bind_param('iiss', $copyId, $id, $claimEnd, $claimStart);
+ $copyOverlap->execute();
+ if ((bool) $copyOverlap->get_result()->fetch_row()) {
+ $copyOverlap->close();
+ $db->rollback();
+ return $response->withHeader('Location', url('/admin/loans') . '?error=loan_copy_conflict')->withStatus(302);
+ }
+ }
+ $copyOverlap->close();
}
}
@@ -900,7 +974,7 @@ public function update(Request $request, Response $response, mysqli $db, int $id
// emails. A data_prestito-only edit does not affect the overdue clock,
// so it is intentionally excluded from this guard (do not reuse the
// combined data_prestito||data_scadenza condition above).
- if ($newScadenza !== (string) $current['data_scadenza']) {
+ if ($newScadenza !== (string) $locked['data_scadenza']) {
$today = \App\Support\DateHelper::today();
$recalcStato = $db->prepare(
"UPDATE prestiti
@@ -1571,17 +1645,22 @@ private function applyBulkLoanExtension(
$todayDate = \DateTimeImmutable::createFromFormat('!Y-m-d', $today);
$base = ($todayDate !== false && $todayDate > $dueDate) ? $todayDate : $dueDate;
$newDueDate = $base->modify('+' . $days . ' days')->format('Y-m-d');
- $loanStart = (string) $loan['data_prestito'];
// Apply each accepted extension immediately inside the transaction, so the
// next capacity check sees all earlier proposed extensions too.
- if (!$capacity->hasFreeCapacity($bookId, $loanStart, $newDueDate, excludePrestitoId: $loanId)) {
+ // #336: BOTH gates check the same interval — only the days the extension
+ // actually adds (day after the current due date → new due date). The due
+ // date itself is already held by this loan, and the copy-overlap check
+ // previously scanned the whole loan window while capacity scanned the
+ // extension window: two different intervals for one decision (CodeRabbit).
+ $extensionStart = $dueDate->modify('+1 day')->format('Y-m-d');
+ if (!$capacity->hasFreeCapacity($bookId, $extensionStart, $newDueDate, excludePrestitoId: $loanId)) {
return null;
}
$copyId = $loan['copia_id'] !== null ? (int) $loan['copia_id'] : null;
if ($copyId !== null) {
- $copyOverlap->bind_param('iiss', $copyId, $loanId, $newDueDate, $loanStart);
+ $copyOverlap->bind_param('iiss', $copyId, $loanId, $newDueDate, $extensionStart);
$copyOverlap->execute();
if ((bool) $copyOverlap->get_result()->fetch_row()) {
return null;
@@ -2025,6 +2104,26 @@ public function exportCsv(Request $request, Response $response, mysqli $db): Res
->withHeader('Pragma', 'no-cache');
}
+ /**
+ * Data in formato STRETTAMENTE Y-m-d E valida sul calendario reale.
+ * A differenza di strtotime(), rifiuta formati ambigui ('2026-2-5') e date
+ * inesistenti ('2026-02-30' — che strtotime normalizzerebbe al 2 marzo):
+ * il round-trip con createFromFormat garantisce input canonico, così i
+ * confronti lessicografici tra stringhe Y-m-d restano corretti.
+ */
+ private static function isStrictIsoDate(string $value): bool
+ {
+ // Shape-check PRIMA di toccare DateTime: createFromFormat() lancia
+ // ValueError su input con byte NUL ('2026-01-01%00'), che fuori da un
+ // try diventerebbe un 500 invece di invalid_dates (CodeRabbit, #337).
+ // /D àncora la fine reale della stringa (niente newline finale tollerato).
+ if (!preg_match('/^\d{4}-\d{2}-\d{2}$/D', $value)) {
+ return false;
+ }
+ $dt = \DateTime::createFromFormat('Y-m-d', $value);
+ return $dt !== false && $dt->format('Y-m-d') === $value;
+ }
+
private function guardStaffAccess(Response $response): ?Response
{
$role = $_SESSION['user']['tipo_utente'] ?? '';
diff --git a/app/Controllers/UserActionsController.php b/app/Controllers/UserActionsController.php
index b5be5d1d9..e5d25f4a0 100644
--- a/app/Controllers/UserActionsController.php
+++ b/app/Controllers/UserActionsController.php
@@ -71,14 +71,17 @@ public function reservationsPage(Request $request, Response $response, mysqli $d
}
$stmt->close();
- // Storico prestiti (ultimi 20) - solo prestiti conclusi
+ // Storico prestiti (ultimi 20) - tutti i prestiti conclusi, inclusi
+ // annullati e scaduti (prima sparivano dallo storico). Questi non hanno
+ // data_restituzione: ordina sul momento di chiusura (updated_at) così
+ // un annullamento recente non finisce in fondo alla lista.
$sql = "SELECT pr.id, pr.libro_id, pr.data_prestito, pr.data_restituzione, pr.stato,
l.titolo, l.copertina_url,
EXISTS(SELECT 1 FROM recensioni r WHERE r.libro_id = pr.libro_id AND r.utente_id = ?) as has_review
FROM prestiti pr
JOIN libri l ON l.id = pr.libro_id AND l.deleted_at IS NULL
- WHERE pr.utente_id = ? AND pr.attivo = 0 AND pr.stato IN ('restituito','perso','danneggiato')
- ORDER BY pr.data_restituzione DESC, pr.data_prestito DESC
+ WHERE pr.utente_id = ? AND pr.attivo = 0 AND pr.stato IN ('restituito','perso','danneggiato','annullato','scaduto')
+ ORDER BY COALESCE(pr.data_restituzione, pr.updated_at) DESC, pr.data_prestito DESC
LIMIT 20";
$stmt = $db->prepare($sql);
$stmt->bind_param('ii', $uid, $uid);
diff --git a/app/Controllers/UserDashboardController.php b/app/Controllers/UserDashboardController.php
index c26e9e463..b708dab8e 100644
--- a/app/Controllers/UserDashboardController.php
+++ b/app/Controllers/UserDashboardController.php
@@ -45,8 +45,9 @@ public function index(Request $request, Response $response, mysqli $db): Respons
$stats['preferiti'] = (int)($res->fetch_assoc()['c'] ?? 0);
$stmt->close();
- // Count user loan history (exclude soft-deleted books)
- $stmt = $db->prepare("SELECT COUNT(*) AS c FROM prestiti p JOIN libri l ON p.libro_id = l.id WHERE p.utente_id = ? AND p.attivo = 0 AND p.stato IN ('restituito','perso','danneggiato') AND l.deleted_at IS NULL");
+ // Count user loan history (exclude soft-deleted books) — includes
+ // cancelled/expired loans, same predicate as the history list below.
+ $stmt = $db->prepare("SELECT COUNT(*) AS c FROM prestiti p JOIN libri l ON p.libro_id = l.id WHERE p.utente_id = ? AND p.attivo = 0 AND p.stato IN ('restituito','perso','danneggiato','annullato','scaduto') AND l.deleted_at IS NULL");
$stmt->bind_param('i', $userId);
$stmt->execute();
$res = $stmt->get_result();
@@ -181,15 +182,17 @@ public function prenotazioni(Request $request, Response $response, mysqli $db, m
}
$stmt->close();
- // Past loans (completed)
+ // Past loans (completed) — includes cancelled/expired loans, which have
+ // no data_restituzione: order on the closing moment (updated_at) so a
+ // recent cancellation doesn't sink to the bottom of the list.
$stmt = $db->prepare("
SELECT pr.id, pr.libro_id, pr.data_prestito, pr.data_restituzione, pr.stato,
l.titolo, l.copertina_url,
EXISTS(SELECT 1 FROM recensioni r WHERE r.libro_id = pr.libro_id AND r.utente_id = ?) AS has_review
FROM prestiti pr
JOIN libri l ON l.id = pr.libro_id
- WHERE pr.utente_id = ? AND pr.attivo = 0 AND pr.stato IN ('restituito','perso','danneggiato') AND l.deleted_at IS NULL
- ORDER BY pr.data_restituzione DESC, pr.data_prestito DESC
+ WHERE pr.utente_id = ? AND pr.attivo = 0 AND pr.stato IN ('restituito','perso','danneggiato','annullato','scaduto') AND l.deleted_at IS NULL
+ ORDER BY COALESCE(pr.data_restituzione, pr.updated_at) DESC, pr.data_prestito DESC
LIMIT 50
");
$stmt->bind_param('ii', $userId, $userId);
diff --git a/app/Support/IcsGenerator.php b/app/Support/IcsGenerator.php
index 76217fc7e..bf5582f1c 100644
--- a/app/Support/IcsGenerator.php
+++ b/app/Support/IcsGenerator.php
@@ -219,6 +219,9 @@ private function fetchEvents(): array
*/
private function getLoanTitle(string $status, string $bookTitle): string
{
+ // Divergenza voluta da translate_loan_status(): questi sono TITOLI di
+ // eventi calendario ("Prestito Scaduto", "Prestito Programmato"), non
+ // etichette di stato — le etichette passano da translateStatus() sotto.
$prefix = match($status) {
'in_corso' => '📖 ' . __('Prestito'),
'da_ritirare' => '📦 ' . __('Da Ritirare'),
@@ -272,15 +275,14 @@ private function getReservationDescription(array $row): string
*/
private function translateStatus(string $status): string
{
- return match($status) {
- 'in_corso' => __('In corso'),
- 'da_ritirare' => __('Da Ritirare'),
- 'prenotato' => __('Programmato'),
- 'in_ritardo' => __('Scaduto'),
- 'pendente' => __('In attesa'),
- 'attiva' => __('Attiva'),
- default => $status
- };
+ // 'attiva' è lo stato delle prenotazioni (tabella prenotazioni), fuori
+ // dall'enum prestiti; tutti gli stati prestito passano dall'helper
+ // canonico translate_loan_status() (#333) — il vecchio alias "Scaduto"
+ // per in_ritardo collideva con lo stato 'scaduto' vero e proprio.
+ if ($status === 'attiva') {
+ return __('Attiva');
+ }
+ return translate_loan_status($status);
}
/**
diff --git a/app/Views/admin/integrity_report.php b/app/Views/admin/integrity_report.php
index 05a0930fc..7c9cc56d6 100644
--- a/app/Views/admin/integrity_report.php
+++ b/app/Views/admin/integrity_report.php
@@ -3,8 +3,10 @@
?>
= __("Attivo:") ?>
diff --git a/app/Views/prestiti/index.php b/app/Views/prestiti/index.php
index 5b7308d70..6909725b1 100644
--- a/app/Views/prestiti/index.php
+++ b/app/Views/prestiti/index.php
@@ -1,33 +1,9 @@
" . __("Pendente") . "";
- case 'prenotato':
- return "" . __("Prenotato") . "";
- case 'da_ritirare':
- return "" . __("Da Ritirare") . "";
- case 'in_corso':
- return "" . __("In Corso") . "";
- case 'in_ritardo':
- return "" . __("In Ritardo") . "";
- case 'restituito':
- return "" . __("Restituito") . "";
- case 'perso':
- return "" . __("Perso") . "";
- case 'danneggiato':
- return "" . __("Danneggiato") . "";
- case 'scaduto':
- return "" . __("Scaduto") . "";
- default:
- return "" . __("Sconosciuto") . "";
- }
-}
+// Badge canonico degli stati prestito (#333): unica mappa colore/icona/etichetta
+// condivisa tra rendering SSR e colonne DataTables.
+require_once __DIR__ . '/../partials/loan-status-badge.php';
$applicationToday = \App\Support\DateHelper::today();
?>
@@ -110,6 +86,35 @@ function getStatusBadge($status) {
case 'loan_not_closable':
echo __('Prestito non trovato o non chiudibile.');
break;
+ case 'no_copies_available':
+ // #336: dire solo "nessuna copia" era fuorviante — il vero motivo
+ // è un conflitto con un altro impegno nel periodo richiesto.
+ echo __('Modifica non salvata: nel nuovo periodo tutte le copie sono già impegnate da altri prestiti o prenotazioni.');
+ break;
+ case 'loan_copy_conflict':
+ echo __('Modifica non salvata: la copia assegnata è già impegnata da un altro prestito nel nuovo periodo.');
+ break;
+ case 'extension_conflicts':
+ echo __('Impossibile rinnovare: un altro prestito o prenotazione occupa il periodo richiesto.');
+ break;
+ case 'loan_overdue':
+ echo __('Impossibile rinnovare: il prestito è in ritardo.');
+ break;
+ case 'max_renewals':
+ echo __('Numero massimo di rinnovi raggiunto per questo prestito.');
+ break;
+ case 'loan_not_active':
+ echo __('Il prestito non è più attivo.');
+ break;
+ case 'loan_not_picked_up':
+ echo __('Impossibile rinnovare: il prestito non è ancora stato ritirato.');
+ break;
+ case 'book_not_found':
+ echo __('Libro non trovato o non più disponibile.');
+ break;
+ case 'renewal_failed':
+ echo __('Rinnovo non riuscito. Riprova.');
+ break;
default:
echo __('Errore durante l\'aggiornamento del prestito.');
}
@@ -313,6 +318,7 @@ class="inline-flex items-center gap-2 whitespace-nowrap px-4 py-2 bg-gray-100 te
+
diff --git a/app/helpers.php b/app/helpers.php
index 0fa8a448f..8002bbfd1 100644
--- a/app/helpers.php
+++ b/app/helpers.php
@@ -329,6 +329,27 @@ function translate_loan_status(string $status): string
}
}
+if (!function_exists('loan_status_label_map')) {
+ /**
+ * Full prestiti.stato enum => localized label, via translate_loan_status()
+ * (single source of truth for the wording). For contexts that need the whole
+ * map at once — e.g. json_encode() into a JS lookup for charts/calendars —
+ * instead of hand-maintained per-view copies (#333). For HTML badges in the
+ * admin views use loan_status_badge() (app/Views/partials/loan-status-badge.php).
+ *
+ * @return array
+ */
+ function loan_status_label_map(): array
+ {
+ $states = ['pendente', 'prenotato', 'da_ritirare', 'in_corso', 'in_ritardo', 'restituito', 'perso', 'danneggiato', 'annullato', 'scaduto'];
+ $map = [];
+ foreach ($states as $stato) {
+ $map[$stato] = translate_loan_status($stato);
+ }
+ return $map;
+ }
+}
+
if (!function_exists('full_name')) {
/**
* Join a member's given name and surname into a display name.
diff --git a/locale/da_DK.json b/locale/da_DK.json
index 300785f3b..8da15f76d 100644
--- a/locale/da_DK.json
+++ b/locale/da_DK.json
@@ -6764,5 +6764,8 @@
"https://...": "https://...",
"⚙️ Comportamento Automatico: Se inserisci codice in \"JavaScript Analitici\" o \"JavaScript Marketing\", i rispettivi toggle in Impostazioni Privacy verranno automaticamente selezionati.": "⚙️ Automatisk adfærd: Hvis du indtaster kode i \"Analytisk JavaScript\" eller \"Marketing-JavaScript\", vil de tilhørende til/fra-knapper i Privatlivsindstillinger automatisk blive valgt.",
"📋 Importante: Devi elencare manualmente i cookie tracciati da questi script nella Pagina Cookie per conformità GDPR.": "📋 Vigtigt: Du skal manuelt angive de cookies, som disse scripts sporer, på Cookie-siden for at overholde GDPR.",
- "Lingua eliminata, ma un file di traduzione non è stato rimosso: potrebbe riapparire se ricrei la lingua con lo stesso codice.": "Sproget er slettet, men en oversættelsesfil kunne ikke fjernes: den kan dukke op igen, hvis du genopretter sproget med samme kode."
+ "Lingua eliminata, ma un file di traduzione non è stato rimosso: potrebbe riapparire se ricrei la lingua con lo stesso codice.": "Sproget er slettet, men en oversættelsesfil kunne ikke fjernes: den kan dukke op igen, hvis du genopretter sproget med samme kode.",
+ "Modifica non salvata: nel nuovo periodo tutte le copie sono già impegnate da altri prestiti o prenotazioni.": "Ændringen blev ikke gemt: I den nye periode er alle eksemplarer allerede optaget af andre udlån eller reservationer.",
+ "Rinnovo non riuscito. Riprova.": "Fornyelsen mislykkedes. Prøv igen.",
+ "Modifica non salvata: la copia assegnata è già impegnata da un altro prestito nel nuovo periodo.": "Ændringen blev ikke gemt: Det tildelte eksemplar er allerede optaget af et andet lån i den nye periode."
}
diff --git a/locale/de_DE.json b/locale/de_DE.json
index e543c5fdb..1a2a2cc92 100644
--- a/locale/de_DE.json
+++ b/locale/de_DE.json
@@ -6764,5 +6764,8 @@
"https://...": "https://...",
"⚙️ Comportamento Automatico: Se inserisci codice in \"JavaScript Analitici\" o \"JavaScript Marketing\", i rispettivi toggle in Impostazioni Privacy verranno automaticamente selezionati.": "⚙️ Automatisches Verhalten: Wenn Sie Code in „Analyse-JavaScript“ oder „Marketing-JavaScript“ eingeben, werden die jeweiligen Schalter in Datenschutzeinstellungen automatisch aktiviert.",
"📋 Importante: Devi elencare manualmente i cookie tracciati da questi script nella Pagina Cookie per conformità GDPR.": "📋 Wichtig: Sie müssen die von diesen Skripten erfassten Cookies manuell auf der Cookie-Seite auflisten, um die DSGVO einzuhalten.",
- "Lingua eliminata, ma un file di traduzione non è stato rimosso: potrebbe riapparire se ricrei la lingua con lo stesso codice.": "Sprache gelöscht, aber eine Übersetzungsdatei konnte nicht entfernt werden: Sie könnte wieder auftauchen, wenn Sie die Sprache mit demselben Code neu anlegen."
+ "Lingua eliminata, ma un file di traduzione non è stato rimosso: potrebbe riapparire se ricrei la lingua con lo stesso codice.": "Sprache gelöscht, aber eine Übersetzungsdatei konnte nicht entfernt werden: Sie könnte wieder auftauchen, wenn Sie die Sprache mit demselben Code neu anlegen.",
+ "Modifica non salvata: nel nuovo periodo tutte le copie sono già impegnate da altri prestiti o prenotazioni.": "Änderung nicht gespeichert: Im neuen Zeitraum sind alle Exemplare bereits durch andere Ausleihen oder Vormerkungen belegt.",
+ "Rinnovo non riuscito. Riprova.": "Verlängerung fehlgeschlagen. Bitte erneut versuchen.",
+ "Modifica non salvata: la copia assegnata è già impegnata da un altro prestito nel nuovo periodo.": "Änderung nicht gespeichert: Das zugewiesene Exemplar ist im neuen Zeitraum bereits durch eine andere Ausleihe belegt."
}
diff --git a/locale/en_US.json b/locale/en_US.json
index f6d73071e..7af4145be 100644
--- a/locale/en_US.json
+++ b/locale/en_US.json
@@ -6764,5 +6764,8 @@
"https://...": "https://...",
"⚙️ Comportamento Automatico: Se inserisci codice in \"JavaScript Analitici\" o \"JavaScript Marketing\", i rispettivi toggle in Impostazioni Privacy verranno automaticamente selezionati.": "⚙️ Automatic behaviour: If you enter code in \"Analytics JavaScript\" or \"Marketing JavaScript\", the respective toggles in Privacy Settings will be selected automatically.",
"📋 Importante: Devi elencare manualmente i cookie tracciati da questi script nella Pagina Cookie per conformità GDPR.": "📋 Important: You must manually list the cookies tracked by these scripts on the Cookie Page for GDPR compliance.",
- "Lingua eliminata, ma un file di traduzione non è stato rimosso: potrebbe riapparire se ricrei la lingua con lo stesso codice.": "Language deleted, but a translation file could not be removed: it may reappear if you recreate the language with the same code."
+ "Lingua eliminata, ma un file di traduzione non è stato rimosso: potrebbe riapparire se ricrei la lingua con lo stesso codice.": "Language deleted, but a translation file could not be removed: it may reappear if you recreate the language with the same code.",
+ "Modifica non salvata: nel nuovo periodo tutte le copie sono già impegnate da altri prestiti o prenotazioni.": "Change not saved: in the new period every copy is already taken by other loans or reservations.",
+ "Rinnovo non riuscito. Riprova.": "Renewal failed. Please try again.",
+ "Modifica non salvata: la copia assegnata è già impegnata da un altro prestito nel nuovo periodo.": "Change not saved: the assigned copy is already committed to another loan in the new period."
}
diff --git a/locale/fr_FR.json b/locale/fr_FR.json
index 7751a5eac..ae737f590 100644
--- a/locale/fr_FR.json
+++ b/locale/fr_FR.json
@@ -6764,5 +6764,8 @@
"https://...": "https://...",
"⚙️ Comportamento Automatico: Se inserisci codice in \"JavaScript Analitici\" o \"JavaScript Marketing\", i rispettivi toggle in Impostazioni Privacy verranno automaticamente selezionati.": "⚙️ Comportement automatique : si vous saisissez du code dans « JavaScript analytique » ou « JavaScript marketing », les interrupteurs correspondants dans Paramètres de confidentialité seront automatiquement activés.",
"📋 Importante: Devi elencare manualmente i cookie tracciati da questi script nella Pagina Cookie per conformità GDPR.": "📋 Important : vous devez lister manuellement les cookies suivis par ces scripts sur la Page Cookies pour la conformité RGPD.",
- "Lingua eliminata, ma un file di traduzione non è stato rimosso: potrebbe riapparire se ricrei la lingua con lo stesso codice.": "Langue supprimée, mais un fichier de traduction n'a pas pu être supprimé : il pourrait réapparaître si vous recréez la langue avec le même code."
+ "Lingua eliminata, ma un file di traduzione non è stato rimosso: potrebbe riapparire se ricrei la lingua con lo stesso codice.": "Langue supprimée, mais un fichier de traduction n'a pas pu être supprimé : il pourrait réapparaître si vous recréez la langue avec le même code.",
+ "Modifica non salvata: nel nuovo periodo tutte le copie sono già impegnate da altri prestiti o prenotazioni.": "Modification non enregistrée : sur la nouvelle période, tous les exemplaires sont déjà occupés par d'autres prêts ou réservations.",
+ "Rinnovo non riuscito. Riprova.": "Échec du renouvellement. Veuillez réessayer.",
+ "Modifica non salvata: la copia assegnata è già impegnata da un altro prestito nel nuovo periodo.": "Modification non enregistrée : l’exemplaire attribué est déjà affecté à un autre prêt pendant la nouvelle période."
}
diff --git a/locale/it_IT.json b/locale/it_IT.json
index fdd0e35f5..141bf1eee 100644
--- a/locale/it_IT.json
+++ b/locale/it_IT.json
@@ -6764,5 +6764,8 @@
"https://...": "https://...",
"⚙️ Comportamento Automatico: Se inserisci codice in \"JavaScript Analitici\" o \"JavaScript Marketing\", i rispettivi toggle in Impostazioni Privacy verranno automaticamente selezionati.": "⚙️ Comportamento Automatico: Se inserisci codice in \"JavaScript Analitici\" o \"JavaScript Marketing\", i rispettivi toggle in Impostazioni Privacy verranno automaticamente selezionati.",
"📋 Importante: Devi elencare manualmente i cookie tracciati da questi script nella Pagina Cookie per conformità GDPR.": "📋 Importante: Devi elencare manualmente i cookie tracciati da questi script nella Pagina Cookie per conformità GDPR.",
- "Lingua eliminata, ma un file di traduzione non è stato rimosso: potrebbe riapparire se ricrei la lingua con lo stesso codice.": "Lingua eliminata, ma un file di traduzione non è stato rimosso: potrebbe riapparire se ricrei la lingua con lo stesso codice."
+ "Lingua eliminata, ma un file di traduzione non è stato rimosso: potrebbe riapparire se ricrei la lingua con lo stesso codice.": "Lingua eliminata, ma un file di traduzione non è stato rimosso: potrebbe riapparire se ricrei la lingua con lo stesso codice.",
+ "Modifica non salvata: nel nuovo periodo tutte le copie sono già impegnate da altri prestiti o prenotazioni.": "Modifica non salvata: nel nuovo periodo tutte le copie sono già impegnate da altri prestiti o prenotazioni.",
+ "Rinnovo non riuscito. Riprova.": "Rinnovo non riuscito. Riprova.",
+ "Modifica non salvata: la copia assegnata è già impegnata da un altro prestito nel nuovo periodo.": "Modifica non salvata: la copia assegnata è già impegnata da un altro prestito nel nuovo periodo."
}
diff --git a/storage/plugins/mobile-api/plugin.json b/storage/plugins/mobile-api/plugin.json
index b38f1f136..bcfe7c1f0 100644
--- a/storage/plugins/mobile-api/plugin.json
+++ b/storage/plugins/mobile-api/plugin.json
@@ -2,7 +2,7 @@
"name": "mobile-api",
"display_name": "Mobile API",
"description": "API REST/JSON versionata (/api/v1) per l'app companion mobile di Pinakes: discovery, autenticazione a token per dispositivo, ricerca catalogo, prestiti/prenotazioni, wishlist, profilo, messaggi e notifiche push. Disattivata per default finché non viene abilitata.",
- "version": "1.4.2",
+ "version": "1.4.3",
"author": "Fabiodalez",
"author_url": "",
"plugin_url": "",
diff --git a/storage/plugins/mobile-api/src/Controllers/ActionsController.php b/storage/plugins/mobile-api/src/Controllers/ActionsController.php
index 32a45b710..9ff0e94c8 100644
--- a/storage/plugins/mobile-api/src/Controllers/ActionsController.php
+++ b/storage/plugins/mobile-api/src/Controllers/ActionsController.php
@@ -89,7 +89,7 @@ public function myLoans(Request $request, ResponseInterface $response): Response
// Active loans (scheduled / to-pickup / in-progress / overdue).
$sql = "SELECT pr.id, pr.libro_id, pr.data_prestito, pr.data_scadenza, pr.data_restituzione,
- pr.stato, pr.renewals, l.titolo, l.copertina_url
+ pr.stato, pr.renewals, pr.created_at, l.titolo, l.copertina_url
FROM prestiti pr
JOIN libri l ON l.id = pr.libro_id AND l.deleted_at IS NULL
WHERE pr.utente_id = ? AND pr.attivo = 1
@@ -99,13 +99,17 @@ public function myLoans(Request $request, ResponseInterface $response): Response
$active[] = $this->mapLoan($r);
}
- // Concluded history (most recent 30).
+ // Concluded history (most recent 30) — includes cancelled/expired
+ // loans like the web history. They have no data_restituzione: order
+ // on the closing moment (updated_at) so a recent cancellation does
+ // not sink to the bottom. `status` is documented as the raw
+ // prestiti.stato value, so the extra states are additive.
$sql = "SELECT pr.id, pr.libro_id, pr.data_prestito, pr.data_restituzione, pr.stato,
- l.titolo, l.copertina_url
+ pr.created_at, l.titolo, l.copertina_url
FROM prestiti pr
JOIN libri l ON l.id = pr.libro_id AND l.deleted_at IS NULL
- WHERE pr.utente_id = ? AND pr.attivo = 0 AND pr.stato IN ('restituito','perso','danneggiato')
- ORDER BY pr.data_restituzione DESC, pr.data_prestito DESC
+ WHERE pr.utente_id = ? AND pr.attivo = 0 AND pr.stato IN ('restituito','perso','danneggiato','annullato','scaduto')
+ ORDER BY COALESCE(pr.data_restituzione, pr.updated_at) DESC, pr.data_prestito DESC
LIMIT 30";
foreach ($this->fetchScoped($sql, $userId) as $r) {
$history[] = $this->mapLoan($r);
@@ -972,6 +976,16 @@ private function mapLoan(array $r): array
'title' => (string) ($r['titolo'] ?? ''),
'cover_url' => absoluteUrl($this->coverPath($r['copertina_url'] ?? null)),
'status' => $status,
+ // Additive since 1.4.3: server-localized label from the canonical
+ // translate_loan_status() helper (#333). For KNOWN states clients
+ // keep their own device-localized wording; this is the fallback for
+ // states a client version doesn't recognize yet.
+ 'status_label' => translate_loan_status($status),
+ // Additive since 1.4.3: the date the loan request was created
+ // (DATE part of created_at). For cancelled/expired loans — which
+ // never went out — this is the only honest date to show; loaned_at
+ // is the *requested start*, not a borrow date.
+ 'requested_at' => !empty($r['created_at']) ? substr((string) $r['created_at'], 0, 10) : null,
'loaned_at' => $this->nullableString($r['data_prestito'] ?? null),
'due_at' => $dueAt,
// Server-authoritative visibility cue: the Android device may be in a
diff --git a/storage/plugins/mobile-api/src/Controllers/OpenApiController.php b/storage/plugins/mobile-api/src/Controllers/OpenApiController.php
index b60ac2461..99314a418 100644
--- a/storage/plugins/mobile-api/src/Controllers/OpenApiController.php
+++ b/storage/plugins/mobile-api/src/Controllers/OpenApiController.php
@@ -1261,6 +1261,8 @@ private function loanItemSchema(): array
'title' => ['type' => 'string'],
'cover_url' => ['type' => 'string', 'format' => 'uri', 'nullable' => true],
'status' => ['type' => 'string', 'description' => 'Raw prestiti.stato value.'],
+ 'status_label' => ['type' => 'string', 'description' => 'Server-localized label for status (since 1.4.3). Fallback for clients without a local mapping for a state; known states may keep device-localized wording.'],
+ 'requested_at' => ['type' => ['string', 'null'], 'format' => 'date', 'description' => 'Date the loan request was created (since 1.4.3). The honest date for cancelled/expired loans, which never went out.'],
'loaned_at' => ['type' => 'string', 'format' => 'date', 'nullable' => true],
'due_at' => ['type' => 'string', 'format' => 'date', 'nullable' => true],
'due_attention' => [
diff --git a/tests/issues-333-334-336.unit.php b/tests/issues-333-334-336.unit.php
new file mode 100644
index 000000000..61f150614
--- /dev/null
+++ b/tests/issues-333-334-336.unit.php
@@ -0,0 +1,298 @@
+ __('Annullato')"),
+ 'badge labels come from translate_loan_status(), which maps annullato'
+);
+foreach ([
+ 'loans list' => $loansIndex,
+ 'loan details page' => $loanDetails,
+ 'user details page' => $userDetails,
+ 'admin book page' => $bookPage,
+] as $surface => $source) {
+ $check(
+ str_contains($source, 'loan-status-badge.php') && str_contains($source, 'loan_status_badge('),
+ "{$surface} renders states through the shared badge partial"
+ );
+}
+$check(
+ str_contains($loansIndex, 'loan_status_badge_map()')
+ && !str_contains($loansIndex, "case 'in_corso':"),
+ 'DataTables status column uses the PHP-generated map, no duplicated JS switch'
+);
+$check(
+ str_contains($loansIndex, 'data-status="annullato"'),
+ 'loans list offers an Annullato status filter button'
+);
+$check(
+ str_contains($loansIndex, 'value="annullato"'),
+ 'CSV export dialog includes the annullato state'
+);
+$check(
+ str_contains($loanDetails, "['annullato', 'scaduto']"),
+ 'loan details page does not claim "not yet returned" for cancelled/expired loans'
+);
+
+echo "== #333 follow-up: cancelled loans appear in the user-facing history ==\n";
+$check(
+ substr_count($userActions, "'restituito','perso','danneggiato','annullato','scaduto'") === 1,
+ 'profile history query includes cancelled/expired loans'
+);
+$check(
+ substr_count($userDashboardCtrl, "'restituito','perso','danneggiato','annullato','scaduto'") === 2,
+ 'user dashboard history query AND its counter include cancelled/expired loans'
+);
+$check(
+ str_contains($userActions, 'COALESCE(pr.data_restituzione, pr.updated_at)')
+ && str_contains($userDashboardCtrl, 'COALESCE(pr.data_restituzione, pr.updated_at)'),
+ 'history sorts cancelled loans by closing time instead of sinking NULL return dates'
+);
+$check(
+ str_contains($profileReservations, "'annullato' => 'fa-ban'")
+ && str_contains($userDashboard, "'annullato' => 'fa-ban'"),
+ 'both user history views give cancelled loans a dedicated icon'
+);
+// Strutturale (CodeRabbit): asserisci l'ESPRESSIONE di esclusione, non la sola
+// presenza della variabile — le due viste iterano con nomi diversi ($p / $loan).
+$check(
+ str_contains($profileReservations, "\$canReview = !in_array(\$p['stato'], ['annullato', 'scaduto'], true)")
+ && str_contains($userDashboard, "\$canReview = !in_array(\$loan['stato'], ['annullato', 'scaduto'], true)"),
+ 'history hides the review button for loans that never went out (annullato/scaduto)'
+);
+
+echo "== #333 sweep: every stato-label consumer routes through the canonical helpers ==\n";
+$statsView = $src('app/Views/admin/stats.php');
+$dashboardView = $src('app/Views/dashboard/index.php');
+$icsGenerator = $src('app/Support/IcsGenerator.php');
+$check(
+ str_contains($helpers, 'function loan_status_label_map()')
+ && str_contains($helpers, "translate_loan_status(\$stato)"),
+ 'helpers.php exposes the enum-wide label map built on translate_loan_status()'
+);
+$check(
+ str_contains($statsView, 'loan_status_label_map()'),
+ 'stats chart labels come from the canonical label map'
+);
+$check(
+ str_contains($dashboardView, 'loan_status_label_map()'),
+ 'dashboard calendar labels come from the canonical label map'
+);
+$check(
+ str_contains($icsGenerator, 'translate_loan_status($status)'),
+ 'ICS feed status labels delegate to translate_loan_status()'
+);
+$check(
+ str_contains($bookPage, 'translate_loan_status((string) $stato)'),
+ 'book page occupancy calendar labels delegate to translate_loan_status()'
+);
+$check(
+ str_contains($profileReservations, 'translate_loan_status(')
+ && str_contains($userDashboard, 'translate_loan_status('),
+ 'user history views delegate labels to translate_loan_status()'
+);
+// No hand-maintained stato→label literals left outside the two helpers: the
+// old maps always spelled a quoted state key next to a __() label call.
+foreach ([
+ 'admin stats view' => $statsView,
+ 'admin dashboard view' => $dashboardView,
+] as $surface => $source) {
+ $check(
+ !preg_match('/[\'"]in_corso[\'"]\s*(=>|:)\s*(<\?=\s*)?(json_encode\()?__\(/', $source),
+ "{$surface} keeps no local stato→label literal map"
+ );
+}
+
+echo "== #333 API: mobile /me/loans matches the web history and labels via the helper ==\n";
+$mobileActions = $src('storage/plugins/mobile-api/src/Controllers/ActionsController.php');
+$mobileOpenApi = $src('storage/plugins/mobile-api/src/Controllers/OpenApiController.php');
+$mobilePlugin = json_decode($src('storage/plugins/mobile-api/plugin.json'), true);
+$check(
+ str_contains($mobileActions, "'restituito','perso','danneggiato','annullato','scaduto'")
+ && str_contains($mobileActions, 'COALESCE(pr.data_restituzione, pr.updated_at)'),
+ 'mobile loans history includes cancelled/expired loans with closing-time order'
+);
+$check(
+ str_contains($mobileActions, "'status_label' => translate_loan_status(\$status)"),
+ 'mobile loan payload carries a server-localized status_label from the canonical helper'
+);
+$check(
+ str_contains($mobileOpenApi, "'status_label'") && str_contains($mobileOpenApi, "'requested_at'"),
+ 'OpenAPI schema documents the additive status_label and requested_at fields'
+);
+// Strutturale (CodeRabbit): estrai la RIGA dello schema e verifica insieme la
+// forma 3.1 e l'ASSENZA del flag legacy — "type array + nullable=true" non passa.
+$requestedAtSchemaLine = '';
+foreach (explode("\n", $mobileOpenApi) as $openApiLine) {
+ if (str_contains($openApiLine, "'requested_at'")) {
+ $requestedAtSchemaLine = $openApiLine;
+ break;
+ }
+}
+$check(
+ str_contains($requestedAtSchemaLine, "'type' => ['string', 'null']")
+ && !str_contains($requestedAtSchemaLine, "'nullable'"),
+ 'requested_at declares nullability the OpenAPI 3.1 way (type array, no legacy nullable flag)'
+);
+$check(
+ str_contains($mobileActions, "'requested_at'")
+ && substr_count($mobileActions, 'pr.created_at') >= 3,
+ 'every /me/loans payload carries requested_at, selected in all three queries'
+);
+$check(
+ is_array($mobilePlugin) && version_compare((string) ($mobilePlugin['version'] ?? '0'), '1.4.3', '>='),
+ 'mobile-api plugin version bumped for the additive API change'
+);
+
+echo "== #334: page header no longer covers the notifications dropdown ==\n";
+// Match class attributes only — the explanatory comments in those views cite
+// the removed utility string verbatim.
+$check(
+ !preg_match('/class="[^"]*sticky top-0 z-30/', $pendingLoans),
+ 'loans overview page header is not sticky at the layout header z-index'
+);
+$check(
+ !preg_match('/class="[^"]*sticky top-0 z-30/', $integrityReport),
+ 'integrity report page header is not sticky at the layout header z-index'
+);
+
+echo "== #336: date edits check only newly-claimed days; clear error messages ==\n";
+$updateStart = strpos($controller, 'public function update(');
+$closeStart = strpos($controller, 'public function close(');
+$updateSource = ($updateStart !== false && $closeStart !== false && $closeStart > $updateStart)
+ ? substr($controller, $updateStart, $closeStart - $updateStart)
+ : '';
+// Guardia: il check negativo qui sotto passerebbe a vuoto su una sezione
+// vuota — l'estrazione deve aver realmente trovato il corpo di update().
+$check($updateSource !== '', 'update() source section extracted (guards the negative checks below)');
+$check(
+ str_contains($updateSource, '$claimedWindows')
+ && str_contains($updateSource, 'excludePrestitoId: $id'),
+ 'update() checks capacity on the newly-claimed windows through CapacityService'
+);
+$check(
+ str_contains($updateSource, '$copyOverlap->bind_param(\'iiss\', $copyId, $id, $claimEnd, $claimStart)')
+ && str_contains($updateSource, '?error=loan_copy_conflict'),
+ 'update() checks the assigned copy on the same newly-claimed windows with a dedicated error'
+);
+// Strutturale (CodeRabbit): i boundary helper devono ALIMENTARE il calcolo
+// delle finestre, non solo comparire nel testo della funzione.
+$check(
+ str_contains($updateSource, '$claimedWindows[] = [$newPrestito, min($dayBefore($oldPrestito), $newScadenza)]')
+ && str_contains($updateSource, '$claimedWindows[] = [max($dayAfter($oldScadenza), $newPrestito), $newScadenza]'),
+ 'claimed windows are the exact set difference (boundary helpers feed the window bounds)'
+);
+$check(
+ substr_count($updateSource, 'isStrictIsoDate(') >= 4
+ && !str_contains($updateSource, 'strtotime('),
+ 'update() validates dates strictly (exact Y-m-d, real calendar) with no strtotime'
+);
+$check(
+ !str_contains($updateSource, 'hasFreeCapacity($libroId, $newPrestito, $newScadenza'),
+ 'update() no longer re-checks the whole loan window (which bounced every edit)'
+);
+$bulkStart = strpos($controller, 'private function applyBulkLoanExtension(');
+$renewStart = strpos($controller, 'public function renew(');
+$bulkSource = ($bulkStart !== false && $renewStart !== false && $renewStart > $bulkStart)
+ ? substr($controller, $bulkStart, $renewStart - $bulkStart)
+ : '';
+$check($bulkSource !== '', 'applyBulkLoanExtension() source section extracted');
+$check(
+ str_contains($bulkSource, 'hasFreeCapacity($bookId, $extensionStart, $newDueDate')
+ && str_contains($bulkSource, '$copyOverlap->bind_param(\'iiss\', $copyId, $loanId, $newDueDate, $extensionStart)'),
+ 'bulk extension gates capacity AND copy overlap on the same added-days interval'
+);
+$check(
+ str_contains($loansIndex, "case 'no_copies_available':")
+ && str_contains($loansIndex, "case 'extension_conflicts':")
+ && str_contains($loansIndex, "case 'loan_copy_conflict':"),
+ 'loans list banner explains capacity conflicts instead of a generic error'
+);
+$check(
+ str_contains($bookPage, "case 'extension_conflicts':")
+ && str_contains($bookPage, "case 'renewal_failed':"),
+ "book page banner recognizes renew()'s actual error keys"
+);
+
+// The new user-facing strings must be translated in every bundled locale.
+$newStrings = [
+ 'Modifica non salvata: nel nuovo periodo tutte le copie sono già impegnate da altri prestiti o prenotazioni.',
+ 'Modifica non salvata: la copia assegnata è già impegnata da un altro prestito nel nuovo periodo.',
+ 'Rinnovo non riuscito. Riprova.',
+];
+foreach (['it_IT', 'en_US', 'de_DE', 'fr_FR', 'da_DK'] as $locale) {
+ $bundle = json_decode($src('locale/' . $locale . '.json'), true);
+ $ok = is_array($bundle);
+ foreach ($newStrings as $key) {
+ $ok = $ok && isset($bundle[$key]) && $bundle[$key] !== '';
+ }
+ $check($ok, "locale {$locale} translates the new loan error strings");
+}
+
+echo PHP_EOL . "Passed: {$passed}, Failed: {$failed}" . PHP_EOL;
+exit($failed === 0 ? 0 : 1);
diff --git a/tests/loan-bulk-extension-capacity.unit.php b/tests/loan-bulk-extension-capacity.unit.php
index b7d3aa9b5..50a0a5aa5 100644
--- a/tests/loan-bulk-extension-capacity.unit.php
+++ b/tests/loan-bulk-extension-capacity.unit.php
@@ -2,9 +2,10 @@
declare(strict_types=1);
/**
- * End-to-end database contract for #281 bulk extension. It invokes the real
- * controller and proves book-capacity conflicts, physical-copy conflicts and
- * all-or-nothing rollback behavior.
+ * End-to-end database contract for #281 bulk extension and #336 manual date
+ * editing. It invokes the real controller and proves book-capacity conflicts,
+ * physical-copy conflicts, newly-claimed-window semantics and all-or-nothing
+ * rollback behavior.
*
* Run: php tests/loan-bulk-extension-capacity.unit.php
*/
@@ -156,6 +157,21 @@
return (new PrestitiController())->bulkExtend($request, $response, $db);
};
+$callUpdate = static function (int $loanId, int $userId, string $start, string $due) use ($db) {
+ // The controller authorizes from the session, while processed_by must also
+ // reference a real user because of the FK. Reuse the fixture borrower.
+ $_SESSION['user'] = ['tipo_utente' => 'admin', 'id' => $userId];
+ $request = (new ServerRequestFactory())
+ ->createServerRequest('POST', '/admin/loans/edit/' . $loanId)
+ ->withParsedBody([
+ 'utente_id' => $userId,
+ 'data_prestito' => $start,
+ 'data_scadenza' => $due,
+ ]);
+ $response = (new ResponseFactory())->createResponse();
+ return (new PrestitiController())->update($request, $response, $db, $loanId);
+};
+
$today = new DateTimeImmutable(DateHelper::today());
$start = $today->modify('-5 days')->format('Y-m-d');
$due = $today->modify('+2 days')->format('Y-m-d');
@@ -189,15 +205,38 @@
echo "B. Physical-copy schedule remains exclusive\n";
[$copyBook, [$scheduledCopy, $freeCopy]] = $makeBook(2);
-$currentLoan = $makeLoan($copyBook, $scheduledCopy, $makeUser(), $start, $due);
+$currentUser = $makeUser();
+$currentLoan = $makeLoan($copyBook, $scheduledCopy, $currentUser, $start, $due);
$futureStart = $today->modify('+8 days')->format('Y-m-d');
$futureEnd = $today->modify('+12 days')->format('Y-m-d');
$futureLoan = $makeLoan($copyBook, $scheduledCopy, $makeUser(), $futureStart, $futureEnd, 'prenotato');
$check($futureLoan > 0 && $freeCopy > 0, 'fixture has spare book capacity but a busy assigned copy');
+$updateResponse = $callUpdate($currentLoan, $currentUser, $start, $today->modify('+10 days')->format('Y-m-d'));
+$check(str_contains($updateResponse->getHeaderLine('Location'), 'error=loan_copy_conflict'), 'manual date edit reports the dedicated same-copy conflict');
+$check($dueDate($currentLoan) === $due, 'manual copy conflict leaves the original due date unchanged');
$response = $callBulk([$currentLoan], 10);
$check(str_contains($response->getHeaderLine('Location'), 'error=bulk_extend_conflict'), 'same-copy future schedule blocks the extension despite spare book capacity');
$check($dueDate($currentLoan) === $due, 'copy conflict leaves the original due date unchanged');
+echo "C. Manual edit checks only newly claimed days (#336)\n";
+[$reservationBook, [$reservationCopy]] = $makeBook(1);
+$reservationHolder = $makeUser();
+$reservationLoan = $makeLoan($reservationBook, $reservationCopy, $reservationHolder, $start, $due);
+$queuedUser = $makeUser();
+$oldOverlapStart = $today->modify('-1 day')->format('Y-m-d');
+$oldOverlapEnd = $today->modify('+1 day')->format('Y-m-d');
+$stmt = $db->prepare(
+ "INSERT INTO prenotazioni (libro_id, utente_id, data_inizio_richiesta, data_fine_richiesta, data_scadenza_prenotazione, stato, queue_position)
+ VALUES (?, ?, ?, ?, ?, 'attiva', 1)"
+);
+$stmt->bind_param('iisss', $reservationBook, $queuedUser, $oldOverlapStart, $oldOverlapEnd, $oldOverlapEnd);
+$stmt->execute();
+$stmt->close();
+$editedDue = $today->modify('+4 days')->format('Y-m-d');
+$updateResponse = $callUpdate($reservationLoan, $reservationHolder, $start, $editedDue);
+$check(!str_contains($updateResponse->getHeaderLine('Location'), 'error='), 'existing reservation on the old window does not block a free added-days segment');
+$check($dueDate($reservationLoan) === $editedDue, 'manual edit persists the conflict-free extension');
+
// ── Area 4: overdue loans clear "Overdue" when extended (issue #281 gap) ─────
echo "D. Extending an overdue loan clears the overdue status\n";
$loanState = static function (int $loanId) use ($db): string {
diff --git a/tests/loan-reservation-consistency.unit.php b/tests/loan-reservation-consistency.unit.php
index 0a276484f..490ddec78 100644
--- a/tests/loan-reservation-consistency.unit.php
+++ b/tests/loan-reservation-consistency.unit.php
@@ -132,7 +132,11 @@ function assertNotContainsText(string $needle, string $haystack, string $message
] as $historyPath) {
$history = readFileOrFail($root . '/' . $historyPath);
assertNotContainsText("stato != 'prestato'", $history, "{$historyPath} must not show pending/cancelled rows as loan history");
- assertContainsText("stato IN ('restituito','perso','danneggiato')", $history, "{$historyPath} must use terminal physical-loan outcomes for history");
+ // Since PR #337 the history predicate includes the CLOSED no-return states
+ // too (annullato = user-cancelled, scaduto = pickup expired): they are
+ // terminal outcomes and must not vanish from the user's history (#333).
+ // The three consumers must keep sharing this exact predicate.
+ assertContainsText("stato IN ('restituito','perso','danneggiato','annullato','scaduto')", $history, "{$historyPath} must use terminal loan outcomes (incl. cancelled/expired) for history");
}
echo "Loan/reservation consistency unit checks passed.\n";