Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
df59c6a
Read every circulation rule through CirculationPolicy
fabiodalez-dev Oct 28, 2026
a53a1f5
Add patron types and the patron type x material rules matrix
fabiodalez-dev Oct 28, 2026
250fe04
Add reservation limits, automatic card duration and card renewal
fabiodalez-dev Oct 28, 2026
989e53e
MARC migration: build the import_type enum from the current values
fabiodalez-dev Oct 28, 2026
a278ae1
User import assigns the patron type by code
fabiodalez-dev Oct 28, 2026
c5c96ee
Statistics: loans of the last 12 months by patron type
fabiodalez-dev Oct 28, 2026
31c09e9
Rules matrix: days per renewal; the renew button says the real length
fabiodalez-dev Oct 28, 2026
61fe2b0
Changelog and README: circulation policy on the 0.9.0 stack
fabiodalez-dev Oct 28, 2026
80f90ad
Queue promotion skips patrons the per-material rules refuse; no deact…
fabiodalez-dev Oct 28, 2026
65f13b7
NCIP requests capped by the request window; no per-row copy query in …
fabiodalez-dev Oct 28, 2026
157eda8
Mark the two archived-title reads of CirculationPolicy for the soft-d…
fabiodalez-dev Oct 28, 2026
9c788b8
Circulation settings are reserved to administrators
fabiodalez-dev Oct 28, 2026
e589fa3
Availability counter excludes the consultation-only copies
fabiodalez-dev Oct 28, 2026
2fdcf9e
User import applies the card duration of the patron type
fabiodalez-dev Oct 28, 2026
57435c3
Create-user form: a typed card expiry wins over the automatic one
fabiodalez-dev Oct 28, 2026
5802159
Settings page: no circulation section for staff
fabiodalez-dev Oct 28, 2026
3505f88
Equivalence test documents the NCIP RequestItem due date cap
fabiodalez-dev Oct 28, 2026
1d07226
Seed the card_expiring email template on fresh installs
fabiodalez-dev Oct 28, 2026
b427ad3
Full maintenance reports the loan recalls and card expiry warnings
fabiodalez-dev Oct 28, 2026
b638882
Merge feat/patron-cards-import into feat/circulation-policy
fabiodalez-dev Oct 28, 2026
62d5db7
Merge feat/patron-cards-import into feat/circulation-policy
fabiodalez-dev Oct 28, 2026
3b10621
Circulation views: plain htmlspecialchars, full JSON_HEX flags
fabiodalez-dev Oct 28, 2026
dbc3870
Create-user form: the suggested 5-year expiry no longer blocks the pa…
fabiodalez-dev Oct 28, 2026
cbb4277
User import resolves the patron type by code or name; the export adds…
fabiodalez-dev Oct 28, 2026
e42c235
Merge feat/patron-cards-import into feat/circulation-policy
fabiodalez-dev Oct 28, 2026
d4959af
Changelog: drop the stale patron-type sentence from the user import b…
fabiodalez-dev Oct 28, 2026
52bab8e
Merge origin/feat/circulation-policy into feat/circulation-policy
fabiodalez-dev Oct 28, 2026
c41ad65
E2E: the consultation-only check signs the previous reader out first
fabiodalez-dev Oct 28, 2026
65c0464
Merge remote-tracking branch 'origin/feat/circulation-policy' into fe…
fabiodalez-dev Oct 28, 2026
ad87f53
Merge remote-tracking branch 'origin/feat/patron-cards-import' into HEAD
fabiodalez-dev Oct 28, 2026
06520da
Merge remote-tracking branch 'origin/feat/patron-cards-import' into HEAD
fabiodalez-dev Oct 28, 2026
3fc5ec3
Users export: one CirculationPolicy for the whole file, not two type …
fabiodalez-dev Oct 28, 2026
35ca0b0
Merge branch 'feat/patron-cards-import' into feat/circulation-policy
fabiodalez-dev Oct 28, 2026
6a14766
Loans list explains the two new refusals; the upgrade recalc sees the…
fabiodalez-dev Oct 29, 2026
f51fcb7
Merge branch 'feat/patron-cards-import' into feat/circulation-policy
fabiodalez-dev Oct 29, 2026
d219798
Updater: call resetSchemaCache() directly
fabiodalez-dev Oct 30, 2026
04a7c60
Merge branch 'feat/patron-cards-import' into feat/circulation-policy
fabiodalez-dev Oct 30, 2026
34dbfa3
Merge branch 'feat/patron-cards-import' into feat/circulation-policy
fabiodalez-dev Oct 30, 2026
2f856c3
Merge branch 'feat/patron-cards-import' into feat/circulation-policy
fabiodalez-dev Oct 30, 2026
7b863ec
Merge branch 'feat/patron-cards-import' into feat/circulation-policy
fabiodalez-dev Oct 31, 2026
dd6c348
Merge branch 'feat/patron-cards-import' into feat/circulation-policy
fabiodalez-dev Oct 31, 2026
7977fa4
Merge branch 'feat/patron-cards-import' into feat/circulation-policy
fabiodalez-dev Oct 31, 2026
12d9942
CHANGELOG: one migration bullet each for stocktake and MARC
fabiodalez-dev Oct 31, 2026
ff79205
Merge branch 'feat/patron-cards-import' into feat/circulation-policy
fabiodalez-dev Oct 31, 2026
20e9965
Merge branch 'feat/patron-cards-import' into feat/circulation-policy
fabiodalez-dev Oct 31, 2026
fae37f2
Merge branch 'feat/patron-cards-import' into feat/circulation-policy
fabiodalez-dev Oct 31, 2026
1bee653
Merge branch 'feat/patron-cards-import' into feat/circulation-policy
fabiodalez-dev Oct 31, 2026
0a94bad
Merge branch 'feat/patron-cards-import' into feat/circulation-policy
fabiodalez-dev Oct 31, 2026
2c088df
Merge branch 'feat/patron-cards-import' into feat/circulation-policy
fabiodalez-dev Oct 31, 2026
be3aeee
Merge branch 'feat/patron-cards-import' into feat/circulation-policy
fabiodalez-dev Oct 31, 2026
f3c6e5e
Merge branch 'feat/patron-cards-import' into feat/circulation-policy
fabiodalez-dev Nov 1, 2026
57e931f
Merge branch 'feat/patron-cards-import' into feat/circulation-policy
fabiodalez-dev Nov 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/ci-quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
18 changes: 15 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading