diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c9509867..abdaf861c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,14 @@ Full version-by-version history for Pinakes. The README shows only the last thre - **"Tessera in scadenza" email** (`card_expiring`, five languages). Sent `loans.card_expiry_warning_days` before the card expires (Settings → Loans, default 0 = off) by `cron/automatic-notifications.php` and `cron/full-maintenance.php` (both report the count), once per expiry date (`utenti.card_expiry_warning_sent_for`); a renewed card re-arms it. Fresh installations get the template seeded in the five `data_*.sql` files (row 29) like every other template; upgrades use the shipped text until the template is saved. - **Administrators only.** Patron types and the rules matrix (Settings → Circulation, the type pages and every form behind them) are reserved to administrators and to custom roles with the settings permission; a staff account is sent back to the dashboard and nothing changes. For staff the Circulation section is absent from the settings navigation and `/admin/settings?tab=circulation` opens the default section without the circulation panel. +### Closures calendar +- **The library's closed days.** A new admin page, Admin → Calendario chiusure (`/admin/calendar`, also linked from Settings → Loans), holds the weekly closed days (`loans.closed_weekdays`) and the closures: single days or periods, once or every year, for every branch or for one. The Italian national holidays come with one click (fixed holidays as yearly closures, Easter and Easter Monday computed for the chosen year), any other calendar can be imported from an ICS file, and a monthly preview shows which days are open. +- **Due dates land on an open day.** The shift lives in `CirculationPolicy::dueDateFor()`, so the per-patron-type durations and renewal days of the circulation policy land on an open day too. A due date that falls on a closed day moves to the next open day: admin loans (proposed and saved), loan edits, reader requests from the website and the app, reservations and their promotion to a loan, renewals and bulk extensions, NCIP CheckOut, RequestItem and RenewItem. A closure of one branch counts for the loans of its copies: when the staff desk lends a copy, an NCIP CheckOutItem binds one, or a request is approved onto one, the due date moves to that branch's next open day (at approval only if the longer window still fits; the NCIP checkout is refused, like the desk's, when the longer window no longer fits). A refused renewal proposes the last open day before the conflict. +- **Pickup days are open days.** The pickup deadline counts only the days the library is open, and the nightly sweep expires no pickup, no scheduled loan and no queue reservation on a closed day; a deadline that falls on a day closed afterwards moves to the next open day when the calendar is saved, so the desk can still hand the book over that day, but never past the loan's due date (a pickup after the loan window is refused), which the calendar save does not move. +- **The request calendars know the closed days.** On the book page and in the admin loan form a closed day cannot be picked as the start (a request that reaches the server with one starts on the next open day); the availability payload carries `closed_dates`, on the website and in the Mobile API `GET /catalog/books/{id}/availability` (additive field). A request auto-approved on a closed day is reported by `POST /reservations` with its real state, `prenotato` (new enum value) with the start day in `start_date` (additive), instead of `da_ritirare`. +- **ICS imports meet the form's limits.** Every event is checked like a closure typed in the form (a yearly closure within a year, a one-off within ten years, end not before start); the import message counts the events discarded. A yearly closure of a year or more, however stored, is applied once as the interval it names and never closes every day; a one-off closure of any admissible length (up to ten years) is stepped over whole, so a date inside it lands on the day after it ends; an ICS date-time in UTC is read in the application timezone. The calendar page follows the settings permission (`settings.manage`), so a custom role with that permission can use it. +- With no closed weekday and no closure configured every date is computed exactly as before. + ### Migration - `migrate_0.9.0-rc.1.sql`, section "MARC bulk import / copy cataloguing": `import_logs.import_type` gains the value `marc`, appended to the enum computed from the current one, so the order of the sections does not matter. Idempotent; existing rows are untouched. @@ -55,6 +63,7 @@ Full version-by-version history for Pinakes. The README shows only the last thre - `migrate_0.9.0-rc.1.sql`, section "minor features": creates `presenze` (visitor log) and `fornitori` (suppliers), adds `libri.fornitore_id` (ON DELETE SET NULL) and the nullable `libri.rda_content_type`, `rda_media_type`, `rda_carrier_type` overrides. Additive and idempotent. - `migrate_0.9.0-rc.1.sql`, section "patron-cards-import": `import_logs.import_type` gains the value `users`; the new enum is computed from the current one, so `marc` and any value added by another section are kept. - `migrate_0.9.0-rc.1.sql`, section "Circulation policy": creates `tipologie_lettore` and `regole_prestito`, seeds the default "Standard" type with every rule NULL, adds `utenti.tipologia_id` (FK `ON DELETE SET NULL`), `utenti.card_expiry_warning_sent_for` and `copie.solo_consultazione`. Idempotent; no existing row changes. +- `migrate_0.9.0-rc.1.sql`, section "closures calendar": creates `calendario_chiusure` (closure intervals, optional yearly recurrence and branch, FK to `sedi` ON DELETE CASCADE); the weekly closed days are the setting `loans.closed_weekdays`, no schema. Additive and idempotent. ## [0.8.9] diff --git a/README.md b/README.md index 7c127a7d7..7f4f22042 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Pinakes is a self-hosted, full-featured ILS for schools, municipalities, and pri - **Visitor log, catalogue cards, suppliers and RDA types**: a kiosk visitor log with GDPR retention, ISBD catalogue cards as PDF, a suppliers register linked to books, RDA content/media/carrier overrides, and new statistics sections. - **Library cards and user import**: printable patron cards (CR80 or A4 sheets, Code128 or QR), patron photo from the webcam, and user import from CSV with column mapping. - **Patron types and circulation rules**: one circulation policy for every channel (desk, website, app, NCIP); loan limit, duration, renewals, request window, reservations and card duration per patron type, a patron type × material rules matrix, consultation-only copies, card renewal and a card-expiry email. -- **Closures calendar**: weekly closed days and closures; due dates and pickup deadlines move to the next open day. +- **Closures calendar** (`/admin/calendar`): weekly closed days, closures per branch or every year, the Italian holidays preset and ICS import; due dates (with the patron type's durations and renewals) move to the next open day, pickup deadlines count open days, and the request calendars refuse a closed start day. - **Fines and patron ledger**: optional late fines with grace days and a cap, an append-only ledger with payments, waivers and receipts, and an optional debt block. --- diff --git a/app/Controllers/Admin/LibraryCalendarController.php b/app/Controllers/Admin/LibraryCalendarController.php new file mode 100644 index 000000000..cf887b6d0 --- /dev/null +++ b/app/Controllers/Admin/LibraryCalendarController.php @@ -0,0 +1,677 @@ +guard($response, $db)) { + return $guard; + } + $query = $request->getQueryParams(); + $today = DateHelper::today(); + + $month = is_string($query['month'] ?? null) && preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $query['month']) === 1 + ? $query['month'] + : substr($today, 0, 7); + $branches = $this->branches($db); + $previewSede = isset($query['sede']) && is_numeric($query['sede']) && isset($branches[(int) $query['sede']]) ? (int) $query['sede'] : null; + + $calendar = LibraryCalendar::fromDatabase($db); + $closedWeekdays = $calendar->closedWeekdays(); + $closures = $this->closures($db); + + $editId = isset($query['edit']) && is_numeric($query['edit']) ? (int) $query['edit'] : 0; + $editing = null; + foreach ($closures as $row) { + if ((int) $row['id'] === $editId) { + $editing = $row; + } + } + + $monthStart = $month . '-01'; + $monthEnd = (new \DateTimeImmutable($monthStart, new \DateTimeZone('UTC')))->modify('last day of this month')->format('Y-m-d'); + $monthClosed = array_flip($calendar->closedDatesBetween($monthStart, $monthEnd, $previewSede)); + $prevMonth = (new \DateTimeImmutable($monthStart, new \DateTimeZone('UTC')))->modify('-1 month')->format('Y-m'); + $nextMonth = (new \DateTimeImmutable($monthStart, new \DateTimeZone('UTC')))->modify('+1 month')->format('Y-m'); + $currentYear = (int) substr($today, 0, 4); + + ob_start(); + require __DIR__ . '/../../Views/admin/calendar.php'; + $content = ob_get_clean(); + ob_start(); + require __DIR__ . '/../../Views/layout.php'; + $html = ob_get_clean(); + $response->getBody()->write($html); + return $response; + } + + public function saveWeekdays(Request $request, Response $response, \mysqli $db): Response + { + if ($guard = $this->guard($response, $db)) { + return $guard; + } + $data = (array) $request->getParsedBody(); + // No field (every box unticked) reopens the whole week; anything that + // is not a list of weekdays 0-6 is refused whole, never half-saved. + $raw = $data['closed_weekdays'] ?? []; + if (!is_array($raw)) { + $_SESSION['error_message'] = __('Giorno della settimana non valido.'); + return $this->back($response); + } + $days = []; + foreach ($raw as $value) { + if (!is_scalar($value) || !ctype_digit((string) $value) || (int) $value > 6) { + $_SESSION['error_message'] = __('Giorno della settimana non valido.'); + return $this->back($response); + } + $days[(int) $value] = (int) $value; + } + if (count($days) === 7) { + $_SESSION['error_message'] = __('Almeno un giorno della settimana deve restare aperto.'); + return $this->back($response); + } + ksort($days); + $error = $this->commitChange($db, function () use ($db, $days): ?string { + (new SettingsRepository($db))->set('loans', 'closed_weekdays', implode(',', $days)); + // The yearly closures may leave open only days that fall on the + // newly closed weekdays. + return $this->yearCoverageError($db, ['start' => '', 'end' => '', 'description' => '', 'yearly' => true, 'sede' => null], array_values($days)) !== null + ? __('Con questi giorni di chiusura settimanale e le chiusure annuali la biblioteca resterebbe chiusa tutto l\'anno: almeno un giorno deve restare aperto.') + : null; + }); + $_SESSION[$error === null ? 'success_message' : 'error_message'] = $error ?? __('Giorni di chiusura settimanale salvati.'); + return $this->back($response); + } + + public function store(Request $request, Response $response, \mysqli $db): Response + { + if ($guard = $this->guard($response, $db)) { + return $guard; + } + $row = $this->validatedClosure($db, (array) $request->getParsedBody()); + if (is_string($row)) { + $_SESSION['error_message'] = $row; + return $this->back($response); + } + $error = $this->commitChange($db, function () use ($db, $row): ?string { + $this->insertClosure($db, $row); + return $this->yearCoverageError($db, $row); + }); + if ($error !== null) { + $_SESSION['error_message'] = $error; + return $this->back($response); + } + $_SESSION['success_message'] = __('Chiusura aggiunta.'); + return $this->back($response, substr($row['start'], 0, 7)); + } + + public function update(Request $request, Response $response, \mysqli $db, int $id): Response + { + if ($guard = $this->guard($response, $db)) { + return $guard; + } + if (!$this->closureExists($db, $id)) { + $_SESSION['error_message'] = __('Chiusura non trovata.'); + return $this->back($response); + } + $row = $this->validatedClosure($db, (array) $request->getParsedBody()); + if (is_string($row)) { + $_SESSION['error_message'] = $row; + return $this->back($response, null, $id); + } + $error = $this->commitChange($db, function () use ($db, $row, $id): ?string { + // Again under the lock: a closure another operator deleted since + // the check above would make the UPDATE touch no row and still + // report "updated". + if (!$this->closureExists($db, $id)) { + return __('Chiusura non trovata.'); + } + $sede = $row['sede']; + $yearly = $row['yearly'] ? 1 : 0; + $stmt = $db->prepare('UPDATE calendario_chiusure SET data_inizio = ?, data_fine = ?, descrizione = ?, ricorrente_annuale = ?, sede_id = ? WHERE id = ?'); + $stmt->bind_param('sssiii', $row['start'], $row['end'], $row['description'], $yearly, $sede, $id); + $stmt->execute(); + $stmt->close(); + return $this->yearCoverageError($db, $row); + }); + if ($error !== null) { + $_SESSION['error_message'] = $error; + return $this->back($response, null, $id); + } + $_SESSION['success_message'] = __('Chiusura aggiornata.'); + return $this->back($response, substr($row['start'], 0, 7)); + } + + public function delete(Request $request, Response $response, \mysqli $db, int $id): Response + { + if ($guard = $this->guard($response, $db)) { + return $guard; + } + $deleted = false; + $error = $this->commitChange($db, static function () use ($db, $id, &$deleted): ?string { + $stmt = $db->prepare('DELETE FROM calendario_chiusure WHERE id = ?'); + $stmt->bind_param('i', $id); + $stmt->execute(); + $deleted = $stmt->affected_rows > 0; + $stmt->close(); + return null; + }); + if ($error !== null) { + $_SESSION['error_message'] = $error; + return $this->back($response); + } + $_SESSION[$deleted ? 'success_message' : 'error_message'] = $deleted ? __('Chiusura eliminata.') : __('Chiusura non trovata.'); + return $this->back($response); + } + + public function importPreset(Request $request, Response $response, \mysqli $db): Response + { + if ($guard = $this->guard($response, $db)) { + return $guard; + } + $data = (array) $request->getParsedBody(); + $currentYear = (int) substr(DateHelper::today(), 0, 4); + $year = is_scalar($data['year'] ?? null) ? (int) $data['year'] : $currentYear; + if ($year < $currentYear - 1 || $year > $currentYear + 10) { + $_SESSION['error_message'] = __('Anno non valido.'); + return $this->back($response); + } + $added = 0; + // Under the calendar lock: two operators importing the same year + // cannot both pass alreadyClosed() and insert the same holiday. + $error = $this->commitChange($db, function () use ($db, $year, &$added): ?string { + foreach (LibraryCalendar::italianHolidays($year) as $holiday) { + $row = ['start' => $holiday['start'], 'end' => $holiday['end'], 'description' => $holiday['description'], 'yearly' => $holiday['yearly'], 'sede' => null]; + if (!$this->alreadyClosed($db, $row)) { + $this->insertClosure($db, $row); + $added++; + } + } + // Like the form: the yearly holidays must not close the last + // open day the stored yearly closures left. + return $added > 0 ? $this->yearCoverageError($db, ['start' => '', 'end' => '', 'description' => '', 'yearly' => true, 'sede' => null]) : null; + }); + if ($error !== null) { + $_SESSION['error_message'] = $error; + return $this->back($response); + } + $_SESSION['success_message'] = __('Festività nazionali importate: %d nuove chiusure.', $added); + return $this->back($response); + } + + public function importIcs(Request $request, Response $response, \mysqli $db): Response + { + if ($guard = $this->guard($response, $db)) { + return $guard; + } + $files = $request->getUploadedFiles(); + $file = $files['ics_file'] ?? null; + if (!$file instanceof UploadedFileInterface || $file->getError() !== UPLOAD_ERR_OK) { + $_SESSION['error_message'] = __('Seleziona un file ICS da importare.'); + return $this->back($response); + } + if (($file->getSize() ?? 0) > self::MAX_ICS_BYTES) { + $_SESSION['error_message'] = __('Il file ICS supera la dimensione massima di 1 MB.'); + return $this->back($response); + } + try { + $ics = (string) $file->getStream()->getContents(); + // ICS is UTF-8 by the standard, but exports in Windows-1252 / + // ISO-8859-1 exist: their accented SUMMARY bytes would fail the + // utf8mb4 INSERT halfway through the import. + // Unfold first (RFC 5545 3.1): a folded line may split one UTF-8 + // character in two, which the encoding check would take for + // Windows-1252. + $ics = (string) preg_replace("/\r?\n[ \t]/", '', $ics); + if (!mb_check_encoding($ics, 'UTF-8')) { + $ics = (string) mb_convert_encoding($ics, 'UTF-8', 'Windows-1252'); + } + } catch (\Throwable $e) { + SecureLogger::warning('Calendar ICS import: unreadable upload: ' . $e->getMessage()); + $ics = ''; + } + if (!str_contains(strtoupper($ics), 'BEGIN:VCALENDAR')) { + $_SESSION['error_message'] = __('Il file non è un calendario ICS valido.'); + return $this->back($response); + } + $data = (array) $request->getParsedBody(); + $sede = $this->branchFromInput($db, $data['sede_id'] ?? null); + if ($sede === false) { + $_SESSION['error_message'] = __('Sede non trovata.'); + return $this->back($response); + } + $added = 0; + $skipped = 0; + $rejected = 0; + $coverageRejected = 0; + $events = LibraryCalendar::parseIcs($ics); + $error = $this->commitChange($db, function () use ($db, $events, $sede, &$added, &$skipped, &$rejected, &$coverageRejected): ?string { + // The whole-year guard's state, carried from event to event: the + // yearly rows are read once and each check only looks again at + // the years whose first open day the new event closes. + $coverage = null; + foreach ($events as $event) { + // The same limits as the form: a yearly event longer than a + // year would otherwise be stored as a closure of every day. + if ($this->spanError($event['start'], $event['end'], $event['yearly']) !== null) { + $rejected++; + continue; + } + $row = [ + 'start' => $event['start'], + 'end' => $event['end'], + 'description' => $event['description'] !== '' ? $event['description'] : __('Chiusura'), + 'yearly' => $event['yearly'], + 'sede' => $sede, + ]; + if ($this->alreadyClosed($db, $row)) { + $skipped++; + continue; + } + $newId = $this->insertClosure($db, $row); + // Like the form: yearly rows that together close every day + // of the year are refused, the event is not kept. + if ($this->yearCoverageError($db, $row, null, $coverage) !== null) { + $this->deleteClosure($db, $newId); + $coverageRejected++; + continue; + } + $added++; + } + return null; + }); + if ($error !== null) { + $_SESSION['error_message'] = $error; + return $this->back($response); + } + $message = $rejected > 0 + ? __('File ICS importato: %1$d chiusure aggiunte, %2$d già presenti, %3$d scartate perché più lunghe del limite (un anno se annuali, dieci anni altrimenti).', $added, $skipped, $rejected) + : __('File ICS importato: %1$d chiusure aggiunte, %2$d già presenti.', $added, $skipped); + // A different cause, a different fix: the span is fine, the yearly + // rows together would leave no open day. + if ($coverageRejected > 0) { + $message .= ' ' . __('%d scartate perché con le altre chiusure annuali lascerebbero la biblioteca chiusa tutto l\'anno.', $coverageRejected); + } + $_SESSION['success_message'] = $message; + return $this->back($response); + } + + // ── Helpers ───────────────────────────────────────────────────────────── + + /** + * Every write goes through here, under one named lock (two operators + * importing at once cannot both pass alreadyClosed()) and one + * transaction: the write, then the pending pickups whose deadline is now + * a closed day move to the next open day. A write that returns an error + * message, or any failure (the deadline move included), rolls the whole + * change back, so the expiry sweep never reads a calendar whose pickup + * deadlines were not moved. Null on success, otherwise the message. + * + * @param callable(): ?string $write + */ + private function commitChange(\mysqli $db, callable $write): ?string + { + $lockName = $this->lockName($db); + try { + $stmt = $db->prepare('SELECT GET_LOCK(?, 10)'); + $stmt->bind_param('s', $lockName); + $stmt->execute(); + $acquired = (int) ($stmt->get_result()->fetch_row()[0] ?? 0) === 1; + $stmt->close(); + } catch (\Throwable $e) { + SecureLogger::error('Calendar: lock not available: ' . $e->getMessage()); + return __('Errore di salvataggio. Riprova più tardi.'); + } + if (!$acquired) { + return __('Un\'altra modifica del calendario è in corso: riprova tra poco.'); + } + try { + $db->begin_transaction(); + try { + $error = $write(); + if ($error === null) { + // The deadline move reads the new calendar. + LibraryCalendar::reset(); + LibraryCalendar::extendPickupDeadlines($db); + $db->commit(); + return null; + } + $db->rollback(); + return $error; + } catch (\Throwable $e) { + $db->rollback(); + SecureLogger::error('Calendar: change not saved: ' . $e->getMessage()); + return __('Errore di salvataggio. Riprova più tardi.'); + } + } finally { + // Committed or rolled back, this request reads the stored state. + LibraryCalendar::reset(); + \App\Support\ConfigStore::clearCache(); + try { + $release = $db->prepare('SELECT RELEASE_LOCK(?)'); + $release->bind_param('s', $lockName); + $release->execute(); + $release->get_result(); + $release->close(); + } catch (\Throwable) { + // Released with the connection anyway. + } + } + } + + /** Schema-scoped and under GET_LOCK()'s 64 characters (see CopyRepository). */ + private function lockName(\mysqli $db): string + { + $dbName = ''; + try { + $res = $db->query('SELECT DATABASE()'); + if ($res instanceof \mysqli_result) { + $dbName = (string) ($res->fetch_row()[0] ?? ''); + $res->free(); + } + } catch (\Throwable) { + } + return 'pinakes-calendar:' . md5($dbName); + } + + /** + * Yearly closures repeat every year, so two rows each shorter than a year + * can still close every day together; nextOpenDay() would then run to its + * bound and a due date would land on a closed day. Checked after $row is + * written (inside the transaction), on the yearly rows that apply to its + * branch, or to the library and to every branch for a row without one, + * together with the weekly closed days ($closedWeekdays, the stored + * loans.closed_weekdays when null). + * Null when every calendar year keeps an open day: then two open days are + * never more than two years apart, well inside the scan bound. + * + * $state carries the check across the rows of one import. Null on the + * first call: the stored yearly rows are read once. On success it holds + * those rows, the weekly days and the first open day found per branch and + * year; the next call adds its $row (already written, not yet in the + * state) and looks again only at the years whose first open day that row + * closes, from that day on: closures only grow during an import, so an + * earlier day cannot reopen. A failed check leaves $state as it was (the + * caller deletes the row). Without it every event re-read and rebuilt + * every yearly row and re-scanned every year from Jan 1, and a large ICS + * import outlasted the calendar lock other writers wait for. + * + * @param array{start: string, end: string, description: string, yearly: bool, sede: ?int} $row + * @param list|null $closedWeekdays + * @param array{rows: list>, weekdays: list, open: array}|null $state + */ + private function yearCoverageError(\mysqli $db, array $row, ?array $closedWeekdays = null, ?array &$state = null): ?string + { + if (!$row['yearly']) { + return null; + } + $added = null; + if ($state === null) { + $result = $db->query('SELECT data_inizio, data_fine, ricorrente_annuale, sede_id FROM calendario_chiusure WHERE ricorrente_annuale = 1'); + $next = [ + 'rows' => $result instanceof \mysqli_result ? $result->fetch_all(MYSQLI_ASSOC) : [], + 'weekdays' => $closedWeekdays ?? LibraryCalendar::parseWeekdays((new SettingsRepository($db))->get('loans', 'closed_weekdays', '')), + 'open' => [], + ]; + } else { + $next = $state; + $stored = ['data_inizio' => $row['start'], 'data_fine' => $row['end'], 'ricorrente_annuale' => 1, 'sede_id' => $row['sede']]; + $next['rows'][] = $stored; + // The new row alone: does it close a day found open before? + $added = new LibraryCalendar([], [$stored]); + } + $calendar = null; + $branches = [$row['sede']]; + if ($row['sede'] === null) { + foreach ($next['rows'] as $stored) { + if ($stored['sede_id'] !== null) { + $branches[] = (int) $stored['sede_id']; + } + } + } + foreach (array_unique($branches, SORT_REGULAR) as $branch) { + // 28 consecutive years hold every pairing of a weekday for Jan 1 + // with a common or leap year, so the weekly closed days meet each + // possible year. A common year with only Feb 29 open fails here. + for ($year = 2025; $year < 2053; $year++) { + $key = ($branch ?? 'library') . '|' . $year; + $day = $year . '-01-01'; + if (isset($next['open'][$key])) { + if ($added !== null && $added->isOpen($next['open'][$key], $branch)) { + continue; + } + $day = LibraryCalendar::shift($next['open'][$key], 1); + } + $calendar ??= new LibraryCalendar($next['weekdays'], $next['rows']); + $open = null; + while ($open === null && str_starts_with($day, (string) $year)) { + if ($calendar->isOpen($day, $branch)) { + $open = $day; + } + $day = LibraryCalendar::shift($day, 1); + } + if ($open === null) { + return __('Con questa chiusura annuale la biblioteca resterebbe chiusa tutto l\'anno: almeno un giorno deve restare aperto.'); + } + $next['open'][$key] = $open; + } + } + $state = $next; + return null; + } + + private function deleteClosure(\mysqli $db, int $id): void + { + $stmt = $db->prepare('DELETE FROM calendario_chiusure WHERE id = ?'); + $stmt->bind_param('i', $id); + $stmt->execute(); + $stmt->close(); + } + + private function guard(Response $response, \mysqli $db): ?Response + { + if (!\App\Support\Permissions::mayConfigure()) { + $_SESSION['error_message'] = __('Operazione riservata agli amministratori'); + return $response->withHeader('Location', url('/admin/dashboard'))->withStatus(302); + } + // Files updated before the 0.9.0 migration ran: the closures table is + // not there yet, and every page and action of the calendar reads it. + try { + $res = $db->query("SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'calendario_chiusure'"); + $ready = $res instanceof \mysqli_result && $res->num_rows > 0; + } catch (\Throwable) { + $ready = false; + } + if (!$ready) { + $_SESSION['error_message'] = __('Questa funzione sarà disponibile al termine dell\'aggiornamento del database.'); + return $response->withHeader('Location', url('/admin/dashboard'))->withStatus(302); + } + return null; + } + + private function back(Response $response, ?string $month = null, ?int $editId = null): Response + { + $params = []; + if ($month !== null) { + $params['month'] = $month; + } + if ($editId !== null) { + $params['edit'] = $editId; + } + $target = url('/admin/calendar') . ($params !== [] ? '?' . http_build_query($params) : ''); + return $response->withHeader('Location', $target)->withStatus(302); + } + + /** + * @param array $data + * @return array{start: string, end: string, description: string, yearly: bool, sede: ?int}|string + * the closure to store, or the error message + */ + private function validatedClosure(\mysqli $db, array $data): array|string + { + $start = is_string($data['data_inizio'] ?? null) ? trim($data['data_inizio']) : ''; + $end = is_string($data['data_fine'] ?? null) ? trim($data['data_fine']) : ''; + if ($end === '') { + $end = $start; + } + if (!DateHelper::isISODateFormat($start) || !DateHelper::isISODateFormat($end)) { + return __('Data non valida: usa il formato AAAA-MM-GG.'); + } + $yearly = !empty($data['ricorrente_annuale']); + if (($error = $this->spanError($start, $end, $yearly)) !== null) { + return $error; + } + $description = is_string($data['descrizione'] ?? null) ? trim($data['descrizione']) : ''; + $description = mb_substr(preg_replace('/\s+/u', ' ', $description) ?? '', 0, 255); + $sede = $this->branchFromInput($db, $data['sede_id'] ?? null); + if ($sede === false) { + return __('Sede non trovata.'); + } + return ['start' => $start, 'end' => $end, 'description' => $description, 'yearly' => $yearly, 'sede' => $sede]; + } + + /** + * The limits every closure meets, from the form or from a file: end not + * before start, a yearly closure within a year, a one-off within ten. + * Null when the span is fine, otherwise the message for the operator. + */ + private function spanError(string $start, string $end, bool $yearly): ?string + { + if ($end < $start) { + return __('La data di fine non può precedere la data di inizio'); + } + $span = LibraryCalendar::spanDays($start, $end); + if ($span < 0) { + return __('Data non valida: usa il formato AAAA-MM-GG.'); + } + if ($yearly && $span > LibraryCalendar::MAX_YEARLY_SPAN_DAYS) { + return __('Una chiusura annuale non può durare più di un anno.'); + } + if (!$yearly && $span > LibraryCalendar::MAX_ONCE_SPAN_DAYS) { + return __('Una chiusura non può durare più di dieci anni.'); + } + return null; + } + + private function closureExists(\mysqli $db, int $id): bool + { + $stmt = $db->prepare('SELECT 1 FROM calendario_chiusure WHERE id = ? LIMIT 1'); + $stmt->bind_param('i', $id); + $stmt->execute(); + $found = $stmt->get_result()->fetch_row() !== null; + $stmt->close(); + return $found; + } + + /** + * The branch a form field names: null for "every branch" (field absent, + * empty or 0), false for anything else that is not an existing branch + * (an unknown or deleted id, garbage, an array), which the caller refuses + * instead of storing a closure for every branch. + */ + private function branchFromInput(\mysqli $db, mixed $raw): int|false|null + { + if ($raw === null || $raw === '' || $raw === '0' || $raw === 0) { + return null; + } + return $this->branchFrom($db, $raw) ?? false; + } + + private function branchFrom(\mysqli $db, mixed $raw): ?int + { + if (!is_scalar($raw) || !ctype_digit((string) $raw) || (int) $raw <= 0) { + return null; + } + $id = (int) $raw; + return isset($this->branches($db)[$id]) ? $id : null; + } + + /** @param array{start: string, end: string, description: string, yearly: bool, sede: ?int} $row */ + private function insertClosure(\mysqli $db, array $row): int + { + $yearly = $row['yearly'] ? 1 : 0; + $sede = $row['sede']; + $stmt = $db->prepare('INSERT INTO calendario_chiusure (data_inizio, data_fine, descrizione, ricorrente_annuale, sede_id) VALUES (?, ?, ?, ?, ?)'); + $stmt->bind_param('sssii', $row['start'], $row['end'], $row['description'], $yearly, $sede); + $stmt->execute(); + $id = (int) $stmt->insert_id; + $stmt->close(); + return $id; + } + + /** + * An identical closure is already stored (same interval, recurrence and + * branch; a yearly row matches the same month-day span in any year), so + * importing a preset or a file twice adds nothing. + * + * @param array{start: string, end: string, description: string, yearly: bool, sede: ?int} $row + */ + private function alreadyClosed(\mysqli $db, array $row): bool + { + $yearly = $row['yearly'] ? 1 : 0; + $sede = $row['sede'] ?? 0; + if ($yearly === 1) { + $from = substr($row['start'], 5); + $to = substr($row['end'], 5); + $stmt = $db->prepare("SELECT 1 FROM calendario_chiusure WHERE ricorrente_annuale = 1 AND DATE_FORMAT(data_inizio, '%m-%d') = ? AND DATE_FORMAT(data_fine, '%m-%d') = ? AND COALESCE(sede_id, 0) = ? LIMIT 1"); + $stmt->bind_param('ssi', $from, $to, $sede); + } else { + $stmt = $db->prepare('SELECT 1 FROM calendario_chiusure WHERE ricorrente_annuale = 0 AND data_inizio = ? AND data_fine = ? AND COALESCE(sede_id, 0) = ? LIMIT 1'); + $stmt->bind_param('ssi', $row['start'], $row['end'], $sede); + } + $stmt->execute(); + $found = $stmt->get_result()->fetch_row() !== null; + $stmt->close(); + return $found; + } + + /** @return list> */ + private function closures(\mysqli $db): array + { + $result = $db->query('SELECT c.id, c.data_inizio, c.data_fine, c.descrizione, c.ricorrente_annuale, c.sede_id, s.nome AS sede_nome + FROM calendario_chiusure c LEFT JOIN sedi s ON s.id = c.sede_id + ORDER BY c.ricorrente_annuale DESC, c.data_inizio, c.data_fine, c.id'); + return $result === false ? [] : $result->fetch_all(MYSQLI_ASSOC); + } + + /** @var array|null */ + private ?array $branchCache = null; + + /** @return array id => name */ + private function branches(\mysqli $db): array + { + if ($this->branchCache !== null) { + return $this->branchCache; + } + $this->branchCache = []; + $result = $db->query('SELECT id, nome FROM sedi ORDER BY nome'); + if ($result !== false) { + foreach ($result->fetch_all(MYSQLI_ASSOC) as $row) { + $this->branchCache[(int) $row['id']] = (string) $row['nome']; + } + } + return $this->branchCache; + } +} diff --git a/app/Controllers/LoanApprovalController.php b/app/Controllers/LoanApprovalController.php index 7f86728f3..2afa043c7 100644 --- a/app/Controllers/LoanApprovalController.php +++ b/app/Controllers/LoanApprovalController.php @@ -374,7 +374,8 @@ public function approveLoan(Request $request, Response $response, mysqli $db): R $pickupDays = (int) ($settingsRepo->get('loans', 'pickup_expiry_days', '3') ?? 3); // Base su $today (timezone applicativo) per coerenza con la // classificazione immediato/futuro a cavallo della mezzanotte. - $pickupDeadline = date('Y-m-d', strtotime("{$today} +{$pickupDays} days")); + // Open days only (closures calendar); identical to +N days without closures. + $pickupDeadline = \App\Support\LibraryCalendar::pickupDeadline($db, $today, $pickupDays); // Cap alla fine della finestra del prestito (L1): una deadline oltre // data_scadenza permetterebbe di confermare il ritiro di un prestito // già scaduto e terrebbe la copia impegnata oltre la finestra. @@ -537,6 +538,37 @@ public function approveLoan(Request $request, Response $response, mysqli $db): R $lockCopyStmt->execute(); $lockCopyStmt->close(); + // The copy is known only now: a due date closed at its branch moves + // to the branch's next open day when the longer window still fits + // (otherwise the requested date stays, as before the calendar). + if ($dataScadenza !== null && $dataScadenza !== '') { + $branchDue = \App\Services\CirculationPolicy::dueDateFor((string) $dataScadenza, 0, $db, (int) $selectedCopy['id']); + if ($branchDue !== $dataScadenza + && (new \App\Services\CapacityService($db))->hasFreeCapacity($libroId, (string) $dataPrestito, $branchDue, excludePrestitoId: $loanId)) { + // The selected copy must be free on the added days too + // (same occupancy predicate as the recheck below). + $extraStart = \App\Support\LibraryCalendar::shift((string) $dataScadenza, 1); + $extraStmt = $db->prepare(" + SELECT 1 FROM prestiti + WHERE copia_id = ? AND id != ? + AND data_prestito <= ? + AND (stato = 'in_ritardo' OR (stato = 'in_corso' AND data_scadenza < ?) OR data_scadenza >= ?) + AND ( + (attivo = 1 AND stato IN ('in_corso','prenotato','da_ritirare','in_ritardo')) + OR (stato = 'pendente' AND copia_id IS NOT NULL) + ) + LIMIT 1 + "); + $extraStmt->bind_param('iisss', $selectedCopy['id'], $loanId, $branchDue, $today, $extraStart); + $extraStmt->execute(); + $extraBusy = (bool) $extraStmt->get_result()->fetch_row(); + $extraStmt->close(); + if (!$extraBusy) { + $dataScadenza = $branchDue; + } + } + } + // Occupazione canonica #157: la copia è tenuta da un prestito attivo // OPPURE da un pending di conversione prenotazione che ha già una // copia (attivo=0, stato='pendente', copia_id valorizzato). Omettere @@ -602,21 +634,29 @@ public function approveLoan(Request $request, Response $response, mysqli $db): R throw new \RuntimeException(__('Impossibile aggiornare lo stato della copia')); } + // The pickup days count the open days of the chosen copy's branch. + if ($pickupDeadline !== null) { + $pickupDeadline = \App\Support\LibraryCalendar::pickupDeadline($db, $today, $pickupDays, null, (int) $selectedCopy['id']); + if ($dataScadenza !== null && $dataScadenza !== '' && $pickupDeadline > $dataScadenza) { + $pickupDeadline = $dataScadenza; + } + } + // Assegna la copia al prestito con lo stato corretto e pickup_deadline se applicabile if ($pickupDeadline !== null) { $stmt = $db->prepare(" UPDATE prestiti - SET stato = ?, attivo = 1, copia_id = ?, pickup_deadline = ?{$pickupNotificationResetSql} + SET stato = ?, attivo = 1, copia_id = ?, pickup_deadline = ?, data_scadenza = ?{$pickupNotificationResetSql} WHERE id = ? AND stato = 'pendente' "); - $stmt->bind_param('sisi', $newState, $selectedCopy['id'], $pickupDeadline, $loanId); + $stmt->bind_param('sissi', $newState, $selectedCopy['id'], $pickupDeadline, $dataScadenza, $loanId); } else { $stmt = $db->prepare(" UPDATE prestiti - SET stato = ?, attivo = 1, copia_id = ?, pickup_deadline = NULL{$pickupNotificationResetSql} + SET stato = ?, attivo = 1, copia_id = ?, pickup_deadline = NULL, data_scadenza = ?{$pickupNotificationResetSql} WHERE id = ? AND stato = 'pendente' "); - $stmt->bind_param('sii', $newState, $selectedCopy['id'], $loanId); + $stmt->bind_param('sisi', $newState, $selectedCopy['id'], $dataScadenza, $loanId); } $stmt->execute(); $stmt->close(); @@ -671,6 +711,10 @@ public function approveLoan(Request $request, Response $response, mysqli $db): R // drifting from the authoritative computation above. 'loan_state' => $newState, 'is_future_loan' => $isFutureLoan, + // The window as written: the due date may have moved over a + // closure of the assigned copy's branch. + 'data_scadenza' => $dataScadenza, + 'copia_id' => (int) $selectedCopy['id'], ])); return $response->withHeader('Content-Type', 'application/json'); diff --git a/app/Controllers/PrestitiController.php b/app/Controllers/PrestitiController.php index df042e785..27f82a195 100644 --- a/app/Controllers/PrestitiController.php +++ b/app/Controllers/PrestitiController.php @@ -189,7 +189,9 @@ public function createForm(Request $request, Response $response, mysqli $db): Re $loanSettings = new \App\Models\SettingsRepository($db); $defaultLoanDays = (new \App\Services\CirculationPolicy($db, $loanSettings))->defaults()->durationDays; $allowMultipleLoansSameBook = $loanSettings->allowsMultipleLoansSameBook(); - $defaultDataScadenza = \App\Services\CirculationPolicy::dueDateFor($defaultDataPrestito, $defaultLoanDays); + $defaultDataScadenza = \App\Services\CirculationPolicy::dueDateFor($defaultDataPrestito, $defaultLoanDays, $db); + // Closed days for the admin pickers (closures calendar), one year ahead. + $closedDates = \App\Support\LibraryCalendar::forDb($db)->closedDatesBetween($defaultDataPrestito, \App\Support\LibraryCalendar::shift($defaultDataPrestito, 400)); $loanSubmissionToken = \App\Support\OneTimeFormToken::issue('loan.create'); ob_start(); @@ -266,7 +268,8 @@ public function store(Request $request, Response $response, mysqli $db): Respons if (!\App\Support\DateHelper::isISODateFormat($data_prestito)) { return $response->withHeader('Location', url('/admin/loans/create') . '?error=invalid_date_format')->withStatus(302); } - if (empty($data_scadenza)) { + $typedDueDate = !empty($data_scadenza); + if (!$typedDueDate) { // Default loan duration from the circulation rules of this patron // and title (fallback: the global loans settings, 30 days). $loanDays = (new \App\Services\CirculationPolicy($db))->forLoan($utente_id, $libro_id)->durationDays; @@ -274,7 +277,7 @@ public function store(Request $request, Response $response, mysqli $db): Respons if ($startDate === false) { return $response->withHeader('Location', url('/admin/loans/create') . '?error=invalid_date_format')->withStatus(302); } - $data_scadenza = \App\Services\CirculationPolicy::dueDateFor($startDate->format('Y-m-d'), $loanDays); + $data_scadenza = \App\Services\CirculationPolicy::dueDateFor($startDate->format('Y-m-d'), $loanDays, $db); } if ($utente_id <= 0 || $libro_id <= 0) { @@ -300,6 +303,10 @@ public function store(Request $request, Response $response, mysqli $db): Respons if ($data_scadenza <= $data_prestito) { return $response->withHeader('Location', url('/admin/loans/create') . '?error=invalid_dates')->withStatus(302); } + if ($typedDueDate) { + // A typed due date on a closed day moves to the next open day. + $data_scadenza = \App\Services\CirculationPolicy::dueDateFor($data_scadenza, 0, $db); + } // A newly-created active loan whose entire window is already over is // physically open-ended until returned, even if its nominal dates do @@ -571,6 +578,19 @@ public function store(Request $request, Response $response, mysqli $db): Respons return $response->withHeader('Location', url('/admin/loans/create') . '?error=copy_not_available')->withStatus(302); } + // The copy is known only now: a due date closed at its branch moves + // to the branch's next open day, and the longer window is checked + // again for capacity (the copy overlap check below uses it too). + $branchDue = \App\Services\CirculationPolicy::dueDateFor($data_scadenza, 0, $db, (int) $selectedCopy['id']); + if ($branchDue !== $data_scadenza) { + $branchCapacity = new \App\Services\CapacityService($db); + if (!$branchCapacity->hasFreeCapacity($libro_id, $data_prestito, $branchDue)) { + $db->rollback(); + return $this->noCapacityRedirect($response, $branchCapacity, $libro_id, $data_prestito, $branchDue); + } + $data_scadenza = $branchDue; + } + // Race condition check to prevent double-booking $overlapCopyStmt = $db->prepare(" SELECT 1 FROM prestiti @@ -646,7 +666,8 @@ public function store(Request $request, Response $response, mysqli $db): Respons // immediato/futuro ed evitare lo scarto di un giorno a mezzanotte. $settingsRepo = new \App\Models\SettingsRepository($db); $pickupDays = (int) ($settingsRepo->get('loans', 'pickup_expiry_days', '3') ?? 3); - $pickupDeadline = date('Y-m-d', strtotime("{$today} +{$pickupDays} days")); + // Open days only (closures calendar); identical to +N days without closures. + $pickupDeadline = \App\Support\LibraryCalendar::pickupDeadline($db, $today, $pickupDays, null, (int) $selectedCopy['id']); // Cap alla fine della finestra del prestito (L1), come // approveLoan/activateScheduledLoans: una deadline oltre // data_scadenza permetterebbe di confermare il ritiro di un @@ -864,7 +885,7 @@ public function update(Request $request, Response $response, mysqli $db, int $id // Lettura NON bloccante della riga corrente: serve libro_id per il lock // canonico (P3) e i valori correnti per completare i campi non inviati. - $check = $db->prepare('SELECT attivo, data_restituzione, libro_id, utente_id, data_prestito, data_scadenza FROM prestiti WHERE id=?'); + $check = $db->prepare('SELECT attivo, data_restituzione, libro_id, utente_id, data_prestito, data_scadenza, copia_id FROM prestiti WHERE id=?'); $check->bind_param('i', $id); $check->execute(); $current = $check->get_result()->fetch_assoc(); @@ -882,6 +903,10 @@ public function update(Request $request, Response $response, mysqli $db, int $id $libroId = (int) $current['libro_id']; $newUserId = isset($updateData['utente_id']) ? (int) $updateData['utente_id'] : (int) $current['utente_id']; $newPrestito = (string) ($updateData['data_prestito'] ?? $current['data_prestito']); + // Closures calendar: a submitted due date on a closed day moves forward. + if (isset($updateData['data_scadenza']) && \App\Support\DateHelper::isISODateFormat((string) $updateData['data_scadenza'])) { + $updateData['data_scadenza'] = \App\Services\CirculationPolicy::dueDateFor((string) $updateData['data_scadenza'], 0, $db, $current['copia_id'] !== null ? (int) $current['copia_id'] : null); + } $newScadenza = (string) ($updateData['data_scadenza'] ?? $current['data_scadenza']); // Valida il range date (M6a): una scadenza precedente alla partenza @@ -1256,7 +1281,7 @@ public function update(Request $request, Response $response, mysqli $db, int $id $pickupRecalc->close(); } elseif ((string) $locked['stato'] === 'da_ritirare') { $pickupDays = (int) ((new \App\Models\SettingsRepository($db))->get('loans', 'pickup_expiry_days', '3') ?? 3); - $pickupDeadline = (new \DateTimeImmutable($todayApp))->modify('+' . $pickupDays . ' days')->format('Y-m-d'); + $pickupDeadline = \App\Support\LibraryCalendar::pickupDeadline($db, $todayApp, $pickupDays, null, !empty($locked['copia_id']) ? (int) $locked['copia_id'] : null); if ($pickupDeadline > $newScadenza) { $pickupDeadline = $newScadenza; } @@ -1995,7 +2020,8 @@ public function bulkExtend(Request $request, Response $response, mysqli $db): Re $copyOverlap, $update, $expectedBookByLoan, - $conflict + $conflict, + $db ); if ($applied === null) { $copyOverlap->close(); @@ -2056,7 +2082,8 @@ private function applyBulkLoanExtension( \mysqli_stmt $copyOverlap, \mysqli_stmt $update, array $expectedBookByLoan, - array &$conflict = [] + array &$conflict = [], + ?\mysqli $db = null ): ?int { $loanId = (int) $loan['id']; $bookId = (int) $loan['libro_id']; @@ -2075,7 +2102,8 @@ private function applyBulkLoanExtension( // extending from its due date. $todayDate = \DateTimeImmutable::createFromFormat('!Y-m-d', $today); $base = ($todayDate !== false && $todayDate > $dueDate) ? $todayDate : $dueDate; - $newDueDate = $base->modify('+' . $days . ' days')->format('Y-m-d'); + // With the connection, a new due date on a closed day moves forward. + $newDueDate = \App\Services\CirculationPolicy::dueDateFor($base->format('Y-m-d'), $days, $db, $loan['copia_id'] !== null ? (int) $loan['copia_id'] : null); // Apply each accepted extension immediately inside the transaction, so the // next capacity check sees all earlier proposed extensions too. @@ -2089,7 +2117,7 @@ private function applyBulkLoanExtension( $copyId = $loan['copia_id'] !== null ? (int) $loan['copia_id'] : null; if (!$capacity->hasFreeCapacity($bookId, $extensionStart, $newDueDate, excludePrestitoId: $loanId)) { $blocked = $capacity->renewalConflictDates($bookId, $loanId, $copyId, $currentDue, $newDueDate); - $conflict = \App\Support\LoanWindow::renewalRedirectParams($currentDue, $blocked, $base->format('Y-m-d')); + $conflict = \App\Support\LoanWindow::renewalRedirectParams($currentDue, $blocked, $base->format('Y-m-d'), $db !== null ? \App\Support\LibraryCalendar::forDb($db) : null, $db !== null ? \App\Support\LibraryCalendar::branchOfCopy($db, $copyId) : null); return null; } @@ -2099,7 +2127,7 @@ private function applyBulkLoanExtension( if ((bool) $copyOverlap->get_result()->fetch_row()) { // The loan's own copy is needed by a later loan: name that day. $blocked = $capacity->renewalConflictDates($bookId, $loanId, $copyId, $currentDue, $newDueDate); - $conflict = \App\Support\LoanWindow::renewalRedirectParams($currentDue, $blocked, $base->format('Y-m-d')); + $conflict = \App\Support\LoanWindow::renewalRedirectParams($currentDue, $blocked, $base->format('Y-m-d'), $db !== null ? \App\Support\LibraryCalendar::forDb($db) : null, $db !== null ? \App\Support\LibraryCalendar::branchOfCopy($db, $copyId) : null); return null; } } @@ -2132,7 +2160,7 @@ public function renew(Request $request, Response $response, mysqli $db, int $id) // Get current loan $stmt = $db->prepare(" - SELECT id, libro_id, utente_id, data_scadenza, stato, renewals, attivo + SELECT id, libro_id, utente_id, data_scadenza, stato, renewals, attivo, copia_id FROM prestiti WHERE id = ? "); @@ -2209,13 +2237,17 @@ public function renew(Request $request, Response $response, mysqli $db, int $id) // Calculate proposed new due date for conflict checking $currentDueDate = $loan['data_scadenza']; - $proposedNewDueDate = \App\Services\CirculationPolicy::dueDateFor((string) $currentDueDate, $renewDays); + $loanCopyId = isset($loan['copia_id']) ? (int) $loan['copia_id'] : null; + $proposedNewDueDate = \App\Services\CirculationPolicy::dueDateFor((string) $currentDueDate, $renewDays, $db, $loanCopyId); // Optional shorter renewal: the day before a scheduled loan or a // reservation, as the refusal below proposes ("Rinnova fino al D"). // A real day, after the current due date, never past the standard // renewal. $rawDue = $data['new_due_date'] ?? ''; $requestedDue = is_string($rawDue) ? trim($rawDue) : ''; + if ($requestedDue !== '' && \App\Support\DateHelper::isISODateFormat($requestedDue)) { + $requestedDue = \App\Services\CirculationPolicy::dueDateFor($requestedDue, 0, $db, $loanCopyId); + } if (!is_string($rawDue) || ($requestedDue !== '' && !\App\Support\DateHelper::isISODateFormat($requestedDue))) { $errorUrl = $redirectTo ?? url('/admin/loans'); $separator = strpos($errorUrl, '?') === false ? '?' : '&'; @@ -2296,7 +2328,7 @@ public function renew(Request $request, Response $response, mysqli $db, int $id) // Use the locked values for calculations (in case they changed) $currentDueDate = $lockedLoan['data_scadenza']; - $proposedNewDueDate = \App\Services\CirculationPolicy::dueDateFor((string) $currentDueDate, $renewDays); + $proposedNewDueDate = \App\Services\CirculationPolicy::dueDateFor((string) $currentDueDate, $renewDays, $db, $lockedLoan['copia_id'] !== null ? (int) $lockedLoan['copia_id'] : null); if ($requestedDue !== '') { // Re-bound under the lock: the due date may have moved meanwhile. if ($requestedDue <= (string) $currentDueDate || $requestedDue > $proposedNewDueDate) { @@ -2327,9 +2359,9 @@ public function renew(Request $request, Response $response, mysqli $db, int $id) $capacity = new \App\Services\CapacityService($db); // A refusal names the longest renewal still possible (LoanWindow), // and which loan it is about, so the page can offer "renew until D". - $explainRefusal = static function (array $blocked) use ($id, $currentDueDate): string { + $explainRefusal = static function (array $blocked) use ($id, $currentDueDate, $db, $copiaId): string { return http_build_query(['error' => 'extension_conflicts', 'loan_id' => $id] - + \App\Support\LoanWindow::renewalRedirectParams((string) $currentDueDate, $blocked)); + + \App\Support\LoanWindow::renewalRedirectParams((string) $currentDueDate, $blocked, null, \App\Support\LibraryCalendar::forDb($db), \App\Support\LibraryCalendar::branchOfCopy($db, $copiaId))); }; if (!$capacity->hasFreeCapacity($libroId, $extensionStart, $extensionEnd, excludePrestitoId: $id)) { $blocked = $capacity->renewalConflictDates($libroId, $id, $copiaId, (string) $currentDueDate, $extensionEnd); diff --git a/app/Controllers/ReservationManager.php b/app/Controllers/ReservationManager.php index 8b9a8d153..2484e19b6 100644 --- a/app/Controllers/ReservationManager.php +++ b/app/Controllers/ReservationManager.php @@ -460,7 +460,8 @@ private function createLoanFromReservation($reservation) { $bookId = (int) $reservation['libro_id']; $startDate = $reservation['data_inizio_richiesta']; - $endDate = $reservation['data_fine_richiesta']; + // Closures calendar: the loan's due date moves to the next open day. + $endDate = \App\Services\CirculationPolicy::dueDateFor((string) $reservation['data_fine_richiesta'], 0, $this->db); // Always create as 'pendente' (attivo=0) - requires admin confirmation of physical pickup // Origin is 'prenotazione' to distinguish from manual requests @@ -999,6 +1000,11 @@ public function isBookAvailableForImmediateLoan($bookId, ?string $startDate = nu */ public function cancelExpiredReservations(): int { + // Closures calendar: a queue reservation does not lose its place on a + // day the library is closed (same rule as checkExpiredReservations()). + if (!\App\Support\LibraryCalendar::forDb($this->db)->isOpen(\App\Support\DateHelper::today())) { + return 0; + } $ownTransaction = $this->beginTransactionIfNeeded(); $expiryNotifications = []; diff --git a/app/Controllers/ReservationsAdminController.php b/app/Controllers/ReservationsAdminController.php index d031ad7f1..c6b1bdbd1 100644 --- a/app/Controllers/ReservationsAdminController.php +++ b/app/Controllers/ReservationsAdminController.php @@ -136,7 +136,7 @@ public function update(Request $request, Response $response, mysqli $db, int $id } if ($end === '') { $loanDays = (new \App\Services\CirculationPolicy($db))->forReservation($id)->durationDays; - $end = \App\Services\CirculationPolicy::dueDateFor($start, $loanDays); + $end = \App\Services\CirculationPolicy::dueDateFor($start, $loanDays, $db); } // Normalize an inverted range on the reservation dates too (form can post // end < start), mirroring the data_*_richiesta clamp below (#252). @@ -516,7 +516,7 @@ public function store(Request $request, Response $response, mysqli $db): Respons $loanDays = (new \App\Services\CirculationPolicy($db))->forLoan($utenteId, $libroId)->durationDays; // The default duration starts from the requested start date, not // from today (critical for future reservations). - $dataScadenzaDate = \App\Services\CirculationPolicy::dueDateFor($dataPrenotazioneDate, $loanDays); + $dataScadenzaDate = \App\Services\CirculationPolicy::dueDateFor($dataPrenotazioneDate, $loanDays, $db); $dataScadenza = $dataScadenzaDate . ' 23:59:59'; } else { $dataScadenzaDate = $dataScadenza; // Keep date only for loan period diff --git a/app/Controllers/ReservationsController.php b/app/Controllers/ReservationsController.php index 52a682b14..d5e75bc6e 100644 --- a/app/Controllers/ReservationsController.php +++ b/app/Controllers/ReservationsController.php @@ -290,6 +290,11 @@ public function createReservation($request, $response, $args) return $response->withHeader('Content-Type', 'application/json')->withStatus(400); } + // A start day the library is closed moves to the next open day, as the + // request calendar proposes it (a crafted request cannot start on a + // closure); identical without closures. + $startDate = \App\Support\LibraryCalendar::forDb($this->db)->nextOpenDay($startDate); + if ($endDate && $endDate < $startDate) { $response->getBody()->write(json_encode(['success' => false, 'error' => 'invalid_range', 'message' => __('La data di fine non può precedere la data di inizio')])); return $response->withHeader('Content-Type', 'application/json')->withStatus(400); @@ -306,11 +311,16 @@ public function createReservation($request, $response, $args) $endDate = \App\Services\CirculationPolicy::dueDateFor((string) $startDate, $requestPolicy->requestDurationDays()); } - $requestedDurationDays = (int) (new DateTime($startDate))->diff(new DateTime($endDate))->days; - if ($requestedDurationDays > $maxDurationDays) { + // The longest loan ends on the maximum day, moved forward like every + // due date when it falls on a closed day (closed days are not loan + // days); without closures this is start + maximum, as before. + if ($endDate > \App\Services\CirculationPolicy::dueDateFor((string) $startDate, $maxDurationDays, $this->db)) { $response->getBody()->write(json_encode(['success' => false, 'error' => 'max_duration_exceeded', 'message' => __('Il periodo richiesto supera la durata massima consentita di %d giorni', $maxDurationDays)])); return $response->withHeader('Content-Type', 'application/json')->withStatus(400); } + // Closures calendar: a return date on a closed day moves to the next + // open day (after the duration cap: closed days are not loan days). + $endDate = \App\Services\CirculationPolicy::dueDateFor($endDate, 0, $this->db); // Availability decision routes through CapacityService — the single // capacity authority — so this web loan-request gate matches the mobile @@ -451,6 +461,9 @@ public function createReservation($request, $response, $args) 'auto_approved' => false, 'status' => 'reserved', 'loan_state' => null, + // The stored window (closed days already skipped). + 'start_date' => $startDate, + 'end_date' => $endDate, ])); return $response->withHeader('Content-Type', 'application/json'); } @@ -542,9 +555,16 @@ public function createReservation($request, $response, $args) // ?string: the persisted state ('prenotato' scheduled loan / // 'da_ritirare' immediate pickup) on success, null when the // request stays pending (setting off / approval failed). + $approved = null; $loanState = $autoApproveEnabled - ? $this->autoApproveLoanRequest($request, $loanRequestId, true) + ? $this->autoApproveLoanRequest($request, $loanRequestId, true, $approved) : null; + // The due date the approval wrote (it may have moved over a + // closure of the assigned copy's branch): the fallback below + // when the stored window cannot be read back. + if (is_array($approved) && is_string($approved['data_scadenza'] ?? null) && \App\Support\DateHelper::isISODateFormat($approved['data_scadenza'])) { + $endDate = $approved['data_scadenza']; + } if ($loanState === null) { // Send notification to admins (an auto-approved request no @@ -573,6 +593,32 @@ public function createReservation($request, $response, $args) $status = 'pending_approval'; } + // The window as stored: approval may still move the due date + // over a closure of the assigned copy's branch. + // The request is already committed: a failed read only loses + // the refinement, never the success the reader must see. + $pickupDeadline = null; + $pickupDays = null; + try { + $storedWindow = $this->db->prepare('SELECT data_prestito, data_scadenza, copia_id, pickup_deadline FROM prestiti WHERE id = ?'); + $storedWindow->bind_param('i', $loanRequestId); + $storedWindow->execute(); + $storedRow = $storedWindow->get_result()->fetch_assoc(); + $storedWindow->close(); + if (is_array($storedRow)) { + $startDate = (string) ($storedRow['data_prestito'] ?? $startDate); + $endDate = (string) ($storedRow['data_scadenza'] ?? $endDate); + if ($loanState !== null) { + [$pickupDeadline, $pickupDays] = $this->expectedPickup($startDate, $endDate, $storedRow); + } + } + } catch (\Throwable $windowError) { + \App\Support\SecureLogger::warning('ReservationsController: stored window not readable', ['error' => $windowError->getMessage()]); + if ($loanState !== null) { + [$pickupDeadline, $pickupDays] = $this->fallbackPickup($startDate, $endDate, $approved); + } + } + $response->getBody()->write(json_encode([ 'success' => true, 'message' => $message, @@ -582,6 +628,15 @@ public function createReservation($request, $response, $args) 'auto_approved' => $loanState !== null, 'status' => $status, 'loan_state' => $loanState, + // The stored window (closed days already skipped). + 'start_date' => $startDate, + 'end_date' => $endDate, + // An approved loan's pickup deadline and open pickup days, + // with the closures of the assigned copy's branch (null + // when unknown: the page then computes them itself). For + // a scheduled loan it is the estimate activation applies. + 'pickup_deadline' => $pickupDeadline, + 'pickup_days' => $pickupDays, ])); return $response->withHeader('Content-Type', 'application/json'); } else { @@ -605,13 +660,65 @@ public function createReservation($request, $response, $args) // (UserActionsController::loan) so the two public entry points can never // diverge again. + /** + * The pickup deadline and open pickup days of an approved request, from + * its stored row: the deadline already stored for a loan awaiting pickup, + * otherwise the one activation will set (LibraryCalendar::pickupDeadline + * with the assigned copy's branch, capped at the due date). The days + * follow the pickup emails: never more than the setting, fewer when the + * loan ends first or closed days fall inside the window. + * + * @param array $row prestiti row (copia_id, pickup_deadline) + * @return array{0: ?string, 1: ?int} + */ + private function expectedPickup(string $startDate, string $endDate, array $row): array + { + if (!\App\Support\DateHelper::isISODateFormat($startDate)) { + return [null, null]; + } + $copiaId = !empty($row['copia_id']) ? (int) $row['copia_id'] : null; + $setting = max(1, (int) ((new \App\Models\SettingsRepository($this->db))->get('loans', 'pickup_expiry_days', '3') ?? 3)); + $stored = $row['pickup_deadline'] !== null ? substr((string) $row['pickup_deadline'], 0, 10) : ''; + $deadline = \App\Support\DateHelper::isISODateFormat($stored) + ? $stored + : \App\Support\LibraryCalendar::pickupDeadline($this->db, $startDate, $setting, \App\Support\DateHelper::isISODateFormat($endDate) ? $endDate : null, $copiaId); + $openDays = \App\Support\LibraryCalendar::forDb($this->db)->openDaysBetween($startDate, $deadline, \App\Support\LibraryCalendar::branchOfCopy($this->db, $copiaId)); + return [$deadline, min($setting, $openDays)]; + } + + /** + * The pickup estimate when the stored row cannot be read back: the copy + * the approval assigned (its JSON body carries copia_id) still brings its + * branch closures, so the page does not fall back to the library-wide + * ones. Counted from the requested start, it is an estimate, not the + * deadline the approval stored (that one counts from the approval day). + * The request is already committed: a failure here only loses the + * estimate, never the success. + * + * @param array|null $approved + * @return array{0: ?string, 1: ?int} + */ + private function fallbackPickup(string $startDate, string $endDate, ?array $approved): array + { + try { + $copiaId = is_array($approved) && is_numeric($approved['copia_id'] ?? null) ? (int) $approved['copia_id'] : null; + return $this->expectedPickup($startDate, $endDate, ['copia_id' => $copiaId, 'pickup_deadline' => null]); + } catch (\Throwable $e) { + \App\Support\SecureLogger::warning('ReservationsController: pickup estimate not computed', ['error' => $e->getMessage()]); + return [null, null]; + } + } + /** * Promote a newly-created request through the canonical approval pipeline * when the automatic-approval setting is on (#301). Mirrors * UserActionsController::autoApproveLoanRequest — a failure deliberately * leaves the request pending so an administrator can still process it. + * + * @param array|null $approved set to the approval's JSON + * body (data_scadenza, copia_id…) when the loan was approved */ - private function autoApproveLoanRequest($request, int $loanId, ?bool $knownEnabled = null): ?string + private function autoApproveLoanRequest($request, int $loanId, ?bool $knownEnabled = null, ?array &$approved = null): ?string { // When the caller already captured the setting under the book lock it // passes that decision in $knownEnabled. Legacy/internal callers may omit @@ -641,6 +748,7 @@ private function autoApproveLoanRequest($request, int $loanId, ?bool $knownEnabl // so the response can describe the real outcome instead of // assuming "awaiting pickup". $body = json_decode((string) $result->getBody(), true); + $approved = is_array($body) ? $body : null; return is_array($body) && isset($body['loan_state']) && is_string($body['loan_state']) ? $body['loan_state'] : 'da_ritirare'; @@ -674,7 +782,7 @@ private function autoApproveLoanRequest($request, int $loanId, ?bool $knownEnabl * answer the filter would give, without hiding the record from the * operator or from the reader who already has it reserved. */ - public function getBookAvailabilityData($bookId, ?string $startDate = null, int $days = 730, ?int $excludeUserId = null): ?array + public function getBookAvailabilityData($bookId, ?string $startDate = null, int $days = 730, ?int $excludeUserId = null, bool $coverLoanWindow = false): ?array { $bookStmt = $this->db->prepare("SELECT id FROM libri WHERE id = ? AND deleted_at IS NULL"); $bookStmt->bind_param('i', $bookId); @@ -724,10 +832,28 @@ public function getBookAvailabilityData($bookId, ?string $startDate = null, int $policy = $excludeUserId !== null ? $circulation->forLoan($excludeUserId, (int) $bookId) : $circulation->defaults(); + // Closures calendar: closed days in the same horizon, so the request + // calendars can refuse them as a start/pickup day, plus enough days + // past it for the browser to move a proposed due date that falls on + // a closed day forward to the next open day (additive field). + $from = $startDate ?? \App\Support\DateHelper::today(); + $closedHorizon = max(0, $days - 1) + max($policy->durationDays, $policy->maxRequestDays) + 31; + // start_until is the last day a request may start on. A start picked + // there still needs occupancy data for its whole loan window, closed + // days included: with $coverLoanWindow the days run on as far as the + // closed days do, or a booked day past them would read as free. + $startUntil = \App\Support\LibraryCalendar::shift($from, max(0, $days - 1)); + if ($coverLoanWindow) { + $days = $closedHorizon + 1; + } + $closedDates = \App\Support\LibraryCalendar::forDb($this->db) + ->closedDatesBetween($from, \App\Support\LibraryCalendar::shift($from, $closedHorizon)); return $this->calculateAvailability($currentLoans, $existingReservations, $totalCopies, $startDate, $days, $excludeUserId) + [ 'loan_duration_days' => $policy->requestDurationDays(), 'max_loan_duration_days' => $policy->maxRequestDays, 'staff_loan_duration_days' => $policy->durationDays, + 'closed_dates' => $closedDates, + 'start_until' => $startUntil, ]; } diff --git a/app/Controllers/UserActionsController.php b/app/Controllers/UserActionsController.php index dce26f473..f3f18500a 100644 --- a/app/Controllers/UserActionsController.php +++ b/app/Controllers/UserActionsController.php @@ -560,6 +560,8 @@ public function changeReservationDate(Request $request, Response $response, mysq } $uid = (int) $user['id']; + // A closed start day moves to the next open day (see reserve()). + $date = \App\Support\LibraryCalendar::forDb($db)->nextOpenDay($date); $startDate = $date; $db->begin_transaction(); @@ -581,7 +583,7 @@ public function changeReservationDate(Request $request, Response $response, mysq $libroId = (int) $reservation['libro_id']; $loanDays = (new \App\Services\CirculationPolicy($db))->forLoan($uid, $libroId)->requestDurationDays(); - $endDate = \App\Services\CirculationPolicy::dueDateFor($date, $loanDays); + $endDate = \App\Services\CirculationPolicy::dueDateFor($date, $loanDays, $db); // Lock book row first to serialize every capacity decision. $lockStmt = $db->prepare("SELECT id FROM libri WHERE id = ? AND deleted_at IS NULL FOR UPDATE"); @@ -717,15 +719,16 @@ public function loan(Request $request, Response $response, mysqli $db): Response // "Today" via DateHelper (M9): PHP process tz is often UTC while the // library runs in a local zone, so a raw date('Y-m-d') near midnight // would date the request to the wrong day. - $data_prestito = \App\Support\DateHelper::today(); + // A day the library is closed starts on the next open day instead. + $data_prestito = \App\Support\LibraryCalendar::forDb($db)->nextOpenDay(\App\Support\DateHelper::today()); $circulation = new \App\Services\CirculationPolicy($db); $loanPolicy = $circulation->forLoan($utenteId, $libroId); - $data_scadenza = \App\Services\CirculationPolicy::dueDateFor($data_prestito, $loanPolicy->requestDurationDays()); + $data_scadenza = \App\Services\CirculationPolicy::dueDateFor($data_prestito, $loanPolicy->requestDurationDays(), $db); // Optional return date (Mobile API 1.8.2): the app proposes the day // before a scheduled loan or reservation when the default would cross // it, exactly as the website calendar does. Bounded like the public // endpoint: a real day, not before the start, within the maximum. - $endOverride = $this->requestedEndDate($loanPolicy->maxRequestDays, $data, $data_prestito); + $endOverride = $this->requestedEndDate($loanPolicy->maxRequestDays, $data, $data_prestito, $db); if ($endOverride === false) { return $this->back($response, ['loan_error' => 'invalid_date']); } @@ -733,7 +736,8 @@ public function loan(Request $request, Response $response, mysqli $db): Response return $this->back($response, ['loan_error' => 'max_duration_exceeded']); } if ($endOverride !== null) { - $data_scadenza = $endOverride; + // A chosen return date on a closed day moves to the next open day. + $data_scadenza = \App\Services\CirculationPolicy::dueDateFor($endOverride, 0, $db); } // Use transaction + lock to prevent race conditions @@ -981,12 +985,14 @@ public function reserve(Request $request, Response $response, mysqli $db): Respo return $this->back($response, ['reserve_error' => 'not_eligible']); } - // Calculate date range for availability check - $start = ($desired !== '') ? $desired : $today; + // Calculate date range for availability check. A start day the library + // is closed moves to the next open day, as the request calendar + // proposes it; identical without closures. + $start = \App\Support\LibraryCalendar::forDb($db)->nextOpenDay(($desired !== '') ? $desired : $today); $reservePolicy = (new \App\Services\CirculationPolicy($db))->forLoan($utenteId, $libroId); - $end = \App\Services\CirculationPolicy::dueDateFor($start, $reservePolicy->requestDurationDays()); + $end = \App\Services\CirculationPolicy::dueDateFor($start, $reservePolicy->requestDurationDays(), $db); // Optional return date (Mobile API 1.8.2), bounded as in loan(). - $endOverride = $this->requestedEndDate($reservePolicy->maxRequestDays, $data, $start); + $endOverride = $this->requestedEndDate($reservePolicy->maxRequestDays, $data, $start, $db); if ($endOverride === false) { return $this->back($response, ['reserve_error' => 'invalid_date']); } @@ -994,7 +1000,8 @@ public function reserve(Request $request, Response $response, mysqli $db): Respo return $this->back($response, ['reserve_error' => 'max_duration_exceeded']); } if ($endOverride !== null) { - $end = $endOverride; + // A chosen end date on a closed day moves to the next open day. + $end = \App\Services\CirculationPolicy::dueDateFor($endOverride, 0, $db); } // Start transaction for concurrency control @@ -1113,7 +1120,8 @@ public function reserve(Request $request, Response $response, mysqli $db): Respo $db->commit(); $params = ['reserve_success' => 1]; if ($desired !== '') { - $params['reserve_date'] = $desired; + // The stored start: a closed desired day moved to the next open one. + $params['reserve_date'] = $start; } return $this->back($response, $params); } @@ -1136,7 +1144,7 @@ public function reserve(Request $request, Response $response, mysqli $db): Respo * @param array $data * @return string|false|null */ - private function requestedEndDate(int $maxDays, array $data, string $startDate): string|false|null + private function requestedEndDate(int $maxDays, array $data, string $startDate, ?\mysqli $db = null): string|false|null { $raw = $data['end_date'] ?? null; if (!is_scalar($raw)) { @@ -1149,8 +1157,10 @@ private function requestedEndDate(int $maxDays, array $data, string $startDate): if (!\App\Support\DateHelper::isISODateFormat($end) || $end < $startDate) { return false; } - $days = (int) (new \DateTimeImmutable($startDate))->diff(new \DateTimeImmutable($end))->days; - return $days > $maxDays ? 'max_duration_exceeded' : $end; + // The longest loan ends on the maximum day, moved forward like every + // due date when it falls on a closed day (closed days are not loan + // days); without closures this is start + maximum, as before. + return $end > \App\Services\CirculationPolicy::dueDateFor($startDate, $maxDays, $db) ? 'max_duration_exceeded' : $end; } private function back(Response $response, array $params): Response diff --git a/app/Models/LoanRepository.php b/app/Models/LoanRepository.php index 108430062..e74ab0f88 100644 --- a/app/Models/LoanRepository.php +++ b/app/Models/LoanRepository.php @@ -100,7 +100,7 @@ public function update(int $id, array $data): bool // hardcoded era metà del default seminato (30) e ignorava la // configurazione dell'admin — stesso fix M5b già applicato a renew(). $policy = (new \App\Services\CirculationPolicy($this->db))->forExistingLoan($id, $utente_id > 0 ? $utente_id : null); - $data_scadenza = \App\Services\CirculationPolicy::dueDateFor((string) $data_prestito, $policy->durationDays); + $data_scadenza = \App\Services\CirculationPolicy::dueDateFor((string) $data_prestito, $policy->durationDays, $this->db); } $processed_by = $data['processed_by'] ?? null; $stmt->bind_param('issii', $utente_id, $data_prestito, $data_scadenza, $processed_by, $id); diff --git a/app/Routes/web.php b/app/Routes/web.php index 1d03abfe9..a21eeb4ba 100644 --- a/app/Routes/web.php +++ b/app/Routes/web.php @@ -842,6 +842,43 @@ return $controller->deleteApiKey($request, $response, $db, (int) $args['id']); })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + // Closures calendar (Admin Only): weekly closed days, closure intervals, + // holiday preset, ICS import. Every action re-checks mayConfigure() inline. + $app->get('/admin/calendar', function ($request, $response) use ($app) { + $db = $app->getContainer()->get('db'); + return (new \App\Controllers\Admin\LibraryCalendarController())->index($request, $response, $db); + })->add(new AdminAuthMiddleware()); + + $app->post('/admin/calendar/weekdays', function ($request, $response) use ($app) { + $db = $app->getContainer()->get('db'); + return (new \App\Controllers\Admin\LibraryCalendarController())->saveWeekdays($request, $response, $db); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + + $app->post('/admin/calendar/closures', function ($request, $response) use ($app) { + $db = $app->getContainer()->get('db'); + return (new \App\Controllers\Admin\LibraryCalendarController())->store($request, $response, $db); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + + $app->post('/admin/calendar/closures/{id:\d+}/update', function ($request, $response, $args) use ($app) { + $db = $app->getContainer()->get('db'); + return (new \App\Controllers\Admin\LibraryCalendarController())->update($request, $response, $db, (int) $args['id']); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + + $app->post('/admin/calendar/closures/{id:\d+}/delete', function ($request, $response, $args) use ($app) { + $db = $app->getContainer()->get('db'); + return (new \App\Controllers\Admin\LibraryCalendarController())->delete($request, $response, $db, (int) $args['id']); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + + $app->post('/admin/calendar/preset', function ($request, $response) use ($app) { + $db = $app->getContainer()->get('db'); + return (new \App\Controllers\Admin\LibraryCalendarController())->importPreset($request, $response, $db); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + + $app->post('/admin/calendar/import-ics', function ($request, $response) use ($app) { + $db = $app->getContainer()->get('db'); + return (new \App\Controllers\Admin\LibraryCalendarController())->importIcs($request, $response, $db); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + // Language Management Routes (Admin Only) $app->get('/admin/languages', function ($request, $response) use ($app) { $db = $app->getContainer()->get('db'); @@ -3068,7 +3105,9 @@ if ($forUser !== null && is_numeric($forUser) && \App\Support\Permissions::can('loans.manage')) { $excludeUserId = (int) $forUser; } - $availability = $controller->getBookAvailabilityData($bookId, \App\Support\DateHelper::today(), 180, $excludeUserId); + // 180 days to start in, and occupancy data past them for the + // longest loan a start on the last of them can ask for. + $availability = $controller->getBookAvailabilityData($bookId, \App\Support\DateHelper::today(), 180, $excludeUserId, true); if ($availability === null) { $response->getBody()->write(json_encode(['success' => false, 'message' => __('Libro non trovato')])); return $response->withStatus(404)->withHeader('Content-Type', 'application/json'); @@ -3091,7 +3130,11 @@ 'max_loan_duration_days' => $availability['max_loan_duration_days'] ?? null, // Staff loan length for this borrower's patron type and // this material (CirculationPolicy), for the admin form. - 'staff_loan_duration_days' => $availability['staff_loan_duration_days'] ?? null + 'staff_loan_duration_days' => $availability['staff_loan_duration_days'] ?? null, + // Closures calendar: days the library is closed (not a start/pickup day). + 'closed_dates' => $availability['closed_dates'] ?? [], + // Last day a request may start on; 'days' run past it. + 'start_until' => $availability['start_until'] ?? null ] ]; $response->getBody()->write(json_encode($data, JSON_UNESCAPED_UNICODE)); diff --git a/app/Services/CirculationPolicy.php b/app/Services/CirculationPolicy.php index a7571945c..a8a73fc22 100644 --- a/app/Services/CirculationPolicy.php +++ b/app/Services/CirculationPolicy.php @@ -69,17 +69,22 @@ public function __construct(private mysqli $db, ?SettingsRepository $settings = * The final due date of a loan or renewal that starts (or is extended * from) $startYmd and lasts $days days. * - * Every due date in the application is computed here. The closures - * calendar hooks in at this point to move a date that falls on a closed - * day forward to the next open day. + * Every due date in the application is computed here. With a connection + * the closures calendar applies: a date that falls on a closed day moves + * forward to the next open day (plan §13, decision 7), including the + * closures of the copy's branch when $copiaId is known. A due date the + * staff or the reader typed goes through here with $days = 0, so it is + * moved by the same rule. Without closures (or without $db) the result is + * the plain calendar arithmetic of every release before 0.9.0. */ - public static function dueDateFor(string $startYmd, int $days): string + public static function dueDateFor(string $startYmd, int $days, ?mysqli $db = null, ?int $copiaId = null): string { $start = \DateTimeImmutable::createFromFormat('!Y-m-d', substr($startYmd, 0, 10)); if ($start === false) { $start = new \DateTimeImmutable($startYmd); } - return $start->modify('+' . max(0, $days) . ' days')->format('Y-m-d'); + $due = $start->modify('+' . max(0, $days) . ' days')->format('Y-m-d'); + return $db === null ? $due : \App\Support\LibraryCalendar::dueDate($db, $due, $copiaId); } /** Drop the per-request schema probe (tests that migrate a database mid-process). */ diff --git a/app/Support/LibraryCalendar.php b/app/Support/LibraryCalendar.php new file mode 100644 index 000000000..97de4507e --- /dev/null +++ b/app/Support/LibraryCalendar.php @@ -0,0 +1,831 @@ + */ + private array $closedWeekdays = []; + + /** @var list */ + private array $closures = []; + + /** @var array per-connection cache, keyed by spl_object_id */ + private static array $instances = []; + + /** @var array branchOfCopy() memo: copy id => sede_id (null when none) */ + private array $copyBranches = []; + + /** + * @param list|array $closedWeekdays 0 = Sunday … 6 = Saturday + * @param list $closures + */ + public function __construct(array $closedWeekdays = [], array $closures = []) + { + foreach ($closedWeekdays as $weekday) { + $weekday = (int) $weekday; + if ($weekday >= 0 && $weekday <= 6) { + $this->closedWeekdays[$weekday] = true; + } + } + // Seven closed weekdays would leave no day to move a date to. Treat + // that misconfiguration as "no weekly closure" rather than letting + // every due date run to the scan limit. + if (count($this->closedWeekdays) === 7) { + $this->closedWeekdays = []; + } + foreach ($closures as $row) { + $start = (string) ($row['data_inizio'] ?? $row['start'] ?? ''); + $end = (string) ($row['data_fine'] ?? $row['end'] ?? $start); + if (!DateHelper::isISODateFormat($start) || !DateHelper::isISODateFormat($end)) { + continue; + } + if ($end < $start) { + [$start, $end] = [$end, $start]; + } + $sede = $row['sede_id'] ?? $row['sede'] ?? null; + // A yearly closure repeats its month-day span, which only makes + // sense for a span shorter than a year. A longer row (the form + // refuses one; an old import may have stored one) is applied once, + // as the interval it names, instead of closing every day. + $yearly = (bool) ($row['ricorrente_annuale'] ?? $row['yearly'] ?? false) + && self::spanDays($start, $end) <= self::MAX_YEARLY_SPAN_DAYS; + $this->closures[] = [ + 'start' => $start, + 'end' => $end, + 'yearly' => $yearly, + 'sede' => ($sede === null || $sede === '' || (int) $sede <= 0) ? null : (int) $sede, + ]; + } + } + + /** The calendar stored in the database (settings + closures table). */ + public static function fromDatabase(\mysqli $db): self + { + return new self(self::loadClosedWeekdays($db), self::loadClosures($db)); + } + + /** + * One calendar per connection and request. Writers call reset() after a + * change so the same request sees it. + */ + public static function forDb(\mysqli $db): self + { + $key = spl_object_id($db); + return self::$instances[$key] ??= self::fromDatabase($db); + } + + public static function reset(): void + { + self::$instances = []; + } + + // ── Funnels used by the loan code ─────────────────────────────────────── + + /** + * THE due-date rule: a due date that falls on a closed day moves forward + * to the next open day (plan §13, decision 7). Every place that stores a + * data_scadenza calls this with the date it computed or received. + * $copiaId, when known, applies that copy's branch closures too. + */ + public static function dueDate(\mysqli $db, string $ymd, ?int $copiaId = null): string + { + if (!DateHelper::isISODateFormat($ymd)) { + return $ymd; + } + $calendar = self::forDb($db); + if (!$calendar->hasRules()) { + return $ymd; + } + return $calendar->nextOpenDay($ymd, self::branchOfCopy($db, $copiaId)); + } + + /** + * The pickup deadline: $days OPEN days after $from, never past $capAt (the + * loan's due date). With no closures this is exactly $from + $days. + */ + public static function pickupDeadline(\mysqli $db, string $from, int $days, ?string $capAt = null, ?int $copiaId = null): string + { + $days = max(0, $days); + $calendar = self::forDb($db); + $deadline = $calendar->hasRules() + ? $calendar->addOpenDays($from, $days, self::branchOfCopy($db, $copiaId)) + : self::shift($from, $days); + if ($capAt !== null && $capAt !== '' && $deadline > $capAt) { + $deadline = $capAt; + } + return $deadline; + } + + /** + * After a calendar change: a pickup still waiting whose deadline now falls + * on a closed day (of the copy's branch) lasts until the next open day, + * because the reader never had that day to come in. The new deadline is + * stored in prestiti.pickup_deadline, so every check that reads it (pickup + * confirmation, the reader's cancellation, the expiry sweep, the expired + * badges) applies the same rule. A deadline whose next open day is already + * past is left to the expiry sweep. + * + * The deadline never passes the loan's due date (prestiti.data_scadenza): + * pickupDeadline() caps it there on creation, and confirmPickup() refuses + * a pickup after the loan window, so an extension past it would promise a + * day the desk cannot honour. The due date of an existing loan is not + * moved here; when the cap leaves nothing to add the row is left alone. + * Returns the number of pickups moved. + */ + public static function extendPickupDeadlines(\mysqli $db): int + { + $calendar = self::forDb($db); + if (!$calendar->hasRules()) { + return 0; + } + $today = DateHelper::today(); + $result = $db->query( + "SELECT p.id, p.pickup_deadline, p.data_scadenza, c.sede_id + FROM prestiti p + LEFT JOIN copie c ON c.id = p.copia_id + WHERE p.stato = 'da_ritirare' AND p.attivo = 1 AND p.pickup_deadline IS NOT NULL" + ); + if (!$result instanceof \mysqli_result) { + return 0; + } + $rows = $result->fetch_all(MYSQLI_ASSOC); + $result->free(); + + $moved = 0; + $stmt = $db->prepare( + "UPDATE prestiti SET pickup_deadline = ? + WHERE id = ? AND stato = 'da_ritirare' AND attivo = 1 AND pickup_deadline = ?" + ); + foreach ($rows as $row) { + $deadline = substr((string) $row['pickup_deadline'], 0, 10); + $next = $calendar->nextOpenDay($deadline, $row['sede_id'] !== null ? (int) $row['sede_id'] : null); + $dueDate = $row['data_scadenza'] !== null ? substr((string) $row['data_scadenza'], 0, 10) : ''; + if (DateHelper::isISODateFormat($dueDate) && $next > $dueDate) { + $next = $dueDate; + } + if ($next <= $deadline || $next < $today) { + continue; + } + $id = (int) $row['id']; + $stmt->bind_param('sis', $next, $id, $deadline); + $stmt->execute(); + $moved += max(0, $stmt->affected_rows); + } + $stmt->close(); + return $moved; + } + + // ── Queries ───────────────────────────────────────────────────────────── + + /** True when a weekly closed day or a closure row is configured. */ + public function hasRules(): bool + { + return $this->closedWeekdays !== [] || $this->closures !== []; + } + + /** + * Whether the library is open on $ymd. A branch-specific closure counts + * only when that branch is asked for; a closure without a branch counts + * for every branch and for the library as a whole ($sedeId = null). + */ + public function isOpen(string $ymd, ?int $sedeId = null): bool + { + if (!DateHelper::isISODateFormat($ymd)) { + return true; + } + if ($this->closedWeekdays !== [] && isset($this->closedWeekdays[self::weekday($ymd)])) { + return false; + } + $monthDay = substr($ymd, 5); + foreach ($this->closures as $closure) { + if ($closure['sede'] !== null && $closure['sede'] !== $sedeId) { + continue; + } + if ($closure['yearly']) { + if (self::recurringCovers($closure['start'], $closure['end'], $ymd, $monthDay)) { + return false; + } + } elseif ($ymd >= $closure['start'] && $ymd <= $closure['end']) { + return false; + } + } + return true; + } + + /** + * $ymd itself when open, otherwise the first open day after it. A day + * inside a one-off closure jumps to the day after that closure; the + * scan bound only applies to closed weekdays and yearly closures. With + * every day closed (two yearly closures covering the year) $ymd is + * returned after the bound, as before. + */ + public function nextOpenDay(string $ymd, ?int $sedeId = null): string + { + if (!DateHelper::isISODateFormat($ymd)) { + return $ymd; + } + $day = $ymd; + for ($i = 0; $i < self::MAX_SCAN_DAYS; $i++) { + if ($this->isOpen($day, $sedeId)) { + return $day; + } + $day = self::shift($this->closedThrough($day, $sedeId) ?? $day, 1); + } + return $ymd; + } + + /** Same as nextOpenDay(): the name the loan code reads best with. */ + public function shiftForward(string $ymd, ?int $sedeId = null): string + { + return $this->nextOpenDay($ymd, $sedeId); + } + + /** + * $ymd itself when open, otherwise the last open day before it: the + * mirror of nextOpenDay(), jumping to the day before a one-off closure. + */ + public function previousOpenDay(string $ymd, ?int $sedeId = null): string + { + if (!DateHelper::isISODateFormat($ymd)) { + return $ymd; + } + $day = $ymd; + for ($i = 0; $i < self::MAX_SCAN_DAYS; $i++) { + if ($this->isOpen($day, $sedeId)) { + return $day; + } + $day = self::shift($this->closedFrom($day, $sedeId) ?? $day, -1); + } + return $ymd; + } + + /** + * The day $n open days after $from ($from itself excluded). A one-off + * closure on the way is stepped over whole; after the scan bound (every + * day closed) the plain $from + $n is returned, as before. + */ + public function addOpenDays(string $from, int $n, ?int $sedeId = null): string + { + if (!DateHelper::isISODateFormat($from) || $n <= 0) { + return $from; + } + $day = $from; + $counted = 0; + for ($i = 0; $i < self::MAX_SCAN_DAYS && $counted < $n; $i++) { + $day = self::shift($day, 1); + if ($this->isOpen($day, $sedeId)) { + $counted++; + } else { + $day = $this->closedThrough($day, $sedeId) ?? $day; + } + } + return $counted < $n ? self::shift($from, $n) : $day; + } + + /** + * Closed days in [$from, $to], both included, for the request calendars. + * Listed day by day, so the list stops MAX_SCAN_DAYS days after $from: + * the callers ask for a month or a 400-day horizon, never a span that + * long, and a ten-year closure is not expanded into ten years of dates. + * + * @return list + */ + public function closedDatesBetween(string $from, string $to, ?int $sedeId = null): array + { + if (!$this->hasRules() || !DateHelper::isISODateFormat($from) || !DateHelper::isISODateFormat($to) || $to < $from) { + return []; + } + $closed = []; + $day = $from; + for ($i = 0; $i < self::MAX_SCAN_DAYS && $day <= $to; $i++) { + if (!$this->isOpen($day, $sedeId)) { + $closed[] = $day; + } + $day = self::shift($day, 1); + } + return $closed; + } + + /** + * The last day of the one-off closures of $sedeId (or of every branch) + * that contain $ymd, the latest when several overlap; null when none + * does (the day is closed by a weekday or a yearly closure, or open). + * Yearly closures are not considered: they are shorter than a year and + * a scan walks them day by day, within its bound. + */ + private function closedThrough(string $ymd, ?int $sedeId): ?string + { + $through = null; + foreach ($this->closures as $closure) { + if ($closure['yearly'] || ($closure['sede'] !== null && $closure['sede'] !== $sedeId)) { + continue; + } + if ($ymd >= $closure['start'] && $ymd <= $closure['end'] && ($through === null || $closure['end'] > $through)) { + $through = $closure['end']; + } + } + return $through; + } + + /** The first day of the one-off closures containing $ymd (see closedThrough()). */ + private function closedFrom(string $ymd, ?int $sedeId): ?string + { + $from = null; + foreach ($this->closures as $closure) { + if ($closure['yearly'] || ($closure['sede'] !== null && $closure['sede'] !== $sedeId)) { + continue; + } + if ($ymd >= $closure['start'] && $ymd <= $closure['end'] && ($from === null || $closure['start'] < $from)) { + $from = $closure['start']; + } + } + return $from; + } + + /** + * Open days after $from up to and including $to: the days a late loan + * counts when closed days do not (fines). 0 when $to <= $from. + */ + public function openDaysBetween(string $from, string $to, ?int $sedeId = null): int + { + if (!DateHelper::isISODateFormat($from) || !DateHelper::isISODateFormat($to) || $to <= $from) { + return 0; + } + if (!$this->hasRules()) { + return (int) (new \DateTimeImmutable($from, new \DateTimeZone('UTC'))) + ->diff(new \DateTimeImmutable($to, new \DateTimeZone('UTC')))->days; + } + $count = 0; + $day = $from; + while (true) { + $day = self::shift($day, 1); + if ($day > $to) { + break; + } + if ($this->isOpen($day, $sedeId)) { + $count++; + } + } + return $count; + } + + /** @return list */ + public function closedWeekdays(): array + { + $days = array_keys($this->closedWeekdays); + sort($days); + return $days; + } + + // ── Presets and import ────────────────────────────────────────────────── + + /** + * Easter Sunday (Gregorian) with the anonymous Meeus/Jones/Butcher + * algorithm: no dependency on the `calendar` extension. + */ + public static function easterSunday(int $year): string + { + $a = $year % 19; + $b = intdiv($year, 100); + $c = $year % 100; + $d = intdiv($b, 4); + $e = $b % 4; + $f = intdiv($b + 8, 25); + $g = intdiv($b - $f + 1, 3); + $h = (19 * $a + $b - $d - $g + 15) % 30; + $i = intdiv($c, 4); + $k = $c % 4; + $l = (32 + 2 * $e + 2 * $i - $h - $k) % 7; + $m = intdiv($a + 11 * $h + 22 * $l, 451); + $month = intdiv($h + $l - 7 * $m + 114, 31); + $day = (($h + $l - 7 * $m + 114) % 31) + 1; + return sprintf('%04d-%02d-%02d', $year, $month, $day); + } + + /** + * Italian national holidays for $year. Fixed dates recur every year; + * Easter and Easter Monday move, so they are dated for that year only. + * + * @return list + */ + public static function italianHolidays(int $year): array + { + $fixed = [ + '01-01' => __('Capodanno'), + '01-06' => __('Epifania'), + '04-25' => __('Festa della Liberazione'), + '05-01' => __('Festa del Lavoro'), + '06-02' => __('Festa della Repubblica'), + '08-15' => __('Ferragosto'), + '11-01' => __('Ognissanti'), + '12-08' => __('Immacolata Concezione'), + '12-25' => __('Natale'), + '12-26' => __('Santo Stefano'), + ]; + $holidays = []; + foreach ($fixed as $monthDay => $label) { + $date = sprintf('%04d-%s', $year, $monthDay); + $holidays[] = ['start' => $date, 'end' => $date, 'description' => $label, 'yearly' => true]; + } + $easter = self::easterSunday($year); + $holidays[] = ['start' => $easter, 'end' => $easter, 'description' => __('Pasqua'), 'yearly' => false]; + $monday = self::shift($easter, 1); + $holidays[] = ['start' => $monday, 'end' => $monday, 'description' => __("Lunedì dell'Angelo (Pasquetta)"), 'yearly' => false]; + usort($holidays, static fn (array $x, array $y): int => strcmp($x['start'], $y['start'])); + return $holidays; + } + + /** + * Closed intervals from an iCalendar file: every VEVENT with a DTSTART. + * DTEND is exclusive (RFC 5545): for an all-day event the last closed day + * is the day before it, and so it is for a timed DTEND at midnight (local + * time) after DTSTART. A plain RRULE:FREQ=YEARLY makes the row recurring; + * any other recurrence (a bounded one with COUNT/UNTIL, an interval of + * more than a year, a positional one with BYDAY/BYSETPOS, a BYMONTH or + * BYMONTHDAY that is not DTSTART's…) is imported as its first occurrence + * only. So is a yearly event with an EXDATE: a stored closure cannot skip + * a year. An event whose DTSTART is itself excluded has no occurrence to + * import and is left out. A UTC DATE-TIME (trailing Z) and one with a + * TZID parameter are read in $timezone (app.timezone by default) before + * their day is taken. The spans are not validated here: the import + * applies the same limits as the form. + * + * @return list + */ + public static function parseIcs(string $ics, int $maxEvents = 1000, ?string $timezone = null): array + { + $timezone ??= self::appTimezone(); + // Unfold continuation lines (RFC 5545 §3.1). + $ics = str_replace(["\r\n", "\r"], "\n", $ics); + $ics = preg_replace("/\n[ \t]/", '', $ics) ?? $ics; + $events = []; + $current = null; + foreach (explode("\n", $ics) as $line) { + $line = rtrim($line); + if (strcasecmp($line, 'BEGIN:VEVENT') === 0) { + $current = ['start' => null, 'startKey' => null, 'end' => null, 'endIsDate' => false, 'endAtMidnight' => false, 'summary' => '', 'rrule' => null, 'exdates' => []]; + continue; + } + if ($current === null) { + continue; + } + if (strcasecmp($line, 'END:VEVENT') === 0) { + // RRULE and EXDATE may come before DTSTART: judged at the end. + // An EXDATE removes the occurrence with DTSTART's date and + // time (RFC 5545): another time the same day leaves it. + if ($current['start'] !== null && !isset($current['exdates'][$current['startKey']])) { + $start = $current['start']; + $end = $current['end']; + if ($end === null) { + $end = $start; + } elseif ($current['endIsDate'] || ($current['endAtMidnight'] && $end > $start)) { + $end = self::shift($end, -1); + } + if ($end < $start) { + $end = $start; + } + $events[] = [ + 'start' => $start, + 'end' => $end, + 'description' => mb_substr(trim($current['summary']), 0, 255), + 'yearly' => $current['rrule'] !== null && $current['exdates'] === [] && self::isPlainYearlyRule($current['rrule'], $start), + ]; + if (count($events) >= $maxEvents) { + break; + } + } + $current = null; + continue; + } + $colon = strpos($line, ':'); + if ($colon === false) { + continue; + } + $rawParams = substr($line, 0, $colon); + $nameAndParams = strtoupper($rawParams); + $value = substr($line, $colon + 1); + $name = explode(';', $nameAndParams)[0]; + if ($name === 'DTSTART' || $name === 'DTEND') { + $tzid = preg_match('/;TZID=("?)([^;:"]+)\1/i', $rawParams, $tz) === 1 ? $tz[2] : null; + $parsed = self::icsDateTime($value, $timezone, $tzid); + if ($parsed === null) { + continue; + } + $date = $parsed['date']; + $isDate = (str_contains($nameAndParams, 'VALUE=DATE') && !str_contains($nameAndParams, 'VALUE=DATE-TIME')) + || preg_match('/^\d{8}$/', trim($value)) === 1; + if ($name === 'DTSTART') { + $current['start'] = $date; + $current['startKey'] = $parsed['key']; + } else { + $current['end'] = $date; + $current['endIsDate'] = $isDate; + $current['endAtMidnight'] = !$isDate && $parsed['midnight']; + } + } elseif ($name === 'SUMMARY') { + $current['summary'] = str_replace(['\\,', '\\;', '\\n', '\\N', '\\\\'], [',', ';', ' ', ' ', '\\'], $value); + } elseif ($name === 'RRULE') { + $current['rrule'] = $value; + } elseif ($name === 'EXDATE') { + $tzid = preg_match('/;TZID=("?)([^;:"]+)\1/i', $rawParams, $tz) === 1 ? $tz[2] : null; + foreach (explode(',', $value) as $excluded) { + $parsed = self::icsDateTime($excluded, $timezone, $tzid); + if ($parsed !== null) { + $current['exdates'][$parsed['key']] = true; + } + } + } + } + return $events; + } + + // ── Internals ─────────────────────────────────────────────────────────── + + /** Y-m-d plus $days, in UTC (no DST: a day is a day). */ + public static function shift(string $ymd, int $days): string + { + $date = \DateTimeImmutable::createFromFormat('!Y-m-d', $ymd, new \DateTimeZone('UTC')); + if ($date === false) { + return $ymd; + } + return $days === 0 ? $date->format('Y-m-d') : $date->modify(($days > 0 ? '+' : '') . $days . ' days')->format('Y-m-d'); + } + + private static function weekday(string $ymd): int + { + $date = \DateTimeImmutable::createFromFormat('!Y-m-d', $ymd, new \DateTimeZone('UTC')); + return $date === false ? -1 : (int) $date->format('w'); + } + + /** Days between two Y-m-d dates (0 for the same day); -1 when either is not a date. */ + public static function spanDays(string $start, string $end): int + { + $from = \DateTimeImmutable::createFromFormat('!Y-m-d', $start, new \DateTimeZone('UTC')); + $to = \DateTimeImmutable::createFromFormat('!Y-m-d', $end, new \DateTimeZone('UTC')); + if ($from === false || $to === false) { + return -1; + } + return (int) $from->diff($to)->days; + } + + /** + * A yearly closure repeats its month-day span every year. A span that + * crosses New Year (12-24 → 01-06) wraps. The constructor keeps only + * spans shorter than a year recurring, so $ymd is closed only when its + * month-day falls inside the span: no span closes every day. + */ + private static function recurringCovers(string $start, string $end, string $ymd, string $monthDay): bool + { + $from = substr($start, 5); + $to = substr($end, 5); + if ($to < $from) { + return $monthDay >= $from || $monthDay <= $to; // wraps over New Year + } + return $monthDay >= $from && $monthDay <= $to; + } + + /** + * True for a rule that repeats the same month-day span every year with + * no end: FREQ=YEARLY, INTERVAL 1 (or absent), no COUNT/UNTIL, no + * positional or weekday part. BYMONTH/BYMONTHDAY are accepted only as the + * restatement of DTSTART ($start, Y-m-d) that calendar apps write: any + * other value names a different day than the one the row would close. + */ + private static function isPlainYearlyRule(string $rule, string $start): bool + { + $parts = []; + foreach (explode(';', strtoupper(trim($rule))) as $part) { + if ($part === '') { + continue; + } + [$key, $val] = array_pad(explode('=', $part, 2), 2, ''); + $parts[$key] = $val; + } + if (($parts['FREQ'] ?? '') !== 'YEARLY') { + return false; + } + if (isset($parts['INTERVAL']) && $parts['INTERVAL'] !== '1') { + return false; + } + foreach (['COUNT', 'UNTIL', 'BYDAY', 'BYSETPOS', 'BYWEEKNO', 'BYYEARDAY', 'BYHOUR', 'BYMINUTE', 'BYSECOND'] as $bounded) { + if (isset($parts[$bounded])) { + return false; + } + } + // Several months or month-days would be several closures, not one. + foreach (['BYMONTH', 'BYMONTHDAY'] as $list) { + if (isset($parts[$list]) && str_contains($parts[$list], ',')) { + return false; + } + } + if (isset($parts['BYMONTH']) && (!ctype_digit($parts['BYMONTH']) || (int) $parts['BYMONTH'] !== (int) substr($start, 5, 2))) { + return false; + } + if (isset($parts['BYMONTHDAY']) && (!ctype_digit($parts['BYMONTHDAY']) || (int) $parts['BYMONTHDAY'] !== (int) substr($start, 8, 2))) { + return false; + } + return true; + } + + /** + * The day of an iCalendar DATE or DATE-TIME, whether that local time is + * midnight, and the local date and time as one key (the date alone for a + * DATE) that matches an EXDATE to its DTSTART. A DATE and a floating + * DATE-TIME are taken as written; a UTC DATE-TIME (trailing Z) or one with + * a valid TZID is converted to $timezone first, so 20260410T230000Z in + * Europe/Rome is 2026-04-11. An unknown TZID is read as floating. Anything + * after the date or time makes the value invalid. + * + * @return array{date: string, midnight: bool, key: string}|null + */ + private static function icsDateTime(string $value, string $timezone, ?string $tzid = null): ?array + { + $value = trim($value); + if (preg_match('/^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})?(Z?))?$/i', $value, $m) !== 1) { + return null; + } + $date = "{$m[1]}-{$m[2]}-{$m[3]}"; + if (!DateHelper::isISODateFormat($date)) { + return null; + } + if (($m[4] ?? '') === '') { + return ['date' => $date, 'midnight' => true, 'key' => $date]; + } + $time = sprintf('%s:%s:%s', $m[4], $m[5], ($m[6] ?? '') !== '' ? $m[6] : '00'); + $source = null; + if (($m[7] ?? '') !== '') { + $source = 'UTC'; + } elseif ($tzid !== null && $tzid !== '') { + $source = $tzid; + } + if ($source !== null) { + try { + $local = (new \DateTimeImmutable($date . 'T' . $time, new \DateTimeZone($source))) + ->setTimezone(new \DateTimeZone($timezone)); + return ['date' => $local->format('Y-m-d'), 'midnight' => $local->format('H:i:s') === '00:00:00', 'key' => $local->format('Y-m-d\TH:i:s')]; + } catch (\Throwable $e) { + // An unknown TZID: the time as written. + } + } + return ['date' => $date, 'midnight' => $time === '00:00:00', 'key' => $date . 'T' . $time]; + } + + /** app.timezone, with the same fallback as DateHelper::today(). */ + private static function appTimezone(): string + { + try { + $tz = (string) ConfigStore::get('app.timezone', 'Europe/Rome'); + new \DateTimeZone($tz); + return $tz; + } catch (\Throwable $e) { + return 'Europe/Rome'; + } + } + + /** + * @return list + */ + public static function parseWeekdays(?string $raw): array + { + $days = []; + foreach (explode(',', (string) $raw) as $part) { + $part = trim($part); + if ($part !== '' && ctype_digit($part) && (int) $part <= 6) { + $days[(int) $part] = (int) $part; + } + } + ksort($days); + return array_values($days); + } + + /** + * Thrown like loadClosures(), never read as "no weekly closure": the + * calendar would be cached without it and a due date stored on a closed + * weekday. + * + * @return list + */ + private static function loadClosedWeekdays(\mysqli $db): array + { + try { + return self::parseWeekdays((new SettingsRepository($db))->get('loans', 'closed_weekdays', '')); + } catch (\Throwable $e) { + SecureLogger::warning('LibraryCalendar: closed weekdays not readable: ' . $e->getMessage()); + throw new \RuntimeException('LibraryCalendar: closed weekdays not readable', 0, $e); + } + } + + /** + * Before the 0.9.0 migration ran the table does not exist: no closures. + * Any other failure is thrown, never read as "no closures": an empty + * calendar would let the loan write that asked for it store a due date + * on a closed day. forDb() caches nothing for a failed load. + * + * @return list> + */ + private static function loadClosures(\mysqli $db): array + { + try { + $exists = $db->query("SHOW TABLES LIKE 'calendario_chiusure'"); + if ($exists === false) { + throw new \RuntimeException('table lookup failed'); + } + if ($exists->num_rows === 0) { + return []; + } + $result = $db->query('SELECT data_inizio, data_fine, ricorrente_annuale, sede_id FROM calendario_chiusure'); + if ($result === false) { + throw new \RuntimeException('closures query failed'); + } + return $result->fetch_all(MYSQLI_ASSOC); + } catch (\Throwable $e) { + SecureLogger::warning('LibraryCalendar: closures not readable: ' . $e->getMessage()); + throw new \RuntimeException('LibraryCalendar: closures not readable', 0, $e); + } + } + + /** + * The branch (copie.sede_id) of a copy, null when the copy has none or + * does not exist. A lookup that fails is thrown, not read as "no branch": + * the branch's closures would be skipped. One query per copy and + * connection: the answer is kept on the forDb() calendar, which reset() + * discards with it. + */ + public static function branchOfCopy(\mysqli $db, ?int $copiaId): ?int + { + if ($copiaId === null || $copiaId <= 0) { + return null; + } + $calendar = self::forDb($db); + if (array_key_exists($copiaId, $calendar->copyBranches)) { + return $calendar->copyBranches[$copiaId]; + } + return $calendar->copyBranches[$copiaId] = self::queryBranchOfCopy($db, $copiaId); + } + + private static function queryBranchOfCopy(\mysqli $db, int $copiaId): ?int + { + try { + $stmt = $db->prepare('SELECT sede_id FROM copie WHERE id = ?'); + if ($stmt === false) { + throw new \RuntimeException('copy branch query not prepared'); + } + $stmt->bind_param('i', $copiaId); + $stmt->execute(); + $result = $stmt->get_result(); + if ($result === false) { + throw new \RuntimeException('copy branch query failed'); + } + $row = $result->fetch_row(); + $stmt->close(); + return ($row === null || $row[0] === null) ? null : (int) $row[0]; + } catch (\Throwable $e) { + SecureLogger::warning('LibraryCalendar: copy branch not readable: ' . $e->getMessage()); + throw new \RuntimeException('LibraryCalendar: copy branch not readable', 0, $e); + } + } +} diff --git a/app/Support/LoanWindow.php b/app/Support/LoanWindow.php index 33b55a942..0987af5a5 100644 --- a/app/Support/LoanWindow.php +++ b/app/Support/LoanWindow.php @@ -133,10 +133,17 @@ public static function explainFromParams(array $params): ?string * later commitment on the loan's own copy. * @param string|null $extensionBase Bulk extensions of overdue loans count * their days from today, not the past due date. + * @param LibraryCalendar|null $calendar The closures calendar: the proposed + * due date must be an open day, and a due date moved forward past a + * closure would run into the conflict, so it steps back to the last + * open day before the first blocked one. + * @param int|null $sedeId The branch of the loan's copy: renewals move due + * dates forward with its closures too, so the step back uses them as + * well (otherwise "renew until D" could move D onto the conflict). * @return array{first_conflict_date: string, max_due_date: string, max_extension_days: int}|null * null when nothing bounds the extension. */ - public static function renewalCap(string $currentDue, array $conflictDates, ?string $extensionBase = null): ?array + public static function renewalCap(string $currentDue, array $conflictDates, ?string $extensionBase = null, ?LibraryCalendar $calendar = null, ?int $sedeId = null): ?array { $future = array_filter($conflictDates, static fn (string $d): bool => $d > $currentDue); if ($future === []) { @@ -144,6 +151,9 @@ public static function renewalCap(string $currentDue, array $conflictDates, ?str } $firstConflict = min($future); $maxDue = (new DateTime($firstConflict))->modify('-1 day')->format('Y-m-d'); + if ($calendar !== null && $calendar->hasRules()) { + $maxDue = $calendar->previousOpenDay($maxDue, $sedeId); + } $base = max($currentDue, $extensionBase ?? $currentDue); // An existing conflict before today's bulk-extension base leaves no // extension possible; never propose a due date in the past. @@ -185,9 +195,9 @@ public static function explainRenewal(int $maxDays, string $maxDueDate, string $ * @param string[] $conflictDates * @return array{max_extension_days?: int, max_due_date?: string, blocked_from?: string} */ - public static function renewalRedirectParams(string $currentDue, array $conflictDates, ?string $extensionBase = null): array + public static function renewalRedirectParams(string $currentDue, array $conflictDates, ?string $extensionBase = null, ?LibraryCalendar $calendar = null, ?int $sedeId = null): array { - $cap = self::renewalCap($currentDue, $conflictDates, $extensionBase); + $cap = self::renewalCap($currentDue, $conflictDates, $extensionBase, $calendar, $sedeId); if ($cap === null) { return []; } diff --git a/app/Support/MaintenanceService.php b/app/Support/MaintenanceService.php index bcbc5b9e0..68ed717cc 100644 --- a/app/Support/MaintenanceService.php +++ b/app/Support/MaintenanceService.php @@ -1003,7 +1003,8 @@ public function activateScheduledLoans(): int // data_scadenza (L1): senza il cap un prestito con finestra corta // restava ritirabile (e la copia bloccata) oltre la fine del // prestito stesso. - $pickupDeadline = date('Y-m-d', strtotime($today . " +{$pickupDays} days")); + // Open days only (closures calendar); identical to +N days without closures. + $pickupDeadline = LibraryCalendar::pickupDeadline($this->db, $today, $pickupDays, null, $copiaId); if (!empty($loan['data_scadenza']) && $loan['data_scadenza'] < $pickupDeadline) { $pickupDeadline = $loan['data_scadenza']; } @@ -1204,6 +1205,10 @@ public function processScheduledReservations(): int public function checkExpiredReservations(): int { $today = DateHelper::today(); + // Closures calendar: nothing expires on a day the library is closed. + if (!LibraryCalendar::forDb($this->db)->isOpen($today)) { + return 0; + } // Find expired reservations. Oltre alle conversioni promosse // (origine='prenotazione'), anche le richieste dirette e NCIP mai @@ -1282,6 +1287,12 @@ public function checkExpiredReservations(): int continue; } $copiaId = $lockedReservation['copia_id'] ? (int) $lockedReservation['copia_id'] : null; + // A closure of the assigned copy's branch counts too: the + // reader could not come in there today either. + if ($copiaId !== null && !LibraryCalendar::forDb($this->db)->isOpen($today, LibraryCalendar::branchOfCopy($this->db, $copiaId))) { + $this->db->rollback(); + continue; + } // Audit: snapshot pre-mutazione, l'evento è registrato DENTRO la // stessa transazione dell'UPDATE (regola di progetto). @@ -1417,10 +1428,15 @@ public function checkExpiredReservations(): int public function checkExpiredPickups(): int { $today = DateHelper::today(); + // Closures calendar: nothing expires on a day the library is closed. + $calendar = LibraryCalendar::forDb($this->db); + if (!$calendar->isOpen($today)) { + return 0; + } // Find expired pickups (da_ritirare with pickup_deadline passed) $stmt = $this->db->prepare(" - SELECT id, libro_id, copia_id, utente_id + SELECT id, libro_id, copia_id, utente_id, pickup_deadline FROM prestiti WHERE stato = 'da_ritirare' AND attivo = 1 @@ -1437,6 +1453,10 @@ public function checkExpiredPickups(): int $result = $stmt->get_result(); $expiredPickups = $result ? $result->fetch_all(MYSQLI_ASSOC) : []; $stmt->close(); + // A deadline that fell on a day later declared closed was already moved + // to the next open day when the calendar changed + // (LibraryCalendar::extendPickupDeadlines()), so the stored deadline is + // the one rule every path reads. $expiredCount = 0; $integrity = new DataIntegrity($this->db); @@ -1483,6 +1503,12 @@ public function checkExpiredPickups(): int continue; } $copiaId = $lockedPickup['copia_id'] ? (int) $lockedPickup['copia_id'] : null; + // A closure of the copy's branch today: the pickup cannot + // expire on a day the reader could not come in. + if ($copiaId !== null && !$calendar->isOpen($today, LibraryCalendar::branchOfCopy($this->db, $copiaId))) { + $this->db->rollback(); + continue; + } // Audit: snapshot pre-mutazione, l'evento è registrato DENTRO la // stessa transazione dell'UPDATE (regola di progetto). diff --git a/app/Support/NotificationService.php b/app/Support/NotificationService.php index ae1071292..bd1d4195f 100644 --- a/app/Support/NotificationService.php +++ b/app/Support/NotificationService.php @@ -66,15 +66,20 @@ private function pickupExpiryDays(): int * the pickup window to the deadline actually set. A loan shorter than the * window caps the deadline at its end, and the staff may move it: in both * cases the configured number would promise days the reader does not have. + * $copiaId applies that copy's branch closures, as pickupDeadline() does. */ - private function effectivePickupDays(?string $windowStart, ?string $deadline): string + private function effectivePickupDays(?string $windowStart, ?string $deadline, ?int $copiaId = null): string { if ($windowStart !== null && $windowStart !== '' && $deadline !== null && $deadline !== '') { try { $start = new \DateTimeImmutable(substr($windowStart, 0, 10)); $end = new \DateTimeImmutable(substr($deadline, 0, 10)); if ($end >= $start) { - return (string) min($this->pickupExpiryDays(), (int) $start->diff($end)->days); + // Open days only (closures calendar): a closed day inside + // the window is not a day to collect the book; calendar + // days without closures. + $openDays = LibraryCalendar::forDb($this->db)->openDaysBetween($start->format('Y-m-d'), $end->format('Y-m-d'), LibraryCalendar::branchOfCopy($this->db, $copiaId)); + return (string) min($this->pickupExpiryDays(), $openDays); } } catch (\Throwable) { } @@ -97,10 +102,11 @@ private function pickupReminderDays(): int * MaintenanceService::activateScheduledLoans() applies: start + pickup days, * never past the end of the loan. Lets the approval email name it up front. */ - private function expectedPickupDeadline(string $start, string $end): string + private function expectedPickupDeadline(string $start, string $end, ?int $copiaId = null): string { - $deadline = (new \DateTimeImmutable($start))->modify('+' . $this->pickupExpiryDays() . ' days')->format('Y-m-d'); - return ($end !== '' && $end < $deadline) ? $end : $deadline; + // Open days only (closures calendar, with the copy's branch), as + // activateScheduledLoans() computes it. + return LibraryCalendar::pickupDeadline($this->db, $start, $this->pickupExpiryDays(), $end !== '' ? $end : null, $copiaId); } /** @@ -961,7 +967,7 @@ public function sendPickupReminders(): int { $openedBefore = (new \DateTimeImmutable($today))->modify('+' . $this->pickupExpiryDays() . ' days')->format('Y-m-d'); $eligibleSql = " - SELECT p.id, p.utente_id, p.pickup_deadline, p.data_prestito, p.data_scadenza, + SELECT p.id, p.utente_id, p.copia_id, p.pickup_deadline, p.data_prestito, p.data_scadenza, l.titolo AS libro_titolo, CONCAT(u.nome, ' ', u.cognome) AS utente_nome, u.email AS utente_email FROM prestiti p @@ -1023,7 +1029,7 @@ public function sendPickupReminders(): int { 'libro_titolo' => $loan['libro_titolo'], 'data_inizio' => $this->formatEmailDate((string) $loan['data_prestito'], false, $recipientLocale), 'data_fine' => $this->formatEmailDate((string) $loan['data_scadenza'], false, $recipientLocale), - 'giorni_ritiro' => $this->effectivePickupDays((string) $loan['data_prestito'], $deadline), + 'giorni_ritiro' => $this->effectivePickupDays((string) $loan['data_prestito'], $deadline, !empty($loan['copia_id']) ? (int) $loan['copia_id'] : null), 'giorni_rimasti' => $daysLeft === 0 ? $todayLabels[$recipientLocale] : (string) $daysLeft, 'scadenza_ritiro' => $formattedDeadline, 'pickup_deadline' => $formattedDeadline, @@ -1922,9 +1928,9 @@ public function sendLoanApprovedNotification(int $loanId): bool { 'giorni_prestito' => $days, // A loan starting later gets its pickup deadline only on the start // day; say now which one it will be and how many days that is. - 'giorni_ritiro' => $this->effectivePickupDays((string) $loan['data_prestito'], $this->expectedPickupDeadline((string) $loan['data_prestito'], (string) $loan['data_scadenza'])), - 'scadenza_ritiro' => $this->formatEmailDate($this->expectedPickupDeadline((string) $loan['data_prestito'], (string) $loan['data_scadenza']), false, $recipientLocale), - 'pickup_deadline' => $this->formatEmailDate($this->expectedPickupDeadline((string) $loan['data_prestito'], (string) $loan['data_scadenza']), false, $recipientLocale), + 'giorni_ritiro' => $this->effectivePickupDays((string) $loan['data_prestito'], $this->expectedPickupDeadline((string) $loan['data_prestito'], (string) $loan['data_scadenza'], !empty($loan['copia_id']) ? (int) $loan['copia_id'] : null), !empty($loan['copia_id']) ? (int) $loan['copia_id'] : null), + 'scadenza_ritiro' => $this->formatEmailDate($this->expectedPickupDeadline((string) $loan['data_prestito'], (string) $loan['data_scadenza'], !empty($loan['copia_id']) ? (int) $loan['copia_id'] : null), false, $recipientLocale), + 'pickup_deadline' => $this->formatEmailDate($this->expectedPickupDeadline((string) $loan['data_prestito'], (string) $loan['data_scadenza'], !empty($loan['copia_id']) ? (int) $loan['copia_id'] : null), false, $recipientLocale), 'pickup_instructions' => $this->translateInLocale('Recati in biblioteca durante gli orari di apertura per ritirare il libro.', $recipientLocale), 'sezione_calendario' => $this->buildCalendarSection((int)$loan['id'], (string)$loan['libro_titolo'], (string)$loan['data_prestito'], (string)$loan['data_scadenza'], $recipientLocale) ]; @@ -2105,7 +2111,7 @@ public function sendPickupReadyNotification(int $loanId): bool { 'data_inizio' => $this->formatEmailDate($loan['data_prestito'], false, $recipientLocale), 'data_fine' => $this->formatEmailDate($loan['data_scadenza'], false, $recipientLocale), 'giorni_prestito' => $days, - 'giorni_ritiro' => $this->effectivePickupDays((string) $loan['data_prestito'], $loan['pickup_deadline'] ? (string) $loan['pickup_deadline'] : null), + 'giorni_ritiro' => $this->effectivePickupDays((string) $loan['data_prestito'], $loan['pickup_deadline'] ? (string) $loan['pickup_deadline'] : null, !empty($loan['copia_id']) ? (int) $loan['copia_id'] : null), 'scadenza_ritiro' => $loan['pickup_deadline'] ? $this->formatEmailDate($loan['pickup_deadline'], false, $recipientLocale) : '', // #304: alias under the DB column name so a customised template using // {{pickup_deadline}} (the natural name a user copies from the schema) @@ -2289,7 +2295,7 @@ public function sendPickupExpiredNotification(int $loanId, ?string $pickupDeadli $variables = [ 'utente_nome' => $loan['utente_nome'], 'libro_titolo' => $loan['libro_titolo'], - 'giorni_ritiro' => $this->effectivePickupDays((string) ($loan['data_prestito'] ?? ''), $deadline ? (string) $deadline : null), + 'giorni_ritiro' => $this->effectivePickupDays((string) ($loan['data_prestito'] ?? ''), $deadline ? (string) $deadline : null, !empty($loan['copia_id']) ? (int) $loan['copia_id'] : null), 'scadenza_ritiro' => $deadline ? $this->formatEmailDate($deadline, false, $recipientLocale) : '', // #304: alias under the DB column name, see sendPickupReadyNotification. 'pickup_deadline' => $deadline ? $this->formatEmailDate($deadline, false, $recipientLocale) : '' @@ -2353,7 +2359,7 @@ public function sendPickupCancelledNotification( return $this->sendWithRetry($loan['utente_email'], 'loan_pickup_expired', [ 'utente_nome' => $loan['utente_nome'], 'libro_titolo' => $loan['libro_titolo'], - 'giorni_ritiro' => $this->effectivePickupDays((string) ($loan['data_prestito'] ?? ''), $effectiveDeadline ? (string) $effectiveDeadline : null), + 'giorni_ritiro' => $this->effectivePickupDays((string) ($loan['data_prestito'] ?? ''), $effectiveDeadline ? (string) $effectiveDeadline : null, !empty($loan['copia_id']) ? (int) $loan['copia_id'] : null), 'scadenza_ritiro' => $formattedDeadline, 'pickup_deadline' => $formattedDeadline, ]); diff --git a/app/Support/Permissions.php b/app/Support/Permissions.php index a846e766a..a073b63d0 100644 --- a/app/Support/Permissions.php +++ b/app/Support/Permissions.php @@ -124,7 +124,10 @@ public static function permissionForPath(string $path): ?string 'loans.manage' => ['/admin/loans', '/admin/attendance', '/admin/reservations', '/api/loans', '/api/reservations', '/api/prestiti', '/prestiti', '/api/v1/staff/loans', '/api/v1/staff/reservations'], 'users.manage' => ['/admin/users', '/api/users', '/api/utenti', '/api/admin/pending-registrations', '/api/admin/pending-registrations-count', '/api/v1/staff/users', '/api/search/utenti'], 'digital.manage' => ['/admin/plugins/digital-library/upload'], - 'settings.manage' => ['/admin/settings'], + // The closures calendar is a settings page: LibraryCalendarController + // admits Permissions::mayConfigure(). /admin/calendar/rotate-link is + // admin-only inside its route, whatever the map says. + 'settings.manage' => ['/admin/settings', '/admin/calendar'], 'inventory.manage' => ['/admin/inventory'], ]; foreach ($groups as $permission => $prefixes) { diff --git a/app/Views/admin/calendar.php b/app/Views/admin/calendar.php new file mode 100644 index 000000000..970f690ad --- /dev/null +++ b/app/Views/admin/calendar.php @@ -0,0 +1,369 @@ + $closedWeekdays + * @var list> $closures + * @var array|null $editing + * @var array $branches + * @var ?int $previewSede + * @var string $month + * @var string $prevMonth + * @var string $nextMonth + * @var array $monthClosed + * @var int $currentYear + * @var string $today + */ + +$csrf = \App\Support\Csrf::ensureToken(); +// Plain escaping: the stored text is shown exactly as saved. +$e = static fn ($v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); +$calLocale = \App\Support\I18n::getLocale(); +$fmt = static function (string $pattern, string $ymd) use ($calLocale): string { + if (class_exists('IntlDateFormatter')) { + $f = new \IntlDateFormatter($calLocale, \IntlDateFormatter::NONE, \IntlDateFormatter::NONE, 'UTC', null, $pattern); + $out = $f->format(new \DateTimeImmutable($ymd . ' 12:00:00', new \DateTimeZone('UTC'))); + if (is_string($out) && $out !== '') { + return $out; + } + } + return $ymd; +}; +$displayDate = static function (string $ymd): string { + return \App\Support\LoanWindow::displayDate($ymd); +}; +// 2026-11-01 is a Sunday: index 0 = Sunday … 6 = Saturday, as closed_weekdays. +$weekdayNames = []; +for ($w = 0; $w <= 6; $w++) { + $weekdayNames[$w] = mb_convert_case($fmt('EEEE', \App\Support\LibraryCalendar::shift('2026-11-01', $w)), MB_CASE_TITLE, 'UTF-8'); +} +$editingId = $editing !== null ? (int) $editing['id'] : 0; +$formAction = $editingId > 0 ? url('/admin/calendar/closures/' . $editingId . '/update') : url('/admin/calendar/closures'); +?> +
+
+

+ + + + +

+ + +
+ +
+
+

+ + +

+
+
+
+ +

+
+ + + +
+
+ +
+
+
+
+ + +
+
+

+ + 0 ? __("Modifica chiusura") : __("Aggiungi chiusura") ?> +

+ 0): ?> + + +
+
+
+ +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ +
+
+
+ +
+
+
+
+
+ +
+ +
+
+

+ + +

+
+
+
+ +

+
+ + +
+
+ +
+
+
+
+ + +
+
+

+ + +

+
+
+
+ +

+
+ + +
+
+ + +
+
+ +
+
+
+
+
+ + +
+
+

+ + +

+
+ + + + +
+ + +
+ +
+
+
+
+ +
+ +
+
+ format('N')) - 1; // Monday first + for ($i = 0; $i < $lead; $i++): ?> +
+ +
+ +
+ +
+
+ + +
+
+
+ + +
+
+

+ + () +

+
+
+ +
+ +

+

+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ – + + + + + + + +
+ + + +
+ + +
+
+
+
+ +
+
+

+
diff --git a/app/Views/frontend/book-detail.php b/app/Views/frontend/book-detail.php index f3a09d929..e0f662a15 100644 --- a/app/Views/frontend/book-detail.php +++ b/app/Views/frontend/book-detail.php @@ -1641,10 +1641,56 @@ function daysBetweenIso(fromIso, toIso) { // proposed return date is the default duration, or the day before the // conflict when that comes first (`capped`, which is what the note under // the field explains). - function loanWindowFrom(startIso, availabilityByDate, defaultDays, maxDays) { + // + // Closures calendar: a return date on a day the library is closed moves + // forward to the next open day (the server applies the same rule, also to + // the maximum), and a return date capped by a conflict steps back to the + // last open day before it. + // Each step lands on a different listed day, so the set's size bounds + // the walk: a closure longer than any fixed bound is crossed whole. + function toOpenDayIso(isoDate, step, closedSet) { + let day = isoDate; + for (let i = 0; closedSet && i <= closedSet.size && closedSet.has(day); i++) { + day = addDaysToIso(day, step); + } + return day; + } + + // The day `days` open days after `startIso` (the pickup deadline rule of + // LibraryCalendar::pickupDeadline); plain calendar days without closures. + function addOpenDaysIso(startIso, days, closedSet) { + if (!closedSet || closedSet.size === 0) return addDaysToIso(startIso, days); + let day = startIso; + let counted = 0; + for (let i = 0; i < 1100 && counted < days; i++) { + day = addDaysToIso(day, 1); + if (!closedSet.has(day)) counted++; + } + return counted < days ? addDaysToIso(startIso, days) : day; + } + + // Open days after `fromIso` up to and including `toIso` (the + // LibraryCalendar::openDaysBetween rule); calendar days without closures. + function openDaysBetweenIso(fromIso, toIso, closedSet) { + const span = daysBetweenIso(fromIso, toIso); + if (!Number.isFinite(span) || span <= 0) return 0; + if (!closedSet || closedSet.size === 0) return span; + let count = 0; + let day = fromIso; + for (let i = 0; i < span; i++) { + day = addDaysToIso(day, 1); + if (!closedSet.has(day)) count++; + } + return count; + } + + function loanWindowFrom(startIso, availabilityByDate, defaultDays, maxDays, closedSet) { const lookAhead = Math.max(defaultDays, maxDays || defaultDays); + // The longest end can pass lookAhead when it skips closed days: the + // conflict search has to reach that day too. + const searchDays = daysBetweenIso(startIso, toOpenDayIso(addDaysToIso(startIso, lookAhead), 1, closedSet)); let firstBlocked = null; - for (let i = 1; i <= lookAhead; i++) { + for (let i = 1; i <= searchDays; i++) { const day = addDaysToIso(startIso, i); const info = availabilityByDate[day]; if (info && info.state !== 'free') { @@ -1652,10 +1698,22 @@ function loanWindowFrom(startIso, availabilityByDate, defaultDays, maxDays) { break; } } - const maxEndDate = firstBlocked === null ? addDaysToIso(startIso, lookAhead) : addDaysToIso(firstBlocked, -1); - const defaultEnd = addDaysToIso(startIso, defaultDays); + let maxEndDate = firstBlocked === null + ? toOpenDayIso(addDaysToIso(startIso, lookAhead), 1, closedSet) + : toOpenDayIso(addDaysToIso(firstBlocked, -1), -1, closedSet); + // A day past the loaded availability has no occupancy data and would + // pass for free (moving over closed days can reach one): the return + // date stops at the last loaded day, or the open day before it. + let lastLoaded = null; + for (const day in availabilityByDate) { + if (lastLoaded === null || day > lastLoaded) lastLoaded = day; + } + if (lastLoaded !== null && maxEndDate > lastLoaded) { + maxEndDate = toOpenDayIso(lastLoaded, -1, closedSet); + } + const defaultEnd = toOpenDayIso(addDaysToIso(startIso, defaultDays), 1, closedSet); const capped = firstBlocked !== null && maxEndDate < defaultEnd; - const endDate = capped ? maxEndDate : defaultEnd; + const endDate = maxEndDate < defaultEnd ? maxEndDate : defaultEnd; return { endDate, maxEndDate, @@ -1701,6 +1759,9 @@ function loanWindowFrom(startIso, availabilityByDate, defaultDays, maxDays) { // Fetch availability data for the calendar let disabledDates = []; let availabilityByDate = {}; + // Closures calendar: days the library is closed. Not a start/pickup + // day; a return date on one is moved by the server to the next open day. + let closedDates = []; let maxAvailableDate = null; try { @@ -1720,6 +1781,7 @@ function loanWindowFrom(startIso, availabilityByDate, defaultDays, maxDays) { return; } disabledDates = availData.availability.unavailable_dates || []; + closedDates = Array.isArray(availData.availability.closed_dates) ? availData.availability.closed_dates : []; if (availData.availability.earliest_available) { const earliest = String(availData.availability.earliest_available); suggestedDate = /^\d{4}-\d{2}-\d{2}$/.test(earliest) ? earliest : appToday; @@ -1731,8 +1793,12 @@ function loanWindowFrom(startIso, availabilityByDate, defaultDays, maxDays) { } return acc; }, {}); - // Get the last date in the availability data to set maxDate - if (availData.availability.days.length > 0) { + // The last start day sets maxDate: the days run past it so a + // late start still has data for its whole loan window. + const startUntil = String(availData.availability.start_until || ''); + if (/^\d{4}-\d{2}-\d{2}$/.test(startUntil)) { + maxAvailableDate = startUntil; + } else if (availData.availability.days.length > 0) { const lastDay = availData.availability.days[availData.availability.days.length - 1]; if (lastDay && lastDay.date) { maxAvailableDate = lastDay.date; @@ -1745,6 +1811,20 @@ function loanWindowFrom(startIso, availabilityByDate, defaultDays, maxDays) { console.error('Error fetching availability:', e); } + const closedDaySet = new Set(closedDates); + // The suggested start must be an open day too. + if (closedDates.length > 0) { + const closedSet = new Set(closedDates); + const blockedSet = new Set(disabledDates); + let guard = 0; + while ((closedSet.has(suggestedDate) || blockedSet.has(suggestedDate)) && guard < 400) { + suggestedDate = addDaysToIso(suggestedDate, 1); + guard++; + } + } + const closedDayText = ; + const closedStartText = ; + const formatDateIT = (dateStr) => { if (!dateStr) { return ''; } const parts = dateStr.split('-'); @@ -1797,7 +1877,7 @@ function loanWindowFrom(startIso, availabilityByDate, defaultDays, maxDays) { // the maximum duration) and say so under the field; from a start with // no conflict ahead the note disappears. const applyLoanWindow = (startIso) => { - const loanWindow = loanWindowFrom(startIso, availabilityByDate, defaultRequestLoanDays, maxRequestLoanDays); + const loanWindow = loanWindowFrom(startIso, availabilityByDate, defaultRequestLoanDays, maxRequestLoanDays, closedDaySet); if (endPicker) { endPicker.set('minDate', startIso); endPicker.set('maxDate', loanWindow.maxEndDate); @@ -1812,7 +1892,7 @@ function loanWindowFrom(startIso, availabilityByDate, defaultDays, maxDays) { const startIso = (document.getElementById('swal-date-start') || {}).value || ''; let text = blockedDayText(isoDate, state); if (/^\d{4}-\d{2}-\d{2}$/.test(startIso)) { - const loanWindow = loanWindowFrom(startIso, availabilityByDate, defaultRequestLoanDays, maxRequestLoanDays); + const loanWindow = loanWindowFrom(startIso, availabilityByDate, defaultRequestLoanDays, maxRequestLoanDays, closedDaySet); if (loanWindow.firstBlocked !== null) { text += ' ' + windowText(loanWindow); } @@ -1886,6 +1966,17 @@ function loanWindowFrom(startIso, availabilityByDate, defaultDays, maxDays) { const isoDate = fp.formatDate(dayElem.dateObj, 'Y-m-d'); const info = availabilityByDate[isoDate]; + if (fp.element && fp.element.id === 'swal-date-start' && closedDates.includes(isoDate)) { + dayElem.classList.add('closed-day', 'flatpickr-disabled'); + dayElem.setAttribute('aria-disabled', 'true'); + dayElem.setAttribute('title', closedDayText); + dayElem.style.backgroundColor = '#f3f4f6'; + dayElem.style.borderColor = '#e5e7eb'; + dayElem.style.color = '#9ca3af'; + dayElem.style.textDecoration = 'line-through'; + return; + } + if (info) { dayElem.classList.add(`${info.state}-day`); if (info.state !== 'free') { @@ -1923,6 +2014,8 @@ function loanWindowFrom(startIso, availabilityByDate, defaultDays, maxDays) { if (window.flatpickr) { startPicker = window.flatpickr(startEl, { ...baseOpts, + // A closed day cannot be the start (pickup) day. + disable: disabledDates.concat(closedDates), onChange: function(selectedDates, dateStr, instance) { if (selectedDates.length > 0 && dateStr) { applyLoanWindow(dateStr); @@ -1966,6 +2059,10 @@ function loanWindowFrom(startIso, availabilityByDate, defaultDays, maxDays) { Swal.showValidationMessage(__('Seleziona una data di inizio')); return false; } + if (closedDates.includes(startDate)) { + Swal.showValidationMessage(closedStartText); + return false; + } // A return date past the first blocked day (or no return date, which // the server fills with the full default) would be refused anyway: // say how long the loan can be instead of sending it. @@ -1973,12 +2070,14 @@ function loanWindowFrom(startIso, availabilityByDate, defaultDays, maxDays) { Swal.showValidationMessage(endBeforeStartText); return false; } - const loanWindow = loanWindowFrom(startDate, availabilityByDate, defaultRequestLoanDays, maxRequestLoanDays); + const loanWindow = loanWindowFrom(startDate, availabilityByDate, defaultRequestLoanDays, maxRequestLoanDays, closedDaySet); if (loanWindow.firstBlocked !== null && loanWindow.maxDays < 1) { Swal.showValidationMessage(noWindowText(loanWindow)); return false; } - const effectiveEnd = endDate || addDaysToIso(startDate, defaultRequestLoanDays); + // A chosen return day the library is closed is stored on the next + // open day, like the default one. + const effectiveEnd = toOpenDayIso(endDate || addDaysToIso(startDate, defaultRequestLoanDays), 1, closedDaySet); if (effectiveEnd > loanWindow.maxEndDate) { Swal.showValidationMessage(loanWindow.firstBlocked !== null ? loanLimitText(loanWindow) : maxDurationText); return false; @@ -2011,27 +2110,40 @@ function loanWindowFrom(startIso, availabilityByDate, defaultDays, maxDays) { if (res.ok && result.success) { await updateReservationsBadge(); - const effectiveEndDate = formValues.endDate || addDaysToIso(formValues.startDate, defaultRequestLoanDays); + // The window the server stored, else the same rule computed here. + const storedStart = typeof result.start_date === 'string' && result.start_date ? result.start_date : formValues.startDate; + const effectiveEndDate = typeof result.end_date === 'string' && result.end_date + ? result.end_date + : toOpenDayIso(formValues.endDate || addDaysToIso(formValues.startDate, defaultRequestLoanDays), 1, closedDaySet); const isWaitlistReservation = result.status === 'reserved'; const rangeTemplate = isWaitlistReservation ? reservationRangeTpl : successRangeTpl; const successHtml = rangeTemplate - .replace('%1$s', formatDateIT(formValues.startDate)) + .replace('%1$s', formatDateIT(storedStart)) .replace('%2$s', formatDateIT(effectiveEndDate)); const isAutoApproved = result.auto_approved === true; // A scheduled ('prenotato') loan is not awaiting pickup yet: branch // the footnote so it doesn't promise imminent pickup instructions. const isScheduled = result.loan_state === 'prenotato'; - let pickupDeadline = addDaysToIso(formValues.startDate, pickupExpiryDays); - if (effectiveEndDate && effectiveEndDate < pickupDeadline) { + // The server's deadline and day count use the assigned copy's + // branch and every closure; the local rule (library-wide + // closures within the loaded horizon) is the fallback. + const serverPickup = typeof result.pickup_deadline === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(result.pickup_deadline) + && Number.isInteger(result.pickup_days) && result.pickup_days >= 0; + let pickupDeadline = serverPickup ? result.pickup_deadline : addOpenDaysIso(storedStart, pickupExpiryDays, closedDaySet); + if (!serverPickup && effectiveEndDate && effectiveEndDate < pickupDeadline) { pickupDeadline = effectiveEndDate; } - // The days actually available: fewer than the setting when the - // loan ends first (same rule as the pickup emails). - const spanDays = Math.round((Date.parse(pickupDeadline) - Date.parse(formValues.startDate)) / 86400000); - const pickupDays = Number.isFinite(spanDays) && spanDays >= 0 ? spanDays : pickupExpiryDays; + // The open days actually available: fewer than the setting when + // the loan ends first (same rule as the pickup emails). + const spanDays = openDaysBetweenIso(storedStart, pickupDeadline, closedDaySet); + // Same count as the pickup emails: never more than the setting, + // fewer when the loan ends first (closed days do not add any). + const pickupDays = serverPickup + ? result.pickup_days + : (Number.isFinite(spanDays) && spanDays >= 0 ? Math.min(pickupExpiryDays, spanDays) : pickupExpiryDays); const scheduledFootnote = scheduledFootnoteTpl - .replace('%s', formatDateIT(formValues.startDate)) + .replace('%s', formatDateIT(storedStart)) .replace('%d', String(pickupDays)) .replace('%s', formatDateIT(pickupDeadline)); const approvedFootnoteText = isScheduled ? scheduledFootnote : approvedFootnote; @@ -2073,7 +2185,9 @@ function loanWindowFrom(startIso, availabilityByDate, defaultDays, maxDays) { const result = await res.json(); if (res.ok && result.success) { await updateReservationsBadge(); - window.SwalApp.success(undefined, + date); + // The day the server stored: a closed start day moves forward. + const storedStart = typeof result.start_date === 'string' && result.start_date ? result.start_date : date; + window.SwalApp.success(undefined, + storedStart); } else { window.SwalApp.error(undefined, (result.message || )); } diff --git a/app/Views/layout.php b/app/Views/layout.php index 40195e48b..1224b642e 100644 --- a/app/Views/layout.php +++ b/app/Views/layout.php @@ -481,6 +481,20 @@ class="flex items-center justify-center w-8 h-8 rounded-lg bg-gray-100 group-hov + + +
+ +
+
+
+
+
+
+ +
__('Impostazioni'), + preg_match('#^/admin/(settings|languages|cms|calendar)(?:/|$)#', $workspacePath) === 1 => __('Impostazioni'), preg_match('#^/admin/(books|authors|publishers|genres|series|placement|inventory|opere|espressioni)#', $workspacePath) === 1 => __('Catalogo'), preg_match('#^/admin/(loans|reservations|users)#', $workspacePath) === 1 => __('Circolazione'), str_starts_with($workspacePath, '/admin/periodicals') => __('Emeroteca'), diff --git a/app/Views/prestiti/crea_prestito.php b/app/Views/prestiti/crea_prestito.php index c32e72476..39e746e5f 100644 --- a/app/Views/prestiti/crea_prestito.php +++ b/app/Views/prestiti/crea_prestito.php @@ -401,6 +401,7 @@ class="mt-0.5 h-4 w-4 rounded border-gray-300 text-gray-800 focus:ring-gray-500" // Translations const i18n = { + closedDay: , noResults: , available: , notAvailable: , @@ -430,6 +431,19 @@ class="mt-0.5 h-4 w-4 rounded border-gray-300 text-gray-800 focus:ring-gray-500" // Once a borrower and a book are chosen, the availability answer carries // the duration of THAT patron type and material (staff_loan_duration_days). let effectiveLoanDays = defaultLoanDays; + // Closures calendar: days the library is closed. Disabled in both + // pickers (today stays selectable: a desk may record a loan while + // closed to the public); a proposed due date steps to an open day. + const closedDates = ; + const closedSet = new Set(closedDates); + const closedDisable = closedDates.filter(function(d) { return d !== appToday; }); + function toOpenDay(isoDate, step) { + let day = isoDate; + for (let i = 0; i < 400 && closedSet.has(day); i++) { + day = addDaysToIso(day, step); + } + return day; + } function addDaysToIso(isoDate, days) { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate || ''); @@ -467,9 +481,10 @@ function loanWindowFrom(startIso, defaultDays) { } } if (firstBlocked === null) { - return { endDate: addDaysToIso(startIso, defaultDays), maxDays: defaultDays, firstBlocked: null, capped: false }; + return { endDate: toOpenDay(addDaysToIso(startIso, defaultDays), 1), maxDays: defaultDays, firstBlocked: null, capped: false }; } - const endDate = addDaysToIso(firstBlocked, -1); + // Capped: stepping forward would run into the blocked day, so step back. + const endDate = toOpenDay(addDaysToIso(firstBlocked, -1), -1); return { endDate, maxDays: daysBetweenIso(startIso, endDate), firstBlocked, capped: true }; } @@ -555,6 +570,13 @@ function colorCalendarDates(dObj, dStr, fp, dayElem) { return; } + if (closedSet.has(isoDate) && isoDate !== today) { + dayElem.classList.add('closed-day'); + dayElem.setAttribute('title', i18n.closedDay); + dayElem.style.textDecoration = 'line-through'; + return; + } + // The create gate forbids another loan while this borrower has an // active queue reservation for the same book, regardless of date. if (bookAvailability.blockedByReservation) { @@ -655,6 +677,7 @@ function updateConsegnaImmediataVisibility(dateStr) { // swaps in the native date picker on mobile, which shows neither the // availability colours nor the occupied days. disableMobile: true, + disable: closedDisable, onDayCreate: colorCalendarDates, onChange: function(selectedDates, dateStr) { if (dateStr) { @@ -682,6 +705,7 @@ function updateConsegnaImmediataVisibility(dateStr) { minDate: dataPrestitoEl.value || appToday, locale: localeObj || undefined, disableMobile: true, + disable: closedDisable, onDayCreate: colorCalendarDates }); diff --git a/app/Views/settings/loans-tab.php b/app/Views/settings/loans-tab.php index d42725950..25be23e18 100644 --- a/app/Views/settings/loans-tab.php +++ b/app/Views/settings/loans-tab.php @@ -86,6 +86,23 @@ class="block w-32 rounded-xl border-gray-300 focus:border-gray-500 focus:ring-gr
+ +
+
diff --git a/installer/database/migrations/migrate_0.9.0-rc.1.sql b/installer/database/migrations/migrate_0.9.0-rc.1.sql index 29f429704..56c3b3f06 100644 --- a/installer/database/migrations/migrate_0.9.0-rc.1.sql +++ b/installer/database/migrations/migrate_0.9.0-rc.1.sql @@ -288,3 +288,24 @@ PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; -- ── end Circulation policy ── + +-- ── closures calendar ── +-- calendario_chiusure: the days the library is closed (one row per interval, +-- optionally every year, optionally for one branch). Due dates that fall on a +-- closed day move to the next open day; pickup deadlines count open days. +-- The weekly closed days live in system_settings (loans.closed_weekdays) and +-- need no schema. Additive and idempotent: CREATE TABLE IF NOT EXISTS. +CREATE TABLE IF NOT EXISTS `calendario_chiusure` ( + `id` int NOT NULL AUTO_INCREMENT, + `data_inizio` date NOT NULL, + `data_fine` date NOT NULL, + `descrizione` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `ricorrente_annuale` tinyint(1) NOT NULL DEFAULT '0', + `sede_id` int DEFAULT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_calendario_chiusure_range` (`data_inizio`,`data_fine`), + KEY `idx_calendario_chiusure_sede` (`sede_id`), + CONSTRAINT `fk_calendario_chiusure_sede` FOREIGN KEY (`sede_id`) REFERENCES `sedi` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- ── end closures calendar ── diff --git a/installer/database/schema.sql b/installer/database/schema.sql index c4e126796..1dc4be109 100755 --- a/installer/database/schema.sql +++ b/installer/database/schema.sql @@ -108,6 +108,22 @@ CREATE TABLE `author_authority_alternates` ( /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `calendario_chiusure` ( + `id` int NOT NULL AUTO_INCREMENT, + `data_inizio` date NOT NULL, + `data_fine` date NOT NULL, + `descrizione` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `ricorrente_annuale` tinyint(1) NOT NULL DEFAULT '0', + `sede_id` int DEFAULT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_calendario_chiusure_range` (`data_inizio`,`data_fine`), + KEY `idx_calendario_chiusure_sede` (`sede_id`), + CONSTRAINT `fk_calendario_chiusure_sede` FOREIGN KEY (`sede_id`) REFERENCES `sedi` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `cms_pages` ( `id` int NOT NULL AUTO_INCREMENT, `slug` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, diff --git a/locale/da_DK.json b/locale/da_DK.json index fe3749e25..5e285da5d 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -6850,6 +6850,7 @@ "Auto-approva le richieste": "Godkend anmodninger automatisk", "Disattiva la fase di approvazione manuale per i nuovi prestiti richiesti dagli utenti.": "Spring manuel godkendelse over for nye lån, som brugerne anmoder om.", "Prestito approvato. Il libro è in attesa di ritiro.": "Lånet er godkendt. Bogen afventer afhentning.", + "Prestito approvato: potrai ritirare il libro dal %s.": "Lånet er godkendt: du kan afhente bogen fra %s.", "Stile interfaccia": "Grænsefladestil", "Cambia la composizione del sito pubblico. Contenuti CMS, sfondo hero, colori e funzionalità restano invariati.": "Skift opbygningen af det offentlige websted. CMS-indhold, hero-baggrund, farver og funktioner forbliver uændrede.", "Un solo frontend": "Én fælles grænseflade", @@ -8891,6 +8892,64 @@ "Rinnova prestito (+%d giorni)": "Forny lån (+%d dage)", "La scadenza verrà estesa di %d giorni": "Fristen forlænges med %d dage", "Rinnovare il prestito? La scadenza verrà estesa di %d giorni.": "Vil du fornye lånet? Udløbsdatoen udvides med %d dage.", + "Eccezioni per tipologia di lettore e tipo di materiale: prestabilità, prestiti massimi di quel materiale (oltre al limite totale), durata e rinnovi. I valori in grigio sono quelli ereditati; scrivi un valore solo dove serve un'eccezione.": "Undtagelser pr. lånerkategori og materialetype: udlånbarhed, maksimalt antal lån af det materiale (ud over den samlede grænse), periode og fornyelser. Grå værdier er arvede; skriv kun en værdi, hvor der er brug for en undtagelse.", + "Aggiunge le festività fisse come chiusure annuali, e Pasqua e Pasquetta per l'anno scelto. Le chiusure già presenti non vengono duplicate.": "Tilføjer de faste helligdage som årlige lukninger samt påskedag og 2. påskedag for det valgte år. Lukninger, der allerede findes, oprettes ikke igen.", + "Aggiungi chiusura": "Tilføj lukning", + "Aggiungi una chiusura o importa le festività nazionali.": "Tilføj en lukning eller importér de nationale helligdage.", + "Almeno un giorno della settimana deve restare aperto.": "Mindst én ugedag skal være åben.", + "Biblioteca chiusa": "Biblioteket er lukket", + "Calendario chiusure": "Lukkekalender", + "Capodanno": "Nytårsdag", + "Chiusura": "Lukning", + "Chiusura aggiornata.": "Lukningen er opdateret.", + "Chiusura aggiunta.": "Lukningen er tilføjet.", + "Chiusura eliminata.": "Lukningen er slettet.", + "Chiusura non trovata.": "Lukningen blev ikke fundet.", + "Chiusura settimanale": "Ugentlig lukning", + "Chiusure": "Lukninger", + "Data non valida: usa il formato AAAA-MM-GG.": "Ugyldig dato: brug formatet ÅÅÅÅ-MM-DD.", + "Eliminare questa chiusura?": "Slet denne lukning?", + "Epifania": "Helligtrekonger", + "Ferragosto": "Marias himmelfart (Ferragosto)", + "Festa del Lavoro": "Arbejdernes kampdag", + "Festa della Liberazione": "Befrielsesdagen", + "Festa della Repubblica": "Republikkens dag", + "Festività nazionali importate: %d nuove chiusure.": "Nationale helligdage importeret: %d nye lukninger.", + "Festività nazionali italiane": "Italienske nationale helligdage", + "File ICS": "ICS-fil", + "File ICS importato: %1$d chiusure aggiunte, %2$d già presenti.": "ICS-fil importeret: %1$d lukninger tilføjet, %2$d fandtes allerede.", + "File ICS importato: %1$d chiusure aggiunte, %2$d già presenti, %3$d scartate perché più lunghe del limite (un anno se annuali, dieci anni altrimenti).": "ICS-fil importeret: %1$d lukninger tilføjet, %2$d fandtes allerede, %3$d afvist, fordi de er længere end grænsen (et år hvis årlige, ellers ti år).", + "Gestisci il calendario chiusure": "Administrér lukkekalenderen", + "Giorni di chiusura e festività": "Lukkedage og helligdage", + "Giorni di chiusura settimanale salvati.": "Ugentlige lukkedage er gemt.", + "Giorni in cui la biblioteca è chiusa: le scadenze che vi cadono passano al primo giorno di apertura e i giorni per il ritiro contano solo i giorni di apertura.": "Dage hvor biblioteket er lukket: afleveringsdatoer, der falder på dem, flyttes til næste åbningsdag, og afhentningsfristen tæller kun åbningsdage.", + "Il file ICS supera la dimensione massima di 1 MB.": "ICS-filen overskrider den maksimale størrelse på 1 MB.", + "Il file non è un calendario ICS valido.": "Filen er ikke en gyldig ICS-kalender.", + "Immacolata Concezione": "Marias undfangelse", + "Importa ICS": "Importér ICS", + "Importa da file ICS": "Importér fra en ICS-fil", + "Importa festività": "Importér helligdage", + "La biblioteca è chiusa in questo giorno: scegli un altro giorno di inizio.": "Biblioteket er lukket denne dag: vælg en anden startdag.", + "Lunedì dell'Angelo (Pasquetta)": "2. påskedag", + "Mese precedente": "Forrige måned", + "Mese successivo": "Næste måned", + "Modifica chiusura": "Redigér lukning", + "Natale": "Juledag", + "Nessuna chiusura configurata": "Ingen lukninger oprettet", + "Ogni anno": "Hvert år", + "Ogni evento del calendario diventa un giorno o un periodo di chiusura. Solo una ricorrenza annuale semplice (RRULE FREQ=YEARLY, senza COUNT, UNTIL, EXDATE né giorni della settimana, ogni anno nella stessa data) si ripete ogni anno; ogni altra ricorrenza importa solo la prima occorrenza come chiusura singola.": "Hver kalenderbegivenhed bliver en lukkedag eller -periode. Kun en simpel årlig gentagelse (RRULE FREQ=YEARLY uden COUNT, UNTIL, EXDATE eller ugedage, hvert år på samme dato) gentages hvert år; enhver anden gentagelse importerer kun den første forekomst som en enkelt lukning.", + "Ognissanti": "Allehelgensdag", + "Pasqua": "Påskedag", + "Ricorrenza": "Gentagelse", + "Santo Stefano": "2. juledag", + "Sede non trovata.": "Filialen blev ikke fundet.", + "Seleziona i giorni della settimana in cui la biblioteca è sempre chiusa.": "Vælg de ugedage, hvor biblioteket altid er lukket.", + "Seleziona un file ICS da importare.": "Vælg en ICS-fil at importere.", + "Si ripete ogni anno": "Gentages hvert år", + "Una chiusura annuale non può durare più di un anno.": "En årlig lukning kan ikke vare mere end et år.", + "Una chiusura non può durare più di dieci anni.": "En lukning kan ikke vare mere end ti år.", + "Una volta": "Én gang", + "es. Chiusura estiva": "f.eks. Sommerlukning", "Senza scadenza tessera nel file, la data viene calcolata dalla durata tessera della tipologia lettore (quella indicata o la predefinita); una scadenza presente nel CSV ha la precedenza.": "Uden udløbsdato for kortet i filen beregnes datoen ud fra lånertypens kortvarighed (den angivne eller standardtypen); en udløbsdato i CSV-filen har forrang.", "Utente importato, ma la scadenza della tessera non è stata calcolata.": "Bruger importeret, men kortets udløbsdato kunne ikke beregnes.", "Calcola la scadenza dalla durata tessera della tipologia lettore (se indicata e se la data qui sopra è lasciata vuota)": "Beregn udløbsdatoen ud fra lånertypens kortvarighed (hvis angivet, og hvis datoen ovenfor er tom)", @@ -8907,6 +8966,10 @@ "Questa funzione sarà disponibile al termine dell'aggiornamento del database.": "Denne funktion bliver tilgængelig, når databaseopdateringen er færdig.", "Segnate come perse": "Markeret som tabt", "Le copie in prestito, in attesa di ritiro, danneggiate, già perse, in restauro, in trasferimento o in manutenzione non vengono toccate.": "Eksemplarer, der er udlånt, venter på afhentning, er beskadigede, allerede tabt, under restaurering, under overførsel eller i vedligeholdelse, røres ikke.", + "Giorno della settimana non valido.": "Ugyldig ugedag.", + "Un'altra modifica del calendario è in corso: riprova tra poco.": "En anden ændring af kalenderen er i gang: prøv igen om lidt.", + "Con questa chiusura annuale la biblioteca resterebbe chiusa tutto l'anno: almeno un giorno deve restare aperto.": "Med denne årlige lukning ville biblioteket være lukket hele året: mindst én dag skal forblive åben.", + "Con questi giorni di chiusura settimanale e le chiusure annuali la biblioteca resterebbe chiusa tutto l'anno: almeno un giorno deve restare aperto.": "Med disse ugentlige lukkedage og de årlige lukninger ville biblioteket være lukket hele året: mindst én dag skal forblive åben.", "Fornitore non assegnato: è stato eliminato durante l'importazione.": "Leverandør ikke tildelt: den blev slettet under importen.", "Il file dell'import non è più disponibile: le righe già elaborate sono state registrate nello storico import. Carica di nuovo il file per completarlo.": "Importfilen er ikke længere tilgængelig: de allerede behandlede rækker er registreret i importhistorikken. Upload filen igen for at fuldføre den.", "Il file dell'import interrotto «%s» non era più disponibile: l'import è stato chiuso dopo %d di %d righe e registrato nello storico import. Gli utenti già creati restano; per completarlo carica di nuovo il file.": "Filen til den afbrudte import «%s» var ikke længere tilgængelig: importen blev lukket efter %d af %d rækker og registreret i importhistorikken. De allerede oprettede brugere bevares; upload filen igen for at fuldføre den.", @@ -8933,6 +8996,7 @@ "Annulla import": "Annuller import", "Riprendi import": "Genoptag import", "Utente non eliminato: la sua foto non può essere rimossa dal disco. Riprova o controlla i permessi della cartella storage.": "Brugeren blev ikke slettet: brugerens foto kan ikke fjernes fra disken. Prøv igen, eller kontrollér rettighederne til mappen storage.", + "%d scartate perché con le altre chiusure annuali lascerebbero la biblioteca chiusa tutto l'anno.": "%d kasseret, fordi de sammen med de andre årlige lukninger ville holde biblioteket lukket hele året.", "L'import precedente è stato chiuso, ma lo storico import non è stato aggiornato: gli utenti già creati restano.": "Den forrige import blev lukket, men importhistorikken kunne ikke opdateres: de allerede oprettede brugere bevares.", "Il file dell'import non è più disponibile e lo storico import non è stato aggiornato: gli utenti già creati restano. Carica di nuovo il file per completarlo.": "Importfilen er ikke længere tilgængelig, og importhistorikken kunne ikke opdateres: de allerede oprettede brugere bevares. Upload filen igen for at fuldføre importen.", "Il file dell'import interrotto «%s» non era più disponibile: l'import è stato chiuso dopo %d di %d righe, ma lo storico import non è stato aggiornato. Gli utenti già creati restano; per completarlo carica di nuovo il file.": "Filen til den afbrudte import «%s» var ikke længere tilgængelig: importen blev lukket efter %d af %d rækker, men importhistorikken kunne ikke opdateres. De allerede oprettede brugere bevares; upload filen igen for at fuldføre den.", diff --git a/locale/de_DE.json b/locale/de_DE.json index 6a22ce735..d9ad637a6 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -6850,6 +6850,7 @@ "Auto-approva le richieste": "Anfragen automatisch genehmigen", "Disattiva la fase di approvazione manuale per i nuovi prestiti richiesti dagli utenti.": "Überspringt die manuelle Genehmigung neuer Ausleihanfragen.", "Prestito approvato. Il libro è in attesa di ritiro.": "Ausleihe genehmigt. Das Buch wartet auf Abholung.", + "Prestito approvato: potrai ritirare il libro dal %s.": "Ausleihe genehmigt: Sie können das Buch ab dem %s abholen.", "Stile interfaccia": "Oberflächenstil", "Cambia la composizione del sito pubblico. Contenuti CMS, sfondo hero, colori e funzionalità restano invariati.": "Ändert den Aufbau der öffentlichen Website. CMS-Inhalte, Hero-Hintergrund, Farben und Funktionen bleiben unverändert.", "Un solo frontend": "Eine gemeinsame Oberfläche", @@ -8891,6 +8892,64 @@ "Rinnova prestito (+%d giorni)": "Ausleihe verlängern (+%d Tage)", "La scadenza verrà estesa di %d giorni": "Die Frist wird um %d Tage verlängert", "Rinnovare il prestito? La scadenza verrà estesa di %d giorni.": "Ausleihe verlängern? Das Fälligkeitsdatum wird um %d Tage verlängert.", + "Eccezioni per tipologia di lettore e tipo di materiale: prestabilità, prestiti massimi di quel materiale (oltre al limite totale), durata e rinnovi. I valori in grigio sono quelli ereditati; scrivi un valore solo dove serve un'eccezione.": "Ausnahmen pro Leserkategorie und Medienart: Ausleihbarkeit, maximale Ausleihen dieses Mediums (zusätzlich zur Gesamtgrenze), Dauer und Verlängerungen. Graue Werte sind geerbt; trage nur dort einen Wert ein, wo eine Ausnahme nötig ist.", + "Aggiunge le festività fisse come chiusure annuali, e Pasqua e Pasquetta per l'anno scelto. Le chiusure già presenti non vengono duplicate.": "Fügt die festen Feiertage als jährliche Schließungen hinzu, dazu Ostern und Ostermontag für das gewählte Jahr. Bereits vorhandene Schließungen werden nicht doppelt angelegt.", + "Aggiungi chiusura": "Schließung hinzufügen", + "Aggiungi una chiusura o importa le festività nazionali.": "Füge eine Schließung hinzu oder importiere die gesetzlichen Feiertage.", + "Almeno un giorno della settimana deve restare aperto.": "Mindestens ein Wochentag muss geöffnet bleiben.", + "Biblioteca chiusa": "Bibliothek geschlossen", + "Calendario chiusure": "Schließtage-Kalender", + "Capodanno": "Neujahr", + "Chiusura": "Schließung", + "Chiusura aggiornata.": "Schließung aktualisiert.", + "Chiusura aggiunta.": "Schließung hinzugefügt.", + "Chiusura eliminata.": "Schließung gelöscht.", + "Chiusura non trovata.": "Schließung nicht gefunden.", + "Chiusura settimanale": "Wöchentliche Schließung", + "Chiusure": "Schließungen", + "Data non valida: usa il formato AAAA-MM-GG.": "Ungültiges Datum: Format JJJJ-MM-TT verwenden.", + "Eliminare questa chiusura?": "Diese Schließung löschen?", + "Epifania": "Heilige Drei Könige", + "Ferragosto": "Mariä Himmelfahrt (Ferragosto)", + "Festa del Lavoro": "Tag der Arbeit", + "Festa della Liberazione": "Tag der Befreiung", + "Festa della Repubblica": "Tag der Republik", + "Festività nazionali importate: %d nuove chiusure.": "Gesetzliche Feiertage importiert: %d neue Schließungen.", + "Festività nazionali italiane": "Italienische gesetzliche Feiertage", + "File ICS": "ICS-Datei", + "File ICS importato: %1$d chiusure aggiunte, %2$d già presenti.": "ICS-Datei importiert: %1$d Schließungen hinzugefügt, %2$d bereits vorhanden.", + "File ICS importato: %1$d chiusure aggiunte, %2$d già presenti, %3$d scartate perché più lunghe del limite (un anno se annuali, dieci anni altrimenti).": "ICS-Datei importiert: %1$d Schließungen hinzugefügt, %2$d bereits vorhanden, %3$d verworfen, weil länger als das Limit (ein Jahr bei jährlichen, sonst zehn Jahre).", + "Gestisci il calendario chiusure": "Schließtage-Kalender verwalten", + "Giorni di chiusura e festività": "Schließtage und Feiertage", + "Giorni di chiusura settimanale salvati.": "Wöchentliche Schließtage gespeichert.", + "Giorni in cui la biblioteca è chiusa: le scadenze che vi cadono passano al primo giorno di apertura e i giorni per il ritiro contano solo i giorni di apertura.": "Tage, an denen die Bibliothek geschlossen ist: Fälligkeiten an diesen Tagen verschieben sich auf den nächsten Öffnungstag, und die Abholfrist zählt nur Öffnungstage.", + "Il file ICS supera la dimensione massima di 1 MB.": "Die ICS-Datei überschreitet die Höchstgröße von 1 MB.", + "Il file non è un calendario ICS valido.": "Die Datei ist kein gültiger ICS-Kalender.", + "Immacolata Concezione": "Mariä Empfängnis", + "Importa ICS": "ICS importieren", + "Importa da file ICS": "Aus ICS-Datei importieren", + "Importa festività": "Feiertage importieren", + "La biblioteca è chiusa in questo giorno: scegli un altro giorno di inizio.": "Die Bibliothek ist an diesem Tag geschlossen: wähle einen anderen Starttag.", + "Lunedì dell'Angelo (Pasquetta)": "Ostermontag", + "Mese precedente": "Vorheriger Monat", + "Mese successivo": "Nächster Monat", + "Modifica chiusura": "Schließung bearbeiten", + "Natale": "Weihnachten", + "Nessuna chiusura configurata": "Keine Schließungen eingerichtet", + "Ogni anno": "Jedes Jahr", + "Ogni evento del calendario diventa un giorno o un periodo di chiusura. Solo una ricorrenza annuale semplice (RRULE FREQ=YEARLY, senza COUNT, UNTIL, EXDATE né giorni della settimana, ogni anno nella stessa data) si ripete ogni anno; ogni altra ricorrenza importa solo la prima occorrenza come chiusura singola.": "Jedes Kalenderereignis wird zu einem Schließtag oder -zeitraum. Nur eine einfache jährliche Wiederholung (RRULE FREQ=YEARLY ohne COUNT, UNTIL, EXDATE oder Wochentage, jedes Jahr am selben Datum) wiederholt sich jedes Jahr; bei jeder anderen Wiederholung wird nur das erste Vorkommen als einzelne Schließung importiert.", + "Ognissanti": "Allerheiligen", + "Pasqua": "Ostersonntag", + "Ricorrenza": "Wiederholung", + "Santo Stefano": "Stephanstag", + "Sede non trovata.": "Zweigstelle nicht gefunden.", + "Seleziona i giorni della settimana in cui la biblioteca è sempre chiusa.": "Wähle die Wochentage, an denen die Bibliothek immer geschlossen ist.", + "Seleziona un file ICS da importare.": "Wähle eine ICS-Datei zum Importieren.", + "Si ripete ogni anno": "Wiederholt sich jedes Jahr", + "Una chiusura annuale non può durare più di un anno.": "Eine jährliche Schließung darf nicht länger als ein Jahr dauern.", + "Una chiusura non può durare più di dieci anni.": "Eine Schließung darf nicht länger als zehn Jahre dauern.", + "Una volta": "Einmalig", + "es. Chiusura estiva": "z. B. Sommerschließung", "Senza scadenza tessera nel file, la data viene calcolata dalla durata tessera della tipologia lettore (quella indicata o la predefinita); una scadenza presente nel CSV ha la precedenza.": "Fehlt das Ablaufdatum des Ausweises in der Datei, wird es aus der Ausweisdauer des Lesertyps berechnet (des angegebenen oder des Standardtyps); ein im CSV vorhandenes Ablaufdatum hat Vorrang.", "Utente importato, ma la scadenza della tessera non è stata calcolata.": "Benutzer importiert, aber das Ablaufdatum des Ausweises konnte nicht berechnet werden.", "Calcola la scadenza dalla durata tessera della tipologia lettore (se indicata e se la data qui sopra è lasciata vuota)": "Ablaufdatum aus der Ausweisdauer des Lesertyps berechnen (falls angegeben und falls das Datum oben leer bleibt)", @@ -8907,6 +8966,10 @@ "Questa funzione sarà disponibile al termine dell'aggiornamento del database.": "Diese Funktion ist verfügbar, sobald die Datenbankaktualisierung abgeschlossen ist.", "Segnate come perse": "Als verloren markiert", "Le copie in prestito, in attesa di ritiro, danneggiate, già perse, in restauro, in trasferimento o in manutenzione non vengono toccate.": "Ausgeliehene, zur Abholung bereitliegende, beschädigte, bereits verlorene, in Restaurierung, im Transfer oder in Wartung befindliche Exemplare werden nicht verändert.", + "Giorno della settimana non valido.": "Ungültiger Wochentag.", + "Un'altra modifica del calendario è in corso: riprova tra poco.": "Eine andere Kalenderänderung läuft gerade: Versuche es gleich noch einmal.", + "Con questa chiusura annuale la biblioteca resterebbe chiusa tutto l'anno: almeno un giorno deve restare aperto.": "Mit dieser jährlichen Schließung wäre die Bibliothek das ganze Jahr geschlossen: Mindestens ein Tag muss geöffnet bleiben.", + "Con questi giorni di chiusura settimanale e le chiusure annuali la biblioteca resterebbe chiusa tutto l'anno: almeno un giorno deve restare aperto.": "Mit diesen wöchentlichen Schließtagen und den jährlichen Schließungen wäre die Bibliothek das ganze Jahr geschlossen: Mindestens ein Tag muss geöffnet bleiben.", "Fornitore non assegnato: è stato eliminato durante l'importazione.": "Lieferant nicht zugewiesen: Er wurde während des Imports gelöscht.", "Il file dell'import non è più disponibile: le righe già elaborate sono state registrate nello storico import. Carica di nuovo il file per completarlo.": "Die Importdatei ist nicht mehr verfügbar: Die bereits verarbeiteten Zeilen wurden im Importverlauf erfasst. Lade die Datei erneut hoch, um den Import abzuschließen.", "Il file dell'import interrotto «%s» non era più disponibile: l'import è stato chiuso dopo %d di %d righe e registrato nello storico import. Gli utenti già creati restano; per completarlo carica di nuovo il file.": "Die Datei des unterbrochenen Imports «%s» war nicht mehr verfügbar: Der Import wurde nach %d von %d Zeilen geschlossen und im Importverlauf erfasst. Die bereits angelegten Benutzer bleiben erhalten; lade die Datei erneut hoch, um ihn abzuschließen.", @@ -8933,6 +8996,7 @@ "Annulla import": "Import abbrechen", "Riprendi import": "Import fortsetzen", "Utente non eliminato: la sua foto non può essere rimossa dal disco. Riprova o controlla i permessi della cartella storage.": "Benutzer nicht gelöscht: Sein Foto kann nicht von der Festplatte entfernt werden. Versuchen Sie es erneut oder prüfen Sie die Berechtigungen des Ordners storage.", + "%d scartate perché con le altre chiusure annuali lascerebbero la biblioteca chiusa tutto l'anno.": "%d verworfen, weil sie zusammen mit den anderen jährlichen Schließungen die Bibliothek das ganze Jahr geschlossen halten würden.", "L'import precedente è stato chiuso, ma lo storico import non è stato aggiornato: gli utenti già creati restano.": "Der vorherige Import wurde geschlossen, aber der Importverlauf konnte nicht aktualisiert werden: Die bereits angelegten Benutzer bleiben erhalten.", "Il file dell'import non è più disponibile e lo storico import non è stato aggiornato: gli utenti già creati restano. Carica di nuovo il file per completarlo.": "Die Importdatei ist nicht mehr verfügbar und der Importverlauf konnte nicht aktualisiert werden: Die bereits angelegten Benutzer bleiben erhalten. Lade die Datei erneut hoch, um den Import abzuschließen.", "Il file dell'import interrotto «%s» non era più disponibile: l'import è stato chiuso dopo %d di %d righe, ma lo storico import non è stato aggiornato. Gli utenti già creati restano; per completarlo carica di nuovo il file.": "Die Datei des unterbrochenen Imports «%s» war nicht mehr verfügbar: Der Import wurde nach %d von %d Zeilen geschlossen, aber der Importverlauf konnte nicht aktualisiert werden. Die bereits angelegten Benutzer bleiben erhalten; lade die Datei erneut hoch, um ihn abzuschließen.", diff --git a/locale/en_US.json b/locale/en_US.json index 69fba3719..b65b069cd 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -6850,6 +6850,7 @@ "Auto-approva le richieste": "Auto-approve requests", "Disattiva la fase di approvazione manuale per i nuovi prestiti richiesti dagli utenti.": "Skip manual approval for new loans requested by patrons.", "Prestito approvato. Il libro è in attesa di ritiro.": "Loan approved. The book is awaiting pickup.", + "Prestito approvato: potrai ritirare il libro dal %s.": "Loan approved: you can pick up the book from %s.", "Stile interfaccia": "Interface style", "Cambia la composizione del sito pubblico. Contenuti CMS, sfondo hero, colori e funzionalità restano invariati.": "Change the public site's composition. CMS content, hero background, colors, and features remain unchanged.", "Un solo frontend": "One shared frontend", @@ -8891,6 +8892,64 @@ "Rinnova prestito (+%d giorni)": "Renew loan (+%d days)", "La scadenza verrà estesa di %d giorni": "The due date will be extended by %d days", "Rinnovare il prestito? La scadenza verrà estesa di %d giorni.": "Renew loan? The due date will be extended by %d days.", + "Eccezioni per tipologia di lettore e tipo di materiale: prestabilità, prestiti massimi di quel materiale (oltre al limite totale), durata e rinnovi. I valori in grigio sono quelli ereditati; scrivi un valore solo dove serve un'eccezione.": "Exceptions per patron type and material type: lendability, maximum loans of that material (on top of the total limit), duration and renewals. Grey values are inherited; enter a value only where an exception is needed.", + "Aggiunge le festività fisse come chiusure annuali, e Pasqua e Pasquetta per l'anno scelto. Le chiusure già presenti non vengono duplicate.": "Adds the fixed holidays as yearly closures, plus Easter and Easter Monday for the chosen year. Closures already present are not duplicated.", + "Aggiungi chiusura": "Add closure", + "Aggiungi una chiusura o importa le festività nazionali.": "Add a closure or import the national holidays.", + "Almeno un giorno della settimana deve restare aperto.": "At least one day of the week must stay open.", + "Biblioteca chiusa": "Library closed", + "Calendario chiusure": "Closures calendar", + "Capodanno": "New Year's Day", + "Chiusura": "Closure", + "Chiusura aggiornata.": "Closure updated.", + "Chiusura aggiunta.": "Closure added.", + "Chiusura eliminata.": "Closure deleted.", + "Chiusura non trovata.": "Closure not found.", + "Chiusura settimanale": "Weekly closure", + "Chiusure": "Closures", + "Data non valida: usa il formato AAAA-MM-GG.": "Invalid date: use the YYYY-MM-DD format.", + "Eliminare questa chiusura?": "Delete this closure?", + "Epifania": "Epiphany", + "Ferragosto": "Assumption Day (Ferragosto)", + "Festa del Lavoro": "Labour Day", + "Festa della Liberazione": "Liberation Day", + "Festa della Repubblica": "Republic Day", + "Festività nazionali importate: %d nuove chiusure.": "National holidays imported: %d new closures.", + "Festività nazionali italiane": "Italian national holidays", + "File ICS": "ICS file", + "File ICS importato: %1$d chiusure aggiunte, %2$d già presenti.": "ICS file imported: %1$d closures added, %2$d already present.", + "File ICS importato: %1$d chiusure aggiunte, %2$d già presenti, %3$d scartate perché più lunghe del limite (un anno se annuali, dieci anni altrimenti).": "ICS file imported: %1$d closures added, %2$d already present, %3$d discarded as longer than the limit (one year if yearly, ten years otherwise).", + "Gestisci il calendario chiusure": "Manage the closures calendar", + "Giorni di chiusura e festività": "Closed days and holidays", + "Giorni di chiusura settimanale salvati.": "Weekly closed days saved.", + "Giorni in cui la biblioteca è chiusa: le scadenze che vi cadono passano al primo giorno di apertura e i giorni per il ritiro contano solo i giorni di apertura.": "Days the library is closed: due dates falling on them move to the next open day, and pickup days count only open days.", + "Il file ICS supera la dimensione massima di 1 MB.": "The ICS file exceeds the 1 MB size limit.", + "Il file non è un calendario ICS valido.": "The file is not a valid ICS calendar.", + "Immacolata Concezione": "Immaculate Conception", + "Importa ICS": "Import ICS", + "Importa da file ICS": "Import from an ICS file", + "Importa festività": "Import holidays", + "La biblioteca è chiusa in questo giorno: scegli un altro giorno di inizio.": "The library is closed on this day: choose another start day.", + "Lunedì dell'Angelo (Pasquetta)": "Easter Monday", + "Mese precedente": "Previous month", + "Mese successivo": "Next month", + "Modifica chiusura": "Edit closure", + "Natale": "Christmas Day", + "Nessuna chiusura configurata": "No closures configured", + "Ogni anno": "Every year", + "Ogni evento del calendario diventa un giorno o un periodo di chiusura. Solo una ricorrenza annuale semplice (RRULE FREQ=YEARLY, senza COUNT, UNTIL, EXDATE né giorni della settimana, ogni anno nella stessa data) si ripete ogni anno; ogni altra ricorrenza importa solo la prima occorrenza come chiusura singola.": "Each calendar event becomes a closed day or period. Only a plain yearly recurrence (RRULE FREQ=YEARLY with no COUNT, UNTIL, EXDATE or weekdays, every year on the same date) repeats every year; any other recurrence imports only its first occurrence as a single closure.", + "Ognissanti": "All Saints' Day", + "Pasqua": "Easter Sunday", + "Ricorrenza": "Recurrence", + "Santo Stefano": "St. Stephen's Day", + "Sede non trovata.": "Branch not found.", + "Seleziona i giorni della settimana in cui la biblioteca è sempre chiusa.": "Select the days of the week on which the library is always closed.", + "Seleziona un file ICS da importare.": "Select an ICS file to import.", + "Si ripete ogni anno": "Repeats every year", + "Una chiusura annuale non può durare più di un anno.": "A yearly closure cannot last more than a year.", + "Una chiusura non può durare più di dieci anni.": "A closure cannot last more than ten years.", + "Una volta": "Once", + "es. Chiusura estiva": "e.g. Summer closure", "Senza scadenza tessera nel file, la data viene calcolata dalla durata tessera della tipologia lettore (quella indicata o la predefinita); una scadenza presente nel CSV ha la precedenza.": "Without a card expiry in the file, the date is computed from the card duration of the patron type (the one given or the default one); an expiry present in the CSV takes precedence.", "Utente importato, ma la scadenza della tessera non è stata calcolata.": "User imported, but the card expiry could not be computed.", "Calcola la scadenza dalla durata tessera della tipologia lettore (se indicata e se la data qui sopra è lasciata vuota)": "Compute the expiry from the card duration of the patron type (when set, and when the date above is left empty)", @@ -8907,6 +8966,10 @@ "Questa funzione sarà disponibile al termine dell'aggiornamento del database.": "This feature will be available once the database update has finished.", "Segnate come perse": "Marked as lost", "Le copie in prestito, in attesa di ritiro, danneggiate, già perse, in restauro, in trasferimento o in manutenzione non vengono toccate.": "Copies on loan, awaiting pickup, damaged, already lost, under restoration, in transfer or in maintenance are not touched.", + "Giorno della settimana non valido.": "Invalid day of the week.", + "Un'altra modifica del calendario è in corso: riprova tra poco.": "Another calendar change is in progress: try again shortly.", + "Con questa chiusura annuale la biblioteca resterebbe chiusa tutto l'anno: almeno un giorno deve restare aperto.": "With this yearly closure the library would be closed all year: at least one day must stay open.", + "Con questi giorni di chiusura settimanale e le chiusure annuali la biblioteca resterebbe chiusa tutto l'anno: almeno un giorno deve restare aperto.": "With these weekly closed days and the yearly closures the library would be closed all year: at least one day must stay open.", "Fornitore non assegnato: è stato eliminato durante l'importazione.": "Supplier not assigned: it was deleted during the import.", "Il file dell'import non è più disponibile: le righe già elaborate sono state registrate nello storico import. Carica di nuovo il file per completarlo.": "The import file is no longer available: the rows already processed have been recorded in the import history. Upload the file again to complete it.", "Il file dell'import interrotto «%s» non era più disponibile: l'import è stato chiuso dopo %d di %d righe e registrato nello storico import. Gli utenti già creati restano; per completarlo carica di nuovo il file.": "The file of the interrupted import «%s» was no longer available: the import was closed after %d of %d rows and recorded in the import history. The users already created remain; to complete it, upload the file again.", @@ -8933,6 +8996,7 @@ "Annulla import": "Cancel import", "Riprendi import": "Resume import", "Utente non eliminato: la sua foto non può essere rimossa dal disco. Riprova o controlla i permessi della cartella storage.": "User not deleted: their photo cannot be removed from disk. Try again or check the permissions of the storage folder.", + "%d scartate perché con le altre chiusure annuali lascerebbero la biblioteca chiusa tutto l'anno.": "%d discarded because, together with the other yearly closures, they would keep the library closed all year.", "L'import precedente è stato chiuso, ma lo storico import non è stato aggiornato: gli utenti già creati restano.": "The previous import was closed, but the import history could not be updated: the users already created remain.", "Il file dell'import non è più disponibile e lo storico import non è stato aggiornato: gli utenti già creati restano. Carica di nuovo il file per completarlo.": "The import file is no longer available and the import history could not be updated: the users already created remain. Upload the file again to complete it.", "Il file dell'import interrotto «%s» non era più disponibile: l'import è stato chiuso dopo %d di %d righe, ma lo storico import non è stato aggiornato. Gli utenti già creati restano; per completarlo carica di nuovo il file.": "The file of the interrupted import «%s» was no longer available: the import was closed after %d of %d rows, but the import history could not be updated. The users already created remain; to complete it, upload the file again.", diff --git a/locale/fr_FR.json b/locale/fr_FR.json index 92991ba5e..299aa59a9 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -6850,6 +6850,7 @@ "Auto-approva le richieste": "Approuver automatiquement", "Disattiva la fase di approvazione manuale per i nuovi prestiti richiesti dagli utenti.": "Désactive l'approbation manuelle des nouveaux prêts demandés par les usagers.", "Prestito approvato. Il libro è in attesa di ritiro.": "Prêt approuvé. Le livre est en attente de retrait.", + "Prestito approvato: potrai ritirare il libro dal %s.": "Prêt approuvé : vous pourrez retirer le livre à partir du %s.", "Stile interfaccia": "Style de l'interface", "Cambia la composizione del sito pubblico. Contenuti CMS, sfondo hero, colori e funzionalità restano invariati.": "Modifie la composition du site public. Les contenus CMS, l'arrière-plan principal, les couleurs et les fonctionnalités restent inchangés.", "Un solo frontend": "Une interface partagée", @@ -8891,6 +8892,64 @@ "Rinnova prestito (+%d giorni)": "Renouveler l'emprunt (+%d jours)", "La scadenza verrà estesa di %d giorni": "La date d'échéance sera prolongée de %d jours", "Rinnovare il prestito? La scadenza verrà estesa di %d giorni.": "Renouveler l'emprunt ? La date d'échéance sera prolongée de %d jours.", + "Eccezioni per tipologia di lettore e tipo di materiale: prestabilità, prestiti massimi di quel materiale (oltre al limite totale), durata e rinnovi. I valori in grigio sono quelli ereditati; scrivi un valore solo dove serve un'eccezione.": "Exceptions par catégorie de lecteur et type de support : possibilité de prêt, prêts maximum de ce support (en plus de la limite totale), durée et renouvellements. Les valeurs en gris sont héritées ; saisissez une valeur seulement là où une exception est nécessaire.", + "Aggiunge le festività fisse come chiusure annuali, e Pasqua e Pasquetta per l'anno scelto. Le chiusure già presenti non vengono duplicate.": "Ajoute les jours fériés fixes comme fermetures annuelles, ainsi que Pâques et le lundi de Pâques pour l'année choisie. Les fermetures déjà présentes ne sont pas dupliquées.", + "Aggiungi chiusura": "Ajouter une fermeture", + "Aggiungi una chiusura o importa le festività nazionali.": "Ajoutez une fermeture ou importez les jours fériés nationaux.", + "Almeno un giorno della settimana deve restare aperto.": "Au moins un jour de la semaine doit rester ouvert.", + "Biblioteca chiusa": "Bibliothèque fermée", + "Calendario chiusure": "Calendrier des fermetures", + "Capodanno": "Jour de l'an", + "Chiusura": "Fermeture", + "Chiusura aggiornata.": "Fermeture mise à jour.", + "Chiusura aggiunta.": "Fermeture ajoutée.", + "Chiusura eliminata.": "Fermeture supprimée.", + "Chiusura non trovata.": "Fermeture introuvable.", + "Chiusura settimanale": "Fermeture hebdomadaire", + "Chiusure": "Fermetures", + "Data non valida: usa il formato AAAA-MM-GG.": "Date non valide : utilisez le format AAAA-MM-JJ.", + "Eliminare questa chiusura?": "Supprimer cette fermeture ?", + "Epifania": "Épiphanie", + "Ferragosto": "Assomption (Ferragosto)", + "Festa del Lavoro": "Fête du Travail", + "Festa della Liberazione": "Fête de la Libération", + "Festa della Repubblica": "Fête de la République", + "Festività nazionali importate: %d nuove chiusure.": "Jours fériés nationaux importés : %d nouvelles fermetures.", + "Festività nazionali italiane": "Jours fériés nationaux italiens", + "File ICS": "Fichier ICS", + "File ICS importato: %1$d chiusure aggiunte, %2$d già presenti.": "Fichier ICS importé : %1$d fermetures ajoutées, %2$d déjà présentes.", + "File ICS importato: %1$d chiusure aggiunte, %2$d già presenti, %3$d scartate perché più lunghe del limite (un anno se annuali, dieci anni altrimenti).": "Fichier ICS importé : %1$d fermetures ajoutées, %2$d déjà présentes, %3$d écartées car plus longues que la limite (un an si annuelles, dix ans sinon).", + "Gestisci il calendario chiusure": "Gérer le calendrier des fermetures", + "Giorni di chiusura e festività": "Jours de fermeture et fériés", + "Giorni di chiusura settimanale salvati.": "Jours de fermeture hebdomadaire enregistrés.", + "Giorni in cui la biblioteca è chiusa: le scadenze che vi cadono passano al primo giorno di apertura e i giorni per il ritiro contano solo i giorni di apertura.": "Jours où la bibliothèque est fermée : les échéances qui y tombent passent au jour d'ouverture suivant et le délai de retrait ne compte que les jours d'ouverture.", + "Il file ICS supera la dimensione massima di 1 MB.": "Le fichier ICS dépasse la taille maximale de 1 Mo.", + "Il file non è un calendario ICS valido.": "Le fichier n'est pas un calendrier ICS valide.", + "Immacolata Concezione": "Immaculée Conception", + "Importa ICS": "Importer l'ICS", + "Importa da file ICS": "Importer depuis un fichier ICS", + "Importa festività": "Importer les jours fériés", + "La biblioteca è chiusa in questo giorno: scegli un altro giorno di inizio.": "La bibliothèque est fermée ce jour-là : choisissez un autre jour de début.", + "Lunedì dell'Angelo (Pasquetta)": "Lundi de Pâques", + "Mese precedente": "Mois précédent", + "Mese successivo": "Mois suivant", + "Modifica chiusura": "Modifier la fermeture", + "Natale": "Noël", + "Nessuna chiusura configurata": "Aucune fermeture configurée", + "Ogni anno": "Chaque année", + "Ogni evento del calendario diventa un giorno o un periodo di chiusura. Solo una ricorrenza annuale semplice (RRULE FREQ=YEARLY, senza COUNT, UNTIL, EXDATE né giorni della settimana, ogni anno nella stessa data) si ripete ogni anno; ogni altra ricorrenza importa solo la prima occorrenza come chiusura singola.": "Chaque événement du calendrier devient un jour ou une période de fermeture. Seule une récurrence annuelle simple (RRULE FREQ=YEARLY sans COUNT, UNTIL, EXDATE ni jours de la semaine, chaque année à la même date) se répète chaque année ; toute autre récurrence n'importe que sa première occurrence comme fermeture unique.", + "Ognissanti": "Toussaint", + "Pasqua": "Pâques", + "Ricorrenza": "Récurrence", + "Santo Stefano": "Saint-Étienne", + "Sede non trovata.": "Site introuvable.", + "Seleziona i giorni della settimana in cui la biblioteca è sempre chiusa.": "Sélectionnez les jours de la semaine où la bibliothèque est toujours fermée.", + "Seleziona un file ICS da importare.": "Sélectionnez un fichier ICS à importer.", + "Si ripete ogni anno": "Se répète chaque année", + "Una chiusura annuale non può durare più di un anno.": "Une fermeture annuelle ne peut pas durer plus d'un an.", + "Una chiusura non può durare più di dieci anni.": "Une fermeture ne peut pas durer plus de dix ans.", + "Una volta": "Une fois", + "es. Chiusura estiva": "ex. Fermeture estivale", "Senza scadenza tessera nel file, la data viene calcolata dalla durata tessera della tipologia lettore (quella indicata o la predefinita); una scadenza presente nel CSV ha la precedenza.": "Sans date d'expiration de la carte dans le fichier, la date est calculée à partir de la durée de carte du type de lecteur (celui indiqué ou celui par défaut) ; une expiration présente dans le CSV a la priorité.", "Utente importato, ma la scadenza della tessera non è stata calcolata.": "Utilisateur importé, mais l'expiration de la carte n'a pas pu être calculée.", "Calcola la scadenza dalla durata tessera della tipologia lettore (se indicata e se la data qui sopra è lasciata vuota)": "Calculer l'expiration à partir de la durée de carte du type de lecteur (si indiquée et si la date ci-dessus est laissée vide)", @@ -8907,6 +8966,10 @@ "Questa funzione sarà disponibile al termine dell'aggiornamento del database.": "Cette fonction sera disponible une fois la mise à jour de la base de données terminée.", "Segnate come perse": "Marquées comme perdues", "Le copie in prestito, in attesa di ritiro, danneggiate, già perse, in restauro, in trasferimento o in manutenzione non vengono toccate.": "Les exemplaires en prêt, en attente de retrait, endommagés, déjà perdus, en restauration, en transfert ou en maintenance ne sont pas modifiés.", + "Giorno della settimana non valido.": "Jour de la semaine non valide.", + "Un'altra modifica del calendario è in corso: riprova tra poco.": "Une autre modification du calendrier est en cours : réessayez dans un instant.", + "Con questa chiusura annuale la biblioteca resterebbe chiusa tutto l'anno: almeno un giorno deve restare aperto.": "Avec cette fermeture annuelle, la bibliothèque serait fermée toute l'année : au moins un jour doit rester ouvert.", + "Con questi giorni di chiusura settimanale e le chiusure annuali la biblioteca resterebbe chiusa tutto l'anno: almeno un giorno deve restare aperto.": "Avec ces jours de fermeture hebdomadaire et les fermetures annuelles, la bibliothèque serait fermée toute l'année : au moins un jour doit rester ouvert.", "Fornitore non assegnato: è stato eliminato durante l'importazione.": "Fournisseur non attribué : il a été supprimé pendant l'importation.", "Il file dell'import non è più disponibile: le righe già elaborate sono state registrate nello storico import. Carica di nuovo il file per completarlo.": "Le fichier d'importation n'est plus disponible : les lignes déjà traitées ont été enregistrées dans l'historique des importations. Chargez à nouveau le fichier pour la terminer.", "Il file dell'import interrotto «%s» non era più disponibile: l'import è stato chiuso dopo %d di %d righe e registrato nello storico import. Gli utenti già creati restano; per completarlo carica di nuovo il file.": "Le fichier de l'importation interrompue «%s» n'était plus disponible : l'importation a été fermée après %d lignes sur %d et enregistrée dans l'historique des importations. Les utilisateurs déjà créés sont conservés ; pour la terminer, chargez à nouveau le fichier.", @@ -8933,6 +8996,7 @@ "Annulla import": "Annuler l'importation", "Riprendi import": "Reprendre l'importation", "Utente non eliminato: la sua foto non può essere rimossa dal disco. Riprova o controlla i permessi della cartella storage.": "Utilisateur non supprimé : sa photo ne peut pas être retirée du disque. Réessayez ou vérifiez les droits du dossier storage.", + "%d scartate perché con le altre chiusure annuali lascerebbero la biblioteca chiusa tutto l'anno.": "%d écartées car, avec les autres fermetures annuelles, elles laisseraient la bibliothèque fermée toute l'année.", "L'import precedente è stato chiuso, ma lo storico import non è stato aggiornato: gli utenti già creati restano.": "L'importation précédente a été fermée, mais l'historique des importations n'a pas pu être mis à jour : les utilisateurs déjà créés sont conservés.", "Il file dell'import non è più disponibile e lo storico import non è stato aggiornato: gli utenti già creati restano. Carica di nuovo il file per completarlo.": "Le fichier d'importation n'est plus disponible et l'historique des importations n'a pas pu être mis à jour : les utilisateurs déjà créés sont conservés. Chargez à nouveau le fichier pour terminer l'importation.", "Il file dell'import interrotto «%s» non era più disponibile: l'import è stato chiuso dopo %d di %d righe, ma lo storico import non è stato aggiornato. Gli utenti già creati restano; per completarlo carica di nuovo il file.": "Le fichier de l'importation interrompue «%s» n'était plus disponible : l'importation a été fermée après %d lignes sur %d, mais l'historique des importations n'a pas pu être mis à jour. Les utilisateurs déjà créés sont conservés ; pour la terminer, chargez à nouveau le fichier.", diff --git a/locale/it_IT.json b/locale/it_IT.json index 493f59aab..40466bbf4 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -6850,6 +6850,7 @@ "Auto-approva le richieste": "Auto-approva le richieste", "Disattiva la fase di approvazione manuale per i nuovi prestiti richiesti dagli utenti.": "Disattiva la fase di approvazione manuale per i nuovi prestiti richiesti dagli utenti.", "Prestito approvato. Il libro è in attesa di ritiro.": "Prestito approvato. Il libro è in attesa di ritiro.", + "Prestito approvato: potrai ritirare il libro dal %s.": "Prestito approvato: potrai ritirare il libro dal %s.", "Stile interfaccia": "Stile interfaccia", "Cambia la composizione del sito pubblico. Contenuti CMS, sfondo hero, colori e funzionalità restano invariati.": "Cambia la composizione del sito pubblico. Contenuti CMS, sfondo hero, colori e funzionalità restano invariati.", "Un solo frontend": "Un solo frontend", @@ -8891,6 +8892,64 @@ "Rinnova prestito (+%d giorni)": "Rinnova prestito (+%d giorni)", "La scadenza verrà estesa di %d giorni": "La scadenza verrà estesa di %d giorni", "Rinnovare il prestito? La scadenza verrà estesa di %d giorni.": "Rinnovare il prestito? La scadenza verrà estesa di %d giorni.", + "Eccezioni per tipologia di lettore e tipo di materiale: prestabilità, prestiti massimi di quel materiale (oltre al limite totale), durata e rinnovi. I valori in grigio sono quelli ereditati; scrivi un valore solo dove serve un'eccezione.": "Eccezioni per tipologia di lettore e tipo di materiale: prestabilità, prestiti massimi di quel materiale (oltre al limite totale), durata e rinnovi. I valori in grigio sono quelli ereditati; scrivi un valore solo dove serve un'eccezione.", + "Aggiunge le festività fisse come chiusure annuali, e Pasqua e Pasquetta per l'anno scelto. Le chiusure già presenti non vengono duplicate.": "Aggiunge le festività fisse come chiusure annuali, e Pasqua e Pasquetta per l'anno scelto. Le chiusure già presenti non vengono duplicate.", + "Aggiungi chiusura": "Aggiungi chiusura", + "Aggiungi una chiusura o importa le festività nazionali.": "Aggiungi una chiusura o importa le festività nazionali.", + "Almeno un giorno della settimana deve restare aperto.": "Almeno un giorno della settimana deve restare aperto.", + "Biblioteca chiusa": "Biblioteca chiusa", + "Calendario chiusure": "Calendario chiusure", + "Capodanno": "Capodanno", + "Chiusura": "Chiusura", + "Chiusura aggiornata.": "Chiusura aggiornata.", + "Chiusura aggiunta.": "Chiusura aggiunta.", + "Chiusura eliminata.": "Chiusura eliminata.", + "Chiusura non trovata.": "Chiusura non trovata.", + "Chiusura settimanale": "Chiusura settimanale", + "Chiusure": "Chiusure", + "Data non valida: usa il formato AAAA-MM-GG.": "Data non valida: usa il formato AAAA-MM-GG.", + "Eliminare questa chiusura?": "Eliminare questa chiusura?", + "Epifania": "Epifania", + "Ferragosto": "Ferragosto", + "Festa del Lavoro": "Festa del Lavoro", + "Festa della Liberazione": "Festa della Liberazione", + "Festa della Repubblica": "Festa della Repubblica", + "Festività nazionali importate: %d nuove chiusure.": "Festività nazionali importate: %d nuove chiusure.", + "Festività nazionali italiane": "Festività nazionali italiane", + "File ICS": "File ICS", + "File ICS importato: %1$d chiusure aggiunte, %2$d già presenti.": "File ICS importato: %1$d chiusure aggiunte, %2$d già presenti.", + "File ICS importato: %1$d chiusure aggiunte, %2$d già presenti, %3$d scartate perché più lunghe del limite (un anno se annuali, dieci anni altrimenti).": "File ICS importato: %1$d chiusure aggiunte, %2$d già presenti, %3$d scartate perché più lunghe del limite (un anno se annuali, dieci anni altrimenti).", + "Gestisci il calendario chiusure": "Gestisci il calendario chiusure", + "Giorni di chiusura e festività": "Giorni di chiusura e festività", + "Giorni di chiusura settimanale salvati.": "Giorni di chiusura settimanale salvati.", + "Giorni in cui la biblioteca è chiusa: le scadenze che vi cadono passano al primo giorno di apertura e i giorni per il ritiro contano solo i giorni di apertura.": "Giorni in cui la biblioteca è chiusa: le scadenze che vi cadono passano al primo giorno di apertura e i giorni per il ritiro contano solo i giorni di apertura.", + "Il file ICS supera la dimensione massima di 1 MB.": "Il file ICS supera la dimensione massima di 1 MB.", + "Il file non è un calendario ICS valido.": "Il file non è un calendario ICS valido.", + "Immacolata Concezione": "Immacolata Concezione", + "Importa ICS": "Importa ICS", + "Importa da file ICS": "Importa da file ICS", + "Importa festività": "Importa festività", + "La biblioteca è chiusa in questo giorno: scegli un altro giorno di inizio.": "La biblioteca è chiusa in questo giorno: scegli un altro giorno di inizio.", + "Lunedì dell'Angelo (Pasquetta)": "Lunedì dell'Angelo (Pasquetta)", + "Mese precedente": "Mese precedente", + "Mese successivo": "Mese successivo", + "Modifica chiusura": "Modifica chiusura", + "Natale": "Natale", + "Nessuna chiusura configurata": "Nessuna chiusura configurata", + "Ogni anno": "Ogni anno", + "Ogni evento del calendario diventa un giorno o un periodo di chiusura. Solo una ricorrenza annuale semplice (RRULE FREQ=YEARLY, senza COUNT, UNTIL, EXDATE né giorni della settimana, ogni anno nella stessa data) si ripete ogni anno; ogni altra ricorrenza importa solo la prima occorrenza come chiusura singola.": "Ogni evento del calendario diventa un giorno o un periodo di chiusura. Solo una ricorrenza annuale semplice (RRULE FREQ=YEARLY, senza COUNT, UNTIL, EXDATE né giorni della settimana, ogni anno nella stessa data) si ripete ogni anno; ogni altra ricorrenza importa solo la prima occorrenza come chiusura singola.", + "Ognissanti": "Ognissanti", + "Pasqua": "Pasqua", + "Ricorrenza": "Ricorrenza", + "Santo Stefano": "Santo Stefano", + "Sede non trovata.": "Sede non trovata.", + "Seleziona i giorni della settimana in cui la biblioteca è sempre chiusa.": "Seleziona i giorni della settimana in cui la biblioteca è sempre chiusa.", + "Seleziona un file ICS da importare.": "Seleziona un file ICS da importare.", + "Si ripete ogni anno": "Si ripete ogni anno", + "Una chiusura annuale non può durare più di un anno.": "Una chiusura annuale non può durare più di un anno.", + "Una chiusura non può durare più di dieci anni.": "Una chiusura non può durare più di dieci anni.", + "Una volta": "Una volta", + "es. Chiusura estiva": "es. Chiusura estiva", "Senza scadenza tessera nel file, la data viene calcolata dalla durata tessera della tipologia lettore (quella indicata o la predefinita); una scadenza presente nel CSV ha la precedenza.": "Senza scadenza tessera nel file, la data viene calcolata dalla durata tessera della tipologia lettore (quella indicata o la predefinita); una scadenza presente nel CSV ha la precedenza.", "Utente importato, ma la scadenza della tessera non è stata calcolata.": "Utente importato, ma la scadenza della tessera non è stata calcolata.", "Calcola la scadenza dalla durata tessera della tipologia lettore (se indicata e se la data qui sopra è lasciata vuota)": "Calcola la scadenza dalla durata tessera della tipologia lettore (se indicata e se la data qui sopra è lasciata vuota)", @@ -8907,6 +8966,10 @@ "Questa funzione sarà disponibile al termine dell'aggiornamento del database.": "Questa funzione sarà disponibile al termine dell'aggiornamento del database.", "Segnate come perse": "Segnate come perse", "Le copie in prestito, in attesa di ritiro, danneggiate, già perse, in restauro, in trasferimento o in manutenzione non vengono toccate.": "Le copie in prestito, in attesa di ritiro, danneggiate, già perse, in restauro, in trasferimento o in manutenzione non vengono toccate.", + "Giorno della settimana non valido.": "Giorno della settimana non valido.", + "Un'altra modifica del calendario è in corso: riprova tra poco.": "Un'altra modifica del calendario è in corso: riprova tra poco.", + "Con questa chiusura annuale la biblioteca resterebbe chiusa tutto l'anno: almeno un giorno deve restare aperto.": "Con questa chiusura annuale la biblioteca resterebbe chiusa tutto l'anno: almeno un giorno deve restare aperto.", + "Con questi giorni di chiusura settimanale e le chiusure annuali la biblioteca resterebbe chiusa tutto l'anno: almeno un giorno deve restare aperto.": "Con questi giorni di chiusura settimanale e le chiusure annuali la biblioteca resterebbe chiusa tutto l'anno: almeno un giorno deve restare aperto.", "Fornitore non assegnato: è stato eliminato durante l'importazione.": "Fornitore non assegnato: è stato eliminato durante l'importazione.", "Il file dell'import non è più disponibile: le righe già elaborate sono state registrate nello storico import. Carica di nuovo il file per completarlo.": "Il file dell'import non è più disponibile: le righe già elaborate sono state registrate nello storico import. Carica di nuovo il file per completarlo.", "Il file dell'import interrotto «%s» non era più disponibile: l'import è stato chiuso dopo %d di %d righe e registrato nello storico import. Gli utenti già creati restano; per completarlo carica di nuovo il file.": "Il file dell'import interrotto «%s» non era più disponibile: l'import è stato chiuso dopo %d di %d righe e registrato nello storico import. Gli utenti già creati restano; per completarlo carica di nuovo il file.", @@ -8933,6 +8996,7 @@ "Annulla import": "Annulla import", "Riprendi import": "Riprendi import", "Utente non eliminato: la sua foto non può essere rimossa dal disco. Riprova o controlla i permessi della cartella storage.": "Utente non eliminato: la sua foto non può essere rimossa dal disco. Riprova o controlla i permessi della cartella storage.", + "%d scartate perché con le altre chiusure annuali lascerebbero la biblioteca chiusa tutto l'anno.": "%d scartate perché con le altre chiusure annuali lascerebbero la biblioteca chiusa tutto l'anno.", "L'import precedente è stato chiuso, ma lo storico import non è stato aggiornato: gli utenti già creati restano.": "L'import precedente è stato chiuso, ma lo storico import non è stato aggiornato: gli utenti già creati restano.", "Il file dell'import non è più disponibile e lo storico import non è stato aggiornato: gli utenti già creati restano. Carica di nuovo il file per completarlo.": "Il file dell'import non è più disponibile e lo storico import non è stato aggiornato: gli utenti già creati restano. Carica di nuovo il file per completarlo.", "Il file dell'import interrotto «%s» non era più disponibile: l'import è stato chiuso dopo %d di %d righe, ma lo storico import non è stato aggiornato. Gli utenti già creati restano; per completarlo carica di nuovo il file.": "Il file dell'import interrotto «%s» non era più disponibile: l'import è stato chiuso dopo %d di %d righe, ma lo storico import non è stato aggiornato. Gli utenti già creati restano; per completarlo carica di nuovo il file.", diff --git a/public/assets/main.css b/public/assets/main.css index 6284977c2..ca36da079 100644 --- a/public/assets/main.css +++ b/public/assets/main.css @@ -4625,6 +4625,9 @@ input:where([type='file']):focus { .grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.grid-cols-7 { + grid-template-columns: repeat(7, minmax(0, 1fr)); +} .grid-cols-\[auto_1fr_1fr\] { grid-template-columns: auto 1fr 1fr; } @@ -6108,6 +6111,10 @@ input:where([type='file']):focus { --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); } +.ring-gray-500 { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(107 114 128 / var(--tw-ring-opacity, 1)); +} .ring-green-100 { --tw-ring-opacity: 1; --tw-ring-color: rgb(220 252 231 / var(--tw-ring-opacity, 1)); diff --git a/storage/plugins/mobile-api/src/Controllers/ActionsController.php b/storage/plugins/mobile-api/src/Controllers/ActionsController.php index 0247c656c..8d2f8aa77 100644 --- a/storage/plugins/mobile-api/src/Controllers/ActionsController.php +++ b/storage/plugins/mobile-api/src/Controllers/ActionsController.php @@ -10,9 +10,11 @@ use App\Controllers\UserActionsController; use App\Controllers\UserWishlistController; use App\Plugins\MobileApi\Support\AppAuthMiddleware; +use App\Plugins\MobileApi\Support\CirculationOutcome; use App\Plugins\MobileApi\Support\JsonBody; use App\Plugins\MobileApi\Support\ResponseEnvelope; use App\Support\DateHelper; +use App\Support\LoanWindow; use App\Support\SecureLogger; use mysqli; use Psr\Http\Message\ResponseInterface; @@ -257,19 +259,32 @@ public function requestReservation(Request $request, ResponseInterface $response $outcome = $this->redirectOutcome($result); if (isset($outcome['loan_request_success'])) { - $autoApproved = ($outcome['auto_approved'] ?? '0') === '1'; + // The persisted state, not a guess: with the library closed + // today the approved loan starts on the next open day and is + // `prenotato`, not awaiting pickup. + $status = CirculationOutcome::requestState($outcome); + $loanId = isset($outcome['loan_id']) ? (int) $outcome['loan_id'] : null; + $startDate = $status === 'prenotato' ? $this->loanStartDate($loanId) : null; + $message = match ($status) { + // Without the stored start the day is not promised: + // today would be wrong when the library is closed. + 'prenotato' => $startDate !== null + ? __('Prestito approvato: potrai ritirare il libro dal %s.', LoanWindow::displayDate($startDate)) + : __('Prestito prenotato con successo'), + 'da_ritirare' => __('Prestito approvato. Il libro è in attesa di ritiro.'), + default => __('Richiesta di prestito inviata. In attesa di approvazione.'), + }; return ResponseEnvelope::success( $response, [ 'type' => 'loan', 'book_id' => $bookId, - 'loan_id' => isset($outcome['loan_id']) ? (int) $outcome['loan_id'] : null, - 'auto_approved' => $autoApproved, - 'status' => $autoApproved ? 'da_ritirare' : 'pendente', + 'loan_id' => $loanId, + 'auto_approved' => $status !== 'pendente', + 'status' => $status, + 'start_date' => $startDate, ], - ['message' => $autoApproved - ? __('Prestito approvato. Il libro è in attesa di ritiro.') - : __('Richiesta di prestito inviata. In attesa di approvazione.')], + ['message' => $message], 201 ); } @@ -1186,6 +1201,28 @@ private function classifyCancellable(int $userId, int $id): ?string * * @return array */ + /** The stored start day (prestiti.data_prestito) of a loan, null when unknown. */ + private function loanStartDate(?int $loanId): ?string + { + if ($loanId === null || $loanId <= 0) { + return null; + } + try { + $stmt = $this->db->prepare('SELECT data_prestito FROM prestiti WHERE id = ?'); + if ($stmt === false) { + return null; + } + $stmt->bind_param('i', $loanId); + $stmt->execute(); + $row = $stmt->get_result()->fetch_row(); + $stmt->close(); + $start = $row === null || $row[0] === null ? '' : substr((string) $row[0], 0, 10); + return DateHelper::isISODateFormat($start) ? $start : null; + } catch (\Throwable $e) { + return null; + } + } + private function redirectOutcome(ResponseInterface $result): array { $location = $result->getHeaderLine('Location'); diff --git a/storage/plugins/mobile-api/src/Controllers/CatalogController.php b/storage/plugins/mobile-api/src/Controllers/CatalogController.php index 0f5a0ba4a..06b6a8840 100644 --- a/storage/plugins/mobile-api/src/Controllers/CatalogController.php +++ b/storage/plugins/mobile-api/src/Controllers/CatalogController.php @@ -865,7 +865,7 @@ public function bookAvailability( * route GET /staff/books/{id}/availability (Mobile API 1.7.0). * * @param array $avail - * @return array{total_copies:int, earliest_available:mixed, unavailable_dates:list, days:list>} + * @return array{total_copies:int, earliest_available:mixed, unavailable_dates:list, closed_dates:list, days:list>} */ public static function mapAvailability(array $avail): array { @@ -873,6 +873,9 @@ public static function mapAvailability(array $avail): array 'total_copies' => (int) ($avail['total_copies'] ?? 0), 'earliest_available' => $avail['earliest_available'] ?? null, 'unavailable_dates' => array_values((array) ($avail['unavailable_dates'] ?? [])), + // Closures calendar (additive): days the library is closed, which + // cannot be a start/pickup day; a return date on one moves forward. + 'closed_dates' => array_values((array) ($avail['closed_dates'] ?? [])), 'days' => self::normalizeMobileAvailabilityDays( array_values((array) ($avail['days'] ?? [])), (int) ($avail['total_copies'] ?? 0) diff --git a/storage/plugins/mobile-api/src/Controllers/OpenApiController.php b/storage/plugins/mobile-api/src/Controllers/OpenApiController.php index d2e56e8b5..950f9ea4d 100644 --- a/storage/plugins/mobile-api/src/Controllers/OpenApiController.php +++ b/storage/plugins/mobile-api/src/Controllers/OpenApiController.php @@ -600,6 +600,7 @@ private function paths(): array 'total_copies' => ['type' => 'integer'], 'earliest_available' => ['type' => 'string', 'format' => 'date', 'nullable' => true], 'unavailable_dates' => ['type' => 'array', 'items' => ['type' => 'string', 'format' => 'date']], + 'closed_dates' => ['type' => 'array', 'items' => ['type' => 'string', 'format' => 'date'], 'description' => 'Days the library is closed (closures calendar): not selectable as a start/pickup day; a return date on one moves to the next open day.'], 'days' => ['type' => 'array', 'items' => [ 'type' => 'object', 'properties' => [ @@ -711,7 +712,7 @@ private function paths(): array 'post' => [ 'tags' => ['loans'], 'summary' => 'Request a reservation / loan', - 'description' => 'Honors existing overlap, availability, max-active-loans, and automatic-approval settings (same as the web form). For immediate loans, data.auto_approved and data.status tell the client whether the request is pending or ready for pickup.', + 'description' => 'Honors existing overlap, availability, max-active-loans, and automatic-approval settings (same as the web form). For immediate loans, data.auto_approved and data.status tell the client whether the request is pending (pendente), ready for pickup (da_ritirare) or, when the library is closed today, scheduled to start on the next open day (prenotato, with data.start_date; Pinakes 0.9.0).', 'operationId' => 'postReservation', 'security' => [['bearerAuth' => []]], 'requestBody' => [ @@ -730,7 +731,8 @@ private function paths(): array 'book_id' => ['type' => 'integer'], 'loan_id' => ['type' => 'integer', 'nullable' => true], 'auto_approved' => ['type' => 'boolean', 'nullable' => true], - 'status' => ['type' => 'string', 'nullable' => true, 'enum' => ['pendente', 'da_ritirare']], + 'status' => ['type' => 'string', 'nullable' => true, 'enum' => ['pendente', 'da_ritirare', 'prenotato']], + 'start_date' => ['type' => 'string', 'format' => 'date', 'nullable' => true, 'description' => 'Start day of a scheduled (prenotato) loan; null otherwise.'], ], ]], ]]], diff --git a/storage/plugins/mobile-api/src/Support/CirculationOutcome.php b/storage/plugins/mobile-api/src/Support/CirculationOutcome.php index 1a1f5b538..a97908e2d 100644 --- a/storage/plugins/mobile-api/src/Support/CirculationOutcome.php +++ b/storage/plugins/mobile-api/src/Support/CirculationOutcome.php @@ -217,6 +217,26 @@ public static function actionsFor(array $row, int $maxRenewals, string $today): } } + /** + * The state of a reader's loan request after UserActionsController::loan() + * (POST /reservations on an available title): the request stays + * `pendente` unless it was auto-approved, in which case loan() threads + * the persisted state through the redirect as `loan_state`: `prenotato` + * when the start day is ahead (the library is closed today, so the loan + * starts on the next open day), `da_ritirare` when it starts today. An + * approved request without a usable state is reported as `da_ritirare`. + * + * @param array $outcome the redirect query of loan() + */ + public static function requestState(array $outcome): string + { + if (($outcome['auto_approved'] ?? '0') !== '1') { + return 'pendente'; + } + $state = (string) ($outcome['loan_state'] ?? ''); + return in_array($state, ['prenotato', 'da_ritirare'], true) ? $state : 'da_ritirare'; + } + /** * Presentation hint for a reservation row. * diff --git a/storage/plugins/ncip-server/NcipServerPlugin.php b/storage/plugins/ncip-server/NcipServerPlugin.php index a8e41cab4..be5bcbaca 100644 --- a/storage/plugins/ncip-server/NcipServerPlugin.php +++ b/storage/plugins/ncip-server/NcipServerPlugin.php @@ -889,9 +889,11 @@ private function handleCheckOutItem( } // Atomic checkout: lock the book row so concurrent requests serialize. + // The due date is the library-wide one; createLoanAtomic() moves it + // with the closures of the chosen copy's branch and hands it back. $today = \App\Support\DateHelper::today(); $loanDays = (new \App\Services\CirculationPolicy($this->db))->forLoan($userId, $itemId)->durationDays; - $dueDate = \App\Services\CirculationPolicy::dueDateFor($today, $loanDays); + $dueDate = \App\Services\CirculationPolicy::dueDateFor($today, $loanDays, $this->db); $failureReason = null; $loanId = $this->createLoanAtomic($itemId, $userId, $dueDate, (int) ($caller['id'] ?? 0), $failureReason); @@ -1168,7 +1170,7 @@ private function handleRequestItem( // A request is capped like the website and app ones: the loan length, // but never past the longest request window of the patron type. $loanDays = (new \App\Services\CirculationPolicy($this->db))->forLoan($userId, $itemId)->requestDurationDays(); - $dueDate = \App\Services\CirculationPolicy::dueDateFor($today, $loanDays); + $dueDate = \App\Services\CirculationPolicy::dueDateFor($today, $loanDays, $this->db); $failureReason = 'db_error'; $requestOutcome = $this->createRequestItemNcip( $itemId, @@ -1858,9 +1860,13 @@ private function findActiveLoan( * terminal — 'not_found' | 'duplicate' | 'ineligible' | 'max_loans' | * 'no_capacity' | 'no_copy'; transient/retryable — 'invalid_due' | 'db_error'. * + * $dueDate is the due date computed for the library as a whole; the copy + * is chosen only here, so a closure of its branch can move it (same rule + * as the staff desk). On success it holds the date stored on the loan. + * * @param-out string $failureReason */ - private function createLoanAtomic(int $bookId, int $userId, string $dueDate, int $processedBy, ?string &$failureReason = null): ?int + private function createLoanAtomic(int $bookId, int $userId, string &$dueDate, int $processedBy, ?string &$failureReason = null): ?int { $failureReason = 'db_error'; $this->db->begin_transaction(); @@ -1982,21 +1988,73 @@ private function createLoanAtomic(int $bookId, int $userId, string $dueDate, int OR (future.stato = 'pendente' AND future.copia_id IS NOT NULL)) ), '9999-12-31') DESC, c.numero_inventario ASC - LIMIT 1 FOR UPDATE"; + FOR UPDATE"; $copyParams[] = $dueDate; $copyTypes .= 's'; $copy = $this->db->prepare($copySql); $copy->bind_param($copyTypes, ...$copyParams); $copy->execute(); - $copyRow = $copy->get_result()->fetch_assoc(); + $copyRows = $copy->get_result()->fetch_all(MYSQLI_ASSOC); $copy->close(); - if (!$copyRow) { + if ($copyRows === []) { $failureReason = 'no_copy'; $this->db->rollback(); return null; } - $copyId = (int) $copyRow['id']; + + // The copy is known only now: a due date closed at its branch moves + // to the branch's next open day, as PrestitiController::store() does + // for a desk loan, and the longer window is checked again, for the + // title's capacity and for the copy's own commitments (the copy was + // selected free up to the shorter date only). When that longer + // window does not fit, the next candidate (in the same preference + // order) is tried: another branch may be open on the original due + // date. Every candidate is locked. + $copyId = null; + $chosenDue = $dueDate; + $lastFailure = 'no_copy'; + foreach ($copyRows as $copyRow) { + $candidateId = (int) $copyRow['id']; + $branchDue = \App\Services\CirculationPolicy::dueDateFor($dueDate, 0, $this->db, $candidateId); + if ($branchDue === $dueDate) { + $copyId = $candidateId; + $chosenDue = $dueDate; + break; + } + if (!$capacity->hasFreeCapacity($bookId, $today, $branchDue)) { + $lastFailure = 'no_capacity'; + continue; + } + $busy = $this->db->prepare( + "SELECT 1 FROM prestiti p + WHERE p.copia_id = ? + AND p.data_prestito <= ? + AND (p.stato = 'in_ritardo' + OR (p.stato = 'in_corso' AND p.data_scadenza < ?) + OR p.data_scadenza >= ?) + AND ((p.attivo = 1 AND p.stato IN ('prenotato','da_ritirare','in_corso','in_ritardo')) + OR (p.attivo = 0 AND p.stato = 'pendente' AND p.copia_id IS NOT NULL)) + LIMIT 1 FOR UPDATE" + ); + $busy->bind_param('isss', $candidateId, $branchDue, $today, $today); + $busy->execute(); + $copyBusy = (bool) $busy->get_result()->fetch_row(); + $busy->close(); + if ($copyBusy) { + $lastFailure = 'no_copy'; + continue; + } + $copyId = $candidateId; + $chosenDue = $branchDue; + break; + } + if ($copyId === null) { + $failureReason = $lastFailure; + $this->db->rollback(); + return null; + } + $dueDate = $chosenDue; $ins = $this->db->prepare( "INSERT INTO prestiti @@ -2796,7 +2854,7 @@ private function extendLoan( } $renewDays = $renewPolicy->renewalDays; $currentDue = (string) $loan['data_scadenza']; - $newDueDate = \App\Services\CirculationPolicy::dueDateFor($currentDue, $renewDays); + $newDueDate = \App\Services\CirculationPolicy::dueDateFor($currentDue, $renewDays, $this->db, $loan['copia_id'] !== null ? (int) $loan['copia_id'] : null); // #336 parity con PrestitiController::renew/bulkExtend: il giorno di // scadenza corrente è già detenuto da QUESTO prestito, quindi la @@ -2812,7 +2870,7 @@ private function extendLoan( $this->db->rollback(); $failureReason = 'no_capacity'; // Same figures the web renew() redirect carries (LoanWindow). - $this->renewalCap = \App\Support\LoanWindow::renewalCap($currentDue, $blocked); + $this->renewalCap = \App\Support\LoanWindow::renewalCap($currentDue, $blocked, null, \App\Support\LibraryCalendar::forDb($this->db), \App\Support\LibraryCalendar::branchOfCopy($this->db, $copyId)); return null; } @@ -2838,7 +2896,7 @@ private function extendLoan( $this->db->rollback(); $failureReason = 'overlap'; // The loan's own copy is needed by a later loan: name that day. - $this->renewalCap = \App\Support\LoanWindow::renewalCap($currentDue, $blocked); + $this->renewalCap = \App\Support\LoanWindow::renewalCap($currentDue, $blocked, null, \App\Support\LibraryCalendar::forDb($this->db), \App\Support\LibraryCalendar::branchOfCopy($this->db, $copyId)); return null; } } diff --git a/tests/calendar-permission.unit.php b/tests/calendar-permission.unit.php new file mode 100644 index 000000000..44087a3e1 --- /dev/null +++ b/tests/calendar-permission.unit.php @@ -0,0 +1,129 @@ + 'standard', 'stato' => 'attivo', 'permissions' => ['backend.access', 'settings.manage']]; +$check(P::allowsPath('/admin/calendar', $configurator), 'a custom role with settings.manage opens the calendar page'); +$check(P::allowsPath('/admin/calendar/import-ics', $configurator, 'POST'), 'and reaches the ICS import'); +foreach (['/admin/books', '/admin/loans', '/admin/users', '/admin/inventory'] as $path) { + $check(!P::allowsPath($path, $configurator), "settings.manage does not open {$path}"); +} + +$desk = ['tipo_utente' => 'standard', 'stato' => 'attivo', 'permissions' => ['backend.access', 'loans.manage']]; +$check(!P::allowsPath('/admin/calendar', $desk), 'the loan desk permission alone does not open the calendar'); +$check(!P::allowsPath('/admin/calendar/closures', $desk, 'POST'), 'nor its writes'); +$check(P::allowsPath('/admin/calendar', ['tipo_utente' => 'staff', 'stato' => 'attivo']), 'Staff passes the middleware (the controller guard decides)'); +$check(P::allowsPath('/admin/calendar', ['tipo_utente' => 'admin', 'stato' => 'attivo']), 'administrators reach the calendar'); + +// The route closure of /admin/calendar/rotate-link refuses every non-admin +// itself: the map admits the configurator to the middleware only. +$routes = (string) file_get_contents(dirname(__DIR__) . '/app/Routes/web.php'); +$pos = strpos($routes, "'/admin/calendar/rotate-link'"); +$check($pos !== false && str_contains(substr($routes, $pos, 400), "!== 'admin'"), 'rotate-link checks tipo_utente admin inside its route'); + +// The same at request level: a POST to /admin/calendar/rotate-link through +// the real routes and middleware, as a custom role holding settings.manage +// (admitted by the map) and as staff, with a valid CSRF token. Both are +// refused by the route and the library feed secret is not rotated. +$root = dirname(__DIR__); +// config/settings.php (read by the session role re-validation) needs the environment. +Dotenv\Dotenv::createImmutable($root)->safeLoad(); +mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); +$env = Dotenv\Dotenv::parse((string) file_get_contents($root . '/.env')); +$db = new mysqli($env['DB_HOST'] ?? 'localhost', $env['DB_USER'], $env['DB_PASS'] ?? '', $env['DB_NAME'], (int) ($env['DB_PORT'] ?? 3306), getenv('E2E_DB_SOCKET') ?: ($env['DB_SOCKET'] ?? null)); +$db->set_charset('utf8mb4'); + +$containerBuilder = new DI\ContainerBuilder(); +require $root . '/config/settings.php'; +require $root . '/config/container.php'; +$containerBuilder->addDefinitions($containerDefinitions ?? []); +$containerBuilder->addDefinitions(['db' => $db]); +$container = $containerBuilder->build(); +Slim\Factory\AppFactory::setContainer($container); +\App\Support\Hooks::init($container->get('hookManager')); +$GLOBALS['hookManager'] = $container->get('hookManager'); +$app = Slim\Factory\AppFactory::create(); +$app->addBodyParsingMiddleware(); +$app->addRoutingMiddleware(); +(require $root . '/app/Routes/web.php')($app); + +$tag = 'calperm-' . bin2hex(random_bytes(4)); +$userIds = []; +$roleId = 0; +// The route writes the flash messages into $_SESSION while it runs. +$flash = static fn (string $key): mixed => $_SESSION[$key] ?? null; +$secretOf = static function () use ($db): ?string { + $row = $db->query("SELECT setting_value FROM system_settings WHERE category = 'calendar' AND setting_key = 'library_ics_secret'")->fetch_row(); + return $row === null ? null : (string) $row[0]; +}; +try { + (new \App\Models\CustomRoleRepository($db))->save([], ['nome' => $tag, 'permissions' => ['backend.access', 'settings.manage']]); + $roleId = (int) $db->query('SELECT id FROM ruoli WHERE nome = ' . "'" . $db->real_escape_string($tag) . "'")->fetch_row()[0]; + $addUser = static function (string $type, ?int $role) use ($db, $tag, &$userIds): int { + $code = $tag . count($userIds); + $email = $code . '@example.test'; + $stmt = $db->prepare("INSERT INTO utenti (nome, cognome, email, password, codice_tessera, tipo_utente, stato, email_verificata, ruolo_id) VALUES ('Cal', 'Perm', ?, 'unused', ?, ?, 'attivo', 1, ?)"); + $stmt->bind_param('sssi', $email, $code, $type, $role); + $stmt->execute(); + $id = (int) $stmt->insert_id; + $stmt->close(); + $userIds[] = $id; + return $id; + }; + // The secret exists before the attempts, so "unchanged" is a real comparison. + (new \App\Support\LoanCalendarLinks($db))->libraryFeedToken(1); + $secretBefore = $secretOf(); + $check($secretBefore !== null && $secretBefore !== '', 'rotate-link: the library feed secret exists before the attempts'); + + foreach (['custom role with settings.manage' => $addUser('standard', $roleId), 'staff' => $addUser('staff', null)] as $who => $userId) { + $_SESSION = ['user' => ['id' => $userId, 'tipo_utente' => 'standard', 'stato' => 'attivo']]; + $token = \App\Support\Csrf::ensureToken(); + $request = (new Slim\Psr7\Factory\ServerRequestFactory()) + ->createServerRequest('POST', '/admin/calendar/rotate-link') + ->withParsedBody(['csrf_token' => $token]); + $res = $app->handle($request); + $check($res->getStatusCode() === 302 && str_ends_with($res->getHeaderLine('Location'), '/admin/dashboard'), "rotate-link: the {$who} passes the middleware and is sent back to the dashboard"); + $check($flash('error_message') === __('Operazione riservata agli amministratori.') && $flash('success_message') === null, "rotate-link: the route refuses the {$who}"); + $check($secretOf() === $secretBefore, "rotate-link: the secret is unchanged after the {$who} POST"); + } +} finally { + foreach ($userIds as $id) { + $db->query('DELETE FROM utenti WHERE id = ' . (int) $id); + } + if ($roleId > 0) { + $db->query('DELETE FROM ruoli WHERE id = ' . $roleId); + } +} + +echo "SUCCESS {$checks} calendar permission checks\n"; diff --git a/tests/closures-calendar-admin.spec.js b/tests/closures-calendar-admin.spec.js new file mode 100644 index 000000000..07666cc82 --- /dev/null +++ b/tests/closures-calendar-admin.spec.js @@ -0,0 +1,460 @@ +// @ts-check +// Closures calendar, admin page /admin/calendar: a librarian manages the days +// the library is closed — closures (add, edit, delete, yearly), the weekly +// closed days, the Italian holidays preset and an ICS import — and checks the +// result in the monthly preview. DB only for setup and verification. +// +// Run: /tmp/run-e2e.sh tests/closures-calendar-admin.spec.js --config=tests/playwright.config.js --workers=1 +const { test, expect } = require('@playwright/test'); +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081'; +const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || ''; +const ADMIN_PASS = process.env.E2E_ADMIN_PASS || ''; +const DB_USER = process.env.E2E_DB_USER || ''; +const DB_PASS = process.env.E2E_DB_PASS || ''; +const DB_SOCKET = process.env.E2E_DB_SOCKET || ''; +const DB_HOST = process.env.E2E_DB_HOST || ''; +const DB_PORT = process.env.E2E_DB_PORT || ''; +const DB_NAME = process.env.E2E_DB_NAME || ''; + +test.skip(!ADMIN_EMAIL || !ADMIN_PASS || !DB_USER || !DB_NAME, 'E2E credentials not configured'); + +function db(sql) { + const args = []; + if (DB_HOST) { args.push('-h', DB_HOST); if (DB_PORT) args.push('-P', DB_PORT); } else if (DB_SOCKET) { args.push('-S', DB_SOCKET); } + args.push('-u', DB_USER, DB_NAME, '-N', '-B', '-e', sql); + return execFileSync('mysql', args, { encoding: 'utf-8', timeout: 10000, env: { ...process.env, MYSQL_PWD: DB_PASS } }).trim(); +} + +function addDays(iso, days) { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso); + if (!m) throw new Error(`not an ISO date: ${iso}`); + const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]))); + d.setUTCDate(d.getUTCDate() + days); + return d.toISOString().slice(0, 10); +} +function weekday(iso) { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso); + return new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]))).getUTCDay(); +} + +// Days the library-wide closures in the table close (whatever run stored +// them), with the LibraryCalendar rules: a yearly row shorter than a year +// repeats its month-day span, wrapping over New Year; any other row is its +// own interval. The weekly closed days are not included. +function libraryClosedInDb() { + const rows = db("SELECT CONCAT(data_inizio,'|',data_fine,'|',ricorrente_annuale,'|',DATEDIFF(data_fine,data_inizio)) FROM calendario_chiusure WHERE sede_id IS NULL") + .split('\n').filter(Boolean).map((line) => line.split('|')); + return (iso) => rows.some(([from, to, yearly, span]) => { + if (yearly !== '1' || Number(span) > 363) return iso >= from && iso <= to; + const md = iso.slice(5), a = from.slice(5), b = to.slice(5); + return b < a ? (md >= a || md <= b) : (md >= a && md <= b); + }); +} + +const RUN = Date.now().toString().slice(-7); +const TAG = `E2E-CAL-${RUN}`; +let startMaxId = 0; +// The preset rows carry the holiday names, not TAG: their ids are recorded so +// the teardown removes exactly what this suite added. +const presetIds = []; +// Yearly Ferragosto rows that existed before the run, made non-yearly while the +// preset is imported so the import must add the holiday itself; restored after. +let parkedFerragosto = ''; +let prevWeekdays = null; +// Pending pickups (id → pickup_deadline) before the run: every calendar save +// moves a deadline that lands on a closed day (LibraryCalendar:: +// extendPickupDeadlines), so the teardown puts back the ones this run moved. +/** @type {Array<[number, string]>} */ +let prevPickups = []; +// The original loans.closed_weekdays, kept outside the database: a run killed +// after beforeAll cleared it would otherwise read '' as the original next time. +const WEEKDAYS_STASH = path.join(os.tmpdir(), `pinakes-e2e-closed-weekdays-${DB_NAME.replace(/[^\w-]/g, '_')}.json`); +let sedeId = 0; +let today = ''; +// A month far enough ahead that no seeded closure of this run lands in it by accident. +let farYear = 0; + +async function login(page) { + await page.goto(`${BASE}/accedi`); + await page.fill('input[name="email"]', ADMIN_EMAIL); + await page.fill('input[name="password"]', ADMIN_PASS); + await page.locator('button[type="submit"]').click(); + await page.waitForURL(/admin/, { timeout: 30000 }); +} + +async function submitAndLand(page, button) { + await Promise.all([ + page.waitForURL(/\/admin\/calendar/, { timeout: 15000 }), + page.locator(button).click(), + ]); +} + +async function addClosure(page, { start, end = '', description, yearly = false, branch = '' }) { + await page.goto(`${BASE}/admin/calendar`); + await page.fill('#closure_start', start); + if (end) await page.fill('#closure_end', end); + await page.fill('#closure_description', description); + if (branch) await page.selectOption('#closure_branch', branch); + if (yearly) await page.check('#closure_yearly'); + await submitAndLand(page, '#save-closure'); +} + +const rowsFor = (description) => db(`SELECT COUNT(*) FROM calendario_chiusure WHERE descrizione='${description}'`); + +test.describe.configure({ mode: 'serial' }); + +test.describe('Closures calendar — admin page', () => { + /** @type {import('@playwright/test').Page} */ + let page; + + test.beforeAll(async ({ browser }) => { + startMaxId = parseInt(db('SELECT COALESCE(MAX(id), 0) FROM calendario_chiusure') || '0', 10); + let stashed = null; + try { stashed = JSON.parse(fs.readFileSync(WEEKDAYS_STASH, 'utf-8')); } catch { /* no interrupted run */ } + prevWeekdays = stashed && typeof stashed.value === 'string' + ? stashed.value + : db("SELECT setting_value FROM system_settings WHERE category='loans' AND setting_key='closed_weekdays'"); + prevPickups = stashed && Array.isArray(stashed.pickups) + ? stashed.pickups + : db("SELECT CONCAT(id,'|',pickup_deadline) FROM prestiti WHERE stato='da_ritirare' AND attivo=1 AND pickup_deadline IS NOT NULL") + .split('\n').filter(Boolean).map((line) => line.split('|')).map(([id, deadline]) => [parseInt(id, 10), deadline]); + fs.writeFileSync(WEEKDAYS_STASH, JSON.stringify({ value: prevWeekdays, pickups: prevPickups })); + db("DELETE FROM system_settings WHERE category='loans' AND setting_key='closed_weekdays'"); + db(`INSERT INTO sedi (nome) VALUES ('${TAG} Succursale')`); + sedeId = parseInt(db(`SELECT id FROM sedi WHERE nome='${TAG} Succursale'`), 10); + const tz = db("SELECT setting_value FROM system_settings WHERE category='app' AND setting_key='timezone' LIMIT 1") || 'Europe/Rome'; + today = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date()); + farYear = parseInt(today.slice(0, 4), 10) + 3; + page = await browser.newPage(); + await login(page); + }); + + test.afterAll(async () => { + // Only the rows this suite created: TAG in the description, or a preset + // row recorded when it was imported. A closure another run added in the + // meantime is left alone. + db(`DELETE FROM calendario_chiusure WHERE descrizione LIKE '${TAG}%'${presetIds.length ? ` OR id IN (${presetIds.join(',')})` : ''}`); + if (/^\d+(,\d+)*$/.test(parkedFerragosto)) db(`UPDATE calendario_chiusure SET ricorrente_annuale=1 WHERE id IN (${parkedFerragosto})`); + // null: beforeAll never read the original, so nothing was changed. + if (prevWeekdays !== null) { + if (!/^[0-6](,[0-6])*$/.test(prevWeekdays)) { + db("DELETE FROM system_settings WHERE category='loans' AND setting_key='closed_weekdays'"); + } else { + db(`INSERT INTO system_settings (category, setting_key, setting_value) VALUES ('loans','closed_weekdays','${prevWeekdays}') ON DUPLICATE KEY UPDATE setting_value=VALUES(setting_value)`); + } + } + // Only pickups still pending whose deadline this run moved forward: one + // collected, cancelled or moved back meanwhile is left as it is. + for (const [id, deadline] of prevPickups) { + if (Number.isInteger(id) && id > 0 && /^\d{4}-\d{2}-\d{2}$/.test(deadline)) { + db(`UPDATE prestiti SET pickup_deadline='${deadline}' WHERE id=${id} AND stato='da_ritirare' AND attivo=1 AND pickup_deadline > '${deadline}'`); + } + } + if (prevWeekdays !== null) { + try { fs.unlinkSync(WEEKDAYS_STASH); } catch { /* already gone */ } + } + if (sedeId) db(`DELETE FROM sedi WHERE id=${sedeId}`); + await page?.close(); + }); + + // ── Closures CRUD ──────────────────────────────────────────────────────── + test('CRUD 1: a one-day closure added from the form is listed and closes that day in the preview', async () => { + const day = `${farYear}-05-14`; + await addClosure(page, { start: day, description: `${TAG} one-day` }); + await expect(page.locator('#calendar-closures')).toContainText(`${TAG} one-day`); + expect(db(`SELECT CONCAT(data_inizio,'|',data_fine,'|',ricorrente_annuale,'|',IFNULL(sede_id,'-')) FROM calendario_chiusure WHERE descrizione='${TAG} one-day'`)) + .toBe(`${day}|${day}|0|-`); + // The redirect lands on the closure's month. + await expect(page.locator(`[data-calendar-day="${day}"]`)).toHaveAttribute('data-closed', '1'); + await expect(page.locator(`[data-calendar-day="${addDays(day, 1)}"]`)).toHaveAttribute('data-closed', '0'); + }); + + test('CRUD 2: a yearly range is stored as recurring and shown as "every year"', async () => { + await addClosure(page, { start: `${farYear}-08-10`, end: `${farYear}-08-20`, description: `${TAG} summer`, yearly: true }); + expect(db(`SELECT ricorrente_annuale FROM calendario_chiusure WHERE descrizione='${TAG} summer'`)).toBe('1'); + const row = page.locator('#calendar-closures tr', { hasText: `${TAG} summer` }); + await expect(row).toContainText(/Ogni anno/); + // Recurs in a later year. + await page.goto(`${BASE}/admin/calendar?month=${farYear + 1}-08`); + await expect(page.locator(`[data-calendar-day="${farYear + 1}-08-15"]`)).toHaveAttribute('data-closed', '1'); + await expect(page.locator(`[data-calendar-day="${farYear + 1}-08-21"]`)).toHaveAttribute('data-closed', '0'); + }); + + test('CRUD 3: editing a closure changes its dates and description', async () => { + await page.goto(`${BASE}/admin/calendar`); + const row = page.locator('#calendar-closures tr', { hasText: `${TAG} one-day` }); + await row.locator('[data-action="edit-closure"]').click(); + await expect(page.locator('#closure_start')).toHaveValue(`${farYear}-05-14`); + await page.fill('#closure_end', `${farYear}-05-16`); + await page.fill('#closure_description', `${TAG} three-days`); + await submitAndLand(page, '#save-closure'); + expect(rowsFor(`${TAG} one-day`)).toBe('0'); + expect(db(`SELECT data_fine FROM calendario_chiusure WHERE descrizione='${TAG} three-days'`)).toBe(`${farYear}-05-16`); + await expect(page.locator(`[data-calendar-day="${farYear}-05-16"]`)).toHaveAttribute('data-closed', '1'); + }); + + test('CRUD 4: deleting a closure asks for confirmation and removes it', async () => { + await page.goto(`${BASE}/admin/calendar`); + const row = page.locator('#calendar-closures tr', { hasText: `${TAG} three-days` }); + await row.locator('[data-action="delete-closure"]').click(); + await page.waitForSelector('.swal2-popup', { timeout: 8000 }); + await Promise.all([ + page.waitForURL(/\/admin\/calendar/, { timeout: 15000 }), + page.locator('.swal2-confirm').click(), + ]); + await expect(page.locator('#calendar-closures')).not.toContainText(`${TAG} three-days`); + expect(rowsFor(`${TAG} three-days`)).toBe('0'); + }); + + test('CRUD 5: an end date before the start is refused and nothing is stored', async () => { + await addClosure(page, { start: `${farYear}-06-10`, end: `${farYear}-06-01`, description: `${TAG} inverted` }); + await expect(page.locator('body')).toContainText(/La data di fine non può precedere la data di inizio/); + expect(rowsFor(`${TAG} inverted`)).toBe('0'); + }); + + test('CRUD 7: updating a closure that no longer exists reports "non trovata" and stores nothing', async () => { + const missingId = parseInt(db('SELECT COALESCE(MAX(id), 0) + 100000 FROM calendario_chiusure'), 10); + const before = db('SELECT COUNT(*) FROM calendario_chiusure'); + await page.goto(`${BASE}/admin/calendar`); + await page.fill('#closure_start', `${farYear}-06-20`); + await page.fill('#closure_description', `${TAG} ghost`); + // Same form, same CSRF token, the update route of an id that does not exist. + await page.evaluate((id) => { + const form = document.querySelector('#save-closure').closest('form'); + form.action = form.action.replace(/\/admin\/calendar\/closures.*$/, `/admin/calendar/closures/${id}/update`); + }, missingId); + await submitAndLand(page, '#save-closure'); + await expect(page.locator('body')).toContainText(/Chiusura non trovata/); + await expect(page.locator('body')).not.toContainText(/Chiusura aggiornata/); + expect(db('SELECT COUNT(*) FROM calendario_chiusure')).toBe(before); + expect(rowsFor(`${TAG} ghost`)).toBe('0'); + }); + + test('CRUD 6: a closure for one branch is stored with that branch', async () => { + await addClosure(page, { start: `${farYear}-07-01`, description: `${TAG} branch`, branch: String(sedeId) }); + expect(db(`SELECT sede_id FROM calendario_chiusure WHERE descrizione='${TAG} branch'`)).toBe(String(sedeId)); + await expect(page.locator('#calendar-closures tr', { hasText: `${TAG} branch` })).toContainText(`${TAG} Succursale`); + // The whole-library preview stays open that day; the branch preview is closed. + await page.goto(`${BASE}/admin/calendar?month=${farYear}-07`); + await expect(page.locator(`[data-calendar-day="${farYear}-07-01"]`)).toHaveAttribute('data-closed', '0'); + await page.goto(`${BASE}/admin/calendar?month=${farYear}-07&sede=${sedeId}`); + await expect(page.locator(`[data-calendar-day="${farYear}-07-01"]`)).toHaveAttribute('data-closed', '1'); + }); + + // ── Weekly closed days ─────────────────────────────────────────────────── + test('Weekly 1: Sunday and Monday saved as closed weekdays', async () => { + await page.goto(`${BASE}/admin/calendar`); + await page.check('#closed_weekday_0'); + await page.check('#closed_weekday_1'); + await submitAndLand(page, '#save-weekdays'); + await expect(page.locator('body')).toContainText(/Giorni di chiusura settimanale salvati/); + expect(db("SELECT setting_value FROM system_settings WHERE category='loans' AND setting_key='closed_weekdays'")).toBe('0,1'); + }); + + test('Weekly 2: the saved days stay checked after a reload', async () => { + await page.goto(`${BASE}/admin/calendar`); + await expect(page.locator('#closed_weekday_0')).toBeChecked(); + await expect(page.locator('#closed_weekday_1')).toBeChecked(); + await expect(page.locator('#closed_weekday_2')).not.toBeChecked(); + }); + + test('Weekly 3: the preview marks every Sunday and Monday closed, Tuesday open', async () => { + await page.goto(`${BASE}/admin/calendar?month=${farYear}-09`); + const days = await page.locator('[data-calendar-day]').evaluateAll((els) => els.map((e) => [e.getAttribute('data-calendar-day'), e.getAttribute('data-closed')])); + expect(days.length).toBe(30); + // A library closure already in the table (another run's, an operator's) + // closes its days too: the rule alone does not decide them. + const closedByRow = libraryClosedInDb(); + for (const [iso, closed] of days) { + const w = weekday(iso); + expect(closed, iso).toBe(w === 0 || w === 1 || closedByRow(iso) ? '1' : '0'); + } + }); + + test('Weekly 4: closing all seven days is refused and the setting is kept', async () => { + await page.goto(`${BASE}/admin/calendar`); + for (let w = 0; w <= 6; w++) await page.check(`#closed_weekday_${w}`); + await submitAndLand(page, '#save-weekdays'); + await expect(page.locator('body')).toContainText(/Almeno un giorno della settimana deve restare aperto/); + expect(db("SELECT setting_value FROM system_settings WHERE category='loans' AND setting_key='closed_weekdays'")).toBe('0,1'); + }); + + test('Weekly 5: unchecking every day reopens the week', async () => { + await page.goto(`${BASE}/admin/calendar`); + await page.uncheck('#closed_weekday_0'); + await page.uncheck('#closed_weekday_1'); + await submitAndLand(page, '#save-weekdays'); + expect(db("SELECT setting_value FROM system_settings WHERE category='loans' AND setting_key='closed_weekdays'")).toBe(''); + await page.goto(`${BASE}/admin/calendar?month=${farYear}-09`); + // Only the days a library closure in the table closes stay closed. + const closedByRow = libraryClosedInDb(); + const expectedClosed = []; + for (let d = 1; d <= 30; d++) { + const iso = `${farYear}-09-${String(d).padStart(2, '0')}`; + if (closedByRow(iso)) expectedClosed.push(iso); + } + const closedDays = await page.locator('[data-calendar-day][data-closed="1"]').evaluateAll((els) => els.map((e) => e.getAttribute('data-calendar-day'))); + expect(closedDays.sort()).toEqual(expectedClosed); + }); + + test('Weekly 6: the page is reachable from the sidebar and from Settings > Loans', async () => { + await page.goto(`${BASE}/admin/settings?tab=loans`); + await Promise.all([page.waitForURL(/\/admin\/calendar/), page.locator('#open-closures-calendar').click()]); + await expect(page.locator('h1')).toContainText(/Calendario chiusure/); + await page.goto(`${BASE}/admin/dashboard`); + await expect(page.locator(`a.nav-link[href$="/admin/calendar"]`).first()).toHaveCount(1); + }); + + // ── Italian holidays preset ────────────────────────────────────────────── + const presetYear = () => parseInt(today.slice(0, 4), 10) + 1; + + test('Preset 1: importing the Italian holidays adds 10 yearly holidays plus Easter and Easter Monday', async () => { + const beforeImport = parseInt(db('SELECT COALESCE(MAX(id), 0) FROM calendario_chiusure') || '0', 10); + // Make sure one recurring holiday is missing, so the import has to add it. + const ferragostoWhere = "ricorrente_annuale=1 AND sede_id IS NULL AND DATE_FORMAT(data_inizio,'%m-%d')='08-15' AND DATE_FORMAT(data_fine,'%m-%d')='08-15'"; + const parked = db(`SELECT GROUP_CONCAT(id) FROM calendario_chiusure WHERE ${ferragostoWhere}`); + if (parked && parked !== 'NULL') { + parkedFerragosto = parked; + db(`UPDATE calendario_chiusure SET ricorrente_annuale=0 WHERE id IN (${parked})`); + } + expect(db(`SELECT COUNT(*) FROM calendario_chiusure WHERE ${ferragostoWhere}`)).toBe('0'); + await page.goto(`${BASE}/admin/calendar`); + await page.selectOption('#preset_year', String(presetYear())); + await submitAndLand(page, '#import-preset'); + await expect(page.locator('body')).toContainText(/Festività nazionali importate/); + const presetYearValue = presetYear(); + const added = db(`SELECT GROUP_CONCAT(id) FROM calendario_chiusure WHERE id > ${beforeImport} AND sede_id IS NULL AND (ricorrente_annuale=1 OR YEAR(data_inizio)=${presetYearValue}) AND descrizione IN ('Natale','Santo Stefano','Capodanno','Epifania','Ferragosto','Ognissanti','Immacolata Concezione','Festa della Liberazione','Festa del Lavoro','Festa della Repubblica','Pasqua','Lunedì dell''Angelo (Pasquetta)')`); + if (added && added !== 'NULL') presetIds.push(...added.split(',').map((id) => parseInt(id, 10)).filter((id) => id > 0)); + // The missing holiday was inserted by this import, once, as a yearly closure. + expect(db(`SELECT COUNT(*) FROM calendario_chiusure WHERE id > ${beforeImport} AND descrizione='Ferragosto' AND ${ferragostoWhere}`)).toBe('1'); + const count = parseInt(db(`SELECT COUNT(*) FROM calendario_chiusure WHERE id > ${startMaxId} AND descrizione IN ('Natale','Santo Stefano','Capodanno','Epifania','Ferragosto','Ognissanti','Immacolata Concezione','Festa della Liberazione','Festa del Lavoro','Festa della Repubblica') AND ricorrente_annuale=1`), 10); + // Fixed holidays already present before this run are not duplicated. + expect(count).toBeLessThanOrEqual(10); + expect(parseInt(db("SELECT COUNT(DISTINCT DATE_FORMAT(data_inizio,'%m-%d')) FROM calendario_chiusure WHERE ricorrente_annuale=1 AND sede_id IS NULL AND DATE_FORMAT(data_inizio,'%m-%d') IN ('01-01','01-06','04-25','05-01','06-02','08-15','11-01','12-08','12-25','12-26')"), 10)).toBe(10); + }); + + test('Preset 2: Easter and Easter Monday are dated by the algorithm for that year', async () => { + // Known Easter Sundays (Gregorian). + const easter = { 2026: '2026-04-05', 2027: '2027-03-28', 2028: '2028-04-16', 2029: '2029-04-01', 2030: '2030-04-21' }[presetYear()]; + test.skip(!easter, 'no reference Easter date for this year'); + expect(db(`SELECT COUNT(*) FROM calendario_chiusure WHERE data_inizio='${easter}' AND ricorrente_annuale=0`)).toBe('1'); + expect(db(`SELECT COUNT(*) FROM calendario_chiusure WHERE data_inizio='${addDays(easter, 1)}' AND ricorrente_annuale=0`)).toBe('1'); + await page.goto(`${BASE}/admin/calendar?month=${addDays(easter, 1).slice(0, 7)}`); + await expect(page.locator(`[data-calendar-day="${addDays(easter, 1)}"]`)).toHaveAttribute('data-closed', '1'); + await expect(page.locator(`[data-calendar-day="${addDays(easter, 2)}"]`)).toHaveAttribute('data-closed', '0'); + }); + + test('Preset 3: importing the same year again adds nothing', async () => { + const before = db('SELECT COUNT(*) FROM calendario_chiusure'); + await page.goto(`${BASE}/admin/calendar`); + await page.selectOption('#preset_year', String(presetYear())); + await submitAndLand(page, '#import-preset'); + await expect(page.locator('body')).toContainText(/0 nuove chiusure/); + expect(db('SELECT COUNT(*) FROM calendario_chiusure')).toBe(before); + }); + + test('Preset 4: Christmas and St Stephen close every December, the 27th stays open', async () => { + await page.goto(`${BASE}/admin/calendar?month=${farYear}-12`); + await expect(page.locator(`[data-calendar-day="${farYear}-12-25"]`)).toHaveAttribute('data-closed', '1'); + await expect(page.locator(`[data-calendar-day="${farYear}-12-26"]`)).toHaveAttribute('data-closed', '1'); + await expect(page.locator(`[data-calendar-day="${farYear}-12-27"]`)).toHaveAttribute('data-closed', '0'); + }); + + test('Preset 5: the holidays appear in the list as recurring rows', async () => { + await page.goto(`${BASE}/admin/calendar`); + const row = page.locator('#calendar-closures tr', { hasText: 'Ferragosto' }).first(); + await expect(row).toContainText(/Ogni anno/); + await expect(row).toContainText(/Tutte le sedi/); + }); + + // ── ICS import ─────────────────────────────────────────────────────────── + const ics = () => [ + 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//E2E//Pinakes//IT', + 'BEGIN:VEVENT', 'UID:a@e2e', `DTSTART;VALUE=DATE:${farYear}1102`, `DTEND;VALUE=DATE:${farYear}1105`, `SUMMARY:${TAG} inventario`, 'END:VEVENT', + 'BEGIN:VEVENT', 'UID:b@e2e', `DTSTART;VALUE=DATE:${farYear}0317`, 'RRULE:FREQ=YEARLY', `SUMMARY:${TAG} patrono`, 'END:VEVENT', + 'END:VCALENDAR', '', + ].join('\r\n'); + + async function importIcs(content, branch = '', encoding = 'utf-8') { + await page.goto(`${BASE}/admin/calendar`); + await page.setInputFiles('#ics_file', { name: 'chiusure.ics', mimeType: 'text/calendar', buffer: Buffer.from(content, encoding) }); + if (branch) await page.selectOption('#ics_branch', branch); + await submitAndLand(page, '#import-ics'); + } + + test('ICS 1: an ICS file adds one closure per event, with the exclusive DTEND honoured', async () => { + await importIcs(ics()); + await expect(page.locator('body')).toContainText(/2 chiusure aggiunte, 0 già presenti/); + expect(db(`SELECT CONCAT(data_inizio,'|',data_fine) FROM calendario_chiusure WHERE descrizione='${TAG} inventario'`)).toBe(`${farYear}-11-02|${farYear}-11-04`); + }); + + test('ICS 2: a yearly event (RRULE FREQ=YEARLY) becomes a recurring closure', async () => { + expect(db(`SELECT ricorrente_annuale FROM calendario_chiusure WHERE descrizione='${TAG} patrono'`)).toBe('1'); + await page.goto(`${BASE}/admin/calendar?month=${farYear + 2}-03`); + await expect(page.locator(`[data-calendar-day="${farYear + 2}-03-17"]`)).toHaveAttribute('data-closed', '1'); + }); + + test('ICS 3: importing the same file again adds nothing', async () => { + await importIcs(ics()); + await expect(page.locator('body')).toContainText(/0 chiusure aggiunte, 2 già presenti/); + expect(rowsFor(`${TAG} inventario`)).toBe('1'); + }); + + test('ICS 4: a file imported for one branch stores that branch', async () => { + const branchIcs = ['BEGIN:VCALENDAR', 'BEGIN:VEVENT', `DTSTART;VALUE=DATE:${farYear}0410`, `SUMMARY:${TAG} sede ics`, 'END:VEVENT', 'END:VCALENDAR', ''].join('\r\n'); + await importIcs(branchIcs, String(sedeId)); + expect(db(`SELECT sede_id FROM calendario_chiusure WHERE descrizione='${TAG} sede ics'`)).toBe(String(sedeId)); + }); + + test('ICS 6: a Windows-1252 export with accented names is imported in UTF-8', async () => { + const latinIcs = ['BEGIN:VCALENDAR', 'BEGIN:VEVENT', `DTSTART;VALUE=DATE:${farYear}0612`, `SUMMARY:${TAG} festività à è`, 'END:VEVENT', 'END:VCALENDAR', ''].join('\r\n'); + await importIcs(latinIcs, '', 'latin1'); + await expect(page.locator('body')).toContainText(/1 chiusure aggiunte, 0 già presenti/); + expect(db(`SELECT data_inizio FROM calendario_chiusure WHERE descrizione='${TAG} festività à è'`)).toBe(`${farYear}-06-12`); + await expect(page.locator('#calendar-closures')).toContainText(`${TAG} festività à è`); + }); + + test('ICS 7: a UTF-8 character split by a folded line is rebuilt, not read as Windows-1252', async () => { + const head = Buffer.from(['BEGIN:VCALENDAR', 'BEGIN:VEVENT', `DTSTART;VALUE=DATE:${farYear}0613`, `SUMMARY:${TAG} caff`].join('\r\n'), 'utf-8'); + // "é" is C3 A9: the fold falls between the two bytes. + const tail = Buffer.from(['', 'END:VEVENT', 'END:VCALENDAR', ''].join('\r\n'), 'utf-8'); + const buffer = Buffer.concat([head, Buffer.from([0xc3]), Buffer.from('\r\n ', 'utf-8'), Buffer.from([0xa9]), tail]); + await page.goto(`${BASE}/admin/calendar`); + await page.setInputFiles('#ics_file', { name: 'chiusure.ics', mimeType: 'text/calendar', buffer }); + await submitAndLand(page, '#import-ics'); + expect(db(`SELECT data_inizio FROM calendario_chiusure WHERE descrizione='${TAG} caffé'`)).toBe(`${farYear}-06-13`); + }); + + test('ICS 8: a yearly event longer than a year is discarded with a reason; the other events and the open days stay', async () => { + // A ten-year RRULE:FREQ=YEARLY event, stored as a yearly row, would have + // closed every day of every year: the import applies the form's limits. + const decadeIcs = [ + 'BEGIN:VCALENDAR', 'VERSION:2.0', + 'BEGIN:VEVENT', 'UID:decade@e2e', `DTSTART;VALUE=DATE:${farYear}0101`, `DTEND;VALUE=DATE:${farYear + 10}0101`, 'RRULE:FREQ=YEARLY', `SUMMARY:${TAG} decennio`, 'END:VEVENT', + 'BEGIN:VEVENT', 'UID:fine@e2e', `DTSTART;VALUE=DATE:${farYear}0921`, `SUMMARY:${TAG} ics valido`, 'END:VEVENT', + 'END:VCALENDAR', '', + ].join('\r\n'); + await importIcs(decadeIcs); + await expect(page.locator('body')).toContainText(/1 chiusure aggiunte, 0 già presenti, 1 scartate perché più lunghe del limite/); + expect(rowsFor(`${TAG} decennio`)).toBe('0'); + expect(rowsFor(`${TAG} ics valido`)).toBe('1'); + // Weekdays of the next years are still open: nothing closed every day. + await page.goto(`${BASE}/admin/calendar?month=${farYear + 2}-09`); + await expect(page.locator(`[data-calendar-day="${farYear + 2}-09-21"]`)).toHaveAttribute('data-closed', '0'); + await expect(page.locator(`[data-calendar-day="${farYear + 2}-09-22"]`)).toHaveAttribute('data-closed', '0'); + await page.goto(`${BASE}/admin/calendar?month=${farYear}-09`); + await expect(page.locator(`[data-calendar-day="${farYear}-09-21"]`)).toHaveAttribute('data-closed', '1'); + }); + + test('ICS 5: a file that is not a calendar is refused', async () => { + const before = db('SELECT COUNT(*) FROM calendario_chiusure'); + await importIcs('this is not a calendar'); + await expect(page.locator('body')).toContainText(/non è un calendario ICS valido/); + expect(db('SELECT COUNT(*) FROM calendario_chiusure')).toBe(before); + }); +}); diff --git a/tests/closures-calendar-circulation.spec.js b/tests/closures-calendar-circulation.spec.js new file mode 100644 index 000000000..e44c9a826 --- /dev/null +++ b/tests/closures-calendar-circulation.spec.js @@ -0,0 +1,225 @@ +// @ts-check +// Closures calendar on top of the circulation policy, as a librarian and a +// reader see it: the loan length and the renewal days of the reader's PATRON +// TYPE (not the global settings) are what lands on a closed day, and both the +// admin desk and the reader's request calendar move that due date forward to +// the next open day; a renewal under the type's renewal rules does the same. +// Patron types, closures and loans are set up in the DB; every action under +// test is a click in the UI. Dates come from the application clock. +// +// Run: /tmp/run-e2e.sh tests/closures-calendar-circulation.spec.js --config=tests/playwright.config.js --workers=1 +const { test, expect } = require('@playwright/test'); +const F = require('./helpers/circulation-fixtures'); + +test.skip(!F.CONFIGURED, 'E2E credentials not configured'); + +const RUN = String(Date.now()).slice(-6); +const PREFIX = `e2ecal${RUN}`; +const TAG = `E2E-CALCIRC-${RUN}`; +const TYPE_DAYS = 10; +const RENEW_DAYS = 7; + +function addDays(iso, days) { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso); + if (!m) throw new Error(`not an ISO date: ${iso}`); + const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]))); + d.setUTCDate(d.getUTCDate() + days); + return d.toISOString().slice(0, 10); +} +function weekday(iso) { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso); + return new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]))).getUTCDay(); +} +function closeDay(from, to = from) { + F.dbQuery(`INSERT INTO calendario_chiusure (data_inizio, data_fine, descrizione) VALUES ('${from}', '${to}', '${TAG}')`); +} +function setWeekdays(value) { + F.dbQuery(`INSERT INTO system_settings (category, setting_key, setting_value) VALUES ('loans','closed_weekdays','${value}') ON DUPLICATE KEY UPDATE setting_value=VALUES(setting_value)`); +} + +test.describe.configure({ mode: 'serial' }); + +test.describe('Closures calendar — patron-type durations and renewals', () => { + let today = ''; + let prevWeekdays = ''; + let typeId = 0; + /** @type {{id:number,email:string,password:string,surname:string}} */ + let patron; + /** @type {Record} */ + const books = {}; + const errors = []; + + test.beforeAll(() => { + today = F.appTodayISO(); + prevWeekdays = F.dbQuery("SELECT setting_value FROM system_settings WHERE category='loans' AND setting_key='closed_weekdays'"); + setWeekdays(''); + // A patron type whose loan lasts 10 days (request window 10 too, so the + // maximum itself is what lands on the closure) and whose renewal adds 7. + F.dbQuery(`INSERT INTO tipologie_lettore (codice, nome, durata_prestito_giorni, max_durata_richiesta_giorni, giorni_rinnovo, max_rinnovi, ordinamento) + VALUES ('${PREFIX}', 'Cal ${RUN}', ${TYPE_DAYS}, ${TYPE_DAYS}, ${RENEW_DAYS}, 3, 90)`); + typeId = F.dbInt(`SELECT id FROM tipologie_lettore WHERE codice='${PREFIX}'`); + patron = F.createPatron(`CALC${RUN}`, { typeId }); + for (const key of ['desk', 'deskweekly', 'deskzero', 'request', 'requestend', 'renew', 'renewweekly']) { + books[key] = F.createTitle(`${PREFIX}-${key}`); + } + }); + + test.afterAll(() => { + F.dbQuery(`DELETE FROM calendario_chiusure WHERE descrizione='${TAG}'`); + if (prevWeekdays === '') F.dbQuery("DELETE FROM system_settings WHERE category='loans' AND setting_key='closed_weekdays'"); + else setWeekdays(prevWeekdays); + const ids = Object.values(books).map((b) => b.id); + // related_id also holds message, user, reservation and review ids: only + // the loan notifications (the loan types, or a general one about loans). + if (ids.length) F.dbQuery(`DELETE FROM admin_notifications WHERE (type IN ('new_loan_request','overdue_loan') OR (type='general' AND link LIKE '%/admin/loans%')) AND related_id IN (SELECT id FROM prestiti WHERE libro_id IN (${ids.join(',')}))`); + F.deleteTitles(ids); + F.deletePatrons([patron?.id]); + F.deleteTypes(PREFIX); + }); + + test.beforeEach(({ page }) => { + page.on('response', (r) => { if (r.status() >= 500) errors.push(`${r.status()} ${r.url()}`); }); + }); + test.afterEach(() => { + F.dbQuery(`DELETE FROM calendario_chiusure WHERE descrizione='${TAG}'`); + setWeekdays(''); + expect(errors.splice(0), 'no HTTP 5xx').toEqual([]); + }); + + // ── Admin desk ───────────────────────────────────────────────────────── + /** Choose the borrower and the copy; resolves once the type's duration has been applied. */ + async function fillDesk(page, book) { + await page.goto(`${F.BASE}/admin/loans/create`); + await F.fillAutocomplete(page, '#utente_search', '#utente_suggest', patron.surname, '/api/search/utenti'); + await expect(page.locator('#utente_id')).toHaveValue(String(patron.id)); + const availability = page.waitForResponse((r) => r.url().includes('/availability') && r.url().includes(`for_user=${patron.id}`), { timeout: 15000 }); + const resolved = page.waitForResponse((r) => r.url().includes('/admin/copies/by-code')); + await page.locator('#copy_code').fill(F.copyCodeOf(book.copyIds[0])); + await page.locator('#copy_code').dispatchEvent('change'); + await resolved; + await expect(page.locator('#libro_id')).toHaveValue(String(book.id)); + const data = await (await availability).json(); + expect(Number(data.availability.staff_loan_duration_days)).toBe(TYPE_DAYS); + } + async function submitDesk(page) { + await page.evaluate(() => document.querySelectorAll('.flatpickr-calendar.open').forEach((c) => c.classList.remove('open'))); + await page.uncheck('#scarica_pdf').catch(() => {}); + await Promise.all([ + page.waitForURL((u) => u.toString().includes('created=1') || u.toString().includes('error='), { timeout: 20000 }), + page.locator('form[action$="/admin/loans/create"] button[type="submit"]:not([name="save_and_new"])').click(), + ]); + expect(page.url()).toContain('created=1'); + } + const loanOf = (book) => F.dbQuery(`SELECT data_scadenza FROM prestiti WHERE libro_id=${book.id} AND utente_id=${patron.id} ORDER BY id DESC LIMIT 1`); + + test('Desk 1: the 10-day loan of the patron type ending on a closure is proposed and stored on the next open day', async ({ page }) => { + const raw = addDays(today, TYPE_DAYS); + closeDay(raw); + await F.loginAsAdmin(page); + await fillDesk(page, books.desk); + await expect(page.locator('#data_scadenza')).toHaveValue(addDays(raw, 1)); + await submitDesk(page); + expect(loanOf(books.desk)).toBe(addDays(raw, 1)); + }); + + test('Desk 2: a two-day closure on the type due date moves it past both days', async ({ page }) => { + const raw = addDays(today, TYPE_DAYS); + closeDay(raw, addDays(raw, 1)); + await F.loginAsAdmin(page); + await fillDesk(page, books.deskweekly); + await expect(page.locator('#data_scadenza')).toHaveValue(addDays(raw, 2)); + await submitDesk(page); + expect(loanOf(books.deskweekly)).toBe(addDays(raw, 2)); + }); + + test('Desk 3: without closures the patron type duration is applied exactly', async ({ page }) => { + await F.loginAsAdmin(page); + await fillDesk(page, books.deskzero); + await expect(page.locator('#data_scadenza')).toHaveValue(addDays(today, TYPE_DAYS)); + await submitDesk(page); + expect(loanOf(books.deskzero)).toBe(addDays(today, TYPE_DAYS)); + }); + + // ── Reader's request ─────────────────────────────────────────────────── + async function openRequest(page, book, start) { + await page.goto(`${F.BASE}/libro/${book.id}`); + await page.locator('#btn-request-loan').click(); + await page.waitForSelector('.swal2-popup', { timeout: 8000 }); + await page.waitForFunction(() => { const el = document.querySelector('#swal-date-start'); return el && /** @type {any} */ (el)._flatpickr; }, null, { timeout: 8000 }); + await page.evaluate((iso) => { /** @type {any} */ (document.querySelector('#swal-date-start'))._flatpickr.setDate(iso, true); }, start); + } + + test('Request 1: the reader calendar proposes the type return date moved past a closure', async ({ page }) => { + const start = addDays(today, 1); + const raw = addDays(start, TYPE_DAYS); + closeDay(raw); + await F.loginAs(page, patron.email, patron.password); + await openRequest(page, books.request, start); + await expect(page.locator('#swal-date-end')).toHaveValue(addDays(raw, 1)); + // The note under the field stays empty: no conflict caps this loan. + await expect(page.locator('#swal-loan-limit')).toBeHidden(); + }); + + test('Request 2: confirming that proposal stores the open day, accepted although it passes the type maximum', async ({ page }) => { + const start = addDays(today, 1); + const raw = addDays(start, TYPE_DAYS); + closeDay(raw); + await F.loginAs(page, patron.email, patron.password); + await openRequest(page, books.requestend, start); + await expect(page.locator('#swal-date-end')).toHaveValue(addDays(raw, 1)); + await page.locator('.swal2-confirm').click(); + await page.waitForFunction(() => !!document.querySelector('.swal2-icon-success, .swal2-icon-error'), null, { timeout: 15000 }); + await expect(page.locator('.swal2-icon-success')).toBeVisible(); + expect(F.dbQuery(`SELECT CONCAT(data_prestito,'|',data_scadenza) FROM prestiti WHERE libro_id=${books.requestend.id} AND utente_id=${patron.id} ORDER BY id DESC LIMIT 1`)) + .toBe(`${start}|${addDays(raw, 1)}`); + }); + + // ── Renewal under the type's renewal rules ───────────────────────────── + function activeLoan(book, due) { + const copy = book.copyIds[0]; + F.dbQuery(`UPDATE copie SET stato='prestato' WHERE id=${copy}`); + F.dbQuery(`INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, attivo, renewals) VALUES (${book.id}, ${copy}, ${patron.id}, '${today}', '${due}', 'in_corso', 1, 0)`); + F.dbQuery(`UPDATE libri SET copie_disponibili=0 WHERE id=${book.id}`); + return F.dbInt(`SELECT id FROM prestiti WHERE libro_id=${book.id} AND utente_id=${patron.id} AND attivo=1`); + } + async function renewFromBookPage(page, book, loanId) { + await page.goto(`${F.BASE}/admin/books/${book.id}`); + const form = page.locator(`form[action*="/admin/loans/renew/${loanId}"]`).last(); + await form.waitFor({ state: 'attached', timeout: 10000 }); + await form.locator('button[type="submit"]').click(); + await page.waitForSelector('.swal2-popup', { timeout: 8000 }); + await Promise.all([ + page.waitForURL(/\/admin\/books\/\d+/, { timeout: 15000 }), + page.locator('.swal2-confirm').click(), + ]); + } + const renewalState = (loanId) => F.dbQuery(`SELECT CONCAT(data_scadenza,'|',renewals) FROM prestiti WHERE id=${loanId}`); + + test('Renew 1: the 7 renewal days of the type ending on a closure land on the next open day', async ({ page }) => { + const due = addDays(today, 5); + const loanId = activeLoan(books.renew, due); + const raw = addDays(due, RENEW_DAYS); + closeDay(raw); + await F.loginAsAdmin(page); + await renewFromBookPage(page, books.renew, loanId); + await expect.poll(() => renewalState(loanId), { timeout: 8000 }).toBe(`${addDays(raw, 1)}|1`); + }); + + test('Renew 2: a second renewal of the type from the moved date skips a weekly closed day', async ({ page }) => { + const loanId = F.dbInt(`SELECT id FROM prestiti WHERE libro_id=${books.renew.id} AND utente_id=${patron.id} AND attivo=1`); + const due = F.dbQuery(`SELECT data_scadenza FROM prestiti WHERE id=${loanId}`); + const raw = addDays(due, RENEW_DAYS); + setWeekdays(String(weekday(raw))); + await F.loginAsAdmin(page); + await renewFromBookPage(page, books.renew, loanId); + await expect.poll(() => renewalState(loanId), { timeout: 8000 }).toBe(`${addDays(raw, 1)}|2`); + }); + + test('Renew 3: without closures the renewal adds exactly the type renewal days', async ({ page }) => { + const due = addDays(today, 4); + const loanId = activeLoan(books.renewweekly, due); + await F.loginAsAdmin(page); + await renewFromBookPage(page, books.renewweekly, loanId); + await expect.poll(() => renewalState(loanId), { timeout: 8000 }).toBe(`${addDays(due, RENEW_DAYS)}|1`); + }); +}); diff --git a/tests/closures-calendar-loans.spec.js b/tests/closures-calendar-loans.spec.js new file mode 100644 index 000000000..401dc14a9 --- /dev/null +++ b/tests/closures-calendar-loans.spec.js @@ -0,0 +1,500 @@ +// @ts-check +// Closures calendar applied to circulation, as a librarian and a reader see it: +// the admin loan form proposes and stores a due date on an open day and +// refuses closed days in its pickers, a renewal lands on an open day, the +// pickup deadline counts open days only, and the reader's request calendar +// refuses a closed start day while a return date on a closed day moves to the +// next open one. Closures are written in the DB (setup); every action is a +// click in the UI. Dates come from the application timezone, not the runner. +// +// Run: /tmp/run-e2e.sh tests/closures-calendar-loans.spec.js --config=tests/playwright.config.js --workers=1 +const { test, expect } = require('@playwright/test'); +const { execFileSync } = require('child_process'); + +const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081'; +const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || ''; +const ADMIN_PASS = process.env.E2E_ADMIN_PASS || ''; +const DB_USER = process.env.E2E_DB_USER || ''; +const DB_PASS = process.env.E2E_DB_PASS || ''; +const DB_SOCKET = process.env.E2E_DB_SOCKET || ''; +const DB_HOST = process.env.E2E_DB_HOST || ''; +const DB_PORT = process.env.E2E_DB_PORT || ''; +const DB_NAME = process.env.E2E_DB_NAME || ''; + +test.skip(!ADMIN_EMAIL || !ADMIN_PASS || !DB_USER || !DB_NAME, 'E2E credentials not configured'); + +function db(sql) { + const args = []; + if (DB_HOST) { args.push('-h', DB_HOST); if (DB_PORT) args.push('-P', DB_PORT); } else if (DB_SOCKET) { args.push('-S', DB_SOCKET); } + args.push('-u', DB_USER, DB_NAME, '-N', '-B', '-e', sql); + return execFileSync('mysql', args, { encoding: 'utf-8', timeout: 10000, env: { ...process.env, MYSQL_PWD: DB_PASS } }).trim(); +} +function addDays(iso, days) { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso); + if (!m) throw new Error(`not an ISO date: ${iso}`); + const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]))); + d.setUTCDate(d.getUTCDate() + days); + return d.toISOString().slice(0, 10); +} +function weekday(iso) { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso); + return new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]))).getUTCDay(); +} + +const RUN = Date.now().toString().slice(-7); +const TAG = `E2E-CALLOAN-${RUN}`; +const READER_EMAIL = `calloan-${RUN}@example.test`; +const READER_PASS = 'CalLoan1234!'; + +let prevWeekdays = ''; +let readerId = 0; +let today = ''; +let loanDays = 30; +// The reader's default request duration: min(loan duration, max duration). +let requestDays = 30; +let pickupDays = 3; +/** @type {Record} */ +const books = {}; + +function closeDay(from, to = from) { + db(`INSERT INTO calendario_chiusure (data_inizio, data_fine, descrizione) VALUES ('${from}', '${to}', '${TAG}')`); +} +function clearClosures() { + db(`DELETE FROM calendario_chiusure WHERE descrizione='${TAG}'`); +} +// Whether a day is closed for a loan with no branch, as the application +// computes it: this run's closures and any already stored, plus the weekly +// closed days. An existing closure next to a test date moves the expected +// date with it instead of failing the assertion. +function closedInDb() { + const weekly = db("SELECT COALESCE((SELECT setting_value FROM system_settings WHERE category='loans' AND setting_key='closed_weekdays'), '')") + .split(',').filter((d) => /^[0-6]$/.test(d.trim())).map(Number); + const closures = db('SELECT CONCAT(data_inizio,\'|\',data_fine,\'|\',ricorrente_annuale,\'|\',DATEDIFF(data_fine,data_inizio)) FROM calendario_chiusure WHERE sede_id IS NULL') + .split('\n').filter(Boolean).map((line) => line.split('|')); + const closed = (iso) => (weekly.length < 7 && weekly.includes(weekday(iso))) || closures.some(([from, to, yearly, span]) => { + if (yearly !== '1' || Number(span) > 363) return iso >= from && iso <= to; + const md = iso.slice(5), a = from.slice(5), b = to.slice(5); + return b < a ? (md >= a || md <= b) : (md >= a && md <= b); + }); + return closed; +} +// The first open day from `day` on. +function nextOpenInDb(day) { + const closed = closedInDb(); + let result = day; + for (let i = 0; i < 1100 && closed(result); i++) result = addDays(result, 1); + return result; +} +// The day `days` open days after `from` (LibraryCalendar::pickupDeadline's +// rule), never past `capAt`. +function addOpenDaysInDb(from, days, capAt) { + const closed = closedInDb(); + let result = from; + for (let counted = 0, i = 0; counted < days && i < 1100; i++) { + result = addDays(result, 1); + if (!closed(result)) counted++; + } + return capAt && result > capAt ? capAt : result; +} +function setWeekdays(value) { + db(`INSERT INTO system_settings (category, setting_key, setting_value) VALUES ('loans','closed_weekdays','${value}') ON DUPLICATE KEY UPDATE setting_value=VALUES(setting_value)`); +} + +async function loginAs(page, email, pass) { + await page.goto(`${BASE}/accedi`); + await page.fill('input[name="email"]', email); + await page.fill('input[name="password"]', pass); + await page.locator('button[type="submit"]').click(); + await page.waitForLoadState('domcontentloaded'); + await page.waitForURL((u) => !u.toString().includes('/accedi'), { timeout: 30000 }); +} + +async function selectBorrower(page) { + await page.fill('#utente_search', READER_EMAIL); + await page.waitForTimeout(400); + const sug = page.locator('#utente_suggest .suggestion-item').first(); + await expect(sug).toBeVisible({ timeout: 8000 }); + await sug.click(); + await expect(page.locator('#utente_id')).toHaveValue(String(readerId)); +} +async function resolveCopy(page, book) { + await page.fill('#copy_code', book.inv); + await expect.poll(async () => page.locator('#libro_id').inputValue(), { timeout: 8000 }).toBe(String(book.id)); +} +async function flatpickrReady(page, selector) { + await expect.poll(async () => page.evaluate((s) => !!(/** @type {any} */ (document.querySelector(s)))?._flatpickr, selector), { timeout: 8000 }).toBe(true); +} +async function submitLoanForm(page) { + await Promise.all([ + page.waitForURL((u) => u.toString().includes('created=1') || u.toString().includes('error='), { timeout: 20000 }), + page.click('form[action$="/admin/loans/create"] button[type="submit"]:not([name="save_and_new"])'), + ]); + expect(page.url()).toContain('created=1'); +} +async function openRequestModal(page, book) { + await page.goto(`${BASE}/libro/${book.id}`); + const availability = page.waitForResponse((r) => /\/availability(\?|$)/.test(r.url()) && r.request().method() === 'GET', { timeout: 15000 }); + await page.locator('#btn-request-loan').click(); + const response = await availability; + await page.waitForSelector('.swal2-popup', { timeout: 8000 }); + await flatpickrReady(page, '#swal-date-start'); + return response.json(); +} + +test.describe.configure({ mode: 'serial' }); + +test.describe('Closures calendar — circulation', () => { + test.beforeAll(() => { + prevWeekdays = db("SELECT setting_value FROM system_settings WHERE category='loans' AND setting_key='closed_weekdays'"); + setWeekdays(''); + const tz = db("SELECT setting_value FROM system_settings WHERE category='app' AND setting_key='timezone' LIMIT 1") || 'Europe/Rome'; + today = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date()); + loanDays = parseInt(db("SELECT COALESCE((SELECT setting_value FROM system_settings WHERE category='loans' AND setting_key='loan_duration_days'), '30')"), 10) || 30; + const maxDays = parseInt(db("SELECT COALESCE((SELECT setting_value FROM system_settings WHERE category='loans' AND setting_key='max_loan_duration_days'), '90')"), 10) || 90; + loanDays = Math.min(loanDays, 3650); + // Staff loans use loan_duration_days; the reader's request defaults to + // min(loan, max), a valid setup when max is the smaller one. + requestDays = Math.min(loanDays, maxDays); + pickupDays = Math.max(1, parseInt(db("SELECT COALESCE((SELECT setting_value FROM system_settings WHERE category='loans' AND setting_key='pickup_expiry_days'), '3')"), 10) || 3); + + const hash = execFileSync('php', ['-r', `echo password_hash('${READER_PASS}', PASSWORD_DEFAULT);`], { encoding: 'utf-8' }).trim(); + db(`INSERT INTO utenti (codice_tessera, nome, cognome, email, password, stato, email_verificata, tipo_utente, created_at) VALUES ('CAL${RUN}', 'Cal', 'Reader', '${READER_EMAIL}', '${hash}', 'attivo', 1, 'standard', NOW())`); + readerId = parseInt(db(`SELECT id FROM utenti WHERE email='${READER_EMAIL}'`), 10); + for (const key of ['prefill', 'store', 'pickup', 'reprieve', 'branch', 'approve', 'approvebusy', 'crafted', 'renew', 'weekly', 'request', 'reqstart', 'api', 'zero']) { + const title = `${TAG} ${key}`; + db(`INSERT INTO libri (titolo, anno_pubblicazione, copie_totali, copie_disponibili, created_at, updated_at) VALUES ('${title}', 2024, 1, 1, NOW(), NOW())`); + const id = parseInt(db(`SELECT id FROM libri WHERE titolo='${title}' AND deleted_at IS NULL ORDER BY id DESC LIMIT 1`), 10); + const inv = `CAL-${key}-${RUN}`; + db(`INSERT INTO copie (libro_id, stato, numero_inventario, created_at) VALUES (${id}, 'disponibile', '${inv}', NOW())`); + db(`UPDATE libri SET search_index = titolo WHERE id=${id}`); + books[key] = { id, inv }; + } + }); + + test.afterAll(() => { + // Only this run's rows: an id watermark would also take closures that a + // concurrent writer stored meanwhile. + db(`DELETE FROM calendario_chiusure WHERE descrizione='${TAG}'`); + if (prevWeekdays === '') db("DELETE FROM system_settings WHERE category='loans' AND setting_key='closed_weekdays'"); + else setWeekdays(prevWeekdays); + const ids = Object.values(books).map((b) => b.id).join(',') || '0'; + // related_id also holds message, user, reservation and review ids: only + // the loan notifications (the loan types, or a general one about loans). + db(`DELETE FROM admin_notifications WHERE (type IN ('new_loan_request','overdue_loan') OR (type='general' AND link LIKE '%/admin/loans%')) AND related_id IN (SELECT id FROM prestiti WHERE libro_id IN (${ids}))`); + db(`DELETE FROM prestiti WHERE libro_id IN (${ids})`); + db(`DELETE FROM prenotazioni WHERE libro_id IN (${ids})`); + db(`DELETE FROM copie WHERE libro_id IN (${ids})`); + db(`DELETE FROM libri WHERE id IN (${ids})`); + if (readerId) db(`DELETE FROM utenti WHERE id=${readerId}`); + }); + + test.afterEach(() => { clearClosures(); setWeekdays(''); }); + + // ── Admin loan form ────────────────────────────────────────────────────── + test('Admin 1: the proposed due date skips a closed day', async ({ page }) => { + const defaultDue = addDays(today, loanDays); + closeDay(defaultDue); + await loginAs(page, ADMIN_EMAIL, ADMIN_PASS); + await page.goto(`${BASE}/admin/loans/create`); + await expect(page.locator('#data_scadenza')).toHaveValue(nextOpenInDb(addDays(defaultDue, 1))); + }); + + test('Admin 2: closed days are disabled in both pickers, today stays selectable', async ({ page }) => { + const closed = addDays(today, 5); + closeDay(today); + closeDay(closed); + await loginAs(page, ADMIN_EMAIL, ADMIN_PASS); + await page.goto(`${BASE}/admin/loans/create`); + await flatpickrReady(page, '#data_scadenza'); + const state = await page.evaluate(({ closed, today }) => { + const s = /** @type {any} */ (document.getElementById('data_prestito'))._flatpickr; + const e = /** @type {any} */ (document.getElementById('data_scadenza'))._flatpickr; + return { startClosed: s.isEnabled(closed), endClosed: e.isEnabled(closed), startToday: s.isEnabled(today) }; + }, { closed, today }); + expect(state.startClosed).toBe(false); + expect(state.endClosed).toBe(false); + expect(state.startToday).toBe(true); + }); + + test('Admin 3: a loan saved with a due date that became a closed day is stored on the next open day', async ({ page }) => { + await loginAs(page, ADMIN_EMAIL, ADMIN_PASS); + await page.goto(`${BASE}/admin/loans/create`); + await selectBorrower(page); + await resolveCopy(page, books.store); + await page.uncheck('#scarica_pdf'); + const due = await page.locator('#data_scadenza').inputValue(); + expect(due).toMatch(/^\d{4}-\d{2}-\d{2}$/); + // The library announces a closure on that very day while the form is open. + closeDay(due); + await submitLoanForm(page); + expect(db(`SELECT data_scadenza FROM prestiti WHERE libro_id=${books.store.id} AND utente_id=${readerId}`)).toBe(nextOpenInDb(addDays(due, 1))); + }); + + test('Admin 4: the pickup deadline counts open days only', async ({ page }) => { + // Two closed days right after today: N pickup days end two days later. + closeDay(addDays(today, 1), addDays(today, 2)); + await loginAs(page, ADMIN_EMAIL, ADMIN_PASS); + await page.goto(`${BASE}/admin/loans/create`); + await selectBorrower(page); + await resolveCopy(page, books.pickup); + await page.uncheck('#consegna_immediata'); + await submitLoanForm(page); + const row = db(`SELECT CONCAT(stato,'|',pickup_deadline,'|',data_scadenza) FROM prestiti WHERE libro_id=${books.pickup.id} AND utente_id=${readerId}`); + const [state, deadline, dueDate] = row.split('|'); + expect(state).toBe('da_ritirare'); + // The deadline never passes the due date (a loan shorter than the pickup + // window, e.g. 1 loan day and 3 pickup days, ends it there). + const openDeadline = addDays(today, pickupDays + 2); + expect(deadline).toBe(dueDate < openDeadline ? dueDate : openDeadline); + }); + + test('Admin 5: with no closures the due date and the pickup deadline are as before', async ({ page }) => { + // This run adds no closure here, but one already in the table (another + // run's, an operator's) still moves the dates: the expectations follow it. + const expectedDue = nextOpenInDb(addDays(today, loanDays)); + const expectedPickup = addOpenDaysInDb(today, pickupDays, expectedDue); + await loginAs(page, ADMIN_EMAIL, ADMIN_PASS); + await page.goto(`${BASE}/admin/loans/create`); + await expect(page.locator('#data_scadenza')).toHaveValue(expectedDue); + await selectBorrower(page); + await resolveCopy(page, books.zero); + await page.uncheck('#consegna_immediata'); + await submitLoanForm(page); + expect(db(`SELECT CONCAT(data_scadenza,'|',pickup_deadline) FROM prestiti WHERE libro_id=${books.zero.id} AND utente_id=${readerId}`)) + .toBe(`${expectedDue}|${expectedPickup}`); + }); + + test('Admin 6: closing the past pickup deadline day from the calendar page keeps the pickup collectable', async ({ page }) => { + // The deadline was yesterday; the librarian then declares yesterday closed. + const yesterday = addDays(today, -1); + const copy = db(`SELECT id FROM copie WHERE libro_id=${books.reprieve.id} LIMIT 1`); + db(`UPDATE copie SET stato='prenotato' WHERE id=${copy}`); + db(`INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, attivo, pickup_deadline, origine) VALUES (${books.reprieve.id}, ${copy}, ${readerId}, '${addDays(today, -4)}', '${addDays(today, 20)}', 'da_ritirare', 1, '${yesterday}', 'diretto')`); + const loanId = parseInt(db(`SELECT id FROM prestiti WHERE libro_id=${books.reprieve.id} AND utente_id=${readerId} AND attivo=1`), 10); + + await loginAs(page, ADMIN_EMAIL, ADMIN_PASS); + await page.goto(`${BASE}/admin/calendar`); + await page.fill('#closure_start', yesterday); + await page.fill('#closure_description', TAG); + await Promise.all([ + page.waitForURL(/\/admin\/calendar/, { timeout: 15000 }), + page.locator('#save-closure').click(), + ]); + // Yesterday was closed, so the reader still has today to come in. + expect(db(`SELECT pickup_deadline FROM prestiti WHERE id=${loanId}`)).toBe(today); + + await page.goto(`${BASE}/admin/loans/pending`); + const pickupBtn = page.locator(`.confirm-pickup-btn[data-loan-id="${loanId}"]`); + await expect(pickupBtn).toBeVisible({ timeout: 10000 }); + await pickupBtn.click(); + await page.waitForSelector('.swal2-popup', { timeout: 10000 }); + const confirm = page.waitForResponse((r) => r.url().includes('/admin/loans/confirm-pickup') && r.request().method() === 'POST', { timeout: 15000 }); + await page.locator('.swal2-confirm').click(); + const body = await (await confirm).json().catch(() => ({})); + expect(body.success, body.message || 'confirm-pickup refused').toBe(true); + expect(db(`SELECT stato FROM prestiti WHERE id=${loanId}`)).toBe('in_corso'); + }); + + test('Admin 7: closures of the copy\'s branch move the due date and lengthen the pickup days', async ({ page }) => { + db(`INSERT INTO sedi (nome) VALUES ('${TAG} sede')`); + const sede = parseInt(db(`SELECT id FROM sedi WHERE nome='${TAG} sede'`), 10); + try { + db(`UPDATE copie SET sede_id=${sede} WHERE libro_id=${books.branch.id}`); + const due = addDays(today, loanDays); + // Closed at that branch only: on the due date and on the day after today. + db(`INSERT INTO calendario_chiusure (data_inizio, data_fine, descrizione, sede_id) VALUES ('${due}', '${due}', '${TAG}', ${sede}), ('${addDays(today, 1)}', '${addDays(today, 1)}', '${TAG}', ${sede})`); + await loginAs(page, ADMIN_EMAIL, ADMIN_PASS); + await page.goto(`${BASE}/admin/loans/create`); + // The form does not know the copy yet: it proposes the library-wide date. + await expect(page.locator('#data_scadenza')).toHaveValue(due); + await selectBorrower(page); + await resolveCopy(page, books.branch); + await page.uncheck('#consegna_immediata'); + await submitLoanForm(page); + expect(db(`SELECT CONCAT(stato,'|',data_scadenza,'|',pickup_deadline) FROM prestiti WHERE libro_id=${books.branch.id} AND utente_id=${readerId}`)) + .toBe(`da_ritirare|${addDays(due, 1)}|${addDays(today, Math.min(pickupDays + 1, loanDays + 1))}`); + } finally { + db(`DELETE FROM calendario_chiusure WHERE sede_id=${sede}`); + db(`UPDATE copie SET sede_id=NULL WHERE libro_id=${books.branch.id}`); + db(`DELETE FROM sedi WHERE id=${sede}`); + } + }); + + test('Admin 8: approving a request onto a copy whose branch is closed on the due date moves it to the next open day', async ({ page }) => { + db(`INSERT INTO sedi (nome) VALUES ('${TAG} sede approva')`); + const sede = parseInt(db(`SELECT id FROM sedi WHERE nome='${TAG} sede approva'`), 10); + try { + db(`UPDATE copie SET sede_id=${sede} WHERE libro_id=${books.approve.id}`); + const due = addDays(today, 10); + db(`INSERT INTO calendario_chiusure (data_inizio, data_fine, descrizione, sede_id) VALUES ('${due}', '${due}', '${TAG}', ${sede})`); + // A reader request waiting for approval (library-wide date, no copy yet). + db(`INSERT INTO prestiti (libro_id, utente_id, data_prestito, data_scadenza, stato, origine, attivo) VALUES (${books.approve.id}, ${readerId}, '${today}', '${due}', 'pendente', 'richiesta', 0)`); + const loanId = parseInt(db(`SELECT id FROM prestiti WHERE libro_id=${books.approve.id} AND utente_id=${readerId} AND stato='pendente'`), 10); + await loginAs(page, ADMIN_EMAIL, ADMIN_PASS); + await page.goto(`${BASE}/admin/loans/pending`); + const approve = page.locator(`.approve-btn[data-loan-id="${loanId}"]`); + await expect(approve).toBeVisible({ timeout: 10000 }); + await approve.click(); + await page.waitForSelector('.swal2-popup', { timeout: 10000 }); + const reply = page.waitForResponse((r) => r.url().includes('/admin/loans/approve') && r.request().method() === 'POST', { timeout: 15000 }); + await page.locator('.swal2-confirm').click(); + expect((await (await reply).json()).success).toBe(true); + expect(db(`SELECT CONCAT(stato,'|',data_scadenza) FROM prestiti WHERE id=${loanId}`)).toBe(`da_ritirare|${addDays(due, 1)}`); + } finally { + db(`DELETE FROM calendario_chiusure WHERE sede_id=${sede}`); + db(`UPDATE copie SET sede_id=NULL WHERE libro_id=${books.approve.id}`); + db(`DELETE FROM sedi WHERE id=${sede}`); + } + }); + + test('Admin 9: the approval keeps the requested due date when the copy is taken right after it', async ({ page }) => { + db(`INSERT INTO sedi (nome) VALUES ('${TAG} sede piena')`); + const sede = parseInt(db(`SELECT id FROM sedi WHERE nome='${TAG} sede piena'`), 10); + const book = books.approvebusy; + let otherId = 0; + try { + const copyA = parseInt(db(`SELECT id FROM copie WHERE libro_id=${book.id} LIMIT 1`), 10); + // A second copy keeps the title-level capacity free on the extra day. + db(`INSERT INTO copie (libro_id, stato, numero_inventario, created_at) VALUES (${book.id}, 'disponibile', 'CAL-approvebusy2-${RUN}', NOW())`); + db(`UPDATE libri SET copie_totali=2, copie_disponibili=2 WHERE id=${book.id}`); + db(`UPDATE copie SET sede_id=${sede} WHERE id=${copyA}`); + const due = addDays(today, 10); + db(`INSERT INTO calendario_chiusure (data_inizio, data_fine, descrizione, sede_id) VALUES ('${due}', '${due}', '${TAG}', ${sede})`); + // Copy A is promised to another reader from the day after the due date. + db(`INSERT INTO utenti (codice_tessera, nome, cognome, email, password, stato, email_verificata, tipo_utente, created_at) VALUES ('CAO${RUN}', 'Cal', 'Other', 'calother-${RUN}@example.test', 'x', 'attivo', 1, 'standard', NOW())`); + otherId = parseInt(db(`SELECT id FROM utenti WHERE email='calother-${RUN}@example.test'`), 10); + db(`INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, origine, attivo) VALUES (${book.id}, ${copyA}, ${otherId}, '${addDays(due, 1)}', '${addDays(due, 20)}', 'prenotato', 'diretto', 1)`); + // A request already holding copy A (as a promoted reservation does). + db(`INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, origine, attivo) VALUES (${book.id}, ${copyA}, ${readerId}, '${today}', '${due}', 'pendente', 'prenotazione', 0)`); + const loanId = parseInt(db(`SELECT id FROM prestiti WHERE libro_id=${book.id} AND stato='pendente'`), 10); + await loginAs(page, ADMIN_EMAIL, ADMIN_PASS); + await page.goto(`${BASE}/admin/loans/pending`); + const approve = page.locator(`.approve-btn[data-loan-id="${loanId}"]`); + await expect(approve).toBeVisible({ timeout: 10000 }); + await approve.click(); + await page.waitForSelector('.swal2-popup', { timeout: 10000 }); + const reply = page.waitForResponse((r) => r.url().includes('/admin/loans/approve') && r.request().method() === 'POST', { timeout: 15000 }); + await page.locator('.swal2-confirm').click(); + const body = await (await reply).json(); + expect(body.success, JSON.stringify(body)).toBe(true); + expect(db(`SELECT CONCAT(stato,'|',copia_id,'|',data_scadenza) FROM prestiti WHERE id=${loanId}`)).toBe(`da_ritirare|${copyA}|${due}`); + } finally { + db(`DELETE FROM calendario_chiusure WHERE sede_id=${sede}`); + db(`UPDATE copie SET sede_id=NULL WHERE libro_id=${book.id}`); + db(`DELETE FROM sedi WHERE id=${sede}`); + if (otherId) { + db(`DELETE FROM prestiti WHERE utente_id=${otherId}`); + db(`DELETE FROM utenti WHERE id=${otherId}`); + } + } + }); + + // ── Renewal ────────────────────────────────────────────────────────────── + async function renewFromBookPage(page, book, loanId) { + await page.goto(`${BASE}/admin/books/${book.id}`); + const form = page.locator(`form[action*="/admin/loans/renew/${loanId}"]`).last(); + await form.waitFor({ state: 'attached', timeout: 10000 }); + await form.locator('button[type="submit"]').click(); + await page.waitForSelector('.swal2-popup', { timeout: 8000 }); + await Promise.all([ + page.waitForURL(/\/admin\/books\/\d+/, { timeout: 15000 }), + page.locator('.swal2-confirm').click(), + ]); + } + function activeLoan(book, due) { + const copy = db(`SELECT id FROM copie WHERE libro_id=${book.id} LIMIT 1`); + db(`UPDATE copie SET stato='prestato' WHERE id=${copy}`); + db(`INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, attivo, renewals) VALUES (${book.id}, ${copy}, ${readerId}, '${today}', '${due}', 'in_corso', 1, 0)`); + db(`UPDATE libri SET copie_disponibili=0 WHERE id=${book.id}`); + return parseInt(db(`SELECT id FROM prestiti WHERE libro_id=${book.id} AND utente_id=${readerId} AND attivo=1`), 10); + } + + test('Renew 1: a renewal ending on a closed day moves to the next open day', async ({ page }) => { + const due = addDays(today, 5); + const loanId = activeLoan(books.renew, due); + closeDay(addDays(due, loanDays)); + await loginAs(page, ADMIN_EMAIL, ADMIN_PASS); + await renewFromBookPage(page, books.renew, loanId); + await expect.poll(() => db(`SELECT CONCAT(data_scadenza,'|',renewals) FROM prestiti WHERE id=${loanId}`), { timeout: 8000 }) + .toBe(`${nextOpenInDb(addDays(due, loanDays + 1))}|1`); + }); + + test('Renew 2: a renewal ending on a weekly closed day moves to the next open day', async ({ page }) => { + const due = addDays(today, 6); + const loanId = activeLoan(books.weekly, due); + const raw = addDays(due, loanDays); + setWeekdays(String(weekday(raw))); + await loginAs(page, ADMIN_EMAIL, ADMIN_PASS); + await renewFromBookPage(page, books.weekly, loanId); + await expect.poll(() => db(`SELECT data_scadenza FROM prestiti WHERE id=${loanId}`), { timeout: 8000 }).toBe(nextOpenInDb(addDays(raw, 1))); + }); + + // ── Reader's request calendar ──────────────────────────────────────────── + test('Request 1: the availability sent to the calendar lists the closed days', async ({ page }) => { + const closed = addDays(today, 3); + closeDay(closed); + await loginAs(page, READER_EMAIL, READER_PASS); + const data = await openRequestModal(page, books.api); + expect(data.availability.closed_dates).toContain(closed); + expect(data.availability.closed_dates).not.toContain(addDays(today, 4)); + }); + + test('Request 2: a closed day cannot be chosen as the start day', async ({ page }) => { + const closed = addDays(today, 3); + closeDay(closed); + await loginAs(page, READER_EMAIL, READER_PASS); + await openRequestModal(page, books.reqstart); + const result = await page.evaluate((closed) => { + const fp = /** @type {any} */ (document.querySelector('#swal-date-start'))._flatpickr; + fp.jumpToDate(closed); + const el = Array.from(document.querySelectorAll('.flatpickr-day')).find((d) => /** @type {any} */ (d).dateObj && fp.formatDate(/** @type {any} */ (d).dateObj, 'Y-m-d') === closed && !d.classList.contains('prevMonthDay') && !d.classList.contains('nextMonthDay')); + fp.setDate(closed, true); + return { enabled: fp.isEnabled(closed), disabledClass: !!el && el.classList.contains('flatpickr-disabled'), title: el ? el.getAttribute('title') : null, selected: fp.input.value }; + }, closed); + expect(result.enabled).toBe(false); + expect(result.disabledClass).toBe(true); + expect(result.title).toMatch(/Biblioteca chiusa/); + expect(result.selected).not.toBe(closed); + }); + + test('Request 3: when today is closed the calendar proposes the next open day', async ({ page }) => { + closeDay(today); + await loginAs(page, READER_EMAIL, READER_PASS); + await openRequestModal(page, books.reqstart); + await expect(page.locator('#swal-date-start')).toHaveValue(addDays(today, 1)); + }); + + test('Request 5: a request sent with a closed start day starts on the next open day', async ({ page }) => { + // The calendar refuses a closed start day; a request built by hand gets + // the same answer from the server, and the window it stored comes back. + const closed = addDays(today, 2); + closeDay(closed); + await loginAs(page, READER_EMAIL, READER_PASS); + await page.goto(`${BASE}/libro/${books.crafted.id}`); + const sent = await page.evaluate(async ({ id, start }) => { + const csrf = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''; + const res = await fetch(`${/** @type {any} */ (window).BASE_PATH || ''}/api/libro/${id}/reservation`, { + method: 'POST', credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf }, + body: JSON.stringify({ start_date: start, csrf_token: csrf }), + }); + return { status: res.status, body: await res.json() }; + }, { id: books.crafted.id, start: closed }); + expect(sent.body.success, JSON.stringify(sent.body)).toBe(true); + expect(sent.body.start_date).toBe(nextOpenInDb(addDays(closed, 1))); + expect(db(`SELECT data_prestito FROM prestiti WHERE libro_id=${books.crafted.id} AND utente_id=${readerId} ORDER BY id DESC LIMIT 1`)).toBe(nextOpenInDb(addDays(closed, 1))); + }); + + test('Request 4: a request whose return date falls on a closed day is saved with the next open day', async ({ page }) => { + const start = addDays(today, 1); + closeDay(addDays(start, requestDays)); + await loginAs(page, READER_EMAIL, READER_PASS); + await openRequestModal(page, books.request); + await page.evaluate((iso) => { /** @type {any} */ (document.querySelector('#swal-date-start'))._flatpickr.setDate(iso, true); }, start); + await page.locator('#swal-date-end').evaluate((el) => /** @type {any} */ (el)._flatpickr.clear()); + await page.locator('.swal2-confirm').click(); + await page.waitForSelector('.swal2-icon-success', { timeout: 15000 }); + expect(db(`SELECT CONCAT(data_prestito,'|',data_scadenza) FROM prestiti WHERE libro_id=${books.request.id} AND utente_id=${readerId} ORDER BY id DESC LIMIT 1`)) + .toBe(`${start}|${nextOpenInDb(addDays(start, requestDays + 1))}`); + }); +}); diff --git a/tests/issues-301-306.unit.php b/tests/issues-301-306.unit.php index cef8e909e..e3ce1be86 100644 --- a/tests/issues-301-306.unit.php +++ b/tests/issues-301-306.unit.php @@ -68,8 +68,11 @@ 'automatic approval sends the patron approval email' ); $check( - str_contains($mobileActions, "'auto_approved' => \$autoApproved") - && str_contains($mobileActions, "'status' => \$autoApproved ? 'da_ritirare' : 'pendente'") + // The state the request was stored with (CirculationOutcome::requestState(): + // pendente, da_ritirare, or prenotato when a closed day moved the start). + str_contains($mobileActions, 'CirculationOutcome::requestState($outcome)') + && str_contains($mobileActions, "'auto_approved' => \$status !== 'pendente'") + && str_contains($mobileActions, "'status' => \$status,") && str_contains($mobileHealth, "'loan_approval_required' => \$loanApprovalRequired") && str_contains($mobileDocs, 'data.auto_approved'), 'Mobile API advertises the setting and returns the actual request outcome' diff --git a/tests/library-calendar-loans.unit.php b/tests/library-calendar-loans.unit.php new file mode 100644 index 000000000..54d8ea452 --- /dev/null +++ b/tests/library-calendar-loans.unit.php @@ -0,0 +1,600 @@ +set_charset('utf8mb4'); + +$passed = 0; +$failed = 0; +$check = static function (bool $ok, string $label) use (&$passed, &$failed): void { + if ($ok) { + $passed++; + echo "OK {$label}\n"; + } else { + $failed++; + echo "FAIL {$label}\n"; + } +}; + +if ($db->query("SHOW TABLES LIKE 'calendario_chiusure'")->num_rows === 0) { + echo "calendario_chiusure missing: run migrate_0.9.0-rc.1.sql first\n"; + exit(1); +} + +$run = bin2hex(random_bytes(3)); +$tag = "zz-cal-{$run}"; +$prevWeekdays = $db->query("SELECT setting_value FROM system_settings WHERE category = 'loans' AND setting_key = 'closed_weekdays'")->fetch_row(); +$bookId = 0; +$userId = 0; +$otherUserId = 0; +$sedeId = 0; +$setWeekdays = static function (string $value) use ($db): void { + $stmt = $db->prepare("INSERT INTO system_settings (category, setting_key, setting_value) VALUES ('loans', 'closed_weekdays', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)"); + $stmt->bind_param('s', $value); + $stmt->execute(); + $stmt->close(); + LibraryCalendar::reset(); +}; +$addClosure = static function (string $from, string $to, ?int $sede = null, int $yearly = 0) use ($db, $tag): void { + $stmt = $db->prepare('INSERT INTO calendario_chiusure (data_inizio, data_fine, descrizione, ricorrente_annuale, sede_id) VALUES (?, ?, ?, ?, ?)'); + $stmt->bind_param('sssii', $from, $to, $tag, $yearly, $sede); + $stmt->execute(); + $stmt->close(); + LibraryCalendar::reset(); +}; +$clearClosures = static function () use ($db, $tag): void { + $db->query("DELETE FROM calendario_chiusure WHERE descrizione = '{$tag}'"); + LibraryCalendar::reset(); +}; +$cleanup = static function () use ($db, $tag, $prevWeekdays, &$bookId, &$userId, &$otherUserId, &$sedeId): void { + $db->query("DELETE FROM calendario_chiusure WHERE descrizione = '{$tag}'"); + if ($prevWeekdays === null) { + $db->query("DELETE FROM system_settings WHERE category = 'loans' AND setting_key = 'closed_weekdays'"); + } else { + $stmt = $db->prepare("UPDATE system_settings SET setting_value = ? WHERE category = 'loans' AND setting_key = 'closed_weekdays'"); + $stmt->bind_param('s', $prevWeekdays[0]); + $stmt->execute(); + } + if ($bookId > 0) { + $db->query("DELETE FROM prenotazioni WHERE libro_id = {$bookId}"); + $db->query("DELETE FROM admin_notifications WHERE (type IN ('new_loan_request', 'overdue_loan') OR (type = 'general' AND link LIKE '%/admin/loans%')) AND related_id IN (SELECT id FROM prestiti WHERE libro_id = {$bookId})"); + $db->query("DELETE FROM prestiti WHERE libro_id = {$bookId}"); + $db->query("DELETE FROM copie WHERE libro_id = {$bookId}"); + $db->query("DELETE FROM libri WHERE id = {$bookId}"); + } + if ($userId > 0) { + $db->query("DELETE FROM utenti WHERE id = {$userId}"); + } + if ($otherUserId > 0) { + $db->query("DELETE FROM utenti WHERE id = {$otherUserId}"); + } + if ($sedeId > 0) { + $db->query("DELETE FROM sedi WHERE id = {$sedeId}"); + } + LibraryCalendar::reset(); +}; +register_shutdown_function($cleanup); + +$today = DateHelper::today(); +$shift = static fn (string $d, int $n): string => LibraryCalendar::shift($d, $n); + +// ── Zero config ─────────────────────────────────────────────────────────── +$setWeekdays(''); +$clearClosures(); +// Closures this run did not add (another suite's, or the operator's) stay in +// the table: the dates below are taken where none of them closes the library. +$baseline = LibraryCalendar::forDb($db); +$openFrom = static function (string $from, int $span = 0) use ($baseline, $shift): string { + for ($day = $from, $i = 0; $i < 1100; $day = $shift($day, 1), $i++) { + $allOpen = true; + for ($k = 0; $k <= $span && $allOpen; $k++) { + $allOpen = $baseline->isOpen($shift($day, $k)); + } + if ($allOpen) { + return $day; + } + } + return $from; +}; +$plain = $openFrom($shift($today, 30)); +$check(LibraryCalendar::dueDate($db, $plain) === $plain, 'zero config: dueDate is the computed date'); +// From a day whose next three days are open: the deadline is plain + N. +$pickFrom = $shift($openFrom($shift($today, 1), 2), -1); +$check(LibraryCalendar::pickupDeadline($db, $pickFrom, 3) === $shift($pickFrom, 3), 'zero config: pickupDeadline is start + N'); +$check(LibraryCalendar::pickupDeadline($db, $pickFrom, 3, $shift($pickFrom, 1)) === $shift($pickFrom, 1), 'pickupDeadline is capped at the due date'); + +// ── A closures read that fails is not an empty calendar ────────────────── +$broken = new mysqli($env['DB_HOST'] ?? 'localhost', $env['DB_USER'], $env['DB_PASS'] ?? '', $env['DB_NAME'], (int) ($env['DB_PORT'] ?? 3306), getenv('E2E_DB_SOCKET') ?: ($env['DB_SOCKET'] ?? null)); +$broken->close(); +$loadError = null; +try { + LibraryCalendar::fromDatabase($broken); +} catch (\Throwable $e) { + $loadError = $e; +} +$check($loadError instanceof \RuntimeException, 'a closures read that fails throws instead of returning a calendar with no closures'); + +// A connection whose prepare() fails for one table: the closed weekdays read +// and the copy branch lookup must throw like the closures read, not answer +// "no weekly closure" / "no branch". +$failingDb = new class ($env['DB_HOST'] ?? 'localhost', $env['DB_USER'], $env['DB_PASS'] ?? '', $env['DB_NAME'], (int) ($env['DB_PORT'] ?? 3306), getenv('E2E_DB_SOCKET') ?: ($env['DB_SOCKET'] ?? null)) extends mysqli { + public string $failOn = ''; + public function prepare(string $query): mysqli_stmt|false + { + if ($this->failOn !== '' && str_contains($query, $this->failOn)) { + return false; + } + return parent::prepare($query); + } +}; +$failingDb->failOn = 'system_settings'; +$weekdaysError = null; +try { + LibraryCalendar::fromDatabase($failingDb); +} catch (\Throwable $e) { + $weekdaysError = $e; +} +$check($weekdaysError instanceof \RuntimeException, 'a closed weekdays read that fails throws instead of returning a calendar with no weekly closure'); +$failingDb->failOn = 'FROM copie'; +LibraryCalendar::reset(); +$branchError = null; +try { + LibraryCalendar::branchOfCopy($failingDb, 1); +} catch (\Throwable $e) { + $branchError = $e; +} +$check($branchError instanceof \RuntimeException, 'a copy branch lookup that fails throws instead of answering "no branch"'); +$failingDb->close(); +LibraryCalendar::reset(); + +// ── 14-day loan ending on Sunday → Monday ───────────────────────────────── +$setWeekdays('0'); +// A Sunday that, like the Monday after it, no pre-existing closure closes. +$sunday = $shift($today, 20); +while ((int) (new DateTimeImmutable($sunday))->format('w') !== 0 || !$baseline->isOpen($sunday) || !$baseline->isOpen($shift($sunday, 1))) { + $sunday = $shift($sunday, 1); +} +$start = $shift($sunday, -14); +$check(LibraryCalendar::dueDate($db, $shift($start, 14)) === $shift($sunday, 1), "14-day loan from {$start} ending Sunday {$sunday} moves to Monday"); + +// ── CirculationPolicy::dueDateFor() is the funnel every due date goes through +$check(CirculationPolicy::dueDateFor($start, 14, $db) === $shift($sunday, 1), 'dueDateFor with the connection: 14 days ending Sunday move to Monday'); +$check(CirculationPolicy::dueDateFor($start, 14) === $sunday, 'dueDateFor without a connection is plain arithmetic'); +$check(CirculationPolicy::dueDateFor($sunday, 0, $db) === $shift($sunday, 1), 'dueDateFor(typed date, 0) moves a typed Sunday to Monday'); +$check(CirculationPolicy::dueDateFor($shift($sunday, -10), 10, $db) === $shift($sunday, 1), 'a 10-day patron-type duration ending Sunday moves to Monday'); +$check(CirculationPolicy::dueDateFor($shift($sunday, 1), 0, $db) === $shift($sunday, 1), 'dueDateFor leaves an open day alone'); +$setWeekdays(''); +$check(CirculationPolicy::dueDateFor($start, 14, $db) === $sunday, 'dueDateFor with no closures equals the historical arithmetic'); + +// ── Ending Dec 25 with 24–26 closed → 27 ───────────────────────────────── +$setWeekdays(''); +$year = (int) substr($today, 0, 4) + 1; +$addClosure("{$year}-12-24", "{$year}-12-26"); +$check(LibraryCalendar::dueDate($db, $shift("{$year}-12-11", 14)) === "{$year}-12-27", "14-day loan ending {$year}-12-25 with 24-26 closed moves to the 27th"); +$check(LibraryCalendar::pickupDeadline($db, "{$year}-12-22", 3) === "{$year}-12-28", 'pickup over Christmas counts only open days (23, 27, 28)'); + +// ── Branch-specific closure through the copy's sede ─────────────────────── +$clearClosures(); +$db->query("INSERT INTO sedi (nome) VALUES ('{$tag}')"); +$sedeId = (int) $db->insert_id; +$title = "Calendar test {$run}"; +$stmt = $db->prepare("INSERT INTO libri (titolo, copie_totali, copie_disponibili) VALUES (?, 1, 1)"); +$stmt->bind_param('s', $title); +$stmt->execute(); +$bookId = (int) $db->insert_id; +$stmt->close(); +$inv = "ZZCAL-{$run}"; +$stmt = $db->prepare("INSERT INTO copie (libro_id, numero_inventario, stato, sede_id) VALUES (?, ?, 'disponibile', ?)"); +$stmt->bind_param('isi', $bookId, $inv, $sedeId); +$stmt->execute(); +$copyId = (int) $db->insert_id; +$stmt->close(); +$branchDay = $shift($today, 40); +$addClosure($branchDay, $branchDay, $sedeId); +$check(LibraryCalendar::dueDate($db, $branchDay, $copyId) === $shift($branchDay, 1), "a closure of the copy's branch moves its due date"); +$check(LibraryCalendar::dueDate($db, $branchDay) === $branchDay, 'the same closure does not move a loan without that branch'); +$check(CirculationPolicy::dueDateFor($shift($branchDay, -7), 7, $db, $copyId) === $shift($branchDay, 1), "dueDateFor applies the copy's branch closures when the copy is known"); + +// {{giorni_ritiro}} counts the open days of the copy's branch, as the +// deadline it describes does. +$pickupSetting = max(1, (int) ((new \App\Models\SettingsRepository($db))->get('loans', 'pickup_expiry_days', '3') ?? 3)); +$effectivePickupDays = new ReflectionMethod(\App\Support\NotificationService::class, 'effectivePickupDays'); +$notifier = new \App\Support\NotificationService($db); +$windowStart = $shift($branchDay, -1); +$windowEnd = $shift($windowStart, $pickupSetting); +$check($effectivePickupDays->invoke($notifier, $windowStart, $windowEnd, $copyId) === (string) ($pickupSetting - 1), "pickup days: a closure of the copy's branch inside the window is not a pickup day"); +$check($effectivePickupDays->invoke($notifier, $windowStart, $windowEnd) === (string) $pickupSetting, 'pickup days: without the copy the branch closure does not count'); + +// ── Renewal cap steps back to an open day ───────────────────────────────── +$clearClosures(); +$conflict = $shift($today, 50); +$closedBefore = $shift($conflict, -1); +$addClosure($closedBefore, $closedBefore); +$cap = LoanWindow::renewalCap($shift($today, 30), [$conflict], null, LibraryCalendar::forDb($db)); +$check($cap !== null && $cap['max_due_date'] === $shift($conflict, -2), 'renewal cap: the day before the conflict is closed, so the proposal steps back one more day'); +$capNoCal = LoanWindow::renewalCap($shift($today, 30), [$conflict]); +$check($capNoCal !== null && $capNoCal['max_due_date'] === $closedBefore, 'renewal cap without a calendar is unchanged'); +$clearClosures(); +$addClosure($closedBefore, $closedBefore, $sedeId); +$capBranch = LoanWindow::renewalCap($shift($today, 30), [$conflict], null, LibraryCalendar::forDb($db), LibraryCalendar::branchOfCopy($db, $copyId)); +$check($capBranch !== null && $capBranch['max_due_date'] === $shift($conflict, -2), "renewal cap: a closure of the copy's branch on the day before the conflict steps back too"); +$check(LibraryCalendar::dueDate($db, $capBranch['max_due_date'] ?? '', $copyId) === ($capBranch['max_due_date'] ?? ''), "the proposed date stays put when the renewal moves it with the copy's branch closures"); +$capLibrary = LoanWindow::renewalCap($shift($today, 30), [$conflict], null, LibraryCalendar::forDb($db)); +$check($capLibrary !== null && $capLibrary['max_due_date'] === $closedBefore, 'without the branch the branch closure is ignored, as before'); + +// ── Nightly jobs ────────────────────────────────────────────────────────── +$clearClosures(); +$email = "{$tag}@test.local"; +$card = strtoupper("ZZCAL{$run}"); +$hash = password_hash('Calendar!1', PASSWORD_DEFAULT); +$stmt = $db->prepare("INSERT INTO utenti (codice_tessera, nome, cognome, email, password, tipo_utente, stato, email_verificata) VALUES (?, 'Cal', 'Test', ?, ?, 'standard', 'attivo', 1)"); +$stmt->bind_param('sss', $card, $email, $hash); +$stmt->execute(); +$userId = (int) $db->insert_id; +$stmt->close(); +$db->query("UPDATE copie SET stato = 'prenotato' WHERE id = {$copyId}"); +$yesterday = $shift($today, -1); +$loanStart = $shift($today, -4); +$loanEnd = $shift($today, 20); +$stmt = $db->prepare("INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, attivo, pickup_deadline) VALUES (?, ?, ?, ?, ?, 'da_ritirare', 1, ?)"); +$stmt->bind_param('iiisss', $bookId, $copyId, $userId, $loanStart, $loanEnd, $yesterday); +$stmt->execute(); +$pickupLoan = (int) $db->insert_id; +$stmt->close(); +$stateOf = static fn (int $id): string => (string) $db->query("SELECT stato FROM prestiti WHERE id = {$id}")->fetch_row()[0]; + +$maintenance = new MaintenanceService($db); +$addClosure($today, $today); +$check($maintenance->checkExpiredPickups() === 0 && $stateOf($pickupLoan) === 'da_ritirare', 'checkExpiredPickups expires nothing on a closed day'); +$check($maintenance->checkExpiredReservations() === 0, 'checkExpiredReservations returns 0 on a closed day'); +// A closure of the copy's branch only: the library is open, that branch is not. +$clearClosures(); +$addClosure($today, $today, $sedeId); +$maintenance->checkExpiredPickups(); +$check($stateOf($pickupLoan) === 'da_ritirare', "checkExpiredPickups keeps a pickup whose copy's branch is closed today"); +$schedStart = $shift($today, -6); +// A second copy of the same branch (the first one holds the pickup). +$inv2 = "ZZCAL-{$run}-2"; +$stmt = $db->prepare("INSERT INTO copie (libro_id, numero_inventario, stato, sede_id) VALUES (?, ?, 'prenotato', ?)"); +$stmt->bind_param('isi', $bookId, $inv2, $sedeId); +$stmt->execute(); +$copy2Id = (int) $db->insert_id; +$stmt->close(); +$stmt = $db->prepare("INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, attivo) VALUES (?, ?, ?, ?, ?, 'prenotato', 1)"); +$stmt->bind_param('iiiss', $bookId, $copy2Id, $userId, $schedStart, $yesterday); +$stmt->execute(); +$schedLoan = (int) $db->insert_id; +$stmt->close(); +$maintenance->checkExpiredReservations(); +$check($stateOf($schedLoan) === 'prenotato', "checkExpiredReservations keeps a reservation whose copy's branch is closed today"); +$clearClosures(); +$maintenance->checkExpiredReservations(); +$check($stateOf($schedLoan) === 'scaduto', 'with the branch open the same reservation expires as before'); +$db->query("DELETE FROM prestiti WHERE id = {$schedLoan}"); +$db->query("DELETE FROM copie WHERE id = {$copy2Id}"); +$db->query("UPDATE copie SET stato = 'prenotato' WHERE id = {$copyId}"); +$clearClosures(); +$addClosure($yesterday, $yesterday); +// What the calendar page does after every save. +$check(LibraryCalendar::extendPickupDeadlines($db) >= 1, 'saving the calendar moves the pickup whose deadline is now closed'); +$deadlineOf = static fn (int $id): string => (string) $db->query("SELECT pickup_deadline FROM prestiti WHERE id = {$id}")->fetch_row()[0]; +$check($deadlineOf($pickupLoan) === $today, 'the stored deadline is the next open day (today), the one every check reads'); +$maintenance->checkExpiredPickups(); +$check($stateOf($pickupLoan) === 'da_ritirare', 'a deadline on a later-declared closed day lasts until the next open day (today)'); +$check(LibraryCalendar::extendPickupDeadlines($db) === 0, 'a second save moves nothing (idempotent)'); + +// The extension never passes the loan's due date (confirmPickup() refuses a +// pickup after the loan window), and the due date itself is not moved. +$clearClosures(); +$pickupDue = $shift($today, 3); +$db->query("UPDATE prestiti SET pickup_deadline = '{$pickupDue}', data_scadenza = '{$pickupDue}' WHERE id = {$pickupLoan}"); +$addClosure($pickupDue, $shift($pickupDue, 2)); +$check(LibraryCalendar::extendPickupDeadlines($db) === 0, 'a deadline equal to the due date is left alone when the due date is closed'); +$check($deadlineOf($pickupLoan) === $pickupDue, 'its stored deadline still is the due date'); +$clearClosures(); +$db->query("UPDATE prestiti SET pickup_deadline = '{$shift($pickupDue, -1)}' WHERE id = {$pickupLoan}"); +$addClosure($shift($pickupDue, -1), $shift($pickupDue, 2)); +$check(LibraryCalendar::extendPickupDeadlines($db) >= 1, 'a deadline the day before the due date, closed through after it, moves'); +$check($deadlineOf($pickupLoan) === $pickupDue, 'and stops at the due date, not at the next open day'); +$check((string) $db->query("SELECT data_scadenza FROM prestiti WHERE id = {$pickupLoan}")->fetch_row()[0] === $pickupDue, 'the due date of the existing loan is not moved'); +$clearClosures(); +$db->query("UPDATE prestiti SET pickup_deadline = '{$shift($pickupDue, -1)}', data_scadenza = '{$shift($pickupDue, 5)}' WHERE id = {$pickupLoan}"); +$addClosure($shift($pickupDue, -1), $pickupDue); +$check(LibraryCalendar::extendPickupDeadlines($db) >= 1 && $deadlineOf($pickupLoan) === $shift($pickupDue, 1), 'with room before the due date the deadline moves to the next open day as before'); +$db->query("UPDATE prestiti SET pickup_deadline = '{$yesterday}', data_scadenza = '{$loanEnd}' WHERE id = {$pickupLoan}"); +$clearClosures(); +$db->query("UPDATE prestiti SET pickup_deadline = '{$yesterday}' WHERE id = {$pickupLoan}"); +$check(LibraryCalendar::extendPickupDeadlines($db) === 0, 'without closures no deadline moves'); +$maintenance->checkExpiredPickups(); +$check($stateOf($pickupLoan) !== 'da_ritirare', 'with the library open the overdue pickup expires as before'); + +// Queue reservation past its deadline: kept on a closed day, expired once open. +$db->query("DELETE FROM prestiti WHERE libro_id = {$bookId}"); +$resStart = $shift($today, -6); +$resDeadline = $shift($today, -1) . ' 23:59:59'; +$stmt = $db->prepare("INSERT INTO prenotazioni (libro_id, utente_id, queue_position, stato, data_prenotazione, data_scadenza_prenotazione, data_inizio_richiesta, data_fine_richiesta) VALUES (?, ?, 1, 'attiva', ?, ?, ?, ?)"); +$resEnd = $shift($today, -1); +$stmt->bind_param('iissss', $bookId, $userId, $resStart, $resDeadline, $resStart, $resEnd); +$stmt->execute(); +$queueRes = (int) $db->insert_id; +$stmt->close(); +$resState = static fn (): string => (string) $db->query("SELECT stato FROM prenotazioni WHERE id = {$queueRes}")->fetch_row()[0]; +$addClosure($today, $today); +$check((new \App\Controllers\ReservationManager($db))->cancelExpiredReservations() === 0 && $resState() === 'attiva', 'the queue expiry keeps a reservation on a closed day'); +$clearClosures(); +(new \App\Controllers\ReservationManager($db))->cancelExpiredReservations(); +$check($resState() !== 'attiva', 'with the library open the expired queue reservation is cancelled as before'); +$db->query("DELETE FROM prenotazioni WHERE id = {$queueRes}"); + +// ── NCIP CheckOutItem: the due date moves with the chosen copy's branch ─── +// handleCheckOutItem() computes the library-wide due date before any copy is +// known; createLoanAtomic() picks the copy under the book lock and must apply +// that copy's branch closures to the date it stores (as the desk does), and +// hand the stored date back for the response's DateDue. +require_once $root . '/storage/plugins/ncip-server/NcipServerPlugin.php'; +$clearClosures(); +$db->query("DELETE FROM prestiti WHERE libro_id = {$bookId}"); +$db->query("UPDATE copie SET stato = 'disponibile' WHERE id = {$copyId}"); +$db->query("UPDATE libri SET copie_disponibili = 1 WHERE id = {$bookId}"); +$ncip = new \App\Plugins\NcipServer\NcipServerPlugin($db, new \App\Support\HookManager($db)); +$createLoanAtomic = new ReflectionMethod($ncip, 'createLoanAtomic'); +$ncipPlainDue = $shift($today, 15); +$addClosure($ncipPlainDue, $ncipPlainDue, $sedeId); +$check(LibraryCalendar::dueDate($db, $ncipPlainDue) === $ncipPlainDue, 'NCIP: the library-wide due date is open (the closure is of the branch only)'); +$ncipDue = $ncipPlainDue; +$ncipFailure = null; +$ncipLoan = $createLoanAtomic->invokeArgs($ncip, [$bookId, $userId, &$ncipDue, 0, &$ncipFailure]); +$check(is_int($ncipLoan) && $ncipLoan > 0, 'NCIP: the checkout with a branch closure on its due date is created (' . var_export($ncipFailure, true) . ')'); +$ncipStored = (string) $db->query('SELECT data_scadenza FROM prestiti WHERE id = ' . (int) $ncipLoan)->fetch_row()[0]; +$check($ncipStored === $shift($ncipPlainDue, 1), "NCIP: the stored data_scadenza is the next open day of the copy's branch"); +$check($ncipDue === $ncipStored, 'NCIP: the handler is handed the stored date (the DateDue of the response)'); + +// The shifted window is checked again: a commitment on the copy from the +// day after the closure refuses the checkout instead of storing a due date +// on the closed day (the title has one copy, so capacity refuses first). +$db->query('DELETE FROM prestiti WHERE id = ' . (int) $ncipLoan); +$db->query("UPDATE copie SET stato = 'disponibile' WHERE id = {$copyId}"); +$otherEmail = "{$tag}-b@test.local"; +$otherCard = strtoupper("ZZCAL{$run}B"); +$stmt = $db->prepare("INSERT INTO utenti (codice_tessera, nome, cognome, email, password, tipo_utente, stato, email_verificata) VALUES (?, 'Cal', 'Other', ?, ?, 'standard', 'attivo', 1)"); +$stmt->bind_param('sss', $otherCard, $otherEmail, $hash); +$stmt->execute(); +$otherUserId = (int) $db->insert_id; +$stmt->close(); +$heldFrom = $shift($ncipPlainDue, 1); +$heldTo = $shift($ncipPlainDue, 10); +$stmt = $db->prepare("INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, attivo) VALUES (?, ?, ?, ?, ?, 'prenotato', 1)"); +$stmt->bind_param('iiiss', $bookId, $copyId, $otherUserId, $heldFrom, $heldTo); +$stmt->execute(); +$stmt->close(); +$ncipDue = $ncipPlainDue; +$ncipFailure = null; +$ncipLoan2 = $createLoanAtomic->invokeArgs($ncip, [$bookId, $userId, &$ncipDue, 0, &$ncipFailure]); +$check($ncipLoan2 === null && in_array($ncipFailure, ['no_capacity', 'no_copy'], true), 'NCIP: a commitment from the shifted day refuses the checkout (' . var_export($ncipFailure, true) . ') rather than storing a closed due date'); +$check($ncipDue === $ncipPlainDue, 'NCIP: a refused checkout leaves the handed-in date alone'); +$check((int) $db->query("SELECT COUNT(*) FROM prestiti WHERE libro_id = {$bookId} AND utente_id = {$userId}")->fetch_row()[0] === 0, 'NCIP: nothing was stored for the refused checkout'); + +// A second copy with no branch, committed from the same day: it comes second +// in the preference order (same next commitment, later inventory number), and +// its due date needs no shift. The first copy's longer window does not fit, +// so the checkout falls through to it instead of being refused. +$inv3 = "ZZCAL-{$run}-3"; +$stmt = $db->prepare("INSERT INTO copie (libro_id, numero_inventario, stato, sede_id) VALUES (?, ?, 'disponibile', NULL)"); +$stmt->bind_param('is', $bookId, $inv3); +$stmt->execute(); +$copy3Id = (int) $db->insert_id; +$stmt->close(); +$db->query("UPDATE libri SET copie_totali = 2, copie_disponibili = 2 WHERE id = {$bookId}"); +$stmt = $db->prepare("INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, attivo) VALUES (?, ?, ?, ?, ?, 'prenotato', 1)"); +$stmt->bind_param('iiiss', $bookId, $copy3Id, $otherUserId, $heldFrom, $heldTo); +$stmt->execute(); +$stmt->close(); +$ncipDue = $ncipPlainDue; +$ncipFailure = null; +$ncipLoan3 = $createLoanAtomic->invokeArgs($ncip, [$bookId, $userId, &$ncipDue, 0, &$ncipFailure]); +$check(is_int($ncipLoan3) && $ncipLoan3 > 0, 'NCIP: when the preferred copy cannot take its shifted window, the next copy is used (' . var_export($ncipFailure, true) . ')'); +$check(is_int($ncipLoan3) && (int) $db->query("SELECT copia_id FROM prestiti WHERE id = {$ncipLoan3}")->fetch_row()[0] === $copy3Id, 'NCIP: the loan is on the copy whose branch is open on the due date'); +$check($ncipDue === $ncipPlainDue, 'NCIP: and keeps the unshifted due date'); +$clearClosures(); + +// ── The admin page: input validation and the whole-year guard ──────────── +$clearClosures(); +$_SESSION = ['user' => ['id' => 1, 'tipo_utente' => 'admin', 'stato' => 'attivo']]; +$controller = new \App\Controllers\Admin\LibraryCalendarController(); +$post = static function (array $body): \Psr\Http\Message\ServerRequestInterface { + return (new \Slim\Psr7\Factory\ServerRequestFactory())->createServerRequest('POST', '/admin/calendar')->withParsedBody($body); +}; +$flash = static function (): array { + $out = [$_SESSION['success_message'] ?? null, $_SESSION['error_message'] ?? null]; + unset($_SESSION['success_message'], $_SESSION['error_message']); + return $out; +}; +$weekdaysNow = static fn (): string => (string) ($db->query("SELECT setting_value FROM system_settings WHERE category = 'loans' AND setting_key = 'closed_weekdays'")->fetch_row()[0] ?? ''); +$taggedRows = static fn (): int => (int) $db->query("SELECT COUNT(*) FROM calendario_chiusure WHERE descrizione = '{$tag}'")->fetch_row()[0]; +$setWeekdays('0'); +$controller->saveWeekdays($post(['closed_weekdays' => ['1', 'x']]), new \Slim\Psr7\Response(), $db); +[$ok, $err] = $flash(); +$check($err !== null && $ok === null && $weekdaysNow() === '0', 'weekdays: one malformed entry refuses the whole save (Sunday stays closed)'); +$controller->saveWeekdays($post(['closed_weekdays' => '1']), new \Slim\Psr7\Response(), $db); +[$ok, $err] = $flash(); +$check($err !== null && $weekdaysNow() === '0', 'weekdays: a scalar instead of a list is refused'); +$controller->saveWeekdays($post([]), new \Slim\Psr7\Response(), $db); +[$ok, $err] = $flash(); +$check($ok !== null && $weekdaysNow() === '', 'weekdays: no field reopens the week on purpose'); +$setWeekdays(''); + +$controller->store($post(['data_inizio' => $shift($today, 50), 'descrizione' => $tag, 'sede_id' => [(string) $sedeId]]), new \Slim\Psr7\Response(), $db); +[$ok, $err] = $flash(); +$check($err === __('Sede non trovata.') && $taggedRows() === 0, 'closure: an array sede_id is refused, not stored for every branch'); +$controller->store($post(['data_inizio' => $shift($today, 50), 'descrizione' => $tag, 'sede_id' => '999999999']), new \Slim\Psr7\Response(), $db); +[$ok, $err] = $flash(); +$check($err === __('Sede non trovata.') && $taggedRows() === 0, 'closure: an unknown sede_id is refused'); +$controller->store($post(['data_inizio' => $shift($today, 50), 'descrizione' => $tag, 'sede_id' => '']), new \Slim\Psr7\Response(), $db); +[$ok, $err] = $flash(); +$check($ok !== null && $taggedRows() === 1, 'closure: an empty sede_id is every branch'); +$clearClosures(); + +$controller->store($post(['data_inizio' => '2026-01-01', 'data_fine' => '2026-06-30', 'ricorrente_annuale' => '1', 'descrizione' => $tag]), new \Slim\Psr7\Response(), $db); +[$ok, $err] = $flash(); +$check($ok !== null && $taggedRows() === 1, 'yearly: the first half of the year is stored'); +$controller->store($post(['data_inizio' => '2026-07-01', 'data_fine' => '2026-12-31', 'ricorrente_annuale' => '1', 'descrizione' => $tag]), new \Slim\Psr7\Response(), $db); +[$ok, $err] = $flash(); +$check($err !== null && $taggedRows() === 1, 'yearly: a second half that would close every day is refused and rolled back'); +$controller->store($post(['data_inizio' => '2026-07-01', 'data_fine' => '2026-12-30', 'ricorrente_annuale' => '1', 'descrizione' => $tag]), new \Slim\Psr7\Response(), $db); +[$ok, $err] = $flash(); +$check($ok !== null && $taggedRows() === 2, 'yearly: leaving Dec 31 open is accepted'); +$check(LibraryCalendar::forDb($db)->nextOpenDay('2027-03-10') === '2027-12-31', 'and a due date reaches that open day'); +$clearClosures(); +$controller->store($post(['data_inizio' => '2026-01-01', 'data_fine' => '2026-06-30', 'ricorrente_annuale' => '1', 'descrizione' => $tag, 'sede_id' => (string) $sedeId]), new \Slim\Psr7\Response(), $db); +$flash(); +$controller->store($post(['data_inizio' => '2026-07-01', 'data_fine' => '2026-12-31', 'ricorrente_annuale' => '1', 'descrizione' => $tag]), new \Slim\Psr7\Response(), $db); +[$ok, $err] = $flash(); +$check($err !== null && $taggedRows() === 1, "yearly: a library-wide half that closes the year of one branch is refused"); +$clearClosures(); + +// The weekly closed days count in the whole-year guard: a yearly closure +// leaving only Jan 1 open closes every year whose Jan 1 is a Sunday. +$setWeekdays('0'); +$controller->store($post(['data_inizio' => '2026-01-02', 'data_fine' => '2026-12-31', 'ricorrente_annuale' => '1', 'descrizione' => $tag]), new \Slim\Psr7\Response(), $db); +[$ok, $err] = $flash(); +$check($err !== null && $taggedRows() === 0, 'yearly: with Sundays closed, a closure leaving only Jan 1 open is refused'); +$setWeekdays(''); +$controller->store($post(['data_inizio' => '2026-01-02', 'data_fine' => '2026-12-31', 'ricorrente_annuale' => '1', 'descrizione' => $tag]), new \Slim\Psr7\Response(), $db); +[$ok, $err] = $flash(); +$check($ok !== null && $taggedRows() === 1, 'yearly: without closed weekdays the same closure is accepted'); +$controller->saveWeekdays($post(['closed_weekdays' => ['0']]), new \Slim\Psr7\Response(), $db); +[$ok, $err] = $flash(); +$check($err !== null && $ok === null && $weekdaysNow() === '', 'weekdays: closing Sundays over a yearly closure that leaves only Jan 1 open is refused and rolled back'); +$controller->saveWeekdays($post([]), new \Slim\Psr7\Response(), $db); +$flash(); +$clearClosures(); + +// The holidays preset meets the same guard: the stored yearly closures leave +// only Aug 15 open, and Ferragosto would close it. +$maxBefore = (int) $db->query('SELECT COALESCE(MAX(id), 0) FROM calendario_chiusure')->fetch_row()[0]; +$controller->store($post(['data_inizio' => '2026-08-16', 'data_fine' => '2027-08-14', 'ricorrente_annuale' => '1', 'descrizione' => $tag]), new \Slim\Psr7\Response(), $db); +[$ok, $err] = $flash(); +$check($ok !== null && $taggedRows() === 1, 'yearly: a closure leaving only Aug 15 open is stored'); +$controller->importPreset($post(['year' => substr($today, 0, 4)]), new \Slim\Psr7\Response(), $db); +[$ok, $err] = $flash(); +// Only rows the preset itself would write (the holiday names, library-wide): +// a closure someone else stores meanwhile is neither counted nor deleted. +$holidayNames = implode(',', array_map(static fn (array $h): string => "'" . $db->real_escape_string($h['description']) . "'", LibraryCalendar::italianHolidays((int) substr($today, 0, 4)))); +$presetWhere = "id > {$maxBefore} AND sede_id IS NULL AND descrizione IN ({$holidayNames})"; +$presetRows = (int) $db->query("SELECT COUNT(*) FROM calendario_chiusure WHERE {$presetWhere}")->fetch_row()[0]; +$check($err !== null && $ok === null && $presetRows === 0, 'preset: an import that would close the whole year is refused and rolled back (' . $presetRows . ' rows kept)'); +$db->query("DELETE FROM calendario_chiusure WHERE {$presetWhere}"); +$clearClosures(); + +$icsFile = static function (string $body): \Slim\Psr7\UploadedFile { + $path = tempnam(sys_get_temp_dir(), 'calics'); + file_put_contents($path, $body); + return new \Slim\Psr7\UploadedFile($path, 'cal.ics', 'text/calendar', strlen($body), UPLOAD_ERR_OK); +}; +$halves = "BEGIN:VCALENDAR\nBEGIN:VEVENT\nDTSTART;VALUE=DATE:20260101\nDTEND;VALUE=DATE:20260701\nRRULE:FREQ=YEARLY\nSUMMARY:{$tag}\nEND:VEVENT\n" + . "BEGIN:VEVENT\nDTSTART;VALUE=DATE:20260701\nDTEND;VALUE=DATE:20270101\nRRULE:FREQ=YEARLY\nSUMMARY:{$tag}\nEND:VEVENT\nEND:VCALENDAR\n"; +$controller->importIcs($post(['sede_id' => '999999999'])->withUploadedFiles(['ics_file' => $icsFile($halves)]), new \Slim\Psr7\Response(), $db); +[$ok, $err] = $flash(); +$check($err === __('Sede non trovata.') && $taggedRows() === 0, 'ICS: an unknown sede_id refuses the import'); +$controller->importIcs($post(['sede_id' => ''])->withUploadedFiles(['ics_file' => $icsFile($halves)]), new \Slim\Psr7\Response(), $db); +[$ok, $err] = $flash(); +$check($ok !== null && $taggedRows() === 1, 'ICS: the yearly event that would close the whole year is discarded, the other kept'); +$check(is_string($ok) && str_contains($ok, __('%d scartate perché con le altre chiusure annuali lascerebbero la biblioteca chiusa tutto l\'anno.', 1)) + && !str_contains($ok, __('File ICS importato: %1$d chiusure aggiunte, %2$d già presenti, %3$d scartate perché più lunghe del limite (un anno se annuali, dieci anni altrimenti).', 1, 0, 1)), 'ICS: a whole-year discard is reported as such, not as a span over the limit'); +$clearClosures(); + +// A whole year of one-day yearly events: only the last one (Dec 31, which +// closes every common year) is discarded, and the whole-year guard reads the +// yearly rows once for the import instead of once per event. +$countingDb = new class ($env['DB_HOST'] ?? 'localhost', $env['DB_USER'], $env['DB_PASS'] ?? '', $env['DB_NAME'], (int) ($env['DB_PORT'] ?? 3306), getenv('E2E_DB_SOCKET') ?: ($env['DB_SOCKET'] ?? null)) extends mysqli { + public int $yearlyReads = 0; + public function query(string $query, int $result_mode = MYSQLI_STORE_RESULT): mysqli_result|bool + { + if (str_contains($query, 'WHERE ricorrente_annuale = 1')) { + $this->yearlyReads++; + } + return parent::query($query, $result_mode); + } +}; +$countingDb->set_charset('utf8mb4'); +$wholeYear = "BEGIN:VCALENDAR\n"; +for ($d = '2026-01-01'; $d <= '2026-12-31'; $d = $shift($d, 1)) { + $wholeYear .= "BEGIN:VEVENT\nDTSTART;VALUE=DATE:" . str_replace('-', '', $d) . "\nRRULE:FREQ=YEARLY\nSUMMARY:{$tag}\nEND:VEVENT\n"; +} +$wholeYear .= "END:VCALENDAR\n"; +$controller->importIcs($post(['sede_id' => ''])->withUploadedFiles(['ics_file' => $icsFile($wholeYear)]), new \Slim\Psr7\Response(), $countingDb); +[$ok, $err] = $flash(); +$stillOpen = (int) $db->query("SELECT COUNT(*) FROM calendario_chiusure WHERE descrizione = '{$tag}' AND DATE_FORMAT(data_inizio, '%m-%d') = '12-31'")->fetch_row()[0]; +$check($ok !== null && $taggedRows() === 364 && $stillOpen === 0, 'ICS: 365 one-day yearly events keep 364, the one that closes the whole year is discarded (' . $taggedRows() . ' kept)'); +$check($countingDb->yearlyReads === 1, "ICS: the whole-year guard reads the yearly rows once per import ({$countingDb->yearlyReads} reads)"); +$countingDb->close(); +$clearClosures(); +// Carried from event to event per branch too: with a branch closed Jan 1 - +// Jun 30 every year, a library-wide import keeps Jul 1 - Dec 30 and drops the +// Dec 31 that follows it, which would close that branch's whole year. +$addClosure('2026-01-01', '2026-06-30', $sedeId, 1); +$branchYear = "BEGIN:VCALENDAR\nBEGIN:VEVENT\nDTSTART;VALUE=DATE:20260701\nDTEND;VALUE=DATE:20261231\nRRULE:FREQ=YEARLY\nSUMMARY:{$tag}\nEND:VEVENT\n" + . "BEGIN:VEVENT\nDTSTART;VALUE=DATE:20261231\nRRULE:FREQ=YEARLY\nSUMMARY:{$tag}\nEND:VEVENT\nEND:VCALENDAR\n"; +$controller->importIcs($post(['sede_id' => ''])->withUploadedFiles(['ics_file' => $icsFile($branchYear)]), new \Slim\Psr7\Response(), $db); +[$ok, $err] = $flash(); +$keptBranchYear = $db->query("SELECT DATE_FORMAT(data_inizio, '%m-%d') FROM calendario_chiusure WHERE descrizione = '{$tag}' AND sede_id IS NULL")->fetch_all(); +$check($ok !== null && array_column($keptBranchYear, 0) === ['07-01'], 'ICS: a library-wide event that would close a branch\'s last open day is discarded after an earlier event of the same import moved it'); +$clearClosures(); + +// update() re-checks the closure under the calendar lock: another operator +// holds the lock, deletes the closure meanwhile, and the update that was +// waiting for the lock reports "not found" instead of "updated". +$addClosure($shift($today, 60), $shift($today, 60)); +$raceId = (int) $db->query("SELECT MAX(id) FROM calendario_chiusure WHERE descrizione = '{$tag}'")->fetch_row()[0]; +$holderCode = <<<'PHP' + [$host, $user, $pass, $name, $port, $socket, $lock, $id] = json_decode($argv[1], true); + mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + $db = new mysqli($host, $user, $pass, $name, $port, $socket ?: null); + $stmt = $db->prepare('SELECT GET_LOCK(?, 5)'); + $stmt->bind_param('s', $lock); + $stmt->execute(); + echo ((int) $stmt->get_result()->fetch_row()[0] === 1 ? 'locked' : 'busy'), "\n"; + $stmt->close(); + usleep(1500000); + $db->query('DELETE FROM calendario_chiusure WHERE id = ' . (int) $id); + $db->query("SELECT RELEASE_LOCK('" . $db->real_escape_string($lock) . "')"); + PHP; +$holderArgs = json_encode([$env['DB_HOST'] ?? 'localhost', $env['DB_USER'], $env['DB_PASS'] ?? '', $env['DB_NAME'], (int) ($env['DB_PORT'] ?? 3306), getenv('E2E_DB_SOCKET') ?: ($env['DB_SOCKET'] ?? ''), 'pinakes-calendar:' . md5($env['DB_NAME']), $raceId]); +$holder = proc_open([PHP_BINARY, '-r', $holderCode, '--', $holderArgs], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $holderPipes); +$holderState = is_resource($holder) ? trim((string) fgets($holderPipes[1])) : 'no process'; +$check($holderState === 'locked', "race: a second connection holds the calendar lock ({$holderState})"); +$controller->update($post(['data_inizio' => $shift($today, 61), 'descrizione' => $tag]), new \Slim\Psr7\Response(), $db, $raceId); +[$ok, $err] = $flash(); +if (is_resource($holder)) { + fclose($holderPipes[1]); + fclose($holderPipes[2]); + proc_close($holder); +} +$check($ok === null && $err === __('Chiusura non trovata.'), 'race: an update whose closure was deleted while it waited for the lock reports "not found", not "updated"'); +$clearClosures(); +$_SESSION = []; + +echo ($failed === 0 ? 'SUCCESS' : 'FAILURE') . " {$passed} passed, {$failed} failed\n"; +exit($failed === 0 ? 0 : 1); diff --git a/tests/library-calendar.unit.php b/tests/library-calendar.unit.php new file mode 100644 index 000000000..728e8b508 --- /dev/null +++ b/tests/library-calendar.unit.php @@ -0,0 +1,344 @@ +hasRules(), false, 'no rules configured'); +$eq($empty->isOpen('2026-12-25'), true, 'without rules Christmas is open'); +$eq($empty->nextOpenDay('2026-11-01'), '2026-11-01', 'without rules nextOpenDay is identity'); +$eq($empty->addOpenDays('2026-10-30', 3), '2026-11-02', 'without rules addOpenDays is +N calendar days'); +$eq($empty->closedDatesBetween('2026-01-01', '2026-12-31'), [], 'without rules no closed dates'); +$eq($empty->openDaysBetween('2026-10-01', '2026-10-31'), 30, 'without rules openDaysBetween is the day difference'); + +// ── Weekly closed days ──────────────────────────────────────────────────── +$sundays = new LibraryCalendar([0]); +$eq($sundays->isOpen('2026-11-01'), false, 'Sunday 2026-11-01 closed with closed_weekdays=0'); +$eq($sundays->isOpen('2026-11-02'), true, 'Monday open'); +$eq($sundays->nextOpenDay('2026-11-01'), '2026-11-02', 'Sunday shifts to Monday'); +$eq($sundays->shiftForward('2026-10-31'), '2026-10-31', 'an open Saturday does not move'); +$eq($sundays->previousOpenDay('2026-11-01'), '2026-10-31', 'previousOpenDay of Sunday is Saturday'); +$sunMon = new LibraryCalendar([0, 1]); +$eq($sunMon->nextOpenDay('2026-11-01'), '2026-11-03', 'Sunday+Monday closed: Sunday shifts to Tuesday'); +$eq($sunMon->closedWeekdays(), [0, 1], 'closedWeekdays returns the sorted list'); +$eq(LibraryCalendar::parseWeekdays('1, 0,7,x,1,6'), [0, 1, 6], 'parseWeekdays drops invalid and duplicate values'); +$all = new LibraryCalendar([0, 1, 2, 3, 4, 5, 6]); +$eq($all->hasRules(), false, 'seven closed weekdays are ignored (no day to move to)'); + +// ── Closure ranges ──────────────────────────────────────────────────────── +$xmas = new LibraryCalendar([], [['data_inizio' => '2026-12-24', 'data_fine' => '2026-12-26', 'ricorrente_annuale' => 0, 'sede_id' => null]]); +$eq($xmas->isOpen('2026-12-23'), true, 'day before the range open'); +$eq($xmas->isOpen('2026-12-24') || $xmas->isOpen('2026-12-25') || $xmas->isOpen('2026-12-26'), false, 'every day of the range closed'); +$eq($xmas->nextOpenDay('2026-12-25'), '2026-12-27', 'Dec 25 inside 24-26 shifts to Dec 27'); +$eq($xmas->isOpen('2027-12-25'), true, 'a non-recurring closure does not repeat next year'); +$eq($xmas->closedDatesBetween('2026-12-20', '2026-12-31'), ['2026-12-24', '2026-12-25', '2026-12-26'], 'closedDatesBetween lists the range'); +$eq($xmas->openDaysBetween('2026-12-20', '2026-12-31'), 8, 'openDaysBetween skips the 3 closed days (11 - 3)'); +$swapped = new LibraryCalendar([], [['start' => '2026-08-20', 'end' => '2026-08-10']]); +$eq($swapped->isOpen('2026-08-15'), false, 'an inverted range is normalised'); + +// ── Yearly recurrence ───────────────────────────────────────────────────── +$yearly = new LibraryCalendar([], [ + ['data_inizio' => '2020-08-10', 'data_fine' => '2020-08-20', 'ricorrente_annuale' => 1], + ['data_inizio' => '2025-12-31', 'data_fine' => '2026-01-02', 'ricorrente_annuale' => '1'], +]); +foreach ([2020, 2026, 2031] as $y) { + $eq($yearly->isOpen("{$y}-08-15"), false, "yearly Aug 10-20 closes Aug 15 in {$y}"); + $eq($yearly->isOpen("{$y}-08-21"), true, "yearly Aug 10-20 leaves Aug 21 open in {$y}"); +} +$eq($yearly->isOpen('2019-08-15'), false, 'a yearly closure applies to earlier years too'); +$eq($yearly->isOpen('2030-12-31') || $yearly->isOpen('2031-01-01') || $yearly->isOpen('2031-01-02'), false, 'a yearly span crossing New Year wraps'); +$eq($yearly->isOpen('2031-01-03'), true, 'the day after a wrapped yearly span is open'); +$eq($yearly->nextOpenDay('2030-12-31'), '2031-01-03', 'shift across a wrapped yearly span'); +$leap = new LibraryCalendar([], [['start' => '2024-02-29', 'end' => '2024-02-29', 'yearly' => true]]); +$eq($leap->isOpen('2028-02-29'), false, 'a yearly Feb 29 closes Feb 29 in leap years'); +$eq($leap->isOpen('2027-03-01'), true, 'and does not close Mar 1 in other years'); + +// A yearly span of a year or more cannot repeat: it is applied once, as the +// interval stored, and never closes a day outside it. +$eq(LibraryCalendar::spanDays('2026-01-01', '2026-01-01'), 0, 'spanDays: same day is 0'); +$eq(LibraryCalendar::spanDays('2026-01-01', '2026-12-31'), 364, 'spanDays: a year less a day is 364'); +$eq(LibraryCalendar::spanDays('2026-01-01', 'nope'), -1, 'spanDays: not a date is -1'); +$eq(LibraryCalendar::MAX_YEARLY_SPAN_DAYS, 363, 'the yearly limit is a year less two days (a 364-day span leaves only Feb 29 open)'); +$decade = new LibraryCalendar([], [['start' => '2026-01-01', 'end' => '2035-12-31', 'yearly' => true]]); +$eq($decade->isOpen('2030-06-15'), false, 'a ten-year yearly row closes a day inside its interval'); +$eq($decade->isOpen('2040-06-15'), true, 'and leaves an arbitrary later date open (it does not close every day)'); +$eq($decade->isOpen('2025-06-15'), true, 'nor an earlier one'); +$twoYears = new LibraryCalendar([], [['start' => '2020-03-01', 'end' => '2022-02-28', 'yearly' => true]]); +$eq($twoYears->isOpen('2021-07-01'), false, 'a two-year yearly row closes its own days'); +$eq($twoYears->isOpen('2026-07-01'), true, 'and not the same day years later'); +$longest = new LibraryCalendar([], [['start' => '2026-06-01', 'end' => '2027-05-30', 'yearly' => true]]); +$eq($longest->isOpen('2030-12-25'), false, 'a 363-day yearly span (Jun 1 - May 30) still recurs and wraps'); +$eq($longest->isOpen('2030-05-30'), false, 'its last month-day is closed'); +$eq($longest->isOpen('2030-05-31'), true, 'and May 31 stays open every year'); +$eq($longest->nextOpenDay('2030-12-25'), '2031-05-31', 'so a due date inside it reaches an open day'); +$eq($longest->hasRules(), true, 'it is a rule'); +$fullYear = new LibraryCalendar([], [['start' => '2026-06-01', 'end' => '2027-05-31', 'yearly' => true]]); +$eq($fullYear->isOpen('2030-12-25'), true, 'a 364-day yearly span (Jun 1 - May 31) would close every day: it does not recur'); +$eq($fullYear->isOpen('2026-12-25'), false, 'but closes its own interval'); +$leapOnly = new LibraryCalendar([], [['start' => '2024-03-01', 'end' => '2025-02-28', 'yearly' => true]]); +$eq($leapOnly->isOpen('2026-07-01'), true, 'a Mar 1 - Feb 28 yearly span (only Feb 29 left) does not recur either'); + +// ── A one-off closure longer than the scan bound is stepped over whole ─── +// A once closure may span ten years (MAX_ONCE_SPAN_DAYS) while a scan walks +// at most ~3 years day by day: the scans jump to the end of the closure. +$fiveYears = new LibraryCalendar([], [['start' => '2026-01-01', 'end' => '2030-12-31']]); +$eq(LibraryCalendar::spanDays('2026-01-01', '2030-12-31') <= LibraryCalendar::MAX_ONCE_SPAN_DAYS, true, 'a five-year once closure is admissible'); +$eq($fiveYears->nextOpenDay('2026-06-15'), '2031-01-01', 'nextOpenDay inside a five-year closure is the day after it ends'); +$eq($fiveYears->nextOpenDay('2026-01-01'), '2031-01-01', 'from its first day too'); +$eq($fiveYears->previousOpenDay('2030-06-15'), '2025-12-31', 'previousOpenDay inside it is the day before it starts'); +$eq($fiveYears->previousOpenDay('2030-12-31'), '2025-12-31', 'from its last day too'); +$eq($fiveYears->addOpenDays('2026-06-15', 3), '2031-01-03', 'addOpenDays from inside it counts open days after it'); +$eq($fiveYears->addOpenDays('2025-12-30', 3), '2031-01-02', 'addOpenDays across it: Dec 31, then Jan 1 and 2 of 2031'); +$eq($fiveYears->closedDatesBetween('2027-01-01', '2027-01-31'), array_map(static fn (int $d): string => sprintf('2027-01-%02d', $d), range(1, 31)), 'closedDatesBetween inside it lists every day of the month asked'); +$cut = $fiveYears->closedDatesBetween('2026-01-01', '2030-12-31'); +$eq($cut[0] === '2026-01-01' && count($cut) > 1000 && count($cut) < 1826 && LibraryCalendar::shift($cut[0], count($cut) - 1) === end($cut), true, 'closedDatesBetween over the whole closure is cut at the scan bound, consecutive from the start'); +$eq($fiveYears->openDaysBetween('2025-12-31', '2031-01-02'), 2, 'openDaysBetween across it counts only the two days after it'); +// Around the jump: closed weekdays and a branch closure right after it still apply. +$afterJump = new LibraryCalendar([3], [['start' => '2026-01-01', 'end' => '2030-12-31'], ['start' => '2031-01-02', 'end' => '2031-01-02', 'sede_id' => 2]]); +$eq($afterJump->nextOpenDay('2028-02-29'), '2031-01-02', '2031-01-01 is a Wednesday (closed weekday): the jump lands on the next open day'); +$eq($afterJump->nextOpenDay('2028-02-29', 2), '2031-01-03', 'and branch 2, closed on Jan 2 as well, opens on Jan 3'); +$eq($afterJump->previousOpenDay('2028-02-29'), '2025-12-30', '2025-12-31 is a Wednesday: previousOpenDay steps back once more'); +$twoCut = new LibraryCalendar([], [['start' => '2026-01-01', 'end' => '2027-12-31'], ['start' => '2027-06-01', 'end' => '2030-12-31']]); +$eq($twoCut->nextOpenDay('2026-06-15'), '2031-01-01', 'overlapping once closures are stepped over one after the other'); +$eq($twoCut->previousOpenDay('2030-06-15'), '2025-12-31', 'backwards too'); +$branchOnly = new LibraryCalendar([], [['start' => '2026-01-01', 'end' => '2030-12-31', 'sede_id' => 5]]); +$eq($branchOnly->nextOpenDay('2026-06-15'), '2026-06-15', 'a branch-only closure does not move the library as a whole'); +$eq($branchOnly->nextOpenDay('2026-06-15', 5), '2031-01-01', 'but moves its branch past it'); +// Every day closed (two yearly closures covering the year): the scans still +// end after the bound and fall back as before, quickly. +$allYear = new LibraryCalendar([], [['start' => '2026-01-01', 'end' => '2026-06-30', 'yearly' => true], ['start' => '2026-07-01', 'end' => '2026-12-31', 'yearly' => true]]); +$started = microtime(true); +$eq($allYear->nextOpenDay('2026-06-15'), '2026-06-15', 'all days closed: nextOpenDay returns its input after the bound'); +$eq($allYear->previousOpenDay('2026-06-15'), '2026-06-15', 'all days closed: previousOpenDay returns its input'); +$eq($allYear->addOpenDays('2026-06-15', 3), '2026-06-18', 'all days closed: addOpenDays falls back to +N calendar days'); +$eq(microtime(true) - $started < 2.0, true, 'the all-closed fallbacks finish quickly'); + +// ── Branch-specific vs all branches ─────────────────────────────────────── +$branches = new LibraryCalendar([], [ + ['start' => '2026-11-10', 'end' => '2026-11-10', 'sede_id' => 2], + ['start' => '2026-11-11', 'end' => '2026-11-11', 'sede_id' => null], +]); +$eq($branches->isOpen('2026-11-10', 2), false, 'branch 2 closed on its own closure'); +$eq($branches->isOpen('2026-11-10', 3), true, 'branch 3 open on branch 2 closure'); +$eq($branches->isOpen('2026-11-10'), true, 'whole library open on a branch-only closure'); +$eq($branches->isOpen('2026-11-11', 2) || $branches->isOpen('2026-11-11', 3) || $branches->isOpen('2026-11-11'), false, 'an all-branch closure closes every branch'); +$eq($branches->nextOpenDay('2026-11-10', 2), '2026-11-12', 'branch 2 shifts over both closures'); +$eq($branches->closedDatesBetween('2026-11-09', '2026-11-12', 2), ['2026-11-10', '2026-11-11'], 'closedDatesBetween honours the branch'); + +// ── Easter over several years ───────────────────────────────────────────── +$known = ['2000' => '2000-04-23', '2008' => '2008-03-23', '2019' => '2019-04-21', '2024' => '2024-03-31', '2025' => '2025-04-20', '2026' => '2026-04-05', '2027' => '2027-03-28', '2038' => '2038-04-25', '2285' => '2285-03-22']; +foreach ($known as $year => $date) { + $eq(LibraryCalendar::easterSunday((int) $year), $date, "Easter {$year}"); +} +$h2026 = LibraryCalendar::italianHolidays(2026); +$dates2026 = array_column($h2026, 'start'); +$eq(count($h2026), 12, 'Italian preset has 12 holidays'); +$eq(in_array('2026-04-06', $dates2026, true), true, 'Easter Monday 2026 is Apr 6'); +$eq(in_array('2026-06-02', $dates2026, true), true, 'Festa della Repubblica included'); +$easterRows = array_values(array_filter($h2026, static fn (array $r): bool => !$r['yearly'])); +$eq(count($easterRows), 2, 'only Easter and Easter Monday are dated for the year'); +$eq($dates2026, array_values(array_unique($dates2026)), 'preset dates are distinct'); +$sorted = $dates2026; +sort($sorted); +$eq($dates2026, $sorted, 'preset is sorted by date'); + +// ── DST change (Europe/Rome: 2026-03-29 and 2026-10-25) ─────────────────── +$prevTz = date_default_timezone_get(); +foreach (['Europe/Rome', 'America/New_York', 'Pacific/Auckland', 'UTC'] as $tz) { + date_default_timezone_set($tz); + $eq(LibraryCalendar::shift('2026-03-28', 1), '2026-03-29', "DST spring: +1 day stays a day ({$tz})"); + $eq(LibraryCalendar::shift('2026-03-29', 1), '2026-03-30', "DST spring day itself ({$tz})"); + $eq(LibraryCalendar::shift('2026-10-24', 2), '2026-10-26', "DST autumn: +2 days ({$tz})"); + $eq($sundays->isOpen('2026-10-25'), false, "the DST Sunday is still a Sunday ({$tz})"); + $eq($sundays->nextOpenDay('2026-03-29'), '2026-03-30', "DST Sunday shifts to Monday ({$tz})"); + $eq($sundays->addOpenDays('2026-10-23', 3), '2026-10-27', "3 open days from Fri across DST Sunday ({$tz})"); +} +date_default_timezone_set($prevTz); + +// ── addOpenDays (pickup deadline arithmetic) ────────────────────────────── +$eq($sundays->addOpenDays('2026-10-30', 3), '2026-11-03', 'Fri + 3 open days with Sundays closed = Tue'); +$eq($sundays->addOpenDays('2026-10-30', 0), '2026-10-30', '0 open days returns the start'); +$bridge = new LibraryCalendar([0], [['start' => '2026-12-24', 'end' => '2026-12-26']]); +$eq($bridge->addOpenDays('2026-12-23', 3), '2026-12-30', 'pickup over Christmas and a Sunday counts only open days'); + +// ── Integration-style: 14-day loans ─────────────────────────────────────── +// 2026-10-18 + 14 = 2026-11-01 (Sunday) → Monday. +$eq($sundays->nextOpenDay(LibraryCalendar::shift('2026-10-18', 14)), '2026-11-02', '14-day loan ending Sunday moves to Monday'); +// 2026-12-11 + 14 = 2026-12-25, closure 24-26 → 27. +$eq($xmas->nextOpenDay(LibraryCalendar::shift('2026-12-11', 14)), '2026-12-27', '14-day loan ending Dec 25 with 24-26 closed moves to Dec 27'); + +// ── ICS ─────────────────────────────────────────────────────────────────── +$ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:a\r\nDTSTART;VALUE=DATE:20261224\r\nDTEND;VALUE=DATE:20261227\r\nSUMMARY:Chiusura\r\n natalizia\\, sede\r\nEND:VEVENT\r\n" + . "BEGIN:VEVENT\r\nDTSTART;VALUE=DATE:20260815\r\nRRULE:FREQ=YEARLY;BYMONTH=8\r\nSUMMARY:Ferragosto\r\nEND:VEVENT\r\n" + . "BEGIN:VEVENT\r\nDTSTART:20261102T090000Z\r\nDTEND:20261102T120000Z\r\nSUMMARY:Inventario\r\nEND:VEVENT\r\n" + . "BEGIN:VEVENT\r\nSUMMARY:no start\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; +$parsed = LibraryCalendar::parseIcs($ics); +$eq(count($parsed), 3, 'ICS: three events with a DTSTART'); +$eq($parsed[0], ['start' => '2026-12-24', 'end' => '2026-12-26', 'description' => 'Chiusura natalizia, sede', 'yearly' => false], 'ICS: all-day DTEND is exclusive, folded summary unescaped'); +$eq($parsed[1]['yearly'] && $parsed[1]['start'] === '2026-08-15' && $parsed[1]['end'] === '2026-08-15', true, 'ICS: RRULE FREQ=YEARLY recurs, no DTEND = one day'); +$eq([$parsed[2]['start'], $parsed[2]['end']], ['2026-11-02', '2026-11-02'], 'ICS: a timed event closes its day'); +$eq(LibraryCalendar::parseIcs('not a calendar'), [], 'ICS: garbage yields nothing'); + +// A UTC DATE-TIME is read in the app timezone before its day is taken; a +// floating DATE-TIME and a DATE are taken as written. +$utcIcs = "BEGIN:VCALENDAR\nBEGIN:VEVENT\nDTSTART:20260410T230000Z\nDTEND:20260411T000000Z\nSUMMARY:utc\nEND:VEVENT\n" + . "BEGIN:VEVENT\nDTSTART:20260410T230000\nSUMMARY:floating\nEND:VEVENT\n" + . "BEGIN:VEVENT\nDTSTART;VALUE=DATE:20260410\nSUMMARY:date\nEND:VEVENT\n" + . "BEGIN:VEVENT\nDTSTART:20260411T020000Z\nSUMMARY:early\nEND:VEVENT\nEND:VCALENDAR\n"; +$rome = LibraryCalendar::parseIcs($utcIcs, 1000, 'Europe/Rome'); +$eq([$rome[0]['start'], $rome[0]['end']], ['2026-04-11', '2026-04-11'], 'ICS: 20260410T230000Z is Apr 11 in Europe/Rome'); +$eq([$rome[1]['start'], $rome[2]['start']], ['2026-04-10', '2026-04-10'], 'ICS: a floating DATE-TIME and a DATE keep their day'); +$eq($rome[3]['start'], '2026-04-11', 'ICS: 20260411T020000Z stays Apr 11 in Europe/Rome'); +$newYork = LibraryCalendar::parseIcs($utcIcs, 1000, 'America/New_York'); +$eq($newYork[0]['start'], '2026-04-10', 'ICS: 20260410T230000Z is still Apr 10 in New York'); +$eq($newYork[3]['start'], '2026-04-10', 'ICS: 20260411T020000Z is Apr 10 in New York'); +$eq(LibraryCalendar::parseIcs($utcIcs, 1000, 'UTC')[0]['start'], '2026-04-10', 'ICS: in UTC the day is as written'); + +// A ten-year yearly event is parsed as such; the import refuses it with the +// form's limits (E2E: closures-calendar-admin.spec.js, ICS 8). +$tenYears = LibraryCalendar::parseIcs("BEGIN:VCALENDAR\nBEGIN:VEVENT\nDTSTART;VALUE=DATE:20260101\nDTEND;VALUE=DATE:20360101\nRRULE:FREQ=YEARLY\nSUMMARY:decade\nEND:VEVENT\nEND:VCALENDAR\n"); +$eq([$tenYears[0]['start'], $tenYears[0]['end'], $tenYears[0]['yearly']], ['2026-01-01', '2035-12-31', true], 'ICS: a ten-year yearly event is parsed as stored'); +$eq(LibraryCalendar::spanDays($tenYears[0]['start'], $tenYears[0]['end']) > LibraryCalendar::MAX_YEARLY_SPAN_DAYS, true, 'ICS: and exceeds the yearly limit the import applies'); + +// DTEND is exclusive for timed events too: one ending at local midnight does +// not close the next day; a zero-length event at midnight keeps its day. +$midnight = LibraryCalendar::parseIcs("BEGIN:VCALENDAR\nBEGIN:VEVENT\nDTSTART:20261224T100000\nDTEND:20261225T000000\nSUMMARY:m\nEND:VEVENT\n" + . "BEGIN:VEVENT\nDTSTART:20261224T000000\nDTEND:20261224T000000\nSUMMARY:zero\nEND:VEVENT\n" + . "BEGIN:VEVENT\nDTSTART:20261224T090000Z\nDTEND:20261225T230000Z\nSUMMARY:utc midnight\nEND:VEVENT\n" + . "BEGIN:VEVENT\nDTSTART:20261224T100000\nDTEND:20261225T000100\nSUMMARY:past midnight\nEND:VEVENT\nEND:VCALENDAR\n", 1000, 'Europe/Rome'); +$eq([$midnight[0]['start'], $midnight[0]['end']], ['2026-12-24', '2026-12-24'], 'ICS: a timed DTEND at midnight is exclusive'); +$eq([$midnight[1]['start'], $midnight[1]['end']], ['2026-12-24', '2026-12-24'], 'ICS: a zero-length event at midnight keeps its day'); +$eq([$midnight[2]['start'], $midnight[2]['end']], ['2026-12-24', '2026-12-25'], 'ICS: a UTC DTEND that is local midnight (Dec 26 00:00 Rome) ends on Dec 25'); +$eq([$midnight[3]['start'], $midnight[3]['end']], ['2026-12-24', '2026-12-25'], 'ICS: a DTEND one minute past midnight closes that day'); + +// TZID: the time is read in that zone, then in the app timezone. +$tzid = LibraryCalendar::parseIcs("BEGIN:VCALENDAR\nBEGIN:VEVENT\nDTSTART;TZID=America/New_York:20260410T230000\nSUMMARY:ny\nEND:VEVENT\n" + . "BEGIN:VEVENT\nDTSTART;TZID=\"Europe/Rome\":20260410T230000\nSUMMARY:quoted\nEND:VEVENT\n" + . "BEGIN:VEVENT\nDTSTART;TZID=Not/AZone:20260410T230000\nSUMMARY:unknown\nEND:VEVENT\nEND:VCALENDAR\n", 1000, 'Europe/Rome'); +$eq($tzid[0]['start'], '2026-04-11', 'ICS: 23:00 in New York is Apr 11 in Europe/Rome'); +$eq($tzid[1]['start'], '2026-04-10', 'ICS: a quoted TZID equal to the app timezone keeps the day'); +$eq($tzid[2]['start'], '2026-04-10', 'ICS: an unknown TZID is read as floating'); + +// Only a plain yearly rule recurs; bounded, sparser or positional rules are +// imported as their first occurrence. +$rule = static fn (string $rrule): bool => LibraryCalendar::parseIcs("BEGIN:VCALENDAR\nBEGIN:VEVENT\nDTSTART;VALUE=DATE:20260601\nRRULE:{$rrule}\nEND:VEVENT\nEND:VCALENDAR\n")[0]['yearly']; +$eq($rule('FREQ=YEARLY'), true, 'RRULE: FREQ=YEARLY recurs'); +$eq($rule('FREQ=YEARLY;INTERVAL=1;BYMONTH=6;BYMONTHDAY=1'), true, 'RRULE: restating DTSTART still recurs'); +$eq($rule('FREQ=YEARLY;COUNT=3'), false, 'RRULE: COUNT is a first occurrence only'); +$eq($rule('FREQ=YEARLY;UNTIL=20280601'), false, 'RRULE: UNTIL is a first occurrence only'); +$eq($rule('FREQ=YEARLY;BYMONTH=6;BYDAY=1MO'), false, 'RRULE: a positional weekday is a first occurrence only'); +$eq($rule('FREQ=YEARLY;BYDAY=MO;BYSETPOS=1'), false, 'RRULE: BYSETPOS is a first occurrence only'); +$eq($rule('FREQ=YEARLY;INTERVAL=2'), false, 'RRULE: every other year is a first occurrence only'); +$eq($rule('FREQ=MONTHLY'), false, 'RRULE: a monthly rule is a first occurrence only'); +// BYMONTH/BYMONTHDAY recur only as DTSTART's own month and day: another +// value names a different day, so the row is DTSTART once, not every year. +$eq($rule('FREQ=YEARLY;BYMONTH=12;BYMONTHDAY=25'), false, 'RRULE: BYMONTH/BYMONTHDAY other than DTSTART is a first occurrence only'); +$eq($rule('FREQ=YEARLY;BYMONTH=6'), true, 'RRULE: BYMONTH equal to DTSTART still recurs'); +$eq($rule('FREQ=YEARLY;BYMONTHDAY=2'), false, 'RRULE: a BYMONTHDAY other than DTSTART is a first occurrence only'); +$eq($rule('FREQ=YEARLY;BYMONTHDAY=-1'), false, 'RRULE: a negative BYMONTHDAY is a first occurrence only'); +$early = LibraryCalendar::parseIcs("BEGIN:VCALENDAR\nBEGIN:VEVENT\nRRULE:FREQ=YEARLY;BYMONTH=12;BYMONTHDAY=25\nDTSTART;VALUE=DATE:20260601\nEND:VEVENT\n" + . "BEGIN:VEVENT\nRRULE:FREQ=YEARLY;BYMONTH=12;BYMONTHDAY=25\nDTSTART;VALUE=DATE:20261225\nEND:VEVENT\nEND:VCALENDAR\n"); +$eq([$early[0]['start'], $early[0]['yearly'], $early[1]['yearly']], ['2026-06-01', false, true], 'RRULE: checked against DTSTART even when the rule comes first'); + +// EXDATE: a stored yearly closure cannot skip a year, so the event is its +// first occurrence only; with DTSTART itself excluded nothing is left. +$exdate = LibraryCalendar::parseIcs("BEGIN:VCALENDAR\nBEGIN:VEVENT\nDTSTART;VALUE=DATE:20260815\nRRULE:FREQ=YEARLY\nEXDATE;VALUE=DATE:20270815\nSUMMARY:skip one\nEND:VEVENT\n" + . "BEGIN:VEVENT\nEXDATE;VALUE=DATE:20280101,20260815\nDTSTART;VALUE=DATE:20260815\nRRULE:FREQ=YEARLY\nSUMMARY:first excluded\nEND:VEVENT\n" + . "BEGIN:VEVENT\nDTSTART;TZID=Europe/Rome:20261224T090000\nRRULE:FREQ=YEARLY\nEXDATE;TZID=Europe/Rome:20271224T090000\nSUMMARY:timed\nEND:VEVENT\n" + . "BEGIN:VEVENT\nDTSTART;VALUE=DATE:20261225\nRRULE:FREQ=YEARLY\nSUMMARY:plain\nEND:VEVENT\nEND:VCALENDAR\n", 1000, 'Europe/Rome'); +$eq(array_column($exdate, 'description'), ['skip one', 'timed', 'plain'], 'EXDATE: an event whose DTSTART is excluded is left out'); +$eq([$exdate[0]['start'], $exdate[0]['yearly']], ['2026-08-15', false], 'EXDATE: a yearly event with an excluded year is imported once'); +$eq($exdate[1]['yearly'], false, 'EXDATE: a timed EXDATE with TZID disables the recurrence too'); +$eq($exdate[2]['yearly'], true, 'EXDATE: without one the yearly event still recurs'); +$eq(count(LibraryCalendar::parseIcs(str_repeat("BEGIN:VEVENT\nDTSTART;VALUE=DATE:20260101\nEND:VEVENT\n", 50), 10)), 10, 'ICS: the event cap holds'); +// An EXDATE excludes the occurrence with DTSTART's date AND time: another +// time the same day leaves DTSTART's occurrence in place (RFC 5545). +$exTime = LibraryCalendar::parseIcs("BEGIN:VCALENDAR\nBEGIN:VEVENT\nDTSTART:20261224T090000\nEXDATE:20261224T150000\nSUMMARY:other time\nEND:VEVENT\n" + . "BEGIN:VEVENT\nDTSTART:20261224T090000\nEXDATE:20261224T090000\nSUMMARY:same time\nEND:VEVENT\n" + . "BEGIN:VEVENT\nDTSTART;TZID=Europe/Rome:20261224T090000\nEXDATE:20261224T080000Z\nSUMMARY:same instant in UTC\nEND:VEVENT\nEND:VCALENDAR\n", 1000, 'Europe/Rome'); +$eq(array_column($exTime, 'description'), ['other time'], 'EXDATE: another time the same day keeps the DTSTART occurrence, the same local time (also written in UTC) excludes it'); +// Text after the date or time is not a date: the event has no DTSTART. +$eq(LibraryCalendar::parseIcs("BEGIN:VCALENDAR\nBEGIN:VEVENT\nDTSTART:20261224garbage\nSUMMARY:junk\nEND:VEVENT\n" + . "BEGIN:VEVENT\nDTSTART:20261224T100000Zjunk\nSUMMARY:junk time\nEND:VEVENT\n" + . "BEGIN:VEVENT\nDTSTART:20261224T1000\nSUMMARY:short time\nEND:VEVENT\nEND:VCALENDAR\n"), [['start' => '2026-12-24', 'end' => '2026-12-24', 'description' => 'short time', 'yearly' => false]], 'ICS: a DTSTART with trailing text is refused, a valid short time is kept'); + +// ── The request calendar's toOpenDayIso() (book-detail.php), run in node ── +// A closure longer than any fixed step bound must still be crossed whole. +$viewSrc = (string) file_get_contents(dirname(__DIR__) . '/app/Views/frontend/book-detail.php'); +$node = trim((string) shell_exec('command -v node 2>/dev/null')); +if ($node === '') { + echo "SKIP toOpenDayIso: node not available\n"; +} elseif (preg_match('/function addDaysToIso\(.*?\n }\n/s', $viewSrc, $addFn) !== 1 || preg_match('/function toOpenDayIso\(.*?\n }\n/s', $viewSrc, $openFn) !== 1) { + $check(false, 'toOpenDayIso and addDaysToIso found in book-detail.php'); +} else { + $closedYears = []; + for ($d = '2026-01-01'; $d <= '2027-03-10'; $d = LibraryCalendar::shift($d, 1)) { + $closedYears[] = $d; // 434 consecutive closed days + } + $js = $addFn[0] . $openFn[0] . 'const s = new Set(' . json_encode($closedYears) . ');' + . 'process.stdout.write(JSON.stringify([toOpenDayIso("2026-01-01", 1, s), toOpenDayIso("2027-03-10", -1, s), toOpenDayIso("2025-12-31", 1, s), toOpenDayIso("2026-06-01", 1, new Set())]));'; + $script = tempnam(sys_get_temp_dir(), 'opendays') . '.js'; + file_put_contents($script, $js); + $out = json_decode((string) shell_exec(escapeshellarg($node) . ' ' . escapeshellarg($script)), true); + @unlink($script); + $eq($out, ['2027-03-11', '2025-12-31', '2025-12-31', '2026-06-01'], 'toOpenDayIso crosses a 434-day closure both ways and leaves open days alone'); +} + +// ── loanWindowFrom() (book-detail.php) stops at the loaded availability ── +// Moving the longest return date over closed days must not reach a day the +// availability did not load (no occupancy data reads as free). +if ($node === '') { + echo "SKIP loanWindowFrom: node not available\n"; +} else { + $fns = ''; + foreach (['addDaysToIso', 'daysBetweenIso', 'toOpenDayIso', 'loanWindowFrom'] as $fn) { + if (preg_match('/function ' . $fn . '\(.*?\n }\n/s', $viewSrc, $fnSrc) !== 1) { + $check(false, "{$fn} found in book-detail.php"); + continue; + } + $fns .= $fnSrc[0]; + } + $loaded = []; + for ($d = '2026-01-01'; $d <= '2026-06-29'; $d = LibraryCalendar::shift($d, 1)) { + $loaded[] = ['date' => $d, 'state' => 'free']; + } + $closedTail = ['2026-06-29', '2026-06-30', '2026-07-01', '2026-07-02', '2026-07-03', '2026-07-04', '2026-07-05']; + $js = $fns . 'const avail = {}; for (const d of ' . json_encode($loaded) . ') avail[d.date] = d;' + . 'const w = loanWindowFrom("2026-01-01", avail, 30, 180, new Set(' . json_encode($closedTail) . '));' + . 'const plain = loanWindowFrom("2026-01-01", avail, 30, 60, new Set());' + . 'process.stdout.write(JSON.stringify([w.maxEndDate, w.endDate, w.capped, plain.maxEndDate]));'; + $script = tempnam(sys_get_temp_dir(), 'loanwindow') . '.js'; + file_put_contents($script, $js); + $out = json_decode((string) shell_exec(escapeshellarg($node) . ' ' . escapeshellarg($script)), true); + @unlink($script); + $eq($out, ['2026-06-28', '2026-01-31', false, '2026-03-02'], 'loanWindowFrom: a longest return date moved past the loaded days stops at the last open loaded day; inside them nothing changes'); +} + +echo ($failures === 0 ? 'SUCCESS' : 'FAILURE') . " {$checks} checks, {$failures} failed\n"; +exit($failures === 0 ? 0 : 1); diff --git a/tests/loan-auto-approval-301-reservation-path.unit.php b/tests/loan-auto-approval-301-reservation-path.unit.php index a1ac035b9..7fbed2745 100644 --- a/tests/loan-auto-approval-301-reservation-path.unit.php +++ b/tests/loan-auto-approval-301-reservation-path.unit.php @@ -201,7 +201,7 @@ // Drive the REAL createReservation with a JSON request + session user, exactly // like the book-detail modal does (CSRF is middleware, not in the controller). -$callCreate = static function (int $bookId, int $userId, ?string $start = null, ?string $end = null) use ($db, $today, $due): array { +$callCreate = static function (int $bookId, int $userId, ?string $start = null, ?string $end = null, ?mysqli $conn = null) use ($db, $today, $due): array { $start = $start ?? $today; $end = $end ?? $due; $_SESSION['user'] = ['id' => $userId, 'tipo_utente' => 'standard']; @@ -214,7 +214,7 @@ ->withHeader('Content-Type', 'application/json'); $request->getBody()->write((string) json_encode(['start_date' => $start, 'end_date' => $end])); $request->getBody()->rewind(); - $controller = new ReservationsController($db); + $controller = new ReservationsController($conn ?? $db); $result = $controller->createReservation($request, new SlimResponse(), ['id' => $bookId]); $payload = json_decode((string) $result->getBody(), true) ?: []; unset($_SESSION['user']); @@ -461,6 +461,107 @@ $check($loanFutureId > 0 && $loanField($loanFutureId, 'stato') === 'prenotato', "21 loan persisted as 'prenotato' (scheduled)"); $check($loanField($loanFutureId, 'pickup_deadline') === null, '22 scheduled loan has no pickup_deadline yet'); +// ── D2. The response carries the pickup deadline the server will apply ──── +// Computed with the assigned copy's branch closures (the page's own rule only +// knows the library-wide ones), and the due date the approval wrote is what +// the request answers with even when it moved over a branch closure. +echo "D2. scheduled request: pickup deadline and due date with the copy's branch closures\n"; +$pickupSetting = max(1, (int) ($settings->get('loans', 'pickup_expiry_days', '3') ?? 3)); +// The pickup window counted day by day: $days open days after $from (with +// $branch's closures), never past the due date $cap, and the open days it +// holds. A setting longer than the 14-day loan ends at the due date with +// fewer days than the setting. +$walkPickup = static function (string $from, int $days, string $cap, ?int $branch) use ($db): array { + $calendar = \App\Support\LibraryCalendar::forDb($db); + $day = $from; + $counted = 0; + while ($counted < $days && $day < $cap) { + $day = \App\Support\LibraryCalendar::shift($day, 1); + if ($calendar->isOpen($day, $branch)) { + $counted++; + } + } + return [$day, $counted]; +}; +$check(($resFuture['payload']['pickup_deadline'] ?? null) === \App\Support\LibraryCalendar::pickupDeadline($db, $futureStart, $pickupSetting, $futureEnd, (int) $loanField($loanFutureId, 'copia_id')), '22a scheduled response carries the pickup deadline activation will set'); +[, $futurePickupDays] = $walkPickup($futureStart, $pickupSetting, (string) $loanField($loanFutureId, 'data_scadenza'), null); +$check(($resFuture['payload']['pickup_days'] ?? null) === $futurePickupDays, "22b and the open pickup days ({$futurePickupDays})"); +$calTag = 'ZZRES301CAL_' . $run; +$db->query("INSERT INTO sedi (nome) VALUES ('{$calTag}')"); +$calSede = (int) $db->insert_id; +$calBook = $makeBook(1); +$calCopy = $copyIdsForBook($calBook)[0]; +try { + $db->query("UPDATE copie SET sede_id = {$calSede} WHERE id = {$calCopy}"); + $closedPickupDay = (new DateTimeImmutable($futureStart))->modify('+1 day')->format('Y-m-d'); + $stmt = $db->prepare('INSERT INTO calendario_chiusure (data_inizio, data_fine, descrizione, ricorrente_annuale, sede_id) VALUES (?, ?, ?, 0, ?), (?, ?, ?, 0, ?)'); + $stmt->bind_param('sssisssi', $closedPickupDay, $closedPickupDay, $calTag, $calSede, $futureEnd, $futureEnd, $calTag, $calSede); + $stmt->execute(); + $stmt->close(); + \App\Support\LibraryCalendar::reset(); + $resBranch = $callCreate($calBook, $makeUser(), $futureStart, $futureEnd); + $branchLoanId = (int) ($resBranch['payload']['loan_request_id'] ?? 0); + $check(($resBranch['payload']['loan_state'] ?? '') === 'prenotato' && (int) $loanField($branchLoanId, 'copia_id') === $calCopy, '22c the request on the branch copy is scheduled on that copy'); + $movedDue = (new DateTimeImmutable($futureEnd))->modify('+1 day')->format('Y-m-d'); + [$expectedPickup, $expectedPickupDays] = $walkPickup($futureStart, $pickupSetting, $movedDue, $calSede); + $check(($resBranch['payload']['pickup_deadline'] ?? null) === $expectedPickup, "22d the pickup deadline skips the branch's closed day ({$expectedPickup})"); + $check(($resBranch['payload']['pickup_days'] ?? null) === $expectedPickupDays, "22e the closed day adds no pickup day ({$expectedPickupDays})"); + $check(($resBranch['payload']['end_date'] ?? null) === $movedDue && $loanField($branchLoanId, 'data_scadenza') === $movedDue, '22f the due date moved over the branch closure is the one answered'); + + // The approval hands back the due date it wrote, so the answer does not + // depend on reading the row again. + $db->query("UPDATE prestiti SET stato = 'annullato', attivo = 0 WHERE id = {$branchLoanId}"); + $db->query("UPDATE copie SET stato = 'disponibile' WHERE id = {$calCopy}"); + $pendingUser = $makeUser(); + $stmt = $db->prepare("INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, origine, attivo) VALUES (?, ?, ?, ?, ?, 'pendente', 'richiesta', 0)"); + $stmt->bind_param('iiiss', $calBook, $calCopy, $pendingUser, $futureStart, $futureEnd); + $stmt->execute(); + $pendingId = (int) $db->insert_id; + $stmt->close(); + $_SESSION['user'] = ['id' => $pendingUser, 'tipo_utente' => 'standard']; + $autoApprove = new ReflectionMethod(ReservationsController::class, 'autoApproveLoanRequest'); + $approvedBody = null; + $approvedState = $autoApprove->invokeArgs(new ReservationsController($db), [(new ServerRequestFactory())->createServerRequest('POST', '/'), $pendingId, true, &$approvedBody]); + unset($_SESSION['user']); + $check($approvedState === 'prenotato' && is_array($approvedBody) && ($approvedBody['data_scadenza'] ?? null) === $movedDue, '22g the automatic approval returns the due date it wrote'); + + // When the stored window cannot be read back, the pickup estimate still + // comes with the assigned copy's branch closures (the approval hands the + // copy back), not null for the page to compute with library-wide ones. + $db->query("UPDATE prestiti SET stato = 'annullato', attivo = 0 WHERE id = {$pendingId}"); + $db->query("UPDATE copie SET stato = 'disponibile' WHERE id = {$calCopy}"); + $noReadBack = $socket !== '' && file_exists($socket) + ? new class (null, getenv('E2E_DB_USER') ?: ($env['DB_USER'] ?? ''), getenv('E2E_DB_PASS') ?: ($env['DB_PASS'] ?? ($env['DB_PASSWORD'] ?? '')), getenv('E2E_DB_NAME') ?: ($env['DB_NAME'] ?? ''), 0, $socket) extends mysqli { + public function prepare(string $query): mysqli_stmt|false + { + if (str_starts_with($query, 'SELECT data_prestito, data_scadenza, copia_id, pickup_deadline FROM prestiti')) { + throw new RuntimeException('stored window read refused by the test'); + } + return parent::prepare($query); + } + } + : new class (getenv('E2E_DB_HOST') ?: ($env['DB_HOST'] ?? '127.0.0.1'), getenv('E2E_DB_USER') ?: ($env['DB_USER'] ?? ''), getenv('E2E_DB_PASS') ?: ($env['DB_PASS'] ?? ($env['DB_PASSWORD'] ?? '')), getenv('E2E_DB_NAME') ?: ($env['DB_NAME'] ?? ''), (int) (getenv('E2E_DB_PORT') ?: ($env['DB_PORT'] ?? 3306))) extends mysqli { + public function prepare(string $query): mysqli_stmt|false + { + if (str_starts_with($query, 'SELECT data_prestito, data_scadenza, copia_id, pickup_deadline FROM prestiti')) { + throw new RuntimeException('stored window read refused by the test'); + } + return parent::prepare($query); + } + }; + $noReadBack->set_charset('utf8mb4'); + \App\Support\DateHelper::synchronizeDatabaseSession($noReadBack); + $resNoReadBack = $callCreate($calBook, $makeUser(), $futureStart, $futureEnd, $noReadBack); + $noReadBack->close(); + $check(($resNoReadBack['payload']['success'] ?? false) === true && ($resNoReadBack['payload']['loan_state'] ?? '') === 'prenotato', '22h a request whose stored window cannot be read back still succeeds'); + $check(($resNoReadBack['payload']['pickup_deadline'] ?? null) === $expectedPickup && ($resNoReadBack['payload']['pickup_days'] ?? null) === $expectedPickupDays, "22i and its pickup estimate still skips the copy's branch closures ({$expectedPickup})"); +} finally { + $db->query("DELETE FROM calendario_chiusure WHERE descrizione = '{$calTag}'"); + $db->query("UPDATE copie SET sede_id = NULL WHERE id = {$calCopy}"); + $db->query("DELETE FROM sedi WHERE id = {$calSede}"); + \App\Support\LibraryCalendar::reset(); +} + // ── E. #384: a currently-out single copy creates a REAL reservation ──────── // The requested future window starts the day after the contractual due date, // exactly like the reporter's screenshot. A due date is not a physical return: diff --git a/tests/loan-coherence-audit.unit.php b/tests/loan-coherence-audit.unit.php index 9a96b3224..be544e3f7 100644 --- a/tests/loan-coherence-audit.unit.php +++ b/tests/loan-coherence-audit.unit.php @@ -186,7 +186,7 @@ // 11. #301: the book-detail modal path (createReservation) must honour the // automatic-approval setting like UserActionsController::loan does. $checks['createReservation honours auto_approve_requests (#301)'] = - str_contains($reservations, 'autoApproveLoanRequest($request, $loanRequestId, true)') + str_contains($reservations, 'autoApproveLoanRequest($request, $loanRequestId, true') && str_contains($reservations, "'automatic_loan_approval'"); // 12. rejectLoan must keep an audit row (annullato), never DELETE it. @@ -300,7 +300,7 @@ && str_contains($loanCreateView, 'addDaysToIso(startIso, defaultDays)') && !str_contains($loanCreateView, 'setMonth(endDate.getMonth() + 1)') && str_contains($frontend, '$defaultRequestLoanDays = $viewerPolicy->requestDurationDays()') - && str_contains($bookDetail, 'loanWindowFrom(startIso, availabilityByDate, defaultRequestLoanDays, maxRequestLoanDays)') + && str_contains($bookDetail, 'loanWindowFrom(startIso, availabilityByDate, defaultRequestLoanDays, maxRequestLoanDays, closedDaySet)') && str_contains($bookDetail, 'addDaysToIso(startIso, defaultDays)') && !str_contains($bookDetail, 'setMonth(endDate.getMonth() + 1)'); diff --git a/tests/loan-renewal-window.unit.php b/tests/loan-renewal-window.unit.php index 4b6850d57..04e58e846 100644 --- a/tests/loan-renewal-window.unit.php +++ b/tests/loan-renewal-window.unit.php @@ -284,7 +284,8 @@ && substr_count($staffSrc, 'self::windowDetails($outcome)') === 2, '12 the Mobile API carries the figures in meta on renew and bulk extend, and accepts new_due_date'); $ncipSrc = (string) file_get_contents($root . '/storage/plugins/ncip-server/NcipServerPlugin.php'); -$check(str_contains($ncipSrc, 'LoanWindow::renewalCap($currentDue, $blocked)') +// The closures calendar (0.9.0) passes the calendar as a further argument. +$check(str_contains($ncipSrc, 'LoanWindow::renewalCap($currentDue, $blocked') && str_contains($ncipSrc, 'renewal possible at most until'), '13 NCIP RenewItem names until when the renewal is still possible in its ProblemDetail'); diff --git a/tests/loan-window-cap.unit.php b/tests/loan-window-cap.unit.php index c63f96ef1..5f5c6ec43 100644 --- a/tests/loan-window-cap.unit.php +++ b/tests/loan-window-cap.unit.php @@ -224,6 +224,19 @@ '4 availability payload: free the day before the scheduled loan, borrowed from its first day', json_encode([$byDate[$expectedMaxEnd] ?? null, $byDate[$conflictStart] ?? null])); +// A start on the last offered day still has occupancy data for its whole loan +// window: the public route asks for it, the default call is unchanged. +$plain = (new ReservationsController($db))->getBookAvailabilityData($singleBook, $today, 180, $requester); +$covered = (new ReservationsController($db))->getBookAvailabilityData($singleBook, $today, 180, $requester, true); +$lastPlain = is_array($plain) ? (string) (end($plain['days'])['date'] ?? '') : ''; +$lastCovered = is_array($covered) ? (string) (end($covered['days'])['date'] ?? '') : ''; +$startUntil = $addDays($today, 179); +$check(($plain['start_until'] ?? null) === $startUntil && $lastPlain === $startUntil + && ($covered['start_until'] ?? null) === $startUntil + && $lastCovered >= $addDays($startUntil, (int) ($covered['max_loan_duration_days'] ?? 0) + 31), + '4b the availability covering the loan window runs past the last start day by the longest loan plus the closed-day margin', + json_encode([$plain['start_until'] ?? null, $lastPlain, $lastCovered, $covered['max_loan_duration_days'] ?? null])); + // ═════════ 5-7: the public calendar endpoint explains the cap ════════════════ $r = $requestCalendar($singleBook, ['start_date' => $today]); $check($r['status'] === 400 @@ -327,6 +340,10 @@ && str_contains($viewSrc, 'Durata massima del prestito: %1$d giorni') && str_contains($viewSrc, 'LoanWindow::explainFromParams($_GET)'), '20 book-detail.php caps the return picker, explains it with the shared message and renders the redirect explanation'); +$routes = (string) file_get_contents($root . '/app/Routes/web.php'); +$check(str_contains($viewSrc, 'availData.availability.start_until') + && str_contains($routes, "getBookAvailabilityData(\$bookId, \\App\\Support\\DateHelper::today(), 180, \$excludeUserId, true)"), + '21 the book page starts no later than start_until, and its route loads the days past it'); $cleanup(); $db->close(); diff --git a/tests/loan-window-surfaces.unit.php b/tests/loan-window-surfaces.unit.php index 9d470fe20..10fe90337 100644 --- a/tests/loan-window-surfaces.unit.php +++ b/tests/loan-window-surfaces.unit.php @@ -43,6 +43,7 @@ require_once $root . '/storage/plugins/mobile-api/src/Support/JsonBody.php'; require_once $root . '/storage/plugins/mobile-api/src/Support/ResponseEnvelope.php'; require_once $root . '/storage/plugins/mobile-api/src/Support/Input.php'; +require_once $root . '/storage/plugins/mobile-api/src/Support/CirculationOutcome.php'; require_once $root . '/storage/plugins/mobile-api/src/Controllers/ActionsController.php'; require_once $root . '/storage/plugins/mobile-api/src/Controllers/CatalogController.php'; diff --git a/tests/migration-0.9.0-closures-calendar.unit.php b/tests/migration-0.9.0-closures-calendar.unit.php new file mode 100644 index 000000000..3b0cac400 --- /dev/null +++ b/tests/migration-0.9.0-closures-calendar.unit.php @@ -0,0 +1,138 @@ +_migtest_cal"). Run: php tests/migration-0.9.0-closures-calendar.unit.php + */ + +require dirname(__DIR__) . '/vendor/autoload.php'; +mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + +$root = dirname(__DIR__); +$env = Dotenv\Dotenv::parse((string) file_get_contents($root . '/.env')); +$socket = getenv('E2E_DB_SOCKET') ?: ($env['DB_SOCKET'] ?? null); +$sandbox = getenv('E2E_MIG_DB') ?: preg_replace('/^fabiodal_biblioteca_/', 'fabiodal_', (string) $env['DB_NAME']) . '_migtest_cal'; +if (preg_match('/^[A-Za-z0-9_]+$/', $sandbox) !== 1) { + fwrite(STDERR, "invalid sandbox name\n"); + exit(1); +} + +$checks = 0; +$check = static function (bool $ok, string $label) use (&$checks): void { + if (!$ok) { + throw new RuntimeException('FAIL ' . $label); + } + $checks++; + echo "OK {$label}\n"; +}; + +// The 0.8.9 schema: the last released schema.sql, so "old" really is old. +// A shallow CI clone has neither the tag nor the commit; the section only +// depends on `sedi` (its foreign key), so the 0.8.9 definition of that table, +// verbatim, is the seed there. +$oldSchema = shell_exec('git -C ' . escapeshellarg($root) . ' show v0.8.9:installer/database/schema.sql 2>/dev/null') + ?: shell_exec('git -C ' . escapeshellarg($root) . ' show 12f05750:installer/database/schema.sql 2>/dev/null'); +if (!is_string($oldSchema) || !str_contains($oldSchema, 'CREATE TABLE `sedi`')) { + echo "0.8.9 schema.sql not in the local git history: seeding the 0.8.9 `sedi` table\n"; + $oldSchema = <<<'SQL' +CREATE TABLE `sedi` ( + `id` int NOT NULL AUTO_INCREMENT, + `nome` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Nome della sede (es. Sede Centrale, Succursale Nord)', + `codice` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Codice identificativo della sede', + `indirizzo` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `citta` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `cap` varchar(10) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `provincia` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `telefono` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `email` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `orari_apertura` text COLLATE utf8mb4_unicode_ci COMMENT 'Orari di apertura in formato libero', + `note` text COLLATE utf8mb4_unicode_ci, + `is_active` tinyint(1) DEFAULT '1', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_codice` (`codice`), + KEY `idx_is_active` (`is_active`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Sedi della biblioteca (per sistemi multi-sede)'; +SQL; +} + +$migration = (string) file_get_contents($root . '/installer/database/migrations/migrate_0.9.0-rc.1.sql'); +if (preg_match('/-- ── closures calendar ──(.*?)-- ── end closures calendar ──/s', $migration, $m) !== 1) { + fwrite(STDERR, "closures calendar section not found in migrate_0.9.0-rc.1.sql\n"); + exit(1); +} +$section = $m[1]; + +$admin = new mysqli($env['DB_HOST'] ?? 'localhost', $env['DB_USER'], $env['DB_PASS'] ?? '', '', (int) ($env['DB_PORT'] ?? 3306), $socket); +$exit = 1; +try { + $admin->query("DROP DATABASE IF EXISTS `{$sandbox}`"); + $admin->query("CREATE DATABASE `{$sandbox}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); + $db = new mysqli($env['DB_HOST'] ?? 'localhost', $env['DB_USER'], $env['DB_PASS'] ?? '', $sandbox, (int) ($env['DB_PORT'] ?? 3306), $socket); + $db->multi_query($oldSchema); + do { + if ($r = $db->store_result()) { + $r->free(); + } + } while ($db->more_results() && $db->next_result()); + + $tableExists = static fn (): bool => (int) $db->query("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'calendario_chiusure'")->fetch_row()[0] === 1; + $check(!$tableExists(), 'the 0.8.9 schema has no calendario_chiusure'); + $check((int) $db->query("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sedi'")->fetch_row()[0] === 1, 'the 0.8.9 schema has sedi'); + + // Execute the way the Updater does: comment lines dropped, split on ';'. + $run = static function () use ($db, $section): void { + $sql = implode("\n", array_filter(explode("\n", $section), static fn (string $l): bool => !str_starts_with(trim($l), '--'))); + foreach (explode(';', $sql) as $statement) { + if (trim($statement) !== '') { + $db->query($statement); + } + } + }; + $run(); + $check($tableExists(), 'the migration creates calendario_chiusure'); + $cols = []; + foreach ($db->query("SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'calendario_chiusure'")->fetch_all(MYSQLI_ASSOC) as $c) { + $cols[$c['COLUMN_NAME']] = $c['DATA_TYPE'] . ':' . $c['IS_NULLABLE']; + } + $expected = ['id', 'data_inizio', 'data_fine', 'descrizione', 'ricorrente_annuale', 'sede_id', 'created_at']; + $check(array_diff($expected, array_keys($cols)) === [] && count($cols) === count($expected), 'columns match the plan'); + $check(($cols['data_inizio'] ?? '') === 'date:NO' && ($cols['data_fine'] ?? '') === 'date:NO', 'the interval is two NOT NULL dates'); + $check(($cols['sede_id'] ?? '') === 'int:YES', 'sede_id is nullable (NULL = all branches)'); + $fk = $db->query("SELECT REFERENCED_TABLE_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'calendario_chiusure' AND COLUMN_NAME = 'sede_id' AND REFERENCED_TABLE_NAME IS NOT NULL")->fetch_row(); + $check(($fk[0] ?? null) === 'sedi', 'sede_id references sedi'); + + $db->query("INSERT INTO sedi (nome) VALUES ('Succursale')"); + $sede = (int) $db->insert_id; + $db->query("INSERT INTO calendario_chiusure (data_inizio, data_fine, descrizione) VALUES ('2026-12-24','2026-12-26','Natale')"); + $db->query("INSERT INTO calendario_chiusure (data_inizio, data_fine, descrizione, ricorrente_annuale, sede_id) VALUES ('2026-08-15','2026-08-15','Ferragosto',1,{$sede})"); + $row = $db->query("SELECT ricorrente_annuale, sede_id FROM calendario_chiusure WHERE descrizione = 'Natale'")->fetch_assoc(); + $check($row['ricorrente_annuale'] === '0' || (int) $row['ricorrente_annuale'] === 0, 'ricorrente_annuale defaults to 0'); + $check($row['sede_id'] === null, 'sede_id defaults to NULL'); + + $run(); + $check((int) $db->query('SELECT COUNT(*) FROM calendario_chiusure')->fetch_row()[0] === 2, 'a second run is a no-op that keeps the rows'); + + $db->query("DELETE FROM sedi WHERE id = {$sede}"); + $check((int) $db->query('SELECT COUNT(*) FROM calendario_chiusure')->fetch_row()[0] === 1, 'deleting a branch removes only its closures'); + + $schema = (string) file_get_contents($root . '/installer/database/schema.sql'); + $check(str_contains($schema, 'CREATE TABLE `calendario_chiusure`') && str_contains($schema, '`fk_calendario_chiusure_sede`'), 'fresh install (schema.sql) has the table'); + $version = json_decode((string) file_get_contents($root . '/version.json'), true)['version']; + $check(version_compare('0.9.0-rc.1', $version, '<='), 'the release version includes the migration'); + $db->close(); + echo "SUCCESS {$checks} migration checks\n"; + $exit = 0; +} catch (\Throwable $e) { + echo $e->getMessage() . "\n"; +} finally { + $admin->query("DROP DATABASE IF EXISTS `{$sandbox}`"); +} +exit($exit); diff --git a/tests/mobile-api-circulation.spec.js b/tests/mobile-api-circulation.spec.js index c7e63b122..5a1f7d750 100644 --- a/tests/mobile-api-circulation.spec.js +++ b/tests/mobile-api-circulation.spec.js @@ -363,6 +363,63 @@ test.describe('Borrower request lifecycle', () => { }); }); +test.describe('Reader request on a closed day', () => { + // The library is closed today: the auto-approved request starts on the + // next open day, so the app is told `prenotato` (with the start day), not + // `da_ritirare` as every approved request used to be reported. + test('with today closed and auto-approval on, POST /reservations answers status prenotato', async ({ request }) => { + const TAG = `E2E-MOBCAL-${RUN}`; + const prevAuto = F.dbScalar("SELECT setting_value FROM system_settings WHERE category='loans' AND setting_key='auto_approve_requests'"); + const prevWeekdays = F.dbScalar("SELECT setting_value FROM system_settings WHERE category='loans' AND setting_key='closed_weekdays'"); + const setSetting = (key, value) => { + if (value === '' || value === null || value === undefined) { + F.dbExec(`DELETE FROM system_settings WHERE category='loans' AND setting_key='${key}'`); + } else { + F.dbExec(`INSERT INTO system_settings (category, setting_key, setting_value) VALUES ('loans','${key}',${F.sqlStr(value)}) ON DUPLICATE KEY UPDATE setting_value=VALUES(setting_value)`); + } + }; + const email = `circ_closed_${RUN}@pinakes.test`; + const userId = F.createUser(email, PASS, 'standard', 'CIRX'); + ctx.users.push(userId); + const token = await F.login(request, email, PASS, 'circ-closed'); + const bookId = await staffBook(request, 'Circ closed today', 1); + try { + // Inside the try: a failed setup still restores both settings. + setSetting('auto_approve_requests', '1'); + setSetting('closed_weekdays', ''); + F.dbExec(`INSERT INTO calendario_chiusure (data_inizio, data_fine, descrizione) VALUES ('${ctx.today}', '${ctx.today}', '${TAG}')`); + // Tomorrow too: the first open day is then never simply today + 1, + // so an implementation that always schedules tomorrow fails. + F.dbExec(`INSERT INTO calendario_chiusure (data_inizio, data_fine, descrizione) VALUES ('${isoDay(1)}', '${isoDay(1)}', '${TAG}')`); + const r = await F.api(request, 'POST', '/reservations', { token, body: { book_id: bookId } }); + expect(r.status, r.text.slice(0, 300)).toBe(201); + expect(r.json.data.type).toBe('loan'); + expect(r.json.data.auto_approved).toBe(true); + expect(r.json.data.status).toBe('prenotato'); + // The exact next open day after today. Weekly closures are off, so + // only the library-wide closures (this run's and any already + // stored) can push it past tomorrow: walk them day by day. + const closures = F.dbRows('SELECT data_inizio, data_fine, ricorrente_annuale FROM calendario_chiusure WHERE sede_id IS NULL'); + const closed = (ymd) => closures.some((c) => { + if (c.ricorrente_annuale !== '1') return ymd >= c.data_inizio && ymd <= c.data_fine; + const md = ymd.slice(5), from = c.data_inizio.slice(5), to = c.data_fine.slice(5); + return to < from ? (md >= from || md <= to) : (md >= from && md <= to); + }); + let offset = 1; + while (offset < 400 && closed(isoDay(offset))) offset++; + expect(offset).toBeGreaterThanOrEqual(2); + expect(r.json.data.start_date).toBe(isoDay(offset)); + expect(r.json.meta.message).not.toMatch(/attesa di ritiro/); + const stored = F.dbScalar(`SELECT CONCAT(stato,'|',data_prestito) FROM prestiti WHERE id=${r.json.data.loan_id}`); + expect(stored).toBe(`prenotato|${r.json.data.start_date}`); + } finally { + F.dbExec(`DELETE FROM calendario_chiusure WHERE descrizione='${TAG}'`); + setSetting('auto_approve_requests', prevAuto); + setSetting('closed_weekdays', prevWeekdays); + } + }); +}); + test.describe('Reservations and queue', () => { test('create → queue position, duplicate 409, cancel, reactivate; queue + availability', async ({ request }) => { const r = await F.api(request, 'POST', '/staff/reservations', { diff --git a/tests/mobile-api-circulation.unit.php b/tests/mobile-api-circulation.unit.php index 4bf886d81..4fcbc2b14 100644 --- a/tests/mobile-api-circulation.unit.php +++ b/tests/mobile-api-circulation.unit.php @@ -171,6 +171,19 @@ } } +// The reader's request (POST /reservations → UserActionsController::loan()): +// the status the app gets is the persisted state, not "approved = da_ritirare". +echo "\nD2. Reader request state from the loan() redirect\n"; +$check(CirculationOutcome::requestState(['loan_request_success' => '1', 'auto_approved' => '0', 'loan_state' => '']) === 'pendente', 'not auto-approved → pendente'); +$check(CirculationOutcome::requestState(['loan_request_success' => '1']) === 'pendente', 'no auto_approved key → pendente'); +$check(CirculationOutcome::requestState(['auto_approved' => '1', 'loan_state' => 'da_ritirare']) === 'da_ritirare', 'approved, starts today → da_ritirare'); +$check(CirculationOutcome::requestState(['auto_approved' => '1', 'loan_state' => 'prenotato']) === 'prenotato', 'approved, library closed today → prenotato (starts on the next open day)'); +$check(CirculationOutcome::requestState(['auto_approved' => '1', 'loan_state' => '']) === 'da_ritirare', 'approved without a state → da_ritirare (fallback)'); +$check(CirculationOutcome::requestState(['auto_approved' => '1', 'loan_state' => 'in_corso']) === 'da_ritirare', 'approved with an unexpected state → da_ritirare'); +$check(CirculationOutcome::requestState(['auto_approved' => '0', 'loan_state' => 'prenotato']) === 'pendente', 'a state without approval is still pendente'); +$openApi = (string) file_get_contents(__DIR__ . '/../storage/plugins/mobile-api/src/Controllers/OpenApiController.php'); +$check(str_contains($openApi, "'enum' => ['pendente', 'da_ritirare', 'prenotato']"), 'the OpenAPI status enum of POST /reservations lists prenotato'); + // ── E. Health flags ────────────────────────────────────────────────────────── echo "\nE. staff_* flags only for mounted areas\n"; $Areas = \App\Plugins\MobileApi\Support\StaffAreas::class; diff --git a/tests/ncip-circulation-coherence.unit.php b/tests/ncip-circulation-coherence.unit.php index 4ef9c5271..cab71a338 100644 --- a/tests/ncip-circulation-coherence.unit.php +++ b/tests/ncip-circulation-coherence.unit.php @@ -372,7 +372,7 @@ $dDue = (new DateTimeImmutable($today))->modify("+{$loanDays} days")->format('Y-m-d'); $createLoanAtomic = new ReflectionMethod(NcipServerPlugin::class, 'createLoanAtomic'); $dFailure = null; -$dLoanId = $createLoanAtomic->invokeArgs($ncip, [$dBookId, $dUserId, $dDue, (int) $staffCaller['id'], &$dFailure]); +$dLoanId = $createLoanAtomic->invokeArgs($ncip, [$dBookId, $dUserId, &$dDue, (int) $staffCaller['id'], &$dFailure]); $check(is_int($dLoanId) && $dLoanId > 0, '13 atomic NCIP checkout creates the in_corso loan (' . var_export($dFailure, true) . ')'); $check($hasAuditEvent($dBookId, 'loan.created', 'ncip', (int) $dLoanId), '14 loan.created audit event recorded with source=ncip'); $dOutbox = 0;