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 @@ ?>
- -
+ +
diff --git a/app/Views/admin/pending_loans.php b/app/Views/admin/pending_loans.php index 92b0b1bdb..a15e8dade 100644 --- a/app/Views/admin/pending_loans.php +++ b/app/Views/admin/pending_loans.php @@ -1,7 +1,9 @@
- -
+ +
@@ -149,7 +151,7 @@ class="w-16 h-22 object-cover rounded-lg shadow-sm"

- +

@@ -352,7 +354,7 @@ class="w-16 h-22 object-cover rounded-lg shadow-sm" - +

diff --git a/app/Views/admin/stats.php b/app/Views/admin/stats.php index 7d6f54f7f..cfc1c8f68 100644 --- a/app/Views/admin/stats.php +++ b/app/Views/admin/stats.php @@ -348,13 +348,9 @@ class="w-10 h-14 object-cover rounded shadow-sm" // Loans By Status Chart const loansByStatusData = ; const statusLabels = loansByStatusData.map(item => { - const labels = { - 'in_corso': , - 'pendente': , - 'in_ritardo': , - 'perso': , - 'danneggiato': - }; + // Etichette canoniche degli stati prestito (loan_status_label_map, #333): + // niente mappa locale da tenere allineata all'enum. + const labels = ; return labels[item.stato] || item.stato; }); const statusValues = loansByStatusData.map(item => parseInt(item.totale)); diff --git a/app/Views/dashboard/index.php b/app/Views/dashboard/index.php index d57a2795c..a80c05b05 100644 --- a/app/Views/dashboard/index.php +++ b/app/Views/dashboard/index.php @@ -945,14 +945,14 @@ function escapeHtml(str) { eventClick: function(info) { const props = info.event.extendedProps; const typeLabel = props.type === 'prenotazione' ? : ; - const statusLabels = { - 'in_corso': , - 'prenotato': , - 'da_ritirare': , - 'in_ritardo': , - 'pendente': , - 'attiva': - }; + // Etichette canoniche degli stati prestito (loan_status_label_map, + // #333) + lo stato 'attiva' delle prenotazioni, che non fa parte + // dell'enum prestiti. Il vecchio alias "Scaduto" per in_ritardo è + // sparito: ora collide con lo stato 'scaduto' vero e proprio. + const statusLabels = __('Attiva')]), + JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT + ) ?>; const statusLabel = statusLabels[props.status] || props.status; // Use originalStart/originalEnd with fallback to event dates diff --git a/app/Views/libri/scheda_libro.php b/app/Views/libri/scheda_libro.php index e535ddcf5..421014f15 100644 --- a/app/Views/libri/scheda_libro.php +++ b/app/Views/libri/scheda_libro.php @@ -1,6 +1,9 @@ __('In prestito'), 'in_ritardo' => __('In ritardo'), @@ -1255,57 +1266,8 @@ class="text-red-600 hover:text-red-900 transition-colors" - - - - - + + 0): ?> @@ -1451,15 +1413,9 @@ class="text-red-600 hover:text-red-900 transition-colors" default => $copyColor }; - // Status label - $statusLabel = match($stato) { - 'in_corso' => __('In prestito'), - 'prenotato' => __('Prenotato'), - 'in_ritardo' => __('In ritardo'), - 'pendente' => __('In attesa'), - 'da_ritirare' => __('Da Ritirare'), - default => ucfirst($stato) - }; + // Etichetta canonica dello stato (translate_loan_status, #333): stessa + // dicitura dei badge nella tabella prestiti di questa stessa pagina. + $statusLabel = translate_loan_status((string) $stato); // FullCalendar expects end date to be exclusive, so add 1 day $endDateObj = new DateTime($endDate); diff --git a/app/Views/partials/loan-status-badge.php b/app/Views/partials/loan-status-badge.php new file mode 100644 index 000000000..5b37fdaec --- /dev/null +++ b/app/Views/partials/loan-status-badge.php @@ -0,0 +1,63 @@ + stato => badge HTML pronto da stampare + */ + function loan_status_badge_map(): array + { + $base = 'inline-flex items-center px-3 py-1 rounded-full text-xs font-medium'; + // stato => [classi colore Tailwind, icona FontAwesome] + $defs = [ + 'pendente' => ['bg-orange-100 text-orange-800', 'fa-hourglass-half'], + 'prenotato' => ['bg-purple-100 text-purple-800', 'fa-calendar-check'], + 'da_ritirare' => ['bg-amber-100 text-amber-800', 'fa-box'], + 'in_corso' => ['bg-blue-100 text-blue-800', 'fa-clock'], + 'in_ritardo' => ['bg-yellow-100 text-yellow-800', 'fa-exclamation-triangle'], + 'restituito' => ['bg-green-100 text-green-800', 'fa-check-circle'], + 'perso' => ['bg-red-100 text-red-800', 'fa-times-circle'], + 'danneggiato' => ['bg-red-100 text-red-800', 'fa-times-circle'], + 'scaduto' => ['bg-gray-200 text-gray-700', 'fa-calendar-times'], + 'annullato' => ['bg-gray-200 text-gray-700', 'fa-ban'], + ]; + $map = []; + foreach ($defs as $stato => [$colors, $icon]) { + $map[$stato] = '' + . htmlspecialchars(translate_loan_status($stato), ENT_QUOTES, 'UTF-8') . ''; + } + return $map; + } +} + +if (!function_exists('loan_status_badge')) { + /** + * Badge HTML per un singolo stato; fallback "Sconosciuto" per valori + * fuori enum (non dovrebbe più accadere: la mappa copre tutto l'enum). + */ + function loan_status_badge(?string $stato): string + { + if ($stato !== null && $stato !== '') { + $map = loan_status_badge_map(); + if (isset($map[$stato])) { + return $map[$stato]; + } + } + return '' + . htmlspecialchars(__('Sconosciuto'), ENT_QUOTES, 'UTF-8') . ''; + } +} diff --git a/app/Views/prestiti/dettagli_prestito.php b/app/Views/prestiti/dettagli_prestito.php index 71dda48bd..b15ccba21 100644 --- a/app/Views/prestiti/dettagli_prestito.php +++ b/app/Views/prestiti/dettagli_prestito.php @@ -1,20 +1,9 @@ __('In Attesa di Approvazione'), - 'prenotato' => __('Prenotato'), - 'da_ritirare' => __('Da Ritirare'), - 'in_corso' => __('In Corso'), - 'in_ritardo' => __('In Ritardo'), - 'restituito' => __('Restituito'), - 'perso' => __('Perso'), - 'danneggiato' => __('Danneggiato'), - default => __('Sconosciuto'), - }; -} +// Badge canonico degli stati prestito (#333): colore/icona/etichetta arrivano +// dal partial condiviso, niente mappa locale da tenere allineata. +require_once __DIR__ . '/../partials/loan-status-badge.php'; ?>
@@ -91,7 +80,18 @@ function formatLoanStatus($status) {
- +
@@ -101,18 +101,7 @@ function formatLoanStatus($status) {
- +
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 +
@@ -371,7 +377,7 @@ class="inline-flex items-center gap-2 whitespace-nowrap px-4 py-2 bg-gray-100 te - + ; + // Mappa canonica stato → badge HTML, generata dal partial loan-status-badge.php + // così la colonna DataTables e il rendering SSR non possono divergere (#333). + const loanStatusBadges = ; + const loanStatusUnknownBadge = ; // Initialize DataTable const table = new DataTable('#prestiti-table', { @@ -532,29 +542,9 @@ className: 'text-center align-middle', data: 'stato', className: 'text-center', render: function(data, type, row) { - const baseClasses = 'inline-flex items-center px-3 py-1 rounded-full text-xs font-medium'; - switch (data) { - case 'pendente': - return ``; - case 'prenotato': - return ``; - case 'da_ritirare': - return ``; - case 'in_corso': - return ``; - case 'in_ritardo': - return ``; - case 'restituito': - return ``; - case 'perso': - return ``; - case 'danneggiato': - return ``; - case 'scaduto': - return ``; - default: - return ``; - } + // Badge dalla mappa canonica PHP (loan-status-badge.php): + // stessa fonte del rendering SSR, nessuna mappa duplicata in JS. + return loanStatusBadges[data] || loanStatusUnknownBadge; } }, { @@ -892,6 +882,10 @@ function applyChecked() { ${__('Scaduto')} +
`, showCancelButton: true, diff --git a/app/Views/profile/reservations.php b/app/Views/profile/reservations.php index 15020ef1e..2dafbeb1e 100644 --- a/app/Views/profile/reservations.php +++ b/app/Views/profile/reservations.php @@ -562,16 +562,18 @@ __('Restituito'), - 'in_ritardo' => __('Restituito in ritardo'), - 'perso' => __('Perso'), - 'danneggiato' => __('Danneggiato'), - 'prestato' => __('Prestato'), - 'in_corso' => __('In corso') - ]; - $statusLabel = $statusLabels[$p['stato']] ?? ucfirst(str_replace('_', ' ', $p['stato'])); + // Etichetta canonica dello stato (translate_loan_status, #333): lo storico + // contiene solo stati chiusi (restituito/perso/danneggiato/annullato/scaduto). + $statusLabel = translate_loan_status((string) $p['stato']); + $statusIcon = match ($p['stato']) { + 'annullato' => 'fa-ban', + 'scaduto' => 'fa-calendar-times', + default => 'fa-check-circle', + }; $hasReview = !empty($p['has_review']); + // Un prestito annullato/scaduto non è mai uscito: nessuna recensione + // proponibile (il server la rifiuterebbe: richiede restituito/in corso). + $canReview = !in_array($p['stato'], ['annullato', 'scaduto'], true); ?>
@@ -584,7 +586,7 @@

- +
@@ -599,7 +601,7 @@ - + +
diff --git a/app/Views/utenti/dettagli_utente.php b/app/Views/utenti/dettagli_utente.php index 2efec0c1f..fc3dbf110 100644 --- a/app/Views/utenti/dettagli_utente.php +++ b/app/Views/utenti/dettagli_utente.php @@ -1,28 +1,9 @@ " . __("In attesa") . ""; - case 'prenotato': - return "" . __("Prenotato") . ""; - case 'in_corso': - return "" . __("In Corso") . ""; - case 'in_ritardo': - return "" . __("In Ritardo") . ""; - case 'restituito': - return "" . __("Restituito") . ""; - case 'perso': - return "" . __("Perso") . ""; - case 'danneggiato': - return "" . __("Danneggiato") . ""; - default: - return "" . __("Sconosciuto") . ""; - } -} +// Badge canonico degli stati prestito (#333): colore/icona/etichetta arrivano +// dal partial condiviso, niente mappa locale da tenere allineata. +require_once __DIR__ . '/../partials/loan-status-badge.php'; $id = (int)($utente['id'] ?? 0); $name = trim(($utente['nome'] ?? '') . ' ' . ($utente['cognome'] ?? '')); @@ -309,7 +290,7 @@ function getLoanStatusBadge($status) {
- + 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";