diff --git a/.github/workflows/ci-quality.yml b/.github/workflows/ci-quality.yml index 51d95334c..be0402280 100644 --- a/.github/workflows/ci-quality.yml +++ b/.github/workflows/ci-quality.yml @@ -211,6 +211,25 @@ jobs: - name: Soft-delete guard (libri queries must include deleted_at IS NULL) run: python3 scripts/ci-check-soft-delete.py app storage/plugins installer + # ── Circulation rules single source ─────────────────────────────────── + # Loan duration, request window, renewals and active-loan limits are read + # only through App\Services\CirculationPolicy (patron types and the rules + # matrix layer on top of the globals there). A direct read anywhere else + # would apply the global value on one channel and the patron's rule on + # another. SettingsController is the settings form itself. + - name: Circulation policy guard (loans rules read only via CirculationPolicy) + run: | + PATTERN="'loans',[[:space:]]*'(max_active_loans_per_user|max_renewals|loan_duration_days|max_loan_duration_days)'" + HITS=$(grep -rnE "$PATTERN" app storage/plugins cron public installer --include='*.php' \ + | grep -v '^app/Services/CirculationPolicy.php:' \ + | grep -v '^app/Controllers/SettingsController.php:' || true) + if [ -n "$HITS" ]; then + echo "✗ Direct read of a circulation setting outside CirculationPolicy:" + echo "$HITS" + exit 1 + fi + echo "✓ circulation settings read only through CirculationPolicy" + # ── Autoloader safety ───────────────────────────────────────────────── - name: Autoloader phpstan-free run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 67b9d9a7a..5c9509867 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,20 +29,32 @@ Full version-by-version history for Pinakes. The README shows only the last thre - **ISBD catalogue cards.** "Scheda catalografica" on the book page and "Schede catalografiche" on a selection in the book list produce a PDF of 125 × 75 mm cards, one per book: call number, main entry heading (principal author, surname first), ISBD description, standard number and subject tracings. Deleted books are never printed. - **Suppliers** (`/admin/suppliers`, sidebar "Fornitori"). New table `fornitori` with name, contact, VAT number, website, address and notes; a supplier select in the book form ("Dettagli Acquisizione"), the supplier on the book page and a "Fornitore" filter in the book list. Deleting a supplier keeps its books and clears their supplier (`libri.fornitore_id`, ON DELETE SET NULL). An e-mail or website that is not one is refused by the supplier form with a message, never saved as empty. The MARC and CSV import pages have a "Fornitore" select: the chosen supplier goes on the books the import creates and, for a MARC import in "Sovrascrivi" mode, on the books it overwrites; skipped duplicates and books only completed in their empty fields keep theirs. - **RDA content, media and carrier types in cataloguing.** The book form shows the three RDA types derived from the media type and lets the cataloguer override each one for a single record (`libri.rda_content_type`, `rda_media_type`, `rda_carrier_type`, NULL = derived). The book page shows the effective types. The BIBFRAME output carries the same effective types: books keep txt/n/nc as before; DVD, disc and audiobook records now emit their real RDA types (tdi/v/vd, prm/s/sd, spw/s/sd) instead of a book's; an override wins over the derived value. -- **Statistics.** `/admin/statistics` adds loans of the last 12 months by material and the visitors of the last 30 days. The outcomes of the last closed stocktake (expected, found, misplaced, on loan, missing, unknown codes) appear once a session has been closed. Loans by patron type and fines have their place on the page and appear as soon as those features provide data (`App\Support\StatisticsSections`). +- **Statistics.** `/admin/statistics` adds loans of the last 12 months by material and the visitors of the last 30 days. The outcomes of the last closed stocktake (expected, found, misplaced, on loan, missing, unknown codes) appear once a session has been closed. Loans of the last 12 months by patron type (readers without a type under the default type) come with the circulation policy; fines have their place on the page and appear as soon as that feature provides data (`App\Support\StatisticsSections`). ### Library cards, patron photo and user import - **Printable library cards.** Every reader's page has "Stampa tessera": a TCPDF PDF in CR80 size (85.6 × 54 mm, one card per page) or on A4 sheets of business-card stock with cut lines (10 per sheet, 9 for the portrait layout). The front carries the library logo and name, the reader's name, the card code as a Code128 barcode or a QR code, the photo and the expiry date; the back carries a text of your choice (rules, website, opening hours), mirrored on the A4 sheet so a long-edge duplex print puts each back behind its own card. The users list gained a selection column: selected readers print in one PDF, as single cards or A4 sheets. - **Settings → Tessere** (`/admin/settings/cards`): three layouts (classic with a header band, minimal without photo, portrait), Code128 or QR, default format, header text, which elements to show, back text. Untouched settings give the classic Code128 card; nothing else changes for an installation that never prints a card. - **Patron photo from the webcam.** The user form has "Scatta foto" (the browser camera, cropped to a portrait frame on a canvas) and "Carica foto"; both go to the same endpoint, which re-encodes the image as a plain JPEG of at most 600 px without EXIF. Photos live in `storage/patron_photos/`, outside the web root, and reach a browser only through `/admin/users/{id}/photo` behind the same permission as the reader record. "Elimina foto" removes file and reference at once (the reader's right to erasure), and deleting an account deletes its photo, also when the account has a loan history and is therefore only suspended. `Permissions-Policy` keeps `camera=(self)` (now built by `ContentSecurityPolicy::permissionsPolicy()`); the CSP needed no change. -- **User import from CSV** (`/admin/users/import`, "Importa CSV" in the users list). Upload, preview of the first rows, column mapping with automatic recognition of Italian, English, German, French and Danish headers (accented letters included, whatever the server's locale) and of the custom registration fields, then chunked processing with a progress bar. Rows whose email or card code already exists (or already appeared in the file) are skipped as duplicates and never merged; invalid rows are reported with their line number. Passwords are never imported: an optional checkbox sends each new reader the password-setup invitation (`PasswordSetupToken`). Every run is written to the import history (type `users`) with the errors downloadable as CSV. A file that is not UTF-8 (a "CSV" saved by Excel on Windows is in the system code page) is converted from Windows-1252 on upload and the preview says so, instead of the upload failing with an empty response. The separator (";", "," or TAB) is the one that splits the first lines into the same number of columns as the header, quoted fields respected, so a ";" file whose addresses hold commas is no longer read as a one-column "," file. A patron-type column is assigned by code once the patron types schema exists; until then it is not offered. +- **User import from CSV** (`/admin/users/import`, "Importa CSV" in the users list). Upload, preview of the first rows, column mapping with automatic recognition of Italian, English, German, French and Danish headers (accented letters included, whatever the server's locale) and of the custom registration fields, then chunked processing with a progress bar. Rows whose email or card code already exists (or already appeared in the file) are skipped as duplicates and never merged; invalid rows are reported with their line number. Passwords are never imported: an optional checkbox sends each new reader the password-setup invitation (`PasswordSetupToken`). Every run is written to the import history (type `users`) with the errors downloadable as CSV. A file that is not UTF-8 (a "CSV" saved by Excel on Windows is in the system code page) is converted from Windows-1252 on upload and the preview says so, instead of the upload failing with an empty response. A patron-type column ("Tipologia") assigns the type by its code or, failing that, by its name (both in any case; a name shared by two types is not guessed), so the users export of another installation with the same types re-imports cleanly; an empty value leaves the reader on the default type, and so does an unknown or deactivated one, reported as a warning with its line. The card expiry follows the user form: when the file gives none, it is computed from the card duration of the assigned patron type (the default type when the row has no type), and an expiry present in the CSV always wins. The separator (";", "," or TAB) is the one that splits the first lines into the same number of columns as the header, quoted fields respected, so a ";" file whose addresses hold commas is no longer read as a one-column "," file. + +### Circulation policy +- **One source for the circulation rules.** Loan duration, request window, renewals and the active-loan limit were read straight from the loans settings in about twenty places (staff desk, reader requests on the website and in the app, approval, queue promotion, renewals, NCIP). They now all go through `App\Services\CirculationPolicy`, which returns immutable `UserPolicy`/`LoanPolicy` objects, and every due date is computed by `CirculationPolicy::dueDateFor()`. With only the global settings the numbers are the same as before (a unit test compares every old read site over a grid of stored values); a CI step rejects any new direct read of those settings. +- **Patron types** (Settings → Circulation). Every reader belongs to a patron type (child, student, teacher…) that decides the circulation rules; the role (`tipo_utente`) keeps deciding only the permissions. A type can set the total active-loan limit, loan duration, longest request, renewals and days per renewal, whether reservations are allowed and how many, and the card duration; an empty field inherits the global loans setting. Types are created, edited, ordered and deleted from the new tab, one of them is the default for patrons without a type. The user forms have a "Tipologia lettore" select, the reader page shows the effective rules, the user list filters by type and the CSV export has the column, followed by the type's code ("Codice tipologia lettore") so a round trip through the user import is exact even when two types share a name. Existing users are not migrated: everyone starts on the default "Standard" type, which inherits everything. +- **Rules matrix patron type × material** (`libri.tipo_media`). A cell can make a material consultation only, cap the loans of that material on top of the total limit, and set its duration, renewals and days per renewal; the matrix shows the inherited value of every empty cell in grey. The book page's renew button and its confirmation say the real renewal length of the loan instead of a fixed "+14 giorni". Per field, the first value wins among rule (type, material) → rule (type, every material) → rule (every type, material) → patron type → global settings. +- **Consultation-only copies.** A single copy can be kept for in-house consultation from the book page (`copie.solo_consultazione`): it is never allocated to a loan nor counted as lendable capacity, and a title whose every copy is kept for consultation is consultation only. The book page shows "Solo consultazione" instead of the request button. The availability counter `libri.copie_disponibili` (book page button and badge, catalogue "available" filter, OPAC and mobile availability) is recalculated without the consultation copies, so a title whose only copy on the shelf is a consultation copy offers "Prenota quando disponibile", not "Richiedi prestito"; `copie_totali` keeps counting every physical copy in circulation. Existing installations get the counters recalculated by the upgrade (the Updater re-derives availability after the migrations) or from Maintenance → "Ricalcola disponibilità". +- **The same rules on every channel.** Staff desk, reader requests, approval, queue promotion, renewals, the Mobile API and NCIP all refuse with the same codes: `media_limit_reached`, `not_loanable`, `reservations_disabled`, `reservation_limit_reached` (Mobile API `CirculationOutcome`, NCIP *Item Does Not Circulate* / *Maximum Check Outs Exceeded*). `GET /api/v1/catalog/books/{id}/availability` gains `loanable` and `reservations_enabled`. NCIP RequestItem due dates are now capped by the maximum loan duration like every other channel (they used to take the bare loan duration). +- **Reservation limits per patron type.** A type without reservations does not see "reserve when available" on the book page and a request that would join the waitlist is refused (`reservations_disabled`); `max_prenotazioni` counts the active queue reservations plus the loan requests still awaiting approval (`reservation_limit_reached`). Website, app, NCIP RequestItem and the admin reservation form apply them; the admin form can override them with an explicit "Ignora i limiti di prenotazione" tick (`override_limits: true` from the staff app). +- **Card duration of the patron type.** When the type sets a card duration, the card expiry is computed on creation and edit ("Calcola la scadenza dalla durata tessera", on by default when creating, where it fills an empty date only: a date typed by the librarian wins), on self-registration and on registration approval; the date can still be set by hand. The reader page has a **"Rinnova tessera"** button: expiry = today + the type's duration (one year when the type sets none), an expired account becomes active again, and the renewal is in the activity log. +- **"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. ### 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. - `migrate_0.9.0-rc.1.sql`, section "Inventory stocktake": creates `inventario_sessioni` (with `azione_mancanti`, the close's choice), `inventario_attese` (with `esito_chiusura`, the bucket frozen at close, and `codice_atteso`/`titolo_atteso`, the copy code and title frozen at opening so a copy deleted mid-session stays identifiable in the report; each added with a guard to a table an earlier build created) and `inventario_scansioni` (UNIQUE `sessione_id, copia_id`) and seeds the "Revisore inventario" role. Additive and idempotent; nothing existing changes. -- `migrate_0.9.0-rc.1.sql`, section "MARC bulk import / copy cataloguing": `import_logs.import_type` gains the value `marc`. Idempotent; existing rows are untouched. - `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. ## [0.8.9] diff --git a/README.md b/README.md index d844a19a8..7c127a7d7 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Pinakes is a self-hosted, full-featured ILS for schools, municipalities, and pri - **MARC bulk import and export, copy cataloguing**: ISO 2709 and MARCXML (MARC 21, UNIMARC, danMARC2, MARC-8) imported in chunks with a preview, the catalogue or a selection exported as MARC 21, MARCXML or UNIMARC, and records searched and imported from other catalogues over SRU. - **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**: loan duration, renewals, request window and limits per patron type and material, from one circulation policy. +- **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. - **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/CirculationSettingsController.php b/app/Controllers/CirculationSettingsController.php new file mode 100644 index 000000000..e35592c75 --- /dev/null +++ b/app/Controllers/CirculationSettingsController.php @@ -0,0 +1,540 @@ + [min, max] (NULL when empty). */ + private const TYPE_INT_FIELDS = [ + 'max_prestiti' => [0, 1000], + 'durata_prestito_giorni' => [1, 3650], + 'max_durata_richiesta_giorni' => [1, 3650], + 'max_rinnovi' => [0, 100], + 'giorni_rinnovo' => [1, 3650], + 'max_prenotazioni' => [0, 1000], + 'durata_tessera_giorni' => [1, 36500], + ]; + + /** Matrix cell fields: form key => [column, min, max]. */ + private const RULE_FIELDS = [ + 'max' => ['max_prestiti', 0, 1000], + 'duration' => ['durata_prestito_giorni', 1, 3650], + 'renewals' => ['max_rinnovi', 0, 100], + 'renewal_days' => ['giorni_rinnovo', 1, 3650], + ]; + + private const TAB_URL = '/admin/settings?tab=circulation'; + + /** + * Data for the circulation tab of the settings page. + * + * @return array{types: array>, defaultTypeId: ?int, media: array, + * rules: array>, inherited: array>, + * globals: array, userCounts: array, effective: array} + */ + public static function viewData(mysqli $db): array + { + $policy = new CirculationPolicy($db); + $types = $policy->types(); + $media = self::mediaTypes(); + $globals = $policy->globalLayer(); + $rules = $policy->rules(); + + // What an empty cell resolves to: every layer below the cell itself. + $inherited = []; + $rows = array_merge([CirculationPolicy::ALL_TYPES], array_keys($types)); + foreach ($rows as $typeId) { + foreach (array_keys($media) as $m) { + if ($typeId === CirculationPolicy::ALL_TYPES) { + $resolved = \App\Services\Circulation\PolicyResolver::resolve([], $globals, \App\Services\Circulation\PolicyResolver::resolveUser(null, $globals), $m); + } else { + $layers = $policy->ruleLayers($typeId, $m); + array_shift($layers); // drop the cell's own rule + $resolved = \App\Services\Circulation\PolicyResolver::resolve($layers, $globals, \App\Services\Circulation\PolicyResolver::resolveUser(null, $globals), $m); + } + $inherited[$typeId . '|' . $m] = [ + 'loanable' => $resolved->loanable, + 'max' => $resolved->maxActiveForMedia, + 'duration' => $resolved->durationDays, + 'renewals' => $resolved->maxRenewals, + 'renewal_days' => $resolved->renewalDays, + ]; + } + } + + $effective = []; + foreach (array_keys($types) as $typeId) { + $effective[$typeId] = $policy->forType($typeId); + } + + $userCounts = []; + if ($policy->schemaReady()) { + $result = $db->query('SELECT tipologia_id, COUNT(*) AS c FROM utenti WHERE tipologia_id IS NOT NULL GROUP BY tipologia_id'); + if ($result instanceof \mysqli_result) { + while ($row = $result->fetch_assoc()) { + $userCounts[(int) $row['tipologia_id']] = (int) $row['c']; + } + } + } + + return [ + 'types' => $types, + 'defaultTypeId' => $policy->defaultTypeId(), + 'media' => $media, + 'rules' => $rules, + 'inherited' => $inherited, + 'globals' => $globals, + 'userCounts' => $userCounts, + 'effective' => $effective, + ]; + } + + /** @return array tipo_media => translated label */ + public static function mediaTypes(): array + { + $out = []; + foreach (MediaLabels::allTypes() as $key => $meta) { + $out[$key] = __($meta['label']); + } + return $out; + } + + /** + * Patron types and the rules matrix are library policy: administrators + * only (or a custom role with settings.manage), like the closures + * calendar. AdminAuthMiddleware admits staff too, so every action checks + * it inline. + */ + private function guard(Response $response): ?Response + { + if (\App\Support\Permissions::mayConfigure()) { + return null; + } + $_SESSION['error_message'] = __('Operazione riservata agli amministratori'); + return $response->withHeader('Location', url('/admin/dashboard'))->withStatus(302); + } + + public function index(Request $request, Response $response): Response + { + if ($guard = $this->guard($response)) { + return $guard; + } + return $response->withHeader('Location', url(self::TAB_URL))->withStatus(302); + } + + public function editForm(Request $request, Response $response, mysqli $db, int $id): Response + { + if ($guard = $this->guard($response)) { + return $guard; + } + $policy = new CirculationPolicy($db); + $type = $policy->types()[$id] ?? null; + if ($type === null) { + $_SESSION['error_message'] = __('Tipologia lettore non trovata.'); + return $response->withHeader('Location', url(self::TAB_URL))->withStatus(302); + } + $globals = $policy->globalLayer(); + $title = __('Modifica tipologia lettore'); + ob_start(); + require __DIR__ . '/../Views/settings/circulation-type-edit.php'; + $content = ob_get_clean(); + ob_start(); + require __DIR__ . '/../Views/layout.php'; + $html = ob_get_clean(); + $response->getBody()->write((string) $html); + return $response; + } + + public function createType(Request $request, Response $response, mysqli $db): Response + { + if ($guard = $this->guard($response)) { + return $guard; + } + $data = (array) ($request->getParsedBody() ?? []); + $fields = $this->typeFields($data, null); + if (is_string($fields)) { + $_SESSION['error_message'] = $fields; + return $response->withHeader('Location', url(self::TAB_URL))->withStatus(302); + } + if (!(new CirculationPolicy($db))->schemaReady()) { + $_SESSION['error_message'] = __('Le tabelle della circolazione non sono ancora state create: completa l\'aggiornamento del database.'); + return $response->withHeader('Location', url(self::TAB_URL))->withStatus(302); + } + if ($this->codeTaken($db, (string) $fields['codice'], null)) { + $_SESSION['error_message'] = __('Esiste già una tipologia con questo codice.'); + return $response->withHeader('Location', url(self::TAB_URL))->withStatus(302); + } + $orderResult = $db->query('SELECT COALESCE(MAX(ordinamento), 0) + 10 FROM tipologie_lettore'); + $fields['ordinamento'] = $orderResult instanceof \mysqli_result ? (int) ($orderResult->fetch_row()[0] ?? 10) : 10; + $makeDefault = !empty($data['predefinita']); + $fields['predefinita'] = 0; + try { + $db->begin_transaction(); + $id = $this->insert($db, 'tipologie_lettore', $fields); + if ($makeDefault) { + $this->setDefaultTx($db, $id); + } + $db->commit(); + $_SESSION['success_message'] = __('Tipologia lettore creata.'); + } catch (\Throwable $e) { + $db->rollback(); + SecureLogger::error('[Circulation] patron type create failed', ['error' => $e->getMessage()]); + $_SESSION['error_message'] = __('Impossibile salvare la tipologia lettore.'); + } + return $response->withHeader('Location', url(self::TAB_URL))->withStatus(302); + } + + public function updateType(Request $request, Response $response, mysqli $db, int $id): Response + { + if ($guard = $this->guard($response)) { + return $guard; + } + $data = (array) ($request->getParsedBody() ?? []); + $policy = new CirculationPolicy($db); + $current = $policy->types()[$id] ?? null; + if ($current === null) { + $_SESSION['error_message'] = __('Tipologia lettore non trovata.'); + return $response->withHeader('Location', url(self::TAB_URL))->withStatus(302); + } + $fields = $this->typeFields($data, $current); + if (is_string($fields)) { + $_SESSION['error_message'] = $fields; + return $response->withHeader('Location', url('/admin/settings/circulation/types/' . $id))->withStatus(302); + } + if ($this->codeTaken($db, (string) $fields['codice'], $id)) { + $_SESSION['error_message'] = __('Esiste già una tipologia con questo codice.'); + return $response->withHeader('Location', url('/admin/settings/circulation/types/' . $id))->withStatus(302); + } + // The default type stays active: new patrons are assigned to it. + if ((int) $current['predefinita'] === 1) { + $fields['attiva'] = 1; + } + try { + $db->begin_transaction(); + $this->update($db, 'tipologie_lettore', $fields, $id); + if (!empty($data['predefinita'])) { + $this->setDefaultTx($db, $id); + } + $db->commit(); + $_SESSION['success_message'] = __('Tipologia lettore aggiornata.'); + } catch (\Throwable $e) { + $db->rollback(); + SecureLogger::error('[Circulation] patron type update failed', ['id' => $id, 'error' => $e->getMessage()]); + $_SESSION['error_message'] = __('Impossibile salvare la tipologia lettore.'); + } + return $response->withHeader('Location', url(self::TAB_URL))->withStatus(302); + } + + public function deleteType(Request $request, Response $response, mysqli $db, int $id): Response + { + if ($guard = $this->guard($response)) { + return $guard; + } + $policy = new CirculationPolicy($db); + $type = $policy->types()[$id] ?? null; + if ($type === null) { + $_SESSION['error_message'] = __('Tipologia lettore non trovata.'); + } elseif ((int) $type['predefinita'] === 1) { + $_SESSION['error_message'] = __('La tipologia predefinita non può essere eliminata: scegli prima un\'altra predefinita.'); + } else { + try { + $db->begin_transaction(); + // Patrons of the removed type fall back to the default one + // (the FK is ON DELETE SET NULL; done explicitly for clarity). + $stmt = $db->prepare('UPDATE utenti SET tipologia_id = NULL WHERE tipologia_id = ?'); + $stmt->bind_param('i', $id); + $stmt->execute(); + $stmt->close(); + $stmt = $db->prepare('DELETE FROM regole_prestito WHERE tipologia_id = ?'); + $stmt->bind_param('i', $id); + $stmt->execute(); + $stmt->close(); + $stmt = $db->prepare('DELETE FROM tipologie_lettore WHERE id = ? AND predefinita = 0'); + $stmt->bind_param('i', $id); + $stmt->execute(); + $stmt->close(); + $db->commit(); + $_SESSION['success_message'] = __('Tipologia lettore eliminata.'); + } catch (\Throwable $e) { + $db->rollback(); + SecureLogger::error('[Circulation] patron type delete failed', ['id' => $id, 'error' => $e->getMessage()]); + $_SESSION['error_message'] = __('Impossibile eliminare la tipologia lettore.'); + } + } + return $response->withHeader('Location', url(self::TAB_URL))->withStatus(302); + } + + public function setDefault(Request $request, Response $response, mysqli $db, int $id): Response + { + if ($guard = $this->guard($response)) { + return $guard; + } + if (!isset((new CirculationPolicy($db))->types()[$id])) { + $_SESSION['error_message'] = __('Tipologia lettore non trovata.'); + return $response->withHeader('Location', url(self::TAB_URL))->withStatus(302); + } + try { + $db->begin_transaction(); + $this->setDefaultTx($db, $id); + $db->commit(); + $_SESSION['success_message'] = __('Tipologia predefinita aggiornata.'); + } catch (\Throwable $e) { + $db->rollback(); + SecureLogger::error('[Circulation] default patron type failed', ['id' => $id, 'error' => $e->getMessage()]); + $_SESSION['error_message'] = __('Impossibile salvare la tipologia lettore.'); + } + return $response->withHeader('Location', url(self::TAB_URL))->withStatus(302); + } + + /** Move a type one place up or down in the display order. */ + public function moveType(Request $request, Response $response, mysqli $db, int $id): Response + { + if ($guard = $this->guard($response)) { + return $guard; + } + $data = (array) ($request->getParsedBody() ?? []); + $direction = ($data['direction'] ?? '') === 'up' ? -1 : 1; + $ids = array_keys((new CirculationPolicy($db))->types()); + $pos = array_search($id, $ids, true); + if ($pos !== false && isset($ids[$pos + $direction])) { + $swap = $ids[$pos + $direction]; + $ids[$pos + $direction] = $id; + $ids[$pos] = $swap; + try { + $db->begin_transaction(); + $stmt = $db->prepare('UPDATE tipologie_lettore SET ordinamento = ? WHERE id = ?'); + foreach ($ids as $i => $typeId) { + $order = ($i + 1) * 10; + $stmt->bind_param('ii', $order, $typeId); + $stmt->execute(); + } + $stmt->close(); + $db->commit(); + } catch (\Throwable $e) { + $db->rollback(); + SecureLogger::error('[Circulation] patron type reorder failed', ['id' => $id, 'error' => $e->getMessage()]); + $_SESSION['error_message'] = __('Impossibile salvare la tipologia lettore.'); + } + } + return $response->withHeader('Location', url(self::TAB_URL))->withStatus(302); + } + + /** Save the whole matrix: an all-empty cell removes its rule. */ + public function saveRules(Request $request, Response $response, mysqli $db): Response + { + if ($guard = $this->guard($response)) { + return $guard; + } + $data = (array) ($request->getParsedBody() ?? []); + $policy = new CirculationPolicy($db); + if (!$policy->schemaReady()) { + $_SESSION['error_message'] = __('Le tabelle della circolazione non sono ancora state create: completa l\'aggiornamento del database.'); + return $response->withHeader('Location', url(self::TAB_URL))->withStatus(302); + } + $cells = is_array($data['rules'] ?? null) ? $data['rules'] : []; + $validTypes = array_merge([CirculationPolicy::ALL_TYPES], array_keys($policy->types())); + $media = array_keys(self::mediaTypes()); + $rows = []; + foreach ($validTypes as $typeId) { + foreach ($media as $m) { + $cell = $cells[$typeId][$m] ?? []; + $cell = is_array($cell) ? $cell : []; + $row = ['prestabile' => null]; + $loanable = (string) ($cell['loanable'] ?? ''); + if ($loanable === '1' || $loanable === '0') { + $row['prestabile'] = (int) $loanable; + } + foreach (self::RULE_FIELDS as $key => [$column, $min, $max]) { + $value = $this->intOrNull($cell[$key] ?? null, $min, $max); + if ($value === false) { + $_SESSION['error_message'] = sprintf(__('Valore non valido nella matrice (%s, %s).'), $typeId === 0 ? __('Tutte le tipologie') : (string) ($policy->types()[$typeId]['nome'] ?? $typeId), (string) (self::mediaTypes()[$m] ?? $m)); + return $response->withHeader('Location', url(self::TAB_URL))->withStatus(302); + } + $row[$column] = $value; + } + $rows[$typeId . '|' . $m] = ['tipologia_id' => $typeId, 'tipo_media' => $m] + $row; + } + } + try { + $db->begin_transaction(); + $delete = $db->prepare('DELETE FROM regole_prestito WHERE tipologia_id = ? AND tipo_media = ?'); + $upsert = $db->prepare( + 'INSERT INTO regole_prestito (tipologia_id, tipo_media, prestabile, max_prestiti, durata_prestito_giorni, max_rinnovi, giorni_rinnovo) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE prestabile = VALUES(prestabile), max_prestiti = VALUES(max_prestiti), + durata_prestito_giorni = VALUES(durata_prestito_giorni), max_rinnovi = VALUES(max_rinnovi), + giorni_rinnovo = VALUES(giorni_rinnovo)' + ); + $existing = $policy->rules(); + foreach ($rows as $key => $row) { + $typeId = (int) $row['tipologia_id']; + $m = (string) $row['tipo_media']; + $empty = $row['prestabile'] === null && $row['max_prestiti'] === null + && $row['durata_prestito_giorni'] === null && $row['max_rinnovi'] === null + && $row['giorni_rinnovo'] === null; + // Fine columns are not edited here yet: a cell that carries + // one is kept even when every edited field is emptied. + $hasOtherFields = isset($existing[$key]) && ( + $existing[$key]['multa_giornaliera'] !== null + || $existing[$key]['multa_massima_prestito'] !== null || $existing[$key]['giorni_tolleranza'] !== null + ); + if ($empty && !$hasOtherFields) { + $delete->bind_param('is', $typeId, $m); + $delete->execute(); + continue; + } + $loanable = $row['prestabile']; + $max = $row['max_prestiti']; + $duration = $row['durata_prestito_giorni']; + $renewals = $row['max_rinnovi']; + $renewalDays = $row['giorni_rinnovo']; + $upsert->bind_param('isiiiii', $typeId, $m, $loanable, $max, $duration, $renewals, $renewalDays); + $upsert->execute(); + } + $delete->close(); + $upsert->close(); + $db->commit(); + $_SESSION['success_message'] = __('Regole di circolazione salvate.'); + } catch (\Throwable $e) { + $db->rollback(); + SecureLogger::error('[Circulation] rules save failed', ['error' => $e->getMessage()]); + $_SESSION['error_message'] = __('Impossibile salvare le regole di circolazione.'); + } + return $response->withHeader('Location', url(self::TAB_URL . '#circulation-matrix'))->withStatus(302); + } + + // ─── Helpers ──────────────────────────────────────────────────────────── + + /** + * Validated column values of a patron type form, or the error message. + * + * @param array $data + * @param array|null $current + * @return array|string + */ + private function typeFields(array $data, ?array $current): array|string + { + $name = trim(strip_tags((string) ($data['nome'] ?? ''))); + if ($name === '' || mb_strlen($name) > 100) { + return __('Il nome della tipologia è obbligatorio (massimo 100 caratteri).'); + } + $code = strtolower(trim((string) ($data['codice'] ?? ''))); + if ($code === '') { + $code = $current !== null ? (string) $current['codice'] : self::slug($name); + } + if (preg_match('/^[a-z0-9][a-z0-9_-]{0,39}$/', $code) !== 1) { + return __('Il codice può contenere solo lettere minuscole, numeri, trattini e trattini bassi (massimo 40 caratteri).'); + } + $description = trim(strip_tags((string) ($data['descrizione'] ?? ''))); + $fields = [ + 'codice' => $code, + 'nome' => $name, + 'descrizione' => $description !== '' ? mb_substr($description, 0, 2000) : null, + 'prenotazioni_abilitate' => !empty($data['prenotazioni_abilitate']) ? 1 : 0, + 'attiva' => !array_key_exists('attiva', $data) || !empty($data['attiva']) ? 1 : 0, + ]; + foreach (self::TYPE_INT_FIELDS as $column => [$min, $max]) { + $value = $this->intOrNull($data[$column] ?? null, $min, $max); + if ($value === false) { + return sprintf(__('Valore non valido per il campo "%s".'), self::fieldLabel($column)); + } + $fields[$column] = $value; + } + return $fields; + } + + public static function fieldLabel(string $column): string + { + return match ($column) { + 'max_prestiti' => __('Prestiti attivi massimi'), + 'durata_prestito_giorni' => __('Durata prestito (giorni)'), + 'max_durata_richiesta_giorni' => __('Durata massima richiedibile (giorni)'), + 'max_rinnovi' => __('Rinnovi massimi'), + 'giorni_rinnovo' => __('Giorni per rinnovo'), + 'max_prenotazioni' => __('Prenotazioni massime'), + 'durata_tessera_giorni' => __('Durata tessera (giorni)'), + default => $column, + }; + } + + /** @return int|null|false null for an empty value, false when out of range or not an integer */ + private function intOrNull(mixed $raw, int $min, int $max): int|null|false + { + if ($raw === null || (is_string($raw) && trim($raw) === '')) { + return null; + } + if (!is_scalar($raw) || preg_match('/^-?\d+$/', trim((string) $raw)) !== 1) { + return false; + } + $value = (int) trim((string) $raw); + return $value < $min || $value > $max ? false : $value; + } + + private static function slug(string $name): string + { + $ascii = function_exists('iconv') ? (string) @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $name) : $name; + $slug = trim((string) preg_replace('/[^a-z0-9]+/', '-', strtolower($ascii)), '-'); + return $slug !== '' ? substr($slug, 0, 40) : 'tipologia-' . bin2hex(random_bytes(3)); + } + + private function codeTaken(mysqli $db, string $code, ?int $exceptId): bool + { + $stmt = $db->prepare('SELECT id FROM tipologie_lettore WHERE codice = ? AND id <> ?'); + $except = $exceptId ?? 0; + $stmt->bind_param('si', $code, $except); + $stmt->execute(); + $taken = $stmt->get_result()->fetch_row() !== null; + $stmt->close(); + return $taken; + } + + /** Exactly one default type: clear the flag everywhere, then set it (inside the caller's transaction). */ + private function setDefaultTx(mysqli $db, int $id): void + { + $db->query('UPDATE tipologie_lettore SET predefinita = 0 WHERE predefinita = 1'); + $stmt = $db->prepare('UPDATE tipologie_lettore SET predefinita = 1, attiva = 1 WHERE id = ?'); + $stmt->bind_param('i', $id); + $stmt->execute(); + $stmt->close(); + } + + /** @param array $fields */ + private function insert(mysqli $db, string $table, array $fields): int + { + $columns = array_keys($fields); + $sql = 'INSERT INTO ' . $table . ' (' . implode(', ', $columns) . ') VALUES (' . implode(', ', array_fill(0, count($columns), '?')) . ')'; + $stmt = $db->prepare($sql); + $values = array_values($fields); + $stmt->bind_param(str_repeat('s', count($values)), ...$values); + $stmt->execute(); + $id = (int) $db->insert_id; + $stmt->close(); + return $id; + } + + /** @param array $fields */ + private function update(mysqli $db, string $table, array $fields, int $id): void + { + $set = implode(', ', array_map(static fn (string $c): string => $c . ' = ?', array_keys($fields))); + $stmt = $db->prepare('UPDATE ' . $table . ' SET ' . $set . ' WHERE id = ?'); + $values = array_values($fields); + $values[] = $id; + $stmt->bind_param(str_repeat('s', count($values) - 1) . 'i', ...$values); + $stmt->execute(); + $stmt->close(); + } +} diff --git a/app/Controllers/CopyController.php b/app/Controllers/CopyController.php index 7f5b655af..468bf7dd1 100644 --- a/app/Controllers/CopyController.php +++ b/app/Controllers/CopyController.php @@ -345,6 +345,107 @@ public function updateCopy(Request $request, Response $response, mysqli $db, int return $response->withHeader('Location', url($this->adminBookPath($libroId)))->withStatus(302); } + /** + * Mark a copy as kept for in-house consultation (never lent) or make it + * lendable again (copie.solo_consultazione). Refused while the copy is + * held by a loan, a pickup or a copy-bound request: those commitments + * were promised on this copy. Making it lendable promotes the queue. + */ + public function toggleConsultation(Request $request, Response $response, mysqli $db, int $copyId): Response + { + $data = (array) $request->getParsedBody(); + $consultation = ($data['solo_consultazione'] ?? '') === '1' ? 1 : 0; + $policy = new \App\Services\CirculationPolicy($db); + if (!$policy->schemaReady()) { + $_SESSION['error_message'] = __('Le tabelle della circolazione non sono ancora state create: completa l\'aggiornamento del database.'); + return $response->withHeader('Location', $this->safeReferer())->withStatus(302); + } + $stmt = $db->prepare('SELECT libro_id, numero_inventario, solo_consultazione FROM copie WHERE id = ?'); + $stmt->bind_param('i', $copyId); + $stmt->execute(); + $copy = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + if (!$copy) { + $_SESSION['error_message'] = __('Copia non trovata.'); + return $response->withHeader('Location', $this->safeReferer())->withStatus(302); + } + $libroId = (int) $copy['libro_id']; + if ((int) $copy['solo_consultazione'] === $consultation) { + return $response->withHeader('Location', url($this->adminBookPath($libroId)))->withStatus(302); + } + + $db->begin_transaction(); + try { + // Canonical lock order: the book row first, then the copy. + $lockBook = $db->prepare('SELECT id FROM libri WHERE id = ? AND deleted_at IS NULL FOR UPDATE'); + $lockBook->bind_param('i', $libroId); + $lockBook->execute(); + $bookLocked = (bool) $lockBook->get_result()->fetch_row(); + $lockBook->close(); + if (!$bookLocked) { + $db->rollback(); + $_SESSION['error_message'] = __('Libro non trovato o non più disponibile.'); + return $response->withHeader('Location', $this->safeReferer())->withStatus(302); + } + $lockCopy = $db->prepare('SELECT id FROM copie WHERE id = ? FOR UPDATE'); + $lockCopy->bind_param('i', $copyId); + $lockCopy->execute(); + $lockCopy->close(); + if ($consultation === 1 && $this->isCopyHeld($db, $copyId)) { + $db->rollback(); + $_SESSION['error_message'] = __('La copia è impegnata da un prestito, un ritiro o una richiesta: chiudili prima di riservarla alla consultazione.'); + return $response->withHeader('Location', url($this->adminBookPath($libroId)))->withStatus(302); + } + $update = $db->prepare('UPDATE copie SET solo_consultazione = ? WHERE id = ?'); + $update->bind_param('ii', $consultation, $copyId); + $update->execute(); + $update->close(); + + $reservationManager = null; + if ($consultation === 0) { + // One more lendable copy: promote every reservation it can serve. + $reservationManager = new \App\Controllers\ReservationManager($db); + $reservationManager->setExternalTransaction(true); + for ($guard = 0; $guard < 1000 && $reservationManager->processBookAvailability($libroId); $guard++) { + // Promote every date-eligible reservation allowed by capacity. + } + } + $integrity = new \App\Support\DataIntegrity($db); + if (!$integrity->recalculateBookAvailability($libroId, insideTransaction: true)) { + throw new \RuntimeException('Impossibile ricalcolare la disponibilità del libro.'); + } + $db->commit(); + if ($reservationManager !== null) { + try { + $reservationManager->flushDeferredNotifications(); + } catch (\Throwable $e) { + SecureLogger::warning(__('Invio notifica cambio stato copia fallito'), ['error' => $e->getMessage()]); + } + } + } catch (\Throwable $e) { + $db->rollback(); + SecureLogger::error('[Copies] consultation flag update failed', ['copia_id' => $copyId, 'error' => $e->getMessage()]); + $_SESSION['error_message'] = __('Impossibile aggiornare la copia senza lasciare dati incoerenti. Nessuna modifica è stata salvata.'); + return $response->withHeader('Location', url($this->adminBookPath($libroId)))->withStatus(302); + } + + \App\Support\ActivityLog::recordBookEvent( + $db, + $libroId, + 'aggiornamento', + 'copy', + 'copy.updated', + ['numero_inventario' => $copy['numero_inventario'] ?? null, 'solo_consultazione' => (int) $copy['solo_consultazione']], + ['numero_inventario' => $copy['numero_inventario'] ?? null, 'solo_consultazione' => $consultation], + entityId: $copyId, + source: 'manual' + ); + $_SESSION['success_message'] = $consultation === 1 + ? __('La copia è ora solo in consultazione.') + : __('La copia è di nuovo prestabile.'); + return $response->withHeader('Location', url($this->adminBookPath($libroId)))->withStatus(302); + } + /** * Crea una o più copie fisiche per un libro, direttamente dalla scheda. * diff --git a/app/Controllers/FrontendController.php b/app/Controllers/FrontendController.php index c90564cc7..7f1cc894a 100644 --- a/app/Controllers/FrontendController.php +++ b/app/Controllers/FrontendController.php @@ -931,8 +931,18 @@ static function () use ($db, $book_id): array { // default to max_loan_duration_days, so expose the same effective value // to the view instead of hardcoding one calendar month in JavaScript. $loanSettings = new \App\Models\SettingsRepository($db); - $maxRequestDays = $loanSettings->maxLoanDurationDays(); - $defaultRequestLoanDays = $loanSettings->requestLoanDurationDays(); + $circulation = new \App\Services\CirculationPolicy($db, $loanSettings); + // The viewer's circulation rules for this title (default patron type + // when anonymous): consultation-only material shows no request button, + // a patron type without reservations no "reserve when available". + $viewerPolicy = $circulation->forViewer( + !empty($_SESSION['user']['id']) ? (int) $_SESSION['user']['id'] : null, + $book_id + ); + $consultationOnly = !$viewerPolicy->loanable; + $viewerReservationsEnabled = $viewerPolicy->user->reservationsEnabled; + $maxRequestDays = $viewerPolicy->maxRequestDays; + $defaultRequestLoanDays = $viewerPolicy->requestDurationDays(); // Days to collect a booked loan once it starts: the scheduled-loan // message names them instead of a vague "when the date approaches". $pickupExpiryDays = max(1, (int) ($loanSettings->get('loans', 'pickup_expiry_days', '3') ?? 3)); diff --git a/app/Controllers/LibriController.php b/app/Controllers/LibriController.php index 5f257cf24..46b314297 100644 --- a/app/Controllers/LibriController.php +++ b/app/Controllers/LibriController.php @@ -563,15 +563,15 @@ public function show(Request $request, Response $response, mysqli $db, int $id): // Renewal cap is admin-configurable (loans.max_renewals); pass it to the // view so the displayed limit and the "Renew" button gate match the // server-side check in PrestitiController::renew (#157). - $loanSettingsRepo = new \App\Models\SettingsRepository($db); - // Validate numeric BEFORE casting: a non-numeric stored value (e.g. "abc") - // would cast to 0, silently flipping the cap to "no renewals" instead of - // falling back to the safe default. Only a genuine number is honoured. - $rawMaxRenewals = $loanSettingsRepo->get('loans', 'max_renewals', '3'); - $maxRenewals = is_numeric($rawMaxRenewals) ? (int) $rawMaxRenewals : 3; - if ($maxRenewals < 0) { - $maxRenewals = 3; - } + // The limit is the one of the active loan's patron and material + // (CirculationPolicy, which also validates a non-numeric stored value + // back to the default 3), the global one when nothing is on loan. + $circulation = new \App\Services\CirculationPolicy($db); + $activeLoanPolicy = !empty($activeLoan['id']) + ? $circulation->forExistingLoan((int) $activeLoan['id']) + : $circulation->defaults(); + $maxRenewals = $activeLoanPolicy->maxRenewals; + $renewalDays = $activeLoanPolicy->renewalDays; // Issue #374: the audit timeline is staff-only. This route is also // reachable by patrons, so do not even query audit/operator data unless diff --git a/app/Controllers/LoanApprovalController.php b/app/Controllers/LoanApprovalController.php index b1f672982..7f86728f3 100644 --- a/app/Controllers/LoanApprovalController.php +++ b/app/Controllers/LoanApprovalController.php @@ -336,8 +336,9 @@ public function approveLoan(Request $request, Response $response, mysqli $db): R // applicato solo alla creazione/richiesta, ma approvando più richieste pendenti // su libri diversi un admin poteva superarlo. Conta i prestiti attivi escluso // quello in approvazione; il lock sulla riga libro sopra serializza. - $maxLoans = (int) ((new \App\Models\SettingsRepository($db))->get('loans', 'max_active_loans_per_user', '0') ?? 0); - if ($maxLoans > 0) { + $circulation = new \App\Services\CirculationPolicy($db); + $approvalPolicy = $circulation->forExistingLoan($loanId); + if (!$approvalPolicy->loanable || $approvalPolicy->hasQuantityLimits()) { // Serialize concurrent approvals for the SAME user on DIFFERENT books: // the libri lock above only serializes this loan's book, so two // parallel approvals could both read activeCount below the cap and @@ -347,16 +348,15 @@ public function approveLoan(Request $request, Response $response, mysqli $db): R $userLockStmt->execute(); $userLockStmt->close(); - $cntStmt = $db->prepare("SELECT COUNT(*) AS c FROM prestiti WHERE utente_id = ? AND id != ? AND attivo = 1 AND stato IN ('prenotato','da_ritirare','in_corso','in_ritardo')"); - $cntStmt->bind_param('ii', $utenteId, $loanId); - $cntStmt->execute(); - $activeCount = (int) ($cntStmt->get_result()->fetch_assoc()['c'] ?? 0); - $cntStmt->close(); - if ($activeCount >= $maxLoans) { + $limitError = $circulation->loanLimitError($approvalPolicy, $utenteId, $loanId); + if ($limitError !== null) { $db->rollback(); $response->getBody()->write(json_encode([ 'success' => false, - 'message' => __('L\'utente ha raggiunto il numero massimo di prestiti attivi consentiti') + 'error' => $limitError, + 'message' => $limitError === 'max_loans_reached' + ? __('L\'utente ha raggiunto il numero massimo di prestiti attivi consentiti') + : \App\Support\LoanEligibility::errorMessage($limitError), ])); return $response->withHeader('Content-Type', 'application/json')->withStatus(409); } @@ -399,6 +399,7 @@ public function approveLoan(Request $request, Response $response, mysqli $db): R WHERE c.id = ? AND c.libro_id = ? AND c.stato NOT IN ('perso', 'danneggiato', 'manutenzione', 'in_restauro', 'in_trasferimento') + AND " . (new \App\Services\CirculationPolicy($db))->lendableCopySql('c') . " AND NOT EXISTS ( SELECT 1 FROM prestiti p WHERE p.copia_id = c.id @@ -455,6 +456,7 @@ public function approveLoan(Request $request, Response $response, mysqli $db): R SELECT c.id FROM copie c WHERE c.libro_id = ? AND c.stato IN ('disponibile', 'prenotato') + AND " . (new \App\Services\CirculationPolicy($db))->lendableCopySql('c') . " AND NOT EXISTS ( SELECT 1 FROM prestiti own WHERE own.copia_id = c.id diff --git a/app/Controllers/PrestitiController.php b/app/Controllers/PrestitiController.php index 28cdc5644..df042e785 100644 --- a/app/Controllers/PrestitiController.php +++ b/app/Controllers/PrestitiController.php @@ -187,9 +187,9 @@ public function createForm(Request $request, Response $response, mysqli $db): Re // mezzanotte, e il '+1 month' della view divergeva dal default server (30gg). $defaultDataPrestito = \App\Support\DateHelper::today(); $loanSettings = new \App\Models\SettingsRepository($db); - $defaultLoanDays = $loanSettings->loanDurationDays(); + $defaultLoanDays = (new \App\Services\CirculationPolicy($db, $loanSettings))->defaults()->durationDays; $allowMultipleLoansSameBook = $loanSettings->allowsMultipleLoansSameBook(); - $defaultDataScadenza = date('Y-m-d', strtotime($defaultDataPrestito . " +{$defaultLoanDays} days")); + $defaultDataScadenza = \App\Services\CirculationPolicy::dueDateFor($defaultDataPrestito, $defaultLoanDays); $loanSubmissionToken = \App\Support\OneTimeFormToken::issue('loan.create'); ob_start(); @@ -267,13 +267,14 @@ public function store(Request $request, Response $response, mysqli $db): Respons return $response->withHeader('Location', url('/admin/loans/create') . '?error=invalid_date_format')->withStatus(302); } if (empty($data_scadenza)) { - // Default loan duration read from admin settings (fallback: 30 days) - $loanDays = (new \App\Models\SettingsRepository($db))->loanDurationDays(); + // 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; $startDate = \DateTimeImmutable::createFromFormat('!Y-m-d', $data_prestito); if ($startDate === false) { return $response->withHeader('Location', url('/admin/loans/create') . '?error=invalid_date_format')->withStatus(302); } - $data_scadenza = $startDate->modify("+{$loanDays} days")->format('Y-m-d'); + $data_scadenza = \App\Services\CirculationPolicy::dueDateFor($startDate->format('Y-m-d'), $loanDays); } if ($utente_id <= 0 || $libro_id <= 0) { @@ -386,19 +387,14 @@ public function store(Request $request, Response $response, mysqli $db): Respons return $response->withHeader('Location', url('/admin/loans/create') . '?error=' . $eligibilityError)->withStatus(302); } - // Enforce max active loans per user (admin setting; 0 = no limit) - $maxLoans = (int) ((new \App\Models\SettingsRepository($db))->get('loans', 'max_active_loans_per_user', '0') ?? 0); - if ($maxLoans > 0) { - $cntStmt = $db->prepare("SELECT COUNT(*) FROM prestiti WHERE utente_id = ? AND attivo = 1 AND stato IN ('prenotato','da_ritirare','in_corso','in_ritardo')"); - $cntStmt->bind_param('i', $utente_id); - $cntStmt->execute(); - $cntResult = $cntStmt->get_result(); - $activeCount = (int) ($cntResult ? $cntResult->fetch_row()[0] : 0); - $cntStmt->close(); - if ($activeCount >= $maxLoans) { - $db->rollback(); - return $response->withHeader('Location', url('/admin/loans/create') . '?error=max_loans_reached')->withStatus(302); - } + // Circulation rules of this patron and title (CirculationPolicy): + // loanability and active-loan limits (0 = no limit), counted under + // the user lock above. + $circulation = new \App\Services\CirculationPolicy($db); + $limitError = $circulation->loanLimitError($circulation->forLoan($utente_id, $libro_id), $utente_id); + if ($limitError !== null) { + $db->rollback(); + return $response->withHeader('Location', url('/admin/loans/create') . '?error=' . $limitError)->withStatus(302); } // Check if loan starts today (immediate loan) or in the future (scheduled loan). @@ -451,6 +447,12 @@ public function store(Request $request, Response $response, mysqli $db): Respons $forcedCopyId = (int) $codeRow['id']; + // A copy kept for in-house consultation is never lent. + if ((new \App\Services\CirculationPolicy($db))->isConsultationCopy($forcedCopyId)) { + $db->rollback(); + return $response->withHeader('Location', url('/admin/loans/create') . '?error=not_loanable')->withStatus(302); + } + // Multiple open rows for one borrower/title must represent // different physical items even when their date windows do not // overlap. The book lock makes this early snapshot race-safe. @@ -519,6 +521,7 @@ public function store(Request $request, Response $response, mysqli $db): Respons SELECT c.id FROM copie c WHERE c.libro_id = ? AND c.stato NOT IN ('perso', 'danneggiato', 'manutenzione', 'in_restauro', 'in_trasferimento') + AND " . (new \App\Services\CirculationPolicy($db))->lendableCopySql('c') . " AND NOT EXISTS ( SELECT 1 FROM prestiti own WHERE own.copia_id = c.id @@ -1060,18 +1063,11 @@ public function update(Request $request, Response $response, mysqli $db, int $id // Cap prestiti attivi del NUOVO utente (M6): stesso enforcement di // store() — senza, la riassegnazione admin aggira silenziosamente // max_active_loans_per_user. - $maxLoans = (int) ((new \App\Models\SettingsRepository($db))->get('loans', 'max_active_loans_per_user', '0') ?? 0); - if ($maxLoans > 0) { - $cntStmt = $db->prepare("SELECT COUNT(*) FROM prestiti WHERE utente_id = ? AND attivo = 1 AND stato IN ('prenotato','da_ritirare','in_corso','in_ritardo')"); - $cntStmt->bind_param('i', $newUserId); - $cntStmt->execute(); - $cntResult = $cntStmt->get_result(); - $activeCount = (int) ($cntResult ? $cntResult->fetch_row()[0] : 0); - $cntStmt->close(); - if ($activeCount >= $maxLoans) { - $db->rollback(); - return $response->withHeader('Location', url('/admin/loans') . '?error=max_loans_reached')->withStatus(302); - } + $circulation = new \App\Services\CirculationPolicy($db); + $limitError = $circulation->loanLimitError($circulation->forExistingLoan($id, $newUserId), $newUserId); + if ($limitError !== null) { + $db->rollback(); + return $response->withHeader('Location', url('/admin/loans') . '?error=' . $limitError)->withStatus(302); } } @@ -1298,10 +1294,7 @@ public function update(Request $request, Response $response, mysqli $db, int $id if ($newScadenza > $oldScadenza && in_array((string) $locked['stato'], ['in_corso', 'in_ritardo'], true)) { try { - $maxRenewals = (int) ((new \App\Models\SettingsRepository($db))->get('loans', 'max_renewals', '3') ?? 3); - if ($maxRenewals < 0) { - $maxRenewals = 3; - } + $maxRenewals = (new \App\Services\CirculationPolicy($db))->forExistingLoan($id)->maxRenewals; (new NotificationService($db))->sendLoanRenewedNotification($id, $maxRenewals); } catch (\Throwable $notifError) { SecureLogger::warning('Loan update renewal notification failed', ['loan_id' => $id, 'error' => $notifError->getMessage()]); @@ -2032,15 +2025,11 @@ public function bulkExtend(Request $request, Response $response, mysqli $db): Re // PER prestito: un invio fallito non ferma né il batch (già durevole) // né le conferme degli altri prestiti. if ($extendedLoanIds !== []) { - $settingsRepo = new \App\Models\SettingsRepository($db); - $maxRenewals = (int) ($settingsRepo->get('loans', 'max_renewals', '3') ?? 3); - if ($maxRenewals < 0) { - $maxRenewals = 3; - } + $circulation = new \App\Services\CirculationPolicy($db); $notifier = new NotificationService($db); foreach ($extendedLoanIds as $extendedLoanId) { try { - $notifier->sendLoanRenewedNotification($extendedLoanId, $maxRenewals); + $notifier->sendLoanRenewedNotification($extendedLoanId, $circulation->forExistingLoan($extendedLoanId)->maxRenewals); } catch (\Throwable $notifError) { SecureLogger::warning('Bulk extend renewal notification failed', ['loan_id' => $extendedLoanId, 'error' => $notifError->getMessage()]); } @@ -2202,12 +2191,11 @@ public function renew(Request $request, Response $response, mysqli $db, int $id) return $response->withHeader('Location', url($errorUrl . $separator . 'error=' . $eligibilityError))->withStatus(302); } - // Check renewal limit (max_renewals configurabile, default 3) — F2 - $settingsRepo = new \App\Models\SettingsRepository($db); - $maxRenewals = (int) ($settingsRepo->get('loans', 'max_renewals', '3') ?? 3); - if ($maxRenewals < 0) { - $maxRenewals = 3; - } + // Check renewal limit (max_renewals configurabile, default 3) — F2. + // Limit and renewal length come from the circulation rules of this + // loan's patron and material (CirculationPolicy). + $renewPolicy = (new \App\Services\CirculationPolicy($db))->forExistingLoan($id); + $maxRenewals = $renewPolicy->maxRenewals; $currentRenewals = (int) $loan['renewals']; if ($currentRenewals >= $maxRenewals) { $errorUrl = $redirectTo ?? url('/admin/loans'); @@ -2217,11 +2205,11 @@ public function renew(Request $request, Response $response, mysqli $db, int $id) // Durata del rinnovo dalla setting di durata prestito (M5b): il vecchio // '+14 days' hardcoded ignorava la configurazione dell'admin. - $renewDays = $settingsRepo->loanDurationDays(); + $renewDays = $renewPolicy->renewalDays; // Calculate proposed new due date for conflict checking $currentDueDate = $loan['data_scadenza']; - $proposedNewDueDate = date('Y-m-d', strtotime($currentDueDate . " +{$renewDays} days")); + $proposedNewDueDate = \App\Services\CirculationPolicy::dueDateFor((string) $currentDueDate, $renewDays); // 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 @@ -2308,7 +2296,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 = date('Y-m-d', strtotime($currentDueDate . " +{$renewDays} days")); + $proposedNewDueDate = \App\Services\CirculationPolicy::dueDateFor((string) $currentDueDate, $renewDays); if ($requestedDue !== '') { // Re-bound under the lock: the due date may have moved meanwhile. if ($requestedDue <= (string) $currentDueDate || $requestedDue > $proposedNewDueDate) { diff --git a/app/Controllers/RegistrationController.php b/app/Controllers/RegistrationController.php index e4b6ad529..d3a35a5fe 100644 --- a/app/Controllers/RegistrationController.php +++ b/app/Controllers/RegistrationController.php @@ -181,7 +181,10 @@ public function register(Request $request, Response $response, mysqli $db): Resp // Ensure timezone consistency BEFORE generating dates $db->query("SET SESSION time_zone = '+00:00'"); - $data_scadenza_tessera = gmdate('Y-m-d', strtotime('+5 years')); // Scadenza tessera tra 5 anni in UTC + // Card validity: the default patron type's card duration when it sets + // one (circulation policy), otherwise the historical 5 years. + $data_scadenza_tessera = \App\Support\PatronTypeAssignment::automaticCardExpiry($db, null) + ?? gmdate('Y-m-d', strtotime('+5 years')); // Scadenza tessera tra 5 anni in UTC $data_scadenza_token = gmdate('Y-m-d H:i:s', time() + 24 * 60 * 60); // Token scade tra 24 ore in UTC // GDPR: Record privacy acceptance timestamp diff --git a/app/Controllers/ReservationManager.php b/app/Controllers/ReservationManager.php index edd4349b0..8b9a8d153 100644 --- a/app/Controllers/ReservationManager.php +++ b/app/Controllers/ReservationManager.php @@ -160,8 +160,8 @@ public function processBookAvailability($bookId) // App-timezone "today" (not PHP UTC) so MySQL-stored dates and the // eligibility comparison agree across the midnight boundary (#157). $today = \App\Support\DateHelper::today(); - $maxLoans = max(0, (int) ((new \App\Models\SettingsRepository($this->db)) - ->get('loans', 'max_active_loans_per_user', '0') ?? 0)); + $circulation = new \App\Services\CirculationPolicy($this->db); + $limitSql = $circulation->totalLoanLimitSql('u'); $ownTransaction = $this->beginTransactionIfNeeded(); try { @@ -202,25 +202,33 @@ public function processBookAvailability($bookId) // irraggiungibile il primo candidato idoneo. Il predicato SQL cerca // direttamente il primo idoneo in FIFO; checkUser() rimane il gate // autorevole sotto la stessa transazione. + // Patrons the per-material rules refuse (not loanable for their + // type, or already at the limit for this material) are skipped + // like those at their total limit; CapacityService leaves the + // same reservations out of the occupied capacity. + $materialRefused = $circulation->materialRefusedReservationUserIds((int) $bookId); + $materialRefusedSql = $materialRefused === [] + ? '' + : ' AND r.utente_id NOT IN (' . implode(',', array_map('intval', $materialRefused)) . ')'; $stmt = $this->db->prepare(" SELECT r.*, u.email, u.nome, u.cognome FROM prenotazioni r JOIN utenti u ON r.utente_id = u.id WHERE r.libro_id = ? AND r.stato = 'attiva' AND " . \App\Support\LoanEligibility::promotableReservationWhere('r') . " - AND " . \App\Support\LoanEligibility::eligibleUserWhere('u') . " - AND (? <= 0 OR ( + AND " . \App\Support\LoanEligibility::eligibleUserWhere('u') . "{$materialRefusedSql} + AND ({$limitSql} <= 0 OR ( SELECT COUNT(*) FROM prestiti cap WHERE cap.utente_id = r.utente_id AND cap.attivo = 1 - AND cap.stato IN ('prenotato','da_ritirare','in_corso','in_ritardo') - ) < ?) + AND cap.stato IN (" . \App\Services\CirculationPolicy::ACTIVE_LOAN_STATES_SQL . ") + ) < {$limitSql}) ORDER BY r.queue_position ASC LIMIT 1 FOR UPDATE "); - $stmt->bind_param('isssii', $bookId, $today, $today, $today, $maxLoans, $maxLoans); + $stmt->bind_param('isss', $bookId, $today, $today, $today); $stmt->execute(); $result = $stmt->get_result(); $nextReservation = $result ? $result->fetch_assoc() : null; @@ -244,21 +252,15 @@ public function processBookAvailability($bookId) 'motivo' => $eligibilityError, ]); $nextReservation = null; - } elseif ($maxLoans > 0) { + } else { // The SQL predicate avoids head-of-line blocking; this // second check under the user lock closes the race with - // approvals for other books. - $countStmt = $this->db->prepare(" - SELECT COUNT(*) AS c - FROM prestiti - WHERE utente_id = ? AND attivo = 1 - AND stato IN ('prenotato','da_ritirare','in_corso','in_ritardo') - "); - $countStmt->bind_param('i', $promotedUserId); - $countStmt->execute(); - $activeCount = (int) ($countStmt->get_result()->fetch_assoc()['c'] ?? 0); - $countStmt->close(); - if ($activeCount >= $maxLoans) { + // approvals for other books (CirculationPolicy limits). + $limitError = $circulation->loanLimitError( + $circulation->forLoan($promotedUserId, (int) $bookId), + $promotedUserId + ); + if ($limitError !== null) { $nextReservation = null; } } @@ -494,6 +496,7 @@ private function createLoanFromReservation($reservation) SELECT c.id FROM copie c WHERE c.libro_id = ? AND c.stato IN ('disponibile', 'prenotato') + AND " . (new \App\Services\CirculationPolicy($this->db))->lendableCopySql('c') . " AND NOT EXISTS ( SELECT 1 FROM prestiti own WHERE own.copia_id = c.id @@ -932,6 +935,7 @@ public function isBookAvailableForImmediateLoan($bookId, ?string $startDate = nu $totalStmt = $this->db->prepare(" SELECT COUNT(*) as total FROM copie WHERE libro_id = ? AND stato NOT IN ('perso', 'danneggiato', 'manutenzione', 'in_restauro', 'in_trasferimento') + AND " . (new \App\Services\CirculationPolicy($this->db))->lendableCopySql('') . " "); $totalStmt->bind_param('i', $bookId); $totalStmt->execute(); diff --git a/app/Controllers/ReservationsAdminController.php b/app/Controllers/ReservationsAdminController.php index 6a1810f73..d031ad7f1 100644 --- a/app/Controllers/ReservationsAdminController.php +++ b/app/Controllers/ReservationsAdminController.php @@ -135,8 +135,8 @@ public function update(Request $request, Response $response, mysqli $db, int $id $start = $today; } if ($end === '') { - $loanDays = $this->loanDurationDays($db); - $end = (new \DateTimeImmutable($start))->modify("+{$loanDays} days")->format('Y-m-d'); + $loanDays = (new \App\Services\CirculationPolicy($db))->forReservation($id)->durationDays; + $end = \App\Services\CirculationPolicy::dueDateFor($start, $loanDays); } // Normalize an inverted range on the reservation dates too (form can post // end < start), mirroring the data_*_richiesta clamp below (#252). @@ -454,7 +454,7 @@ public function createForm(Request $request, Response $response, mysqli $db): Re } } - $defaultLoanDays = $this->loanDurationDays($db); + $defaultLoanDays = (new \App\Services\CirculationPolicy($db))->defaults()->durationDays; ob_start(); $title = "Crea Prenotazione - Admin"; @@ -513,12 +513,10 @@ public function store(Request $request, Response $response, mysqli $db): Respons if (empty($dataScadenza)) { // Durata di default allineata all'impostazione admin loan_duration_days // (stesso valore mostrato dalla view), non più 30 giorni hardcoded. - $loanDays = $this->loanDurationDays($db); + $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 = (new \DateTimeImmutable($dataPrenotazioneDate)) - ->modify("+{$loanDays} days") - ->format('Y-m-d'); + $dataScadenzaDate = \App\Services\CirculationPolicy::dueDateFor($dataPrenotazioneDate, $loanDays); $dataScadenza = $dataScadenzaDate . ' 23:59:59'; } else { $dataScadenzaDate = $dataScadenza; // Keep date only for loan period @@ -603,6 +601,19 @@ public function store(Request $request, Response $response, mysqli $db): Respons return $response->withHeader('Location', url('/admin/reservations/create') . '?error=' . rawurlencode($eligibilityError))->withStatus(302); } + // Circulation rules of the patron type, under the user lock. + // Consultation-only material is never queued; the reservation + // limits can be overridden by the staff with an explicit flag. + $circulation = new \App\Services\CirculationPolicy($db); + $reservationPolicy = $circulation->forLoan($utenteId, $libroId); + $policyError = !$reservationPolicy->loanable + ? 'not_loanable' + : (($data['force_limits'] ?? '') === '1' ? null : $circulation->reservationLimitError($reservationPolicy->user, $utenteId, queue: true)); + if ($policyError !== null) { + $db->rollback(); + return $response->withHeader('Location', url('/admin/reservations/create') . '?error=' . $policyError)->withStatus(302); + } + // Capacity gate (the decision): a waitlist reservation occupies one // capacity unit for its promised period, so reject it when the book is // already at capacity for that window (counting other commitments). @@ -660,17 +671,6 @@ public function store(Request $request, Response $response, mysqli $db): Respons } } - /** - * The configured loan duration in days (loans.loan_duration_days), with the - * canonical fallback to 30 for a missing or non-positive value. Centralizes - * the lookup previously duplicated in update(), store() and createForm(). - */ - private function loanDurationDays(mysqli $db): int - { - $days = (int) ((new \App\Models\SettingsRepository($db))->get('loans', 'loan_duration_days', '30') ?? 30); - return $days > 0 ? $days : 30; - } - private function reorderQueuePositions(mysqli $db, int $libroId): void { $stmt = $db->prepare(" diff --git a/app/Controllers/ReservationsController.php b/app/Controllers/ReservationsController.php index eb30a05e3..52a682b14 100644 --- a/app/Controllers/ReservationsController.php +++ b/app/Controllers/ReservationsController.php @@ -297,15 +297,13 @@ public function createReservation($request, $response, $args) // Configurable duration cap (anti-DoS): applies both to an explicit // end_date and to the default end computed from loan_duration_days. - $settings = new \App\Models\SettingsRepository($this->db); - $maxDurationDays = $settings->maxLoanDurationDays(); + $circulation = new \App\Services\CirculationPolicy($this->db); + $requestPolicy = $circulation->forLoan($userId, (int) $bookId); + $maxDurationDays = $requestPolicy->maxRequestDays; // If no end date specified, set it using the configured loan duration (fallback: 30 days) if (!$endDate) { - $loanDays = $settings->requestLoanDurationDays(); - $endDateTime = new DateTime($startDate); - $endDateTime->modify("+{$loanDays} days"); - $endDate = $endDateTime->format('Y-m-d'); + $endDate = \App\Services\CirculationPolicy::dueDateFor((string) $startDate, $requestPolicy->requestDurationDays()); } $requestedDurationDays = (int) (new DateTime($startDate))->diff(new DateTime($endDate))->days; @@ -410,11 +408,39 @@ public function createReservation($request, $response, $args) // capacity-free pending loan be created. The gate runs inside THIS // transaction (inCallerTransaction: true) under the canonical book // lock taken above. + // Circulation rules of the patron type, under the user lock: + // consultation-only material is neither lent nor queued, and the + // reservation limit counts queue reservations plus requests + // awaiting approval (CirculationPolicy). + $policyError = !$requestPolicy->loanable + ? 'not_loanable' + : $circulation->reservationLimitError($requestPolicy->user, $userId, queue: false); + if ($policyError !== null) { + $this->db->rollback(); + $response->getBody()->write(json_encode([ + 'success' => false, + 'error' => $policyError, + 'message' => \App\Support\LoanEligibility::errorMessage($policyError), + ])); + return $response->withHeader('Content-Type', 'application/json')->withStatus(409); + } + $routing = (new \App\Services\LoanRequestGate($this->db)) ->route($bookId, $userId, $startDate, $endDate, inCallerTransaction: true); $assignableCopyId = $routing->assignableCopyId; if ($routing->isReservation()) { + // The gate queued the request: refused for a patron type + // without reservations (the row is rolled back with the rest). + if (!$requestPolicy->user->reservationsEnabled) { + $this->db->rollback(); + $response->getBody()->write(json_encode([ + 'success' => false, + 'error' => 'reservations_disabled', + 'message' => \App\Support\LoanEligibility::errorMessage('reservations_disabled'), + ])); + return $response->withHeader('Content-Type', 'application/json')->withStatus(409); + } $reservationId = (int) $routing->reservationId; $this->db->commit(); @@ -433,19 +459,17 @@ public function createReservation($request, $response, $args) // A real waitlist reservation is intentionally handled above: like // UserActionsController::reserve(), it does not consume an active- // loan slot until promotion. - $maxLoans = (int) ((new \App\Models\SettingsRepository($this->db))->get('loans', 'max_active_loans_per_user', '0') ?? 0); - if ($maxLoans > 0) { - $cntStmt = $this->db->prepare("SELECT COUNT(*) FROM prestiti WHERE utente_id = ? AND attivo = 1 AND stato IN ('prenotato','da_ritirare','in_corso','in_ritardo')"); - $cntStmt->bind_param('i', $userId); - $cntStmt->execute(); - $cntResult = $cntStmt->get_result(); - $activeCount = (int) ($cntResult ? $cntResult->fetch_row()[0] : 0); - $cntStmt->close(); - if ($activeCount >= $maxLoans) { - $this->db->rollback(); - $response->getBody()->write(json_encode(['success' => false, 'message' => __('Hai raggiunto il numero massimo di prestiti attivi consentiti')])); - return $response->withHeader('Content-Type', 'application/json')->withStatus(409); - } + $limitError = $circulation->loanLimitError($requestPolicy, $userId); + if ($limitError !== null) { + $this->db->rollback(); + $response->getBody()->write(json_encode([ + 'success' => false, + 'error' => $limitError, + 'message' => $limitError === 'max_loans_reached' + ? __('Hai raggiunto il numero massimo di prestiti attivi consentiti') + : \App\Support\LoanEligibility::errorMessage($limitError), + ])); + return $response->withHeader('Content-Type', 'application/json')->withStatus(409); } // Read the setting while the canonical book lock is still held. With @@ -696,10 +720,14 @@ public function getBookAvailabilityData($bookId, ?string $startDate = null, int // The durations every calendar proposes with (public book page, admin // loan and reservation forms, the app): the configured default, capped // by the maximum the request endpoints enforce, from one source. - $settings = new \App\Models\SettingsRepository($this->db); + $circulation = new \App\Services\CirculationPolicy($this->db); + $policy = $excludeUserId !== null + ? $circulation->forLoan($excludeUserId, (int) $bookId) + : $circulation->defaults(); return $this->calculateAvailability($currentLoans, $existingReservations, $totalCopies, $startDate, $days, $excludeUserId) + [ - 'loan_duration_days' => $settings->requestLoanDurationDays(), - 'max_loan_duration_days' => $settings->maxLoanDurationDays(), + 'loan_duration_days' => $policy->requestDurationDays(), + 'max_loan_duration_days' => $policy->maxRequestDays, + 'staff_loan_duration_days' => $policy->durationDays, ]; } @@ -794,6 +822,7 @@ private function getBookTotalCopies(int $bookId): int SELECT COUNT(*) as total FROM copie WHERE libro_id = ? AND stato NOT IN ('perso', 'danneggiato', 'manutenzione', 'in_restauro', 'in_trasferimento') + AND " . (new \App\Services\CirculationPolicy($this->db))->lendableCopySql('') . " "); $stmt->bind_param('i', $bookId); $stmt->execute(); diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php index d563edd88..0245b8d2f 100644 --- a/app/Controllers/SettingsController.php +++ b/app/Controllers/SettingsController.php @@ -80,6 +80,11 @@ public function index(Request $request, Response $response, mysqli $db): Respons $eventsHeaderDefaults = \App\Support\CatalogHeader::defaultsFor(array_map('strval', array_keys($catalogHeaderLocales)), 'events'); $advancedSettings = $this->resolveAdvancedSettings($repository); $loansSettings = $this->resolveLoansSettings($repository); + // Patron types and rules matrix (Settings → Circulation): reserved to + // administrators like every CirculationSettingsController action, so + // staff gets no data and the view renders no panel (the section is + // absent from the settings catalogue for them too). + $circulationData = \App\Support\Permissions::mayConfigure() ? CirculationSettingsController::viewData($db) : null; $contactMessages = $this->loadContactMessages($db); $cookieBannerTexts = $this->resolveCookieBannerTexts($repository); // Custom registration fields (issue #255) — edited from the email tab's @@ -114,6 +119,7 @@ public function index(Request $request, Response $response, mysqli $db): Respons 'eventsHeaderDefaults', 'advancedSettings', 'loansSettings', + 'circulationData', 'contactMessages', 'activeTab', 'db', @@ -1672,7 +1678,7 @@ public function updateCatalogHeader(Request $request, Response $response, mysqli } /** - * @return array{loan_duration_days: int, pickup_expiry_days: int, pickup_reminder_days: int, max_renewals: int, max_active_loans_per_user: int, max_loan_duration_days: int, auto_approve_requests: bool, allow_multiple_loans_same_book: bool, recall_auto_enabled: bool, recall_interval_days: int, recall_max_count: int, app_timezone: string} + * @return array{loan_duration_days: int, pickup_expiry_days: int, pickup_reminder_days: int, card_expiry_warning_days: int, max_renewals: int, max_active_loans_per_user: int, max_loan_duration_days: int, auto_approve_requests: bool, allow_multiple_loans_same_book: bool, recall_auto_enabled: bool, recall_interval_days: int, recall_max_count: int, app_timezone: string} */ private function resolveLoansSettings(SettingsRepository $repository): array { @@ -1680,6 +1686,8 @@ private function resolveLoansSettings(SettingsRepository $repository): array 'loan_duration_days' => (int) ($repository->get('loans', 'loan_duration_days', '30') ?? 30), 'pickup_expiry_days' => (int) ($repository->get('loans', 'pickup_expiry_days', '3') ?? 3), 'pickup_reminder_days' => (int) ($repository->get('loans', 'pickup_reminder_days', '2') ?? 2), + // Card-expiry warning (circulation policy): 0 = off. + 'card_expiry_warning_days' => (int) ($repository->get('loans', 'card_expiry_warning_days', '0') ?? 0), 'max_renewals' => (int) ($repository->get('loans', 'max_renewals', '3') ?? 3), 'max_active_loans_per_user' => (int) ($repository->get('loans', 'max_active_loans_per_user', '0') ?? 0), 'max_loan_duration_days' => (int) ($repository->get('loans', 'max_loan_duration_days', '90') ?? 90), @@ -1712,6 +1720,7 @@ public function updateLoansSettings(Request $request, Response $response, mysqli $loanDurationDays = min(3650, max(1, (int) ($data['loan_duration_days'] ?? 30))); // 1 day … 10 years $pickupExpiryDays = min(30, max(1, (int) ($data['pickup_expiry_days'] ?? 3))); // 1 … 30 days (matches the loans-tab input max) $pickupReminderDays = min(30, max(0, (int) ($data['pickup_reminder_days'] ?? 2))); // 0 (off) … 30 days before the pickup deadline + $cardExpiryWarningDays = min(365, max(0, (int) ($data['card_expiry_warning_days'] ?? 0))); // 0 (off) … 365 days before the card expires $maxRenewals = min(100, max(0, (int) ($data['max_renewals'] ?? 3))); // 0 … 100 $maxActiveLoans = min(1000, max(0, (int) ($data['max_active_loans_per_user'] ?? 0))); // 0 (unlimited) … 1000 $maxLoanDuration = min(3650, max(1, (int) ($data['max_loan_duration_days'] ?? 90))); // 1 day … 10 years (reservation-window cap enforced by ReservationsController) @@ -1732,6 +1741,7 @@ public function updateLoansSettings(Request $request, Response $response, mysqli $repository->set('loans', 'loan_duration_days', (string) $loanDurationDays); $repository->set('loans', 'pickup_expiry_days', (string) $pickupExpiryDays); $repository->set('loans', 'pickup_reminder_days', (string) $pickupReminderDays); + $repository->set('loans', 'card_expiry_warning_days', (string) $cardExpiryWarningDays); $repository->set('loans', 'max_renewals', (string) $maxRenewals); $repository->set('loans', 'max_active_loans_per_user', (string) $maxActiveLoans); $repository->set('loans', 'max_loan_duration_days', (string) $maxLoanDuration); diff --git a/app/Controllers/UserActionsController.php b/app/Controllers/UserActionsController.php index ae6b51eae..dce26f473 100644 --- a/app/Controllers/UserActionsController.php +++ b/app/Controllers/UserActionsController.php @@ -561,8 +561,6 @@ public function changeReservationDate(Request $request, Response $response, mysq $uid = (int) $user['id']; $startDate = $date; - $loanDays = (new \App\Models\SettingsRepository($db))->requestLoanDurationDays(); - $endDate = (new \DateTimeImmutable($date))->modify("+{$loanDays} days")->format('Y-m-d'); $db->begin_transaction(); @@ -582,6 +580,8 @@ 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); // 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"); @@ -718,13 +718,14 @@ public function loan(Request $request, Response $response, mysqli $db): Response // 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(); - $loanDays = (new \App\Models\SettingsRepository($db))->requestLoanDurationDays(); - $data_scadenza = date('Y-m-d', strtotime($data_prestito . " +{$loanDays} days")); + $circulation = new \App\Services\CirculationPolicy($db); + $loanPolicy = $circulation->forLoan($utenteId, $libroId); + $data_scadenza = \App\Services\CirculationPolicy::dueDateFor($data_prestito, $loanPolicy->requestDurationDays()); // 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($db, $data, $data_prestito); + $endOverride = $this->requestedEndDate($loanPolicy->maxRequestDays, $data, $data_prestito); if ($endOverride === false) { return $this->back($response, ['loan_error' => 'invalid_date']); } @@ -819,9 +820,24 @@ public function loan(Request $request, Response $response, mysqli $db): Response // Checked BEFORE the max-active-loans cap, mirroring // createReservation: a waitlist reservation does not consume an // active-loan slot until promotion. + // Circulation rules of the patron type, under the user lock + // (same order as createReservation): consultation-only material, + // then the reservation limit (queue + requests awaiting approval). + $policyError = !$loanPolicy->loanable + ? 'not_loanable' + : $circulation->reservationLimitError($loanPolicy->user, $utenteId, queue: false); + if ($policyError !== null) { + $db->rollback(); + return $this->back($response, ['loan_error' => $policyError]); + } + $routing = (new \App\Services\LoanRequestGate($db)) ->route($libroId, $utenteId, $data_prestito, $data_scadenza, inCallerTransaction: true); if ($routing->isReservation()) { + if (!$loanPolicy->user->reservationsEnabled) { + $db->rollback(); + return $this->back($response, ['loan_error' => 'reservations_disabled']); + } $db->commit(); // Reuse the reserve() success contract: the frontend already // renders reserve_success as "queued in the waitlist". @@ -831,19 +847,12 @@ public function loan(Request $request, Response $response, mysqli $db): Response ]); } - // Enforce max active loans per user (admin setting; 0 = no limit) - $maxLoans = (int) ((new \App\Models\SettingsRepository($db))->get('loans', 'max_active_loans_per_user', '0') ?? 0); - if ($maxLoans > 0) { - $cntStmt = $db->prepare("SELECT COUNT(*) FROM prestiti WHERE utente_id = ? AND attivo = 1 AND stato IN ('prenotato','da_ritirare','in_corso','in_ritardo')"); - $cntStmt->bind_param('i', $utenteId); - $cntStmt->execute(); - $cntResult = $cntStmt->get_result(); - $activeCount = (int) ($cntResult ? $cntResult->fetch_row()[0] : 0); - $cntStmt->close(); - if ($activeCount >= $maxLoans) { - $db->rollback(); - return $this->back($response, ['loan_error' => 'max_loans_reached']); - } + // Circulation limits of this patron and title (CirculationPolicy; + // 0 = no limit), counted under the user lock. + $limitError = $circulation->loanLimitError($loanPolicy, $utenteId); + if ($limitError !== null) { + $db->rollback(); + return $this->back($response, ['loan_error' => $limitError]); } // Capture the setting while the canonical book/copy locks are still @@ -974,10 +983,10 @@ public function reserve(Request $request, Response $response, mysqli $db): Respo // Calculate date range for availability check $start = ($desired !== '') ? $desired : $today; - $loanDays = (new \App\Models\SettingsRepository($db))->requestLoanDurationDays(); - $end = (new \DateTimeImmutable($start))->modify("+{$loanDays} days")->format('Y-m-d'); + $reservePolicy = (new \App\Services\CirculationPolicy($db))->forLoan($utenteId, $libroId); + $end = \App\Services\CirculationPolicy::dueDateFor($start, $reservePolicy->requestDurationDays()); // Optional return date (Mobile API 1.8.2), bounded as in loan(). - $endOverride = $this->requestedEndDate($db, $data, $start); + $endOverride = $this->requestedEndDate($reservePolicy->maxRequestDays, $data, $start); if ($endOverride === false) { return $this->back($response, ['reserve_error' => 'invalid_date']); } @@ -1035,6 +1044,15 @@ public function reserve(Request $request, Response $response, mysqli $db): Respo return $this->back($response, ['reserve_error' => 'not_eligible']); } + // Circulation rules of the patron type, under the user lock. + $policyError = !$reservePolicy->loanable + ? 'not_loanable' + : (new \App\Services\CirculationPolicy($db))->reservationLimitError($reservePolicy->user, $utenteId, queue: true); + if ($policyError !== null) { + $db->rollback(); + return $this->back($response, ['reserve_error' => $policyError]); + } + // Canonical peak-capacity decision (same service as admin create, // approval, renew and audit), excluding this user defensively. $capacity = new \App\Services\CapacityService($db); @@ -1118,7 +1136,7 @@ public function reserve(Request $request, Response $response, mysqli $db): Respo * @param array $data * @return string|false|null */ - private function requestedEndDate(mysqli $db, array $data, string $startDate): string|false|null + private function requestedEndDate(int $maxDays, array $data, string $startDate): string|false|null { $raw = $data['end_date'] ?? null; if (!is_scalar($raw)) { @@ -1131,7 +1149,6 @@ private function requestedEndDate(mysqli $db, array $data, string $startDate): s if (!\App\Support\DateHelper::isISODateFormat($end) || $end < $startDate) { return false; } - $maxDays = (new \App\Models\SettingsRepository($db))->maxLoanDurationDays(); $days = (int) (new \DateTimeImmutable($startDate))->diff(new \DateTimeImmutable($end))->days; return $days > $maxDays ? 'max_duration_exceeded' : $end; } diff --git a/app/Controllers/UserImportController.php b/app/Controllers/UserImportController.php index fdf5c96be..09b35cdca 100644 --- a/app/Controllers/UserImportController.php +++ b/app/Controllers/UserImportController.php @@ -7,6 +7,7 @@ use App\Support\ImportLogger; use App\Support\NotificationService; use App\Support\PasswordSetupToken; +use App\Support\PatronTypeAssignment; use App\Support\PatronTypeImport; use App\Support\RegistrationFields; use App\Support\SecureLogger; @@ -782,6 +783,28 @@ private function completeImportedUser(mysqli $db, array &$state, int $userId, in } catch (\Throwable $e) { $warn(__('Utente importato, ma la tipologia lettore non è stata assegnata.'), $e); } + // Card expiry of the patron type, as the user form computes it with + // "calcola la scadenza" ticked: only when the file gave no expiry (an + // explicit date always wins), and from the default type when the row + // has no type or an unknown one, so a file without a type column gets + // the default type's card duration too. No duration: the expiry stays + // empty, as on the form. + if (($parsed['record']['data_scadenza_tessera'] ?? null) === null) { + try { + $expiry = PatronTypeAssignment::automaticCardExpiry($db, PatronTypeAssignment::of($db, $userId)); + if ($expiry !== null) { + $stmt = $db->prepare('UPDATE utenti SET data_scadenza_tessera = ? WHERE id = ?'); + if ($stmt === false) { + throw new \RuntimeException($db->error); + } + $stmt->bind_param('si', $expiry, $userId); + $stmt->execute(); + $stmt->close(); + } + } catch (\Throwable $e) { + $warn(__('Utente importato, ma la scadenza della tessera non è stata calcolata.'), $e); + } + } if ($notifier !== null) { try { $token = PasswordSetupToken::issue($db, $userId); diff --git a/app/Controllers/UsersController.php b/app/Controllers/UsersController.php index bfe814622..5d9f312f9 100644 --- a/app/Controllers/UsersController.php +++ b/app/Controllers/UsersController.php @@ -54,6 +54,9 @@ public function createForm(Request $request, Response $response, ?mysqli $db = n $roleChoices = $db !== null && ($_SESSION['user']['tipo_utente'] ?? '') === 'admin' ? (new \App\Models\CustomRoleRepository($db))->all() : []; $availableLocales = \App\Support\I18n::getAvailableLocales(); $selectedLocale = $this->installationLocale(); + $patronTypes = $db !== null ? \App\Support\PatronTypeAssignment::choices($db) : []; + $patronTypeId = null; + $defaultPatronTypeId = $db !== null ? (new \App\Services\CirculationPolicy($db))->defaultTypeId() : null; ob_start(); require __DIR__ . '/../Views/utenti/crea_utente.php'; $content = ob_get_clean(); @@ -172,6 +175,20 @@ public function store(Request $request, Response $response, mysqli $db): Respons $codiceTessera = $codiceTesseraInput !== '' ? $codiceTesseraInput : $this->generateTessera($db); $dataScadenzaTessera = $dataScadenzaInput !== '' ? $dataScadenzaInput : null; } + // Patron type (circulation rules) and, when asked, the card expiry it + // implies (today + the type's card duration). The checkbox is ticked + // by default on this form, so it fills an empty date only: a date the + // librarian typed wins. (The edit form opts in explicitly and + // recomputes, see update().) + $patronTypeId = \App\Support\PatronTypeAssignment::fromSubmission($db, $data); + // The form pre-fills a 5-year date as a suggestion and flags it + // (card_expiry_prefilled=1) until the librarian edits the field: that + // date is not "typed", the type's card duration replaces it, and it + // stays when the type has no card duration. + $expiryTyped = $dataScadenzaInput !== '' && (string) ($data['card_expiry_prefilled'] ?? '0') !== '1'; + if (!$isAdmin && in_array($role, ['standard', 'premium'], true) && !empty($data['card_auto']) && !$expiryTyped) { + $dataScadenzaTessera = \App\Support\PatronTypeAssignment::automaticCardExpiry($db, $patronTypeId) ?? $dataScadenzaTessera; + } $stato = (string) ($data['stato'] ?? ($isAdmin ? 'attivo' : 'attivo')); if (!in_array($stato, ['attivo', 'sospeso', 'scaduto'], true)) { @@ -243,6 +260,7 @@ public function store(Request $request, Response $response, mysqli $db): Respons ); $stmt->execute(); $userId = (int) $stmt->insert_id; + \App\Support\PatronTypeAssignment::save($db, $userId, $patronTypeId); } catch (\Throwable $e) { $code = ($e instanceof \mysqli_sql_exception) ? \App\Support\UniqueViolation::errorCode($e) : 'db_error'; if ($code === 'db_error') { @@ -325,6 +343,10 @@ public function editForm(Request $request, Response $response, mysqli $db, int $ // admin can see community handles (e.g. Telegram) alongside the account. $customFieldValues = \App\Support\RegistrationFields::labelledValuesForUser($db, $id); + $patronTypeId = isset($utente['tipologia_id']) ? (int) $utente['tipologia_id'] : null; + $patronTypes = \App\Support\PatronTypeAssignment::choices($db, $patronTypeId); + $defaultPatronTypeId = (new \App\Services\CirculationPolicy($db))->defaultTypeId(); + ob_start(); require __DIR__ . '/../Views/utenti/modifica_utente.php'; $content = ob_get_clean(); @@ -460,6 +482,11 @@ public function update(Request $request, Response $response, mysqli $db, int $id $dataScadenzaTessera = $dataScadenzaInput !== '' ? $dataScadenzaInput : null; } } + $originalPatronType = isset($original['tipologia_id']) ? (int) $original['tipologia_id'] : null; + $patronTypeId = \App\Support\PatronTypeAssignment::fromSubmission($db, $data, $originalPatronType); + if (!$isAdmin && in_array($role, ['standard', 'premium'], true) && !empty($data['card_auto'])) { + $dataScadenzaTessera = \App\Support\PatronTypeAssignment::automaticCardExpiry($db, $patronTypeId) ?? $dataScadenzaTessera; + } $stato = (string) ($data['stato'] ?? ($original['stato'] ?? 'attivo')); if (!in_array($stato, ['attivo', 'sospeso', 'scaduto'], true)) { @@ -553,6 +580,9 @@ public function update(Request $request, Response $response, mysqli $db, int $id $id ); $stmt->execute(); + if ($patronTypeId !== $originalPatronType) { + \App\Support\PatronTypeAssignment::save($db, $id, $patronTypeId); + } } catch (\Throwable $e) { // $stmt stays null if prepare() itself threw — guard the close(). if ($stmt instanceof \mysqli_stmt) { @@ -794,6 +824,12 @@ public function details(Request $request, Response $response, mysqli $db, int $i } $loanStmt->close(); + // Effective circulation rules of this patron (patron type → globals). + $circulation = new \App\Services\CirculationPolicy($db); + $patronPolicy = $circulation->forType($circulation->patronTypeOf($id)); + $patronTypeName = $patronPolicy->user->patronTypeName; + $patronTypeIsOwn = isset($utente['tipologia_id']); + ob_start(); require __DIR__ . '/../Views/utenti/dettagli_utente.php'; $content = ob_get_clean(); @@ -909,6 +945,9 @@ public function approveAndSendActivation(Request $request, Response $response, m } $stmt->close(); + // Card validity from the patron type, when it sets one. + \App\Support\PatronTypeAssignment::applyCardDurationOnApproval($db, $userId); + // Invia email di attivazione con link di verifica $notifier = new NotificationService($db); $notifier->sendUserActivationWithVerification($userId, $token); @@ -967,6 +1006,9 @@ public function activateDirectly(Request $request, Response $response, mysqli $d } $stmt->close(); + // Card validity from the patron type, when it sets one. + \App\Support\PatronTypeAssignment::applyCardDurationOnApproval($db, $userId); + // Invia email di benvenuto $notifier = new NotificationService($db); $notifier->sendUserAccountApproved($userId); @@ -978,6 +1020,41 @@ public function activateDirectly(Request $request, Response $response, mysqli $d )->withStatus(302); } + /** + * "Rinnova tessera": new expiry = today + the patron type's card duration + * (365 days when the type sets none); an expired account is reactivated. + */ + public function renewCard(Request $request, Response $response, mysqli $db, int $id): Response + { + if ($guard = $this->guardAdminStaff($response)) { + return $guard; + } + $stmt = $db->prepare('SELECT * FROM utenti WHERE id = ? LIMIT 1'); + $stmt->bind_param('i', $id); + $stmt->execute(); + $user = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + if (!$user) { + return $response->withHeader('Location', url('/admin/users?error=user_not_found'))->withStatus(302); + } + if (!in_array($_SESSION['user']['tipo_utente'] ?? '', ['admin', 'staff'], true) + && !\App\Support\UserRoleAssignment::canEdit($user)) { + return $response->withStatus(403); + } + try { + $expiry = \App\Support\PatronTypeAssignment::renewCard($db, $id); + } catch (\Throwable $e) { + \App\Support\SecureLogger::error('[Users] card renewal failed for user ' . $id, ['error' => $e->getMessage()]); + $expiry = null; + } + if ($expiry === null) { + $_SESSION['error_message'] = __('Impossibile rinnovare la tessera di questo account.'); + } else { + $_SESSION['success_message'] = sprintf(__('Tessera rinnovata fino al %s.'), format_date($expiry, false, '/')); + } + return $response->withHeader('Location', url('/admin/users/details/' . $id))->withStatus(302); + } + /** * Export utenti to CSV format */ @@ -1033,12 +1110,21 @@ public function exportCsv(Request $request, Response $response, mysqli $db): Res $bindValues[] = $createdFrom; } + // Patron type filter (users without a type belong to the default one) + $patronType = \App\Support\PatronTypeAssignment::filterCondition($db, $params['patron_type_filter'] ?? ''); + if ($patronType !== null) { + $whereClauses[] = $patronType['sql']; + $bindTypes .= $patronType['types']; + array_push($bindValues, ...$patronType['params']); + } + $patronTypeColumn = (new \App\Services\CirculationPolicy($db))->schemaReady() ? ', tipologia_id' : ''; + // Build the query - export ALL fields except sensitive ones (password, tokens) $query = "SELECT id, codice_tessera, nome, cognome, email, telefono, indirizzo, data_nascita, sesso, data_registrazione, data_scadenza_tessera, stato, tipo_utente, cod_fiscale, note_utente, email_verificata, - data_ultimo_accesso, created_at, updated_at + data_ultimo_accesso, created_at, updated_at{$patronTypeColumn} FROM utenti"; if (!empty($whereClauses)) { @@ -1086,6 +1172,8 @@ public function exportCsv(Request $request, Response $response, mysqli $db): Res __('Data Scadenza Tessera'), __('Stato'), __('Tipo Utente'), + __('Tipologia lettore'), + __('Codice tipologia lettore'), __('Codice Fiscale'), __('Note Utente'), __('Email Verificata'), @@ -1101,6 +1189,8 @@ public function exportCsv(Request $request, Response $response, mysqli $db): Res // (nome, email, note) that start with = + - @. $writer = Csv::writerToStream($stream, ';', "'"); $writer->insertOne($headers); + // One policy for the whole file: the patron types are read once. + $exportPolicy = new \App\Services\CirculationPolicy($db); foreach ($utenti as $utente) { $row = [ @@ -1117,6 +1207,10 @@ public function exportCsv(Request $request, Response $response, mysqli $db): Res isset($utente['data_scadenza_tessera']) ? format_date($utente['data_scadenza_tessera'], false, '/') : '', $utente['stato'] ?? '', $utente['tipo_utente'] ?? '', + \App\Support\PatronTypeAssignment::nameOf($db, $utente['tipologia_id'] ?? null, $exportPolicy), + // The code beside the name: the import resolves by code first, + // so this file re-imports exactly where the same types exist. + \App\Support\PatronTypeAssignment::codeOf($db, $utente['tipologia_id'] ?? null, $exportPolicy), $utente['cod_fiscale'] ?? '', $utente['note_utente'] ?? '', ($utente['email_verificata'] ?? 0) == 1 ? 'Sì' : 'No', diff --git a/app/Controllers/UtentiApiController.php b/app/Controllers/UtentiApiController.php index c3a7d8324..91711a77d 100644 --- a/app/Controllers/UtentiApiController.php +++ b/app/Controllers/UtentiApiController.php @@ -53,6 +53,12 @@ public function list(Request $request, Response $response, mysqli $db): Response $params[] = $createdFrom; $types .= 's'; } + $patronType = \App\Support\PatronTypeAssignment::filterCondition($db, $q['patron_type_filter'] ?? '', 'u.tipologia_id'); + if ($patronType !== null) { + $where .= ' AND ' . $patronType['sql'] . ' '; + array_push($params, ...$patronType['params']); + $types .= $patronType['types']; + } // Count total records with prepared statement $total_sql = 'SELECT COUNT(*) AS c FROM utenti WHERE 1=1' . \App\Support\Permissions::accountScopeSql(); @@ -207,6 +213,12 @@ public function exportPdf(Request $request, Response $response, mysqli $db): Res $params[] = $createdFrom; $types .= 's'; } + $patronType = \App\Support\PatronTypeAssignment::filterCondition($db, $q['patron_type_filter'] ?? '', 'u.tipologia_id'); + if ($patronType !== null) { + $where .= ' AND ' . $patronType['sql'] . ' '; + array_push($params, ...$patronType['params']); + $types .= $patronType['types']; + } // Unfiltered total for the header summary ("N filtered out of M total"). $total = 0; diff --git a/app/Models/CopyRepository.php b/app/Models/CopyRepository.php index 8da299766..e58cb2dee 100644 --- a/app/Models/CopyRepository.php +++ b/app/Models/CopyRepository.php @@ -770,6 +770,7 @@ public function getAvailableByBookId(int $bookId): array LEFT JOIN prestiti p ON c.id = p.copia_id AND ((p.attivo = 1 AND p.stato IN ('in_corso', 'in_ritardo', 'da_ritirare', 'prenotato')) OR (p.stato = 'pendente' AND p.copia_id IS NOT NULL)) WHERE c.libro_id = ? AND c.stato = 'disponibile' + AND " . (new \App\Services\CirculationPolicy($this->db))->lendableCopySql('c') . " AND p.id IS NULL ORDER BY c.numero_inventario ASC "); @@ -806,6 +807,7 @@ public function getAvailableByBookIdForDateRange(int $bookId, string $startDate, FROM copie c WHERE c.libro_id = ? AND c.stato NOT IN ('perso', 'danneggiato', 'manutenzione', 'in_restauro', 'in_trasferimento') + AND " . (new \App\Services\CirculationPolicy($this->db))->lendableCopySql('c') . " AND NOT EXISTS ( SELECT 1 FROM prestiti p WHERE p.copia_id = c.id @@ -840,6 +842,7 @@ public function countAvailableByBookId(int $bookId): int LEFT JOIN prestiti p ON c.id = p.copia_id AND ((p.attivo = 1 AND p.stato IN ('in_corso', 'in_ritardo', 'da_ritirare', 'prenotato')) OR (p.stato = 'pendente' AND p.copia_id IS NOT NULL)) WHERE c.libro_id = ? AND c.stato = 'disponibile' + AND " . (new \App\Services\CirculationPolicy($this->db))->lendableCopySql('c') . " AND p.id IS NULL "); $stmt->bind_param('i', $bookId); diff --git a/app/Models/LoanRepository.php b/app/Models/LoanRepository.php index bd6484cbe..108430062 100644 --- a/app/Models/LoanRepository.php +++ b/app/Models/LoanRepository.php @@ -99,8 +99,8 @@ public function update(int $id, array $data): bool // Fallback dalla setting di durata prestito: il vecchio +14gg // hardcoded era metà del default seminato (30) e ignorava la // configurazione dell'admin — stesso fix M5b già applicato a renew(). - $loanDays = (new SettingsRepository($this->db))->loanDurationDays(); - $data_scadenza = date('Y-m-d', strtotime($data_prestito . " +{$loanDays} days")); + $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); } $processed_by = $data['processed_by'] ?? null; $stmt->bind_param('issii', $utente_id, $data_prestito, $data_scadenza, $processed_by, $id); diff --git a/app/Models/SettingsRepository.php b/app/Models/SettingsRepository.php index 20764a0a1..487c88a23 100644 --- a/app/Models/SettingsRepository.php +++ b/app/Models/SettingsRepository.php @@ -108,26 +108,33 @@ public function allowsMultipleLoansSameBook(): bool } /** - * Configured default loan duration. Invalid or missing values retain the - * historical 30-day fallback in every loan creation/update path. + * Configured default loan duration (global rule, no patron context). + * + * @deprecated read the rules from \App\Services\CirculationPolicy (forLoan()/defaults()). */ public function loanDurationDays(): int { - $days = (int) ($this->get('loans', 'loan_duration_days', '30') ?? 30); - return $days >= 1 ? $days : 30; + return (new \App\Services\CirculationPolicy($this->db, $this))->defaults()->durationDays; } - /** Maximum duration of a reader's request, including an explicit end date. */ + /** + * Maximum duration of a reader's request, including an explicit end date. + * + * @deprecated read the rules from \App\Services\CirculationPolicy (forLoan()/defaults()). + */ public function maxLoanDurationDays(): int { - $days = (int) ($this->get('loans', 'max_loan_duration_days', '90') ?? 90); - return $days >= 1 ? $days : 90; + return (new \App\Services\CirculationPolicy($this->db, $this))->defaults()->maxRequestDays; } - /** Default for reader requests and their calendars; staff loans retain their own duration. */ + /** + * Default for reader requests and their calendars; staff loans retain their own duration. + * + * @deprecated read the rules from \App\Services\CirculationPolicy (forLoan()/defaults()). + */ public function requestLoanDurationDays(): int { - return min($this->loanDurationDays(), $this->maxLoanDurationDays()); + return (new \App\Services\CirculationPolicy($this->db, $this))->defaults()->requestDurationDays(); } /** diff --git a/app/Routes/web.php b/app/Routes/web.php index d40df1a61..1d03abfe9 100644 --- a/app/Routes/web.php +++ b/app/Routes/web.php @@ -777,6 +777,46 @@ return $controller->updateLoansSettings($request, $response, $db); })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + // Circulation policy: patron types and rules matrix (Settings → Circulation) + $app->get('/admin/settings/circulation', function ($request, $response) { + return (new \App\Controllers\CirculationSettingsController())->index($request, $response); + })->add(new AdminAuthMiddleware()); + + $app->post('/admin/settings/circulation/types', function ($request, $response) use ($app) { + $db = $app->getContainer()->get('db'); + return (new \App\Controllers\CirculationSettingsController())->createType($request, $response, $db); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + + $app->get('/admin/settings/circulation/types/{id:\d+}', function ($request, $response, $args) use ($app) { + $db = $app->getContainer()->get('db'); + return (new \App\Controllers\CirculationSettingsController())->editForm($request, $response, $db, (int) $args['id']); + })->add(new AdminAuthMiddleware()); + + $app->post('/admin/settings/circulation/types/{id:\d+}', function ($request, $response, $args) use ($app) { + $db = $app->getContainer()->get('db'); + return (new \App\Controllers\CirculationSettingsController())->updateType($request, $response, $db, (int) $args['id']); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + + $app->post('/admin/settings/circulation/types/{id:\d+}/delete', function ($request, $response, $args) use ($app) { + $db = $app->getContainer()->get('db'); + return (new \App\Controllers\CirculationSettingsController())->deleteType($request, $response, $db, (int) $args['id']); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + + $app->post('/admin/settings/circulation/types/{id:\d+}/default', function ($request, $response, $args) use ($app) { + $db = $app->getContainer()->get('db'); + return (new \App\Controllers\CirculationSettingsController())->setDefault($request, $response, $db, (int) $args['id']); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + + $app->post('/admin/settings/circulation/types/{id:\d+}/move', function ($request, $response, $args) use ($app) { + $db = $app->getContainer()->get('db'); + return (new \App\Controllers\CirculationSettingsController())->moveType($request, $response, $db, (int) $args['id']); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + + $app->post('/admin/settings/circulation/rules', function ($request, $response) use ($app) { + $db = $app->getContainer()->get('db'); + return (new \App\Controllers\CirculationSettingsController())->saveRules($request, $response, $db); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + // API Settings Routes $app->post('/admin/settings/api/toggle', function ($request, $response) use ($app) { $db = $app->getContainer()->get('db'); @@ -1276,6 +1316,11 @@ $db = $app->getContainer()->get('db'); return $controller->approveAndSendActivation($request, $response, $db, $args); })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + $app->post('/admin/users/{id:\d+}/renew-card', function ($request, $response, $args) use ($app) { + $db = $app->getContainer()->get('db'); + return (new \App\Controllers\UsersController())->renewCard($request, $response, $db, (int) $args['id']); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + $app->post('/admin/users/{id:\d+}/activate-directly', function ($request, $response, $args) use ($app) { $controller = new \App\Controllers\UsersController(); $db = $app->getContainer()->get('db'); @@ -1743,6 +1788,12 @@ return $controller->updateCopy($request, $response, $db, (int) $args['id']); })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + $app->post('/admin/books/copies/{id:\d+}/consultation', function ($request, $response, $args) use ($app) { + $controller = new \App\Controllers\CopyController(); + $db = $app->getContainer()->get('db'); + return $controller->toggleConsultation($request, $response, $db, (int) $args['id']); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + $app->post('/admin/books/copies/{id:\d+}/delete', function ($request, $response, $args) use ($app) { $controller = new \App\Controllers\CopyController(); $db = $app->getContainer()->get('db'); @@ -3037,7 +3088,10 @@ // page, admin loan and reservation forms): the configured // default, capped by the maximum the request endpoints enforce. 'loan_duration_days' => $availability['loan_duration_days'] ?? null, - 'max_loan_duration_days' => $availability['max_loan_duration_days'] ?? null + '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 ] ]; $response->getBody()->write(json_encode($data, JSON_UNESCAPED_UNICODE)); diff --git a/app/Services/CapacityService.php b/app/Services/CapacityService.php index ae769a7e4..1dcfd21c9 100644 --- a/app/Services/CapacityService.php +++ b/app/Services/CapacityService.php @@ -40,7 +40,8 @@ */ final class CapacityService { - private ?int $maxActiveLoansPerUser = null; + /** Circulation rules, resolved once per instance (the settings rows are cached there). */ + private ?CirculationPolicy $circulation = null; public function __construct(private mysqli $db) {} @@ -63,7 +64,8 @@ public function totalCopies(int $libroId): int $stmt = $this->db->prepare( "SELECT COUNT(*) AS total FROM copie WHERE libro_id = ? - AND stato NOT IN ('perso','danneggiato','manutenzione','in_restauro','in_trasferimento')" + AND stato NOT IN ('perso','danneggiato','manutenzione','in_restauro','in_trasferimento') + AND " . ($this->circulation ??= new CirculationPolicy($this->db))->lendableCopySql('') . "" ); $stmt->bind_param('i', $libroId); $stmt->execute(); @@ -441,7 +443,7 @@ private function activeReservationIntervals(int $libroId, string $start, string // loan limit. The reservation stays active and regains its FIFO priority // automatically once the account becomes eligible again. $today = \App\Support\DateHelper::today(); - $maxLoans = $this->maxActiveLoansPerUser(); + $circulation = $this->circulation ??= new CirculationPolicy($this->db); $sql = "SELECT GREATEST($rStart, ?) AS s, LEAST($rEnd, ?) AS e FROM prenotazioni r JOIN utenti u ON u.id = r.utente_id @@ -453,7 +455,9 @@ private function activeReservationIntervals(int $libroId, string $start, string AND $rEnd >= ?"; $types = 'ssisss'; $params = [$start, $end, $libroId, $today, $end, $start]; - if ($maxLoans > 0) { + if ($circulation->anyTotalLoanLimit()) { + // Per-patron limit (CirculationPolicy; 0 = no limit for that patron). + $limitSql = $circulation->totalLoanLimitSql('u'); // Future reservations reserve capacity against overlapping commitments. // Once they become promotable, promotion and approval enforce the total // active-loan cap (including future scheduled loans). Use the SHARED @@ -463,23 +467,33 @@ private function activeReservationIntervals(int $libroId, string $start, string // today — the two predicates must stay identical by construction, or // an unpromotable-at-cap legacy head would keep occupying capacity // and freeze the queue (the very bug this branch fixes). - $sql .= " AND ( + $sql .= " AND ({$limitSql} <= 0 OR ( SELECT COUNT(*) FROM prestiti cap WHERE cap.utente_id = r.utente_id AND cap.attivo = 1 - AND cap.stato IN ('prenotato','da_ritirare','in_corso','in_ritardo') + AND cap.stato IN (" . CirculationPolicy::ACTIVE_LOAN_STATES_SQL . ") AND (" . \App\Support\LoanEligibility::promotableReservationWhere('r') . " OR (cap.data_prestito <= $rEnd AND (cap.stato = 'in_ritardo' OR (cap.stato = 'in_corso' AND cap.data_scadenza < ?) OR cap.data_scadenza >= $rStart))) - ) < ?"; - $types .= 'sssi'; + ) < {$limitSql})"; + $types .= 'sss'; $params[] = $today; $params[] = $today; $params[] = $today; - $params[] = $maxLoans; + } + // Same pause as queue promotion for the per-material rules: a + // promotable reservation of a patron the matrix refuses for this + // title does not hold capacity (ReservationManager skips it too). + $materialRefused = $circulation->materialRefusedReservationUserIds($libroId); + if ($materialRefused !== []) { + $sql .= ' AND NOT (r.utente_id IN (' . implode(',', array_map('intval', $materialRefused)) . ') + AND ' . \App\Support\LoanEligibility::promotableReservationWhere('r') . ')'; + $types .= 'ss'; + $params[] = $today; + $params[] = $today; } if ($excludeReservationId !== null) { $sql .= ' AND r.id <> ?'; @@ -504,16 +518,6 @@ private function activeReservationIntervals(int $libroId, string $start, string return $this->fetchIntervals($sql, $types, $params); } - private function maxActiveLoansPerUser(): int - { - if ($this->maxActiveLoansPerUser === null) { - $value = (new \App\Models\SettingsRepository($this->db)) - ->get('loans', 'max_active_loans_per_user', '0'); - $this->maxActiveLoansPerUser = max(0, (int) ($value ?? 0)); - } - return $this->maxActiveLoansPerUser; - } - /** * @param string $types * @param list $params diff --git a/app/Services/Circulation/LoanPolicy.php b/app/Services/Circulation/LoanPolicy.php new file mode 100644 index 000000000..e6d3c11cd --- /dev/null +++ b/app/Services/Circulation/LoanPolicy.php @@ -0,0 +1,51 @@ +durationDays, $this->maxRequestDays); + } + + /** Whether any quantity limit has to be counted for this loan. */ + public function hasQuantityLimits(): bool + { + return $this->maxActiveLoans > 0 || $this->maxActiveForMedia > 0; + } +} diff --git a/app/Services/Circulation/PolicyResolver.php b/app/Services/Circulation/PolicyResolver.php new file mode 100644 index 000000000..9493f76c1 --- /dev/null +++ b/app/Services/Circulation/PolicyResolver.php @@ -0,0 +1,141 @@ +|null> $layers + */ + public static function pick(array $layers, string $field): mixed + { + foreach ($layers as $layer) { + if (is_array($layer) && array_key_exists($field, $layer) && $layer[$field] !== null && $layer[$field] !== '') { + return $layer[$field]; + } + } + return null; + } + + /** + * @param list|null> $layers rule/type layers, most specific first, WITHOUT the global one + * @param array $globals the complete global layer (see CirculationPolicy::globalLayer()) + */ + public static function resolve(array $layers, array $globals, UserPolicy $user, string $mediaType = '*'): LoanPolicy + { + $all = $layers; + $all[] = $globals; + + $globalDuration = self::positiveInt($globals['duration_days'] ?? null, 30); + $globalMaxRequest = self::positiveInt($globals['max_request_days'] ?? null, 90); + $globalRenewals = self::nonNegativeInt($globals['max_renewals'] ?? null, 3); + + $duration = self::positiveInt(self::pick($all, 'duration_days'), $globalDuration); + $renewal = self::pick($all, 'renewal_days'); + + return new LoanPolicy( + user: $user, + mediaType: $mediaType, + loanable: (bool) (self::pick($all, 'loanable') ?? true), + durationDays: $duration, + maxRequestDays: self::positiveInt(self::pick($all, 'max_request_days'), $globalMaxRequest), + maxRenewals: self::nonNegativeInt(self::pick($all, 'max_renewals'), $globalRenewals), + renewalDays: self::positiveInt($renewal, $duration), + maxActiveLoans: $user->maxActiveLoans, + maxActiveForMedia: self::nonNegativeInt(self::pick($all, 'max_active_for_media'), 0), + dailyFine: self::nullableFloat(self::pick($all, 'daily_fine')), + graceDays: self::nullableInt(self::pick($all, 'grace_days')), + maxFinePerLoan: self::nullableFloat(self::pick($all, 'max_fine_per_loan')), + debtBlockThreshold: $user->debtBlockThreshold, + ); + } + + /** + * Patron-only limits: patron type first, then the globals. + * + * @param array|null $type patron type row (keys: id, code, name, max_active_loans, + * reservations_enabled, max_reservations, card_duration_days, + * debt_block_threshold) + * @param array $globals + */ + public static function resolveUser(?array $type, array $globals): UserPolicy + { + $layers = [$type, $globals]; + $enabled = self::pick($layers, 'reservations_enabled'); + + return new UserPolicy( + patronTypeId: isset($type['id']) ? (int) $type['id'] : null, + patronTypeCode: isset($type['code']) ? (string) $type['code'] : null, + patronTypeName: isset($type['name']) ? (string) $type['name'] : null, + maxActiveLoans: self::nonNegativeInt(self::pick($layers, 'max_active_loans'), 0), + reservationsEnabled: $enabled === null ? true : (bool) $enabled, + maxReservations: self::nonNegativeInt(self::pick($layers, 'max_reservations'), 0), + cardDurationDays: self::nullablePositiveInt(self::pick($layers, 'card_duration_days')), + debtBlockThreshold: self::nullableFloat(self::pick($layers, 'debt_block_threshold')), + ); + } + + private static function positiveInt(mixed $value, int $fallback): int + { + if ($value === null || !is_numeric($value)) { + return $fallback; + } + $int = (int) $value; + return $int >= 1 ? $int : $fallback; + } + + private static function nonNegativeInt(mixed $value, int $fallback): int + { + if ($value === null || !is_numeric($value)) { + return $fallback; + } + $int = (int) $value; + return $int >= 0 ? $int : $fallback; + } + + private static function nullableInt(mixed $value): ?int + { + return $value === null || !is_numeric($value) ? null : max(0, (int) $value); + } + + private static function nullablePositiveInt(mixed $value): ?int + { + return $value === null || !is_numeric($value) || (int) $value < 1 ? null : (int) $value; + } + + private static function nullableFloat(mixed $value): ?float + { + return $value === null || !is_numeric($value) ? null : (float) $value; + } +} diff --git a/app/Services/Circulation/UserPolicy.php b/app/Services/Circulation/UserPolicy.php new file mode 100644 index 000000000..d7e0c4184 --- /dev/null +++ b/app/Services/Circulation/UserPolicy.php @@ -0,0 +1,32 @@ + schema availability per connection (one information_schema read per request) */ + private static array $schemaReady = []; + + /** @var array|null */ + private ?array $globals = null; + /** @var array>|null */ + private ?array $types = null; + private ?int $defaultTypeId = null; + /** @var array>|null */ + private ?array $rules = null; + /** @var array */ + private array $userTypes = []; + /** @var array */ + private array $media = []; + + private SettingsRepository $settings; + + public function __construct(private mysqli $db, ?SettingsRepository $settings = null) + { + $this->settings = $settings ?? new SettingsRepository($db); + } + + /** + * 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. + */ + public static function dueDateFor(string $startYmd, int $days): 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'); + } + + /** Drop the per-request schema probe (tests that migrate a database mid-process). */ + public static function resetSchemaCache(): void + { + self::$schemaReady = []; + } + + /** Rules that depend only on the patron. */ + public function forUser(int $userId): UserPolicy + { + return PolicyResolver::resolveUser($this->userTypeLayer($this->patronTypeOf($userId)), $this->globalLayer()); + } + + /** Rules for $userId borrowing title $libroId (optionally the copy $copiaId). */ + public function forLoan(int $userId, int $libroId, ?int $copiaId = null): LoanPolicy + { + return $this->forTypeAndTitle($this->patronTypeOf($userId), $libroId, $copiaId); + } + + /** + * Rules for whoever views a title: the patron's when logged in, the + * default patron type's for an anonymous visitor (book page buttons). + */ + public function forViewer(?int $userId, int $libroId): LoanPolicy + { + return $userId !== null && $userId > 0 + ? $this->forLoan($userId, $libroId) + : $this->forTypeAndTitle($this->defaultTypeId(), $libroId, null); + } + + private function forTypeAndTitle(?int $typeId, int $libroId, ?int $copiaId): LoanPolicy + { + $user = PolicyResolver::resolveUser($this->userTypeLayer($typeId), $this->globalLayer()); + $media = $this->mediaOf($libroId); + $layers = $this->ruleLayers($typeId, $media); + // The single copy kept for consultation, or a title whose every copy + // still in circulation is a consultation copy, is not lent. + if ($copiaId !== null && $copiaId > 0 + ? $this->isConsultationCopy($copiaId) + : $this->onlyConsultationCopies($libroId)) { + array_unshift($layers, ['loanable' => false]); + } + return PolicyResolver::resolve($layers, $this->globalLayer(), $user, $media); + } + + /** + * Rules for an existing loan row ($userId overrides the borrower, e.g. on + * reassignment). Falls back to the global rules when the row is gone. + */ + public function forExistingLoan(int $loanId, ?int $userId = null): LoanPolicy + { + $stmt = $this->db->prepare('SELECT utente_id, libro_id, copia_id FROM prestiti WHERE id = ?'); + $stmt->bind_param('i', $loanId); + $stmt->execute(); + $result = $stmt->get_result(); + $row = $result ? $result->fetch_assoc() : null; + $stmt->close(); + if (!is_array($row)) { + return $this->defaults(); + } + return $this->forLoan( + $userId ?? (int) $row['utente_id'], + (int) $row['libro_id'], + $row['copia_id'] !== null ? (int) $row['copia_id'] : null + ); + } + + /** Rules for the patron and title of an existing reservation (globals when it is gone). */ + public function forReservation(int $reservationId): LoanPolicy + { + $stmt = $this->db->prepare('SELECT utente_id, libro_id FROM prenotazioni WHERE id = ?'); + $stmt->bind_param('i', $reservationId); + $stmt->execute(); + $result = $stmt->get_result(); + $row = $result ? $result->fetch_assoc() : null; + $stmt->close(); + if (!is_array($row)) { + return $this->defaults(); + } + return $this->forLoan((int) $row['utente_id'], (int) $row['libro_id']); + } + + /** The rules of the default patron type, for contexts with no patron (admin defaults, dashboards). */ + public function defaults(): LoanPolicy + { + $typeId = $this->defaultTypeId(); + $globals = $this->globalLayer(); + return PolicyResolver::resolve( + $this->ruleLayers($typeId, self::ALL_MEDIA), + $globals, + PolicyResolver::resolveUser($this->userTypeLayer($typeId), $globals), + self::ALL_MEDIA + ); + } + + /** + * Rules of a patron type for a material, without a patron (admin matrix, + * effective-rules summaries). $typeId null = the default type. + */ + public function forType(?int $typeId, string $media = self::ALL_MEDIA): LoanPolicy + { + $typeId ??= $this->defaultTypeId(); + $globals = $this->globalLayer(); + return PolicyResolver::resolve( + $this->ruleLayers($typeId, $media), + $globals, + PolicyResolver::resolveUser($this->userTypeLayer($typeId), $globals), + $media + ); + } + + /** + * The global layer, read from the loans settings with the historical + * fallbacks (30-day duration, 90-day request cap, 3 renewals, no limit). + * + * @return array + */ + public function globalLayer(): array + { + if ($this->globals === null) { + $rawRenewals = $this->settings->get('loans', 'max_renewals', '3'); + $renewals = is_numeric($rawRenewals) ? (int) $rawRenewals : 3; + $duration = (int) ($this->settings->get('loans', 'loan_duration_days', '30') ?? 30); + $maxRequest = (int) ($this->settings->get('loans', 'max_loan_duration_days', '90') ?? 90); + $this->globals = [ + 'loanable' => true, + 'duration_days' => $duration >= 1 ? $duration : 30, + 'max_request_days' => $maxRequest >= 1 ? $maxRequest : 90, + 'max_renewals' => $renewals >= 0 ? $renewals : 3, + 'renewal_days' => null, + 'max_active_for_media' => null, + 'max_active_loans' => max(0, (int) ($this->settings->get('loans', 'max_active_loans_per_user', '0') ?? 0)), + 'reservations_enabled' => true, + 'max_reservations' => 0, + 'card_duration_days' => null, + 'debt_block_threshold' => null, + 'daily_fine' => null, + 'grace_days' => null, + 'max_fine_per_loan' => null, + ]; + } + return $this->globals; + } + + // ─── Counting and refusals ────────────────────────────────────────────── + + /** + * Open loans of $userId that count against the active-loan limit. + * Callers that enforce a limit hold the user row lock first. + */ + public function countActiveLoans(int $userId, ?int $excludeLoanId = null): int + { + $sql = 'SELECT COUNT(*) FROM prestiti WHERE utente_id = ? AND attivo = 1 AND stato IN (' . self::ACTIVE_LOAN_STATES_SQL . ')'; + if ($excludeLoanId !== null) { + $sql .= ' AND id <> ?'; + } + $stmt = $this->db->prepare($sql); + if ($excludeLoanId !== null) { + $stmt->bind_param('ii', $userId, $excludeLoanId); + } else { + $stmt->bind_param('i', $userId); + } + $stmt->execute(); + $result = $stmt->get_result(); + $count = (int) ($result ? ($result->fetch_row()[0] ?? 0) : 0); + $stmt->close(); + return $count; + } + + /** Open loans of $userId on titles of material $media (same states as countActiveLoans()). */ + public function countActiveLoansForMedia(int $userId, string $media, ?int $excludeLoanId = null): int + { + // CI-SOFT-DELETE-EXEMPT: a loan on a title archived after checkout is still out and still counts against the limit. + $sql = 'SELECT COUNT(*) FROM prestiti p JOIN libri l ON l.id = p.libro_id + WHERE p.utente_id = ? AND p.attivo = 1 AND p.stato IN (' . self::ACTIVE_LOAN_STATES_SQL . ') + AND l.tipo_media = ?'; + if ($excludeLoanId !== null) { + $sql .= ' AND p.id <> ?'; + } + $stmt = $this->db->prepare($sql); + if ($excludeLoanId !== null) { + $stmt->bind_param('isi', $userId, $media, $excludeLoanId); + } else { + $stmt->bind_param('is', $userId, $media); + } + $stmt->execute(); + $result = $stmt->get_result(); + $count = (int) ($result ? ($result->fetch_row()[0] ?? 0) : 0); + $stmt->close(); + return $count; + } + + /** + * The quantity/loanability refusal for a new open loan, or null. + * + * Codes: 'not_loanable', 'max_loans_reached', 'media_limit_reached'. + * $excludeLoanId leaves out the loan being approved/reassigned. Both + * limits apply: the total one and the one for the material. + */ + public function loanLimitError(LoanPolicy $policy, int $userId, ?int $excludeLoanId = null): ?string + { + if (!$policy->loanable) { + return 'not_loanable'; + } + if ($policy->maxActiveLoans > 0 && $this->countActiveLoans($userId, $excludeLoanId) >= $policy->maxActiveLoans) { + return 'max_loans_reached'; + } + if ($policy->maxActiveForMedia > 0 && $policy->mediaType !== self::ALL_MEDIA + && $this->countActiveLoansForMedia($userId, $policy->mediaType, $excludeLoanId) >= $policy->maxActiveForMedia) { + return 'media_limit_reached'; + } + return null; + } + + /** + * Patrons holding an active reservation on $libroId whom the per-material + * rules refuse right now: the title's material is not loanable for their + * patron type, or they already hold the limit for that material. Queue + * promotion (ReservationManager) and capacity occupancy (CapacityService) + * both leave their promotable reservations aside, the same pause the + * total-limit SQL predicate applies, so a refused head of the queue does + * not block the patrons behind it. Empty without rules-matrix rows (a + * zero-config install never pauses anyone here). + * + * @return list + */ + public function materialRefusedReservationUserIds(int $libroId): array + { + if ($this->rules() === []) { + return []; + } + $media = $this->mediaOf($libroId); + $stmt = $this->db->prepare("SELECT DISTINCT utente_id FROM prenotazioni WHERE libro_id = ? AND stato = 'attiva'"); + $stmt->bind_param('i', $libroId); + $stmt->execute(); + $result = $stmt->get_result(); + $userIds = []; + while ($result && ($row = $result->fetch_row())) { + $userIds[] = (int) $row[0]; + } + $stmt->close(); + + $refused = []; + foreach ($userIds as $userId) { + $policy = $this->forType($this->patronTypeOf($userId), $media); + if (!$policy->loanable + || ($policy->maxActiveForMedia > 0 && $media !== self::ALL_MEDIA + && $this->countActiveLoansForMedia($userId, $media) >= $policy->maxActiveForMedia)) { + $refused[] = $userId; + } + } + return $refused; + } + + /** + * Active queue reservations plus loan requests still awaiting approval: + * what the reservation limit counts (plan decision 4). + */ + public function countReservations(int $userId, ?int $excludeReservationId = null): int + { + $sql = "SELECT + (SELECT COUNT(*) FROM prenotazioni WHERE utente_id = ? AND stato = 'attiva'" . ($excludeReservationId !== null ? ' AND id <> ?' : '') . ") + + (SELECT COUNT(*) FROM prestiti WHERE utente_id = ? AND attivo = 0 AND stato = 'pendente')"; + $stmt = $this->db->prepare($sql); + if ($excludeReservationId !== null) { + $stmt->bind_param('iii', $userId, $excludeReservationId, $userId); + } else { + $stmt->bind_param('ii', $userId, $userId); + } + $stmt->execute(); + $result = $stmt->get_result(); + $count = (int) ($result ? ($result->fetch_row()[0] ?? 0) : 0); + $stmt->close(); + return $count; + } + + /** + * Refusal for a new queue reservation ($queue = true) or reader loan + * request ($queue = false), or null. Codes: 'reservations_disabled' + * (queue reservations only), 'reservation_limit_reached'. + */ + public function reservationLimitError(UserPolicy $user, int $userId, bool $queue = true): ?string + { + if ($queue && !$user->reservationsEnabled) { + return 'reservations_disabled'; + } + if ($user->maxReservations > 0 && $this->countReservations($userId) >= $user->maxReservations) { + return 'reservation_limit_reached'; + } + return null; + } + + /** + * SQL expression evaluating to the total active-loan limit of the patron + * row aliased $userAlias (utenti); 0 = no limit. No placeholders: the ids + * and the global value are integers rendered by the application. + * Queue selection uses it to skip patrons already at their limit. + */ + public function totalLoanLimitSql(string $userAlias): string + { + $global = (int) $this->globalLayer()['max_active_loans']; + if (!$this->schemaReady()) { + return (string) $global; + } + $default = $this->defaultTypeId() ?? 0; + return "COALESCE((SELECT tl.max_prestiti FROM tipologie_lettore tl WHERE tl.id = COALESCE({$userAlias}.tipologia_id, {$default})), {$global})"; + } + + /** Whether any patron can be subject to a total active-loan limit. */ + public function anyTotalLoanLimit(): bool + { + if ((int) $this->globalLayer()['max_active_loans'] > 0) { + return true; + } + foreach ($this->types() as $type) { + if ($type['max_prestiti'] !== null && (int) $type['max_prestiti'] > 0) { + return true; + } + } + return false; + } + + // ─── Patron types and matrix ──────────────────────────────────────────── + + /** + * Patron types ordered for display. + * + * @return array> + */ + public function types(): array + { + if ($this->types === null) { + $this->types = []; + if ($this->schemaReady()) { + $result = $this->db->query('SELECT * FROM tipologie_lettore ORDER BY ordinamento ASC, nome ASC, id ASC'); + if ($result instanceof \mysqli_result) { + while ($row = $result->fetch_assoc()) { + $this->types[(int) $row['id']] = $row; + if ((int) $row['predefinita'] === 1 && $this->defaultTypeId === null) { + $this->defaultTypeId = (int) $row['id']; + } + } + $result->free(); + } + } + } + return $this->types; + } + + /** The patron type assigned to users without one (null when none exists). */ + public function defaultTypeId(): ?int + { + $this->types(); + return $this->defaultTypeId; + } + + /** + * Matrix rules keyed "typeId|media". + * + * @return array> + */ + public function rules(): array + { + if ($this->rules === null) { + $this->rules = []; + if ($this->schemaReady()) { + $result = $this->db->query('SELECT * FROM regole_prestito'); + if ($result instanceof \mysqli_result) { + while ($row = $result->fetch_assoc()) { + $this->rules[(int) $row['tipologia_id'] . '|' . (string) $row['tipo_media']] = $row; + } + $result->free(); + } + } + } + return $this->rules; + } + + /** The patron type governing $userId: theirs, else the default one. */ + public function patronTypeOf(int $userId): ?int + { + if (!$this->schemaReady()) { + return null; + } + if (!array_key_exists($userId, $this->userTypes)) { + $stmt = $this->db->prepare('SELECT tipologia_id FROM utenti WHERE id = ?'); + $stmt->bind_param('i', $userId); + $stmt->execute(); + $result = $stmt->get_result(); + $row = $result ? $result->fetch_assoc() : null; + $stmt->close(); + $own = is_array($row) && $row['tipologia_id'] !== null ? (int) $row['tipologia_id'] : null; + $this->userTypes[$userId] = ($own !== null && isset($this->types()[$own])) ? $own : $this->defaultTypeId(); + } + return $this->userTypes[$userId]; + } + + /** + * The layers of $typeId for $media, most specific first (see the class + * docblock), as PolicyResolver field arrays. + * + * @return list|null> + */ + public function ruleLayers(?int $typeId, string $media): array + { + $rules = $this->rules(); + $layers = []; + if ($typeId !== null) { + if ($media !== self::ALL_MEDIA) { + $layers[] = self::ruleLayer($rules[$typeId . '|' . $media] ?? null, true); + } + $layers[] = self::ruleLayer($rules[$typeId . '|' . self::ALL_MEDIA] ?? null, false); + } + if ($media !== self::ALL_MEDIA) { + $layers[] = self::ruleLayer($rules[self::ALL_TYPES . '|' . $media] ?? null, true); + } + $layers[] = $typeId !== null ? self::typeLayer($this->types()[$typeId] ?? null) : null; + return $layers; + } + + /** + * @param array|null $rule + * @return array|null + */ + private static function ruleLayer(?array $rule, bool $concreteMedia): ?array + { + if ($rule === null) { + return null; + } + return [ + 'loanable' => $rule['prestabile'], + 'duration_days' => $rule['durata_prestito_giorni'], + 'max_renewals' => $rule['max_rinnovi'], + 'renewal_days' => $rule['giorni_rinnovo'], + // The per-material limit exists only for a concrete material. + 'max_active_for_media' => $concreteMedia ? $rule['max_prestiti'] : null, + 'daily_fine' => $rule['multa_giornaliera'], + 'grace_days' => $rule['giorni_tolleranza'], + 'max_fine_per_loan' => $rule['multa_massima_prestito'], + ]; + } + + /** + * @param array|null $type + * @return array|null + */ + private static function typeLayer(?array $type): ?array + { + if ($type === null) { + return null; + } + return [ + 'duration_days' => $type['durata_prestito_giorni'], + 'max_request_days' => $type['max_durata_richiesta_giorni'], + 'max_renewals' => $type['max_rinnovi'], + 'renewal_days' => $type['giorni_rinnovo'], + 'daily_fine' => $type['multa_giornaliera'], + 'grace_days' => $type['giorni_tolleranza'], + 'max_fine_per_loan' => $type['multa_massima_prestito'], + ]; + } + + /** @return array|null */ + private function userTypeLayer(?int $typeId): ?array + { + $type = $typeId !== null ? ($this->types()[$typeId] ?? null) : null; + if ($type === null) { + return null; + } + return [ + 'id' => (int) $type['id'], + 'code' => (string) $type['codice'], + 'name' => (string) $type['nome'], + 'max_active_loans' => $type['max_prestiti'], + 'reservations_enabled' => $type['prenotazioni_abilitate'], + 'max_reservations' => $type['max_prenotazioni'], + 'card_duration_days' => $type['durata_tessera_giorni'], + 'debt_block_threshold' => $type['soglia_blocco_debito'], + ]; + } + + /** libri.tipo_media of $libroId ('*' when the title is unknown). */ + public function mediaOf(int $libroId): string + { + if (!array_key_exists($libroId, $this->media)) { + // CI-SOFT-DELETE-EXEMPT: the rules of a running loan stay those of its material after the title is archived. + $stmt = $this->db->prepare('SELECT tipo_media FROM libri WHERE id = ?'); + $stmt->bind_param('i', $libroId); + $stmt->execute(); + $result = $stmt->get_result(); + $row = $result ? $result->fetch_assoc() : null; + $stmt->close(); + $this->media[$libroId] = is_array($row) && is_string($row['tipo_media']) && $row['tipo_media'] !== '' + ? $row['tipo_media'] + : self::ALL_MEDIA; + } + return $this->media[$libroId]; + } + + /** Whether the copy is kept for in-house consultation only. */ + public function isConsultationCopy(int $copiaId): bool + { + if (!$this->schemaReady()) { + return false; + } + $stmt = $this->db->prepare('SELECT solo_consultazione FROM copie WHERE id = ?'); + $stmt->bind_param('i', $copiaId); + $stmt->execute(); + $result = $stmt->get_result(); + $row = $result ? $result->fetch_assoc() : null; + $stmt->close(); + return is_array($row) && (int) $row['solo_consultazione'] === 1; + } + + /** + * Whether $libroId has consultation copies and no other copy in + * circulation (copies lost, damaged or in repair do not count). + */ + public function onlyConsultationCopies(int $libroId): bool + { + if (!$this->schemaReady()) { + return false; + } + $stmt = $this->db->prepare( + "SELECT COALESCE(SUM(solo_consultazione = 1), 0) AS consultation, + COALESCE(SUM(solo_consultazione = 0 AND stato NOT IN ('perso', 'danneggiato', 'manutenzione', 'in_restauro', 'in_trasferimento')), 0) AS lendable + FROM copie WHERE libro_id = ?" + ); + $stmt->bind_param('i', $libroId); + $stmt->execute(); + $result = $stmt->get_result(); + $row = $result ? $result->fetch_assoc() : null; + $stmt->close(); + return is_array($row) && (int) $row['consultation'] > 0 && (int) $row['lendable'] === 0; + } + + /** + * SQL predicate (no placeholders) excluding consultation-only copies from + * lendable capacity and from copy allocation. $alias is the copie alias + * (empty string for an unaliased table). Always true before the 0.9.0 + * migration has run. + */ + public function lendableCopySql(string $alias = 'c'): string + { + if (!$this->schemaReady()) { + return '1 = 1'; + } + return ($alias !== '' ? $alias . '.' : '') . 'solo_consultazione = 0'; + } + + /** + * Whether the 0.9.0 circulation tables and columns exist. Code ships + * before its migration runs during an upgrade: until then every rule is + * the global one, exactly as before. + */ + public function schemaReady(): bool + { + $key = (string) spl_object_id($this->db); + if (!array_key_exists($key, self::$schemaReady)) { + $ready = false; + try { + $result = $this->db->query( + "SELECT + (SELECT COUNT(*) FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ('tipologie_lettore', 'regole_prestito')) + + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND ((TABLE_NAME = 'utenti' AND COLUMN_NAME = 'tipologia_id') + OR (TABLE_NAME = 'copie' AND COLUMN_NAME = 'solo_consultazione')))" + ); + $ready = $result instanceof \mysqli_result && (int) ($result->fetch_row()[0] ?? 0) === 4; + } catch (\Throwable) { + $ready = false; + } + self::$schemaReady[$key] = $ready; + } + return self::$schemaReady[$key]; + } +} diff --git a/app/Services/LoanRequestGate.php b/app/Services/LoanRequestGate.php index d7cce1cdf..db7d340fc 100644 --- a/app/Services/LoanRequestGate.php +++ b/app/Services/LoanRequestGate.php @@ -163,6 +163,7 @@ private function findAssignableInLibraryCopyThrough(int $bookId, string $endDate FROM copie c WHERE c.libro_id = ? AND c.stato IN ('disponibile', 'prenotato') + AND " . (new \App\Services\CirculationPolicy($this->db))->lendableCopySql('c') . " AND NOT EXISTS ( SELECT 1 FROM prestiti p diff --git a/app/Services/ReservationReassignmentService.php b/app/Services/ReservationReassignmentService.php index 2c60d58f8..f95039119 100644 --- a/app/Services/ReservationReassignmentService.php +++ b/app/Services/ReservationReassignmentService.php @@ -640,6 +640,7 @@ private function findAvailableCopyExcluding( FROM copie c WHERE c.libro_id = ? AND c.stato NOT IN ('perso', 'danneggiato', 'manutenzione', 'in_restauro', 'in_trasferimento') + AND " . (new \App\Services\CirculationPolicy($this->db))->lendableCopySql('c') . " AND NOT EXISTS ( SELECT 1 FROM prestiti own diff --git a/app/Support/DataIntegrity.php b/app/Support/DataIntegrity.php index d8c5dec3e..094b206b0 100644 --- a/app/Support/DataIntegrity.php +++ b/app/Support/DataIntegrity.php @@ -26,6 +26,9 @@ public function recalculateAllBookAvailability(bool $insideTransaction = false): // stored dates are app-tz, so CURDATE() (DB session tz) drifts by a day in the window // around local midnight. Validated Y-m-d string → safe to interpolate. $today = \App\Support\DateHelper::today(); + // Consultation-only copies are never lendable capacity: the public + // counter excludes them like CapacityService and the allocators do. + $lendable = (new \App\Services\CirculationPolicy($this->db))->lendableCopySql('c'); try { if (!$insideTransaction) { @@ -74,6 +77,10 @@ public function recalculateAllBookAvailability(bool $insideTransaction = false): // - Include copie 'disponibile' e 'prenotato' (fisicamente in biblioteca) // - Esclude quelle con prestiti attivi già iniziati (in_corso, in_ritardo, da_ritirare, prenotato con data <= oggi) // - Sottrae le prenotazioni attive che coprono la data odierna (slot-level) + // - Esclude le copie di sola consultazione (copie.solo_consultazione): non sono + // capacità prestabile (CapacityService, allocatori), quindi non contano come + // disponibili e non rendono 'disponibile' lo stato del libro. copie_totali + // resta il numero di copie fisiche in circolazione, consultazione compresa. // Note: 'da_ritirare' conta come slot occupato anche se la copia fisica è ancora in biblioteca $stmt = $this->db->prepare(" UPDATE libri l @@ -91,6 +98,7 @@ public function recalculateAllBookAvailability(bool $insideTransaction = false): ) WHERE c.libro_id = l.id AND c.stato IN ('disponibile', 'prenotato') + AND {$lendable} AND p.id IS NULL ) - ( SELECT COUNT(*) @@ -124,6 +132,7 @@ public function recalculateAllBookAvailability(bool $insideTransaction = false): ) WHERE c.libro_id = l.id AND c.stato IN ('disponibile', 'prenotato') + AND {$lendable} AND p.id IS NULL ) - ( SELECT COUNT(*) @@ -139,7 +148,7 @@ public function recalculateAllBookAvailability(bool $insideTransaction = false): WHEN (SELECT COUNT(*) FROM copie c WHERE c.libro_id = l.id AND c.stato = 'prestato') > 0 THEN 'prestato' WHEN (SELECT COUNT(*) FROM copie c WHERE c.libro_id = l.id AND c.stato = 'prenotato') > 0 THEN 'prenotato' -- Copia in biblioteca ma assorbita da una prenotazione attiva → riservata (A1) - WHEN (SELECT COUNT(*) FROM copie c WHERE c.libro_id = l.id AND c.stato IN ('disponibile', 'prenotato')) > 0 THEN 'prenotato' + WHEN (SELECT COUNT(*) FROM copie c WHERE c.libro_id = l.id AND c.stato IN ('disponibile', 'prenotato') AND {$lendable}) > 0 THEN 'prenotato' -- Il libro HA copie ma nessuna è circolante (tutte in manutenzione/ -- in_restauro/perso/danneggiato/in_trasferimento) e nessuna è -- prestata/prenotata → non è disponibile. Senza questo ramo lo stato @@ -306,6 +315,7 @@ public function recalculateBookAvailability(int $bookId, bool $insideTransaction // App-timezone "today" (see recalculateAllBookAvailability) — interpolated in place of // the DB CURDATE() so the availability comparisons don't drift around local midnight. $today = \App\Support\DateHelper::today(); + $lendable = (new \App\Services\CirculationPolicy($this->db))->lendableCopySql('c'); try { if (!$insideTransaction) { $this->db->begin_transaction(); @@ -363,6 +373,10 @@ public function recalculateBookAvailability(int $bookId, bool $insideTransaction // - Include copie 'disponibile' e 'prenotato' (fisicamente in biblioteca) // - Esclude quelle con prestiti attivi già iniziati (in_corso, in_ritardo, da_ritirare, prenotato con data <= oggi) // - Sottrae le prenotazioni attive che coprono la data odierna (slot-level) + // - Esclude le copie di sola consultazione (copie.solo_consultazione): non sono + // capacità prestabile (CapacityService, allocatori), quindi non contano come + // disponibili e non rendono 'disponibile' lo stato del libro. copie_totali + // resta il numero di copie fisiche in circolazione, consultazione compresa. // Note: 'da_ritirare' conta come slot occupato anche se la copia fisica è ancora in biblioteca $stmt = $this->db->prepare(" UPDATE libri l @@ -380,6 +394,7 @@ public function recalculateBookAvailability(int $bookId, bool $insideTransaction ) WHERE c.libro_id = ? AND c.stato IN ('disponibile', 'prenotato') + AND {$lendable} AND p.id IS NULL ) - ( SELECT COUNT(*) @@ -413,6 +428,7 @@ public function recalculateBookAvailability(int $bookId, bool $insideTransaction ) WHERE c.libro_id = ? AND c.stato IN ('disponibile', 'prenotato') + AND {$lendable} AND p.id IS NULL ) - ( SELECT COUNT(*) @@ -430,7 +446,7 @@ public function recalculateBookAvailability(int $bookId, bool $insideTransaction -- Copia fisicamente in biblioteca ma assorbita da una prenotazione attiva: -- è riservata, non 'disponibile' né stantia (A1). Evita di lasciare il libro -- bloccato su uno stato precedente (es. 'prestato') dopo la restituzione. - WHEN (SELECT COUNT(*) FROM copie c WHERE c.libro_id = ? AND c.stato IN ('disponibile', 'prenotato')) > 0 THEN 'prenotato' + WHEN (SELECT COUNT(*) FROM copie c WHERE c.libro_id = ? AND c.stato IN ('disponibile', 'prenotato') AND {$lendable}) > 0 THEN 'prenotato' -- Come nel ricalcolo globale: copie presenti ma tutte fuori -- circolazione e nessuna prestata/prenotata → 'non_disponibile'. WHEN (SELECT COUNT(*) FROM copie c WHERE c.libro_id = ?) > 0 THEN 'non_disponibile' diff --git a/app/Support/LoanEligibility.php b/app/Support/LoanEligibility.php index e7e122e40..fbbbfe16d 100644 --- a/app/Support/LoanEligibility.php +++ b/app/Support/LoanEligibility.php @@ -77,6 +77,17 @@ public static function errorMessage(string $code): string return __('Utente sospeso: prestiti non consentiti'); case 'card_expired': return __('Tessera scaduta: rinnovala per richiedere prestiti'); + // Circulation rules (CirculationPolicy: patron type × material). + case 'max_loans_reached': + return __('Hai raggiunto il numero massimo di prestiti attivi consentiti'); + case 'media_limit_reached': + return __('Hai raggiunto il numero massimo di prestiti per questo tipo di materiale'); + case 'not_loanable': + return __('Questo materiale è solo in consultazione: non può essere preso in prestito'); + case 'reservations_disabled': + return __('Le prenotazioni non sono consentite per la tua tipologia di lettore'); + case 'reservation_limit_reached': + return __('Hai raggiunto il numero massimo di prenotazioni e richieste in attesa'); default: return __('Utente non idoneo al prestito'); } diff --git a/app/Support/MaintenanceService.php b/app/Support/MaintenanceService.php index 6a305a92f..bcbc5b9e0 100644 --- a/app/Support/MaintenanceService.php +++ b/app/Support/MaintenanceService.php @@ -43,7 +43,7 @@ public function __construct(mysqli $db) * non basta, due admin in sessioni diverse eseguirebbero entrambi runAll(). * * @param int $cooldownMinutes Minimum minutes between runs (default: 60) - * @return array{skipped?: bool, reason?: string, scheduled_loans_activated?: int, invalid_ready_pickups_repaired?: int, expired_waitlist_reservations?: int, reservations_converted?: int, expired_reservations?: int, expired_pickups?: int, overdue_loans_updated?: int, expiration_warnings?: int, overdue_notifications?: int, wishlist_notifications?: int, reservation_notifications_retried?: int, pickup_notifications_retried?: int, ics_generated?: bool, errors?: array} Results or skip status + * @return array{skipped?: bool, reason?: string, scheduled_loans_activated?: int, invalid_ready_pickups_repaired?: int, expired_waitlist_reservations?: int, reservations_converted?: int, expired_reservations?: int, expired_pickups?: int, overdue_loans_updated?: int, expiration_warnings?: int, overdue_notifications?: int, loan_recalls?: int, pickup_reminders?: int, card_expiry_warnings?: int, wishlist_notifications?: int, reservation_notifications_retried?: int, pickup_notifications_retried?: int, ics_generated?: bool, errors?: array} Results or skip status */ public function runIfNeeded(int $cooldownMinutes = 60): array { @@ -107,7 +107,7 @@ public function runIfNeeded(int $cooldownMinutes = 60): array * overdue loan updates, expired pickups, notifications, and ICS calendar generation. * Each task is wrapped in try-catch to prevent failures from blocking others. * - * @return array{skipped: true, reason: 'in_progress'}|array{scheduled_loans_activated: int, invalid_ready_pickups_repaired: int, expired_waitlist_reservations: int, reservations_converted: int, expired_reservations: int, expired_pickups: int, overdue_loans_updated: int, expiration_warnings: int, overdue_notifications: int, wishlist_notifications: int, reservation_notifications_retried: int, pickup_notifications_retried: int, queued_emails_retried: int, ics_generated: bool, attendance_purged?: int, errors: array} Results for each maintenance task, or the skip marker when another connection holds the maintenance lock + * @return array{skipped: true, reason: 'in_progress'}|array{scheduled_loans_activated: int, invalid_ready_pickups_repaired: int, expired_waitlist_reservations: int, reservations_converted: int, expired_reservations: int, expired_pickups: int, overdue_loans_updated: int, expiration_warnings: int, overdue_notifications: int, loan_recalls: int, pickup_reminders: int, card_expiry_warnings: int, wishlist_notifications: int, reservation_notifications_retried: int, pickup_notifications_retried: int, queued_emails_retried: int, ics_generated: bool, attendance_purged?: int, errors: array} Results for each maintenance task, or the skip marker when another connection holds the maintenance lock */ public function runAll(): array { @@ -147,7 +147,7 @@ public function runAll(): array } } - /** @return array{scheduled_loans_activated: int, invalid_ready_pickups_repaired: int, expired_waitlist_reservations: int, reservations_converted: int, expired_reservations: int, expired_pickups: int, overdue_loans_updated: int, expiration_warnings: int, overdue_notifications: int, wishlist_notifications: int, reservation_notifications_retried: int, pickup_notifications_retried: int, queued_emails_retried: int, ics_generated: bool, attendance_purged?: int, errors: array} */ + /** @return array{scheduled_loans_activated: int, invalid_ready_pickups_repaired: int, expired_waitlist_reservations: int, reservations_converted: int, expired_reservations: int, expired_pickups: int, overdue_loans_updated: int, expiration_warnings: int, overdue_notifications: int, loan_recalls: int, pickup_reminders: int, card_expiry_warnings: int, wishlist_notifications: int, reservation_notifications_retried: int, pickup_notifications_retried: int, queued_emails_retried: int, ics_generated: bool, attendance_purged?: int, errors: array} */ private function runAllLocked(): array { $results = [ @@ -160,6 +160,9 @@ private function runAllLocked(): array 'overdue_loans_updated' => 0, 'expiration_warnings' => 0, 'overdue_notifications' => 0, + 'loan_recalls' => 0, + 'pickup_reminders' => 0, + 'card_expiry_warnings' => 0, 'wishlist_notifications' => 0, 'reservation_notifications_retried' => 0, 'pickup_notifications_retried' => 0, @@ -248,7 +251,9 @@ private function runAllLocked(): array $notificationResults = $this->runNotifications(); $results['expiration_warnings'] = $notificationResults['expiration_warnings']; $results['overdue_notifications'] = $notificationResults['overdue_notifications']; + $results['loan_recalls'] = $notificationResults['loan_recalls']; $results['pickup_reminders'] = $notificationResults['pickup_reminders']; + $results['card_expiry_warnings'] = $notificationResults['card_expiry_warnings']; $results['wishlist_notifications'] = $notificationResults['wishlist_notifications']; } catch (\Throwable $e) { $results['errors'][] = 'runNotifications: ' . $e->getMessage(); @@ -463,14 +468,16 @@ public function generateIcsCalendar(): bool * Delegates to NotificationService to send loan expiration warnings, * overdue loan notifications, and wishlist availability alerts. * - * @return array{expiration_warnings: int, overdue_notifications: int, pickup_reminders: int, wishlist_notifications: int, errors: array} Notification counts and any errors + * @return array{expiration_warnings: int, overdue_notifications: int, loan_recalls: int, pickup_reminders: int, card_expiry_warnings: int, wishlist_notifications: int, errors: array} Notification counts and any errors */ public function runNotifications(): array { $results = [ 'expiration_warnings' => 0, 'overdue_notifications' => 0, + 'loan_recalls' => 0, 'pickup_reminders' => 0, + 'card_expiry_warnings' => 0, 'wishlist_notifications' => 0, 'errors' => [] ]; @@ -479,9 +486,14 @@ public function runNotifications(): array $notificationService = new NotificationService($this->db); $notifResults = $notificationService->runAutomaticNotifications(); + // Every counter runAutomaticNotifications() reports, so the cron + // log of full-maintenance.php names the recalls and the card + // expiry warnings it sent, like automatic-notifications.php does. $results['expiration_warnings'] = $notifResults['expiration_warnings'] ?? 0; $results['overdue_notifications'] = $notifResults['overdue_notifications'] ?? 0; + $results['loan_recalls'] = $notifResults['loan_recalls'] ?? 0; $results['pickup_reminders'] = $notifResults['pickup_reminders'] ?? 0; + $results['card_expiry_warnings'] = $notifResults['card_expiry_warnings'] ?? 0; $results['wishlist_notifications'] = $notifResults['wishlist_notifications'] ?? 0; if (!empty($notifResults['errors'])) { @@ -937,6 +949,7 @@ public function activateScheduledLoans(): int FROM copie c WHERE c.libro_id = ? AND c.stato IN ('disponibile', 'prenotato') + AND " . (new \App\Services\CirculationPolicy($this->db))->lendableCopySql('c') . " AND NOT EXISTS ( SELECT 1 FROM prestiti own diff --git a/app/Support/NotificationService.php b/app/Support/NotificationService.php index 635d74ea6..ae1071292 100644 --- a/app/Support/NotificationService.php +++ b/app/Support/NotificationService.php @@ -34,6 +34,8 @@ class NotificationService { // Serialized by a connection-scoped lock and acknowledged through // pickup_reminder_sent_for: an outbox copy would send it twice. 'loan_pickup_reminder', + // Acknowledged through utenti.card_expiry_warning_sent_for. + 'card_expiring', ]; /** @@ -821,6 +823,110 @@ public function sendOverdueLoanNotifications(): int { return $sentCount; } + /** + * Warn patrons whose library card expires within + * loans.card_expiry_warning_days (0 = off, the default). + * + * Only standard/premium accounts (admin and staff have no card) that are + * active and have an email. The expiry date is acknowledged in + * utenti.card_expiry_warning_sent_for only after delivery: it goes out + * once per expiry, and a renewed card (new date) re-arms it. An advisory + * lock per patron serializes concurrent cron/HTTP workers. + */ + public function sendCardExpiryWarnings(): int { + $warningDays = max(0, min(365, (int) ((new \App\Models\SettingsRepository($this->db))->get('loans', 'card_expiry_warning_days', '0') ?? 0))); + if ($warningDays <= 0 || !(new \App\Services\CirculationPolicy($this->db))->schemaReady()) { + return 0; + } + $hasFlag = $this->db->query("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'utenti' AND COLUMN_NAME = 'card_expiry_warning_sent_for'"); + if (!$hasFlag instanceof \mysqli_result || (int) ($hasFlag->fetch_row()[0] ?? 0) === 0) { + return 0; + } + + $sentCount = 0; + try { + $today = DateHelper::today(); + $lastDay = (new \DateTimeImmutable($today))->modify('+' . $warningDays . ' days')->format('Y-m-d'); + $eligibleSql = " + SELECT id, email, data_scadenza_tessera, codice_tessera, + CONCAT(nome, ' ', cognome) AS utente_nome + FROM utenti + WHERE stato = 'attivo' + AND tipo_utente IN ('standard', 'premium') + AND data_scadenza_tessera IS NOT NULL + AND data_scadenza_tessera >= ? AND data_scadenza_tessera <= ? + AND (card_expiry_warning_sent_for IS NULL OR card_expiry_warning_sent_for <> data_scadenza_tessera) + AND email IS NOT NULL AND TRIM(email) <> '' + "; + $stmt = $this->db->prepare($eligibleSql); + $stmt->bind_param('ss', $today, $lastDay); + $stmt->execute(); + $users = $stmt->get_result()->fetch_all(MYSQLI_ASSOC); + $stmt->close(); + + $database = (string) $this->db->query('SELECT DATABASE()')->fetch_row()[0]; + $lockPrefix = 'pinakes_card_expiry_' . substr(hash('sha256', $database), 0, 24) . '_'; + foreach ($users as $user) { + $userId = (int) $user['id']; + $lockName = $lockPrefix . $userId; + $locked = false; + try { + $claim = $this->db->prepare('SELECT GET_LOCK(?, 0)'); + $claim->bind_param('s', $lockName); + $claim->execute(); + $locked = (int) $claim->get_result()->fetch_row()[0] === 1; + $claim->close(); + if (!$locked) { + continue; + } + // Re-read under the lock: another worker may have sent it, + // or the desk may have renewed the card meanwhile. + $fresh = $this->db->prepare($eligibleSql . ' AND id = ?'); + $fresh->bind_param('ssi', $today, $lastDay, $userId); + $fresh->execute(); + $user = $fresh->get_result()->fetch_assoc(); + $fresh->close(); + if (!$user) { + continue; + } + $expiry = (string) $user['data_scadenza_tessera']; + $recipientLocale = $this->resolveRecipientLocale((string) $user['email']); + $daysLeft = (int) (new \DateTimeImmutable($today))->diff(new \DateTimeImmutable($expiry))->days; + $sent = $this->sendWithRetry((string) $user['email'], 'card_expiring', [ + 'utente_nome' => $user['utente_nome'], + 'codice_tessera' => (string) $user['codice_tessera'], + 'data_scadenza_tessera' => $this->formatEmailDate($expiry, false, $recipientLocale), + 'giorni_rimasti' => $daysLeft === 0 ? $this->translateInLocale('oggi', $recipientLocale) : (string) $daysLeft, + ]); + if ($sent) { + $ack = $this->db->prepare('UPDATE utenti SET card_expiry_warning_sent_for = ? WHERE id = ? AND data_scadenza_tessera = ?'); + $ack->bind_param('sis', $expiry, $userId, $expiry); + $ack->execute(); + $ack->close(); + $sentCount++; + } + } catch (\Throwable $e) { + SecureLogger::error("Failed to send card expiry warning for user {$userId}: " . $e->getMessage()); + } finally { + if ($locked) { + try { + $release = $this->db->prepare('SELECT RELEASE_LOCK(?)'); + $release->bind_param('s', $lockName); + $release->execute(); + $release->close(); + } catch (\Throwable $releaseError) { + SecureLogger::error("Failed to release card expiry lock for user {$userId}: " . $releaseError->getMessage()); + } + } + } + } + } catch (\Throwable $e) { + SecureLogger::error('Failed to send card expiry warnings: ' . $e->getMessage()); + } + + return $sentCount; + } + /** * Reminder to collect a book that is waiting for pickup, sent * loans.pickup_reminder_days before the pickup deadline (0 = off). @@ -1430,6 +1536,7 @@ public function runAutomaticNotifications(): array { 'overdue_notifications' => 0, 'loan_recalls' => 0, 'pickup_reminders' => 0, + 'card_expiry_warnings' => 0, 'wishlist_notifications' => 0, 'errors' => [] ]; @@ -1445,6 +1552,7 @@ public function runAutomaticNotifications(): array { // recall query requires overdue_notification_sent = 1. $results['loan_recalls'] = $this->sendLoanRecalls(); $results['pickup_reminders'] = $this->sendPickupReminders(); + $results['card_expiry_warnings'] = $this->sendCardExpiryWarnings(); $results['wishlist_notifications'] = $this->checkAndNotifyWishlistAvailability(); } catch (\Throwable $e) { diff --git a/app/Support/PatronTypeAssignment.php b/app/Support/PatronTypeAssignment.php new file mode 100644 index 000000000..d6b6b383a --- /dev/null +++ b/app/Support/PatronTypeAssignment.php @@ -0,0 +1,226 @@ + $data + */ + public static function fromSubmission(mysqli $db, array $data, ?int $current = null): ?int + { + if (!array_key_exists('tipologia_id', $data)) { + return $current; + } + $raw = $data['tipologia_id']; + if ($raw === null || $raw === '' || $raw === '0') { + return null; + } + if (!is_scalar($raw) || preg_match('/^\d+$/', (string) $raw) !== 1) { + return $current; + } + $id = (int) $raw; + $types = (new CirculationPolicy($db))->types(); + if (!isset($types[$id])) { + return $current; + } + return ((int) ($types[$id]['attiva'] ?? 1) === 1 || $id === $current) ? $id : $current; + } + + /** Store the patron type of $userId (no-op until the 0.9.0 schema exists). */ + public static function save(mysqli $db, int $userId, ?int $typeId): void + { + if (!(new CirculationPolicy($db))->schemaReady()) { + return; + } + $stmt = $db->prepare('UPDATE utenti SET tipologia_id = ? WHERE id = ?'); + $stmt->bind_param('ii', $typeId, $userId); + $stmt->execute(); + $stmt->close(); + } + + /** The stored patron type of $userId (null = default or not migrated). */ + public static function of(mysqli $db, int $userId): ?int + { + if (!(new CirculationPolicy($db))->schemaReady()) { + return null; + } + $stmt = $db->prepare('SELECT tipologia_id FROM utenti WHERE id = ?'); + $stmt->bind_param('i', $userId); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + return is_array($row) && $row['tipologia_id'] !== null ? (int) $row['tipologia_id'] : null; + } + + /** + * Card expiry computed from the patron type ($typeId null = default type): + * today + durata_tessera_giorni, or null when the type has no card duration. + */ + public static function automaticCardExpiry(mysqli $db, ?int $typeId): ?string + { + $days = (new CirculationPolicy($db))->forType($typeId)->user->cardDurationDays; + return $days !== null ? self::todayPlus($days) : null; + } + + /** + * SQL condition of the user-list "patron type" filter, or null when the + * filter is empty or invalid. $column is the utenti.tipologia_id column + * reference. Users without a type belong to the default one. + * + * @return array{sql: string, types: string, params: list}|null + */ + public static function filterCondition(mysqli $db, mixed $value, string $column = 'tipologia_id'): ?array + { + $policy = new CirculationPolicy($db); + if (!$policy->schemaReady() || !is_scalar($value) || preg_match('/^\d+$/', (string) $value) !== 1) { + return null; + } + $id = (int) $value; + if (!isset($policy->types()[$id])) { + return null; + } + if ($id === $policy->defaultTypeId()) { + return ['sql' => "({$column} = ? OR {$column} IS NULL)", 'types' => 'i', 'params' => [$id]]; + } + return ['sql' => "{$column} = ?", 'types' => 'i', 'params' => [$id]]; + } + + /** + * Display name of a stored patron type (null/unknown = the default type). + */ + public static function nameOf(mysqli $db, mixed $typeId, ?CirculationPolicy $policy = null): string + { + return self::fieldOf($db, $typeId, 'nome', $policy); + } + + /** + * Code of a stored patron type (null/unknown = the default type). The + * users CSV export writes it beside the name: the import resolves a type + * by its code first, so an export re-imports exactly on an installation + * with the same types, even when two of them share a name. + */ + public static function codeOf(mysqli $db, mixed $typeId, ?CirculationPolicy $policy = null): string + { + return self::fieldOf($db, $typeId, 'codice', $policy); + } + + /** + * $policy: pass one instance when resolving many rows (the users export), + * so the patron types are read once instead of once per call. + */ + private static function fieldOf(mysqli $db, mixed $typeId, string $field, ?CirculationPolicy $policy = null): string + { + $policy ??= new CirculationPolicy($db); + $types = $policy->types(); + $id = is_numeric($typeId) ? (int) $typeId : null; + $id = $id !== null && isset($types[$id]) ? $id : $policy->defaultTypeId(); + return $id !== null ? (string) ($types[$id][$field] ?? '') : ''; + } + + /** Card validity used by "Rinnova tessera" when the patron type sets none. */ + public const DEFAULT_RENEWAL_DAYS = 365; + + /** + * Renew the library card of $userId: expiry = today + the card duration + * of the patron type (DEFAULT_RENEWAL_DAYS when the type sets none), an + * expired account becomes active again and the expiry warning re-arms. + * Returns the new expiry date, or null when the account has no card + * (admin/staff) or does not exist. + */ + public static function renewCard(mysqli $db, int $userId): ?string + { + $stmt = $db->prepare('SELECT id, tipo_utente, stato, data_scadenza_tessera FROM utenti WHERE id = ?'); + $stmt->bind_param('i', $userId); + $stmt->execute(); + $user = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + if (!$user || !in_array($user['tipo_utente'], ['standard', 'premium'], true)) { + return null; + } + $policy = new CirculationPolicy($db); + $days = $policy->forUser($userId)->cardDurationDays ?? self::DEFAULT_RENEWAL_DAYS; + $expiry = self::todayPlus($days); + $state = $user['stato'] === 'scaduto' ? 'attivo' : (string) $user['stato']; + $flagSql = self::hasExpiryWarningFlag($db) ? ', card_expiry_warning_sent_for = NULL' : ''; + $update = $db->prepare('UPDATE utenti SET data_scadenza_tessera = ?, stato = ?' . $flagSql . ' WHERE id = ?'); + $update->bind_param('ssi', $expiry, $state, $userId); + $update->execute(); + $update->close(); + ActivityLog::recordEntityEvent( + $db, + 'utenti', + $userId, + 'user.card_renewed', + ['data_scadenza_tessera' => $user['data_scadenza_tessera'], 'stato' => $user['stato']], + ['data_scadenza_tessera' => $expiry, 'stato' => $state], + source: 'manual' + ); + return $expiry; + } + + /** + * Apply the patron type's card duration on registration approval: only + * when the type sets one (otherwise the stored expiry is kept). + */ + public static function applyCardDurationOnApproval(mysqli $db, int $userId): void + { + $policy = new CirculationPolicy($db); + $days = $policy->forUser($userId)->cardDurationDays; + if ($days === null) { + return; + } + $expiry = self::todayPlus($days); + $stmt = $db->prepare("UPDATE utenti SET data_scadenza_tessera = ? WHERE id = ? AND tipo_utente IN ('standard', 'premium')"); + $stmt->bind_param('si', $expiry, $userId); + $stmt->execute(); + $stmt->close(); + } + + /** + * today + $days as a calendar date. Card expiries are not loan due dates: + * they deliberately bypass CirculationPolicy::dueDateFor(), which the + * closures calendar shifts to the next open day. + */ + public static function todayPlus(int $days): string + { + return (new \DateTimeImmutable(DateHelper::today()))->modify('+' . max(0, $days) . ' days')->format('Y-m-d'); + } + + private static function hasExpiryWarningFlag(mysqli $db): bool + { + $result = $db->query("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'utenti' AND COLUMN_NAME = 'card_expiry_warning_sent_for'"); + return $result instanceof \mysqli_result && (int) ($result->fetch_row()[0] ?? 0) === 1; + } + + /** + * Patron types an operator may assign: the active ones plus $currentId + * (kept selectable even after it was deactivated). + * + * @return array> + */ + public static function choices(mysqli $db, ?int $currentId = null): array + { + $out = []; + foreach ((new CirculationPolicy($db))->types() as $id => $type) { + if ((int) $type['attiva'] === 1 || $id === $currentId) { + $out[$id] = $type; + } + } + return $out; + } +} diff --git a/app/Support/PatronTypeImport.php b/app/Support/PatronTypeImport.php index 4fd2bf236..3fd6c5037 100644 --- a/app/Support/PatronTypeImport.php +++ b/app/Support/PatronTypeImport.php @@ -13,8 +13,14 @@ * against that contract but stands on its own: when the table, its `codice` * column or `utenti.tipologia_id` is missing, available() is false, the import * does not offer the column, and assign() is a no-op. Once the patron types - * schema exists the very same code starts assigning the type by its code, with - * no other change to the import. + * schema exists the very same code starts assigning the type, with no other + * change to the import. + * + * A value of the patron type column resolves by the type's `codice` first, + * then by its `nome` (both trimmed and case-insensitive, active types only): + * the users CSV export writes the name and the code, a hand-made file may + * carry either. A name shared by two active types never resolves, the import + * does not guess between them. * * The table and column names are constructor parameters only so the unit test * can run against sandbox tables. @@ -23,6 +29,8 @@ final class PatronTypeImport { /** @var array|null code (lowercase) => id */ private ?array $codes = null; + /** @var array name (lowercase) => id, null when two active types share the name */ + private array $names = []; private ?bool $available = null; public function __construct( @@ -45,19 +53,19 @@ public function available(): bool } /** - * Assign the patron type with code $code to the user. + * Assign the patron type whose code, or else whose name, is $value to the user. * - * @return 'skipped'|'assigned'|'unknown' skipped = nothing to do (no code, - * or no patron types schema); unknown = the code matches no type, - * so the reader keeps the default type + * @return 'skipped'|'assigned'|'unknown' skipped = nothing to do (no value, + * or no patron types schema); unknown = the value matches no type + * by code or by name, so the reader keeps the default type */ - public function assign(int $userId, ?string $code): string + public function assign(int $userId, ?string $value): string { - $code = trim((string) $code); - if ($code === '' || $userId <= 0 || !$this->available()) { + $value = trim((string) $value); + if ($value === '' || $userId <= 0 || !$this->available()) { return 'skipped'; } - $typeId = $this->idFor($code); + $typeId = $this->idFor($value); if ($typeId === null) { return 'unknown'; } @@ -71,19 +79,46 @@ public function assign(int $userId, ?string $code): string return 'assigned'; } - private function idFor(string $code): ?int + private function idFor(string $value): ?int { - if ($this->codes === null) { - $this->codes = []; - $result = $this->db->query("SELECT id, codice FROM `{$this->typesTable}`"); - if ($result instanceof \mysqli_result) { - while ($row = $result->fetch_assoc()) { - $this->codes[mb_strtolower((string) $row['codice'], 'UTF-8')] = (int) $row['id']; - } - $result->free(); + $this->load(); + $key = self::key($value); + return $this->codes[$key] ?? $this->names[$key] ?? null; + } + + /** Read the active types once: their codes, and their names where the table has them. */ + private function load(): void + { + if ($this->codes !== null) { + return; + } + $this->codes = []; + $this->names = []; + // A deactivated type is not offered in the user form either, so + // neither its code nor its name resolves here: the reader keeps the default. + $active = $this->columnExists($this->typesTable, 'attiva') ? ' WHERE attiva = 1' : ''; + $withName = $this->columnExists($this->typesTable, 'nome'); + $columns = $withName ? 'id, codice, nome' : 'id, codice'; + $result = $this->db->query("SELECT {$columns} FROM `{$this->typesTable}`{$active}"); + if (!($result instanceof \mysqli_result)) { + return; + } + while ($row = $result->fetch_assoc()) { + $id = (int) $row['id']; + $this->codes[self::key((string) $row['codice'])] = $id; + $name = $withName ? self::key((string) $row['nome']) : ''; + if ($name !== '') { + // Two active types with one name: neither is chosen. + $this->names[$name] = array_key_exists($name, $this->names) ? null : $id; } } - return $this->codes[mb_strtolower($code, 'UTF-8')] ?? null; + $result->free(); + } + + /** The lookup key of a code or a name: trimmed, lower case. */ + private static function key(string $value): string + { + return mb_strtolower(trim($value), 'UTF-8'); } private function columnExists(string $table, string $column): bool diff --git a/app/Support/SettingsMailTemplates.php b/app/Support/SettingsMailTemplates.php index ecb979057..d9132a05c 100644 --- a/app/Support/SettingsMailTemplates.php +++ b/app/Support/SettingsMailTemplates.php @@ -430,6 +430,22 @@ public static function all(?string $locale = null): array

Se non ti serve più, puoi annullare il prestito dalla pagina «Prenotazioni» del tuo account: il libro passerà subito a qualcun altro.

Buona lettura!

+HTML, + ], + 'card_expiring' => [ + 'label' => __('Tessera in scadenza'), + 'description' => __("Inviata qualche giorno prima della scadenza della tessera del lettore. I giorni di preavviso si impostano in Impostazioni → Prestiti (0 = disattivata)."), + 'subject' => '🪪 La tua tessera scade il {{data_scadenza_tessera}}', + 'placeholders' => ['utente_nome', 'codice_tessera', 'data_scadenza_tessera', 'giorni_rimasti'], + 'body' => <<<'HTML' +

La tua tessera sta per scadere

+

Ciao {{utente_nome}},

+

la tua tessera della biblioteca (n. {{codice_tessera}}) scade il {{data_scadenza_tessera}} (giorni rimasti: {{giorni_rimasti}}).

+
+

Dopo la scadenza non potrai più chiedere prestiti né prenotare libri finché la tessera non viene rinnovata.

+
+

Per rinnovarla rivolgiti al banco della biblioteca durante gli orari di apertura.

+

A presto!

HTML, ], 'loan_pickup_cancelled' => [ diff --git a/app/Support/StatisticsSections.php b/app/Support/StatisticsSections.php index 7030c0628..3138bf8b7 100644 --- a/app/Support/StatisticsSections.php +++ b/app/Support/StatisticsSections.php @@ -13,8 +13,9 @@ * section, so a feature that is not installed (no table yet) simply does not * appear and the page keeps rendering. * - * EXTENSION POINTS — loansByPatronType(), fines() and stocktakeOutcomes() are - * placeholders that other 0.9.0 branches fill in, either by replacing the + * EXTENSION POINTS — fines() is a placeholder the fines branch fills in; + * loansByPatronType() (circulation policy) and stocktakeOutcomes() have their + * built-in provider. Each can still be filled or overridden, either by replacing the * body of the function or by registering a filter on its hook * (statistics.section.patron_types / .fines / .stocktake, initial value NULL, * argument the mysqli connection). They must keep the same signature and @@ -91,14 +92,61 @@ public static function loansByMediaType(mysqli $db): array } /** - * EXTENSION POINT — loans by patron type (tipologie_lettore). - * Filled by the patron-types branch; NULL hides the section. + * Loans in the last 12 months grouped by patron type (tipologie_lettore). + * A reader without a type counts under the default type, as the + * circulation policy treats them. Every active type is listed, also with + * no loans; a deactivated type only when it has loans in the period. A + * filter on statistics.section.patron_types still wins; no patron types + * schema (upgrade not run yet) hides the section. * * @return array{rows: list, note?: string}|null */ public static function loansByPatronType(mysqli $db): ?array { - return self::tableSection(Hooks::apply('statistics.section.patron_types', null, [$db])); + $hooked = self::tableSection(Hooks::apply('statistics.section.patron_types', null, [$db])); + if ($hooked !== null) { + return $hooked; + } + $policy = new \App\Services\CirculationPolicy($db); + if (!$policy->schemaReady()) { + return null; + } + $types = $policy->types(); + if ($types === []) { + return null; + } + $defaultId = $policy->defaultTypeId() ?? 0; + $states = "'" . implode("','", self::CIRCULATED_STATES) . "'"; + $sql = "SELECT COALESCE(u.tipologia_id, {$defaultId}) AS tipologia, COUNT(p.id) AS totale + FROM prestiti p + JOIN libri l ON l.id = p.libro_id AND l.deleted_at IS NULL + LEFT JOIN utenti u ON u.id = p.utente_id + WHERE p.stato IN ({$states}) + AND p.data_prestito >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH) + GROUP BY COALESCE(u.tipologia_id, {$defaultId})"; + $counts = []; + $res = $db->query($sql); + if ($res instanceof \mysqli_result) { + while ($row = $res->fetch_assoc()) { + $counts[(int) $row['tipologia']] = (int) $row['totale']; + } + $res->free(); + } + $rows = []; + foreach ($types as $id => $type) { + $count = $counts[$id] ?? 0; + if ((int) $type['attiva'] !== 1 && $count === 0) { + continue; + } + $rows[] = ['label' => (string) $type['nome'], 'value' => $count]; + } + $defaultName = $defaultId > 0 && isset($types[$defaultId]) ? (string) $types[$defaultId]['nome'] : ''; + return [ + 'rows' => $rows, + 'note' => $defaultName !== '' + ? sprintf(__('Ultimi 12 mesi. I lettori senza tipologia sono contati in «%s», la tipologia predefinita.'), $defaultName) + : __('Ultimi 12 mesi.'), + ]; } /** diff --git a/app/Support/Updater.php b/app/Support/Updater.php index fd0ceeb9d..42b8741c4 100644 --- a/app/Support/Updater.php +++ b/app/Support/Updater.php @@ -4092,6 +4092,11 @@ public function runMigrations(string $fromVersion, string $toVersion): array */ private function recalculateAfterMigrations(): void { + // Schema probes cached earlier in this request (before the migration + // created the circulation tables) must not survive into the recalc. + // This runs from the Updater of the release that ships the classes, + // so they are always there. + \App\Services\CirculationPolicy::resetSchemaCache(); try { $integrity = new \App\Support\DataIntegrity($this->db); $integrity->recalculateAllBookAvailability(); diff --git a/app/Support/UserImport.php b/app/Support/UserImport.php index c0c84dda1..4fa4db97c 100644 --- a/app/Support/UserImport.php +++ b/app/Support/UserImport.php @@ -63,7 +63,18 @@ final class UserImport 'data_scadenza_tessera' => ['datascadenzatessera', 'scadenzatessera', 'scadenza', 'expiry', 'expirydate', 'cardexpiry', 'expires', 'ablaufdatum', 'gultigbis', 'datedexpiration', 'udlobsdato'], 'note_utente' => ['noteutente', 'note', 'notes', 'notizen', 'noter', 'noteinterne'], 'locale' => ['locale', 'lingua', 'language', 'sprache', 'langue', 'sprog'], - 'tipologia' => ['tipologia', 'tipologialettore', 'patrontype', 'membertype', 'readertype', 'lesertyp', 'typedelecteur', 'lanertype'], + 'tipologia' => ['tipologia', 'tipologialettore', 'patrontype', 'membertype', 'readertype', 'lesertyp', 'leserkategorie', 'typedelecteur', 'categoriedelecteur', 'lanertype', 'lanerkategori'], + ]; + + /** + * Header spellings that name the patron type CODE column of the users CSV + * export ("Codice tipologia lettore" in the five languages). They map to + * `tipologia` like the name column, but guessMapping() prefers them: an + * export carries both columns and must re-import by code, which is exact + * even when two types share a name. + */ + private const PREFERRED_ALIASES = [ + 'tipologia' => ['codicetipologia', 'codicetipologialettore', 'tipologiacodice', 'tipologialettorecodice', 'patrontypecode', 'typecode', 'membertypecode', 'readertypecode', 'lesertypcode', 'codederleserkategorie', 'leserkategoriecode', 'codetypedelecteur', 'codedelacategoriedelecteur', 'lanertypekode', 'lanerkategorikode', 'kodeforlanerkategori'], ]; /** @@ -91,7 +102,7 @@ public static function targetFields(array $customDefinitions, bool $withPatronTy 'locale' => __('Lingua dell\'interfaccia'), ]; if ($withPatronType) { - $fields['tipologia'] = __('Tipologia lettore (codice)'); + $fields['tipologia'] = __('Tipologia lettore (codice o nome)'); } foreach ($customDefinitions as $def) { $fields['custom:' . $def['id']] = $def['etichetta']; @@ -146,7 +157,10 @@ public static function normalizeHeader(string $header): string /** * Suggested mapping: column index => field key ('' = ignore). Each field is - * proposed for one column at most (the first that matches). + * proposed for one column at most: the first that matches, except that a + * PREFERRED_ALIASES spelling wins over any other wherever it stands (the + * users export has the patron type name and then its code; the code is + * the column that re-imports exactly). * * @param list $headers * @param array $targetFields from targetFields() @@ -166,21 +180,38 @@ public static function guessMapping(array $headers, array $targetFields): array // installation carries localized headers). $byAlias[self::normalizeHeader($targetFields[$field])] ??= $field; } + $preferred = []; + foreach (self::PREFERRED_ALIASES as $field => $aliases) { + if (!isset($targetFields[$field])) { + continue; + } + foreach ($aliases as $alias) { + $byAlias[$alias] ??= $field; + if ($byAlias[$alias] === $field) { + $preferred[$alias] = true; + } + } + } foreach ($targetFields as $key => $label) { if (str_starts_with($key, 'custom:')) { $byAlias[self::normalizeHeader($label)] ??= $key; } } - $mapping = []; + $mapping = array_fill_keys(array_keys($headers), ''); $taken = []; - foreach ($headers as $i => $header) { - $norm = self::normalizeHeader((string) $header); - $field = $byAlias[$norm] ?? ''; - if ($field !== '' && !isset($taken[$field])) { - $mapping[$i] = $field; - $taken[$field] = true; - } else { - $mapping[$i] = ''; + // Two passes: the preferred spellings take their field first, then + // the other headers take what is left, in file order. + foreach ([true, false] as $preferredPass) { + foreach ($headers as $i => $header) { + $norm = self::normalizeHeader((string) $header); + if (isset($preferred[$norm]) !== $preferredPass) { + continue; + } + $field = $byAlias[$norm] ?? ''; + if ($field !== '' && !isset($taken[$field])) { + $mapping[$i] = $field; + $taken[$field] = true; + } } } return $mapping; diff --git a/app/Support/mail_templates/da_DK.php b/app/Support/mail_templates/da_DK.php index e0bb8f7d5..fe3a78676 100644 --- a/app/Support/mail_templates/da_DK.php +++ b/app/Support/mail_templates/da_DK.php @@ -202,6 +202,17 @@

Har du ikke længere brug for den, kan du annullere lånet på siden »Reservationer« på din konto: så går bogen straks videre til en anden.

God læselyst!

', + ], + 'card_expiring' => [ + 'subject' => '🪪 Dit lånerkort udløber den {{data_scadenza_tessera}}', + 'body' => '

Dit lånerkort udløber snart

+

Hej {{utente_nome}},

+

dit lånerkort (nr. {{codice_tessera}}) udløber den {{data_scadenza_tessera}} (dage tilbage: {{giorni_rimasti}}).

+
+

Når det er udløbet, kan du ikke låne eller reservere bøger, før kortet er fornyet.

+
+

Henvend dig ved bibliotekets skranke i åbningstiden for at forny det.

+

På gensyn!

', ], 'loan_pickup_cancelled' => [ 'subject' => '❌ Afhentning annulleret', diff --git a/app/Support/mail_templates/de_DE.php b/app/Support/mail_templates/de_DE.php index 90a58c3a6..337d42bb9 100644 --- a/app/Support/mail_templates/de_DE.php +++ b/app/Support/mail_templates/de_DE.php @@ -193,6 +193,17 @@

Wenn du es nicht mehr brauchst, kannst du die Ausleihe auf der Seite „Reservierungen“ deines Kontos stornieren: Das Buch geht dann sofort an jemand anderen.

Viel Freude beim Lesen!

', + ], + 'card_expiring' => [ + 'subject' => '🪪 Dein Bibliotheksausweis läuft am {{data_scadenza_tessera}} ab', + 'body' => '

Dein Bibliotheksausweis läuft bald ab

+

Hallo {{utente_nome}},

+

dein Bibliotheksausweis (Nr. {{codice_tessera}}) läuft am {{data_scadenza_tessera}} ab (verbleibende Tage: {{giorni_rimasti}}).

+
+

Nach dem Ablauf kannst du keine Bücher mehr ausleihen oder vormerken, bis der Ausweis verlängert wird.

+
+

Zum Verlängern wende dich während der Öffnungszeiten an die Theke der Bibliothek.

+

Bis bald!

', ], 'loan_pickup_cancelled' => [ 'subject' => '❌ Abholung storniert', diff --git a/app/Support/mail_templates/en_US.php b/app/Support/mail_templates/en_US.php index 43d1f63b1..817a948a0 100644 --- a/app/Support/mail_templates/en_US.php +++ b/app/Support/mail_templates/en_US.php @@ -193,6 +193,17 @@

If you no longer need it, you can cancel the loan from the “Reservations” page of your account: the book will go to someone else right away.

Happy reading!

', + ], + 'card_expiring' => [ + 'subject' => '🪪 Your library card expires on {{data_scadenza_tessera}}', + 'body' => '

Your library card is about to expire

+

Hi {{utente_nome}},

+

your library card (no. {{codice_tessera}}) expires on {{data_scadenza_tessera}} (days left: {{giorni_rimasti}}).

+
+

Once it has expired you will not be able to borrow or reserve books until the card is renewed.

+
+

To renew it, ask at the library desk during opening hours.

+

See you soon!

', ], 'loan_pickup_cancelled' => [ 'subject' => '❌ Pickup Cancelled', diff --git a/app/Support/mail_templates/fr_FR.php b/app/Support/mail_templates/fr_FR.php index 3b8d15ec5..67d7e4b91 100644 --- a/app/Support/mail_templates/fr_FR.php +++ b/app/Support/mail_templates/fr_FR.php @@ -193,6 +193,17 @@

Si vous n’en avez plus besoin, vous pouvez annuler le prêt depuis la page « Réservations » de votre compte : le livre passera aussitôt à quelqu’un d’autre.

Bonne lecture !

', + ], + 'card_expiring' => [ + 'subject' => '🪪 Votre carte de bibliothèque expire le {{data_scadenza_tessera}}', + 'body' => '

Votre carte de bibliothèque va expirer

+

Bonjour {{utente_nome}},

+

votre carte de bibliothèque (n° {{codice_tessera}}) expire le {{data_scadenza_tessera}} (jours restants : {{giorni_rimasti}}).

+
+

Après l\'expiration, vous ne pourrez plus emprunter ni réserver de livres tant que la carte n\'aura pas été renouvelée.

+
+

Pour la renouveler, adressez-vous à l\'accueil de la bibliothèque pendant les heures d\'ouverture.

+

À bientôt !

', ], 'loan_pickup_cancelled' => [ 'subject' => '❌ Retrait annulé', diff --git a/app/Views/frontend/book-detail.php b/app/Views/frontend/book-detail.php index 63a793ccd..f3a09d929 100644 --- a/app/Views/frontend/book-detail.php +++ b/app/Views/frontend/book-detail.php @@ -624,11 +624,23 @@ class="book-cover-large img-fluid pk-book__img
+ + + + + + + + + + + + @@ -1169,6 +1171,9 @@ class="inline-flex items-center gap-2 px-3 py-1.5 bg-gray-800 text-white text-sm + + + @@ -1186,6 +1191,16 @@ class="inline-flex items-center gap-2 px-3 py-1.5 bg-gray-800 text-white text-sm $canEdit = empty($copia['prestito_id']) || $bookAtLibrary; $canDelete = $canEdit && in_array($rawCopiaStatus, ['perso', 'danneggiato', 'manutenzione', 'in_restauro', 'in_trasferimento'], true); ?> + +
+ + + +
+
+ + + +
; // Staff use the configured duration; availability.loan_duration_days is // the reader default, which may be shortened by the reader's maximum. - const effectiveLoanDays = defaultLoanDays; + // 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; function addDaysToIso(isoDate, days) { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate || ''); @@ -475,7 +483,7 @@ function formatDisplayDate(iso) { // typed is never replaced by a later availability fetch; one this code // proposed follows the new subject (another book or borrower may have no // conflict, or a different one). - let autoProposedEnd = null; + let autoProposedEnd = ; function setLoanLimitHint(text) { const hint = document.getElementById('data_scadenza_hint'); if (!hint) return; @@ -768,6 +776,8 @@ function fetchBookAvailability(bookId) { if (requestId !== availabilityRequestId) return; if (data.success && data.availability) { + const staffDays = parseInt(data.availability.staff_loan_duration_days, 10); + effectiveLoanDays = Number.isFinite(staffDays) && staffDays > 0 ? staffDays : defaultLoanDays; const blockedByReservation = data.availability.has_active_reservation === true; // Build availabilityByDate map (same structure as frontend) availabilityByDate = {}; diff --git a/app/Views/prestiti/index.php b/app/Views/prestiti/index.php index 74a0e893d..9ca1ca507 100644 --- a/app/Views/prestiti/index.php +++ b/app/Views/prestiti/index.php @@ -137,6 +137,12 @@ case 'renewal_failed': echo __('Rinnovo non riuscito. Riprova.'); break; + case 'media_limit_reached': + echo __('L\'utente ha raggiunto il numero massimo di prestiti consentiti per questo tipo di materiale dalla sua tipologia di lettore.'); + break; + case 'not_loanable': + echo __('Questo materiale (o la copia indicata) è solo in consultazione per la tipologia di questo lettore.'); + break; default: echo __('Errore durante l\'aggiornamento del prestito.'); } diff --git a/app/Views/settings/circulation-tab.php b/app/Views/settings/circulation-tab.php new file mode 100644 index 000000000..cd54520d4 --- /dev/null +++ b/app/Views/settings/circulation-tab.php @@ -0,0 +1,190 @@ +>, defaultTypeId: ?int, media: array, rules: array>, inherited: array>, globals: array, userCounts: array, effective: array} $circulationData */ + +$cTypes = $circulationData['types']; +$cMedia = $circulationData['media']; +$cRules = $circulationData['rules']; +$cInherited = $circulationData['inherited']; +$cGlobals = $circulationData['globals']; +$cIntOrEmpty = static fn ($v): string => $v === null || $v === '' ? '' : (string) (int) $v; +?> +
+
+ + +
+
+

+ + +

+

+
+
+ +
+ +
+
+
+
+
+
+ + + + +
+ + +
+ +
+
+
+
+
+ +
+
+ + + + + + + + + + + + + + + + $cType): $cEff = $circulationData['effective'][$cTypeId]; ?> + + + + + + + + +
+ +

+
+
+ + + + + + + +
+
+
+ maxActiveLoans > 0 ? (string) $cEff->maxActiveLoans : '∞', $cEff->durationDays, $cEff->maxRenewals) ?> +
+ user->reservationsEnabled + ? sprintf(__('Prenotazioni: %s'), $cEff->user->maxReservations > 0 ? (string) $cEff->user->maxReservations : '∞') + : __('Prenotazioni disattivate') ?> + user->cardDurationDays !== null): ?> · user->cardDurationDays) ?> +
+
+
+ + ['fa-arrow-up', __('Sposta su'), $cIndex > 0], 'down' => ['fa-arrow-down', __('Sposta giù'), $cIndex < $cLast]] as $cDir => [$cIcon, $cLabel, $cEnabled]): if (!$cEnabled) { continue; } ?> +
+ + + +
+ + +
+ + +
+
+ + +
+ +
+
+
+
+ + +
+

+ + +

+

+
+ +
+
+ + + + + + + + + + + ['nome' => __('Tutte le tipologie')]] + $cTypes as $cTypeId => $cType): ?> + + + $cMediaLabel): + $cKey = (int) $cTypeId . '|' . $cM; + $cRule = $cRules[$cKey] ?? []; + $cInh = $cInherited[$cKey]; + $cName = 'rules[' . (int) $cTypeId . '][' . $cM . ']'; + $cId = 'rule-' . (int) $cTypeId . '-' . $cM; + $cLoanable = isset($cRule['prestabile']) ? (string) (int) $cRule['prestabile'] : ''; + ?> + + + + + +
+
+ + + [__('Max prestiti'), 'max_prestiti', $cInh['max'] > 0 ? (string) $cInh['max'] : '∞'], 'duration' => [__('Giorni'), 'durata_prestito_giorni', (string) $cInh['duration']], 'renewals' => [__('Rinnovi'), 'max_rinnovi', (string) $cInh['renewals']], 'renewal_days' => [__('Gg/rinnovo'), 'giorni_rinnovo', (string) $cInh['renewal_days']]] as $cField => [$cLabel, $cColumn, $cPlaceholder]): ?> + + +
+
+
+
+
+ +
+
+
+
+
diff --git a/app/Views/settings/circulation-type-edit.php b/app/Views/settings/circulation-type-edit.php new file mode 100644 index 000000000..f04f80bdf --- /dev/null +++ b/app/Views/settings/circulation-type-edit.php @@ -0,0 +1,36 @@ + $type + * @var array $globals + */ +use App\Support\Csrf; + +$csrfToken = Csrf::ensureToken(); +$typeForm = $type; +$cGlobals = $globals; +?> +
+ +
+ + +
+ +
+
+
diff --git a/app/Views/settings/index.php b/app/Views/settings/index.php index c231e8395..fbfde5f49 100644 --- a/app/Views/settings/index.php +++ b/app/Views/settings/index.php @@ -956,6 +956,9 @@ class="sharing-provider-checkbox w-4 h-4 rounded text-gray-900 focus:ring-gray-5 + + +
diff --git a/app/Views/settings/loans-tab.php b/app/Views/settings/loans-tab.php index 53fc9edc2..d42725950 100644 --- a/app/Views/settings/loans-tab.php +++ b/app/Views/settings/loans-tab.php @@ -199,6 +199,38 @@ class="block w-32 rounded-xl border-gray-300 focus:border-gray-500 focus:ring-gr + +
+
+

+ + +

+

+
+
+
+ +
+ + +
+

+ + +

+
+
+
+
diff --git a/app/Views/settings/partials/circulation-type-fields.php b/app/Views/settings/partials/circulation-type-fields.php new file mode 100644 index 000000000..afe3d2065 --- /dev/null +++ b/app/Views/settings/partials/circulation-type-fields.php @@ -0,0 +1,60 @@ +|null $typeForm current row, null for a new type + * @var array $cGlobals global layer (placeholders = inherited values) + */ +$tf = $typeForm ?? []; +$tfPrefix = $typeForm === null ? 'new-type' : 'type-' . (int) ($tf['id'] ?? 0); +$tfInt = static fn (string $k): string => isset($tf[$k]) && $tf[$k] !== '' ? (string) (int) $tf[$k] : ''; +$tfGlobal = [ + 'max_prestiti' => (int) ($cGlobals['max_active_loans'] ?? 0) > 0 ? (string) (int) $cGlobals['max_active_loans'] : '∞', + 'durata_prestito_giorni' => (string) (int) ($cGlobals['duration_days'] ?? 30), + 'max_durata_richiesta_giorni' => (string) (int) ($cGlobals['max_request_days'] ?? 90), + 'max_rinnovi' => (string) (int) ($cGlobals['max_renewals'] ?? 3), + 'giorni_rinnovo' => (string) (int) ($cGlobals['duration_days'] ?? 30), + 'max_prenotazioni' => '∞', + 'durata_tessera_giorni' => '—', +]; +?> +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+ +
+

+ + +

+
+ + + +
diff --git a/app/Views/utenti/crea_utente.php b/app/Views/utenti/crea_utente.php index 5e01bb9a5..b5236ee5b 100644 --- a/app/Views/utenti/crea_utente.php +++ b/app/Views/utenti/crea_utente.php @@ -85,6 +85,7 @@
+
@@ -172,6 +173,12 @@ +
+ +
@@ -211,6 +218,20 @@ const day = String(fiveYearsLater.getDate()).padStart(2, '0'); dataScadenzaTesseraField.value = `${year}-${month}-${day}`; dataScadenzaTesseraField.dataset.originalValue = dataScadenzaTesseraField.value; + // The 5-year date is a suggestion, not a date the librarian typed: tell + // the server, so "Calcola la scadenza dalla durata tessera" still applies + // the patron type's card duration. Any edit of the field clears the flag. + const userForm = document.getElementById('user-form'); + if (userForm) { + const prefilled = document.createElement('input'); + prefilled.type = 'hidden'; + prefilled.name = 'card_expiry_prefilled'; + prefilled.value = '1'; + userForm.appendChild(prefilled); + const typed = () => { prefilled.value = '0'; }; + dataScadenzaTesseraField.addEventListener('input', typed); + dataScadenzaTesseraField.addEventListener('change', typed); + } } const storeOriginalState = (container) => { diff --git a/app/Views/utenti/dettagli_utente.php b/app/Views/utenti/dettagli_utente.php index 76b5fee2a..eb4390d9d 100644 --- a/app/Views/utenti/dettagli_utente.php +++ b/app/Views/utenti/dettagli_utente.php @@ -286,8 +286,63 @@ +
+
+
+ + () +
+
+ + + + +
+

+
+
+
+
maxActiveLoans > 0 ? (int) $patronPolicy->maxActiveLoans : __('Nessun limite') ?>
+
+
+
+
durationDays ?>
+
+
+
+
maxRequestDays ?>
+
+
+
+
maxRenewals ?>
+
+
+
+
+ user->reservationsEnabled + ? __('Prenotazioni disattivate') + : ($patronPolicy->user->maxReservations > 0 ? sprintf(__('Massimo %d'), $patronPolicy->user->maxReservations) : __('Nessun limite')) ?> +
+
+
+
+
user->cardDurationDays !== null ? (int) $patronPolicy->user->cardDurationDays : '—' ?>
+
+

+
+ + +
+ diff --git a/app/Views/utenti/import.php b/app/Views/utenti/import.php index 9a6276ce4..030afceaf 100644 --- a/app/Views/utenti/import.php +++ b/app/Views/utenti/import.php @@ -247,7 +247,8 @@
  • -
  • +
  • +
  • diff --git a/app/Views/utenti/index.php b/app/Views/utenti/index.php index a7ad75a70..3378c6704 100644 --- a/app/Views/utenti/index.php +++ b/app/Views/utenti/index.php @@ -231,6 +231,22 @@ class="btn-icon btn-secondary p-2 text-gray-500 hover:text-gray-700 hover:bg-gra + types() : []; ?> + +
    + + +
    + +
    + @@ -234,6 +235,12 @@ +
    + +
    diff --git a/app/Views/utenti/tipologia-lettore.php b/app/Views/utenti/tipologia-lettore.php new file mode 100644 index 000000000..d04f7abeb --- /dev/null +++ b/app/Views/utenti/tipologia-lettore.php @@ -0,0 +1,25 @@ +> $patronTypes assignable types + * @var int|null $patronTypeId current type (null = default) + * @var int|null $defaultPatronTypeId + */ + +$ptDefaultName = $defaultPatronTypeId !== null && isset($patronTypes[$defaultPatronTypeId]) + ? (string) $patronTypes[$defaultPatronTypeId]['nome'] + : __('Standard'); +?> + +
    + + +

    +
    + diff --git a/cron/automatic-notifications.php b/cron/automatic-notifications.php index fb91b876c..53eba09ec 100644 --- a/cron/automatic-notifications.php +++ b/cron/automatic-notifications.php @@ -145,6 +145,7 @@ function logMessage(string $message): void { logMessage("- Expiration warnings sent: " . $results['expiration_warnings']); logMessage("- Overdue notifications sent: " . $results['overdue_notifications']); logMessage("- Loan recalls sent: " . ($results['loan_recalls'] ?? 0)); + logMessage("- Card expiry warnings sent: " . ($results['card_expiry_warnings'] ?? 0)); logMessage("- Wishlist notifications sent: " . $results['wishlist_notifications']); if (!empty($results['errors'])) { diff --git a/cron/full-maintenance.php b/cron/full-maintenance.php index 51e744c73..1a4ec00ff 100644 --- a/cron/full-maintenance.php +++ b/cron/full-maintenance.php @@ -159,6 +159,9 @@ function logMessage(string $message): void { logMessage("Notifications sent:"); logMessage(" - Expiration warnings: " . $results['expiration_warnings']); logMessage(" - Overdue notifications: " . $results['overdue_notifications']); + logMessage(" - Loan recalls: " . $results['loan_recalls']); + logMessage(" - Pickup reminders: " . $results['pickup_reminders']); + logMessage(" - Card expiry warnings: " . $results['card_expiry_warnings']); logMessage(" - Wishlist notifications: " . $results['wishlist_notifications']); logMessage(" - Reservation availability notifications retried: " . $results['reservation_notifications_retried']); logMessage(" - Pickup-ready notifications retried: " . $results['pickup_notifications_retried']); diff --git a/installer/database/data_da_DK.sql b/installer/database/data_da_DK.sql index 0d2f5f3fa..35840b3af 100644 --- a/installer/database/data_da_DK.sql +++ b/installer/database/data_da_DK.sql @@ -292,6 +292,7 @@ INSERT INTO `email_templates` VALUES (25,'reservation_awaiting_approval','da_DK' INSERT INTO `email_templates` VALUES (26,'loan_copy_outcome','da_DK','⚠️ Opdatering om dit lånte eksemplar','

    Låneopdatering

    Hej {{utente_nome}},

    Lånet er lukket med følgende resultat for det fysiske eksemplar:

    Bog: {{libro_titolo}}

    Resultat: {{esito_copia}}

    Lukket den: {{data_chiusura}}

    Noter: {{note}}

    Registreret beløb: {{sanzione}}

    Kontakt biblioteket, hvis du har spørgsmål.

    ','Inviata quando un prestito viene chiuso dichiarando la copia persa o danneggiata.',1,NULL,NULL); INSERT INTO `email_templates` VALUES (27,'loan_picked_up','da_DK','📖 God læselyst! Dit lån er begyndt','

    Afhentning bekræftet

    \n

    Hej {{utente_nome}},

    \n

    Du har afhentet bogen, og dit lån er officielt begyndt:

    \n
    \n

    Bog: {{libro_titolo}}

    \n

    Afhentningsdato: {{data_prestito}}

    \n

    Afleveringsdato: {{data_scadenza}}

    \n
    \n

    Vigtigt: Husk at aflevere bogen inden afleveringsdatoen. Du modtager en påmindelse nogle dage før.

    \n

    God læselyst!

    ','Sendes, når læseren fysisk afhenter bogen, og lånet begynder.',1,NULL,NULL); INSERT INTO `email_templates` VALUES (28,'loan_pickup_reminder','da_DK','⏰ Din bog venter: hent den senest {{scadenza_ritiro}}','

    Din bog venter på dig

    \n

    Hej {{utente_nome}},

    \n

    bogen, du har bedt om at låne, er klar og venter på dig på biblioteket, men du har ikke hentet den endnu.

    \n
    \n

    {{libro_titolo}}

    \n

    ⏰ Frist for afhentning: {{scadenza_ritiro}} (dage tilbage: {{giorni_rimasti}})

    \n

    Henter du den ikke inden da, annulleres lånet automatisk, og bogen bliver tilgængelig for andre lånere.

    \n
    \n
    \n

    📦 Sådan henter du den

    \n

    {{pickup_instructions}}

    \n
    \n

    Har du ikke længere brug for den, kan du annullere lånet på siden »Reservationer« på din konto: så går bogen straks videre til en anden.

    \n

    God læselyst!

    ','Sendes nogle dage før fristen for afhentning, hvis bogen endnu ikke er hentet. Antallet af dages varsel indstilles under Indstillinger → Lån.',1,NULL,NULL); +INSERT INTO `email_templates` VALUES (29,'card_expiring','da_DK','🪪 Dit lånerkort udløber den {{data_scadenza_tessera}}','

    Dit lånerkort udløber snart

    \n

    Hej {{utente_nome}},

    \n

    dit lånerkort (nr. {{codice_tessera}}) udløber den {{data_scadenza_tessera}} (dage tilbage: {{giorni_rimasti}}).

    \n
    \n

    Når det er udløbet, kan du ikke låne eller reservere bøger, før kortet er fornyet.

    \n
    \n

    Henvend dig ved bibliotekets skranke i åbningstiden for at forny det.

    \n

    På gensyn!

    ','Sendes nogle dage før lånerens kort udløber. Antallet af dages varsel angives under Indstillinger → Udlån (0 = slået fra).',1,NULL,NULL); -- ============================================================================ -- System Settings - Complete default configuration @@ -404,4 +405,7 @@ INSERT INTO `languages` (`code`, `name`, `native_name`, `flag_emoji`, `is_defaul ON DUPLICATE KEY UPDATE name = VALUES(name), native_name = VALUES(native_name), flag_emoji = VALUES(flag_emoji), translation_file = VALUES(translation_file), total_keys = VALUES(total_keys), translated_keys = VALUES(translated_keys), completion_percentage = VALUES(completion_percentage), is_active = VALUES(is_active), is_default = VALUES(is_default); +-- Default patron type (circulation policy): inherits every rule from the loans settings. +INSERT INTO `tipologie_lettore` (`codice`, `nome`, `predefinita`, `ordinamento`) VALUES ('standard', 'Standard', 1, 0); + SET FOREIGN_KEY_CHECKS=1; diff --git a/installer/database/data_de_DE.sql b/installer/database/data_de_DE.sql index c1f4bde76..76c0b9fdd 100644 --- a/installer/database/data_de_DE.sql +++ b/installer/database/data_de_DE.sql @@ -356,6 +356,7 @@ INSERT IGNORE INTO `email_templates` VALUES (25,'reservation_awaiting_approval', INSERT IGNORE INTO `email_templates` VALUES (26,'loan_copy_outcome','de_DE','⚠️ Mitteilung zu deinem ausgeliehenen Exemplar','

    Aktualisierung der Ausleihe

    Hallo {{utente_nome}},

    Die Ausleihe wurde mit folgendem Ergebnis für das Exemplar geschlossen:

    Buch: {{libro_titolo}}

    Ergebnis: {{esito_copia}}

    Abschlussdatum: {{data_chiusura}}

    Notizen: {{note}}

    Erfasster Betrag: {{sanzione}}

    Bei Fragen wende dich bitte an die Bibliothek.

    ','Inviata quando un prestito viene chiuso dichiarando la copia persa o danneggiata.',1,NULL,NULL); INSERT IGNORE INTO `email_templates` VALUES (27,'loan_picked_up','de_DE','📖 Viel Freude beim Lesen! Deine Ausleihe hat begonnen','

    Abholung bestätigt

    \n

    Hallo {{utente_nome}},

    \n

    Du hast das Buch abgeholt und deine Ausleihe hat offiziell begonnen:

    \n
    \n

    Buch: {{libro_titolo}}

    \n

    Abholdatum: {{data_prestito}}

    \n

    Fälligkeitsdatum: {{data_scadenza}}

    \n
    \n

    Wichtig: Bitte gib das Buch bis zum Fälligkeitsdatum zurück. Du erhältst einige Tage vorher eine Erinnerung.

    \n

    Viel Freude beim Lesen!

    ','Wird gesendet, wenn die Leserin oder der Leser das Buch physisch abholt und die Ausleihe beginnt.',1,NULL,NULL); INSERT IGNORE INTO `email_templates` VALUES (28,'loan_pickup_reminder','de_DE','⏰ Dein Buch wartet: hol es bis zum {{scadenza_ritiro}} ab','

    Dein Buch wartet auf dich

    \n

    Hallo {{utente_nome}},

    \n

    das Buch, das du ausleihen möchtest, liegt in der Bibliothek für dich bereit, aber du hast es noch nicht abgeholt.

    \n
    \n

    {{libro_titolo}}

    \n

    ⏰ Abholfrist: {{scadenza_ritiro}} (verbleibende Tage: {{giorni_rimasti}})

    \n

    Holst du es bis dahin nicht ab, wird die Ausleihe automatisch storniert und das Buch steht anderen Lesern wieder zur Verfügung.

    \n
    \n
    \n

    📦 So holst du es ab

    \n

    {{pickup_instructions}}

    \n
    \n

    Wenn du es nicht mehr brauchst, kannst du die Ausleihe auf der Seite „Reservierungen“ deines Kontos stornieren: Das Buch geht dann sofort an jemand anderen.

    \n

    Viel Freude beim Lesen!

    ','Wird einige Tage vor Ablauf der Abholfrist gesendet, wenn das Buch noch nicht abgeholt wurde. Die Vorlaufzeit wird unter Einstellungen → Ausleihen festgelegt.',1,NULL,NULL); +INSERT IGNORE INTO `email_templates` VALUES (29,'card_expiring','de_DE','🪪 Dein Bibliotheksausweis läuft am {{data_scadenza_tessera}} ab','

    Dein Bibliotheksausweis läuft bald ab

    \n

    Hallo {{utente_nome}},

    \n

    dein Bibliotheksausweis (Nr. {{codice_tessera}}) läuft am {{data_scadenza_tessera}} ab (verbleibende Tage: {{giorni_rimasti}}).

    \n
    \n

    Nach dem Ablauf kannst du keine Bücher mehr ausleihen oder vormerken, bis der Ausweis verlängert wird.

    \n
    \n

    Zum Verlängern wende dich während der Öffnungszeiten an die Theke der Bibliothek.

    \n

    Bis bald!

    ','Wird einige Tage vor dem Ablauf des Leserausweises gesendet. Die Vorlauftage werden unter Einstellungen → Ausleihen festgelegt (0 = aus).',1,NULL,NULL); -- Languages (German as default) -- FIX F023: total_keys/translated_keys/completion_percentage updated to real @@ -399,4 +400,7 @@ INSERT IGNORE INTO `themes` (`name`, `slug`, `version`, `author`, `description`, ('Coral Warm', 'coral-warm', '1.0.0', 'Pinakes Team', 'Warme und einladende Korallentöne für eine gemütliche Atmosphäre', 0, '{"colors": {"primary": "#f43f5e", "secondary": "#9f1239", "button": "#fb7185", "button_text": "#ffffff"}, "typography": {"font_family": "system-ui, sans-serif", "font_size_base": "16px"}, "logo": {"url": "", "width": "auto", "height": "50px"}, "advanced": {"custom_css": "", "custom_js": ""}}'), ('Navy Classic', 'navy-classic', '1.0.0', 'Pinakes Team', 'Klassisches und zeitloses Marineblau für einen institutionellen Look', 0, '{"colors": {"primary": "#1e40af", "secondary": "#1e3a8a", "button": "#3b82f6", "button_text": "#ffffff"}, "typography": {"font_family": "system-ui, sans-serif", "font_size_base": "16px"}, "logo": {"url": "", "width": "auto", "height": "50px"}, "advanced": {"custom_css": "", "custom_js": ""}}'); +-- Default patron type (circulation policy): inherits every rule from the loans settings. +INSERT INTO `tipologie_lettore` (`codice`, `nome`, `predefinita`, `ordinamento`) VALUES ('standard', 'Standard', 1, 0); + SET FOREIGN_KEY_CHECKS=1; diff --git a/installer/database/data_en_US.sql b/installer/database/data_en_US.sql index 29b9614d0..2d6a76055 100644 --- a/installer/database/data_en_US.sql +++ b/installer/database/data_en_US.sql @@ -355,6 +355,7 @@ INSERT INTO `email_templates` VALUES (25,'reservation_awaiting_approval','en_US' INSERT INTO `email_templates` VALUES (26,'loan_copy_outcome','en_US','⚠️ Update about your borrowed copy','

    Loan update

    Hi {{utente_nome}},

    The loan below was closed with an outcome concerning the physical copy:

    Book: {{libro_titolo}}

    Outcome: {{esito_copia}}

    Closed on: {{data_chiusura}}

    Notes: {{note}}

    Recorded charge: {{sanzione}}

    Please contact the library if you have any questions.

    ','Inviata quando un prestito viene chiuso dichiarando la copia persa o danneggiata.',1,NULL,NULL); INSERT INTO `email_templates` VALUES (27,'loan_picked_up','en_US','📖 Happy reading! Your loan has started','

    Pickup confirmed

    \n

    Hi {{utente_nome}},

    \n

    You picked up the book and your loan has officially started:

    \n
    \n

    Book: {{libro_titolo}}

    \n

    Pickup date: {{data_prestito}}

    \n

    Due date: {{data_scadenza}}

    \n
    \n

    Important: Please return the book by the due date. You will receive a reminder a few days before.

    \n

    Happy reading!

    ','Sent when the reader physically picks up the book and the loan starts.',1,NULL,NULL); INSERT INTO `email_templates` VALUES (28,'loan_pickup_reminder','en_US','⏰ Your book is waiting: pick it up by {{scadenza_ritiro}}','

    Your book is waiting for you

    \n

    Hi {{utente_nome}},

    \n

    the book you asked to borrow is ready and waiting for you at the library, but you have not picked it up yet.

    \n
    \n

    {{libro_titolo}}

    \n

    ⏰ Pickup deadline: {{scadenza_ritiro}} (days left: {{giorni_rimasti}})

    \n

    If you do not pick it up by then, the loan will be cancelled automatically and the book will be available to other readers.

    \n
    \n
    \n

    📦 How to pick it up

    \n

    {{pickup_instructions}}

    \n
    \n

    If you no longer need it, you can cancel the loan from the “Reservations” page of your account: the book will go to someone else right away.

    \n

    Happy reading!

    ','Sent a few days before the pickup deadline if the book has not been picked up yet. The days of notice are set in Settings → Loans.',1,NULL,NULL); +INSERT INTO `email_templates` VALUES (29,'card_expiring','en_US','🪪 Your library card expires on {{data_scadenza_tessera}}','

    Your library card is about to expire

    \n

    Hi {{utente_nome}},

    \n

    your library card (no. {{codice_tessera}}) expires on {{data_scadenza_tessera}} (days left: {{giorni_rimasti}}).

    \n
    \n

    Once it has expired you will not be able to borrow or reserve books until the card is renewed.

    \n
    \n

    To renew it, ask at the library desk during opening hours.

    \n

    See you soon!

    ','Sent a few days before the patron''s card expires. The days of notice are set in Settings → Loans (0 = off).',1,NULL,NULL); -- Languages (English as default) -- Issue #108: seed ALL shipped locales so users whose `utenti.locale` @@ -403,4 +404,7 @@ INSERT INTO `themes` (`name`, `slug`, `version`, `author`, `description`, `activ ('Coral Warm', 'coral-warm', '1.0.0', 'Pinakes Team', 'Warm and inviting coral tones for a welcoming atmosphere', 0, '{"colors": {"primary": "#f43f5e", "secondary": "#9f1239", "button": "#fb7185", "button_text": "#ffffff"}, "typography": {"font_family": "system-ui, sans-serif", "font_size_base": "16px"}, "logo": {"url": "", "width": "auto", "height": "50px"}, "advanced": {"custom_css": "", "custom_js": ""}}'), ('Navy Classic', 'navy-classic', '1.0.0', 'Pinakes Team', 'Classic and timeless navy blue for an institutional look', 0, '{"colors": {"primary": "#1e40af", "secondary": "#1e3a8a", "button": "#3b82f6", "button_text": "#ffffff"}, "typography": {"font_family": "system-ui, sans-serif", "font_size_base": "16px"}, "logo": {"url": "", "width": "auto", "height": "50px"}, "advanced": {"custom_css": "", "custom_js": ""}}'); +-- Default patron type (circulation policy): inherits every rule from the loans settings. +INSERT INTO `tipologie_lettore` (`codice`, `nome`, `predefinita`, `ordinamento`) VALUES ('standard', 'Standard', 1, 0); + SET FOREIGN_KEY_CHECKS=1; diff --git a/installer/database/data_fr_FR.sql b/installer/database/data_fr_FR.sql index 06de7f9ee..13c396528 100644 --- a/installer/database/data_fr_FR.sql +++ b/installer/database/data_fr_FR.sql @@ -356,6 +356,7 @@ INSERT IGNORE INTO `email_templates` VALUES (25,'reservation_awaiting_approval', INSERT IGNORE INTO `email_templates` VALUES (26,'loan_copy_outcome','fr_FR','⚠️ Mise à jour concernant votre exemplaire emprunté','

    Mise à jour du prêt

    Bonjour {{utente_nome}},

    Le prêt a été clôturé avec le résultat suivant pour l’exemplaire physique :

    Livre : {{libro_titolo}}

    Résultat : {{esito_copia}}

    Date de clôture : {{data_chiusura}}

    Notes : {{note}}

    Montant enregistré : {{sanzione}}

    Pour toute question, contactez la bibliothèque.

    ','Inviata quando un prestito viene chiuso dichiarando la copia persa o danneggiata.',1,NULL,NULL); INSERT IGNORE INTO `email_templates` VALUES (27,'loan_picked_up','fr_FR','📖 Bonne lecture ! Votre prêt a commencé','

    Retrait confirmé

    \n

    Bonjour {{utente_nome}},

    \n

    Vous avez retiré le livre et votre prêt a officiellement commencé :

    \n
    \n

    Livre : {{libro_titolo}}

    \n

    Date de retrait : {{data_prestito}}

    \n

    Date d\'échéance : {{data_scadenza}}

    \n
    \n

    Important : Pensez à rendre le livre avant la date d\'échéance. Vous recevrez un rappel quelques jours avant.

    \n

    Bonne lecture !

    ','Envoyée lorsque le lecteur retire physiquement le livre et que le prêt commence.',1,NULL,NULL); INSERT IGNORE INTO `email_templates` VALUES (28,'loan_pickup_reminder','fr_FR','⏰ Votre livre vous attend : retirez-le au plus tard le {{scadenza_ritiro}}','

    Votre livre vous attend

    \n

    Bonjour {{utente_nome}},

    \n

    le livre que vous avez demandé est prêt et vous attend à la bibliothèque, mais vous ne l’avez pas encore retiré.

    \n
    \n

    {{libro_titolo}}

    \n

    ⏰ Date limite de retrait : {{scadenza_ritiro}} (jours restants : {{giorni_rimasti}})

    \n

    Si vous ne le retirez pas d’ici cette date, le prêt sera annulé automatiquement et le livre redeviendra disponible pour d’autres lecteurs.

    \n
    \n
    \n

    📦 Comment le retirer

    \n

    {{pickup_instructions}}

    \n
    \n

    Si vous n’en avez plus besoin, vous pouvez annuler le prêt depuis la page « Réservations » de votre compte : le livre passera aussitôt à quelqu’un d’autre.

    \n

    Bonne lecture !

    ','Envoyé quelques jours avant la date limite de retrait si le livre n’a pas encore été retiré. Le délai de préavis se règle dans Paramètres → Emprunts.',1,NULL,NULL); +INSERT IGNORE INTO `email_templates` VALUES (29,'card_expiring','fr_FR','🪪 Votre carte de bibliothèque expire le {{data_scadenza_tessera}}','

    Votre carte de bibliothèque va expirer

    \n

    Bonjour {{utente_nome}},

    \n

    votre carte de bibliothèque (n° {{codice_tessera}}) expire le {{data_scadenza_tessera}} (jours restants : {{giorni_rimasti}}).

    \n
    \n

    Après l''expiration, vous ne pourrez plus emprunter ni réserver de livres tant que la carte n''aura pas été renouvelée.

    \n
    \n

    Pour la renouveler, adressez-vous à l''accueil de la bibliothèque pendant les heures d''ouverture.

    \n

    À bientôt !

    ','Envoyé quelques jours avant l''expiration de la carte du lecteur. Les jours de préavis se règlent dans Paramètres → Prêts (0 = désactivé).',1,NULL,NULL); -- Languages (French as default) -- FIX F023: total_keys/translated_keys/completion_percentage updated to real @@ -399,4 +400,7 @@ INSERT IGNORE INTO `themes` (`name`, `slug`, `version`, `author`, `description`, ('Coral Warm', 'coral-warm', '1.0.0', 'Pinakes Team', 'Teintes corail chaudes et accueillantes pour une atmosphère chaleureuse', 0, '{"colors": {"primary": "#f43f5e", "secondary": "#9f1239", "button": "#fb7185", "button_text": "#ffffff"}, "typography": {"font_family": "system-ui, sans-serif", "font_size_base": "16px"}, "logo": {"url": "", "width": "auto", "height": "50px"}, "advanced": {"custom_css": "", "custom_js": ""}}'), ('Navy Classic', 'navy-classic', '1.0.0', 'Pinakes Team', 'Bleu marine classique et intemporel pour un style institutionnel', 0, '{"colors": {"primary": "#1e40af", "secondary": "#1e3a8a", "button": "#3b82f6", "button_text": "#ffffff"}, "typography": {"font_family": "system-ui, sans-serif", "font_size_base": "16px"}, "logo": {"url": "", "width": "auto", "height": "50px"}, "advanced": {"custom_css": "", "custom_js": ""}}'); +-- Default patron type (circulation policy): inherits every rule from the loans settings. +INSERT INTO `tipologie_lettore` (`codice`, `nome`, `predefinita`, `ordinamento`) VALUES ('standard', 'Standard', 1, 0); + SET FOREIGN_KEY_CHECKS=1; diff --git a/installer/database/data_it_IT.sql b/installer/database/data_it_IT.sql index 0dc5874b3..5f802a3c7 100644 --- a/installer/database/data_it_IT.sql +++ b/installer/database/data_it_IT.sql @@ -292,6 +292,7 @@ INSERT INTO `email_templates` VALUES (25,'reservation_awaiting_approval','it_IT' INSERT INTO `email_templates` VALUES (26,'loan_copy_outcome','it_IT','⚠️ Aggiornamento sulla copia in prestito','

    Aggiornamento sul tuo prestito

    \n

    Ciao {{utente_nome}},

    \n

    Il prestito seguente è stato chiuso con un esito relativo alla copia fisica:

    \n
    \n

    Libro: {{libro_titolo}}

    \n

    Esito: {{esito_copia}}

    \n

    Data chiusura: {{data_chiusura}}

    \n

    Note: {{note}}

    \n

    Importo registrato: {{sanzione}}

    \n
    \n

    Per qualsiasi chiarimento contatta la biblioteca.

    ','Inviata quando un prestito viene chiuso dichiarando la copia persa o danneggiata.',1,NULL,NULL); INSERT INTO `email_templates` VALUES (27,'loan_picked_up','it_IT','📖 Buona lettura! Il tuo prestito è iniziato','

    Ritiro confermato

    \n

    Ciao {{utente_nome}},

    \n

    Hai ritirato il libro e il tuo prestito è ufficialmente iniziato:

    \n
    \n

    Libro: {{libro_titolo}}

    \n

    Data ritiro: {{data_prestito}}

    \n

    Data scadenza: {{data_scadenza}}

    \n
    \n

    Importante: Ricorda di restituire il libro entro la data di scadenza. Riceverai un promemoria alcuni giorni prima.

    \n

    Buona lettura!

    ','Inviata quando il lettore ritira fisicamente il libro e il prestito inizia.',1,NULL,NULL); INSERT INTO `email_templates` VALUES (28,'loan_pickup_reminder','it_IT','⏰ Il tuo libro ti aspetta: ritiralo entro il {{scadenza_ritiro}}','

    Il tuo libro ti aspetta

    \n

    Ciao {{utente_nome}},

    \n

    il libro che hai chiesto in prestito è pronto e ti aspetta in biblioteca, ma non l''hai ancora ritirato.

    \n
    \n

    {{libro_titolo}}

    \n

    ⏰ Scadenza ritiro: {{scadenza_ritiro}} (giorni rimasti: {{giorni_rimasti}})

    \n

    Se non lo ritiri entro questa data, il prestito verrà annullato automaticamente e il libro tornerà disponibile per altri utenti.

    \n
    \n
    \n

    📦 Come ritirare

    \n

    {{pickup_instructions}}

    \n
    \n

    Se non ti serve più, puoi annullare il prestito dalla pagina «Prenotazioni» del tuo account: il libro passerà subito a qualcun altro.

    \n

    Buona lettura!

    ','Inviata qualche giorno prima della scadenza del ritiro se il libro non è ancora stato ritirato. I giorni di preavviso si impostano in Impostazioni → Prestiti.',1,NULL,NULL); +INSERT INTO `email_templates` VALUES (29,'card_expiring','it_IT','🪪 La tua tessera scade il {{data_scadenza_tessera}}','

    La tua tessera sta per scadere

    \n

    Ciao {{utente_nome}},

    \n

    la tua tessera della biblioteca (n. {{codice_tessera}}) scade il {{data_scadenza_tessera}} (giorni rimasti: {{giorni_rimasti}}).

    \n
    \n

    Dopo la scadenza non potrai più chiedere prestiti né prenotare libri finché la tessera non viene rinnovata.

    \n
    \n

    Per rinnovarla rivolgiti al banco della biblioteca durante gli orari di apertura.

    \n

    A presto!

    ','Inviata qualche giorno prima della scadenza della tessera del lettore. I giorni di preavviso si impostano in Impostazioni → Prestiti (0 = disattivata).',1,NULL,NULL); -- ============================================================================ -- System Settings - Complete default configuration @@ -404,4 +405,7 @@ INSERT INTO `languages` (`code`, `name`, `native_name`, `flag_emoji`, `is_defaul ON DUPLICATE KEY UPDATE name = VALUES(name), native_name = VALUES(native_name), flag_emoji = VALUES(flag_emoji), translation_file = VALUES(translation_file), total_keys = VALUES(total_keys), translated_keys = VALUES(translated_keys), completion_percentage = VALUES(completion_percentage), is_active = VALUES(is_active), is_default = VALUES(is_default); +-- Default patron type (circulation policy): inherits every rule from the loans settings. +INSERT INTO `tipologie_lettore` (`codice`, `nome`, `predefinita`, `ordinamento`) VALUES ('standard', 'Standard', 1, 0); + SET FOREIGN_KEY_CHECKS=1; 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 39d9052d5..29f429704 100644 --- a/installer/database/migrations/migrate_0.9.0-rc.1.sql +++ b/installer/database/migrations/migrate_0.9.0-rc.1.sql @@ -101,10 +101,11 @@ INSERT IGNORE INTO ruoli (nome, permessi) VALUES -- ── MARC bulk import / copy cataloguing ── -- import_logs.import_type gains 'marc': the MARC bulk import and copy -- cataloguing record their runs in the import history like the CSV and --- LibraryThing imports. The enum keeps its existing values in order. -SET @marc_has_type = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'import_logs' AND COLUMN_NAME = 'import_type'); -SET @marc_has_value = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'import_logs' AND COLUMN_NAME = 'import_type' AND COLUMN_TYPE LIKE '%''marc''%'); -SET @sql = IF(@marc_has_type = 1 AND @marc_has_value = 0, 'ALTER TABLE import_logs MODIFY COLUMN import_type ENUM(''csv'',''librarything'',''marc'') COLLATE utf8mb4_unicode_ci NOT NULL COMMENT ''Type of import''', 'SELECT 1'); +-- LibraryThing imports. The new enum is computed from the current COLUMN_TYPE +-- (marc appended at the end), so every value already there is kept, whatever +-- order the 0.9.0 sections run in. +SET @marc_type = (SELECT COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'import_logs' AND COLUMN_NAME = 'import_type'); +SET @sql = IF(@marc_type IS NOT NULL AND LOCATE('''marc''', @marc_type) = 0, CONCAT('ALTER TABLE import_logs MODIFY COLUMN import_type ', LEFT(@marc_type, CHAR_LENGTH(@marc_type) - 1), ',''marc'') COLLATE utf8mb4_unicode_ci NOT NULL COMMENT ''Type of import'''), 'SELECT 1'); PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; @@ -194,3 +195,96 @@ PREPARE pci_stmt FROM @pci_sql; EXECUTE pci_stmt; DEALLOCATE PREPARE pci_stmt; -- ── end patron-cards-import ── + +-- ── Circulation policy: patron types, rules matrix, consultation-only copies ── +-- Patron types (tipologie_lettore) decide the circulation rules; the role +-- (utenti.tipo_utente) keeps deciding permissions. Every rule column is NULL = +-- inherit, so the seeded "Standard" type changes nothing (zero-config +-- equivalence). No user is touched: utenti.tipologia_id NULL = default type. +CREATE TABLE IF NOT EXISTS `tipologie_lettore` ( + `id` int NOT NULL AUTO_INCREMENT, + `codice` varchar(40) COLLATE utf8mb4_unicode_ci NOT NULL, + `nome` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL, + `descrizione` text COLLATE utf8mb4_unicode_ci, + `max_prestiti` int DEFAULT NULL, + `durata_prestito_giorni` int DEFAULT NULL, + `max_durata_richiesta_giorni` int DEFAULT NULL, + `max_rinnovi` int DEFAULT NULL, + `giorni_rinnovo` int DEFAULT NULL, + `prenotazioni_abilitate` tinyint(1) NOT NULL DEFAULT '1', + `max_prenotazioni` int DEFAULT NULL, + `durata_tessera_giorni` int DEFAULT NULL, + `multa_giornaliera` decimal(10,2) DEFAULT NULL, + `giorni_tolleranza` int DEFAULT NULL, + `multa_massima_prestito` decimal(10,2) DEFAULT NULL, + `soglia_blocco_debito` decimal(10,2) DEFAULT NULL, + `predefinita` tinyint(1) NOT NULL DEFAULT '0', + `attiva` tinyint(1) NOT NULL DEFAULT '1', + `ordinamento` int NOT NULL DEFAULT '0', + `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_tipologie_lettore_codice` (`codice`), + KEY `idx_tipologie_lettore_ordinamento` (`ordinamento`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Exceptions of the matrix patron type x material (libri.tipo_media). +-- Sentinels instead of NULL keep the UNIQUE key effective: tipologia_id 0 = +-- every patron type, tipo_media '*' = every material. +CREATE TABLE IF NOT EXISTS `regole_prestito` ( + `id` int NOT NULL AUTO_INCREMENT, + `tipologia_id` int NOT NULL DEFAULT '0', + `tipo_media` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '*', + `prestabile` tinyint(1) DEFAULT NULL, + `max_prestiti` int DEFAULT NULL, + `durata_prestito_giorni` int DEFAULT NULL, + `max_rinnovi` int DEFAULT NULL, + `giorni_rinnovo` int DEFAULT NULL, + `multa_giornaliera` decimal(10,2) DEFAULT NULL, + `multa_massima_prestito` decimal(10,2) DEFAULT NULL, + `giorni_tolleranza` int DEFAULT NULL, + `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_regole_prestito_tipologia_media` (`tipologia_id`,`tipo_media`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- The default patron type, inheriting every rule from the global settings. +-- Seeded only into an empty table: an existing set of types is never touched. +INSERT INTO `tipologie_lettore` (`codice`, `nome`, `predefinita`, `ordinamento`) +SELECT 'standard', 'Standard', 1, 0 FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `tipologie_lettore`); + +SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'utenti' AND COLUMN_NAME = 'tipologia_id'); +SET @sql = IF(@col_exists = 0, 'ALTER TABLE `utenti` ADD COLUMN `tipologia_id` int DEFAULT NULL', 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @idx_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'utenti' AND INDEX_NAME = 'idx_utenti_tipologia'); +SET @sql = IF(@idx_exists = 0, 'ALTER TABLE `utenti` ADD KEY `idx_utenti_tipologia` (`tipologia_id`)', 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @fk_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'utenti' AND CONSTRAINT_NAME = 'fk_utenti_tipologia' AND CONSTRAINT_TYPE = 'FOREIGN KEY'); +SET @sql = IF(@fk_exists = 0, 'ALTER TABLE `utenti` ADD CONSTRAINT `fk_utenti_tipologia` FOREIGN KEY (`tipologia_id`) REFERENCES `tipologie_lettore` (`id`) ON DELETE SET NULL', 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- A single copy kept for in-house consultation (SLiMS "no loan" item status). +SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'copie' AND COLUMN_NAME = 'solo_consultazione'); +SET @sql = IF(@col_exists = 0, 'ALTER TABLE `copie` ADD COLUMN `solo_consultazione` tinyint(1) NOT NULL DEFAULT ''0''', 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; +-- Card-expiry warning (loans.card_expiry_warning_days): the expiry date the +-- warning was sent for, so it goes out once per expiry and a renewed card +-- re-arms it. NULL = never warned. +SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'utenti' AND COLUMN_NAME = 'card_expiry_warning_sent_for'); +SET @sql = IF(@col_exists = 0, 'ALTER TABLE `utenti` ADD COLUMN `card_expiry_warning_sent_for` date DEFAULT NULL', 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; +-- ── end Circulation policy ── diff --git a/installer/database/schema.sql b/installer/database/schema.sql index 2284bf5d0..c4e126796 100755 --- a/installer/database/schema.sql +++ b/installer/database/schema.sql @@ -160,6 +160,7 @@ CREATE TABLE `copie` ( `numero_inventario` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL, `stato` enum('disponibile','prestato','prenotato','manutenzione','in_restauro','perso','danneggiato','in_trasferimento') COLLATE utf8mb4_unicode_ci DEFAULT 'disponibile', `note` text COLLATE utf8mb4_unicode_ci, + `solo_consultazione` tinyint(1) NOT NULL DEFAULT '0', `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), @@ -1073,6 +1074,51 @@ CREATE TABLE IF NOT EXISTS `ruoli` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS `tipologie_lettore` ( + `id` int NOT NULL AUTO_INCREMENT, + `codice` varchar(40) COLLATE utf8mb4_unicode_ci NOT NULL, + `nome` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL, + `descrizione` text COLLATE utf8mb4_unicode_ci, + `max_prestiti` int DEFAULT NULL, + `durata_prestito_giorni` int DEFAULT NULL, + `max_durata_richiesta_giorni` int DEFAULT NULL, + `max_rinnovi` int DEFAULT NULL, + `giorni_rinnovo` int DEFAULT NULL, + `prenotazioni_abilitate` tinyint(1) NOT NULL DEFAULT '1', + `max_prenotazioni` int DEFAULT NULL, + `durata_tessera_giorni` int DEFAULT NULL, + `multa_giornaliera` decimal(10,2) DEFAULT NULL, + `giorni_tolleranza` int DEFAULT NULL, + `multa_massima_prestito` decimal(10,2) DEFAULT NULL, + `soglia_blocco_debito` decimal(10,2) DEFAULT NULL, + `predefinita` tinyint(1) NOT NULL DEFAULT '0', + `attiva` tinyint(1) NOT NULL DEFAULT '1', + `ordinamento` int NOT NULL DEFAULT '0', + `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_tipologie_lettore_codice` (`codice`), + KEY `idx_tipologie_lettore_ordinamento` (`ordinamento`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `regole_prestito` ( + `id` int NOT NULL AUTO_INCREMENT, + `tipologia_id` int NOT NULL DEFAULT '0', + `tipo_media` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '*', + `prestabile` tinyint(1) DEFAULT NULL, + `max_prestiti` int DEFAULT NULL, + `durata_prestito_giorni` int DEFAULT NULL, + `max_rinnovi` int DEFAULT NULL, + `giorni_rinnovo` int DEFAULT NULL, + `multa_giornaliera` decimal(10,2) DEFAULT NULL, + `multa_massima_prestito` decimal(10,2) DEFAULT NULL, + `giorni_tolleranza` int DEFAULT NULL, + `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_regole_prestito_tipologia_media` (`tipologia_id`,`tipo_media`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE `utenti` ( `id` int NOT NULL AUTO_INCREMENT, `codice_tessera` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL, @@ -1098,6 +1144,8 @@ CREATE TABLE `utenti` ( `data_accettazione_privacy` datetime DEFAULT NULL COMMENT 'UTC timestamp of privacy acceptance', `privacy_policy_version` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Version of accepted privacy policy', `locale` varchar(10) COLLATE utf8mb4_unicode_ci DEFAULT 'it_IT', + `tipologia_id` int DEFAULT NULL, + `card_expiry_warning_sent_for` date DEFAULT NULL, `token_verifica_email` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, `data_token_verifica` datetime DEFAULT NULL, `token_reset_password` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, @@ -1116,7 +1164,9 @@ CREATE TABLE `utenti` ( KEY `idx_utenti_data_ultimo_accesso` (`data_ultimo_accesso`), KEY `idx_tipo_utente` (`tipo_utente`), KEY `idx_utenti_ruolo` (`ruolo_id`), - CONSTRAINT `fk_utenti_ruolo` FOREIGN KEY (`ruolo_id`) REFERENCES `ruoli` (`id`) ON DELETE RESTRICT + KEY `idx_utenti_tipologia` (`tipologia_id`), + CONSTRAINT `fk_utenti_ruolo` FOREIGN KEY (`ruolo_id`) REFERENCES `ruoli` (`id`) ON DELETE RESTRICT, + CONSTRAINT `fk_utenti_tipologia` FOREIGN KEY (`tipologia_id`) REFERENCES `tipologie_lettore` (`id`) ON DELETE SET NULL ) 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 */; diff --git a/locale/da_DK.json b/locale/da_DK.json index b9d511f64..fe3749e25 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -8722,7 +8722,7 @@ "Utente importato, ma i campi personalizzati non sono stati salvati.": "Bruger importeret, men de brugerdefinerede felter blev ikke gemt.", "Utente importato, ma la tipologia lettore non è stata assegnata.": "Bruger importeret, men lånertypen blev ikke tildelt.", "Tessere della biblioteca": "Bibliotekets lånerkort", - "Tipologia lettore (codice)": "Lånertype (kode)", + "Tipologia lettore (codice o nome)": "Lånertype (kode eller navn)", "Mappatura delle colonne non valida.": "Ugyldig kolonnetilknytning.", "Il campo \"%s\" è associato a più di una colonna.": "Feltet \"%s\" er knyttet til mere end én kolonne.", "Associa una colonna al campo obbligatorio \"%s\".": "Knyt en kolonne til det obligatoriske felt \"%s\".", @@ -8758,7 +8758,6 @@ "— Ignora colonna —": "— Ignorér kolonne —", "Caricamento del file...": "Uploader filen...", "%d righe da importare": "%d rækker at importere", - "Il file non era in UTF-8 ed è stato convertito da Windows-1252: controlla le lettere accentate nell'anteprima.": "Filen var ikke UTF-8 og er blevet konverteret fra Windows-1252: tjek de accentuerede bogstaver i forhåndsvisningen.", "%d di %d righe elaborate": "%d af %d rækker behandlet", "Errore di rete. Riprova.": "Netværksfejl. Prøv igen.", "Duplicato": "Dublet", @@ -8789,10 +8788,113 @@ "Senza codice tessera ne viene generato uno nuovo.": "Uden kortkode genereres en ny.", "Le date possono essere scritte come 2026-12-31, 31/12/2026 o 31.12.2026.": "Datoer kan skrives som 2026-12-31, 31/12/2026 eller 31.12.2026.", "I campi personalizzati della registrazione compaiono tra le colonne da associare.": "De brugerdefinerede registreringsfelter vises blandt de kolonner, der skal tilknyttes.", - "La tipologia lettore si indica con il suo codice.": "Lånertypen angives med sin kode.", + "La tipologia lettore si indica con il suo codice o con il suo nome.": "Lånertypen angives med sin kode eller sit navn.", "Le password non vengono mai importate.": "Adgangskoder importeres aldrig.", "Il riepilogo di ogni import, con gli errori da scaricare, resta nello": "Oversigten over hver import, med fejl til download, gemmes i", "storico import": "importhistorikken", + "L'utente ha raggiunto il numero massimo di prestiti per questo tipo di materiale.": "Låneren har nået det maksimale antal lån for denne materialetype.", + "Questo materiale è solo in consultazione per la tipologia del lettore.": "Dette materiale er kun til brug på stedet for lånerens kategori.", + "Le prenotazioni non sono consentite per la tipologia del lettore.": "Reserveringer er ikke tilladt for lånerens kategori.", + "Il lettore ha raggiunto il numero massimo di prenotazioni.": "Låneren har nået det maksimale antal reserveringer.", + "Calcola la scadenza dalla durata tessera della tipologia lettore (se indicata)": "Beregn udløbsdatoen ud fra lånerkategoriens kortvarighed (hvis angivet)", + "Tipologia lettore": "Lånerkategori", + "Codice tipologia lettore": "Lånerkategorikode", + "Solo consultazione": "Kun til brug på stedet", + "Rendi prestabile": "Gør udlånbar", + "Riserva alla consultazione": "Reserver til brug på stedet", + "predefinita": "standard", + "Regole di circolazione": "Udlånsregler", + "Prestiti attivi massimi": "Maksimalt antal aktive lån", + "Nessun limite": "Ingen grænse", + "Durata prestito (giorni)": "Låneperiode (dage)", + "Durata massima richiedibile (giorni)": "Længste periode der kan anmodes om (dage)", + "Prenotazioni disattivate": "Reserveringer slået fra", + "Massimo %d": "Højst %d", + "Durata tessera (giorni)": "Kortets varighed (dage)", + "Le regole per tipo di materiale (Impostazioni → Circolazione) possono ridurre questi valori per singoli materiali.": "Reglerne pr. materialetype (Indstillinger → Udlån) kan sænke disse værdier for enkelte materialer.", + "Le tabelle della circolazione non sono ancora state create: completa l'aggiornamento del database.": "Udlånstabellerne er ikke oprettet endnu: gennemfør databaseopdateringen.", + "La copia è impegnata da un prestito, un ritiro o una richiesta: chiudili prima di riservarla alla consultazione.": "Eksemplaret er bundet af et lån, en afhentning eller en anmodning: luk dem, før du reserverer det til brug på stedet.", + "La copia è ora solo in consultazione.": "Eksemplaret er nu kun til brug på stedet.", + "La copia è di nuovo prestabile.": "Eksemplaret kan udlånes igen.", + "generato dal nome": "dannet ud fra navnet", + "Eredita: %s": "Arvet: %s", + "Prestiti attivi massimi 0 = nessun limite. La durata della tessera, se indicata, calcola la scadenza alla creazione, all'approvazione e al rinnovo della tessera.": "Maksimalt antal aktive lån 0 = ingen grænse. Kortets varighed beregner, hvis den er angivet, udløbsdatoen ved oprettelse, godkendelse og fornyelse af kortet.", + "Prenotazioni consentite": "Reserveringer tilladt", + "Assegnabile ai lettori": "Kan tildeles lånere", + "Tipologia predefinita per i nuovi lettori": "Standardkategori for nye lånere", + "Hai raggiunto il numero massimo di prestiti per questo tipo di materiale": "Du har nået det maksimale antal lån for denne materialetype", + "Questo materiale è solo in consultazione: non può essere preso in prestito": "Dette materiale er kun til brug på stedet: det kan ikke lånes", + "Le prenotazioni non sono consentite per la tua tipologia di lettore": "Reserveringer er ikke tilladt for din lånerkategori", + "Hai raggiunto il numero massimo di prenotazioni e richieste in attesa": "Du har nået det maksimale antal reserveringer og ventende anmodninger", + "Tipologia lettore non trovata.": "Lånerkategorien blev ikke fundet.", + "Modifica tipologia lettore": "Rediger lånerkategori", + "Esiste già una tipologia con questo codice.": "Der findes allerede en kategori med denne kode.", + "Tipologia lettore creata.": "Lånerkategori oprettet.", + "Impossibile salvare la tipologia lettore.": "Lånerkategorien kunne ikke gemmes.", + "Tipologia lettore aggiornata.": "Lånerkategori opdateret.", + "La tipologia predefinita non può essere eliminata: scegli prima un'altra predefinita.": "Standardkategorien kan ikke slettes: vælg først en anden standardkategori.", + "Tipologia lettore eliminata.": "Lånerkategori slettet.", + "Impossibile eliminare la tipologia lettore.": "Lånerkategorien kunne ikke slettes.", + "Tipologia predefinita aggiornata.": "Standardkategori opdateret.", + "Valore non valido nella matrice (%s, %s).": "Ugyldig værdi i matrixen (%s, %s).", + "Regole di circolazione salvate.": "Udlånsregler gemt.", + "Impossibile salvare le regole di circolazione.": "Udlånsreglerne kunne ikke gemmes.", + "Il nome della tipologia è obbligatorio (massimo 100 caratteri).": "Kategoriens navn er påkrævet (højst 100 tegn).", + "Il codice può contenere solo lettere minuscole, numeri, trattini e trattini bassi (massimo 40 caratteri).": "Koden må kun indeholde små bogstaver, tal, bindestreger og understregninger (højst 40 tegn).", + "Giorni per rinnovo": "Dage pr. fornyelse", + "Prenotazioni massime": "Maksimalt antal reserveringer", + "Regole di circolazione del lettore": "Lånerens udlånsregler", + "Per confermare comunque la prenotazione, spunta «Ignora i limiti di prenotazione» e salva di nuovo.": "For at bekræfte reserveringen alligevel skal du sætte flueben i «Ignorer reserveringsgrænserne» og gemme igen.", + "Ignora i limiti di prenotazione della tipologia del lettore": "Ignorer reserveringsgrænserne for lånerens kategori", + "Decide le regole di prestito (limiti, durate, prenotazioni). Il tipo utente decide solo i permessi.": "Bestemmer udlånsreglerne (grænser, perioder, reserveringer). Brugertypen bestemmer kun rettighederne.", + "Tipologie di lettore": "Lånerkategorier", + "Ogni lettore appartiene a una tipologia (bambino, studente, docente…) che decide le sue regole di prestito. Il ruolo dell'utente continua a decidere solo i permessi.": "Hver låner tilhører en kategori (barn, studerende, lærer…), der bestemmer vedkommendes udlånsregler. Brugerens rolle bestemmer fortsat kun rettighederne.", + "Un campo lasciato vuoto eredita l'impostazione globale della scheda Prestiti. I lettori senza tipologia usano quella predefinita.": "Et tomt felt arver den globale indstilling fra fanen Udlån. Lånere uden kategori bruger standardkategorien.", + "Nuova tipologia": "Ny kategori", + "Crea tipologia": "Opret kategori", + "Regole effettive": "Gældende regler", + "Lettori": "Lånere", + "Nessuna tipologia di lettore": "Ingen lånerkategorier", + "Predefinita": "Standard", + "%s prestiti · %d giorni · %d rinnovi": "%s lån · %d dage · %d fornyelser", + "Prenotazioni: %s": "Reserveringer: %s", + "Tessera: %d giorni": "Kort: %d dage", + "Rendi predefinita": "Gør til standard", + "Eliminare questa tipologia? I suoi lettori passeranno alla tipologia predefinita.": "Slet denne kategori? Dens lånere flyttes til standardkategorien.", + "Regole per tipo di materiale": "Regler pr. materialetype", + "Eccezioni per tipologia di lettore e tipo di materiale: prestabilità, prestiti massimi di quel materiale (oltre al limite totale), durata, rinnovi e giorni per rinnovo. 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, fornyelser og dage pr. fornyelse. Grå værdier er arvede; skriv kun en værdi, hvor der er brug for en undtagelse.", + "Eredita (%s)": "Arv (%s)", + "prestabile": "udlånbar", + "consultazione": "brug på stedet", + "Max prestiti": "Maks. lån", + "Giorni": "Dage", + "Salva regole": "Gem regler", + "Torna alla circolazione": "Tilbage til udlån", + "Salva tipologia": "Gem kategori", + "Prenotazioni non disponibili per il tuo profilo": "Reserveringer er ikke tilgængelige for din profil", + "L'utente ha raggiunto il numero massimo di prestiti consentiti per questo tipo di materiale dalla sua tipologia di lettore.": "Låneren har nået det maksimale antal lån, som lånerkategorien tillader for denne materialetype.", + "Questo materiale (o la copia indicata) è solo in consultazione per la tipologia di questo lettore.": "Dette materiale (eller det angivne eksemplar) er kun til brug på stedet for denne lånerkategori.", + "Tipologie di lettore e regole di prestito per tipo di materiale.": "Lånerkategorier og udlånsregler pr. materialetype.", + "Avviso scadenza tessera": "Varsel om kortudløb", + "Avviso scadenza tessera: giorni prima della scadenza": "Varsel om kortudløb: dage før udløb", + "Il lettore riceve un'email quando la sua tessera sta per scadere, così può rinnovarla prima di non poter più chiedere prestiti.": "Låneren får en e-mail, når kortet snart udløber, så det kan fornyes, før vedkommende ikke længere kan låne.", + "Impossibile rinnovare la tessera di questo account.": "Kortet for denne konto kan ikke fornyes.", + "Inviata qualche giorno prima della scadenza della tessera del lettore. I giorni di preavviso si impostano in Impostazioni → Prestiti (0 = disattivata).": "Sendes nogle dage før lånerens kort udløber. Antallet af dages varsel angives under Indstillinger → Udlån (0 = slået fra).", + "Inviato una sola volta per scadenza dal processo pianificato delle notifiche (template «Tessera in scadenza»). 0 disattiva l'avviso. Predefinito: 0": "Sendes én gang pr. udløbsdato af det planlagte notifikationsjob (skabelonen «Kort udløber snart»). 0 slår varslet fra. Standard: 0", + "Rinnova tessera": "Forny kort", + "Rinnovare la tessera fino al %s?": "Forny kortet til den %s?", + "Tessera in scadenza": "Kort udløber snart", + "Tessera rinnovata fino al %s.": "Kortet er fornyet til den %s.", + "Ultimi 12 mesi. I lettori senza tipologia sono contati in «%s», la tipologia predefinita.": "Seneste 12 måneder. Lånere uden lånertype tælles under »%s«, standardtypen.", + "Ultimi 12 mesi.": "Seneste 12 måneder.", + "Gg/rinnovo": "Dage/forny.", + "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.", + "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)", + "Il file non era in UTF-8 ed è stato convertito da Windows-1252: controlla le lettere accentate nell'anteprima.": "Filen var ikke UTF-8 og er blevet konverteret fra Windows-1252: tjek de accentuerede bogstaver i forhåndsvisningen.", "L'email del fornitore non è un indirizzo valido.": "Leverandørens e-mail er ikke en gyldig adresse.", "Il sito web del fornitore non è un indirizzo valido: indica un URL http(s), ad esempio https://www.esempio.it.": "Leverandørens websted er ikke en gyldig adresse: angiv en http(s)-URL, for eksempel https://www.eksempel.dk.", "Assegnato ai libri nuovi e, con «Sovrascrivi», ai libri già presenti. I libri saltati o solo completati non cambiano fornitore.": "Tildeles de nye bøger og, med \"Overskriv\", de bøger der allerede findes. Bøger der springes over eller kun udfyldes beholder deres leverandør.", diff --git a/locale/de_DE.json b/locale/de_DE.json index d1327ee2b..6a22ce735 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -8722,7 +8722,7 @@ "Utente importato, ma i campi personalizzati non sono stati salvati.": "Benutzer importiert, aber die benutzerdefinierten Felder wurden nicht gespeichert.", "Utente importato, ma la tipologia lettore non è stata assegnata.": "Benutzer importiert, aber der Lesertyp wurde nicht zugewiesen.", "Tessere della biblioteca": "Leserausweise der Bibliothek", - "Tipologia lettore (codice)": "Lesertyp (Code)", + "Tipologia lettore (codice o nome)": "Lesertyp (Code oder Name)", "Mappatura delle colonne non valida.": "Ungültige Spaltenzuordnung.", "Il campo \"%s\" è associato a più di una colonna.": "Das Feld \"%s\" ist mehr als einer Spalte zugeordnet.", "Associa una colonna al campo obbligatorio \"%s\".": "Ordnen Sie dem Pflichtfeld \"%s\" eine Spalte zu.", @@ -8758,7 +8758,6 @@ "— Ignora colonna —": "— Spalte ignorieren —", "Caricamento del file...": "Datei wird hochgeladen...", "%d righe da importare": "%d Zeilen zu importieren", - "Il file non era in UTF-8 ed è stato convertito da Windows-1252: controlla le lettere accentate nell'anteprima.": "Die Datei war nicht UTF-8 und wurde aus Windows-1252 umgewandelt: prüfen Sie die Umlaute und Akzente in der Vorschau.", "%d di %d righe elaborate": "%d von %d Zeilen verarbeitet", "Errore di rete. Riprova.": "Netzwerkfehler. Bitte erneut versuchen.", "Duplicato": "Duplikat", @@ -8789,10 +8788,113 @@ "Senza codice tessera ne viene generato uno nuovo.": "Ohne Ausweisnummer wird eine neue erzeugt.", "Le date possono essere scritte come 2026-12-31, 31/12/2026 o 31.12.2026.": "Datumsangaben können als 2026-12-31, 31/12/2026 oder 31.12.2026 geschrieben werden.", "I campi personalizzati della registrazione compaiono tra le colonne da associare.": "Die benutzerdefinierten Registrierungsfelder erscheinen unter den zuzuordnenden Spalten.", - "La tipologia lettore si indica con il suo codice.": "Der Lesertyp wird über seinen Code angegeben.", + "La tipologia lettore si indica con il suo codice o con il suo nome.": "Der Lesertyp wird über seinen Code oder seinen Namen angegeben.", "Le password non vengono mai importate.": "Passwörter werden nie importiert.", "Il riepilogo di ogni import, con gli errori da scaricare, resta nello": "Die Zusammenfassung jedes Imports mit herunterladbaren Fehlern bleibt im", "storico import": "Importverlauf", + "L'utente ha raggiunto il numero massimo di prestiti per questo tipo di materiale.": "Die Person hat die Höchstzahl an Ausleihen für diese Medienart erreicht.", + "Questo materiale è solo in consultazione per la tipologia del lettore.": "Dieses Medium ist für die Leserkategorie nur zur Einsichtnahme vor Ort.", + "Le prenotazioni non sono consentite per la tipologia del lettore.": "Vormerkungen sind für die Leserkategorie nicht erlaubt.", + "Il lettore ha raggiunto il numero massimo di prenotazioni.": "Die Person hat die Höchstzahl an Vormerkungen erreicht.", + "Calcola la scadenza dalla durata tessera della tipologia lettore (se indicata)": "Ablaufdatum aus der Ausweisdauer der Leserkategorie berechnen (falls angegeben)", + "Tipologia lettore": "Leserkategorie", + "Codice tipologia lettore": "Code der Leserkategorie", + "Solo consultazione": "Nur Einsichtnahme", + "Rendi prestabile": "Ausleihbar machen", + "Riserva alla consultazione": "Nur zur Einsichtnahme vorbehalten", + "predefinita": "Standard", + "Regole di circolazione": "Ausleihregeln", + "Prestiti attivi massimi": "Maximale aktive Ausleihen", + "Nessun limite": "Keine Begrenzung", + "Durata prestito (giorni)": "Leihdauer (Tage)", + "Durata massima richiedibile (giorni)": "Maximal anfragbare Dauer (Tage)", + "Prenotazioni disattivate": "Vormerkungen deaktiviert", + "Massimo %d": "Höchstens %d", + "Durata tessera (giorni)": "Ausweisdauer (Tage)", + "Le regole per tipo di materiale (Impostazioni → Circolazione) possono ridurre questi valori per singoli materiali.": "Die Regeln pro Medienart (Einstellungen → Ausleihe) können diese Werte für einzelne Medien senken.", + "Le tabelle della circolazione non sono ancora state create: completa l'aggiornamento del database.": "Die Ausleihtabellen wurden noch nicht angelegt: Schließe die Datenbankaktualisierung ab.", + "La copia è impegnata da un prestito, un ritiro o una richiesta: chiudili prima di riservarla alla consultazione.": "Das Exemplar ist durch eine Ausleihe, eine Abholung oder eine Anfrage gebunden: Schließe sie, bevor du es zur Einsichtnahme vorbehältst.", + "La copia è ora solo in consultazione.": "Das Exemplar ist jetzt nur zur Einsichtnahme.", + "La copia è di nuovo prestabile.": "Das Exemplar ist wieder ausleihbar.", + "generato dal nome": "aus dem Namen erzeugt", + "Eredita: %s": "Geerbt: %s", + "Prestiti attivi massimi 0 = nessun limite. La durata della tessera, se indicata, calcola la scadenza alla creazione, all'approvazione e al rinnovo della tessera.": "Maximale aktive Ausleihen 0 = keine Begrenzung. Die Ausweisdauer berechnet, falls angegeben, das Ablaufdatum bei Anlage, Freigabe und Verlängerung des Ausweises.", + "Prenotazioni consentite": "Vormerkungen erlaubt", + "Assegnabile ai lettori": "Lesern zuweisbar", + "Tipologia predefinita per i nuovi lettori": "Standardkategorie für neue Leser", + "Hai raggiunto il numero massimo di prestiti per questo tipo di materiale": "Du hast die Höchstzahl an Ausleihen für diese Medienart erreicht", + "Questo materiale è solo in consultazione: non può essere preso in prestito": "Dieses Medium ist nur zur Einsichtnahme: Es kann nicht ausgeliehen werden", + "Le prenotazioni non sono consentite per la tua tipologia di lettore": "Vormerkungen sind für deine Leserkategorie nicht erlaubt", + "Hai raggiunto il numero massimo di prenotazioni e richieste in attesa": "Du hast die Höchstzahl an Vormerkungen und offenen Anfragen erreicht", + "Tipologia lettore non trovata.": "Leserkategorie nicht gefunden.", + "Modifica tipologia lettore": "Leserkategorie bearbeiten", + "Esiste già una tipologia con questo codice.": "Es gibt bereits eine Kategorie mit diesem Code.", + "Tipologia lettore creata.": "Leserkategorie angelegt.", + "Impossibile salvare la tipologia lettore.": "Die Leserkategorie konnte nicht gespeichert werden.", + "Tipologia lettore aggiornata.": "Leserkategorie aktualisiert.", + "La tipologia predefinita non può essere eliminata: scegli prima un'altra predefinita.": "Die Standardkategorie kann nicht gelöscht werden: Wähle zuerst eine andere Standardkategorie.", + "Tipologia lettore eliminata.": "Leserkategorie gelöscht.", + "Impossibile eliminare la tipologia lettore.": "Die Leserkategorie konnte nicht gelöscht werden.", + "Tipologia predefinita aggiornata.": "Standardkategorie aktualisiert.", + "Valore non valido nella matrice (%s, %s).": "Ungültiger Wert in der Matrix (%s, %s).", + "Regole di circolazione salvate.": "Ausleihregeln gespeichert.", + "Impossibile salvare le regole di circolazione.": "Die Ausleihregeln konnten nicht gespeichert werden.", + "Il nome della tipologia è obbligatorio (massimo 100 caratteri).": "Der Kategoriename ist erforderlich (höchstens 100 Zeichen).", + "Il codice può contenere solo lettere minuscole, numeri, trattini e trattini bassi (massimo 40 caratteri).": "Der Code darf nur Kleinbuchstaben, Ziffern, Bindestriche und Unterstriche enthalten (höchstens 40 Zeichen).", + "Giorni per rinnovo": "Tage pro Verlängerung", + "Prenotazioni massime": "Maximale Vormerkungen", + "Regole di circolazione del lettore": "Ausleihregeln der Person", + "Per confermare comunque la prenotazione, spunta «Ignora i limiti di prenotazione» e salva di nuovo.": "Um die Vormerkung trotzdem zu bestätigen, aktiviere „Vormerkungsgrenzen ignorieren“ und speichere erneut.", + "Ignora i limiti di prenotazione della tipologia del lettore": "Vormerkungsgrenzen der Leserkategorie ignorieren", + "Decide le regole di prestito (limiti, durate, prenotazioni). Il tipo utente decide solo i permessi.": "Bestimmt die Ausleihregeln (Grenzen, Dauer, Vormerkungen). Der Benutzertyp bestimmt nur die Berechtigungen.", + "Tipologie di lettore": "Leserkategorien", + "Ogni lettore appartiene a una tipologia (bambino, studente, docente…) che decide le sue regole di prestito. Il ruolo dell'utente continua a decidere solo i permessi.": "Jede Person gehört zu einer Kategorie (Kind, Studierende, Lehrkraft…), die ihre Ausleihregeln bestimmt. Die Rolle des Benutzers bestimmt weiterhin nur die Berechtigungen.", + "Un campo lasciato vuoto eredita l'impostazione globale della scheda Prestiti. I lettori senza tipologia usano quella predefinita.": "Ein leeres Feld übernimmt die globale Einstellung des Reiters Ausleihen. Personen ohne Kategorie verwenden die Standardkategorie.", + "Nuova tipologia": "Neue Kategorie", + "Crea tipologia": "Kategorie anlegen", + "Regole effettive": "Geltende Regeln", + "Lettori": "Leser", + "Nessuna tipologia di lettore": "Keine Leserkategorien", + "Predefinita": "Standard", + "%s prestiti · %d giorni · %d rinnovi": "%s Ausleihen · %d Tage · %d Verlängerungen", + "Prenotazioni: %s": "Vormerkungen: %s", + "Tessera: %d giorni": "Ausweis: %d Tage", + "Rendi predefinita": "Als Standard festlegen", + "Eliminare questa tipologia? I suoi lettori passeranno alla tipologia predefinita.": "Diese Kategorie löschen? Ihre Leser wechseln zur Standardkategorie.", + "Regole per tipo di materiale": "Regeln pro Medienart", + "Eccezioni per tipologia di lettore e tipo di materiale: prestabilità, prestiti massimi di quel materiale (oltre al limite totale), durata, rinnovi e giorni per rinnovo. 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, Verlängerungen und Tage pro Verlängerung. Graue Werte sind geerbt; trage nur dort einen Wert ein, wo eine Ausnahme nötig ist.", + "Eredita (%s)": "Erben (%s)", + "prestabile": "ausleihbar", + "consultazione": "Einsichtnahme", + "Max prestiti": "Max. Ausleihen", + "Giorni": "Tage", + "Salva regole": "Regeln speichern", + "Torna alla circolazione": "Zurück zur Ausleihe", + "Salva tipologia": "Kategorie speichern", + "Prenotazioni non disponibili per il tuo profilo": "Vormerkungen sind für dein Profil nicht verfügbar", + "L'utente ha raggiunto il numero massimo di prestiti consentiti per questo tipo di materiale dalla sua tipologia di lettore.": "Die Person hat die Höchstzahl an Ausleihen erreicht, die ihre Leserkategorie für diese Medienart erlaubt.", + "Questo materiale (o la copia indicata) è solo in consultazione per la tipologia di questo lettore.": "Dieses Medium (oder das angegebene Exemplar) ist für die Kategorie dieser Person nur zur Einsichtnahme.", + "Tipologie di lettore e regole di prestito per tipo di materiale.": "Leserkategorien und Ausleihregeln pro Medienart.", + "Avviso scadenza tessera": "Hinweis zum Ausweisablauf", + "Avviso scadenza tessera: giorni prima della scadenza": "Hinweis zum Ausweisablauf: Tage vor dem Ablauf", + "Il lettore riceve un'email quando la sua tessera sta per scadere, così può rinnovarla prima di non poter più chiedere prestiti.": "Die Person erhält eine E-Mail, wenn ihr Ausweis bald abläuft, damit sie ihn verlängern kann, bevor sie nichts mehr ausleihen kann.", + "Impossibile rinnovare la tessera di questo account.": "Der Ausweis dieses Kontos kann nicht verlängert werden.", + "Inviata qualche giorno prima della scadenza della tessera del lettore. I giorni di preavviso si impostano in Impostazioni → Prestiti (0 = disattivata).": "Wird einige Tage vor dem Ablauf des Leserausweises gesendet. Die Vorlauftage werden unter Einstellungen → Ausleihen festgelegt (0 = aus).", + "Inviato una sola volta per scadenza dal processo pianificato delle notifiche (template «Tessera in scadenza»). 0 disattiva l'avviso. Predefinito: 0": "Wird pro Ablaufdatum einmal vom geplanten Benachrichtigungsprozess gesendet (Vorlage „Ausweis läuft ab“). 0 schaltet den Hinweis aus. Standard: 0", + "Rinnova tessera": "Ausweis verlängern", + "Rinnovare la tessera fino al %s?": "Ausweis bis zum %s verlängern?", + "Tessera in scadenza": "Ausweis läuft ab", + "Tessera rinnovata fino al %s.": "Ausweis bis zum %s verlängert.", + "Ultimi 12 mesi. I lettori senza tipologia sono contati in «%s», la tipologia predefinita.": "Letzte 12 Monate. Leser ohne Lesertyp werden unter „%s“ gezählt, dem Standardtyp.", + "Ultimi 12 mesi.": "Letzte 12 Monate.", + "Gg/rinnovo": "Tage/Verl.", + "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.", + "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)", + "Il file non era in UTF-8 ed è stato convertito da Windows-1252: controlla le lettere accentate nell'anteprima.": "Die Datei war nicht UTF-8 und wurde aus Windows-1252 umgewandelt: prüfen Sie die Umlaute und Akzente in der Vorschau.", "L'email del fornitore non è un indirizzo valido.": "Die E-Mail-Adresse des Lieferanten ist ungültig.", "Il sito web del fornitore non è un indirizzo valido: indica un URL http(s), ad esempio https://www.esempio.it.": "Die Website des Lieferanten ist keine gültige Adresse: geben Sie eine http(s)-URL an, zum Beispiel https://www.beispiel.de.", "Assegnato ai libri nuovi e, con «Sovrascrivi», ai libri già presenti. I libri saltati o solo completati non cambiano fornitore.": "Wird den neuen Büchern und, mit „Überschreiben“, den bereits vorhandenen Büchern zugewiesen. Übersprungene oder nur ergänzte Bücher behalten ihren Lieferanten.", diff --git a/locale/en_US.json b/locale/en_US.json index 07829ef57..69fba3719 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -8722,7 +8722,7 @@ "Utente importato, ma i campi personalizzati non sono stati salvati.": "User imported, but the custom fields were not saved.", "Utente importato, ma la tipologia lettore non è stata assegnata.": "User imported, but the patron type was not assigned.", "Tessere della biblioteca": "Library cards", - "Tipologia lettore (codice)": "Patron type (code)", + "Tipologia lettore (codice o nome)": "Patron type (code or name)", "Mappatura delle colonne non valida.": "Invalid column mapping.", "Il campo \"%s\" è associato a più di una colonna.": "The field \"%s\" is mapped to more than one column.", "Associa una colonna al campo obbligatorio \"%s\".": "Map a column to the required field \"%s\".", @@ -8758,7 +8758,6 @@ "— Ignora colonna —": "— Ignore column —", "Caricamento del file...": "Uploading the file...", "%d righe da importare": "%d rows to import", - "Il file non era in UTF-8 ed è stato convertito da Windows-1252: controlla le lettere accentate nell'anteprima.": "The file was not UTF-8 and has been converted from Windows-1252: check the accented letters in the preview.", "%d di %d righe elaborate": "%d of %d rows processed", "Errore di rete. Riprova.": "Network error. Please try again.", "Duplicato": "Duplicate", @@ -8789,10 +8788,113 @@ "Senza codice tessera ne viene generato uno nuovo.": "Without a card code a new one is generated.", "Le date possono essere scritte come 2026-12-31, 31/12/2026 o 31.12.2026.": "Dates can be written as 2026-12-31, 31/12/2026 or 31.12.2026.", "I campi personalizzati della registrazione compaiono tra le colonne da associare.": "The custom registration fields appear among the columns to map.", - "La tipologia lettore si indica con il suo codice.": "The patron type is given by its code.", + "La tipologia lettore si indica con il suo codice o con il suo nome.": "The patron type is given by its code or by its name.", "Le password non vengono mai importate.": "Passwords are never imported.", "Il riepilogo di ogni import, con gli errori da scaricare, resta nello": "The summary of every import, with downloadable errors, is kept in the", "storico import": "import history", + "L'utente ha raggiunto il numero massimo di prestiti per questo tipo di materiale.": "The patron has reached the maximum number of loans for this material type.", + "Questo materiale è solo in consultazione per la tipologia del lettore.": "This material is for consultation only for the patron's type.", + "Le prenotazioni non sono consentite per la tipologia del lettore.": "Reservations are not allowed for the patron's type.", + "Il lettore ha raggiunto il numero massimo di prenotazioni.": "The patron has reached the maximum number of reservations.", + "Calcola la scadenza dalla durata tessera della tipologia lettore (se indicata)": "Compute the expiry from the patron type's card duration (if set)", + "Tipologia lettore": "Patron type", + "Codice tipologia lettore": "Patron type code", + "Solo consultazione": "Consultation only", + "Rendi prestabile": "Make lendable", + "Riserva alla consultazione": "Keep for consultation only", + "predefinita": "default", + "Regole di circolazione": "Circulation rules", + "Prestiti attivi massimi": "Maximum active loans", + "Nessun limite": "No limit", + "Durata prestito (giorni)": "Loan duration (days)", + "Durata massima richiedibile (giorni)": "Longest request (days)", + "Prenotazioni disattivate": "Reservations disabled", + "Massimo %d": "Maximum %d", + "Durata tessera (giorni)": "Card duration (days)", + "Le regole per tipo di materiale (Impostazioni → Circolazione) possono ridurre questi valori per singoli materiali.": "The rules per material type (Settings → Circulation) can lower these values for single materials.", + "Le tabelle della circolazione non sono ancora state create: completa l'aggiornamento del database.": "The circulation tables have not been created yet: complete the database update.", + "La copia è impegnata da un prestito, un ritiro o una richiesta: chiudili prima di riservarla alla consultazione.": "The copy is held by a loan, a pickup or a request: close them before keeping it for consultation.", + "La copia è ora solo in consultazione.": "The copy is now for consultation only.", + "La copia è di nuovo prestabile.": "The copy is lendable again.", + "generato dal nome": "generated from the name", + "Eredita: %s": "Inherited: %s", + "Prestiti attivi massimi 0 = nessun limite. La durata della tessera, se indicata, calcola la scadenza alla creazione, all'approvazione e al rinnovo della tessera.": "Maximum active loans 0 = no limit. The card duration, when set, computes the expiry on creation, on approval and when the card is renewed.", + "Prenotazioni consentite": "Reservations allowed", + "Assegnabile ai lettori": "Assignable to patrons", + "Tipologia predefinita per i nuovi lettori": "Default type for new patrons", + "Hai raggiunto il numero massimo di prestiti per questo tipo di materiale": "You have reached the maximum number of loans for this material type", + "Questo materiale è solo in consultazione: non può essere preso in prestito": "This material is for consultation only: it cannot be borrowed", + "Le prenotazioni non sono consentite per la tua tipologia di lettore": "Reservations are not allowed for your patron type", + "Hai raggiunto il numero massimo di prenotazioni e richieste in attesa": "You have reached the maximum number of reservations and pending requests", + "Tipologia lettore non trovata.": "Patron type not found.", + "Modifica tipologia lettore": "Edit patron type", + "Esiste già una tipologia con questo codice.": "A patron type with this code already exists.", + "Tipologia lettore creata.": "Patron type created.", + "Impossibile salvare la tipologia lettore.": "Unable to save the patron type.", + "Tipologia lettore aggiornata.": "Patron type updated.", + "La tipologia predefinita non può essere eliminata: scegli prima un'altra predefinita.": "The default type cannot be deleted: choose another default first.", + "Tipologia lettore eliminata.": "Patron type deleted.", + "Impossibile eliminare la tipologia lettore.": "Unable to delete the patron type.", + "Tipologia predefinita aggiornata.": "Default type updated.", + "Valore non valido nella matrice (%s, %s).": "Invalid value in the matrix (%s, %s).", + "Regole di circolazione salvate.": "Circulation rules saved.", + "Impossibile salvare le regole di circolazione.": "Unable to save the circulation rules.", + "Il nome della tipologia è obbligatorio (massimo 100 caratteri).": "The type name is required (at most 100 characters).", + "Il codice può contenere solo lettere minuscole, numeri, trattini e trattini bassi (massimo 40 caratteri).": "The code may contain only lowercase letters, digits, hyphens and underscores (at most 40 characters).", + "Giorni per rinnovo": "Days per renewal", + "Prenotazioni massime": "Maximum reservations", + "Regole di circolazione del lettore": "Patron's circulation rules", + "Per confermare comunque la prenotazione, spunta «Ignora i limiti di prenotazione» e salva di nuovo.": "To confirm the reservation anyway, tick «Ignore the reservation limits» and save again.", + "Ignora i limiti di prenotazione della tipologia del lettore": "Ignore the reservation limits of the patron's type", + "Decide le regole di prestito (limiti, durate, prenotazioni). Il tipo utente decide solo i permessi.": "Decides the loan rules (limits, durations, reservations). The user type only decides the permissions.", + "Tipologie di lettore": "Patron types", + "Ogni lettore appartiene a una tipologia (bambino, studente, docente…) che decide le sue regole di prestito. Il ruolo dell'utente continua a decidere solo i permessi.": "Every patron belongs to a type (child, student, teacher…) that decides their loan rules. The user's role still decides only the permissions.", + "Un campo lasciato vuoto eredita l'impostazione globale della scheda Prestiti. I lettori senza tipologia usano quella predefinita.": "A field left empty inherits the global setting of the Loans tab. Patrons without a type use the default one.", + "Nuova tipologia": "New patron type", + "Crea tipologia": "Create type", + "Regole effettive": "Effective rules", + "Lettori": "Patrons", + "Nessuna tipologia di lettore": "No patron types", + "Predefinita": "Default", + "%s prestiti · %d giorni · %d rinnovi": "%s loans · %d days · %d renewals", + "Prenotazioni: %s": "Reservations: %s", + "Tessera: %d giorni": "Card: %d days", + "Rendi predefinita": "Make default", + "Eliminare questa tipologia? I suoi lettori passeranno alla tipologia predefinita.": "Delete this type? Its patrons will move to the default type.", + "Regole per tipo di materiale": "Rules per material type", + "Eccezioni per tipologia di lettore e tipo di materiale: prestabilità, prestiti massimi di quel materiale (oltre al limite totale), durata, rinnovi e giorni per rinnovo. 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, renewals and days per renewal. Grey values are inherited; enter a value only where an exception is needed.", + "Eredita (%s)": "Inherit (%s)", + "prestabile": "lendable", + "consultazione": "consultation", + "Max prestiti": "Max loans", + "Giorni": "Days", + "Salva regole": "Save rules", + "Torna alla circolazione": "Back to circulation", + "Salva tipologia": "Save type", + "Prenotazioni non disponibili per il tuo profilo": "Reservations are not available for your profile", + "L'utente ha raggiunto il numero massimo di prestiti consentiti per questo tipo di materiale dalla sua tipologia di lettore.": "The patron has reached the maximum number of loans their patron type allows for this material type.", + "Questo materiale (o la copia indicata) è solo in consultazione per la tipologia di questo lettore.": "This material (or the given copy) is for consultation only for this patron's type.", + "Tipologie di lettore e regole di prestito per tipo di materiale.": "Patron types and loan rules per material type.", + "Avviso scadenza tessera": "Card expiry warning", + "Avviso scadenza tessera: giorni prima della scadenza": "Card expiry warning: days before the expiry", + "Il lettore riceve un'email quando la sua tessera sta per scadere, così può rinnovarla prima di non poter più chiedere prestiti.": "The patron receives an email when their card is about to expire, so they can renew it before they can no longer borrow.", + "Impossibile rinnovare la tessera di questo account.": "This account's card cannot be renewed.", + "Inviata qualche giorno prima della scadenza della tessera del lettore. I giorni di preavviso si impostano in Impostazioni → Prestiti (0 = disattivata).": "Sent a few days before the patron's card expires. The days of notice are set in Settings → Loans (0 = off).", + "Inviato una sola volta per scadenza dal processo pianificato delle notifiche (template «Tessera in scadenza»). 0 disattiva l'avviso. Predefinito: 0": "Sent once per expiry by the scheduled notifications job (template «Card expiring»). 0 turns the warning off. Default: 0", + "Rinnova tessera": "Renew card", + "Rinnovare la tessera fino al %s?": "Renew the card until %s?", + "Tessera in scadenza": "Card expiring", + "Tessera rinnovata fino al %s.": "Card renewed until %s.", + "Ultimi 12 mesi. I lettori senza tipologia sono contati in «%s», la tipologia predefinita.": "Last 12 months. Readers without a patron type are counted under «%s», the default type.", + "Ultimi 12 mesi.": "Last 12 months.", + "Gg/rinnovo": "Days/renewal", + "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.", + "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)", + "Il file non era in UTF-8 ed è stato convertito da Windows-1252: controlla le lettere accentate nell'anteprima.": "The file was not UTF-8 and has been converted from Windows-1252: check the accented letters in the preview.", "L'email del fornitore non è un indirizzo valido.": "The supplier e-mail is not a valid address.", "Il sito web del fornitore non è un indirizzo valido: indica un URL http(s), ad esempio https://www.esempio.it.": "The supplier website is not a valid address: enter an http(s) URL, for example https://www.example.org.", "Assegnato ai libri nuovi e, con «Sovrascrivi», ai libri già presenti. I libri saltati o solo completati non cambiano fornitore.": "Assigned to the new books and, with \"Overwrite\", to the books already in the catalogue. Skipped or merely completed books keep their supplier.", diff --git a/locale/fr_FR.json b/locale/fr_FR.json index 79ae4c543..92991ba5e 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -8722,7 +8722,7 @@ "Utente importato, ma i campi personalizzati non sono stati salvati.": "Utilisateur importé, mais les champs personnalisés n'ont pas été enregistrés.", "Utente importato, ma la tipologia lettore non è stata assegnata.": "Utilisateur importé, mais le type de lecteur n'a pas été attribué.", "Tessere della biblioteca": "Cartes de la bibliothèque", - "Tipologia lettore (codice)": "Type de lecteur (code)", + "Tipologia lettore (codice o nome)": "Type de lecteur (code ou nom)", "Mappatura delle colonne non valida.": "Correspondance des colonnes non valide.", "Il campo \"%s\" è associato a più di una colonna.": "Le champ \"%s\" est associé à plusieurs colonnes.", "Associa una colonna al campo obbligatorio \"%s\".": "Associez une colonne au champ obligatoire \"%s\".", @@ -8758,7 +8758,6 @@ "— Ignora colonna —": "— Ignorer la colonne —", "Caricamento del file...": "Téléversement du fichier...", "%d righe da importare": "%d lignes à importer", - "Il file non era in UTF-8 ed è stato convertito da Windows-1252: controlla le lettere accentate nell'anteprima.": "Le fichier n'était pas en UTF-8 et a été converti depuis Windows-1252 : vérifiez les lettres accentuées dans l'aperçu.", "%d di %d righe elaborate": "%d lignes traitées sur %d", "Errore di rete. Riprova.": "Erreur réseau. Réessayez.", "Duplicato": "Doublon", @@ -8789,10 +8788,113 @@ "Senza codice tessera ne viene generato uno nuovo.": "Sans code de carte, un nouveau est généré.", "Le date possono essere scritte come 2026-12-31, 31/12/2026 o 31.12.2026.": "Les dates peuvent s'écrire 2026-12-31, 31/12/2026 ou 31.12.2026.", "I campi personalizzati della registrazione compaiono tra le colonne da associare.": "Les champs d'inscription personnalisés figurent parmi les colonnes à associer.", - "La tipologia lettore si indica con il suo codice.": "Le type de lecteur s'indique par son code.", + "La tipologia lettore si indica con il suo codice o con il suo nome.": "Le type de lecteur s'indique par son code ou par son nom.", "Le password non vengono mai importate.": "Les mots de passe ne sont jamais importés.", "Il riepilogo di ogni import, con gli errori da scaricare, resta nello": "Le récapitulatif de chaque import, avec les erreurs à télécharger, reste dans l'", "storico import": "historique des imports", + "L'utente ha raggiunto il numero massimo di prestiti per questo tipo di materiale.": "Le lecteur a atteint le nombre maximal de prêts pour ce type de support.", + "Questo materiale è solo in consultazione per la tipologia del lettore.": "Ce document est en consultation sur place uniquement pour la catégorie du lecteur.", + "Le prenotazioni non sono consentite per la tipologia del lettore.": "Les réservations ne sont pas autorisées pour la catégorie du lecteur.", + "Il lettore ha raggiunto il numero massimo di prenotazioni.": "Le lecteur a atteint le nombre maximal de réservations.", + "Calcola la scadenza dalla durata tessera della tipologia lettore (se indicata)": "Calculer l'échéance à partir de la durée de carte de la catégorie de lecteur (si indiquée)", + "Tipologia lettore": "Catégorie de lecteur", + "Codice tipologia lettore": "Code de la catégorie de lecteur", + "Solo consultazione": "Consultation sur place", + "Rendi prestabile": "Rendre empruntable", + "Riserva alla consultazione": "Réserver à la consultation sur place", + "predefinita": "par défaut", + "Regole di circolazione": "Règles de circulation", + "Prestiti attivi massimi": "Prêts actifs maximum", + "Nessun limite": "Aucune limite", + "Durata prestito (giorni)": "Durée du prêt (jours)", + "Durata massima richiedibile (giorni)": "Durée maximale demandable (jours)", + "Prenotazioni disattivate": "Réservations désactivées", + "Massimo %d": "Maximum %d", + "Durata tessera (giorni)": "Durée de la carte (jours)", + "Le regole per tipo di materiale (Impostazioni → Circolazione) possono ridurre questi valori per singoli materiali.": "Les règles par type de support (Paramètres → Circulation) peuvent réduire ces valeurs pour certains supports.", + "Le tabelle della circolazione non sono ancora state create: completa l'aggiornamento del database.": "Les tables de circulation n'ont pas encore été créées : terminez la mise à jour de la base de données.", + "La copia è impegnata da un prestito, un ritiro o una richiesta: chiudili prima di riservarla alla consultazione.": "L'exemplaire est engagé par un prêt, un retrait ou une demande : clôturez-les avant de le réserver à la consultation.", + "La copia è ora solo in consultazione.": "L'exemplaire est désormais en consultation sur place uniquement.", + "La copia è di nuovo prestabile.": "L'exemplaire est de nouveau empruntable.", + "generato dal nome": "généré à partir du nom", + "Eredita: %s": "Hérité : %s", + "Prestiti attivi massimi 0 = nessun limite. La durata della tessera, se indicata, calcola la scadenza alla creazione, all'approvazione e al rinnovo della tessera.": "Prêts actifs maximum 0 = aucune limite. La durée de la carte, si elle est indiquée, calcule l'échéance à la création, à l'approbation et au renouvellement de la carte.", + "Prenotazioni consentite": "Réservations autorisées", + "Assegnabile ai lettori": "Attribuable aux lecteurs", + "Tipologia predefinita per i nuovi lettori": "Catégorie par défaut des nouveaux lecteurs", + "Hai raggiunto il numero massimo di prestiti per questo tipo di materiale": "Vous avez atteint le nombre maximal de prêts pour ce type de support", + "Questo materiale è solo in consultazione: non può essere preso in prestito": "Ce document est en consultation sur place : il ne peut pas être emprunté", + "Le prenotazioni non sono consentite per la tua tipologia di lettore": "Les réservations ne sont pas autorisées pour votre catégorie de lecteur", + "Hai raggiunto il numero massimo di prenotazioni e richieste in attesa": "Vous avez atteint le nombre maximal de réservations et de demandes en attente", + "Tipologia lettore non trovata.": "Catégorie de lecteur introuvable.", + "Modifica tipologia lettore": "Modifier la catégorie de lecteur", + "Esiste già una tipologia con questo codice.": "Une catégorie avec ce code existe déjà.", + "Tipologia lettore creata.": "Catégorie de lecteur créée.", + "Impossibile salvare la tipologia lettore.": "Impossible d'enregistrer la catégorie de lecteur.", + "Tipologia lettore aggiornata.": "Catégorie de lecteur mise à jour.", + "La tipologia predefinita non può essere eliminata: scegli prima un'altra predefinita.": "La catégorie par défaut ne peut pas être supprimée : choisissez d'abord une autre catégorie par défaut.", + "Tipologia lettore eliminata.": "Catégorie de lecteur supprimée.", + "Impossibile eliminare la tipologia lettore.": "Impossible de supprimer la catégorie de lecteur.", + "Tipologia predefinita aggiornata.": "Catégorie par défaut mise à jour.", + "Valore non valido nella matrice (%s, %s).": "Valeur non valide dans la matrice (%s, %s).", + "Regole di circolazione salvate.": "Règles de circulation enregistrées.", + "Impossibile salvare le regole di circolazione.": "Impossible d'enregistrer les règles de circulation.", + "Il nome della tipologia è obbligatorio (massimo 100 caratteri).": "Le nom de la catégorie est obligatoire (100 caractères maximum).", + "Il codice può contenere solo lettere minuscole, numeri, trattini e trattini bassi (massimo 40 caratteri).": "Le code ne peut contenir que des lettres minuscules, des chiffres, des tirets et des tirets bas (40 caractères maximum).", + "Giorni per rinnovo": "Jours par renouvellement", + "Prenotazioni massime": "Réservations maximum", + "Regole di circolazione del lettore": "Règles de circulation du lecteur", + "Per confermare comunque la prenotazione, spunta «Ignora i limiti di prenotazione» e salva di nuovo.": "Pour confirmer quand même la réservation, cochez « Ignorer les limites de réservation » et enregistrez à nouveau.", + "Ignora i limiti di prenotazione della tipologia del lettore": "Ignorer les limites de réservation de la catégorie du lecteur", + "Decide le regole di prestito (limiti, durate, prenotazioni). Il tipo utente decide solo i permessi.": "Détermine les règles de prêt (limites, durées, réservations). Le type d'utilisateur ne détermine que les autorisations.", + "Tipologie di lettore": "Catégories de lecteurs", + "Ogni lettore appartiene a una tipologia (bambino, studente, docente…) che decide le sue regole di prestito. Il ruolo dell'utente continua a decidere solo i permessi.": "Chaque lecteur appartient à une catégorie (enfant, étudiant, enseignant…) qui détermine ses règles de prêt. Le rôle de l'utilisateur ne détermine toujours que les autorisations.", + "Un campo lasciato vuoto eredita l'impostazione globale della scheda Prestiti. I lettori senza tipologia usano quella predefinita.": "Un champ laissé vide hérite du paramètre global de l'onglet Prêts. Les lecteurs sans catégorie utilisent la catégorie par défaut.", + "Nuova tipologia": "Nouvelle catégorie", + "Crea tipologia": "Créer la catégorie", + "Regole effettive": "Règles effectives", + "Lettori": "Lecteurs", + "Nessuna tipologia di lettore": "Aucune catégorie de lecteur", + "Predefinita": "Par défaut", + "%s prestiti · %d giorni · %d rinnovi": "%s prêts · %d jours · %d renouvellements", + "Prenotazioni: %s": "Réservations : %s", + "Tessera: %d giorni": "Carte : %d jours", + "Rendi predefinita": "Définir par défaut", + "Eliminare questa tipologia? I suoi lettori passeranno alla tipologia predefinita.": "Supprimer cette catégorie ? Ses lecteurs passeront à la catégorie par défaut.", + "Regole per tipo di materiale": "Règles par type de support", + "Eccezioni per tipologia di lettore e tipo di materiale: prestabilità, prestiti massimi di quel materiale (oltre al limite totale), durata, rinnovi e giorni per rinnovo. 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, renouvellements et jours par renouvellement. Les valeurs en gris sont héritées ; saisissez une valeur seulement là où une exception est nécessaire.", + "Eredita (%s)": "Hériter (%s)", + "prestabile": "empruntable", + "consultazione": "consultation", + "Max prestiti": "Prêts max.", + "Giorni": "Jours", + "Salva regole": "Enregistrer les règles", + "Torna alla circolazione": "Retour à la circulation", + "Salva tipologia": "Enregistrer la catégorie", + "Prenotazioni non disponibili per il tuo profilo": "Réservations non disponibles pour votre profil", + "L'utente ha raggiunto il numero massimo di prestiti consentiti per questo tipo di materiale dalla sua tipologia di lettore.": "Le lecteur a atteint le nombre maximal de prêts que sa catégorie autorise pour ce type de support.", + "Questo materiale (o la copia indicata) è solo in consultazione per la tipologia di questo lettore.": "Ce document (ou l'exemplaire indiqué) est en consultation sur place uniquement pour la catégorie de ce lecteur.", + "Tipologie di lettore e regole di prestito per tipo di materiale.": "Catégories de lecteurs et règles de prêt par type de support.", + "Avviso scadenza tessera": "Avis d'expiration de la carte", + "Avviso scadenza tessera: giorni prima della scadenza": "Avis d'expiration de la carte : jours avant l'échéance", + "Il lettore riceve un'email quando la sua tessera sta per scadere, così può rinnovarla prima di non poter più chiedere prestiti.": "Le lecteur reçoit un e-mail quand sa carte va expirer, pour la renouveler avant de ne plus pouvoir emprunter.", + "Impossibile rinnovare la tessera di questo account.": "Impossible de renouveler la carte de ce compte.", + "Inviata qualche giorno prima della scadenza della tessera del lettore. I giorni di preavviso si impostano in Impostazioni → Prestiti (0 = disattivata).": "Envoyé quelques jours avant l'expiration de la carte du lecteur. Les jours de préavis se règlent dans Paramètres → Prêts (0 = désactivé).", + "Inviato una sola volta per scadenza dal processo pianificato delle notifiche (template «Tessera in scadenza»). 0 disattiva l'avviso. Predefinito: 0": "Envoyé une seule fois par échéance par la tâche planifiée des notifications (modèle « Carte bientôt expirée »). 0 désactive l'avis. Par défaut : 0", + "Rinnova tessera": "Renouveler la carte", + "Rinnovare la tessera fino al %s?": "Renouveler la carte jusqu'au %s ?", + "Tessera in scadenza": "Carte bientôt expirée", + "Tessera rinnovata fino al %s.": "Carte renouvelée jusqu'au %s.", + "Ultimi 12 mesi. I lettori senza tipologia sono contati in «%s», la tipologia predefinita.": "12 derniers mois. Les lecteurs sans type sont comptés dans « %s », le type par défaut.", + "Ultimi 12 mesi.": "12 derniers mois.", + "Gg/rinnovo": "J./renouv.", + "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.", + "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)", + "Il file non era in UTF-8 ed è stato convertito da Windows-1252: controlla le lettere accentate nell'anteprima.": "Le fichier n'était pas en UTF-8 et a été converti depuis Windows-1252 : vérifiez les lettres accentuées dans l'aperçu.", "L'email del fornitore non è un indirizzo valido.": "L'e-mail du fournisseur n'est pas une adresse valide.", "Il sito web del fornitore non è un indirizzo valido: indica un URL http(s), ad esempio https://www.esempio.it.": "Le site web du fournisseur n'est pas une adresse valide : indiquez une URL http(s), par exemple https://www.exemple.fr.", "Assegnato ai libri nuovi e, con «Sovrascrivi», ai libri già presenti. I libri saltati o solo completati non cambiano fornitore.": "Attribué aux nouveaux livres et, avec « Écraser », aux livres déjà présents. Les livres ignorés ou seulement complétés gardent leur fournisseur.", diff --git a/locale/it_IT.json b/locale/it_IT.json index 0cf6bfe19..493f59aab 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -8722,7 +8722,7 @@ "Utente importato, ma i campi personalizzati non sono stati salvati.": "Utente importato, ma i campi personalizzati non sono stati salvati.", "Utente importato, ma la tipologia lettore non è stata assegnata.": "Utente importato, ma la tipologia lettore non è stata assegnata.", "Tessere della biblioteca": "Tessere della biblioteca", - "Tipologia lettore (codice)": "Tipologia lettore (codice)", + "Tipologia lettore (codice o nome)": "Tipologia lettore (codice o nome)", "Mappatura delle colonne non valida.": "Mappatura delle colonne non valida.", "Il campo \"%s\" è associato a più di una colonna.": "Il campo \"%s\" è associato a più di una colonna.", "Associa una colonna al campo obbligatorio \"%s\".": "Associa una colonna al campo obbligatorio \"%s\".", @@ -8758,7 +8758,6 @@ "— Ignora colonna —": "— Ignora colonna —", "Caricamento del file...": "Caricamento del file...", "%d righe da importare": "%d righe da importare", - "Il file non era in UTF-8 ed è stato convertito da Windows-1252: controlla le lettere accentate nell'anteprima.": "Il file non era in UTF-8 ed è stato convertito da Windows-1252: controlla le lettere accentate nell'anteprima.", "%d di %d righe elaborate": "%d di %d righe elaborate", "Errore di rete. Riprova.": "Errore di rete. Riprova.", "Duplicato": "Duplicato", @@ -8789,10 +8788,113 @@ "Senza codice tessera ne viene generato uno nuovo.": "Senza codice tessera ne viene generato uno nuovo.", "Le date possono essere scritte come 2026-12-31, 31/12/2026 o 31.12.2026.": "Le date possono essere scritte come 2026-12-31, 31/12/2026 o 31.12.2026.", "I campi personalizzati della registrazione compaiono tra le colonne da associare.": "I campi personalizzati della registrazione compaiono tra le colonne da associare.", - "La tipologia lettore si indica con il suo codice.": "La tipologia lettore si indica con il suo codice.", + "La tipologia lettore si indica con il suo codice o con il suo nome.": "La tipologia lettore si indica con il suo codice o con il suo nome.", "Le password non vengono mai importate.": "Le password non vengono mai importate.", "Il riepilogo di ogni import, con gli errori da scaricare, resta nello": "Il riepilogo di ogni import, con gli errori da scaricare, resta nello", "storico import": "storico import", + "L'utente ha raggiunto il numero massimo di prestiti per questo tipo di materiale.": "L'utente ha raggiunto il numero massimo di prestiti per questo tipo di materiale.", + "Questo materiale è solo in consultazione per la tipologia del lettore.": "Questo materiale è solo in consultazione per la tipologia del lettore.", + "Le prenotazioni non sono consentite per la tipologia del lettore.": "Le prenotazioni non sono consentite per la tipologia del lettore.", + "Il lettore ha raggiunto il numero massimo di prenotazioni.": "Il lettore ha raggiunto il numero massimo di prenotazioni.", + "Calcola la scadenza dalla durata tessera della tipologia lettore (se indicata)": "Calcola la scadenza dalla durata tessera della tipologia lettore (se indicata)", + "Tipologia lettore": "Tipologia lettore", + "Codice tipologia lettore": "Codice tipologia lettore", + "Solo consultazione": "Solo consultazione", + "Rendi prestabile": "Rendi prestabile", + "Riserva alla consultazione": "Riserva alla consultazione", + "predefinita": "predefinita", + "Regole di circolazione": "Regole di circolazione", + "Prestiti attivi massimi": "Prestiti attivi massimi", + "Nessun limite": "Nessun limite", + "Durata prestito (giorni)": "Durata prestito (giorni)", + "Durata massima richiedibile (giorni)": "Durata massima richiedibile (giorni)", + "Prenotazioni disattivate": "Prenotazioni disattivate", + "Massimo %d": "Massimo %d", + "Durata tessera (giorni)": "Durata tessera (giorni)", + "Le regole per tipo di materiale (Impostazioni → Circolazione) possono ridurre questi valori per singoli materiali.": "Le regole per tipo di materiale (Impostazioni → Circolazione) possono ridurre questi valori per singoli materiali.", + "Le tabelle della circolazione non sono ancora state create: completa l'aggiornamento del database.": "Le tabelle della circolazione non sono ancora state create: completa l'aggiornamento del database.", + "La copia è impegnata da un prestito, un ritiro o una richiesta: chiudili prima di riservarla alla consultazione.": "La copia è impegnata da un prestito, un ritiro o una richiesta: chiudili prima di riservarla alla consultazione.", + "La copia è ora solo in consultazione.": "La copia è ora solo in consultazione.", + "La copia è di nuovo prestabile.": "La copia è di nuovo prestabile.", + "generato dal nome": "generato dal nome", + "Eredita: %s": "Eredita: %s", + "Prestiti attivi massimi 0 = nessun limite. La durata della tessera, se indicata, calcola la scadenza alla creazione, all'approvazione e al rinnovo della tessera.": "Prestiti attivi massimi 0 = nessun limite. La durata della tessera, se indicata, calcola la scadenza alla creazione, all'approvazione e al rinnovo della tessera.", + "Prenotazioni consentite": "Prenotazioni consentite", + "Assegnabile ai lettori": "Assegnabile ai lettori", + "Tipologia predefinita per i nuovi lettori": "Tipologia predefinita per i nuovi lettori", + "Hai raggiunto il numero massimo di prestiti per questo tipo di materiale": "Hai raggiunto il numero massimo di prestiti per questo tipo di materiale", + "Questo materiale è solo in consultazione: non può essere preso in prestito": "Questo materiale è solo in consultazione: non può essere preso in prestito", + "Le prenotazioni non sono consentite per la tua tipologia di lettore": "Le prenotazioni non sono consentite per la tua tipologia di lettore", + "Hai raggiunto il numero massimo di prenotazioni e richieste in attesa": "Hai raggiunto il numero massimo di prenotazioni e richieste in attesa", + "Tipologia lettore non trovata.": "Tipologia lettore non trovata.", + "Modifica tipologia lettore": "Modifica tipologia lettore", + "Esiste già una tipologia con questo codice.": "Esiste già una tipologia con questo codice.", + "Tipologia lettore creata.": "Tipologia lettore creata.", + "Impossibile salvare la tipologia lettore.": "Impossibile salvare la tipologia lettore.", + "Tipologia lettore aggiornata.": "Tipologia lettore aggiornata.", + "La tipologia predefinita non può essere eliminata: scegli prima un'altra predefinita.": "La tipologia predefinita non può essere eliminata: scegli prima un'altra predefinita.", + "Tipologia lettore eliminata.": "Tipologia lettore eliminata.", + "Impossibile eliminare la tipologia lettore.": "Impossibile eliminare la tipologia lettore.", + "Tipologia predefinita aggiornata.": "Tipologia predefinita aggiornata.", + "Valore non valido nella matrice (%s, %s).": "Valore non valido nella matrice (%s, %s).", + "Regole di circolazione salvate.": "Regole di circolazione salvate.", + "Impossibile salvare le regole di circolazione.": "Impossibile salvare le regole di circolazione.", + "Il nome della tipologia è obbligatorio (massimo 100 caratteri).": "Il nome della tipologia è obbligatorio (massimo 100 caratteri).", + "Il codice può contenere solo lettere minuscole, numeri, trattini e trattini bassi (massimo 40 caratteri).": "Il codice può contenere solo lettere minuscole, numeri, trattini e trattini bassi (massimo 40 caratteri).", + "Giorni per rinnovo": "Giorni per rinnovo", + "Prenotazioni massime": "Prenotazioni massime", + "Regole di circolazione del lettore": "Regole di circolazione del lettore", + "Per confermare comunque la prenotazione, spunta «Ignora i limiti di prenotazione» e salva di nuovo.": "Per confermare comunque la prenotazione, spunta «Ignora i limiti di prenotazione» e salva di nuovo.", + "Ignora i limiti di prenotazione della tipologia del lettore": "Ignora i limiti di prenotazione della tipologia del lettore", + "Decide le regole di prestito (limiti, durate, prenotazioni). Il tipo utente decide solo i permessi.": "Decide le regole di prestito (limiti, durate, prenotazioni). Il tipo utente decide solo i permessi.", + "Tipologie di lettore": "Tipologie di lettore", + "Ogni lettore appartiene a una tipologia (bambino, studente, docente…) che decide le sue regole di prestito. Il ruolo dell'utente continua a decidere solo i permessi.": "Ogni lettore appartiene a una tipologia (bambino, studente, docente…) che decide le sue regole di prestito. Il ruolo dell'utente continua a decidere solo i permessi.", + "Un campo lasciato vuoto eredita l'impostazione globale della scheda Prestiti. I lettori senza tipologia usano quella predefinita.": "Un campo lasciato vuoto eredita l'impostazione globale della scheda Prestiti. I lettori senza tipologia usano quella predefinita.", + "Nuova tipologia": "Nuova tipologia", + "Crea tipologia": "Crea tipologia", + "Regole effettive": "Regole effettive", + "Lettori": "Lettori", + "Nessuna tipologia di lettore": "Nessuna tipologia di lettore", + "Predefinita": "Predefinita", + "%s prestiti · %d giorni · %d rinnovi": "%s prestiti · %d giorni · %d rinnovi", + "Prenotazioni: %s": "Prenotazioni: %s", + "Tessera: %d giorni": "Tessera: %d giorni", + "Rendi predefinita": "Rendi predefinita", + "Eliminare questa tipologia? I suoi lettori passeranno alla tipologia predefinita.": "Eliminare questa tipologia? I suoi lettori passeranno alla tipologia predefinita.", + "Regole per tipo di materiale": "Regole per tipo di materiale", + "Eccezioni per tipologia di lettore e tipo di materiale: prestabilità, prestiti massimi di quel materiale (oltre al limite totale), durata, rinnovi e giorni per rinnovo. 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, rinnovi e giorni per rinnovo. I valori in grigio sono quelli ereditati; scrivi un valore solo dove serve un'eccezione.", + "Eredita (%s)": "Eredita (%s)", + "prestabile": "prestabile", + "consultazione": "consultazione", + "Max prestiti": "Max prestiti", + "Giorni": "Giorni", + "Salva regole": "Salva regole", + "Torna alla circolazione": "Torna alla circolazione", + "Salva tipologia": "Salva tipologia", + "Prenotazioni non disponibili per il tuo profilo": "Prenotazioni non disponibili per il tuo profilo", + "L'utente ha raggiunto il numero massimo di prestiti consentiti per questo tipo di materiale dalla sua tipologia di lettore.": "L'utente ha raggiunto il numero massimo di prestiti consentiti per questo tipo di materiale dalla sua tipologia di lettore.", + "Questo materiale (o la copia indicata) è solo in consultazione per la tipologia di questo lettore.": "Questo materiale (o la copia indicata) è solo in consultazione per la tipologia di questo lettore.", + "Tipologie di lettore e regole di prestito per tipo di materiale.": "Tipologie di lettore e regole di prestito per tipo di materiale.", + "Avviso scadenza tessera": "Avviso scadenza tessera", + "Avviso scadenza tessera: giorni prima della scadenza": "Avviso scadenza tessera: giorni prima della scadenza", + "Il lettore riceve un'email quando la sua tessera sta per scadere, così può rinnovarla prima di non poter più chiedere prestiti.": "Il lettore riceve un'email quando la sua tessera sta per scadere, così può rinnovarla prima di non poter più chiedere prestiti.", + "Impossibile rinnovare la tessera di questo account.": "Impossibile rinnovare la tessera di questo account.", + "Inviata qualche giorno prima della scadenza della tessera del lettore. I giorni di preavviso si impostano in Impostazioni → Prestiti (0 = disattivata).": "Inviata qualche giorno prima della scadenza della tessera del lettore. I giorni di preavviso si impostano in Impostazioni → Prestiti (0 = disattivata).", + "Inviato una sola volta per scadenza dal processo pianificato delle notifiche (template «Tessera in scadenza»). 0 disattiva l'avviso. Predefinito: 0": "Inviato una sola volta per scadenza dal processo pianificato delle notifiche (template «Tessera in scadenza»). 0 disattiva l'avviso. Predefinito: 0", + "Rinnova tessera": "Rinnova tessera", + "Rinnovare la tessera fino al %s?": "Rinnovare la tessera fino al %s?", + "Tessera in scadenza": "Tessera in scadenza", + "Tessera rinnovata fino al %s.": "Tessera rinnovata fino al %s.", + "Ultimi 12 mesi. I lettori senza tipologia sono contati in «%s», la tipologia predefinita.": "Ultimi 12 mesi. I lettori senza tipologia sono contati in «%s», la tipologia predefinita.", + "Ultimi 12 mesi.": "Ultimi 12 mesi.", + "Gg/rinnovo": "Gg/rinnovo", + "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.", + "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)", + "Il file non era in UTF-8 ed è stato convertito da Windows-1252: controlla le lettere accentate nell'anteprima.": "Il file non era in UTF-8 ed è stato convertito da Windows-1252: controlla le lettere accentate nell'anteprima.", "L'email del fornitore non è un indirizzo valido.": "L'email del fornitore non è un indirizzo valido.", "Il sito web del fornitore non è un indirizzo valido: indica un URL http(s), ad esempio https://www.esempio.it.": "Il sito web del fornitore non è un indirizzo valido: indica un URL http(s), ad esempio https://www.esempio.it.", "Assegnato ai libri nuovi e, con «Sovrascrivi», ai libri già presenti. I libri saltati o solo completati non cambiano fornitore.": "Assegnato ai libri nuovi e, con «Sovrascrivi», ai libri già presenti. I libri saltati o solo completati non cambiano fornitore.", diff --git a/storage/plugins/mobile-api/src/Controllers/ActionsController.php b/storage/plugins/mobile-api/src/Controllers/ActionsController.php index 2a3c7b443..0247c656c 100644 --- a/storage/plugins/mobile-api/src/Controllers/ActionsController.php +++ b/storage/plugins/mobile-api/src/Controllers/ActionsController.php @@ -1231,6 +1231,13 @@ private function mapActionError(ResponseInterface $response, string $code, array return ResponseEnvelope::error($response, 'not_available', __('Il libro non è disponibile per il periodo richiesto.'), 409); case 'max_loans_reached': return ResponseEnvelope::error($response, 'max_loans_reached', __('Hai raggiunto il numero massimo di prestiti attivi.'), 409); + // Circulation rules of the patron type (Pinakes 0.9.0): additive + // codes; older apps still show the server message. + case 'media_limit_reached': + case 'not_loanable': + case 'reservations_disabled': + case 'reservation_limit_reached': + return ResponseEnvelope::error($response, $code, \App\Support\LoanEligibility::errorMessage($code), 409); case 'not_eligible': // Guardia di idoneità M7 (utente sospeso o tessera scaduta): // condizione permanente lato utente, mai un errore server. diff --git a/storage/plugins/mobile-api/src/Controllers/CatalogController.php b/storage/plugins/mobile-api/src/Controllers/CatalogController.php index 73754b32f..0f5a0ba4a 100644 --- a/storage/plugins/mobile-api/src/Controllers/CatalogController.php +++ b/storage/plugins/mobile-api/src/Controllers/CatalogController.php @@ -845,6 +845,12 @@ public function bookAvailability( // Upper bound POST /reservations accepts for end_date (Mobile API 1.8.2). 'max_loan_duration_days' => (int) ($avail['max_loan_duration_days'] ?? 90), ]; + // The reader's circulation rules for this title (Pinakes 0.9.0, + // additive): consultation-only material is not requestable and a + // patron type without reservations cannot join the waitlist. + $viewer = (new \App\Services\CirculationPolicy($this->db))->forViewer($userId > 0 ? $userId : null, $bookId); + $data['loanable'] = $viewer->loanable; + $data['reservations_enabled'] = $viewer->user->reservationsEnabled; return ResponseEnvelope::success($response, $data, []); } catch (\Throwable $e) { diff --git a/storage/plugins/mobile-api/src/Controllers/OpenApiController.php b/storage/plugins/mobile-api/src/Controllers/OpenApiController.php index 80aed872b..d2e56e8b5 100644 --- a/storage/plugins/mobile-api/src/Controllers/OpenApiController.php +++ b/storage/plugins/mobile-api/src/Controllers/OpenApiController.php @@ -612,6 +612,8 @@ private function paths(): array ]], 'loan_duration_days' => ['type' => 'integer', 'minimum' => 1, 'description' => 'Days POST /reservations adds to the chosen start to set the return date.'], 'max_loan_duration_days' => ['type' => 'integer', 'minimum' => 1, 'description' => 'Longest loan POST /reservations accepts through end_date (Mobile API 1.8.2).'], + 'loanable' => ['type' => 'boolean', 'description' => 'False when the title is consultation only for this reader (patron type × material rule, or every copy kept for consultation): POST /reservations answers 409 not_loanable (Pinakes 0.9.0).'], + 'reservations_enabled' => ['type' => 'boolean', 'description' => 'False when the reader\'s patron type does not allow waitlist reservations (409 reservations_disabled) (Pinakes 0.9.0).'], ], ]], ]]], @@ -735,7 +737,7 @@ private function paths(): array ], '400' => ['$ref' => '#/components/responses/UnprocessableEntity'], '401' => ['$ref' => '#/components/responses/Unauthorized'], - '409' => ['description' => 'Overlap or book unavailable. For not_available, when the start day is free but a scheduled loan or reservation begins inside the requested window, error.message names the longest loan still possible and meta carries max_duration_days, max_end_date and first_conflict_date (Mobile API 1.8.2) so the client can propose that return date.', 'content' => $json], + '409' => ['description' => 'Overlap or book unavailable. For not_available, when the start day is free but a scheduled loan or reservation begins inside the requested window, error.message names the longest loan still possible and meta carries max_duration_days, max_end_date and first_conflict_date (Mobile API 1.8.2) so the client can propose that return date. Circulation rules of the reader\'s patron type (Pinakes 0.9.0): max_loans_reached, media_limit_reached (limit for this material type), not_loanable (consultation only), reservations_disabled, reservation_limit_reached.', 'content' => $json], '422' => ['description' => 'Invalid book id or date, or end_date beyond max_loan_duration_days (max_duration_exceeded).', 'content' => $json], '500' => ['$ref' => '#/components/responses/InternalError'], ], diff --git a/storage/plugins/mobile-api/src/Controllers/StaffReservationsController.php b/storage/plugins/mobile-api/src/Controllers/StaffReservationsController.php index 857dd9a09..0d93de7f1 100644 --- a/storage/plugins/mobile-api/src/Controllers/StaffReservationsController.php +++ b/storage/plugins/mobile-api/src/Controllers/StaffReservationsController.php @@ -95,6 +95,9 @@ public function create(ServerRequestInterface $request, ResponseInterface $respo 'data_scadenza' => $dates['expires_at'] ?? '', 'data_inizio_richiesta' => $dates['requested_from'] ?? '', 'data_fine_richiesta' => $dates['requested_to'] ?? '', + // Staff may override the patron type's reservation limits + // explicitly (Pinakes 0.9.0), as the admin form does. + 'force_limits' => ($json['override_limits'] ?? false) === true ? '1' : '', ]; $db = $this->db; $reader = $this->reader; diff --git a/storage/plugins/mobile-api/src/Support/CirculationOutcome.php b/storage/plugins/mobile-api/src/Support/CirculationOutcome.php index f95b16e12..1a1f5b538 100644 --- a/storage/plugins/mobile-api/src/Support/CirculationOutcome.php +++ b/storage/plugins/mobile-api/src/Support/CirculationOutcome.php @@ -36,6 +36,8 @@ final class CirculationOutcome 'user_suspended' => [409, 'not_eligible', null, true], 'card_expired' => [409, 'not_eligible', null, true], 'max_loans_reached' => [409, 'max_loans_reached', null, false], + 'media_limit_reached' => [409, 'media_limit_reached', null, false], + 'not_loanable' => [409, 'not_loanable', null, false], 'copy_not_available' => [409, 'not_available', null, true], 'no_copies_available' => [409, 'not_available', null, true], 'libro_in_prestito' => [409, 'not_available', null, true], @@ -97,6 +99,9 @@ final class CirculationOutcome 'promoted_loan_active' => [409, 'conflict', null, true], 'user_suspended' => [409, 'not_eligible', null, true], 'card_expired' => [409, 'not_eligible', null, true], + 'not_loanable' => [409, 'not_loanable', null, false], + 'reservations_disabled' => [409, 'reservations_disabled', null, false], + 'reservation_limit_reached' => [409, 'reservation_limit_reached', null, false], 'save_failed' => [500, 'internal_error', null, false], 'update_failed' => [500, 'internal_error', null, false], ]; @@ -244,6 +249,10 @@ public static function message(string $coreCode): string 'copy_wrong_book' => __('La copia indicata appartiene a un altro libro.'), 'duplicate_reservation', 'duplicate' => __('Esiste già un prestito o una prenotazione attiva per questo utente e questo libro.'), 'max_loans_reached' => __('L\'utente ha raggiunto il numero massimo di prestiti attivi.'), + 'media_limit_reached' => __('L\'utente ha raggiunto il numero massimo di prestiti per questo tipo di materiale.'), + 'not_loanable' => __('Questo materiale è solo in consultazione per la tipologia del lettore.'), + 'reservations_disabled' => __('Le prenotazioni non sono consentite per la tipologia del lettore.'), + 'reservation_limit_reached' => __('Il lettore ha raggiunto il numero massimo di prenotazioni.'), 'copy_not_available', 'no_copies_available', 'libro_in_prestito', 'loan_copy_conflict', 'extension_conflicts', 'capacity_full', 'bulk_extend_conflict' => __('Nessuna copia disponibile per il periodo richiesto.'), 'loan_closed' => __('Il prestito è chiuso e non può essere modificato.'), diff --git a/storage/plugins/mobile-api/src/Support/StaffLoanReader.php b/storage/plugins/mobile-api/src/Support/StaffLoanReader.php index 5eb6e9007..5c58924d5 100644 --- a/storage/plugins/mobile-api/src/Support/StaffLoanReader.php +++ b/storage/plugins/mobile-api/src/Support/StaffLoanReader.php @@ -23,7 +23,7 @@ final class StaffLoanReader public const BUCKETS = ['pending', 'pickup', 'scheduled', 'active', 'overdue', 'history', 'all']; public const RESERVATION_STATES = ['attiva', 'completata', 'annullata', 'all']; - private ?int $maxRenewals = null; + private ?\App\Services\CirculationPolicy $circulation = null; public function __construct(private mysqli $db) { @@ -214,6 +214,7 @@ public function summary(): array => (int) ($this->scalar('SELECT COUNT(*)' . $join . ' WHERE ' . $where, $types, $params) ?? 0); $settings = new \App\Models\SettingsRepository($this->db); + $defaults = $this->circulation()->defaults(); return [ 'pending' => $count("p.stato = 'pendente'"), @@ -230,22 +231,45 @@ public function summary(): array 'returned_today' => $count('p.data_restituzione = ?', 's', [$today]), 'today' => $today, 'settings' => [ - 'loan_duration_days' => $settings->loanDurationDays(), + 'loan_duration_days' => $defaults->durationDays, 'pickup_expiry_days' => (int) ($settings->get('loans', 'pickup_expiry_days', '3') ?? 3), - 'max_renewals' => $this->maxRenewals(), - 'max_active_loans_per_user' => (int) ($settings->get('loans', 'max_active_loans_per_user', '0') ?? 0), + 'max_renewals' => $defaults->maxRenewals, + 'max_active_loans_per_user' => $defaults->maxActiveLoans, 'allow_multiple_loans_same_book' => $settings->allowsMultipleLoansSameBook(), ], ]; } + /** The global renewal limit (no patron context). */ public function maxRenewals(): int { - if ($this->maxRenewals === null) { - $this->maxRenewals = (int) ((new \App\Models\SettingsRepository($this->db))->get('loans', 'max_renewals', '3') ?? 3); + return $this->circulation()->defaults()->maxRenewals; + } + + /** + * Renewal limit of one loan row: the circulation rules of its patron and + * material (CirculationPolicy), the global limit when the row lacks ids. + * + * @param array $r + */ + public function maxRenewalsFor(array $r): int + { + if (!isset($r['utente_id'], $r['libro_id'])) { + return $this->maxRenewals(); } + // The renewal limit depends on the patron type and the material only + // (a consultation copy changes whether it is lent, not how often it is + // renewed), and both lookups are memoised per list: no per-row copy query. + $circulation = $this->circulation(); + return $circulation->forType( + $circulation->patronTypeOf((int) $r['utente_id']), + $circulation->mediaOf((int) $r['libro_id']) + )->maxRenewals; + } - return $this->maxRenewals; + private function circulation(): \App\Services\CirculationPolicy + { + return $this->circulation ??= new \App\Services\CirculationPolicy($this->db); } // ─── Reservations ─────────────────────────────────────────────────────── @@ -438,7 +462,7 @@ public function mapLoan(array $r): array $daysOverdue = (int) ((new \DateTimeImmutable($due))->diff(new \DateTimeImmutable($today))->days ?: 0); } $penalty = $r['sanzione'] ?? null; - $maxRenewals = $this->maxRenewals(); + $maxRenewals = $this->maxRenewalsFor($r); $processedBy = isset($r['processed_by'], $r['pb_nome']) ? ['id' => (int) $r['processed_by'], 'name' => self::name($r['pb_nome'], $r['pb_cognome'] ?? '')] : null; diff --git a/storage/plugins/ncip-server/NcipServerPlugin.php b/storage/plugins/ncip-server/NcipServerPlugin.php index 8caf1842d..a8e41cab4 100644 --- a/storage/plugins/ncip-server/NcipServerPlugin.php +++ b/storage/plugins/ncip-server/NcipServerPlugin.php @@ -79,6 +79,9 @@ class NcipServerPlugin 'user-ineligible-to-renew' => ['renewitemprocessingerror.scm', 'User Ineligible To Renew This Item'], 'maximum-renewals-exceeded' => ['renewitemprocessingerror.scm', 'Maximum Renewals Exceeded'], 'user-ineligible-to-request' => ['requestitemprocessingerror.scm', 'User Ineligible To Request This Item'], + // Consultation-only material for this patron (CirculationPolicy). + 'item-does-not-circulate' => ['checkoutitemprocessingerror.scm', 'Item Does Not Circulate'], + 'request-item-does-not-circulate' => ['requestitemprocessingerror.scm', 'Item Does Not Circulate'], 'unknown-request' => ['cancelrequestitemprocessingerror.scm', 'Unknown Request'], ]; @@ -887,9 +890,8 @@ private function handleCheckOutItem( // Atomic checkout: lock the book row so concurrent requests serialize. $today = \App\Support\DateHelper::today(); - $loanDays = (int) ((new \App\Models\SettingsRepository($this->db))->get('loans', 'loan_duration_days', '30') ?? 30); - $loanDays = $loanDays > 0 ? $loanDays : 30; - $dueDate = (new \DateTimeImmutable($today))->modify("+{$loanDays} days")->format('Y-m-d'); + $loanDays = (new \App\Services\CirculationPolicy($this->db))->forLoan($userId, $itemId)->durationDays; + $dueDate = \App\Services\CirculationPolicy::dueDateFor($today, $loanDays); $failureReason = null; $loanId = $this->createLoanAtomic($itemId, $userId, $dueDate, (int) ($caller['id'] ?? 0), $failureReason); @@ -907,6 +909,8 @@ private function handleCheckOutItem( return $this->xmlResponse($response, $this->buildProblem('User is not eligible to check out this item', 'user-ineligible-to-check-out')); case 'max_loans': return $this->xmlResponse($response, $this->buildProblem('Maximum item checkouts reached for this user', 'user-loan-limit-reached')); + case 'not_loanable': + return $this->xmlResponse($response, $this->buildProblem('Item is for consultation only and does not circulate for this user', 'item-does-not-circulate')); case 'no_capacity': case 'no_copy': return $this->xmlResponse($response, $this->buildProblem('No copies available', 'item-not-checked-in')); @@ -1161,9 +1165,10 @@ private function handleRequestItem( $requestId = (string) ($message?->RequestId->RequestIdentifierValue ?? ''); $today = \App\Support\DateHelper::today(); - $loanDays = (int) ((new \App\Models\SettingsRepository($this->db))->get('loans', 'loan_duration_days', '30') ?? 30); - $loanDays = $loanDays > 0 ? $loanDays : 30; - $dueDate = (new \DateTimeImmutable($today))->modify("+{$loanDays} days")->format('Y-m-d'); + // 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); $failureReason = 'db_error'; $requestOutcome = $this->createRequestItemNcip( $itemId, @@ -1178,6 +1183,8 @@ private function handleRequestItem( 'duplicate' => 'duplicate-request', 'ineligible' => 'user-ineligible-to-request', 'max_loans' => 'user-ineligible-to-request', + 'not_loanable' => 'request-item-does-not-circulate', + 'reservation_limit', 'reservations_disabled' => 'user-ineligible-to-request', default => 'temporary-processing-failure', }; SecureLogger::error('[NcipServer] createRequestItemNcip failed: ' . $failureReason); @@ -1921,22 +1928,18 @@ private function createLoanAtomic(int $bookId, int $userId, string $dueDate, int return null; } - $maxLoans = (int) ((new \App\Models\SettingsRepository($this->db))->get('loans', 'max_active_loans_per_user', '0') ?? 0); - if ($maxLoans > 0) { - $count = $this->db->prepare( - "SELECT COUNT(*) FROM prestiti - WHERE utente_id = ? AND attivo = 1 - AND stato IN ('prenotato','da_ritirare','in_corso','in_ritardo')" - ); - $count->bind_param('i', $userId); - $count->execute(); - $activeLoans = (int) ($count->get_result()->fetch_row()[0] ?? 0); - $count->close(); - if ($activeLoans >= $maxLoans) { + // Circulation rules of this patron and title (CirculationPolicy), counted + // under the user lock: loanability, total and per-material limits. + $circulation = new \App\Services\CirculationPolicy($this->db); + $limitError = $circulation->loanLimitError($circulation->forLoan($userId, $bookId), $userId); + if ($limitError !== null) { + if ($limitError === 'not_loanable') { + $failureReason = 'not_loanable'; + } else { $failureReason = 'max_loans'; - $this->db->rollback(); - return null; } + $this->db->rollback(); + return null; } $capacity = new \App\Services\CapacityService($this->db); @@ -1956,6 +1959,7 @@ private function createLoanAtomic(int $bookId, int $userId, string $dueDate, int "SELECT c.id FROM copie c WHERE c.libro_id = ? AND c.stato IN ('disponibile','prenotato') + AND " . (new \App\Services\CirculationPolicy($this->db))->lendableCopySql('c') . " AND NOT EXISTS ( SELECT 1 FROM prestiti p WHERE p.copia_id = c.id @@ -2114,6 +2118,21 @@ private function createRequestItemNcip( // core patron entry point. If the only capacity depends on an // earlier return, persist a real FIFO reservation instead of a // bare pending loan that staff approval would inevitably reject. + // Patron type rules before the gate: consultation-only material + // is neither lent nor queued, and the reservation limit counts + // queue reservations plus requests awaiting approval. + $requestPolicy = (new \App\Services\CirculationPolicy($this->db))->forLoan($userId, $bookId); + if (!$requestPolicy->loanable) { + $failureReason = 'not_loanable'; + $this->db->rollback(); + return null; + } + if ((new \App\Services\CirculationPolicy($this->db))->reservationLimitError($requestPolicy->user, $userId, queue: false) !== null) { + $failureReason = 'reservation_limit'; + $this->db->rollback(); + return null; + } + $routing = (new \App\Services\LoanRequestGate($this->db))->route( $bookId, $userId, @@ -2122,6 +2141,11 @@ private function createRequestItemNcip( inCallerTransaction: true ); if ($routing->isReservation()) { + if (!$requestPolicy->user->reservationsEnabled) { + $failureReason = 'reservations_disabled'; + $this->db->rollback(); + return null; + } $reservationId = (int) $routing->reservationId; if (!$this->insertTransactionRecord( 'RequestItem', @@ -2138,22 +2162,18 @@ private function createRequestItemNcip( // A waitlist reservation does not consume an active-loan slot. // Enforce the cap only after the gate kept the request on the loan // path, matching ReservationsController/UserActionsController. - $maxLoans = (int) ((new \App\Models\SettingsRepository($this->db))->get('loans', 'max_active_loans_per_user', '0') ?? 0); - if ($maxLoans > 0) { - $count = $this->db->prepare( - "SELECT COUNT(*) FROM prestiti - WHERE utente_id = ? AND attivo = 1 - AND stato IN ('prenotato','da_ritirare','in_corso','in_ritardo')" - ); - $count->bind_param('i', $userId); - $count->execute(); - $activeLoans = (int) ($count->get_result()->fetch_row()[0] ?? 0); - $count->close(); - if ($activeLoans >= $maxLoans) { + // Circulation rules of this patron and title (CirculationPolicy), counted + // under the user lock: loanability, total and per-material limits. + $circulation = new \App\Services\CirculationPolicy($this->db); + $limitError = $circulation->loanLimitError($circulation->forLoan($userId, $bookId), $userId); + if ($limitError !== null) { + if ($limitError === 'not_loanable') { + $failureReason = 'not_loanable'; + } else { $failureReason = 'max_loans'; - $this->db->rollback(); - return null; } + $this->db->rollback(); + return null; } $stmt = $this->db->prepare( @@ -2766,18 +2786,17 @@ private function extendLoan( return null; } - $settings = new \App\Models\SettingsRepository($this->db); - $maxRenewals = (int) ($settings->get('loans', 'max_renewals', '3') ?? 3); - $maxRenewals = $maxRenewals >= 0 ? $maxRenewals : 3; + // Renewal limit and length of this loan's patron and material. + $renewPolicy = (new \App\Services\CirculationPolicy($this->db))->forExistingLoan($loanId); + $maxRenewals = $renewPolicy->maxRenewals; if ((int) $loan['renewals'] >= $maxRenewals) { $this->db->rollback(); $failureReason = 'max_renewals'; return null; } - $renewDays = (int) ($settings->get('loans', 'loan_duration_days', '30') ?? 30); - $renewDays = $renewDays > 0 ? $renewDays : 30; + $renewDays = $renewPolicy->renewalDays; $currentDue = (string) $loan['data_scadenza']; - $newDueDate = (new \DateTimeImmutable($currentDue))->modify("+{$renewDays} days")->format('Y-m-d'); + $newDueDate = \App\Services\CirculationPolicy::dueDateFor($currentDue, $renewDays); // #336 parity con PrestitiController::renew/bulkExtend: il giorno di // scadenza corrente è già detenuto da QUESTO prestito, quindi la diff --git a/tests/circulation-card.spec.js b/tests/circulation-card.spec.js new file mode 100644 index 000000000..26cdda804 --- /dev/null +++ b/tests/circulation-card.spec.js @@ -0,0 +1,250 @@ +// @ts-check +/** + * Circulation policy — library card of the patron type. + * A: automatic card duration on creation, edit and registration approval. + * B: "Rinnova tessera" from the reader page. + * C: the card_expiring email, sent once per expiry by the notifications cron + * (Mailpit on :8025 receives it). + */ +const { test, expect } = require('@playwright/test'); +const { execFileSync } = require('child_process'); +const path = require('path'); +const F = require('./helpers/circulation-fixtures'); + +test.skip(!F.CONFIGURED, 'E2E credentials not configured'); + +const RUN = String(Date.now()).slice(-6); +const PREFIX = `e2ecd${RUN}`; +const MAILPIT = process.env.MAILPIT_API || 'http://localhost:8025/api/v1'; +const ROOT = path.resolve(__dirname, '..'); + +/** Days between two Y-m-d dates (calendar, DST-safe). */ +const daysBetween = (a, b) => Math.round((Date.parse(`${b}T12:00:00Z`) - Date.parse(`${a}T12:00:00Z`)) / 86400000); +const cardOf = (id) => F.dbQuery(`SELECT IFNULL(data_scadenza_tessera,'') FROM utenti WHERE id=${id}`); + +/** Create a patron from the admin form; returns its id. */ +async function createUserViaForm(page, { email, surname, typeId, cardAuto = true, manualExpiry = '' }) { + await page.goto(`${F.BASE}/admin/users/create`); + await page.locator('#nome').fill('Card'); + await page.locator('#cognome').fill(surname); + await page.locator('#email').fill(email); + await page.locator('#telefono').fill('0000000'); + await page.locator('#indirizzo').fill('Via E2E 1'); + await page.locator('#password').fill('Circ1234!'); + if (typeId) await page.locator('#tipologia_id').selectOption(String(typeId)); + if (manualExpiry) { + await page.evaluate((d) => { + const el = /** @type {any} */ (document.querySelector('#data_scadenza_tessera')); + if (el && el._flatpickr) el._flatpickr.setDate(d, true, 'Y-m-d'); else if (el) el.value = d; + }, manualExpiry); + } + if (!cardAuto) await page.locator('#card_auto').uncheck(); + await page.locator('#user-form button[type="submit"]').click(); + await page.waitForURL((u) => u.pathname.endsWith('/admin/users') && u.search.includes('created=1'), { timeout: 20000 }); + return F.dbInt(`SELECT id FROM utenti WHERE email=${F.sqlStr(email)}`); +} + +test.describe.serial('Card duration, renewal and expiry warning', () => { + /** @type {import('@playwright/test').Page} */ let admin; + let ctx; let yearType = 0; let monthType = 0; let plainType = 0; + const users = []; + const errors = []; + const savedEmail = {}; + let savedWarning = null; + + test.beforeAll(async ({ browser }) => { + ctx = await browser.newContext(); admin = await ctx.newPage(); + admin.on('response', (r) => { if (r.status() >= 500) errors.push(`${r.status()} ${r.url()}`); }); + await F.loginAsAdmin(admin); + yearType = await F.createTypeViaUi(admin, { nome: `Annuale ${RUN}`, codice: `${PREFIX}-year`, durata_tessera_giorni: 365 }); + monthType = await F.createTypeViaUi(admin, { nome: `Mensile ${RUN}`, codice: `${PREFIX}-month`, durata_tessera_giorni: 30 }); + plainType = await F.createTypeViaUi(admin, { nome: `Libera ${RUN}`, codice: `${PREFIX}-plain` }); + for (const key of ['type', 'driver_mode', 'smtp_host', 'smtp_port', 'smtp_security', 'smtp_username', 'smtp_password']) { + savedEmail[key] = F.dbQuery(`SELECT setting_value FROM system_settings WHERE category='email' AND setting_key='${key}' LIMIT 1`); + } + savedWarning = F.dbQuery("SELECT setting_value FROM system_settings WHERE category='loans' AND setting_key='card_expiry_warning_days' LIMIT 1"); + F.dbQuery(`INSERT INTO system_settings (category, setting_key, setting_value) VALUES + ('email','type','smtp'),('email','driver_mode','smtp'),('email','smtp_host','localhost'),('email','smtp_port','1025'), + ('email','smtp_security',''),('email','smtp_username',''),('email','smtp_password','') + ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`); + }); + test.afterEach(() => { expect(errors.splice(0), 'no HTTP 5xx').toEqual([]); }); + test.afterAll(async () => { + for (const [key, value] of Object.entries(savedEmail)) { + F.dbQuery(`UPDATE system_settings SET setting_value=${F.sqlStr(value)} WHERE category='email' AND setting_key='${key}'`); + } + if (savedWarning === '') F.dbQuery("DELETE FROM system_settings WHERE category='loans' AND setting_key='card_expiry_warning_days'"); + else F.dbQuery(`UPDATE system_settings SET setting_value=${F.sqlStr(savedWarning)} WHERE category='loans' AND setting_key='card_expiry_warning_days'`); + F.dbQuery("UPDATE tipologie_lettore SET durata_tessera_giorni=NULL WHERE codice='standard'"); + F.deletePatrons(users); + F.deleteTypes(PREFIX); + await ctx?.close(); + }); + + // ── A. Automatic card duration ────────────────────────────────────── + test('creating a patron of a 365-day type sets the card to today + 365', async () => { + const id = await createUserViaForm(admin, { email: `${PREFIX}-a1@circ.test`, surname: `CDA${RUN}`, typeId: yearType }); + users.push(id); + expect(daysBetween(F.appTodayISO(), cardOf(id))).toBe(365); + expect(F.dbInt(`SELECT tipologia_id FROM utenti WHERE id=${id}`)).toBe(yearType); + }); + + test('with "calcola" unchecked the date typed by the librarian is kept', async () => { + const manual = F.appDateOffsetISO(100); + const id = await createUserViaForm(admin, { email: `${PREFIX}-a2@circ.test`, surname: `CDB${RUN}`, typeId: yearType, cardAuto: false, manualExpiry: manual }); + users.push(id); + expect(cardOf(id)).toBe(manual); + }); + + test('a type without card duration keeps the date even with "calcola"', async () => { + const manual = F.appDateOffsetISO(77); + const id = await createUserViaForm(admin, { email: `${PREFIX}-a3@circ.test`, surname: `CDC${RUN}`, typeId: plainType, manualExpiry: manual }); + users.push(id); + expect(cardOf(id)).toBe(manual); + }); + + test('with "calcola" left ticked (the default), a date typed by the librarian still wins', async () => { + const manual = F.appDateOffsetISO(50); + const id = await createUserViaForm(admin, { email: `${PREFIX}-a4@circ.test`, surname: `CDD${RUN}`, typeId: yearType, manualExpiry: manual }); + users.push(id); + expect(cardOf(id)).toBe(manual); + }); + + test('editing a patron to a 30-day type with "calcola" recomputes the card', async () => { + const id = users[0]; + await admin.goto(`${F.BASE}/admin/users/edit/${id}`); + await admin.locator('#tipologia_id').selectOption(String(monthType)); + await admin.locator('#card_auto').check(); + await admin.locator('#user-form button[type="submit"]').click(); + await admin.waitForURL((u) => !u.pathname.includes('/edit/'), { timeout: 20000 }); + expect(daysBetween(F.appTodayISO(), cardOf(id))).toBe(30); + }); + + test('approving a registration gives the default type card duration', async () => { + F.dbQuery("UPDATE tipologie_lettore SET durata_tessera_giorni=200 WHERE codice='standard'"); + const pending = F.createPatron(`CDR${RUN}`, { cardExpiry: F.appDateOffsetISO(1800) }); + users.push(pending.id); + F.dbQuery(`UPDATE utenti SET stato='sospeso' WHERE id=${pending.id}`); + await admin.goto(`${F.BASE}/admin/users/details/${pending.id}`); + await admin.locator(`form[action$="/admin/users/${pending.id}/activate-directly"] button[type="submit"]`).click(); + const confirm = admin.locator('.swal2-confirm'); + if (await confirm.isVisible({ timeout: 3000 }).catch(() => false)) await confirm.click(); + await expect.poll(() => F.dbQuery(`SELECT stato FROM utenti WHERE id=${pending.id}`), { timeout: 15000 }).toBe('attivo'); + expect(daysBetween(F.appTodayISO(), cardOf(pending.id))).toBe(200); + F.dbQuery("UPDATE tipologie_lettore SET durata_tessera_giorni=NULL WHERE codice='standard'"); + }); + + // ── B. Card renewal ───────────────────────────────────────────────── + async function renewFromPage(id) { + await admin.goto(`${F.BASE}/admin/users/details/${id}`); + await admin.locator('[data-testid="renew-card"]').click(); + const confirm = admin.locator('.swal2-confirm'); + if (await confirm.isVisible({ timeout: 3000 }).catch(() => false)) await confirm.click(); + await admin.waitForURL((u) => u.pathname.endsWith(`/admin/users/details/${id}`)); + } + + test('an expired card of a 365-day patron is renewed to today + 365 and the account reactivated', async () => { + const p = F.createPatron(`CDE${RUN}`, { typeId: yearType, cardExpiry: F.appDateOffsetISO(-3) }); + users.push(p.id); + F.dbQuery(`UPDATE utenti SET stato='scaduto' WHERE id=${p.id}`); + await renewFromPage(p.id); + expect(daysBetween(F.appTodayISO(), cardOf(p.id))).toBe(365); + expect(F.dbQuery(`SELECT stato FROM utenti WHERE id=${p.id}`)).toBe('attivo'); + await expect(admin.locator('[role="alert"]').filter({ hasText: /rinnovata|renewed/i }).first()).toBeVisible(); + }); + + test('a type without card duration renews by one year', async () => { + const p = F.createPatron(`CDF${RUN}`, { typeId: plainType, cardExpiry: F.appDateOffsetISO(5) }); + users.push(p.id); + await renewFromPage(p.id); + expect(daysBetween(F.appTodayISO(), cardOf(p.id))).toBe(365); + }); + + test('the renewal is recorded in the activity log', async () => { + const id = users[users.length - 1]; + expect(F.dbInt(`SELECT COUNT(*) FROM log_modifiche WHERE tabella='utenti' AND record_id=${id} AND dati_nuovi LIKE '%user.card_renewed%'`)).toBe(1); + }); + + test('a reader with an expired card is refused, and can borrow again after the renewal', async ({ browser }) => { + const p = F.createPatron(`CDG${RUN}`, { typeId: monthType, cardExpiry: F.appDateOffsetISO(10) }); + users.push(p.id); + const title = F.createTitle(`${RUN} CARD`, 'libro'); + const rctx = await browser.newContext(); const reader = await rctx.newPage(); + try { + await F.loginAs(reader, p.email, p.password); + // The card expires while the reader is logged in. + F.dbQuery(`UPDATE utenti SET data_scadenza_tessera=${F.sqlStr(F.appDateOffsetISO(-1))} WHERE id=${p.id}`); + const before = await F.requestFromBookPage(reader, title.id, F.appTodayISO()); + expect(before.ok).toBe(false); + expect(before.text).toMatch(/[Tt]essera|card/); + await renewFromPage(p.id); + expect(daysBetween(F.appTodayISO(), cardOf(p.id))).toBe(30); + const after = await F.requestFromBookPage(reader, title.id, F.appTodayISO()); + expect(after.ok, after.text).toBe(true); + } finally { + await rctx.close(); + F.deleteTitles([title.id]); + } + }); + + test('staff and admin accounts have no card to renew', async () => { + const staff = F.createPatron(`CDH${RUN}`, { role: 'staff' }); + users.push(staff.id); + await admin.goto(`${F.BASE}/admin/users/details/${staff.id}`); + await expect(admin.locator('[data-testid="renew-card"]')).toHaveCount(0); + }); + + // ── C. Card expiring email ────────────────────────────────────────── + const runCron = () => execFileSync('php', [path.join(ROOT, 'cron/automatic-notifications.php')], { cwd: ROOT, encoding: 'utf-8', timeout: 120000 }); + const mailsTo = async (email) => { + const r = await fetch(`${MAILPIT}/search?query=${encodeURIComponent(`to:${email}`)}`); + const data = await r.json(); + return (data.messages || []).filter((m) => /tessera|card/i.test(m.Subject || '')); + }; + + test('the librarian enables the warning 10 days ahead in Settings → Loans', async () => { + await admin.goto(`${F.BASE}/admin/settings?tab=loans`); + await admin.locator('#card_expiry_warning_days').fill('10'); + await admin.locator('[data-settings-panel="loans"] button[type="submit"]').click(); + await admin.waitForURL((u) => u.searchParams.get('tab') === 'loans'); + expect(F.dbQuery("SELECT setting_value FROM system_settings WHERE category='loans' AND setting_key='card_expiry_warning_days'")).toBe('10'); + }); + + test('the cron warns a patron whose card expires in 5 days', async () => { + const p = F.createPatron(`CDM${RUN}`, { email: `${PREFIX}-mail1@circ.test`, cardExpiry: F.appDateOffsetISO(5) }); + users.push(p.id); + runCron(); + await expect.poll(async () => (await mailsTo(p.email)).length, { timeout: 30000 }).toBe(1); + expect(F.dbQuery(`SELECT card_expiry_warning_sent_for FROM utenti WHERE id=${p.id}`)).toBe(F.appDateOffsetISO(5)); + }); + + test('a second cron run sends no duplicate', async () => { + const id = users[users.length - 1]; + runCron(); + const email = F.dbQuery(`SELECT email FROM utenti WHERE id=${id}`); + expect((await mailsTo(email)).length).toBe(1); + }); + + test('a card expiring after the window gets nothing', async () => { + const p = F.createPatron(`CDN${RUN}`, { email: `${PREFIX}-mail2@circ.test`, cardExpiry: F.appDateOffsetISO(20) }); + users.push(p.id); + runCron(); + expect((await mailsTo(p.email)).length).toBe(0); + expect(F.dbQuery(`SELECT IFNULL(card_expiry_warning_sent_for,'NULL') FROM utenti WHERE id=${p.id}`)).toBe('NULL'); + }); + + test('a new expiry date re-arms the warning; 0 turns it off', async () => { + const id = users[users.length - 2]; + const email = F.dbQuery(`SELECT email FROM utenti WHERE id=${id}`); + F.dbQuery(`UPDATE utenti SET data_scadenza_tessera=${F.sqlStr(F.appDateOffsetISO(8))} WHERE id=${id}`); + runCron(); + await expect.poll(async () => (await mailsTo(email)).length, { timeout: 30000 }).toBe(2); + await admin.goto(`${F.BASE}/admin/settings?tab=loans`); + await admin.locator('#card_expiry_warning_days').fill('0'); + await admin.locator('[data-settings-panel="loans"] button[type="submit"]').click(); + await admin.waitForURL((u) => u.searchParams.get('tab') === 'loans'); + F.dbQuery(`UPDATE utenti SET data_scadenza_tessera=${F.sqlStr(F.appDateOffsetISO(3))} WHERE id=${id}`); + runCron(); + expect((await mailsTo(email)).length).toBe(2); + }); +}); diff --git a/tests/circulation-consultation.spec.js b/tests/circulation-consultation.spec.js new file mode 100644 index 000000000..85d7eb178 --- /dev/null +++ b/tests/circulation-consultation.spec.js @@ -0,0 +1,187 @@ +// @ts-check +/** + * Circulation policy — consultation-only material and copies. + * A: a matrix rule makes a material "Solo consultazione" for a patron type + * (or for everyone): no request button, staff desk and API refuse it. + * B: a single copy is kept for in-house consultation (copie.solo_consultazione). + */ +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 = `e2ecs${RUN}`; + +/** POST the book-page request endpoint from the reader's page, as the button does. */ +async function apiRequest(page, bookId) { + return page.evaluate(async ({ id, start }) => { + const csrf = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''; + const r = await fetch(`${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 }), + }); + return { status: r.status, body: await r.json() }; + }, { id: bookId, start: F.appTodayISO() }); +} + +test.describe.serial('Consultation-only material (matrix rule)', () => { + /** @type {import('@playwright/test').Page} */ let admin; + /** @type {import('@playwright/test').Page} */ let child; + /** @type {import('@playwright/test').Page} */ let adultPage; + /** @type {import('@playwright/test').Page} */ let anon; + const ctxs = []; + let kidType = 0; let kid; let adult; let dvd; let other; + const errors = []; + + test.beforeAll(async ({ browser }) => { + for (let i = 0; i < 4; i++) ctxs.push(await browser.newContext()); + [admin, child, adultPage, anon] = await Promise.all(ctxs.map((c) => c.newPage())); + for (const p of [admin, child, adultPage, anon]) p.on('response', (r) => { if (r.status() >= 500) errors.push(`${r.status()} ${r.url()}`); }); + await F.loginAsAdmin(admin); + kidType = await F.createTypeViaUi(admin, { nome: `Bambino ${RUN}`, codice: `${PREFIX}-kid` }); + kid = F.createPatron(`CSK${RUN}`, { typeId: kidType }); + adult = F.createPatron(`CSA${RUN}`); + dvd = F.createTitle(`${RUN} DVD`, 'dvd'); + other = F.createTitle(`${RUN} OBJ`, 'altro'); + }); + test.afterEach(() => { expect(errors.splice(0), 'no HTTP 5xx').toEqual([]); }); + test.afterAll(async () => { + try { F.dbQuery("DELETE FROM regole_prestito WHERE tipologia_id=0 AND tipo_media='altro'"); } catch { /* */ } + F.deleteTitles([dvd?.id, other?.id]); + F.deletePatrons([kid?.id, adult?.id]); + F.deleteTypes(PREFIX); + for (const c of ctxs) await c.close(); + }); + + test('the librarian marks DVDs "consultation only" for children in the matrix', async () => { + await F.setMatrixCell(admin, kidType, 'dvd', { loanable: 0 }); + expect(F.dbQuery(`SELECT prestabile FROM regole_prestito WHERE tipologia_id=${kidType} AND tipo_media='dvd'`)).toBe('0'); + await F.openCirculationTab(admin); + await expect(admin.locator(`#rule-${kidType}-dvd-loanable`)).toHaveValue('0'); + }); + + test('the child sees "Solo consultazione" instead of the request button', async () => { + await F.loginAs(child, kid.email, kid.password); + await child.goto(`${F.BASE}/libro/${dvd.id}`); + await expect(child.locator('#consultation-only')).toBeVisible(); + await expect(child.locator('#btn-request-loan')).toHaveCount(0); + }); + + test('a request sent anyway is refused with not_loanable', async () => { + const res = await apiRequest(child, dvd.id); + expect(res.status).toBe(409); + expect(res.body.error).toBe('not_loanable'); + expect(F.dbInt(`SELECT COUNT(*) FROM prestiti WHERE utente_id=${kid.id}`) + F.dbInt(`SELECT COUNT(*) FROM prenotazioni WHERE utente_id=${kid.id}`)).toBe(0); + }); + + test('the staff desk cannot lend it to the child either', async () => { + const url = await F.staffLoan(admin, kid, dvd); + expect(url).toContain('error=not_loanable'); + await expect(admin.locator('body')).toContainText(/consultazione|consultation/i); + }); + + test('an adult (default type) still sees the request button and can borrow', async () => { + await F.loginAs(adultPage, adult.email, adult.password); + await adultPage.goto(`${F.BASE}/libro/${dvd.id}`); + await expect(adultPage.locator('#btn-request-loan')).toBeVisible(); + await expect(adultPage.locator('#consultation-only')).toHaveCount(0); + expect(await F.staffLoan(admin, adult, dvd)).not.toContain('error='); + }); + + test('an all-types rule makes "altro" consultation only, also for anonymous visitors', async () => { + await F.setMatrixCell(admin, 0, 'altro', { loanable: 0 }); + await anon.goto(`${F.BASE}/libro/${other.id}`); + await expect(anon.locator('#consultation-only')).toBeVisible(); + await adultPage.goto(`${F.BASE}/libro/${other.id}`); + await expect(adultPage.locator('#consultation-only')).toBeVisible(); + const url = await F.staffLoan(admin, adult, other); + expect(url).toContain('error=not_loanable'); + }); +}); + +test.describe.serial('Consultation-only copy (single copy flag)', () => { + /** @type {import('@playwright/test').Page} */ let admin; + /** @type {import('@playwright/test').Page} */ let reader; + let ctxA; let ctxR; let patron; let viewer; let title; + const errors = []; + + test.beforeAll(async ({ browser }) => { + ctxA = await browser.newContext(); admin = await ctxA.newPage(); + ctxR = await browser.newContext(); reader = await ctxR.newPage(); + for (const p of [admin, reader]) p.on('response', (r) => { if (r.status() >= 500) errors.push(`${r.status()} ${r.url()}`); }); + await F.loginAsAdmin(admin); + patron = F.createPatron(`CSC${RUN}`); + viewer = F.createPatron(`CSW${RUN}`); + title = F.createTitle(`${RUN} TWO`, 'libro', 2); + }); + test.afterEach(() => { expect(errors.splice(0), 'no HTTP 5xx').toEqual([]); }); + test.afterAll(async () => { + F.deleteTitles([title?.id]); + F.deletePatrons([patron?.id, viewer?.id]); + await ctxA?.close(); await ctxR?.close(); + }); + + /** Click the consultation toggle of a copy row on the admin book page. */ + async function toggleCopy(copyId) { + await admin.goto(`${F.BASE}/admin/books/${title.id}`); + await admin.locator(`form[action$="/admin/books/copies/${copyId}/consultation"] [data-testid="copy-consultation-toggle"]`).click(); + await admin.waitForURL((u) => u.pathname.endsWith(`/admin/books/${title.id}`)); + } + + test('the librarian keeps the first copy for consultation from the book page', async () => { + await toggleCopy(title.copyIds[0]); + expect(F.dbInt(`SELECT solo_consultazione FROM copie WHERE id=${title.copyIds[0]}`)).toBe(1); + await expect(admin.locator('[data-testid="copy-consultation-badge"]')).toHaveCount(1); + await expect(admin.locator('[role="alert"]').filter({ hasText: /consultazione|consultation/i }).first()).toBeVisible(); + }); + + test('lending that copy by its inventory code is refused', async () => { + const url = await F.staffLoan(admin, patron, title, { copyCode: F.copyCodeOf(title.copyIds[0]) }); + expect(url).toContain('error=not_loanable'); + expect(F.dbInt(`SELECT COUNT(*) FROM prestiti WHERE utente_id=${patron.id}`)).toBe(0); + }); + + test('a loan without a code takes the other copy', async () => { + expect(await F.staffLoan(admin, patron, title)).not.toContain('error='); + expect(F.dbInt(`SELECT copia_id FROM prestiti WHERE utente_id=${patron.id} AND attivo=1 ORDER BY id DESC LIMIT 1`)).toBe(title.copyIds[1]); + }); + + test('with the lendable copy out, the consultation copy does not count: the reader is offered a reservation, not a loan', async () => { + // libri.copie_disponibili is the counter behind the book page button and + // the catalogue "available" filter: the consultation copy on the shelf + // must not make the title look lendable while the other copy is out. + expect(F.dbInt(`SELECT copie_disponibili FROM libri WHERE id=${title.id}`)).toBe(0); + expect(F.dbInt(`SELECT copie_totali FROM libri WHERE id=${title.id}`)).toBe(2); + await F.loginAs(reader, viewer.email, viewer.password); + await reader.goto(`${F.BASE}/libro/${title.id}`); + await expect(reader.locator('#consultation-only')).toHaveCount(0); + const button = reader.locator('#btn-request-loan'); + await expect(button).toBeVisible(); + await expect(button).toContainText(/Prenota quando disponibile|Reserve when available/i); + await expect(button).not.toContainText(/Richiedi prestito|Request loan/i); + }); + + test('a copy held by a loan cannot be reserved for consultation', async () => { + await toggleCopy(title.copyIds[1]); + expect(F.dbInt(`SELECT solo_consultazione FROM copie WHERE id=${title.copyIds[1]}`)).toBe(0); + await expect(admin.locator('[role="alert"]').filter({ hasText: /impegnata|held/i }).first()).toBeVisible(); + }); + + test('with every copy kept for consultation the title is consultation only; making one lendable restores the button', async () => { + F.dbQuery(`DELETE FROM prestiti WHERE utente_id=${patron.id}`); + F.dbQuery(`UPDATE copie SET stato='disponibile' WHERE libro_id=${title.id}`); + await toggleCopy(title.copyIds[1]); + expect(F.dbInt(`SELECT SUM(solo_consultazione) FROM copie WHERE libro_id=${title.id}`)).toBe(2); + // The reader page is still signed in as the previous test's viewer. + await reader.context().clearCookies(); + await F.loginAs(reader, patron.email, patron.password); + await reader.goto(`${F.BASE}/libro/${title.id}`); + await expect(reader.locator('#consultation-only')).toBeVisible(); + await toggleCopy(title.copyIds[0]); + expect(F.dbInt(`SELECT solo_consultazione FROM copie WHERE id=${title.copyIds[0]}`)).toBe(0); + await reader.goto(`${F.BASE}/libro/${title.id}`); + await expect(reader.locator('#btn-request-loan')).toBeVisible(); + }); +}); diff --git a/tests/circulation-hardening-0780.unit.php b/tests/circulation-hardening-0780.unit.php index 191ae1ba6..ba2ad4005 100644 --- a/tests/circulation-hardening-0780.unit.php +++ b/tests/circulation-hardening-0780.unit.php @@ -51,12 +51,14 @@ $check(str_contains((string) $capacity, "JOIN utenti u ON u.id = r.utente_id") && str_contains((string) $capacity, "eligibleUserWhere('u')"), 'ineligible reservations are paused instead of consuming capacity'); -$check(str_contains((string) $capacity, 'maxActiveLoansPerUser') && str_contains((string) $capacity, 'FROM prestiti cap'), +// 0.9.0: the limit is the patron's (CirculationPolicy::totalLoanLimitSql()). +$check(str_contains((string) $capacity, 'totalLoanLimitSql') && str_contains((string) $capacity, 'FROM prestiti cap'), 'reservations at the active-loan limit are paused in capacity calculations'); $reservationManager = file_get_contents($root . '/app/Controllers/ReservationManager.php'); $promote = $method((string) $reservationManager, 'processBookAvailability'); -$check(str_contains($promote, 'max_active_loans_per_user') && str_contains($promote, 'SELECT COUNT(*) AS c'), +$check(str_contains($promote, 'totalLoanLimitSql') && str_contains($promote, 'FROM prestiti cap') + && str_contains($promote, 'loanLimitError('), 'promotion enforces the active-loan cap both in selection and under lock'); $notifications = file_get_contents($root . '/app/Support/NotificationService.php'); diff --git a/tests/circulation-interop.spec.js b/tests/circulation-interop.spec.js new file mode 100644 index 000000000..71e85fc01 --- /dev/null +++ b/tests/circulation-interop.spec.js @@ -0,0 +1,130 @@ +// @ts-check +/** + * Circulation policy — the same patron-type rules on every channel: + * the Mobile API (reader and staff routes) and the NCIP server refuse what + * the website refuses, with stable codes. + */ +const { test, expect } = require('@playwright/test'); +const F = require('./helpers/circulation-fixtures'); +const M = require('./helpers/mobile-api-fixtures'); + +test.skip(!F.CONFIGURED, 'E2E credentials not configured'); + +const RUN = String(Date.now()).slice(-6); +const PREFIX = `e2eio${RUN}`; +const PASS = 'Circ1234!'; +const NCIP_NS = 'http://www.niso.org/2008/ncip'; + +const ncipXml = (service, userId, itemId) => ` + + <${service}> + ${userId ? `${userId}` : ''} + ${itemId} + +`; +const ncipAuth = 'Basic ' + Buffer.from(`${F.ADMIN_EMAIL}:${F.ADMIN_PASS}`).toString('base64'); +const ncip = (request, body) => request.post(`${F.BASE}/ncip`, { data: body, headers: { 'Content-Type': 'application/xml', Authorization: ncipAuth } }); + +test.describe.serial('Patron-type rules on the Mobile API and NCIP', () => { + const ctx = { users: [], titles: [] }; + let gate = null; + + test.beforeAll(async ({ browser, request }) => { + const page = await browser.newPage(); + gate = await M.ensureMobileApiReady(page); + // NCIP server active (same activation path as ncip-server.spec.js). + const ncipId = F.dbInt("SELECT id FROM plugins WHERE name='ncip-server' LIMIT 1"); + if (ncipId > 0 && F.dbQuery(`SELECT is_active FROM plugins WHERE id=${ncipId}`) !== '1') { + await F.loginAsAdmin(page); + await page.goto(`${F.BASE}/admin/plugins`); + await page.evaluate(async (pid) => { + const fd = new FormData(); + fd.append('csrf_token', document.querySelector('input[name="csrf_token"]')?.getAttribute('value') || ''); + await fetch(`${window.location.origin}${window.BASE_PATH || ''}/admin/plugins/${pid}/activate`, { method: 'POST', body: fd }); + }, ncipId); + } + await F.loginAsAdmin(page); + ctx.type = await F.createTypeViaUi(page, { nome: `Ragazzi ${RUN}`, codice: `${PREFIX}-kid`, max_prestiti: 1, max_rinnovi: 0 }, { reservations: false }); + await F.setMatrixCell(page, ctx.type, 'dvd', { loanable: 0 }); + await page.close(); + + ctx.kid = F.createPatron(`IOK${RUN}`, { typeId: ctx.type, password: PASS }); + ctx.users.push(ctx.kid.id); + ctx.staffEmail = `io_staff_${RUN}@circ.test`; + ctx.staffId = M.createUser(ctx.staffEmail, PASS, 'staff', 'IOST'); + ctx.users.push(ctx.staffId); + ctx.dvd = F.createTitle(`${RUN} IODVD`, 'dvd'); + ctx.book1 = F.createTitle(`${RUN} IOB1`, 'libro'); + ctx.book2 = F.createTitle(`${RUN} IOB2`, 'libro'); + ctx.busy = F.createTitle(`${RUN} IOBUSY`, 'libro'); + ctx.titles.push(ctx.dvd.id, ctx.book1.id, ctx.book2.id, ctx.busy.id); + ctx.kidToken = await M.login(request, ctx.kid.email, PASS, `io-kid-${RUN}`); + ctx.staffToken = await M.login(request, ctx.staffEmail, PASS, `io-staff-${RUN}`); + }); + test.afterAll(() => { + F.deleteTitles(ctx.titles); + F.deletePatrons(ctx.users); + F.deleteTypes(PREFIX); + M.restoreGate(gate); + }); + + test('app availability tells the reader the DVD is consultation only and reservations are off', async ({ request }) => { + const r = await M.api(request, 'GET', `/catalog/books/${ctx.dvd.id}/availability`, { token: ctx.kidToken }); + expect(r.status, r.text.slice(0, 200)).toBe(200); + expect(r.json.data.loanable).toBe(false); + expect(r.json.data.reservations_enabled).toBe(false); + const b = await M.api(request, 'GET', `/catalog/books/${ctx.book1.id}/availability`, { token: ctx.kidToken }); + expect(b.json.data.loanable).toBe(true); + }); + + test('app request of the consultation-only DVD → 409 not_loanable', async ({ request }) => { + const r = await M.api(request, 'POST', '/reservations', { token: ctx.kidToken, body: { book_id: ctx.dvd.id } }); + expect(r.status, r.text.slice(0, 300)).toBe(409); + expect(r.json.error.code).toBe('not_loanable'); + }); + + test('app staff loan of the DVD to the child → 409 not_loanable', async ({ request }) => { + const r = await M.api(request, 'POST', '/staff/loans', { token: ctx.staffToken, body: { user_id: ctx.kid.id, book_id: ctx.dvd.id, immediate: true } }); + expect(r.status, r.text.slice(0, 300)).toBe(409); + expect(r.json.error.code).toBe('not_loanable'); + }); + + test('NCIP CheckOutItem of the DVD → Item Does Not Circulate', async ({ request }) => { + const res = await ncip(request, ncipXml('CheckOutItem', ctx.kid.id, ctx.dvd.id)); + const body = await res.text(); + expect(body).toContain('Item Does Not Circulate'); + expect(F.dbInt(`SELECT COUNT(*) FROM prestiti WHERE utente_id=${ctx.kid.id}`)).toBe(0); + }); + + test('NCIP checkout of a book works, the second is over the type limit of 1', async ({ request }) => { + const ok = await ncip(request, ncipXml('CheckOutItem', ctx.kid.id, ctx.book1.id)); + expect(await ok.text()).toContain('CheckOutItemResponse'); + expect(F.dbInt(`SELECT COUNT(*) FROM prestiti WHERE utente_id=${ctx.kid.id} AND attivo=1`)).toBe(1); + const over = await ncip(request, ncipXml('CheckOutItem', ctx.kid.id, ctx.book2.id)); + expect(await over.text()).toContain('Maximum Check Outs Exceeded'); + }); + + test('NCIP RenewItem honours the type renewals (0) → Maximum Renewals Exceeded', async ({ request }) => { + const loanId = F.dbInt(`SELECT id FROM prestiti WHERE utente_id=${ctx.kid.id} AND libro_id=${ctx.book1.id} AND attivo=1`); + F.dbQuery(`UPDATE prestiti SET stato='in_corso' WHERE id=${loanId}`); + const res = await ncip(request, ncipXml('RenewItem', ctx.kid.id, ctx.book1.id)); + expect(await res.text()).toContain('Maximum Renewals Exceeded'); + }); + + test('app request over the total limit → 409 max_loans_reached', async ({ request }) => { + const r = await M.api(request, 'POST', '/reservations', { token: ctx.kidToken, body: { book_id: ctx.book2.id } }); + expect(r.status, r.text.slice(0, 300)).toBe(409); + expect(r.json.error.code).toBe('max_loans_reached'); + }); + + test('app waitlist request with reservations off → 409 reservations_disabled', async ({ request }) => { + // Free the limit, then make the only copy busy: the request would queue. + F.dbQuery(`DELETE FROM prestiti WHERE utente_id=${ctx.kid.id}`); + F.dbQuery(`INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, attivo, origine) VALUES (${ctx.busy.id}, ${ctx.busy.copyIds[0]}, ${ctx.staffId}, CURDATE(), DATE_ADD(CURDATE(), INTERVAL 20 DAY), 'in_corso', 1, 'diretto')`); + F.dbQuery(`UPDATE copie SET stato='prestato' WHERE id=${ctx.busy.copyIds[0]}`); + const r = await M.api(request, 'POST', '/reservations', { token: ctx.kidToken, body: { book_id: ctx.busy.id } }); + expect(r.status, r.text.slice(0, 300)).toBe(409); + expect(r.json.error.code).toBe('reservations_disabled'); + expect(F.dbInt(`SELECT COUNT(*) FROM prenotazioni WHERE utente_id=${ctx.kid.id}`)).toBe(0); + }); +}); diff --git a/tests/circulation-patron-types.spec.js b/tests/circulation-patron-types.spec.js new file mode 100644 index 000000000..efb546739 --- /dev/null +++ b/tests/circulation-patron-types.spec.js @@ -0,0 +1,149 @@ +// @ts-check +/** + * Circulation policy — patron types (Settings → Circulation). + * A librarian creates, edits, orders, makes default and deletes patron types, + * assigns one to a reader from the user form, reads the effective rules on the + * reader's page and filters/exports the user list by type. + */ +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 = `e2ept${RUN}`; + +test.describe.serial('Patron types CRUD', () => { + /** @type {import('@playwright/test').Page} */ + let page; + let context; + let typeA = 0; + let typeB = 0; + let patron; + const errors = []; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + page.on('response', (r) => { if (r.status() >= 500) errors.push(`${r.status()} ${r.url()}`); }); + await F.loginAsAdmin(page); + patron = F.createPatron(`PT${RUN}`); + }); + test.afterEach(() => { expect(errors.splice(0), 'no HTTP 5xx').toEqual([]); }); + test.afterAll(async () => { + F.deletePatrons([patron?.id]); + F.deleteTypes(PREFIX); + await context?.close(); + }); + + test('the circulation tab lists the seeded default type', async () => { + await F.openCirculationTab(page); + const row = page.locator('#circulation-types-table tr[data-type-code="standard"]'); + await expect(row).toBeVisible(); + await expect(row.locator('[data-testid="default-type-badge"]')).toBeVisible(); + }); + + test('create a "Studente" type from the form', async () => { + typeA = await F.createTypeViaUi(page, { nome: `Studente ${RUN}`, codice: `${PREFIX}-stud`, max_prestiti: 2, durata_prestito_giorni: 14 }); + await expect(page.locator('.alert, [role="alert"]').filter({ hasText: /creata|created/i }).first()).toBeVisible(); + const row = page.locator(`#circulation-types-table tr[data-type-code="${PREFIX}-stud"]`); + await expect(row).toContainText(`Studente ${RUN}`); + await expect(row).toContainText('2'); + expect(F.dbQuery(`SELECT CONCAT(max_prestiti,'|',durata_prestito_giorni,'|',IFNULL(max_rinnovi,'NULL')) FROM tipologie_lettore WHERE id=${typeA}`)).toBe('2|14|NULL'); + }); + + test('a duplicate code is refused with a message', async () => { + await F.openCirculationTab(page); + await page.locator('#circulation-new-type summary').click(); + const form = page.locator('[data-testid="circulation-type-create"]'); + await form.locator('[name="nome"]').fill('Doppione'); + await form.locator('[name="codice"]').fill(`${PREFIX}-stud`); + await form.locator('button[type="submit"]').click(); + await expect(page.locator('[role="alert"]').filter({ hasText: /codice|code/i }).first()).toBeVisible(); + expect(F.dbInt(`SELECT COUNT(*) FROM tipologie_lettore WHERE codice='${PREFIX}-stud'`)).toBe(1); + }); + + test('edit the type: rename, disable reservations, set a card duration', async () => { + await F.openCirculationTab(page); + await page.locator(`#circulation-types-table tr[data-type-code="${PREFIX}-stud"] [data-testid="edit-type"]`).click(); + const form = page.locator('[data-testid="circulation-type-edit"]'); + await expect(form).toBeVisible(); + await form.locator('[name="nome"]').fill(`Studenti ${RUN}`); + await form.locator('[name="durata_tessera_giorni"]').fill('365'); + await form.locator('input[type="checkbox"][name="prenotazioni_abilitate"]').uncheck(); + await form.locator('button[type="submit"]').click(); + await page.waitForURL((u) => u.searchParams.get('tab') === 'circulation'); + expect(F.dbQuery(`SELECT CONCAT(nome,'|',prenotazioni_abilitate,'|',durata_tessera_giorni) FROM tipologie_lettore WHERE id=${typeA}`)).toBe(`Studenti ${RUN}|0|365`); + await expect(page.locator(`#circulation-types-table tr[data-type-code="${PREFIX}-stud"]`)).toContainText(/Prenotazioni disattivate|Reservations disabled/); + }); + + test('order: the second type moves above the first', async () => { + typeB = await F.createTypeViaUi(page, { nome: `Docente ${RUN}`, codice: `${PREFIX}-doc` }); + const order = () => F.dbQuery(`SELECT GROUP_CONCAT(codice ORDER BY ordinamento, nome, id) FROM tipologie_lettore WHERE codice LIKE '${PREFIX}%'`); + expect(order()).toBe(`${PREFIX}-stud,${PREFIX}-doc`); + await F.openCirculationTab(page); + await page.locator(`#circulation-types-table tr[data-type-code="${PREFIX}-doc"] [data-testid="move-up"]`).click(); + await page.waitForURL((u) => u.searchParams.get('tab') === 'circulation'); + expect(order()).toBe(`${PREFIX}-doc,${PREFIX}-stud`); + }); + + test('make a type the default, then back: exactly one default', async () => { + await F.openCirculationTab(page); + await page.locator(`#circulation-types-table tr[data-type-code="${PREFIX}-doc"] [data-testid="make-default"]`).click(); + await page.waitForURL((u) => u.searchParams.get('tab') === 'circulation'); + expect(F.dbQuery('SELECT GROUP_CONCAT(codice) FROM tipologie_lettore WHERE predefinita=1')).toBe(`${PREFIX}-doc`); + await page.locator('#circulation-types-table tr[data-type-code="standard"] [data-testid="make-default"]').click(); + await page.waitForURL((u) => u.searchParams.get('tab') === 'circulation'); + expect(F.dbQuery('SELECT GROUP_CONCAT(codice) FROM tipologie_lettore WHERE predefinita=1')).toBe('standard'); + await expect(page.locator('#circulation-types-table tr[data-type-code="standard"] [data-testid="delete-type"]')).toHaveCount(0); + }); + + test('assign the type from the user edit form and read the effective rules', async () => { + await page.goto(`${F.BASE}/admin/users/edit/${patron.id}`); + await page.locator('#tipologia_id').selectOption(String(typeA)); + await page.locator('#user-form button[type="submit"]').click(); + await page.waitForURL((u) => !u.pathname.includes('/edit/'), { timeout: 20000 }); + expect(page.url()).not.toContain('error='); + expect(F.dbInt(`SELECT IFNULL(tipologia_id,0) FROM utenti WHERE id=${patron.id}`)).toBe(typeA); + await page.goto(`${F.BASE}/admin/users/details/${patron.id}`); + await expect(page.locator('[data-testid="patron-type-name"]')).toContainText(`Studenti ${RUN}`); + const rules = page.locator('[data-testid="patron-effective-rules"]'); + await expect(rules).toContainText('14'); + await expect(rules).toContainText(/Prenotazioni disattivate|Reservations disabled/); + await expect(rules).toContainText('365'); + }); + + test('the user list filters by patron type and the CSV carries the column', async () => { + await page.goto(`${F.BASE}/admin/users`); + const listResponse = page.waitForResponse((r) => r.url().includes('/api/utenti') && r.url().includes(`patron_type_filter=${typeA}`)); + await page.locator('#patron_type_filter').selectOption(String(typeA)); + const json = await (await listResponse).json(); + const emails = (json.data || []).map((r) => JSON.stringify(r)); + expect(emails.join(' ')).toContain(patron.email); + expect(json.recordsFiltered).toBe(1); + const csv = await page.request.get(`${F.BASE}/admin/users/export/csv?patron_type_filter=${typeA}`); + expect(csv.status()).toBe(200); + const text = await csv.text(); + expect(text).toMatch(/Tipologia lettore|Patron type/); + expect(text).toContain(patron.email); + expect(text).toContain(`Studenti ${RUN}`); + // The type code stands right after the name, so the file re-imports by code. + const header = text.replace(/^\uFEFF/, '').split(/\r?\n/)[0].split(';').map((h) => h.replace(/^"|"$/g, '')); + const nameCol = header.findIndex((h) => /^(Tipologia lettore|Patron type)$/.test(h)); + expect(nameCol).toBeGreaterThan(0); + expect(header[nameCol + 1]).toMatch(/^(Codice tipologia lettore|Patron type code)$/); + const row = text.split(/\r?\n/).find((l) => l.includes(patron.email)) || ''; + expect(row.split(';')[nameCol + 1].replace(/^"|"$/g, '')).toBe(`${PREFIX}-stud`); + }); + + test('deleting a type moves its patrons back to the default type', async () => { + await F.openCirculationTab(page); + page.once('dialog', (d) => d.accept()); + await page.locator(`#circulation-types-table tr[data-type-code="${PREFIX}-stud"] [data-testid="delete-type"]`).click(); + await page.waitForURL((u) => u.searchParams.get('tab') === 'circulation'); + expect(F.dbInt(`SELECT COUNT(*) FROM tipologie_lettore WHERE id=${typeA}`)).toBe(0); + expect(F.dbQuery(`SELECT IFNULL(tipologia_id,'NULL') FROM utenti WHERE id=${patron.id}`)).toBe('NULL'); + await page.goto(`${F.BASE}/admin/users/details/${patron.id}`); + await expect(page.locator('[data-testid="patron-type-name"]')).toContainText('Standard'); + }); +}); diff --git a/tests/circulation-policy-equivalence.unit.php b/tests/circulation-policy-equivalence.unit.php new file mode 100644 index 000000000..deac5831b --- /dev/null +++ b/tests/circulation-policy-equivalence.unit.php @@ -0,0 +1,177 @@ +getMessage()}\n"); + exit(1); +} + +/** Settings stub: the grid value of each key, without touching system_settings. */ +final class GridSettings extends SettingsRepository +{ + /** @param array $values */ + public function __construct(mysqli $db, private array $values) + { + parent::__construct($db); + } + + public function get(string $category, string $key, ?string $default = null): ?string + { + if ($category !== 'loans' || !array_key_exists($key, $this->values) || $this->values[$key] === null) { + return $default; + } + return $this->values[$key]; + } +} + +$passed = 0; +$failed = 0; +$check = static function (bool $ok, string $label) use (&$passed, &$failed): void { + if ($ok) { + $passed++; + } else { + $failed++; + echo "FAIL {$label}\n"; + } +}; + +// Historical expressions, verbatim from the read sites they replaced. +$old = [ + // SettingsRepository::loanDurationDays() (PrestitiController store/createForm/renew, LoanRepository, StaffLoanReader) + 'duration' => static fn (SettingsRepository $s): int => (($d = (int) ($s->get('loans', 'loan_duration_days', '30') ?? 30)) >= 1 ? $d : 30), + // ReservationsAdminController::loanDurationDays(), NCIP CheckOut/RenewItem. + // NCIP RequestItem used this bare duration too; it now takes + // requestDurationDays() (duration capped by the request window), the + // same due date as a website or app request — checked below. + 'duration_ncip' => static fn (SettingsRepository $s): int => (($d = (int) ($s->get('loans', 'loan_duration_days', '30') ?? 30)) > 0 ? $d : 30), + // SettingsRepository::maxLoanDurationDays() (ReservationsController, UserActionsController, FrontendController) + 'max_request' => static fn (SettingsRepository $s): int => (($d = (int) ($s->get('loans', 'max_loan_duration_days', '90') ?? 90)) >= 1 ? $d : 90), + // PrestitiController::renew()/update()/bulkExtend(), NCIP extendLoan (after the < 0 → 3 guard) + 'renewals' => static fn (SettingsRepository $s): int => (($r = (int) ($s->get('loans', 'max_renewals', '3') ?? 3)) < 0 ? 3 : $r), + // LibriController (numeric validation before the cast) + 'renewals_numeric' => static function (SettingsRepository $s): int { + $raw = $s->get('loans', 'max_renewals', '3'); + $r = is_numeric($raw) ? (int) $raw : 3; + return $r < 0 ? 3 : $r; + }, + // PrestitiController/LoanApprovalController/UserActionsController/ReservationsController/NCIP: `if ($maxLoans > 0)` + 'max_loans' => static fn (SettingsRepository $s): int => (int) ($s->get('loans', 'max_active_loans_per_user', '0') ?? 0), + // ReservationManager / CapacityService + 'max_loans_clamped' => static fn (SettingsRepository $s): int => max(0, (int) ($s->get('loans', 'max_active_loans_per_user', '0') ?? 0)), +]; + +$grid = [ + 'loan_duration_days' => [null, '30', '14', '1', '0', '-5', '365'], + 'max_loan_duration_days' => [null, '90', '7', '0', '-1', '400'], + 'max_renewals' => [null, '3', '0', '1', '-2', '100'], + 'max_active_loans_per_user' => [null, '0', '1', '5', '-3'], +]; + +$cases = 0; +foreach ($grid['loan_duration_days'] as $duration) { + foreach ($grid['max_loan_duration_days'] as $maxRequest) { + foreach ($grid['max_renewals'] as $renewals) { + foreach ($grid['max_active_loans_per_user'] as $maxLoans) { + $settings = new GridSettings($db, [ + 'loan_duration_days' => $duration, + 'max_loan_duration_days' => $maxRequest, + 'max_renewals' => $renewals, + 'max_active_loans_per_user' => $maxLoans, + ]); + $tag = json_encode([$duration, $maxRequest, $renewals, $maxLoans]); + $policy = new CirculationPolicy($db, $settings); + foreach ([$policy->defaults(), $policy->forLoan(1, 1), $policy->forLoan(999999, 999999, 5)] as $i => $p) { + $check($p->durationDays === $old['duration']($settings), "duration #{$i} {$tag}"); + $check($p->durationDays === $old['duration_ncip']($settings), "duration (NCIP form) #{$i} {$tag}"); + $check($p->renewalDays === $old['duration']($settings), "renewal length = loan duration #{$i} {$tag}"); + $check($p->maxRequestDays === $old['max_request']($settings), "max request #{$i} {$tag}"); + $check($p->requestDurationDays() === min($old['duration']($settings), $old['max_request']($settings)), "request default #{$i} {$tag}"); + // NCIP RequestItem (NcipServerPlugin::handleRequestItem) no longer + // equals the bare NCIP duration: it is capped like every other channel. + $check($p->requestDurationDays() === min($old['duration_ncip']($settings), $old['max_request']($settings)), "NCIP RequestItem due = duration capped by the request window #{$i} {$tag}"); + $check($p->maxRenewals === $old['renewals']($settings), "renewals #{$i} {$tag}"); + $check($p->maxRenewals === $old['renewals_numeric']($settings), "renewals (numeric form) #{$i} {$tag}"); + $check(($p->maxActiveLoans > 0) === ($old['max_loans']($settings) > 0), "limit enabled #{$i} {$tag}"); + $check($p->maxActiveLoans === $old['max_loans_clamped']($settings), "limit value #{$i} {$tag}"); + $check($p->loanable === true && $p->maxActiveForMedia === 0, "loanable, no media limit #{$i} {$tag}"); + $check($p->dailyFine === null && $p->graceDays === null && $p->maxFinePerLoan === null && $p->debtBlockThreshold === null, "no fines #{$i} {$tag}"); + } + $user = $policy->forUser(1); + $check($user->maxActiveLoans === $old['max_loans_clamped']($settings), "user limit {$tag}"); + $check($user->reservationsEnabled === true && $user->maxReservations === 0 && $user->cardDurationDays === null, "user reservations/card unchanged {$tag}"); + // The SQL form of the limit (queue promotion, capacity) evaluates + // to the same number for a patron of the inheriting default type. + $limitSql = $policy->totalLoanLimitSql('u'); + $evaluated = (int) $db->query("SELECT {$limitSql} FROM (SELECT NULL AS tipologia_id) u")->fetch_row()[0]; + $check($evaluated === $old['max_loans_clamped']($settings), "SQL limit {$tag}"); + $check($policy->anyTotalLoanLimit() === ($old['max_loans_clamped']($settings) > 0), "SQL limit gate {$tag}"); + // The deprecated SettingsRepository helpers delegate and keep their numbers. + $check($settings->loanDurationDays() === $old['duration']($settings), "deprecated loanDurationDays {$tag}"); + $check($settings->maxLoanDurationDays() === $old['max_request']($settings), "deprecated maxLoanDurationDays {$tag}"); + $check($settings->requestLoanDurationDays() === min($old['duration']($settings), $old['max_request']($settings)), "deprecated requestLoanDurationDays {$tag}"); + $cases++; + } + } + } +} + +// Malformed values: a non-numeric renewals value falls back to 3 everywhere +// (the cast forms used to read it as 0 — the only site-level divergence, now +// resolved towards the validated LibriController reading). +$malformed = new GridSettings($db, ['max_renewals' => 'abc']); +$check((new CirculationPolicy($db, $malformed))->defaults()->maxRenewals === 3, 'non-numeric max_renewals falls back to 3'); + +// dueDateFor() equals both historical arithmetic forms (strtotime and +// DateTimeImmutable::modify) across month ends, leap days and DST changes. +$tzBefore = date_default_timezone_get(); +foreach (['UTC', 'Europe/Rome', 'America/New_York'] as $tz) { + date_default_timezone_set($tz); + foreach (['2026-01-31', '2026-02-28', '2028-02-29', '2026-03-28', '2026-03-29', '2026-10-24', '2026-10-25', '2026-12-31'] as $start) { + foreach ([0, 1, 7, 14, 30, 31, 90, 365] as $days) { + $viaStrtotime = date('Y-m-d', strtotime($start . " +{$days} days")); + $viaModify = (new DateTimeImmutable($start))->modify("+{$days} days")->format('Y-m-d'); + $new = CirculationPolicy::dueDateFor($start, $days); + $check($new === $viaStrtotime && $new === $viaModify, "dueDateFor({$start}, {$days}) in {$tz}"); + } + } + $check(CirculationPolicy::dueDateFor('2026-11-02 23:59:59', 3) === '2026-11-05', "dueDateFor ignores a time part in {$tz}"); +} +date_default_timezone_set($tzBefore); + +// The shared state set is the one every limit check counted. +$check(CirculationPolicy::ACTIVE_LOAN_STATES === ['prenotato', 'da_ritirare', 'in_corso', 'in_ritardo'], 'active-loan states'); +$check(CirculationPolicy::ACTIVE_LOAN_STATES_SQL === "'" . implode("','", CirculationPolicy::ACTIVE_LOAN_STATES) . "'", 'SQL list matches the array'); + +echo "{$cases} setting combinations\n"; +if ($failed > 0) { + echo "FAILED: {$failed} of " . ($passed + $failed) . " checks\n"; + exit(1); +} +echo "SUCCESS: {$passed} checks\n"; diff --git a/tests/circulation-policy-resolver.unit.php b/tests/circulation-policy-resolver.unit.php new file mode 100644 index 000000000..b7103cd22 --- /dev/null +++ b/tests/circulation-policy-resolver.unit.php @@ -0,0 +1,122 @@ + true, 'duration_days' => 30, 'max_request_days' => 90, 'max_renewals' => 3, + 'renewal_days' => null, 'max_active_for_media' => null, 'max_active_loans' => 5, + 'reservations_enabled' => true, 'max_reservations' => 0, 'card_duration_days' => null, + 'debt_block_threshold' => null, 'daily_fine' => null, 'grace_days' => null, 'max_fine_per_loan' => null, +]; +$noType = PolicyResolver::resolveUser(null, $globals); + +echo "A. Globals only\n"; +$p = PolicyResolver::resolve([], $globals, $noType, 'libro'); +$check($p->durationDays === 30 && $p->maxRequestDays === 90 && $p->maxRenewals === 3, 'globals pass through'); +$check($p->renewalDays === 30, 'renewal length defaults to the duration'); +$check($p->loanable && $p->maxActiveForMedia === 0 && $p->maxActiveLoans === 5, 'loanable, total limit 5, no media limit'); +$check($p->requestDurationDays() === 30, 'request default = min(duration, max request)'); +$check($p->dailyFine === null && $p->graceDays === null && $p->maxFinePerLoan === null, 'fines unset'); + +echo "B. Precedence, one field at a time\n"; +$type = ['duration_days' => 21, 'max_request_days' => 60, 'max_renewals' => 2, 'renewal_days' => null]; +$ruleAllMedia = ['duration_days' => 7, 'max_active_for_media' => 2, 'loanable' => null]; +$ruleTypeStar = ['duration_days' => 14, 'max_renewals' => null]; +$ruleTypeMedia = ['duration_days' => 3, 'loanable' => null, 'max_active_for_media' => 1]; +$layers = [$ruleTypeMedia, $ruleTypeStar, $ruleAllMedia, $type]; +$p = PolicyResolver::resolve($layers, $globals, $noType, 'dvd'); +$check($p->durationDays === 3, 'layer 1 (type × material) wins'); +$p = PolicyResolver::resolve([null, $ruleTypeStar, $ruleAllMedia, $type], $globals, $noType, 'dvd'); +$check($p->durationDays === 14, 'layer 2 (type × *) when layer 1 is absent'); +$p = PolicyResolver::resolve([['duration_days' => null], null, $ruleAllMedia, $type], $globals, $noType, 'dvd'); +$check($p->durationDays === 7, 'layer 3 (* × material) when 1 and 2 are NULL'); +$p = PolicyResolver::resolve([null, null, null, $type], $globals, $noType, 'dvd'); +$check($p->durationDays === 21 && $p->maxRequestDays === 60 && $p->maxRenewals === 2, 'layer 4 (patron type)'); +$p = PolicyResolver::resolve([null, null, null, ['duration_days' => null]], $globals, $noType, 'dvd'); +$check($p->durationDays === 30, 'layer 5 (globals) when every layer is NULL'); +$p = PolicyResolver::resolve($layers, $globals, $noType, 'dvd'); +$check($p->maxRenewals === 2, 'renewals: layers 1-3 NULL, so the type value applies'); +$check($p->maxActiveForMedia === 1, 'per-material limit from the most specific rule'); +$check($p->maxRequestDays === 60, 'request maximum only from the type'); +$check($p->renewalDays === 3, 'renewal length follows the resolved duration'); +$check($p->requestDurationDays() === 3, 'request default follows the resolved duration'); + +echo "C. Fields resolve independently\n"; +$p = PolicyResolver::resolve([['max_renewals' => 0], ['duration_days' => 10], null, $type], $globals, $noType, 'libro'); +$check($p->maxRenewals === 0 && $p->durationDays === 10, '0 renewals from layer 1, duration from layer 2'); +$p = PolicyResolver::resolve([['renewal_days' => 5], null, null, $type], $globals, $noType, 'libro'); +$check($p->renewalDays === 5 && $p->durationDays === 21, 'explicit renewal length'); + +echo "D. Loanability\n"; +$p = PolicyResolver::resolve([['loanable' => 0], null, ['loanable' => 1], null], $globals, $noType, 'dvd'); +$check(!$p->loanable, 'a type × material "consultation only" beats the all-types rule'); +$p = PolicyResolver::resolve([['loanable' => null, 'max_active_for_media' => 1], null, ['loanable' => 0], null], $globals, $noType, 'dvd'); +$check(!$p->loanable && $p->maxActiveForMedia === 1, 'an inherit cell keeps the all-types "consultation only"'); +$p = PolicyResolver::resolve([['loanable' => false], ['loanable' => 1]], $globals, $noType, 'libro'); +$check(!$p->loanable, 'the copy layer (loanable false) on top wins'); +$p = PolicyResolver::resolve([['loanable' => '1']], $globals, $noType, 'libro'); +$check($p->loanable, 'string "1" from the database'); + +echo "E. Malformed values fall back\n"; +$p = PolicyResolver::resolve([['duration_days' => 0, 'max_renewals' => -1, 'max_active_for_media' => 'x']], $globals, $noType, 'libro'); +$check($p->durationDays === 30, 'non-positive duration → global'); +$check($p->maxRenewals === 3, 'negative renewals → global'); +$check($p->maxActiveForMedia === 0, 'non-numeric media limit → none'); +$p = PolicyResolver::resolve([['duration_days' => '']], $globals, $noType, 'libro'); +$check($p->durationDays === 30, 'empty string = inherit'); +$bad = ['duration_days' => -4, 'max_request_days' => 'abc', 'max_renewals' => null] + $globals; +$p = PolicyResolver::resolve([], $bad, $noType, 'libro'); +$check($p->durationDays === 30 && $p->maxRequestDays === 90 && $p->maxRenewals === 3, 'malformed globals → historical defaults'); + +echo "F. Patron-only limits\n"; +$u = PolicyResolver::resolveUser(['id' => 4, 'code' => 'studente', 'name' => 'Studente', 'max_active_loans' => 2, + 'reservations_enabled' => '0', 'max_reservations' => 1, 'card_duration_days' => 365, 'debt_block_threshold' => '10.50'], $globals); +$check($u->patronTypeId === 4 && $u->patronTypeCode === 'studente', 'type identity'); +$check($u->maxActiveLoans === 2, 'type total limit beats the global 5'); +$check(!$u->reservationsEnabled && $u->maxReservations === 1, 'reservations disabled, limit 1'); +$check($u->cardDurationDays === 365 && $u->debtBlockThreshold === 10.5, 'card duration and debt threshold'); +$u = PolicyResolver::resolveUser(['id' => 1, 'max_active_loans' => null, 'reservations_enabled' => 1, 'card_duration_days' => 0], $globals); +$check($u->maxActiveLoans === 5, 'NULL type limit inherits the global'); +$check($u->maxActiveLoans === 5 && $u->reservationsEnabled, 'reservations enabled'); +$check($u->cardDurationDays === null, 'card duration 0 = not automatic'); +$u = PolicyResolver::resolveUser(['id' => 1, 'max_active_loans' => 0], $globals); +$check($u->maxActiveLoans === 0, 'type limit 0 = unlimited even with a global limit'); +$p = PolicyResolver::resolve([], $globals, $u, 'libro'); +$check($p->maxActiveLoans === 0 && !$p->hasQuantityLimits(), 'the loan policy carries the patron total limit'); +$p = PolicyResolver::resolve([['max_active_for_media' => 1]], $globals, $u, 'dvd'); +$check($p->hasQuantityLimits(), 'a media limit alone is a quantity limit'); + +echo "G. Fines fields carried (not applied yet)\n"; +$p = PolicyResolver::resolve([['daily_fine' => '0.50'], null, ['grace_days' => 2], ['max_fine_per_loan' => '5']], $globals, $u, 'dvd'); +$check($p->dailyFine === 0.5 && $p->graceDays === 2 && $p->maxFinePerLoan === 5.0, 'fines resolve with the same precedence'); + +if ($failed > 0) { + echo "\nFAILED: {$failed} of " . ($passed + $failed) . " checks\n"; + exit(1); +} +echo "\nALL {$passed} PASS\n"; diff --git a/tests/circulation-queue-material-rules.unit.php b/tests/circulation-queue-material-rules.unit.php new file mode 100644 index 000000000..dd633686e --- /dev/null +++ b/tests/circulation-queue-material-rules.unit.php @@ -0,0 +1,291 @@ +set_charset('utf8mb4'); + DateHelper::synchronizeDatabaseSession($db); +} catch (Throwable $e) { + fwrite(STDERR, "FAIL: database unreachable: {$e->getMessage()}\n"); + exit(1); +} + +if (!(new CirculationPolicy($db))->schemaReady()) { + fwrite(STDERR, "FAIL: the 0.9.0 circulation schema is missing on this database\n"); + exit(1); +} + +$run = bin2hex(random_bytes(5)); +$prefix = "ZZ_CQM_{$run}"; +$passed = 0; +$failed = 0; +$check = static function (bool $ok, string $label) use (&$passed, &$failed): void { + echo ($ok ? ' OK ' : ' FAIL ') . $label . PHP_EOL; + $ok ? $passed++ : $failed++; +}; + +$bookIds = []; +$userIds = []; +$typeIds = []; + +$cleanup = static function () use ($db, &$bookIds, &$userIds, &$typeIds): void { + foreach ($bookIds as $id) { + $db->query("DELETE FROM log_modifiche WHERE tabella = 'libri' AND record_id = {$id}"); + $db->query("DELETE FROM prenotazioni WHERE libro_id = {$id}"); + $db->query("DELETE FROM prestiti WHERE libro_id = {$id}"); + $db->query("DELETE FROM copie WHERE libro_id = {$id}"); + $db->query("DELETE FROM libri WHERE id = {$id}"); + } + foreach ($userIds as $id) { + try { $db->query("DELETE FROM notifications WHERE user_id = {$id}"); } catch (Throwable) {} + $db->query("DELETE FROM utenti WHERE id = {$id}"); + } + foreach ($typeIds as $id) { + $db->query("DELETE FROM regole_prestito WHERE tipologia_id = {$id}"); + $db->query("DELETE FROM tipologie_lettore WHERE id = {$id}"); + } + try { $db->query("DELETE FROM email_delivery_outbox WHERE recipient_email LIKE 'zzcqm+%@test.local'"); } catch (Throwable) {} +}; +set_exception_handler(static function (Throwable $e) use ($cleanup, $db): void { + try { $cleanup(); } catch (Throwable) {} + fwrite(STDERR, 'FAIL: uncaught ' . $e->getMessage() . PHP_EOL . $e->getTraceAsString() . PHP_EOL); + $db->close(); + exit(1); +}); + +$makeType = static function (string $suffix, int $active = 1) use ($db, $run, &$typeIds): int { + $code = "zzcqm-{$suffix}-{$run}"; + $name = "ZZ CQM {$suffix}"; + $stmt = $db->prepare('INSERT INTO tipologie_lettore (codice, nome, attiva) VALUES (?, ?, ?)'); + $stmt->bind_param('ssi', $code, $name, $active); + $stmt->execute(); + $id = (int) $db->insert_id; + $stmt->close(); + $typeIds[] = $id; + return $id; +}; + +$makeRule = static function (int $typeId, string $media, ?int $loanable, ?int $maxLoans) use ($db): void { + $stmt = $db->prepare('INSERT INTO regole_prestito (tipologia_id, tipo_media, prestabile, max_prestiti) VALUES (?, ?, ?, ?)'); + $stmt->bind_param('isii', $typeId, $media, $loanable, $maxLoans); + $stmt->execute(); + $stmt->close(); +}; + +$makeBook = static function (string $suffix, string $media) use ($db, $prefix, &$bookIds): array { + $title = "{$prefix}_{$suffix}"; + $stmt = $db->prepare("INSERT INTO libri (titolo, copie_totali, copie_disponibili, stato, tipo_media) VALUES (?, 1, 1, 'disponibile', ?)"); + $stmt->bind_param('ss', $title, $media); + $stmt->execute(); + $bookId = (int) $db->insert_id; + $stmt->close(); + $bookIds[] = $bookId; + $inv = 'ZZCQM-' . strtoupper($suffix) . '-' . strtoupper($prefix); + $inv = substr($inv, 0, 50); + $stmt = $db->prepare("INSERT INTO copie (libro_id, numero_inventario, stato) VALUES (?, ?, 'disponibile')"); + $stmt->bind_param('is', $bookId, $inv); + $stmt->execute(); + $copyId = (int) $db->insert_id; + $stmt->close(); + return [$bookId, $copyId]; +}; + +$makeUser = static function (string $suffix, ?int $typeId) use ($db, $run, &$userIds): int { + $email = "zzcqm+{$suffix}-{$run}@test.local"; + $card = substr('ZM' . strtoupper($suffix) . strtoupper($run), 0, 20); + $password = password_hash('CqmSuite!1', PASSWORD_DEFAULT); + $stmt = $db->prepare( + "INSERT INTO utenti (codice_tessera, nome, cognome, email, password, tipo_utente, stato, email_verificata, tipologia_id) + VALUES (?, 'Cqm', ?, ?, ?, 'standard', 'attivo', 1, ?)" + ); + $cog = ucfirst($suffix); + $stmt->bind_param('ssssi', $card, $cog, $email, $password, $typeId); + $stmt->execute(); + $id = (int) $db->insert_id; + $stmt->close(); + $userIds[] = $id; + return $id; +}; + +$today = DateHelper::today(); +$d = static fn (int $offset): string => (new DateTimeImmutable($today))->modify(($offset >= 0 ? '+' : '') . $offset . ' days')->format('Y-m-d'); + +$mkLoan = static function (int $bookId, int $copyId, int $userId) use ($db, $d): void { + $start = $d(-2); + $end = $d(20); + $stmt = $db->prepare( + "INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, origine, attivo) + VALUES (?, ?, ?, ?, ?, 'in_corso', 'diretto', 1)" + ); + $stmt->bind_param('iiiss', $bookId, $copyId, $userId, $start, $end); + $stmt->execute(); + $stmt->close(); + $db->query("UPDATE copie SET stato = 'prestato' WHERE id = {$copyId}"); +}; + +$mkReservation = static function (int $bookId, int $userId, int $queuePos) use ($db, $d): int { + $start = $d(0); + $end = $d(14); + $startDt = $start . ' 00:00:00'; + $endDt = $end . ' 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 (?, ?, ?, 'attiva', ?, ?, ?, ?)" + ); + $stmt->bind_param('iiissss', $bookId, $userId, $queuePos, $startDt, $endDt, $start, $end); + $stmt->execute(); + $id = (int) $db->insert_id; + $stmt->close(); + return $id; +}; + +$reservationState = static function (int $id) use ($db): ?string { + $res = $db->query("SELECT stato FROM prenotazioni WHERE id = {$id}"); + $row = $res ? $res->fetch_assoc() : null; + return $row['stato'] ?? null; +}; +$openLoanOf = static function (int $bookId, int $userId) use ($db): int { + $res = $db->query("SELECT COUNT(*) AS c FROM prestiti WHERE libro_id = {$bookId} AND utente_id = {$userId} AND stato <> 'annullato'"); + return (int) ($res->fetch_assoc()['c'] ?? 0); +}; + +/* ------------------------------------------------------------------------ + * 1. Per-material limit reached: the head of the queue is skipped. + * ---------------------------------------------------------------------- */ +echo "Queue promotion past a patron at the limit for the material\n"; +$student = $makeType('stud'); +$makeRule($student, 'dvd', null, 1); +[$heldDvd, $heldCopy] = $makeBook('held', 'dvd'); +[$dvd, $dvdCopy] = $makeBook('queued', 'dvd'); +$head = $makeUser('head', $student); +$next = $makeUser('next', null); +$mkLoan($heldDvd, $heldCopy, $head); +$headRes = $mkReservation($dvd, $head, 1); +$nextRes = $mkReservation($dvd, $next, 2); + +$policy = new CirculationPolicy($db); +$check($policy->materialRefusedReservationUserIds($dvd) === [$head], 'the patron at the DVD limit is the only refused one'); +$check((new CapacityService($db))->occupiedCount($dvd, $d(0), $d(14)) === 1, 'the refused head does not hold capacity (only the next reservation counts)'); +$check((new CapacityService($db))->hasFreeCapacity($dvd, $d(0), $d(14), excludeReservationId: $nextRes, excludeReservationsAfterQueuePos: 2), 'the only copy is free for the next patron'); +$promoted = (new ReservationManager($db))->processBookAvailability($dvd); +$check($promoted === true, 'a return promotes somebody'); +$check($openLoanOf($dvd, $next) === 1 && $reservationState($nextRes) === 'completata', 'the second patron in the queue gets the DVD'); +$check($openLoanOf($dvd, $head) === 0 && $reservationState($headRes) === 'attiva', 'the refused head keeps its reservation, without a loan'); + +/* ------------------------------------------------------------------------ + * 2. Material not loanable for the patron type: same pause. + * ---------------------------------------------------------------------- */ +echo "Queue promotion past a patron whose type may not borrow the material\n"; +$guest = $makeType('guest'); +$makeRule($guest, 'disco', 0, null); +[$cd, $cdCopy] = $makeBook('cd', 'disco'); +$guestUser = $makeUser('guest', $guest); +$after = $makeUser('after', null); +$guestRes = $mkReservation($cd, $guestUser, 1); +$afterRes = $mkReservation($cd, $after, 2); +$check((new CirculationPolicy($db))->materialRefusedReservationUserIds($cd) === [$guestUser], 'the patron whose type may not borrow CDs is refused'); +$check((new ReservationManager($db))->processBookAvailability($cd) === true, 'the CD is promoted'); +$check($openLoanOf($cd, $after) === 1 && $reservationState($afterRes) === 'completata', 'the patron behind gets the CD'); +$check($reservationState($guestRes) === 'attiva', 'the refused reservation stays in the queue'); + +/* ------------------------------------------------------------------------ + * 3. Nobody refused: unchanged behaviour (the head is promoted). + * ---------------------------------------------------------------------- */ +echo "No refusal: the head of the queue is promoted as before\n"; +[$book, $bookCopy] = $makeBook('book', 'libro'); +$first = $makeUser('first', $student); +$second = $makeUser('second', null); +$firstRes = $mkReservation($book, $first, 1); +$mkReservation($book, $second, 2); +$check((new CirculationPolicy($db))->materialRefusedReservationUserIds($book) === [], 'no refusal for a material without rules'); +$check((new ReservationManager($db))->processBookAvailability($book) === true, 'the book is promoted'); +$check($openLoanOf($book, $first) === 1 && $reservationState($firstRes) === 'completata', 'the head of the queue gets it'); + +/* ------------------------------------------------------------------------ + * 4. PatronTypeAssignment::fromSubmission() and deactivated types. + * ---------------------------------------------------------------------- */ +echo "Patron type submitted on the user form\n"; +$retired = $makeType('retired', 0); +$check(PatronTypeAssignment::fromSubmission($db, ['tipologia_id' => (string) $student]) === $student, 'an active type is accepted'); +$check(PatronTypeAssignment::fromSubmission($db, ['tipologia_id' => (string) $retired]) === null, 'a deactivated type is refused on a new user'); +$check(PatronTypeAssignment::fromSubmission($db, ['tipologia_id' => (string) $retired], $student) === $student, 'a deactivated type keeps the current one on update'); +$check(PatronTypeAssignment::fromSubmission($db, ['tipologia_id' => (string) $retired], $retired) === $retired, 'a user keeps the deactivated type they already have'); +$check(PatronTypeAssignment::fromSubmission($db, ['tipologia_id' => '999999999'], $student) === $student, 'an unknown type keeps the current one'); +$check(PatronTypeAssignment::fromSubmission($db, ['tipologia_id' => ''], $student) === null, 'an empty value selects the default type'); + +/* ------------------------------------------------------------------------ + * 5. libri.copie_disponibili excludes the consultation-only copies. + * ---------------------------------------------------------------------- */ +echo "Availability counter without the consultation copies\n"; +[$mixed, $lendableCopy] = $makeBook('mixed', 'libro'); +$consultationInv = substr('ZZCQM-MIXED-CONS-' . strtoupper($prefix), 0, 50); +$stmt = $db->prepare("INSERT INTO copie (libro_id, numero_inventario, stato, solo_consultazione) VALUES (?, ?, 'disponibile', 1)"); +$stmt->bind_param('is', $mixed, $consultationInv); +$stmt->execute(); +$stmt->close(); +$counters = static function (int $bookId) use ($db): string { + $row = $db->query("SELECT copie_disponibili, copie_totali, stato FROM libri WHERE id = {$bookId}")->fetch_assoc(); + return $row['copie_disponibili'] . '/' . $row['copie_totali'] . '/' . $row['stato']; +}; +$integrity = new DataIntegrity($db); +$check($integrity->recalculateBookAvailability($mixed) && $counters($mixed) === '1/2/disponibile', 'one lendable + one consultation copy: 1 available of 2 physical copies'); +$borrower = $makeUser('borrower', null); +$mkLoan($mixed, $lendableCopy, $borrower); +$check($integrity->recalculateBookAvailability($mixed) && $counters($mixed) === '0/2/prestato', 'with the lendable copy out the consultation copy does not count: 0 available, title lent'); +$db->query("UPDATE copie SET solo_consultazione = 0 WHERE numero_inventario = '{$consultationInv}'"); +$check($integrity->recalculateBookAvailability($mixed) && $counters($mixed) === '1/2/disponibile', 'back in circulation, the copy counts again'); +$db->query("UPDATE copie SET solo_consultazione = 1 WHERE libro_id = {$mixed}"); +$db->query("DELETE FROM prestiti WHERE libro_id = {$mixed}"); +$db->query("UPDATE copie SET stato = 'disponibile' WHERE libro_id = {$mixed}"); +$check($integrity->recalculateBookAvailability($mixed) && $counters($mixed) === '0/2/non_disponibile', 'every copy kept for consultation: nothing available, title not lendable'); +$all = $integrity->recalculateAllBookAvailability(); +$check($all['errors'] === [] && $counters($mixed) === '0/2/non_disponibile', 'the global recalculation applies the same rule'); + +$cleanup(); +$db->close(); + +if ($failed > 0) { + echo "FAILED: {$failed} of " . ($passed + $failed) . " checks\n"; + exit(1); +} +echo "SUCCESS: {$passed} checks\n"; diff --git a/tests/circulation-reservations.spec.js b/tests/circulation-reservations.spec.js new file mode 100644 index 000000000..b5c823a4d --- /dev/null +++ b/tests/circulation-reservations.spec.js @@ -0,0 +1,189 @@ +// @ts-check +/** + * Circulation policy — reservation rules of the patron type. + * A: a type without reservations: the waitlist button disappears, a queued + * request is refused, the staff can override explicitly. + * B: max_prenotazioni counts queue reservations + requests awaiting approval. + */ +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 = `e2ers${RUN}`; + +/** POST the book-page request endpoint from the reader's page, as the button does. */ +async function apiRequest(page, bookId, start) { + return page.evaluate(async ({ id, s }) => { + const csrf = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''; + const r = await fetch(`${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: s }), + }); + return { status: r.status, body: await r.json() }; + }, { id: bookId, s: start }); +} + +/** Make the only copy of a title busy for 20 days (another patron's loan). */ +function makeBusy(title, otherUserId) { + F.dbQuery(`INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, attivo, origine) + VALUES (${title.id}, ${title.copyIds[0]}, ${otherUserId}, CURDATE(), DATE_ADD(CURDATE(), INTERVAL 20 DAY), 'in_corso', 1, 'diretto')`); + F.dbQuery(`UPDATE copie SET stato='prestato' WHERE id=${title.copyIds[0]}`); + F.dbQuery(`UPDATE libri SET copie_disponibili=0 WHERE id=${title.id}`); +} + +/** Create a reservation from the admin form; returns the redirect URL. */ +async function adminReserve(page, patron, title, { force = false, start = F.appDateOffsetISO(25) } = {}) { + await page.goto(`${F.BASE}/admin/reservations/create`); + await page.locator('#libro_id').selectOption(String(title.id)); + await page.locator('#utente_id').selectOption(String(patron.id)); + // After the other patron's loan: a free window, queued behind the copy. + await page.evaluate((d) => { + const el = /** @type {any} */ (document.querySelector('#data_prenotazione')); + if (el && el._flatpickr) el._flatpickr.setDate(d, true, 'Y-m-d'); else if (el) el.value = d; + }, start); + if (force) await page.locator('#force_limits').check(); + await page.locator('form[action$="/admin/reservations/create"] button[type="submit"]').click(); + await page.waitForURL((u) => u.search.includes('error=') || u.search.includes('created=1'), { timeout: 20000 }); + return page.url(); +} + +test.describe.serial('Patron type without reservations', () => { + /** @type {import('@playwright/test').Page} */ let admin; + /** @type {import('@playwright/test').Page} */ let reader; + let ctxA; let ctxR; let typeId = 0; let patron; let other; let busy; let free; + const errors = []; + + test.beforeAll(async ({ browser }) => { + ctxA = await browser.newContext(); admin = await ctxA.newPage(); + ctxR = await browser.newContext(); reader = await ctxR.newPage(); + for (const p of [admin, reader]) p.on('response', (r) => { if (r.status() >= 500) errors.push(`${r.status()} ${r.url()}`); }); + await F.loginAsAdmin(admin); + typeId = await F.createTypeViaUi(admin, { nome: `Ospite ${RUN}`, codice: `${PREFIX}-guest` }, { reservations: false }); + patron = F.createPatron(`RSG${RUN}`, { typeId }); + other = F.createPatron(`RSO${RUN}`); + busy = F.createTitle(`${RUN} BUSY`, 'libro'); + free = F.createTitle(`${RUN} FREE`, 'libro'); + makeBusy(busy, other.id); + await F.loginAs(reader, patron.email, patron.password); + }); + test.afterEach(() => { expect(errors.splice(0), 'no HTTP 5xx').toEqual([]); }); + test.afterAll(async () => { + F.deleteTitles([busy?.id, free?.id]); + F.deletePatrons([patron?.id, other?.id]); + F.deleteTypes(PREFIX); + await ctxA?.close(); await ctxR?.close(); + }); + + test('the "reserve when available" button is replaced for this patron', async () => { + await reader.goto(`${F.BASE}/libro/${busy.id}`); + await expect(reader.locator('#reservations-disabled')).toBeVisible(); + await expect(reader.locator('#btn-request-loan')).toHaveCount(0); + }); + + test('a patron of the default type still sees the button on the same title', async ({ browser }) => { + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + await F.loginAs(page, other.email, other.password); + await page.goto(`${F.BASE}/libro/${busy.id}`); + await expect(page.locator('#btn-request-loan')).toBeVisible(); + await ctx.close(); + }); + + test('a loan request on an available title still works', async () => { + const res = await F.requestFromBookPage(reader, free.id, F.appTodayISO()); + expect(res.ok, res.text).toBe(true); + expect(F.dbInt(`SELECT COUNT(*) FROM prestiti WHERE utente_id=${patron.id} AND libro_id=${free.id}`)).toBe(1); + }); + + test('a request that would join the waitlist is refused with reservations_disabled', async () => { + await reader.goto(`${F.BASE}/libro/${free.id}`); + // A start after the other loan ends: capacity is free, but the copy is + // still out, so the request would wait in the queue. + const res = await apiRequest(reader, busy.id, F.appDateOffsetISO(25)); + expect(res.status).toBe(409); + expect(res.body.error).toBe('reservations_disabled'); + expect(F.dbInt(`SELECT COUNT(*) FROM prenotazioni WHERE utente_id=${patron.id}`)).toBe(0); + }); + + test('the admin form refuses it too, and accepts it with the explicit override', async () => { + const refused = await adminReserve(admin, patron, busy); + expect(refused).toContain('error=reservations_disabled'); + await expect(admin.locator('[data-testid="reservation-policy-error"]')).toBeVisible(); + const forced = await adminReserve(admin, patron, busy, { force: true }); + expect(forced).toContain('created=1'); + expect(F.dbInt(`SELECT COUNT(*) FROM prenotazioni WHERE utente_id=${patron.id} AND libro_id=${busy.id} AND stato='attiva'`)).toBe(1); + }); +}); + +test.describe.serial('Reservation limit of the patron type', () => { + /** @type {import('@playwright/test').Page} */ let admin; + /** @type {import('@playwright/test').Page} */ let reader; + let ctxA; let ctxR; let typeId = 0; let patron; let other; let a; let b; let c; + const errors = []; + + test.beforeAll(async ({ browser }) => { + ctxA = await browser.newContext(); admin = await ctxA.newPage(); + ctxR = await browser.newContext(); reader = await ctxR.newPage(); + for (const p of [admin, reader]) p.on('response', (r) => { if (r.status() >= 500) errors.push(`${r.status()} ${r.url()}`); }); + await F.loginAsAdmin(admin); + typeId = await F.createTypeViaUi(admin, { nome: `Limitato ${RUN}`, codice: `${PREFIX}-lim`, max_prenotazioni: 1 }); + patron = F.createPatron(`RSL${RUN}`, { typeId }); + other = F.createPatron(`RSP${RUN}`); + a = F.createTitle(`${RUN} LA`, 'libro'); + b = F.createTitle(`${RUN} LB`, 'libro'); + c = F.createTitle(`${RUN} LC`, 'libro'); + makeBusy(c, other.id); + await F.loginAs(reader, patron.email, patron.password); + }); + test.afterEach(() => { expect(errors.splice(0), 'no HTTP 5xx').toEqual([]); }); + test.afterAll(async () => { + F.deleteTitles([a?.id, b?.id, c?.id]); + F.deletePatrons([patron?.id, other?.id]); + F.deleteTypes(PREFIX); + await ctxA?.close(); await ctxR?.close(); + }); + + test('the first request is accepted (it awaits approval)', async () => { + const res = await F.requestFromBookPage(reader, a.id, F.appTodayISO()); + expect(res.ok, res.text).toBe(true); + expect(F.dbInt(`SELECT COUNT(*) FROM prestiti WHERE utente_id=${patron.id} AND stato='pendente'`)).toBe(1); + }); + + test('the second request is refused: the pending one counts against the limit of 1', async () => { + const res = await F.requestFromBookPage(reader, b.id, F.appTodayISO()); + expect(res.ok).toBe(false); + expect(res.text).toMatch(/prenotazioni|reservations/i); + expect(F.dbInt(`SELECT COUNT(*) FROM prestiti WHERE utente_id=${patron.id} AND libro_id=${b.id}`)).toBe(0); + }); + + test('once the librarian approves the first request the slot is free again', async () => { + const loanId = F.dbInt(`SELECT id FROM prestiti WHERE utente_id=${patron.id} AND libro_id=${a.id} AND stato='pendente'`); + await admin.goto(`${F.BASE}/admin/loans/details/${loanId}`); + await admin.locator('.approve-btn').click(); + const confirm = admin.locator('.swal2-confirm'); + if (await confirm.isVisible({ timeout: 3000 }).catch(() => false)) await confirm.click(); + await expect.poll(() => F.dbQuery(`SELECT stato FROM prestiti WHERE id=${loanId}`), { timeout: 15000 }).not.toBe('pendente'); + const res = await F.requestFromBookPage(reader, b.id, F.appTodayISO()); + expect(res.ok, res.text).toBe(true); + }); + + test('a queue reservation counts too: the admin form refuses a second one', async () => { + // b is now a pending request (1 of 1): a reservation on c is over the limit. + const refused = await adminReserve(admin, patron, c); + expect(refused).toContain('error=reservation_limit_reached'); + await expect(admin.locator('[data-testid="reservation-policy-error"]')).toContainText(/prenotazioni|reservations/i); + }); + + test('the explicit override lets the staff queue it anyway, and then the reader is at the limit', async () => { + F.dbQuery(`DELETE FROM prestiti WHERE utente_id=${patron.id} AND libro_id=${b.id}`); + const forced = await adminReserve(admin, patron, c, { force: true }); + expect(forced).toContain('created=1'); + await reader.goto(`${F.BASE}/libro/${b.id}`); + const res = await apiRequest(reader, b.id, F.appTodayISO()); + expect(res.status).toBe(409); + expect(res.body.error).toBe('reservation_limit_reached'); + }); +}); diff --git a/tests/circulation-rules-matrix.spec.js b/tests/circulation-rules-matrix.spec.js new file mode 100644 index 000000000..d7567a0a2 --- /dev/null +++ b/tests/circulation-rules-matrix.spec.js @@ -0,0 +1,154 @@ +// @ts-check +/** + * Circulation policy — rules matrix patron type × material. + * "Studente" may hold 2 loans in total and at most 1 DVD; a book borrowed + * by a student lasts 10 days. The librarian sets the matrix cell from the UI, + * lends from the staff desk and the reader requests from the book page. + */ +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 = `e2emx${RUN}`; + +test.describe.serial('Rules matrix: total and per-material limits, due date per type', () => { + /** @type {import('@playwright/test').Page} */ let admin; + /** @type {import('@playwright/test').Page} */ let reader; + let ctxA; let ctxR; + let studentType = 0; + let student; let adult; + let dvd1; let dvd2; let book1; let book2; let audio; + const errors = []; + + test.beforeAll(async ({ browser }) => { + ctxA = await browser.newContext(); + admin = await ctxA.newPage(); + ctxR = await browser.newContext(); + reader = await ctxR.newPage(); + for (const p of [admin, reader]) p.on('response', (r) => { if (r.status() >= 500) errors.push(`${r.status()} ${r.url()}`); }); + await F.loginAsAdmin(admin); + studentType = await F.createTypeViaUi(admin, { nome: `Studente ${RUN}`, codice: `${PREFIX}-stud`, max_prestiti: 2, durata_prestito_giorni: 10 }); + student = F.createPatron(`MXS${RUN}`, { typeId: studentType }); + adult = F.createPatron(`MXA${RUN}`); + dvd1 = F.createTitle(`${RUN} D1`, 'dvd'); + dvd2 = F.createTitle(`${RUN} D2`, 'dvd'); + book1 = F.createTitle(`${RUN} B1`, 'libro'); + book2 = F.createTitle(`${RUN} B2`, 'libro'); + audio = F.createTitle(`${RUN} A1`, 'audiolibro'); + }); + test.afterEach(() => { expect(errors.splice(0), 'no HTTP 5xx').toEqual([]); }); + test.afterAll(async () => { + F.deleteTitles([dvd1?.id, dvd2?.id, book1?.id, book2?.id, audio?.id]); + F.deletePatrons([student?.id, adult?.id]); + try { F.dbQuery(`DELETE FROM regole_prestito WHERE tipologia_id=0 AND tipo_media='audiolibro'`); } catch { /* */ } + F.deleteTypes(PREFIX); + await ctxA?.close(); await ctxR?.close(); + }); + + test('the matrix saves a cell and greys the inherited values elsewhere', async () => { + await F.setMatrixCell(admin, studentType, 'dvd', { max: 1 }); + expect(F.dbQuery(`SELECT CONCAT(IFNULL(prestabile,'-'),'|',max_prestiti,'|',IFNULL(durata_prestito_giorni,'-')) FROM regole_prestito WHERE tipologia_id=${studentType} AND tipo_media='dvd'`)).toBe('-|1|-'); + await F.openCirculationTab(admin); + await expect(admin.locator(`#rule-${studentType}-dvd-max`)).toHaveValue('1'); + // An empty cell shows what it inherits: the type's 10 days, the global renewals. + await expect(admin.locator(`#rule-${studentType}-libro-duration`)).toHaveValue(''); + await expect(admin.locator(`#rule-${studentType}-libro-duration`)).toHaveAttribute('placeholder', '10'); + // No row was written for the cells left empty. + expect(F.dbInt(`SELECT COUNT(*) FROM regole_prestito WHERE tipologia_id=${studentType}`)).toBe(1); + }); + + test('the staff desk lends the first DVD with the student due date (10 days)', async () => { + const url = await F.staffLoan(admin, student, dvd1); + expect(url).not.toContain('error='); + const due = F.dbQuery(`SELECT DATEDIFF(data_scadenza, data_prestito) FROM prestiti WHERE utente_id=${student.id} AND libro_id=${dvd1.id} ORDER BY id DESC LIMIT 1`); + expect(Number(due)).toBe(10); + }); + + test('a second DVD is refused: limit for the material type', async () => { + const url = await F.staffLoan(admin, student, dvd2); + expect(url).toContain('error=media_limit_reached'); + await expect(admin.locator('body')).toContainText(/tipo di materiale|material type/i); + expect(F.dbInt(`SELECT COUNT(*) FROM prestiti WHERE utente_id=${student.id} AND libro_id=${dvd2.id}`)).toBe(0); + }); + + test('the reader is refused the second DVD from the book page too', async () => { + await F.loginAs(reader, student.email, student.password); + const res = await F.requestFromBookPage(reader, dvd2.id, F.appTodayISO()); + expect(res.ok).toBe(false); + expect(res.text).toMatch(/tipo di materiale|material type/i); + expect(F.dbInt(`SELECT COUNT(*) FROM prestiti WHERE utente_id=${student.id} AND libro_id=${dvd2.id}`)).toBe(0); + }); + + test('a book still passes (different material), then the total limit of 2 stops the next one', async () => { + expect(await F.staffLoan(admin, student, book1)).not.toContain('error='); + expect(F.dbInt(`SELECT COUNT(*) FROM prestiti WHERE utente_id=${student.id} AND attivo=1`)).toBe(2); + const url = await F.staffLoan(admin, student, book2); + expect(url).toContain('error=max_loans_reached'); + }); + + test('a matrix cell with 0 renewals makes the student book loan not renewable', async () => { + await F.setMatrixCell(admin, studentType, 'libro', { renewals: 0 }); + const loanId = F.dbInt(`SELECT id FROM prestiti WHERE utente_id=${student.id} AND libro_id=${book1.id} AND attivo=1 ORDER BY id DESC LIMIT 1`); + // The staff desk loan starts as "to collect": the patron picks it up. + F.dbQuery(`UPDATE prestiti SET stato='in_corso' WHERE id=${loanId}`); + await admin.goto(`${F.BASE}/admin/books/${book1.id}`); + await expect(admin.locator('body')).toContainText(/Limite massimo rinnovi raggiunto|Maximum renewals reached/); + await expect(admin.locator(`form[action$="/admin/loans/renew/${loanId}"]`)).toHaveCount(0); + }); + + test('a patron of the default type is not affected by the student rules', async () => { + expect(await F.staffLoan(admin, adult, dvd2)).not.toContain('error='); + const globalDays = Number(F.dbQuery("SELECT IFNULL((SELECT setting_value FROM system_settings WHERE category='loans' AND setting_key='loan_duration_days'), '30')")); + const due = Number(F.dbQuery(`SELECT DATEDIFF(data_scadenza, data_prestito) FROM prestiti WHERE utente_id=${adult.id} AND libro_id=${dvd2.id} ORDER BY id DESC LIMIT 1`)); + expect(due).toBe(globalDays > 0 ? globalDays : 30); + }); + + test('an all-types rule for audiobooks sets 5 days for everyone', async () => { + await F.setMatrixCell(admin, 0, 'audiolibro', { duration: 5 }); + expect(await F.staffLoan(admin, adult, audio)).not.toContain('error='); + const due = Number(F.dbQuery(`SELECT DATEDIFF(data_scadenza, data_prestito) FROM prestiti WHERE utente_id=${adult.id} AND libro_id=${audio.id} ORDER BY id DESC LIMIT 1`)); + expect(due).toBe(5); + }); + + test('days per renewal from a matrix cell: the button says it and the renewal extends by it', async () => { + // Placeholder of an empty cell: the global renewal length (= loan duration). + await F.openCirculationTab(admin); + await expect(admin.locator('#rule-0-audiolibro-renewal_days')).toHaveValue(''); + await F.setMatrixCell(admin, 0, 'audiolibro', { renewalDays: 7 }); + expect(F.dbQuery(`SELECT CONCAT(durata_prestito_giorni,'|',giorni_rinnovo) FROM regole_prestito WHERE tipologia_id=0 AND tipo_media='audiolibro'`)).toBe('5|7'); + await F.openCirculationTab(admin); + await expect(admin.locator('#rule-0-audiolibro-renewal_days')).toHaveValue('7'); + // Other cells of the audiobook column inherit the 7 days of the all-types rule. + await expect(admin.locator(`#rule-${studentType}-audiolibro-renewal_days`)).toHaveAttribute('placeholder', '7'); + + const loanId = F.dbInt(`SELECT id FROM prestiti WHERE utente_id=${adult.id} AND libro_id=${audio.id} AND attivo=1 ORDER BY id DESC LIMIT 1`); + F.dbQuery(`UPDATE prestiti SET stato='in_corso' WHERE id=${loanId}`); + const dueBefore = F.dbQuery(`SELECT data_scadenza FROM prestiti WHERE id=${loanId}`); + await admin.goto(`${F.BASE}/admin/books/${audio.id}`); + const renew = admin.locator(`form[action$="/admin/loans/renew/${loanId}"] button[type="submit"]`); + await expect(renew).toContainText('+7 giorni'); + await renew.click(); + await expect(admin.locator('.swal2-popup')).toContainText('7 giorni'); + await Promise.all([admin.waitForURL(/\/admin\/books\//), admin.locator('.swal2-confirm').click()]); + expect(F.dbQuery(`SELECT CONCAT(DATEDIFF(data_scadenza, '${dueBefore}'),'|',renewals) FROM prestiti WHERE id=${loanId}`)).toBe('7|1'); + // The cell is emptied again: no rule row is left for a cell with nothing in it. + await F.openCirculationTab(admin); + await admin.locator('#rule-0-audiolibro-duration').fill(''); + await admin.locator('#rule-0-audiolibro-renewal_days').fill(''); + await admin.locator('[data-testid="circulation-matrix-form"] button[type="submit"]').click(); + await admin.waitForURL((u) => u.searchParams.get('tab') === 'circulation'); + expect(F.dbInt(`SELECT COUNT(*) FROM regole_prestito WHERE tipologia_id=0 AND tipo_media='audiolibro'`)).toBe(0); + }); + + test('the reader request of a book gets the student duration', async () => { + // Free one slot: the librarian returns the DVD. + F.dbQuery(`UPDATE prestiti SET stato='restituito', attivo=0, data_restituzione=CURDATE() WHERE utente_id=${student.id} AND libro_id=${dvd1.id}`); + F.dbQuery(`UPDATE copie SET stato='disponibile' WHERE libro_id=${dvd1.id}`); + const res = await F.requestFromBookPage(reader, book2.id, F.appTodayISO()); + expect(res.ok, res.text).toBe(true); + const days = Number(F.dbQuery(`SELECT DATEDIFF(data_scadenza, data_prestito) FROM prestiti WHERE utente_id=${student.id} AND libro_id=${book2.id} ORDER BY id DESC LIMIT 1`)); + expect(days).toBe(10); + }); +}); diff --git a/tests/circulation-settings-access.spec.js b/tests/circulation-settings-access.spec.js new file mode 100644 index 000000000..bfb18a5fe --- /dev/null +++ b/tests/circulation-settings-access.spec.js @@ -0,0 +1,113 @@ +// @ts-check +/** + * Circulation policy — who may change it. Patron types and the rules matrix + * are library policy: a staff account reaches the admin area but every + * circulation settings action (pages and forms) sends it back to the + * dashboard with nothing changed; the administrator keeps full access. + * + * Run: /tmp/run-e2e.sh tests/circulation-settings-access.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 = `e2eacc${RUN}`; + +test.describe.serial('Circulation settings: administrators only', () => { + /** @type {import('@playwright/test').Page} */ + let staffPage; + /** @type {import('@playwright/test').Page} */ + let adminPage; + let staff; + let typeId = 0; + let snapshot = ''; + const snap = () => F.dbQuery("SELECT CONCAT(COUNT(*),'|',COALESCE(SUM(CRC32(CONCAT_WS('|',id,codice,nome,IFNULL(max_prestiti,''),predefinita,ordinamento,attiva))),0)) FROM tipologie_lettore") + + '#' + F.dbQuery("SELECT CONCAT(COUNT(*),'|',COALESCE(SUM(CRC32(CONCAT_WS('|',tipologia_id,tipo_media,IFNULL(prestabile,''),IFNULL(max_prestiti,'')))),0)) FROM regole_prestito"); + + test.beforeAll(async ({ browser }) => { + adminPage = await (await browser.newContext()).newPage(); + await F.loginAsAdmin(adminPage); + // A type the staff will try to change, made by the administrator. + typeId = await F.createTypeViaUi(adminPage, { nome: `Accesso ${RUN}`, codice: `${PREFIX}-t`, max_prestiti: 3 }); + expect(typeId).toBeGreaterThan(0); + staff = F.createPatron(`AC${RUN}`, { role: 'staff' }); + staffPage = await (await browser.newContext()).newPage(); + await F.loginAs(staffPage, staff.email, staff.password); + snapshot = snap(); + }); + + test.afterAll(async () => { + F.deletePatrons([staff?.id]); + F.deleteTypes(PREFIX); + await staffPage?.context().close(); + await adminPage?.context().close(); + }); + + test('staff opening the circulation pages lands on the dashboard with the admin-only message', async () => { + for (const path of ['/admin/settings/circulation', `/admin/settings/circulation/types/${typeId}`]) { + await staffPage.goto(`${F.BASE}${path}`); + await expect(staffPage).toHaveURL(/\/admin\/dashboard/); + await expect(staffPage.locator('body')).toContainText(/riservata agli amministratori|reserved to administrators/i); + } + await expect(staffPage.locator('[data-testid="circulation-type-edit"]')).toHaveCount(0); + }); + + test('staff opening the settings page on the circulation tab gets the default section, without the panel or the menu entry', async () => { + await staffPage.goto(`${F.BASE}/admin/settings?tab=circulation`); + await expect(staffPage).toHaveURL(/\/admin\/settings/); + await expect(staffPage.locator('[data-settings-panel="general"]')).toBeVisible(); + await expect(staffPage.locator('[data-settings-panel="circulation"]')).toHaveCount(0); + await expect(staffPage.locator('[data-testid="circulation-type-create"]')).toHaveCount(0); + await expect(staffPage.locator('form[action$="/admin/settings/circulation/rules"]')).toHaveCount(0); + await expect(staffPage.locator('[data-settings-section="circulation"]')).toHaveCount(0); + await expect(staffPage.locator('[data-settings-section="loans"]').first()).toBeAttached(); + }); + + test('every circulation form sent by staff is refused and changes nothing', async () => { + await staffPage.goto(`${F.BASE}/admin/dashboard`); + const csrf = await staffPage.locator('meta[name="csrf-token"]').getAttribute('content'); + expect(csrf).toBeTruthy(); + const posts = [ + ['/admin/settings/circulation/types', { nome: 'Intruso', codice: `${PREFIX}-x`, max_prestiti: '99' }], + [`/admin/settings/circulation/types/${typeId}`, { nome: 'Modificato', codice: `${PREFIX}-t`, max_prestiti: '99' }], + [`/admin/settings/circulation/types/${typeId}/default`, {}], + [`/admin/settings/circulation/types/${typeId}/move`, { direction: 'up' }], + ['/admin/settings/circulation/rules', { [`rules[${typeId}][dvd][prestabile]`]: '0' }], + [`/admin/settings/circulation/types/${typeId}/delete`, {}], + ]; + for (const [path, fields] of posts) { + const r = await staffPage.request.post(`${F.BASE}${path}`, { form: { csrf_token: String(csrf), ...fields }, maxRedirects: 0 }); + expect(r.status(), path).toBe(302); + expect(r.headers()['location'] || '', path).toMatch(/\/admin\/dashboard/); + } + expect(snap()).toBe(snapshot); + expect(F.dbQuery(`SELECT nome FROM tipologie_lettore WHERE id=${typeId}`)).toBe(`Accesso ${RUN}`); + }); + + test('the administrator still edits the type from its page', async () => { + await adminPage.goto(`${F.BASE}/admin/settings/circulation/types/${typeId}`); + const form = adminPage.locator('[data-testid="circulation-type-edit"]'); + await expect(form).toBeVisible(); + await form.locator('[name="max_prestiti"]').fill('4'); + await form.locator('button[type="submit"]').first().click(); + await adminPage.waitForURL(/tab=circulation/); + expect(F.dbQuery(`SELECT max_prestiti FROM tipologie_lettore WHERE id=${typeId}`)).toBe('4'); + }); + + test('the administrator still opens the circulation tab and deletes the type', async () => { + await F.openCirculationTab(adminPage); + await expect(adminPage.locator(`#circulation-types-table tr[data-type-code="${PREFIX}-t"]`)).toBeVisible(); + const csrf = await adminPage.locator('meta[name="csrf-token"]').getAttribute('content'); + const r = await adminPage.request.post(`${F.BASE}/admin/settings/circulation/types/${typeId}/delete`, { form: { csrf_token: String(csrf) }, maxRedirects: 0 }); + expect(r.status()).toBe(302); + expect(r.headers()['location'] || '').not.toMatch(/\/admin\/dashboard/); + expect(F.dbInt(`SELECT COUNT(*) FROM tipologie_lettore WHERE id=${typeId}`)).toBe(0); + }); + + test('staff still reaches the rest of the admin area (loans list)', async () => { + await staffPage.goto(`${F.BASE}/admin/loans`); + await expect(staffPage).toHaveURL(/\/admin\/loans/); + }); +}); diff --git a/tests/helpers/circulation-fixtures.js b/tests/helpers/circulation-fixtures.js new file mode 100644 index 000000000..e59855ee9 --- /dev/null +++ b/tests/helpers/circulation-fixtures.js @@ -0,0 +1,234 @@ +// @ts-check +/** + * Shared fixtures for the circulation-policy E2E suites (patron types, rules + * matrix, reservation limits, card duration). DB is used only for setup and + * verification; every behaviour under test is driven through the UI or the + * public endpoints a real client uses. + */ +const { expect } = require('@playwright/test'); +const { execFileSync } = require('child_process'); +const { appTodayISO, appDateOffsetISO } = require('./app-date'); + +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_HOST = process.env.E2E_DB_HOST || ''; +const DB_PORT = process.env.E2E_DB_PORT || ''; +const DB_SOCKET = process.env.E2E_DB_SOCKET || ''; +const DB_NAME = process.env.E2E_DB_NAME || ''; +const CONFIGURED = Boolean(ADMIN_EMAIL && ADMIN_PASS && DB_USER && DB_NAME); + +function dbQuery(sql) { + const args = ['--default-character-set=utf8mb4', '-N', '-B', '-e', sql]; + if (DB_HOST) args.push('-h', DB_HOST); + if (DB_PORT) args.push('-P', DB_PORT); + if (!DB_HOST && DB_SOCKET) args.push('-S', DB_SOCKET); + args.push('-u', DB_USER, DB_NAME); + return execFileSync('mysql', args, { encoding: 'utf-8', timeout: 15000, env: { ...process.env, MYSQL_PWD: DB_PASS } }).trim(); +} +const dbInt = (sql) => parseInt(dbQuery(sql) || '0', 10); +const sqlStr = (v) => "'" + String(v).replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'"; + +function clearRateLimits() { + try { + execFileSync('php', ['-r', 'foreach ([sys_get_temp_dir(), "/var/tmp", "/tmp"] as $d) { array_map("unlink", glob($d."/pinakes_ratelimit/*.json") ?: []); }'], { encoding: 'utf-8', timeout: 5000 }); + } catch { /* ignore */ } +} + +function phpHash(password) { + return execFileSync('php', ['-r', `echo password_hash(${JSON.stringify(password)}, PASSWORD_DEFAULT);`], { encoding: 'utf-8', timeout: 5000 }).trim(); +} + +/** Create a verified, active patron; returns its id. */ +function createPatron(tag, { email, password = 'Circ1234!', typeId = null, cardExpiry = null, role = 'standard' } = {}) { + const mail = email || `${tag.toLowerCase()}@circ.test`; + dbQuery(`DELETE FROM prestiti WHERE utente_id IN (SELECT id FROM utenti WHERE email=${sqlStr(mail)})`); + dbQuery(`DELETE FROM prenotazioni WHERE utente_id IN (SELECT id FROM utenti WHERE email=${sqlStr(mail)})`); + dbQuery(`DELETE FROM utenti WHERE email=${sqlStr(mail)}`); + const card = `${tag}${String(Date.now()).slice(-7)}`.slice(0, 20); + dbQuery(`INSERT INTO utenti (nome, cognome, email, password, telefono, indirizzo, tipo_utente, stato, email_verificata, codice_tessera, privacy_accettata, data_scadenza_tessera, tipologia_id, created_at) + VALUES ('Circ', ${sqlStr(tag)}, ${sqlStr(mail)}, ${sqlStr(phpHash(password))}, '0000000', 'Via E2E 1', ${sqlStr(role)}, 'attivo', 1, ${sqlStr(card)}, 1, + ${cardExpiry ? sqlStr(cardExpiry) : 'NULL'}, ${typeId ? Number(typeId) : 'NULL'}, NOW())`); + return { id: dbInt(`SELECT id FROM utenti WHERE email=${sqlStr(mail)}`), email: mail, password, surname: tag }; +} + +/** Create a title of the given material with N available copies; returns {id, title, copyIds}. */ +/** Inventory code of a copy. */ +const copyCodeOf = (copyId) => dbQuery(`SELECT numero_inventario FROM copie WHERE id=${Number(copyId)}`); + +function createTitle(tag, media = 'libro', copies = 1) { + const title = `CIRC ${tag} ${media}`; + dbQuery(`INSERT INTO libri (titolo, search_index, tipo_media, copie_totali, copie_disponibili, stato, created_at, updated_at) + VALUES (${sqlStr(title)}, ${sqlStr(title)}, ${sqlStr(media)}, ${copies}, ${copies}, 'disponibile', NOW(), NOW())`); + const id = dbInt(`SELECT id FROM libri WHERE titolo=${sqlStr(title)} AND deleted_at IS NULL ORDER BY id DESC LIMIT 1`); + const copyIds = []; + for (let i = 0; i < copies; i++) { + const inv = `CIRC-${tag}-${i}-${String(Date.now()).slice(-6)}`; + dbQuery(`INSERT INTO copie (libro_id, numero_inventario, stato, created_at) VALUES (${id}, ${sqlStr(inv)}, 'disponibile', NOW())`); + copyIds.push(dbInt(`SELECT id FROM copie WHERE numero_inventario=${sqlStr(inv)}`)); + } + return { id, title, copyIds }; +} + +function deleteTitles(ids) { + for (const id of ids.filter(Boolean)) { + try { + dbQuery(`DELETE FROM prestiti WHERE libro_id=${id}`); + dbQuery(`DELETE FROM prenotazioni WHERE libro_id=${id}`); + dbQuery(`DELETE FROM copie WHERE libro_id=${id}`); + dbQuery(`DELETE FROM libri WHERE id=${id}`); + } catch { /* best effort */ } + } +} + +function deletePatrons(ids) { + for (const id of ids.filter(Boolean)) { + try { + dbQuery(`DELETE FROM prestiti WHERE utente_id=${id}`); + dbQuery(`DELETE FROM prenotazioni WHERE utente_id=${id}`); + dbQuery(`DELETE FROM user_sessions WHERE utente_id=${id}`); + dbQuery(`DELETE FROM utenti WHERE id=${id}`); + } catch { /* best effort */ } + } +} + +/** Remove the patron types (and their rules) whose code starts with $prefix. */ +function deleteTypes(prefix) { + try { + dbQuery(`UPDATE utenti SET tipologia_id=NULL WHERE tipologia_id IN (SELECT id FROM (SELECT id FROM tipologie_lettore WHERE codice LIKE ${sqlStr(prefix + '%')}) t)`); + dbQuery(`DELETE FROM regole_prestito WHERE tipologia_id IN (SELECT id FROM tipologie_lettore WHERE codice LIKE ${sqlStr(prefix + '%')})`); + dbQuery(`DELETE FROM tipologie_lettore WHERE codice LIKE ${sqlStr(prefix + '%')} AND predefinita=0`); + } catch { /* best effort */ } +} + +async function loginAs(page, email, password) { + for (let attempt = 0; attempt < 2; attempt++) { + clearRateLimits(); + await page.goto(`${BASE}/accedi`); + await page.fill('input[name="email"]', email); + await page.fill('input[name="password"]', password); + await page.locator('button[type="submit"]').click(); + try { + await page.waitForURL((u) => !u.toString().includes('/accedi'), { timeout: 20000 }); + return; + } catch { + if (attempt === 1) throw new Error(`login failed for ${email}`); + } + } +} + +const loginAsAdmin = (page) => loginAs(page, ADMIN_EMAIL, ADMIN_PASS); + +/** Open the circulation tab of the settings page. */ +async function openCirculationTab(page) { + await page.goto(`${BASE}/admin/settings/circulation`); + await page.waitForURL((u) => u.searchParams.get('tab') === 'circulation'); + await expect(page.locator('[data-settings-panel="circulation"]')).toBeVisible(); +} + +/** + * Create a patron type through the "Nuova tipologia" form. + * @param {Record} fields form fields (nome, codice, max_prestiti, ...) + * @param {{reservations?: boolean}} [opts] + */ +async function createTypeViaUi(page, fields, opts = {}) { + await openCirculationTab(page); + await page.locator('#circulation-new-type summary').click(); + const form = page.locator('[data-testid="circulation-type-create"]'); + for (const [name, value] of Object.entries(fields)) { + await form.locator(`[name="${name}"]:not([type="hidden"])`).fill(String(value)); + } + if (opts.reservations === false) { + await form.locator('input[type="checkbox"][name="prenotazioni_abilitate"]').uncheck(); + } + await form.locator('button[type="submit"]').click(); + await page.waitForURL((u) => u.searchParams.get('tab') === 'circulation'); + const id = dbInt(`SELECT id FROM tipologie_lettore WHERE codice=${sqlStr(String(fields.codice))}`); + expect(id, `patron type ${fields.codice} created`).toBeGreaterThan(0); + return id; +} + +/** Fill one matrix cell and save the matrix. */ +async function setMatrixCell(page, typeId, media, { loanable, max, duration, renewals, renewalDays } = {}) { + await openCirculationTab(page); + const prefix = `#rule-${typeId}-${media}`; + if (loanable !== undefined) await page.locator(`${prefix}-loanable`).selectOption(String(loanable)); + if (max !== undefined) await page.locator(`${prefix}-max`).fill(String(max)); + if (duration !== undefined) await page.locator(`${prefix}-duration`).fill(String(duration)); + if (renewals !== undefined) await page.locator(`${prefix}-renewals`).fill(String(renewals)); + if (renewalDays !== undefined) await page.locator(`${prefix}-renewal_days`).fill(String(renewalDays)); + await page.locator('[data-testid="circulation-matrix-form"] button[type="submit"]').click(); + await page.waitForURL((u) => u.searchParams.get('tab') === 'circulation'); +} + +/** Pick a value in one of the admin autocompletes (user/book). */ +async function fillAutocomplete(page, inputSelector, suggestSelector, query, apiUrlFragment) { + for (let attempt = 1; attempt <= 3; attempt++) { + await page.fill(inputSelector, ''); + const responsePromise = page.waitForResponse((r) => r.url().includes(apiUrlFragment) && r.status() === 200, { timeout: 15000 }); + await page.locator(inputSelector).pressSequentially(query, { delay: 40 }); + await responsePromise; + const item = page.locator(`${suggestSelector} .suggestion-item`).first(); + if (await item.isVisible({ timeout: 3000 }).catch(() => false)) { + await item.click(); + return; + } + } + await page.locator(`${suggestSelector} .suggestion-item`).first().click({ timeout: 5000 }); +} + +/** + * Lend $book to $patron from the staff desk form; returns the redirect URL + * (…/admin/loans?… on success, …/admin/loans/create?error=… on refusal). + */ +async function staffLoan(page, patron, book, { copyCode = '' } = {}) { + await page.goto(`${BASE}/admin/loans/create`); + await page.waitForLoadState('domcontentloaded'); + await fillAutocomplete(page, '#utente_search', '#utente_suggest', patron.surname, '/api/search/utenti'); + expect(Number(await page.locator('#utente_id').inputValue())).toBe(patron.id); + const availability = page.waitForResponse((r) => r.url().includes('/availability') && r.url().includes(`for_user=${patron.id}`), { timeout: 15000 }).catch(() => null); + if (copyCode) { + const resolved = page.waitForResponse((r) => r.url().includes('/admin/copies/by-code')); + await page.locator('#copy_code').fill(copyCode); + await page.locator('#copy_code').dispatchEvent('change'); + await resolved; + await expect(page.locator('#libro_id')).toHaveValue(String(book.id)); + } else { + await fillAutocomplete(page, '#libro_search', '#libro_suggest', book.title, '/api/search/libri'); + } + expect(Number(await page.locator('#libro_id').inputValue())).toBe(book.id); + // The borrower/book availability answer sets the due date of this patron + // type and material: wait for it before submitting. + await availability; + await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => r(null)))); + await page.evaluate(() => document.querySelectorAll('.flatpickr-calendar.open').forEach((c) => c.classList.remove('open'))); + await page.locator('button[type="submit"]:not([name="save_and_new"])').click(); + await page.waitForFunction(() => !window.location.pathname.endsWith('/create') || window.location.search.includes('error='), null, { timeout: 30000 }); + return page.url(); +} + +/** Open the book page as the logged-in patron and submit a request from the calendar popup. */ +async function requestFromBookPage(page, bookId, startISO) { + await page.goto(`${BASE}/libro/${bookId}`); + 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); }, startISO); + await page.locator('.swal2-confirm').click(); + await page.waitForFunction(() => !!document.querySelector('.swal2-icon-success, .swal2-icon-error'), null, { timeout: 15000 }); + const ok = await page.locator('.swal2-icon-success').isVisible().catch(() => false); + const text = (await page.locator('.swal2-html-container').textContent().catch(() => '')) || ''; + await page.locator('.swal2-confirm').click().catch(() => {}); + return { ok, text }; +} + +module.exports = { + BASE, ADMIN_EMAIL, ADMIN_PASS, CONFIGURED, + dbQuery, dbInt, sqlStr, clearRateLimits, phpHash, + createPatron, createTitle, deleteTitles, deletePatrons, deleteTypes, + loginAs, loginAsAdmin, openCirculationTab, createTypeViaUi, setMatrixCell, + fillAutocomplete, staffLoan, requestFromBookPage, copyCodeOf, + appTodayISO, appDateOffsetISO, +}; diff --git a/tests/loan-coherence-audit.unit.php b/tests/loan-coherence-audit.unit.php index 18a899e57..9a96b3224 100644 --- a/tests/loan-coherence-audit.unit.php +++ b/tests/loan-coherence-audit.unit.php @@ -140,7 +140,8 @@ // 5. LoanRepository::update() fallback honours the configured duration. $checks['LoanRepository::update fallback reads loan_duration_days'] = !str_contains($loanRepo, "+14 days") - && str_contains($loanRepo, 'loanDurationDays()'); + && str_contains($loanRepo, '->durationDays') + && str_contains($loanRepo, 'CirculationPolicy::dueDateFor('); // 6. rejectLoan promotes the waitlist + flushes deferred notifications. $rejectBody = $extractMethod($approval, 'public function rejectLoan('); @@ -298,7 +299,7 @@ && str_contains($loanCreateView, 'loanWindowFrom(startIso, effectiveLoanDays)') && str_contains($loanCreateView, 'addDaysToIso(startIso, defaultDays)') && !str_contains($loanCreateView, 'setMonth(endDate.getMonth() + 1)') - && str_contains($frontend, '$defaultRequestLoanDays = $loanSettings->requestLoanDurationDays()') + && str_contains($frontend, '$defaultRequestLoanDays = $viewerPolicy->requestDurationDays()') && str_contains($bookDetail, 'loanWindowFrom(startIso, availabilityByDate, defaultRequestLoanDays, maxRequestLoanDays)') && str_contains($bookDetail, 'addDaysToIso(startIso, defaultDays)') && !str_contains($bookDetail, 'setMonth(endDate.getMonth() + 1)'); diff --git a/tests/migration-0.8.6-rc.1-accounts.unit.php b/tests/migration-0.8.6-rc.1-accounts.unit.php index db5e63122..25874a22b 100644 --- a/tests/migration-0.8.6-rc.1-accounts.unit.php +++ b/tests/migration-0.8.6-rc.1-accounts.unit.php @@ -55,7 +55,7 @@ exit(1); } $lines = array_filter(explode("\n", $m[1]), static fn (string $line): bool => - !preg_match('/`ruolo_id`|`accesso_digitale`|idx_utenti_ruolo|fk_utenti_ruolo/', $line)); + !preg_match('/`ruolo_id`|`accesso_digitale`|idx_utenti_ruolo|fk_utenti_ruolo|`tipologia_id`|idx_utenti_tipologia|fk_utenti_tipologia|`card_expiry_warning_sent_for`/', $line)); $body = rtrim(implode("\n", $lines)); $body = rtrim($body, ','); $db->query("CREATE TABLE `{$users}` ({$body}\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); diff --git a/tests/migration-0.9.0-circulation-policy.unit.php b/tests/migration-0.9.0-circulation-policy.unit.php new file mode 100644 index 000000000..58057fc20 --- /dev/null +++ b/tests/migration-0.9.0-circulation-policy.unit.php @@ -0,0 +1,142 @@ +getMessage()}\n"); + exit(1); +} + +$run = bin2hex(random_bytes(3)); +$names = [ + 'tipologie_lettore' => "zz_m090_tl_{$run}", + 'regole_prestito' => "zz_m090_rp_{$run}", + 'utenti' => "zz_m090_u_{$run}", + 'copie' => "zz_m090_c_{$run}", + 'fk_utenti_tipologia' => "zz_m090_fk_{$run}", +]; +$file = dirname(__DIR__) . '/installer/database/migrations/migrate_0.9.0-rc.1.sql'; +$source = (string) file_get_contents($file); +// Only the circulation section: other features add their own sections. +if (!preg_match('/-- ── Circulation policy:.*?-- ── end Circulation policy ──/s', $source, $m)) { + fwrite(STDERR, "FAIL: circulation section not found in migrate_0.9.0-rc.1.sql\n"); + exit(1); +} +$section = (string) preg_replace('/^--.*$/m', '', $m[0]); +foreach ($names as $from => $to) { + $section = (string) preg_replace('/\b' . preg_quote($from, '/') . '\b/', $to, $section); +} +$migrate = static function () use ($db, $section): void { + foreach (explode(';', $section) as $sql) { + if (trim($sql) !== '') { + $db->query($sql); + } + } +}; + +$checks = 0; +$check = static function (bool $ok, string $label) use (&$checks): void { + if (!$ok) { + throw new RuntimeException('FAIL ' . $label); + } + $checks++; + echo "OK {$label}\n"; +}; +$scalar = static fn (string $sql) => $db->query($sql)->fetch_row()[0] ?? null; +$column = static function (string $table, string $col) use ($db): ?array { + $r = $db->query("SELECT DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{$table}' AND COLUMN_NAME = '{$col}'"); + return $r->fetch_assoc(); +}; +[$tl, $rp, $u, $c, $fk] = array_values($names); + +try { + // 0.8.9 shape of the columns involved, with existing rows. + $db->query("CREATE TABLE {$u} (id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, email VARCHAR(100) NOT NULL, tipo_utente ENUM('standard','premium','staff','admin') DEFAULT 'standard', data_scadenza_tessera DATE DEFAULT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); + $db->query("CREATE TABLE {$c} (id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, libro_id INT NOT NULL, stato VARCHAR(20) NOT NULL DEFAULT 'disponibile') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); + $db->query("INSERT INTO {$u} (email, tipo_utente, data_scadenza_tessera) VALUES ('a@x', 'standard', '2027-01-01'), ('b@x', 'premium', NULL), ('c@x', 'admin', NULL)"); + $db->query("INSERT INTO {$c} (libro_id, stato) VALUES (1, 'disponibile'), (1, 'prestato')"); + $check($column($u, 'tipologia_id') === null && $column($c, 'solo_consultazione') === null, 'the pre-upgrade tables have no circulation columns'); + + $migrate(); + + $check((int) $scalar("SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ('{$tl}', '{$rp}')") === 2, 'patron types and rules tables are created'); + $check((int) $scalar("SELECT COUNT(*) FROM {$tl}") === 1, 'exactly one patron type is seeded'); + $check($scalar("SELECT CONCAT(codice, '|', nome, '|', predefinita, '|', attiva, '|', prenotazioni_abilitate) FROM {$tl}") === 'standard|Standard|1|1|1', 'the seeded type is the active default "Standard" with reservations allowed'); + $check($scalar("SELECT CONCAT_WS(',', max_prestiti, durata_prestito_giorni, max_durata_richiesta_giorni, max_rinnovi, giorni_rinnovo, max_prenotazioni, durata_tessera_giorni, multa_giornaliera, giorni_tolleranza, multa_massima_prestito, soglia_blocco_debito) FROM {$tl}") === '', 'every rule of the seeded type is NULL: it inherits the global settings'); + $col = $column($u, 'tipologia_id'); + $check(is_array($col) && $col['DATA_TYPE'] === 'int' && $col['IS_NULLABLE'] === 'YES' && ($col['COLUMN_DEFAULT'] === null || $col['COLUMN_DEFAULT'] === 'NULL'), 'utenti.tipologia_id is a nullable INT defaulting to NULL'); + $check($scalar("SELECT GROUP_CONCAT(CONCAT(email, ':', tipo_utente, ':', IFNULL(data_scadenza_tessera, '-'), ':', IFNULL(tipologia_id, '-')) ORDER BY id) FROM {$u}") === 'a@x:standard:2027-01-01:-,b@x:premium:-:-,c@x:admin:-:-', 'no existing user is touched (premium is NOT migrated to a type)'); + $check((int) $scalar("SELECT COUNT(*) FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = '{$fk}' AND REFERENCED_TABLE_NAME = '{$tl}' AND DELETE_RULE = 'SET NULL'") === 1, 'tipologia_id references the types with ON DELETE SET NULL'); + $check((int) $scalar("SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{$u}' AND INDEX_NAME = 'idx_utenti_tipologia'") === 1, 'tipologia_id is indexed'); + $col = $column($u, 'card_expiry_warning_sent_for'); + $check(is_array($col) && $col['DATA_TYPE'] === 'date' && $col['IS_NULLABLE'] === 'YES' && ($col['COLUMN_DEFAULT'] === null || $col['COLUMN_DEFAULT'] === 'NULL'), 'utenti.card_expiry_warning_sent_for is a nullable DATE: nobody counts as warned'); + $col = $column($c, 'solo_consultazione'); + $check(is_array($col) && $col['IS_NULLABLE'] === 'NO' && trim((string) $col['COLUMN_DEFAULT'], "'") === '0', 'copie.solo_consultazione is NOT NULL DEFAULT 0'); + $check($scalar("SELECT GROUP_CONCAT(CONCAT(id, ':', stato, ':', solo_consultazione) ORDER BY id) FROM {$c}") === '1:disponibile:0,2:prestato:0', 'existing copies stay lendable'); + $check((int) $scalar("SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{$rp}' AND INDEX_NAME = 'uniq_regole_prestito_tipologia_media' AND NON_UNIQUE = 0") === 2, 'rules are UNIQUE on (tipologia_id, tipo_media)'); + $db->query("INSERT INTO {$rp} (tipologia_id, tipo_media, max_prestiti) VALUES (0, 'dvd', 1)"); + $duplicate = false; + try { + $db->query("INSERT INTO {$rp} (tipologia_id, tipo_media, max_prestiti) VALUES (0, 'dvd', 2)"); + } catch (mysqli_sql_exception) { + $duplicate = true; + } + $check($duplicate, 'the sentinels make the "all types × dvd" rule unique (no NULL in the key)'); + $check($scalar("SELECT CONCAT(tipologia_id, '|', tipo_media, '|', IFNULL(prestabile, 'inherit')) FROM {$rp}") === '0|dvd|inherit', 'rule sentinels and an inheriting prestabile default'); + + // A second run is a no-op that keeps what the admin changed. + $db->query("UPDATE {$tl} SET nome = 'Adulti', max_prestiti = 4"); + $db->query("INSERT INTO {$tl} (codice, nome) VALUES ('studente', 'Studente')"); + $studente = (int) $scalar("SELECT id FROM {$tl} WHERE codice = 'studente'"); + $db->query("UPDATE {$u} SET tipologia_id = {$studente} WHERE email = 'a@x'"); + $db->query("UPDATE {$c} SET solo_consultazione = 1 WHERE id = 2"); + $migrate(); + $check((int) $scalar("SELECT COUNT(*) FROM {$tl}") === 2 && (int) $scalar("SELECT COUNT(*) FROM {$tl} WHERE predefinita = 1") === 1, 'a second run seeds nothing: one default type'); + $check($scalar("SELECT CONCAT(nome, '|', max_prestiti) FROM {$tl} WHERE predefinita = 1") === 'Adulti|4', 'a second run keeps the edited default type'); + $check((int) $scalar("SELECT tipologia_id FROM {$u} WHERE email = 'a@x'") === $studente && (int) $scalar("SELECT solo_consultazione FROM {$c} WHERE id = 2") === 1, 'a second run keeps assignments and copy flags'); + $check((int) $scalar("SELECT COUNT(*) FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = '{$fk}'") === 1, 'a second run adds no second foreign key'); + + // Deleting a type sends its patrons back to the default one (NULL). + $db->query("DELETE FROM {$tl} WHERE id = {$studente}"); + $check($scalar("SELECT IFNULL(tipologia_id, 'NULL') FROM {$u} WHERE email = 'a@x'") === 'NULL', 'deleting a type sets its patrons to NULL (= default type)'); + + $schema = (string) file_get_contents(dirname(__DIR__) . '/installer/database/schema.sql'); + $check(str_contains($schema, 'CREATE TABLE IF NOT EXISTS `tipologie_lettore`') && str_contains($schema, 'CREATE TABLE IF NOT EXISTS `regole_prestito`'), 'fresh install creates both tables'); + $check(str_contains($schema, '`tipologia_id` int DEFAULT NULL') && str_contains($schema, 'CONSTRAINT `fk_utenti_tipologia` FOREIGN KEY (`tipologia_id`) REFERENCES `tipologie_lettore` (`id`) ON DELETE SET NULL'), 'fresh install has utenti.tipologia_id with its foreign key'); + $check(str_contains($schema, "`solo_consultazione` tinyint(1) NOT NULL DEFAULT '0'"), 'fresh install has copie.solo_consultazione'); + $check(str_contains($schema, '`card_expiry_warning_sent_for` date DEFAULT NULL'), 'fresh install has utenti.card_expiry_warning_sent_for'); + foreach (['it_IT', 'en_US', 'de_DE', 'fr_FR', 'da_DK'] as $locale) { + $data = (string) file_get_contents(dirname(__DIR__) . "/installer/database/data_{$locale}.sql"); + $check(str_contains($data, "INSERT INTO `tipologie_lettore` (`codice`, `nome`, `predefinita`, `ordinamento`) VALUES ('standard', 'Standard', 1, 0);"), "fresh {$locale} install seeds the default type"); + // The card_expiring template is seeded like the other templates (row 29), with the shipped text's placeholders. + $check(preg_match("/`email_templates` VALUES \\(29,'card_expiring','{$locale}',[^\\n]*\\{\\{data_scadenza_tessera\\}\\}[^\\n]*\\{\\{giorni_rimasti\\}\\}/", $data) === 1, "fresh {$locale} install seeds the card_expiring email template"); + } + $version = json_decode((string) file_get_contents(dirname(__DIR__) . '/version.json'), true)['version']; + $check(version_compare('0.9.0-rc.1', (string) $version, '<='), 'the release includes the migration'); + echo "SUCCESS {$checks} migration checks\n"; +} catch (Throwable $e) { + echo $e->getMessage() . "\n"; + exit(1); +} finally { + $db->query('SET FOREIGN_KEY_CHECKS = 0'); + foreach ([$u, $c, $rp, $tl] as $table) { + $db->query("DROP TABLE IF EXISTS {$table}"); + } + $db->query('SET FOREIGN_KEY_CHECKS = 1'); +} diff --git a/tests/migration-0.9.0-marc.unit.php b/tests/migration-0.9.0-marc.unit.php index 665685d52..31d151d6b 100644 --- a/tests/migration-0.9.0-marc.unit.php +++ b/tests/migration-0.9.0-marc.unit.php @@ -3,7 +3,9 @@ /** * Run the real MARC section of migrate_0.9.0-rc.1.sql against a scoped copy of the * 0.8.9 import_logs table, twice: import_type gains 'marc', existing rows and - * the other values survive, and a second run changes nothing. + * the other values survive, and a second run changes nothing. A second table + * that another 0.9.0 section already extended ('users') proves the enum is + * built from the current values, so the order of the sections does not matter. */ require dirname(__DIR__) . '/vendor/autoload.php'; $env = Dotenv\Dotenv::parse(file_get_contents(dirname(__DIR__) . '/.env')); @@ -17,10 +19,10 @@ exit(1); } $source = substr($file, $start, $end - $start); -$source = preg_replace('/^--.*$/m', '', $source); -$source = preg_replace('/\bimport_logs\b/', $table, $source); -$run = static function () use ($db, $source): void { - foreach (explode(';', (string) $source) as $sql) { +$section = (string) preg_replace('/^--.*$/m', '', $source); +$run = static function (?string $target = null) use ($db, $section, $table): void { + $scoped = (string) preg_replace('/\bimport_logs\b/', $target ?? $table, $section); + foreach (explode(';', $scoped) as $sql) { if (trim($sql) !== '') { $db->query($sql); } @@ -34,7 +36,8 @@ $checks++; echo "OK {$label}\n"; }; -$type = static fn (): string => (string) $db->query("SELECT COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{$table}' AND COLUMN_NAME = 'import_type'")->fetch_row()[0]; +$type = static fn (?string $t = null): string => (string) $db->query("SELECT COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '" . ($t ?? $table) . "' AND COLUMN_NAME = 'import_type'")->fetch_row()[0]; +$other = $table . '_u'; try { // The 0.8.9 shape of the column the migration touches. $db->query("CREATE TABLE {$table} (id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, import_id VARCHAR(36) NOT NULL, import_type ENUM('csv','librarything') COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Type of import', file_name VARCHAR(255) DEFAULT NULL, KEY idx_import_type (import_type)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); @@ -49,6 +52,15 @@ $check($db->query("SELECT import_type FROM {$table} WHERE import_id='c'")->fetch_row()[0] === 'marc', 'a MARC import can be logged'); $run(); $check($type() === "enum('csv','librarything','marc')" && (int) $db->query("SELECT COUNT(*) FROM {$table}")->fetch_row()[0] === 3, 'a second run is a no-op'); + // Another section ran first and added its own value: marc goes after it + // and the existing value and rows survive. + $db->query("CREATE TABLE {$other} (id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, import_type ENUM('csv','librarything','users') COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Type of import') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); + $db->query("INSERT INTO {$other} (import_type) VALUES ('users'),('csv')"); + $run($other); + $check($type($other) === "enum('csv','librarything','users','marc')", 'a value added by an earlier section is kept, marc is appended'); + $check($db->query("SELECT GROUP_CONCAT(import_type ORDER BY id) FROM {$other}")->fetch_row()[0] === 'users,csv', 'rows of the earlier value keep their type'); + $run($other); + $check($type($other) === "enum('csv','librarything','users','marc')", 'a second run on the extended column is a no-op'); // Other 0.9.0 sections may add their own values after marc. $check(preg_match("/`import_type` enum\\('csv','librarything','marc'[,)]/", (string) file_get_contents(dirname(__DIR__) . '/installer/database/schema.sql')) === 1, 'fresh install has the marc value'); $version = json_decode((string) file_get_contents(dirname(__DIR__) . '/version.json'), true)['version']; @@ -56,4 +68,5 @@ echo "SUCCESS {$checks} migration checks\n"; } finally { $db->query("DROP TABLE IF EXISTS {$table}"); + $db->query("DROP TABLE IF EXISTS {$other}"); } diff --git a/tests/mobile-api-circulation.unit.php b/tests/mobile-api-circulation.unit.php index f2f401c8d..4bf886d81 100644 --- a/tests/mobile-api-circulation.unit.php +++ b/tests/mobile-api-circulation.unit.php @@ -99,6 +99,9 @@ ['CREATE', 'copy_wrong_book', 422, 'validation_error', 'copy_code', null], ['CREATE', 'duplicate_reservation', 409, 'duplicate', null, 'duplicate_reservation'], ['CREATE', 'max_loans_reached', 409, 'max_loans_reached', null, null], + ['CREATE', 'media_limit_reached', 409, 'media_limit_reached', null, null], + ['CREATE', 'not_loanable', 409, 'not_loanable', null, null], + ['UPDATE', 'media_limit_reached', 409, 'media_limit_reached', null, null], ['CREATE', 'libro_in_prestito', 409, 'not_available', null, 'libro_in_prestito'], ['CREATE', 'duplicate_submission', 500, 'internal_error', null, null], ['UPDATE', 'loan_closed', 409, 'loan_closed', null, null], @@ -118,6 +121,9 @@ ['RESERVATION', 'capacity_full', 409, 'not_available', null, 'capacity_full'], ['RESERVATION', 'promoted_loan_active', 409, 'conflict', null, 'promoted_loan_active'], ['RESERVATION', 'not_found', 404, 'not_found', null, null], + ['RESERVATION', 'not_loanable', 409, 'not_loanable', null, null], + ['RESERVATION', 'reservations_disabled', 409, 'reservations_disabled', null, null], + ['RESERVATION', 'reservation_limit_reached', 409, 'reservation_limit_reached', null, null], ]; foreach ($expect as [$table, $code, $status, $apiCode, $field, $reason]) { [$got, $body] = $decode(CirculationOutcome::error(new Response(), constant(CirculationOutcome::class . '::' . $table), $code, 'unit')); diff --git a/tests/patron-cards-import.unit.php b/tests/patron-cards-import.unit.php index 3fa8f5f3d..0ef3b9277 100644 --- a/tests/patron-cards-import.unit.php +++ b/tests/patron-cards-import.unit.php @@ -49,6 +49,13 @@ $check($guess === [0 => 'nome', 1 => 'cognome', 2 => 'email', 3 => 'data_nascita', 4 => 'sesso'], 'German headers are recognised'); $withTypes = UserImport::targetFields([], true); $check(UserImport::guessMapping(['Patron type'], $withTypes) === [0 => 'tipologia'], 'a patron type column is recognised once the schema exists'); +$check(UserImport::guessMapping(['Leserkategorie'], $withTypes) === [0 => 'tipologia'] && UserImport::guessMapping(['Catégorie de lecteur'], $withTypes) === [0 => 'tipologia'] && UserImport::guessMapping(['Lånerkategori'], $withTypes) === [0 => 'tipologia'], 'the translated name column of the users export is recognised'); +// The users export carries the type name and then its code: the code column +// is the one mapped, wherever it stands, since it re-imports exactly. +$check(UserImport::guessMapping(['Tipologia lettore', 'Codice tipologia lettore'], $withTypes) === [0 => '', 1 => 'tipologia'], 'the patron type code column wins over the name column that precedes it'); +$check(UserImport::guessMapping(['Patron type code', 'Patron type'], $withTypes) === [0 => 'tipologia', 1 => ''], 'the patron type code column wins when it comes first too'); +$check(UserImport::guessMapping(['Code der Leserkategorie'], $withTypes) === [0 => 'tipologia'] && UserImport::guessMapping(['Code de la catégorie de lecteur'], $withTypes) === [0 => 'tipologia'] && UserImport::guessMapping(['Lånerkategorikode'], $withTypes) === [0 => 'tipologia'], 'the translated code column of the users export is recognised'); +$check(UserImport::guessMapping(['Codice tipologia lettore'], UserImport::targetFields([], false)) === [0 => ''], 'the code column is not offered while the patron types schema is missing'); $v = UserImport::validateMapping(['0' => 'nome', '2' => 'email', '1' => ''], $fields, 3); $check($v['error'] === null && $v['mapping'] === [0 => 'nome', 2 => 'email'], 'a valid mapping is kept, ignored columns dropped'); @@ -269,6 +276,32 @@ $studentId = (int) $db->query("SELECT id FROM {$types} WHERE codice = 'studente'")->fetch_row()[0]; $check((int) $db->query("SELECT tipologia_id FROM {$users} WHERE id = 1")->fetch_row()[0] === $studentId, 'the reader carries the type id'); $check($present->assign(1, 'docente') === 'unknown' && $present->assign(1, '') === 'skipped', 'an unknown code is reported, an empty one skipped'); + $db->query("ALTER TABLE {$types} ADD COLUMN attiva TINYINT(1) NOT NULL DEFAULT 1"); + $db->query("INSERT INTO {$types} (codice, attiva) VALUES ('ritirata', 0)"); + $check((new PatronTypeImport($db, $types, $users))->assign(1, 'ritirata') === 'unknown' && (int) $db->query("SELECT tipologia_id FROM {$users} WHERE id = 1")->fetch_row()[0] === $studentId, 'a deactivated type is not assigned, the reader keeps its type'); + + // With a name column the value resolves by code first, then by name. + $db->query("ALTER TABLE {$types} ADD COLUMN nome VARCHAR(100) NOT NULL DEFAULT ''"); + $db->query("UPDATE {$types} SET nome = CASE codice WHEN 'standard' THEN 'Standard' WHEN 'studente' THEN 'Studenti' ELSE 'Ritirata' END"); + $standardId = (int) $db->query("SELECT id FROM {$types} WHERE codice = 'standard'")->fetch_row()[0]; + $typeOfReader = static fn (): int => (int) $db->query("SELECT tipologia_id FROM {$users} WHERE id = 1")->fetch_row()[0]; + $byName = new PatronTypeImport($db, $types, $users); + $check($byName->assign(1, 'Standard') === 'assigned' && $typeOfReader() === $standardId, 'the type name is resolved when the code does not match'); + $check($byName->assign(1, ' STUDENTI ') === 'assigned' && $typeOfReader() === $studentId, 'the name is matched trimmed and case-insensitively'); + $check($byName->assign(1, 'standard') === 'assigned' && $typeOfReader() === $standardId && $byName->assign(1, 'STUDENTE') === 'assigned' && $typeOfReader() === $studentId, 'the code keeps resolving on the same instance'); + $check($byName->assign(1, 'Ritirata') === 'unknown' && $typeOfReader() === $studentId, 'the name of a deactivated type does not resolve either'); + $check($byName->assign(1, 'docenti') === 'unknown' && $typeOfReader() === $studentId, 'a value that is neither a code nor a name is still unknown'); + // A type named like another type's code: the code wins. + $db->query("INSERT INTO {$types} (codice, nome) VALUES ('prof', 'Studente')"); + $profId = (int) $db->query("SELECT id FROM {$types} WHERE codice = 'prof'")->fetch_row()[0]; + $codeFirst = new PatronTypeImport($db, $types, $users); + $check($codeFirst->assign(1, 'Studente') === 'assigned' && $typeOfReader() === $studentId, 'a value matching one type by code and another by name goes by the code'); + $check($codeFirst->assign(1, 'prof') === 'assigned' && $typeOfReader() === $profId, 'the type named like a code is still reachable by its own code'); + // Two active types with one name: the import does not guess between them. + $db->query("INSERT INTO {$types} (codice, nome) VALUES ('ext', 'Studenti')"); + $shared = new PatronTypeImport($db, $types, $users); + $check($shared->assign(1, 'Studenti') === 'unknown' && $typeOfReader() === $profId, 'a name shared by two active types resolves neither'); + $check($shared->assign(1, 'ext') === 'assigned' && $typeOfReader() !== $studentId && $typeOfReader() !== $profId, 'each of them is still reachable by code'); // Patron photo against one scratch reader. $email = 'zz-pci-' . $suffix . '@example.invalid'; diff --git a/tests/statistics-090.spec.js b/tests/statistics-090.spec.js index 4d8d263ca..6acc0c07c 100644 --- a/tests/statistics-090.spec.js +++ b/tests/statistics-090.spec.js @@ -1,7 +1,8 @@ // @ts-check // /admin/statistics in 0.9.0: loans by material, visitors from the visitor -// log, the outcomes of the last closed stocktake, and the extension -// sections (patron types, fines) hidden until another feature provides them. +// log, the outcomes of the last closed stocktake, loans by patron type +// (readers without a type count under the default type), and the fines +// section hidden until the fines feature provides it. const { test, expect } = require('@playwright/test'); const h = require('./helpers/minor-features-090'); @@ -25,7 +26,7 @@ test.describe.serial('Statistics 0.9.0 sections', () => { const shownCount = (media) => page.locator(`#stats-loans-by-media tr[data-media="${media}"] [data-count]`); test.beforeAll(async ({ browser }) => { - h.dbQuery(`INSERT INTO utenti (codice_tessera, nome, cognome, email, password, stato, tipo_utente) VALUES (${h.sqlString(CARD)}, 'Lia', 'Statistica', ${h.sqlString(`stats-${STAMP}@example.test`)}, 'x', 'attivo', 'standard')`); + h.dbQuery(`INSERT INTO utenti (codice_tessera, nome, cognome, email, password, telefono, indirizzo, stato, tipo_utente) VALUES (${h.sqlString(CARD)}, 'Lia', 'Statistica', ${h.sqlString(`stats-${STAMP}@example.test`)}, 'x', '0000000', 'Via E2E 1', 'attivo', 'standard')`); userId = parseInt(h.dbQuery(`SELECT id FROM utenti WHERE codice_tessera = ${h.sqlString(CARD)}`), 10); dvdId = h.seedBook({ titolo: DVD_TITLE, tipo_media: 'dvd' }); context = await browser.newContext(); @@ -87,15 +88,70 @@ test.describe.serial('Statistics 0.9.0 sections', () => { await expect(page).toHaveURL(/\/admin\/attendance$/); }); - test('5. Patron-type and fines sections stay hidden until a feature provides them', async () => { + test('5. The fines section stays hidden until the fines feature provides it', async () => { await page.goto(`${h.BASE}/admin/statistics`); await expect(page.locator('#stats-loans-by-media')).toBeVisible(); - for (const id of ['patron-types', 'fines']) { - await expect(page.locator(`#stats-${id}`)).toHaveCount(0); + await expect(page.locator('#stats-fines')).toHaveCount(0); + await expect(page.locator('#stats-patron-types')).toBeVisible(); + }); + + test('6. Loans by patron type: a typed reader counts under the type, an untyped one under the default type', async () => { + const typeCode = `e2est${STAMP}`.slice(0, 40); + const typeName = `E2E Tipologia statistiche ${STAMP}`; + const typeCount = (sqlTypeCond) => parseInt(h.dbQuery( + `SELECT COUNT(*) FROM prestiti p JOIN libri l ON l.id = p.libro_id AND l.deleted_at IS NULL + LEFT JOIN utenti u ON u.id = p.utente_id + WHERE p.stato IN ('in_corso','in_ritardo','restituito','perso','danneggiato') + AND p.data_prestito >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH) + AND ${sqlTypeCond}`), 10); + const defaultId = parseInt(h.dbQuery(`SELECT id FROM tipologie_lettore WHERE predefinita = 1 ORDER BY id LIMIT 1`), 10); + const defaultName = h.dbQuery(`SELECT nome FROM tipologie_lettore WHERE id = ${defaultId}`); + const defaultCount = () => typeCount(`COALESCE(u.tipologia_id, ${defaultId}) = ${defaultId}`); + h.dbQuery(`INSERT INTO tipologie_lettore (codice, nome, attiva, ordinamento) VALUES (${h.sqlString(typeCode)}, ${h.sqlString(typeName)}, 1, 95)`); + const typeId = parseInt(h.dbQuery(`SELECT id FROM tipologie_lettore WHERE codice = ${h.sqlString(typeCode)}`), 10); + const section = page.locator('#stats-patron-types'); + const value = (label) => section.locator('tr', { has: page.locator('td', { hasText: new RegExp(`^${label}$`) }) }).locator('td').nth(1); + try { + // A new active type with no loans is listed with 0. + await page.goto(`${h.BASE}/admin/statistics`); + await expect(section.getByRole('heading', { name: 'Prestiti per tipologia di lettore' })).toBeVisible(); + await expect(value(typeName)).toHaveText('0'); + await expect(value(defaultName)).toHaveText(String(defaultCount())); + await expect(section).toContainText(`I lettori senza tipologia sono contati in «${defaultName}»`); + + // The reader of the DVD loan (test 2) has no type: the loan is under the default type. + expect(h.dbQuery(`SELECT COALESCE(tipologia_id, 'NULL') FROM utenti WHERE id = ${userId}`)).toBe('NULL'); + const defaultBefore = defaultCount(); + + // The librarian gives the reader the new type from the edit form. + await page.goto(`${h.BASE}/admin/users/edit/${userId}`); + await page.locator('select[name="tipologia_id"]').selectOption(String(typeId)); + await page.locator('#user-form button[type="submit"]').click(); + await page.waitForURL((u) => !u.pathname.includes('/edit/'), { timeout: 20000 }); + expect(page.url()).not.toContain('error='); + expect(h.dbQuery(`SELECT tipologia_id FROM utenti WHERE id = ${userId}`)).toBe(String(typeId)); + + await page.goto(`${h.BASE}/admin/statistics`); + const typed = typeCount(`u.tipologia_id = ${typeId}`); + expect(typed).toBeGreaterThan(0); + await expect(value(typeName)).toHaveText(String(typed)); + await expect(value(defaultName)).toHaveText(String(defaultBefore - typed)); + + // A deactivated type with loans is still listed; without loans it disappears. + h.dbQuery(`UPDATE tipologie_lettore SET attiva = 0 WHERE id = ${typeId}`); + await page.goto(`${h.BASE}/admin/statistics`); + await expect(value(typeName)).toHaveText(String(typed)); + h.dbQuery(`UPDATE utenti SET tipologia_id = NULL WHERE id = ${userId}`); + await page.goto(`${h.BASE}/admin/statistics`); + await expect(section.locator('td', { hasText: new RegExp(`^${typeName}$`) })).toHaveCount(0); + await expect(value(defaultName)).toHaveText(String(defaultBefore)); + } finally { + h.dbQuery(`UPDATE utenti SET tipologia_id = NULL WHERE id = ${userId}`); + h.dbQuery(`DELETE FROM tipologie_lettore WHERE id = ${typeId}`); } }); - test('6. The last closed stocktake shows its outcomes', async () => { + test('7. The last closed stocktake shows its outcomes', async () => { const name = `E2E Revisione statistiche ${STAMP}`; const bookId = h.seedBook({ titolo: `E2E Statistiche revisione ${STAMP}` }); const copies = ['A', 'B', 'C'].map((suffix) => { diff --git a/tests/user-import.spec.js b/tests/user-import.spec.js index 648b748e7..124d05ca0 100644 --- a/tests/user-import.spec.js +++ b/tests/user-import.spec.js @@ -3,7 +3,9 @@ // User import from CSV (plan phase 7). A librarian uploads 20 rows — 17 new // readers, 2 duplicates (one email and one card code already in use) and 1 bad // row — checks the preview, fixes the column mapping by hand, runs the import -// with invitations, and downloads the errors. Invitations are checked in +// with invitations, and downloads the errors. The "Tipologia" column carries a +// patron type code (in either case, one unknown, one empty) and is assigned by +// code through PatronTypeImport. Invitations are checked in // Mailpit, filtered by this run's recipient domain (the inbox is shared). const { test, expect } = require('@playwright/test'); const { execFileSync } = require('child_process'); @@ -11,6 +13,7 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const { serverFileExists, listServerDir, writeServerFile } = require('./helpers/server-files'); +const { appDateOffsetISO } = require('./helpers/app-date'); const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081'; const ROOT = path.resolve(__dirname, '..'); @@ -61,19 +64,31 @@ const CUSTOM_LABEL = `Social${RUN}`; const EXISTING_EMAIL = `existing.${RUN}@${DOMAIN}`; const EXISTING_CODE = `ZEX${RUN_UP}`; const SECRET = 'Secret123!'; +const TYPE_CODE = `st${RUN}`; +const TYPE_NAME = `Studenti ${RUN}`; +const TYPE_CARD_DAYS = 200; +const UNKNOWN_TYPE = `nessuna${RUN}`; +const MANUAL_EXPIRY = '2031-12-31'; -/** 20 data rows: 17 valid, a duplicate email (line 6), a duplicate card code (line 13), a bad email (line 17). */ +/** + * 20 data rows: 17 valid, a duplicate email (line 6), a duplicate card code (line 13), a bad email (line 17). + * Patron type: readers 1-15 carry the type, as its name (every third one, TYPE_NAME) or as TYPE_CODE + * (upper case for the even ones, lower case otherwise); 16 none, 17 (line 21) an unknown value. + * Card expiry: only reader 3 carries one (MANUAL_EXPIRY); every other row leaves the column empty. + */ function buildCsv() { - const lines = [['Nome', 'Cognome', 'E-mail', 'Tel. cellulare', 'Data di nascita', 'Tessera', 'Password', CUSTOM_LABEL, 'Tipologia'].join(';')]; + const lines = [['Nome', 'Cognome', 'E-mail', 'Tel. cellulare', 'Data di nascita', 'Tessera', 'Password', CUSTOM_LABEL, 'Tipologia', 'Scadenza tessera'].join(';')]; let n = 0; for (let row = 1; row <= 20; row++) { const line = row + 1; - if (line === 6) { lines.push(['Doppia', 'Email', EXISTING_EMAIL, '', '', '', SECRET, '', ''].join(';')); continue; } - if (line === 13) { lines.push(['Doppio', 'Codice', `dupcode.${RUN}@${DOMAIN}`, '', '', EXISTING_CODE, SECRET, '', ''].join(';')); continue; } - if (line === 17) { lines.push(['Sbagliata', 'Riga', 'not-an-email', '', '', '', SECRET, '', ''].join(';')); continue; } + if (line === 6) { lines.push(['Doppia', 'Email', EXISTING_EMAIL, '', '', '', SECRET, '', '', ''].join(';')); continue; } + if (line === 13) { lines.push(['Doppio', 'Codice', `dupcode.${RUN}@${DOMAIN}`, '', '', EXISTING_CODE, SECRET, '', '', ''].join(';')); continue; } + if (line === 17) { lines.push(['Sbagliata', 'Riga', 'not-an-email', '', '', '', SECRET, '', '', ''].join(';')); continue; } n++; const code = n % 2 === 0 ? `ZIM${RUN_UP}${n}` : ''; - lines.push([`Imp${n}`, `Run${RUN}`, `imp${n}.${RUN}@${DOMAIN}`, `333000${String(n).padStart(4, '0')}`, `${String(n).padStart(2, '0')}/03/1990`, code, SECRET, `@imp${n}`, 'studente'].join(';')); + const type = n === 17 ? UNKNOWN_TYPE : n === 16 ? '' : n % 3 === 0 ? TYPE_NAME : (n % 2 === 0 ? TYPE_CODE.toUpperCase() : TYPE_CODE); + const expiry = n === 3 ? MANUAL_EXPIRY : ''; + lines.push([`Imp${n}`, `Run${RUN}`, `imp${n}.${RUN}@${DOMAIN}`, `333000${String(n).padStart(4, '0')}`, `${String(n).padStart(2, '0')}/03/1990`, code, SECRET, `@imp${n}`, type, expiry].join(';')); } return lines.join('\n') + '\n'; } @@ -85,6 +100,7 @@ test.describe.serial('User import from CSV (phase 7)', () => { let context; let customId = 0; let existingId = 0; + let typeId = 0; let mailpit = false; /** @type {Record} */ const savedEmail = {}; @@ -98,6 +114,9 @@ test.describe.serial('User import from CSV (phase 7)', () => { dbQuery(`INSERT INTO utenti (nome, cognome, email, password, codice_tessera, stato, tipo_utente, email_verificata) VALUES ('Gia', 'Presente', ${sqlStr(EXISTING_EMAIL)}, 'x', ${sqlStr(EXISTING_CODE)}, 'attivo', 'standard', 1)`); existingId = Number(dbQuery(`SELECT id FROM utenti WHERE email = ${sqlStr(EXISTING_EMAIL)}`)); + // The type carries a card duration: imported readers of this type get today + TYPE_CARD_DAYS. + dbQuery(`INSERT INTO tipologie_lettore (codice, nome, attiva, ordinamento, durata_tessera_giorni) VALUES (${sqlStr(TYPE_CODE)}, ${sqlStr(TYPE_NAME)}, 1, 90, ${TYPE_CARD_DAYS})`); + typeId = Number(dbQuery(`SELECT id FROM tipologie_lettore WHERE codice = ${sqlStr(TYPE_CODE)}`)); // Route the application mail to Mailpit for this suite, restored afterwards. for (const k of ['type', 'driver_mode', 'smtp_host', 'smtp_port', 'smtp_security', 'smtp_username', 'smtp_password']) { @@ -132,6 +151,7 @@ test.describe.serial('User import from CSV (phase 7)', () => { dbQuery(`DELETE FROM utenti_campi_valori WHERE campo_id = ${customId}`); dbQuery(`DELETE FROM registrazione_campi WHERE id = ${customId}`); dbQuery(`DELETE FROM utenti WHERE email LIKE ${sqlStr(`%@${DOMAIN}`)}`); + if (typeId) dbQuery(`DELETE FROM tipologie_lettore WHERE id = ${typeId}`); dbQuery(`DELETE FROM import_logs WHERE file_name = ${sqlStr(path.basename(csvFile))}`); for (const [k, v] of Object.entries(savedEmail)) { if (v === '__absent__') dbQuery(`DELETE FROM system_settings WHERE category='email' AND setting_key=${sqlStr(k)}`); @@ -174,9 +194,10 @@ test.describe.serial('User import from CSV (phase 7)', () => { await expect(select(6)).toHaveValue(''); // Password: never mapped await expect(select(6).locator('option[value="password"]')).toHaveCount(0); await expect(select(7)).toHaveValue(`custom:${customId}`); // custom registration field - // Patron types do not exist on this installation: the column is not offered. - await expect(select(8)).toHaveValue(''); - await expect(select(8).locator('option[value="tipologia"]')).toHaveCount(0); + // Patron types exist: the "Tipologia" header is recognised as the patron type (code or name). + await expect(select(8).locator('option[value="tipologia"]')).toHaveCount(1); + await expect(select(8)).toHaveValue('tipologia'); + await expect(select(9)).toHaveValue('data_scadenza_tessera'); // Without an email column the import refuses to start. await select(2).selectOption(''); @@ -203,6 +224,11 @@ test.describe.serial('User import from CSV (phase 7)', () => { await expect(page.locator('#user-import-errors tr[data-import-error="duplicate"]')).toHaveCount(2); await expect(page.locator('#user-import-errors tr[data-import-error="validation"]')).toHaveCount(1); await expect(page.locator('#user-import-errors tr[data-import-error="validation"]')).toContainText('17'); + // The unknown patron type (neither a code nor a name) is a warning: the reader is created with the default type. + const warning = page.locator('#user-import-errors tr[data-import-error="warning"]'); + await expect(warning).toHaveCount(1); + await expect(warning).toContainText('21'); + await expect(warning).toContainText(UNKNOWN_TYPE); // Database: 17 new readers, the existing one untouched, no duplicate rows. expect(dbQuery(`SELECT COUNT(*) FROM utenti WHERE email LIKE ${sqlStr(`imp%.${RUN}@${DOMAIN}`)}`)).toBe('17'); @@ -245,14 +271,49 @@ test.describe.serial('User import from CSV (phase 7)', () => { const file = await download.path(); const csv = fs.readFileSync(String(file), 'utf-8'); const rows = csv.trim().split(/\r?\n/).slice(1); - expect(rows.length).toBe(3); + expect(rows.length).toBe(4); expect(rows.find((r) => r.startsWith('6,'))).toContain('duplicate'); expect(rows.find((r) => r.startsWith('13,'))).toContain('duplicate'); expect(rows.find((r) => r.startsWith('17,'))).toContain('validation'); + expect(rows.find((r) => r.startsWith('21,'))).toContain(UNKNOWN_TYPE); expect(csv).toContain(EXISTING_EMAIL); expect(csv).toContain(EXISTING_CODE); }); + test('the patron type column assigns the type by code or by name, case-insensitively; empty and unknown values keep the default', async () => { + // Readers 1-15: the code in lower (odd) and upper (even) case, and the name (every third), all resolve to the type. + expect(dbQuery(`SELECT COUNT(*) FROM utenti WHERE email LIKE ${sqlStr(`imp%.${RUN}@${DOMAIN}`)} AND tipologia_id = ${typeId}`)).toBe('15'); + for (const n of [3, 6, 15]) { + expect(dbQuery(`SELECT COALESCE(tipologia_id, 'NULL') FROM utenti WHERE email = ${sqlStr(`imp${n}.${RUN}@${DOMAIN}`)}`), `reader ${n} by name`).toBe(String(typeId)); + } + expect(dbQuery(`SELECT COALESCE(tipologia_id, 'NULL') FROM utenti WHERE email = ${sqlStr(`imp16.${RUN}@${DOMAIN}`)}`)).toBe('NULL'); + expect(dbQuery(`SELECT COALESCE(tipologia_id, 'NULL') FROM utenti WHERE email = ${sqlStr(`imp17.${RUN}@${DOMAIN}`)}`)).toBe('NULL'); + expect(dbQuery(`SELECT COALESCE(tipologia_id, 'NULL') FROM utenti WHERE id = ${existingId}`)).toBe('NULL'); + + // The librarian sees it on the reader page: the imported type, or the default one. + const typed = dbQuery(`SELECT id FROM utenti WHERE email = ${sqlStr(`imp4.${RUN}@${DOMAIN}`)}`); + await page.goto(`${BASE}/admin/users/details/${typed}`); + await expect(page.getByTestId('patron-type-name')).toContainText(TYPE_NAME); + await expect(page.getByTestId('patron-type-name')).not.toContainText('predefinita'); + const unknown = dbQuery(`SELECT id FROM utenti WHERE email = ${sqlStr(`imp17.${RUN}@${DOMAIN}`)}`); + await page.goto(`${BASE}/admin/users/details/${unknown}`); + await expect(page.getByTestId('patron-type-name')).toContainText('predefinita'); + }); + + test('the card expiry follows the patron type duration unless the CSV gives one', async () => { + // Readers of the 200-day type without an expiry in the file: today + 200. + const typedExpiry = appDateOffsetISO(TYPE_CARD_DAYS); + expect(dbQuery(`SELECT COUNT(*) FROM utenti WHERE email LIKE ${sqlStr(`imp%.${RUN}@${DOMAIN}`)} AND tipologia_id = ${typeId} AND data_scadenza_tessera = ${sqlStr(typedExpiry)}`)).toBe('14'); + // The explicit date in the CSV wins over the type duration. + expect(dbQuery(`SELECT IFNULL(data_scadenza_tessera,'NULL') FROM utenti WHERE email = ${sqlStr(`imp3.${RUN}@${DOMAIN}`)}`)).toBe(MANUAL_EXPIRY); + // Readers on the default type (no code, unknown code) get its duration, or no expiry when it has none. + const defaultDays = dbQuery('SELECT IFNULL(durata_tessera_giorni, \'\') FROM tipologie_lettore WHERE predefinita = 1 LIMIT 1'); + const expected = defaultDays === '' ? 'NULL' : appDateOffsetISO(Number(defaultDays)); + for (const n of [16, 17]) { + expect(dbQuery(`SELECT IFNULL(data_scadenza_tessera,'NULL') FROM utenti WHERE email = ${sqlStr(`imp${n}.${RUN}@${DOMAIN}`)}`), `reader ${n}`).toBe(expected); + } + }); + test('the run is in the import history as a users import', async () => { const importId = dbQuery(`SELECT import_id FROM import_logs WHERE file_name = ${sqlStr(path.basename(csvFile))} ORDER BY id DESC LIMIT 1`); expect(importId).not.toBe('');