diff --git a/.github/workflows/ci-deep-regression.yml b/.github/workflows/ci-deep-regression.yml
index 896da77d3..d86b4c43c 100644
--- a/.github/workflows/ci-deep-regression.yml
+++ b/.github/workflows/ci-deep-regression.yml
@@ -73,7 +73,8 @@ jobs:
sudo apt-get update -q
# php-apcu: the app's page cache must run on its production backend
# (APCu shared memory) under Apache/mod_php, not the file fallback.
- sudo apt-get install -y apache2 libapache2-mod-php php-apcu default-mysql-client msmtp-mta
+ # PDF layout assertions need Poppler; without it they silently skip.
+ sudo apt-get install -y apache2 libapache2-mod-php php-apcu default-mysql-client msmtp-mta poppler-utils
sudo a2enmod rewrite headers env
php_module="$(find /etc/apache2/mods-available -maxdepth 1 -name 'php*.load' -printf '%f\n' | sed 's/\.load$//' | sort -V | tail -n1)"
[ -z "${php_module}" ] || sudo a2enmod "${php_module}"
@@ -232,6 +233,10 @@ jobs:
RESULT_FILE: test-results/shard-${{ matrix.shard }}.json
run: node scripts/ci-audit-playwright-results.js "$RESULT_FILE"
+ - name: Reject PHP fatal errors in the server log
+ if: always() && steps.deep-regression.outcome != 'skipped'
+ run: bash scripts/ci-check-php-errors.sh /var/log/apache2/pinakes-error.log
+
- name: Upload browser evidence
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
if: always()
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2a1afb419..3c039197a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,38 @@
Full version-by-version history for Pinakes. The README shows only the last three releases; everything older lives here.
+## [0.9.0-rc.3]
+
+Release candidate: published as a GitHub prerelease, offered only to installations that opt in with `UPDATER_CHANNEL=rc` or `UPDATER_ALLOW_PRERELEASE=1`. No migration and no schema change.
+
+### Loans follow closures added later
+
+- **Existing due dates move.** Saving a closure, a weekly closed day or a holiday in Admin → Calendar now moves every active loan (out, waiting for pickup or booked ahead) whose due date, today or later, falls on a day that is now closed for the copy's branch to the next open day, as a loan made after the change would get; pickup deadlines follow. Each move is recorded in the loan history and re-arms the due-soon reminder. A move the book cannot take (the next reservation of that copy starts on the new date, or the added days are promised to another reservation) is left out and the loan keeps its date. A loan already marked late whose due date is now closed goes back to in progress. When a move is blocked by a lock (the book row held by another transaction) the calendar page warns that the moves are incomplete and the next save or the scheduled maintenance completes them; nothing is half-written.
+- **Overdue counts start from the first open day.** A loan due on a day that was closed afterwards is not late until the first open day after it: the overdue sweep, the overdue e-mail, the mobile overdue push and the fine all follow `LibraryCalendar::isPastDue()`. Notices and recalls state the days late in open days, as the fine counts them, and never say "0 days" for a loan that is late.
+- **Renewals, reader alerts and native clients agree with the calendar.** A reader can renew on the first usable return day after a retroactive closure, and the renewal counts from that day, as it would had the closure been saved before the due date. A return on that day is not recorded as late. Web lists, reader alerts, the mobile notification feed and the due-soon push use that same deadline. Reader and staff loan responses expose `effective_due_at` and `is_overdue`, while `due_at` retains the stored date for history and editing. Calendar saves also re-read capacity under the book lock, including reservations confirmed by another operator after the initial scan.
+- **Recalls and pushes re-read the loan under the lock.** A manual recall or an overdue push claimed on stale data (a loan renewed or returned in the meantime) no longer goes out; the librarian is asked to reload the page, or told that another recall for the same loan has just gone out. A failed release of the calendar lock is retried at once, so the next batch does not wait for the session to end.
+- **NCIP, reminder e-mails and exported calendars use the usable return day too.** NCIP renewals count from the same first open day as the desk, including when the old late flag has not been reconciled yet. Due-soon e-mails use that day for both the warning window and the message, checked again under the calendar lock. The library feed, the reader's personal feed and the per-loan calendar file keep an outstanding loan through that day and use its actual overdue status. The staff dashboard's "due today" count uses the same day, including dates whose move was blocked by capacity.
+- **The remaining e-mails and staff pages use the usable return day too.** The overdue, recall, fine, receipt (with its PDF), approval, pickup and staff alert e-mails name the first open day after a closed due date, beside the open days counted from it. The admin dashboard, the loan desk, the staff tiles and lists, the reader's home page and the book page show that same day and mark a loan late only once it has gone by; a loan due today is no longer red on the book page, and a cancelled one no longer reads as overdue in its history. Saving a loan without changing its date no longer moves it or resets its reminders, and a renewal of a loan that is not yet late clears an old late flag.
+- **Book availability follows the usable return day, by the library's choice.** A loan due on a closed day holds its book through the first open day after it, the day the reader is told, even when the move was refused and the row kept its date. Settings → Loans gains "Same-day handover after a closure": when on, another reader can start on that open day itself. It is off by default. A request's start day now also follows the branch of the book's copies when they all sit in one.
+- **Concurrent circulation changes keep their commitments.** A calendar save re-reads the current copy and branch after taking the book lock. Reservations made eligible by a return under a per-material limit, or by a payment settling a debt, remain counted when the calendar checks whether a due date can move.
+- **Patron types and accounts save consistently.** Changing the default type, editing it and deleting another type decide from locked rows, so a concurrent default change cannot erase the type's assignments or rules. Creating or editing a reader saves the account and its patron type together: a type removed during the request leaves neither a half-created account nor partially saved account fields.
+
+### Large holdings
+
+- **Book details fit within the default PHP memory limit.** A title with 9,999 physical copies can be opened without exhausting the 128 MiB limit. Rendering uses temporary streams instead of keeping several full HTML documents in memory. The creation regression now checks the complete detail response, and the deep CI gate rejects PHP fatal errors even when browser assertions pass.
+
+### The 0.9.0 admin on a phone
+
+- **Circulation settings.** The patron types table and the patron type × material rules matrix stack as cards below the `sm` breakpoint, with the same inputs; on desktop their labels no longer break mid-word.
+- **Patron photo.** On a phone or tablet "Scatta foto" opens the device's own camera app (either lens); the shot is cropped to the card's 4:5 format and scaled to 480 × 600 with the EXIF orientation honoured. Desktops keep the inline webcam.
+- **Visitor log.** The card field has the self-hosted camera scanner of the loan desk (Code128 and QR): a card read by the camera records the entry at once, a typed code still waits for "Registra ingresso".
+- **Every 0.9.0 page checked at 390 px.** Attendance, closures calendar, stocktake, MARC import, suppliers, patron import, the new settings tabs, the reader record with its ledger and the return with a fine: nothing scrolls sideways, no field is small enough to make iOS zoom, wide tables read as cards with each value under its column name (as real text, for screen readers), the calendar's month arrows sit side by side and a notification count over 99 shows as "99+".
+
+### Older admin pages on a phone
+
+- **Book list, book page and book form.** The row actions stay inside the book list at full size and titles are no longer clamped; the book page's reservations, copies, volumes and loan history stack as cards; the change log wraps long values; the TinyMCE toolbar wraps on several rows; the chip remove cross is readable in both themes; a cover whose file is gone shows no empty frame and keeps "Rimuovi".
+- **Long values wrap.** Write-permission paths in Admin → Updates, catalogue URLs in copy cataloguing and the linked records of an archival authority no longer run past the screen edge.
+
## [0.9.0-rc.2]
Release candidate: published as a GitHub prerelease, offered only to installations that opt in with `UPDATER_CHANNEL=rc` or `UPDATER_ALLOW_PRERELEASE=1`. No migration: the only schema change is in the Mobile API plugin, which widens `mobile_import_jobs.kind` from an ENUM to `VARCHAR(32)` in its own `ensureSchema()` when it upgrades to 1.10.0.
diff --git a/README.md b/README.md
index 3ed144879..f5b0d8f8b 100644
--- a/README.md
+++ b/README.md
@@ -25,9 +25,9 @@ Pinakes is a self-hosted, full-featured ILS for schools, municipalities, and pri
[](https://fabiodalez-dev.github.io/Pinakes/)
[](https://buymeacoffee.com/fabiodalez)
-## What's New in v0.9.0-rc.2
+## What's New in v0.9.0-rc.3
-**Release candidate. Everything the 0.9.0 admin does, ready for the Android app.** The Mobile API plugin (1.10.0) opens every new 0.9.0 tool to the staff app: stocktake with the phone as scanner, the patron ledger at the desk, library settings, patron types, the rules matrix and the closures calendar, suppliers, catalogue cards, MARC import and export, copy cataloguing, the visitor log, statistics and patron import from CSV. Each area follows its module switch and goes through the same controllers as the website; the Android app gains the matching screens in its next release. No migration. Full details in **[CHANGELOG.md](CHANGELOG.md)**.
+**Release candidate. Loans follow closures added later, and the admin works on a phone.** Saving a closure, a weekly closed day or a holiday now moves the active loans that fall on a newly closed day to the next open day, with their pickup deadlines, history and due-soon reminder; a loan due on a day closed afterwards is not late until the first open day after it, and the overdue sweep, e-mail, push and fine all count open days only. Every 0.9.0 page and the older book list, book page, book form, updates and archival pages were checked at 390 px: nothing scrolls sideways, wide tables read as cards and long values wrap. No migration. Full details in **[CHANGELOG.md](CHANGELOG.md)**.
---
@@ -53,13 +53,13 @@ The showcase contains 50 catalogue books, 8 desiderata, 3 periodicals with 10 is
Highlights of the two releases before the current one are below; the README never lists more than the last three versions. The full version-by-version history lives in **[CHANGELOG.md](CHANGELOG.md)**.
-### v0.9.0-rc.1
+### v0.9.0-rc.2
-**Stocktake, MARC in and out, and the circulation tools of a full library system.** Inventory stocktake sessions with barcode, camera or list scanning and a live report; MARC 21, UNIMARC and danMARC2 bulk import and export with copy cataloguing over SRU; a visitor log, ISBD catalogue cards, suppliers and RDA types; printable library cards, patron photos and user import from CSV; patron types with a rules matrix, a closures calendar and optional fines with a patron ledger. Every module is optional and starts switched off on an upgrade. One additive migration (`migrate_0.9.0-rc.1.sql`). Full details in **[CHANGELOG.md](CHANGELOG.md)**.
+**Everything the 0.9.0 admin does, ready for the Android app.** The Mobile API plugin (1.10.0) opens every new 0.9.0 tool to the staff app: stocktake with the phone as scanner, the patron ledger at the desk, library settings, patron types, the rules matrix, the closures calendar, suppliers, catalogue cards, MARC import and export, copy cataloguing, the visitor log, statistics and patron import, each through the same controllers as the website. No migration. Full details in **[CHANGELOG.md](CHANGELOG.md)**.
-### v0.8.9
+### v0.9.0-rc.1
-**Search Console verification, a pickup reminder, and an SEO pass.** Settings → Advanced has a field for the `` verification tags of Google, Bing and the other search engines ([#497](https://github.com/fabiodalez-dev/Pinakes/pull/497)). Readers get a reminder before the pickup deadline passes, "Loan approved" can say how many days they have to pick the book up, and the staff is told when a pickup expires ([#498](https://github.com/fabiodalez-dev/Pinakes/pull/498)); the new approval text ships to new installations only, existing ones keep their template and can add `{{giorni_ritiro}}` and `{{scadenza_ritiro}}`. The 404 page is no longer indexed, empty pages past the last one answer 404, structured data and the sitemap are more complete, and the loan request calendar fills its row. One additive migration (`migrate_0.8.9.sql`). Full details in **[CHANGELOG.md](CHANGELOG.md)**.
+**Stocktake, MARC in and out, and the circulation tools of a full library system.** Inventory stocktake sessions with barcode, camera or list scanning and a live report; MARC 21, UNIMARC and danMARC2 bulk import and export with copy cataloguing over SRU; a visitor log, ISBD catalogue cards, suppliers and RDA types; printable library cards, patron photos and user import from CSV; patron types with a rules matrix, a closures calendar and optional fines with a patron ledger. Every module is optional and starts switched off on an upgrade. One additive migration (`migrate_0.9.0-rc.1.sql`). Full details in **[CHANGELOG.md](CHANGELOG.md)**.
Older releases are in **[CHANGELOG.md](CHANGELOG.md)**.
diff --git a/app/Controllers/Admin/LibraryCalendarController.php b/app/Controllers/Admin/LibraryCalendarController.php
index b65ef4dd9..08a0b8398 100644
--- a/app/Controllers/Admin/LibraryCalendarController.php
+++ b/app/Controllers/Admin/LibraryCalendarController.php
@@ -625,6 +625,7 @@ private function commitChange(\mysqli $db, callable $write): ?string
LibraryCalendar::reset();
LibraryCalendar::extendDueDates($db);
LibraryCalendar::extendPickupDeadlines($db);
+ LibraryCalendar::bumpRevision($db);
$db->commit();
return null;
}
diff --git a/app/Controllers/CirculationSettingsController.php b/app/Controllers/CirculationSettingsController.php
index 7ab40803a..792186199 100644
--- a/app/Controllers/CirculationSettingsController.php
+++ b/app/Controllers/CirculationSettingsController.php
@@ -261,6 +261,15 @@ public function updateType(Request $request, Response $response, mysqli $db, int
}
try {
$db->begin_transaction();
+ $locked = $this->lockType($db, $id);
+ if ($locked === null) {
+ $db->rollback();
+ $_SESSION['error_message'] = __('Tipologia lettore non trovata.');
+ return $response->withHeader('Location', url(self::TAB_URL))->withStatus(302);
+ }
+ if ((int) $locked['predefinita'] === 1) {
+ $fields['attiva'] = 1;
+ }
$this->update($db, 'tipologie_lettore', $fields, $id);
if (!empty($data['predefinita'])) {
$this->setDefaultTx($db, $id);
@@ -289,6 +298,17 @@ public function deleteType(Request $request, Response $response, mysqli $db, int
} else {
try {
$db->begin_transaction();
+ // The initial form read may precede another administrator's
+ // set-default transaction. Decide before touching readers or
+ // rules, from the same locked row setDefaultTx() uses.
+ $locked = $this->lockType($db, $id);
+ if ($locked === null || (int) $locked['predefinita'] === 1) {
+ $db->rollback();
+ $_SESSION['error_message'] = $locked === null
+ ? __('Tipologia lettore non trovata.')
+ : __('La tipologia predefinita non può essere eliminata: scegli prima un\'altra predefinita.');
+ return $response->withHeader('Location', url(self::TAB_URL))->withStatus(302);
+ }
// 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 = ?');
@@ -603,6 +623,11 @@ private function codeTaken(mysqli $db, string $code, ?int $exceptId): bool
/** Exactly one default type: clear the flag everywhere, then set it (inside the caller's transaction). */
private function setDefaultTx(mysqli $db, int $id): void
{
+ // A target deleted after the page read must not clear the old default.
+ // Lock before either write so deletion and default selection serialize.
+ if ($this->lockType($db, $id) === null) {
+ throw new \RuntimeException('Patron type no longer exists.');
+ }
$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);
@@ -610,6 +635,17 @@ private function setDefaultTx(mysqli $db, int $id): void
$stmt->close();
}
+ /** Current patron type inside the caller's transaction. @return array|null */
+ private function lockType(mysqli $db, int $id): ?array
+ {
+ $stmt = $db->prepare('SELECT * FROM tipologie_lettore WHERE id = ? FOR UPDATE');
+ $stmt->bind_param('i', $id);
+ $stmt->execute();
+ $row = $stmt->get_result()->fetch_assoc();
+ $stmt->close();
+ return $row ?: null;
+ }
+
/** @param array $fields */
private function insert(mysqli $db, string $table, array $fields): int
{
diff --git a/app/Controllers/LibriController.php b/app/Controllers/LibriController.php
index 584d1d508..19ac94ee3 100644
--- a/app/Controllers/LibriController.php
+++ b/app/Controllers/LibriController.php
@@ -467,6 +467,8 @@ public function show(Request $request, Response $response, mysqli $db, int $id):
$loanHistoryQuery = "
SELECT
p.id,
+ p.copia_id,
+ p.attivo,
p.data_prestito,
p.data_scadenza,
p.data_restituzione,
@@ -488,11 +490,14 @@ public function show(Request $request, Response $response, mysqli $db, int $id):
$stmt = $db->prepare($loanHistoryQuery);
$stmt->bind_param('i', $id);
$stmt->execute();
- $result = $stmt->get_result();
- while ($row = $result->fetch_assoc()) {
- $loanHistory[] = $row;
- }
+ $historyRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
$stmt->close();
+ // One branch read for the page, not one per loan.
+ \App\Support\LibraryCalendar::primeCopyBranches($db, array_map(static fn (array $r): int => (int) ($r['copia_id'] ?? 0), $historyRows));
+ $historyToday = \App\Support\DateHelper::today();
+ foreach ($historyRows as $row) {
+ $loanHistory[] = $row + \App\Support\LibraryCalendar::loanDates($db, $row, $historyToday);
+ }
}
// Active reservations for this book (admin/staff only - contains PII)
@@ -602,20 +607,34 @@ public function show(Request $request, Response $response, mysqli $db, int $id):
);
}
- ob_start();
- // extract([
- // 'libro' => $libro,
- // 'activeLoan' => $activeLoan,
- // 'bookPath' => $bookPath,
- // 'loanHistory' => $loanHistory,
- // 'libraryThingInstalled' => $libraryThingInstalled,
- // ]);
- require __DIR__ . '/../Views/libri/scheda_libro.php';
- $content = ob_get_clean();
- ob_start();
- require __DIR__ . '/../Views/layout.php';
- $html = ob_get_clean();
- $response->getBody()->write($html);
+ // A book may have thousands of physical copies. Keep both rendering
+ // stages in temporary streams instead of holding full copies of the
+ // view, layout buffer and final HTML alongside the database rows.
+ // HTTP emission still happens after this action returns.
+ $content = (new \Slim\Psr7\Factory\StreamFactory())->createStream();
+ $body = $response->getBody();
+ $bufferLevel = ob_get_level();
+ try {
+ ob_start(static function (string $chunk) use ($content): string {
+ $content->write($chunk);
+ return '';
+ }, 8192);
+ require __DIR__ . '/../Views/libri/scheda_libro.php';
+ ob_end_flush();
+ $content->rewind();
+
+ ob_start(static function (string $chunk) use ($body): string {
+ $body->write($chunk);
+ return '';
+ }, 8192);
+ require __DIR__ . '/../Views/layout.php';
+ ob_end_flush();
+ } finally {
+ while (ob_get_level() > $bufferLevel) {
+ ob_end_clean();
+ }
+ $content->close();
+ }
return $response;
}
diff --git a/app/Controllers/LoanApprovalController.php b/app/Controllers/LoanApprovalController.php
index 955f27d5b..3ea1fe2f8 100644
--- a/app/Controllers/LoanApprovalController.php
+++ b/app/Controllers/LoanApprovalController.php
@@ -77,7 +77,7 @@ public function pendingLoans(Request $request, Response $response, mysqli $db):
$scheduledLoans = $scheduledStmt->get_result()->fetch_all(MYSQLI_ASSOC);
$scheduledStmt->close();
- // Get active loans (in_corso)
+ // Get the loans that are out, split below by is_overdue
$activeStmt = $db->prepare("
SELECT p.*, l.titolo, l.copertina_url,
CONCAT(u.nome, ' ', u.cognome) as utente_nome, u.email,
@@ -88,32 +88,30 @@ public function pendingLoans(Request $request, Response $response, mysqli $db):
JOIN libri l ON p.libro_id = l.id AND l.deleted_at IS NULL
JOIN utenti u ON p.utente_id = u.id
LEFT JOIN copie c ON p.copia_id = c.id
- WHERE p.stato = 'in_corso' AND p.attivo = 1
+ WHERE p.stato IN ('in_corso', 'in_ritardo') AND p.attivo = 1
ORDER BY p.data_scadenza ASC
");
$activeStmt->execute();
$activeLoans = $activeStmt->get_result()->fetch_all(MYSQLI_ASSOC);
$activeStmt->close();
- // Get overdue loans (in_ritardo)
- $overdueStmt = $db->prepare("
- SELECT p.*, l.titolo, l.copertina_url,
- CONCAT(u.nome, ' ', u.cognome) as utente_nome, u.email,
- p.data_prestito as data_richiesta_inizio,
- p.data_scadenza as data_richiesta_fine,
- c.numero_inventario as copia_inventario,
- DATEDIFF(?, p.data_scadenza) as giorni_ritardo
- FROM prestiti p
- JOIN libri l ON p.libro_id = l.id AND l.deleted_at IS NULL
- JOIN utenti u ON p.utente_id = u.id
- LEFT JOIN copie c ON p.copia_id = c.id
- WHERE p.stato = 'in_ritardo' AND p.attivo = 1
- ORDER BY p.data_scadenza ASC
- ");
- $overdueStmt->bind_param('s', $today);
- $overdueStmt->execute();
- $overdueLoans = $overdueStmt->get_result()->fetch_all(MYSQLI_ASSOC);
- $overdueStmt->close();
+ // A loan out sits in one list, by what is_overdue says whatever a sweep
+ // has stored so far. Both read the usable return day, and the late one the
+ // open-day late count, as the notices and the fine do. One branch read for the page.
+ \App\Support\LibraryCalendar::primeCopyBranches($db, array_map(static fn (array $r): int => (int) ($r['copia_id'] ?? 0), $activeLoans));
+ $loansOut = $activeLoans;
+ $activeLoans = [];
+ $overdueLoans = [];
+ foreach ($loansOut as $loan) {
+ $copyId = $loan['copia_id'] !== null ? (int) $loan['copia_id'] : null;
+ $dated = $loan + \App\Support\LibraryCalendar::loanDates($db, $loan, $today);
+ if ($dated['is_overdue']) {
+ $dated['giorni_ritardo'] = max(1, \App\Support\LibraryCalendar::lateDaysForNotice($db, (string) $loan['data_scadenza'], $copyId, $today));
+ $overdueLoans[] = $dated;
+ } else {
+ $activeLoans[] = $dated;
+ }
+ }
// Get active reservations
$reservationsStmt = $db->prepare("
@@ -1529,7 +1527,7 @@ public function returnLoan(Request $request, Response $response, mysqli $db): Re
// This is the primary quick-return path — without it late returns keep the
// flag at 0 and Stats/Recensioni undercount late returners.
$scadenza = (string) ($loan['data_scadenza'] ?? '');
- $ritardo = ($scadenza !== '' && $scadenza < $dataRestituzione) ? 1 : 0;
+ $ritardo = \App\Support\LibraryCalendar::isPastDue($db, $scadenza, $copiaId, $dataRestituzione) ? 1 : 0;
// Close the loan
$stmt = $db->prepare("UPDATE prestiti SET stato = 'restituito', restituito_in_ritardo = ?, data_restituzione = ?, attivo = 0 WHERE id = ? AND attivo = 1");
diff --git a/app/Controllers/PrestitiApiController.php b/app/Controllers/PrestitiApiController.php
index ff8894ebe..d3dec7459 100644
--- a/app/Controllers/PrestitiApiController.php
+++ b/app/Controllers/PrestitiApiController.php
@@ -59,7 +59,13 @@ public function list(Request $request, Response $response, mysqli $db): Response
$params[] = $to;
$param_types .= 's';
}
- if ($statoSpecifico !== '') {
+ if ($statoSpecifico === 'in_corso' || $statoSpecifico === 'in_ritardo') {
+ // Same verdict as the row's is_overdue, not the stored stato a sweep
+ // may not have updated yet.
+ $late = \App\Support\LibraryCalendar::overdueLoanCondition($db, \App\Support\DateHelper::today());
+ $where_prepared .= " AND p.attivo = 1 AND p.stato IN ('in_corso', 'in_ritardo') AND "
+ . ($statoSpecifico === 'in_ritardo' ? "({$late})" : "NOT ({$late})") . ' ';
+ } elseif ($statoSpecifico !== '') {
$where_prepared .= " AND p.stato = ? ";
$params[] = $statoSpecifico;
$param_types .= 's';
@@ -138,7 +144,7 @@ public function list(Request $request, Response $response, mysqli $db): Response
$param_types .= 'ii';
$sql_prepared = "SELECT p.id, l.titolo AS libro, CONCAT(u.nome,' ',u.cognome) AS utente,
- p.data_prestito, p.data_scadenza, p.data_restituzione, p.attivo, p.stato,
+ p.copia_id, p.data_prestito, p.data_scadenza, p.data_restituzione, p.attivo, p.stato,
CONCAT(staff.nome,' ',staff.cognome) AS processed_by
$base $where_prepared
ORDER BY $orderColumn $orderDir LIMIT ?, ?";
@@ -154,14 +160,17 @@ public function list(Request $request, Response $response, mysqli $db): Response
$stmt->execute();
$res = $stmt->get_result();
+ $fetched = $res->fetch_all(MYSQLI_ASSOC);
+ // One branch read for the page, not one per loan.
+ \App\Support\LibraryCalendar::primeCopyBranches($db, array_map(static fn (array $r): int => (int) ($r['copia_id'] ?? 0), $fetched));
$rows = [];
- while ($r = $res->fetch_assoc()) {
+ foreach ($fetched as $r) {
$actions = ''.htmlspecialchars(__('Dettagli'), ENT_QUOTES, 'UTF-8').'';
$actions .= ' | '.htmlspecialchars(__('Modifica'), ENT_QUOTES, 'UTF-8').'';
if ((int)$r['attivo'] === 1 && in_array((string) $r['stato'], ['in_corso', 'in_ritardo'], true)) {
$actions .= ' | '.htmlspecialchars(__('Restituito'), ENT_QUOTES, 'UTF-8').'';
}
- $rows[] = [
+ $rows[] = \App\Support\LibraryCalendar::loanDates($db, $r) + [
'id' => (int)$r['id'],
'libro' => $r['libro'] ?? '',
'utente' => $r['utente'] ?? '',
diff --git a/app/Controllers/PrestitiController.php b/app/Controllers/PrestitiController.php
index e1eb7fb88..8ced56956 100644
--- a/app/Controllers/PrestitiController.php
+++ b/app/Controllers/PrestitiController.php
@@ -903,9 +903,19 @@ public function update(Request $request, Response $response, mysqli $db, int $id
$libroId = (int) $current['libro_id'];
$newUserId = isset($updateData['utente_id']) ? (int) $updateData['utente_id'] : (int) $current['utente_id'];
$newPrestito = (string) ($updateData['data_prestito'] ?? $current['data_prestito']);
- // Closures calendar: a submitted due date on a closed day moves forward.
- if (isset($updateData['data_scadenza']) && \App\Support\DateHelper::isISODateFormat((string) $updateData['data_scadenza'])) {
- $updateData['data_scadenza'] = \App\Services\CirculationPolicy::dueDateFor((string) $updateData['data_scadenza'], 0, $db, $current['copia_id'] !== null ? (int) $current['copia_id'] : null);
+ // Closures calendar: a changed due date on a closed day moves forward.
+ // The edit form always submits the field, so the stored value coming
+ // back is no change at all and stays as it is, closed day or not.
+ $submittedScadenza = isset($updateData['data_scadenza']) && \App\Support\DateHelper::isISODateFormat((string) $updateData['data_scadenza'])
+ ? (string) $updateData['data_scadenza']
+ : null;
+ $dueFromSubmitted = static function (array $row) use ($submittedScadenza, $db): string {
+ return $submittedScadenza === substr((string) $row['data_scadenza'], 0, 10)
+ ? (string) $row['data_scadenza']
+ : \App\Services\CirculationPolicy::dueDateFor((string) $submittedScadenza, 0, $db, $row['copia_id'] !== null ? (int) $row['copia_id'] : null);
+ };
+ if ($submittedScadenza !== null) {
+ $updateData['data_scadenza'] = $dueFromSubmitted($current);
}
$newScadenza = (string) ($updateData['data_scadenza'] ?? $current['data_scadenza']);
@@ -984,6 +994,11 @@ public function update(Request $request, Response $response, mysqli $db, int $id
// come fast-fail senza aprire la transazione).
$newUserId = isset($updateData['utente_id']) ? (int) $updateData['utente_id'] : (int) $locked['utente_id'];
$newPrestito = (string) ($updateData['data_prestito'] ?? $locked['data_prestito']);
+ // Decided again against the locked row: the date or the copy may have
+ // changed since the first read.
+ if ($submittedScadenza !== null) {
+ $updateData['data_scadenza'] = $dueFromSubmitted($locked);
+ }
$newScadenza = (string) ($updateData['data_scadenza'] ?? $locked['data_scadenza']);
// A pickup sender owns an immutable recipient snapshot while its
@@ -1571,7 +1586,9 @@ public function processReturn(Request $request, Response $response, mysqli $db,
// Ritardo: un rientro oltre la scadenza è 'restituito' + flag (I4/BUG5).
// Confronto lessicografico Y-m-d sicuro. Significativo solo per 'restituito'.
$scadenza = (string) ($loan['data_scadenza'] ?? '');
- $ritardo = ($loan_stato === 'restituito' && $scadenza !== '' && $scadenza < $data_restituzione) ? 1 : 0;
+ // Late by the calendar, as the fine: a due day closed afterwards is
+ // met by a return on the next open day.
+ $ritardo = ($loan_stato === 'restituito' && \App\Support\LibraryCalendar::isPastDue($db, $scadenza, $loan['copia_id'] !== null ? (int) $loan['copia_id'] : null, (string) $data_restituzione)) ? 1 : 0;
// Aggiorna il prestito (state-guard sull'attivo per evitare doppie restituzioni)
$stmt = $db->prepare("UPDATE prestiti SET stato = ?, data_restituzione = ?, note = ?, sanzione = ?, attivo = 0, restituito_in_ritardo = ? WHERE id = ? AND attivo = 1");
@@ -1766,12 +1783,13 @@ public function processReturn(Request $request, Response $response, mysqli $db,
} catch (\Throwable $e) {
$db->rollback();
SecureLogger::error(__('Errore elaborazione restituzione'), ['loan_id' => $id, 'error' => $e->getMessage()]);
- // Deadlock (1213) o lock wait timeout (1205): la transazione è stata
- // annullata integralmente, ritentare è sicuro e quasi sempre risolve.
+ // Deadlock (1213), lock wait timeout (1205) o riga cambiata dopo lo
+ // snapshot (1020, MariaDB con innodb_snapshot_isolation): la transazione
+ // è stata annullata, ritentare è sicuro e quasi sempre risolve.
// Un errore dedicato evita il generico "update_failed" che non dice
// all'operatore che basta ripetere l'operazione.
$errno = $e instanceof \mysqli_sql_exception ? (int) $e->getCode() : 0;
- $errorCode = in_array($errno, [1213, 1205], true) ? 'concurrent_retry' : 'update_failed';
+ $errorCode = in_array($errno, [1020, 1213, 1205], true) ? 'concurrent_retry' : 'update_failed';
if ($redirectTo) {
$separator = strpos($redirectTo, '?') === false ? '?' : '&';
return $response->withHeader('Location', url($redirectTo . $separator . 'error=' . $errorCode))->withStatus(302);
@@ -1867,6 +1885,7 @@ public function details(Request $request, Response $response, mysqli $db, int $i
$prestito = $result->fetch_assoc();
$stmt->close();
+ $prestito += \App\Support\LibraryCalendar::loanDates($db, $prestito);
// Plan §7: fine accruing on an open overdue loan (shown, written only at
// return) and the late fine already charged on a closed one.
$ledger = \App\Services\PatronLedger::forDb($db);
@@ -2171,8 +2190,18 @@ private function applyBulkLoanExtension(
// in the future and clears the "Overdue" status — instead of staying
// overdue when N is smaller than the overdue gap. A loan not yet due keeps
// extending from its due date.
+ // The due date counts as its usable return day, as for a single renewal:
+ // a stored date on a day closed afterwards is due on the next open day.
+ // The capacity window below still starts after the stored date (wider).
+ $usableDue = $dueDate;
+ if ($db !== null) {
+ $usable = \DateTimeImmutable::createFromFormat('!Y-m-d', \App\Support\LibraryCalendar::dueDate($db, $dueDate->format('Y-m-d'), $loan['copia_id'] !== null ? (int) $loan['copia_id'] : null));
+ if ($usable !== false && $usable > $dueDate) {
+ $usableDue = $usable;
+ }
+ }
$todayDate = \DateTimeImmutable::createFromFormat('!Y-m-d', $today);
- $base = ($todayDate !== false && $todayDate > $dueDate) ? $todayDate : $dueDate;
+ $base = ($todayDate !== false && $todayDate > $usableDue) ? $todayDate : $usableDue;
// With the connection, a new due date on a closed day moves forward.
$newDueDate = \App\Services\CirculationPolicy::dueDateFor($base->format('Y-m-d'), $days, $db, $loan['copia_id'] !== null ? (int) $loan['copia_id'] : null);
@@ -2256,15 +2285,12 @@ public function renew(Request $request, Response $response, mysqli $db, int $id)
return $response->withHeader('Location', url($errorUrl . $separator . 'error=loan_not_active'))->withStatus(302);
}
- // Check if loan is overdue — by STATE or by DATE (#366 residual): a loan
- // whose data_scadenza is already past but that the maintenance sweep has
- // not yet flipped to 'in_ritardo' is just as overdue. Renewing it would
- // compute the new due date from the stale past date AND reset
- // warning_sent/overdue_notification_sent, re-arming a duplicate overdue
- // email. Same refusal, same error code.
+ // A due day closed after lending remains usable on its next open day,
+ // as in the overdue sweep and fines. Check both before and under the
+ // loan lock so renewal and the calendar agree on who is actually late.
$todayApp = \App\Support\DateHelper::today();
- $isLate = ($loan['stato'] === 'in_ritardo')
- || ($loan['stato'] === 'in_corso' && (string) $loan['data_scadenza'] < $todayApp);
+ $isLate = in_array($loan['stato'], ['in_corso', 'in_ritardo'], true)
+ && \App\Support\LibraryCalendar::isPastDue($db, (string) $loan['data_scadenza'], isset($loan['copia_id']) ? (int) $loan['copia_id'] : null, $todayApp);
if ($isLate) {
$errorUrl = $redirectTo ?? url('/admin/loans');
$separator = strpos($errorUrl, '?') === false ? '?' : '&';
@@ -2309,7 +2335,10 @@ public function renew(Request $request, Response $response, mysqli $db, int $id)
// Calculate proposed new due date for conflict checking
$currentDueDate = $loan['data_scadenza'];
$loanCopyId = isset($loan['copia_id']) ? (int) $loan['copia_id'] : null;
- $proposedNewDueDate = \App\Services\CirculationPolicy::dueDateFor((string) $currentDueDate, $renewDays, $db, $loanCopyId);
+ // The renewal counts from the usable return day: a stored due date on
+ // a day closed afterwards is due on the next open day, as the late check.
+ $usableDueDate = \App\Support\LibraryCalendar::dueDate($db, (string) $currentDueDate, $loanCopyId);
+ $proposedNewDueDate = \App\Services\CirculationPolicy::dueDateFor($usableDueDate, $renewDays, $db, $loanCopyId);
// Optional shorter renewal: the day before a scheduled loan or a
// reservation, as the refusal below proposes ("Rinnova fino al D").
// A real day, after the current due date, never past the standard
@@ -2324,7 +2353,7 @@ public function renew(Request $request, Response $response, mysqli $db, int $id)
$separator = strpos($errorUrl, '?') === false ? '?' : '&';
return $response->withHeader('Location', url($errorUrl . $separator . 'error=invalid_date_format'))->withStatus(302);
}
- if ($requestedDue !== '' && ($requestedDue <= (string) $currentDueDate || $requestedDue > $proposedNewDueDate)) {
+ if ($requestedDue !== '' && ($requestedDue <= $usableDueDate || $requestedDue > $proposedNewDueDate)) {
$errorUrl = $redirectTo ?? url('/admin/loans');
$separator = strpos($errorUrl, '?') === false ? '?' : '&';
return $response->withHeader('Location', url($errorUrl . $separator . 'error=invalid_renewal_date'))->withStatus(302);
@@ -2372,9 +2401,8 @@ public function renew(Request $request, Response $response, mysqli $db, int $id)
$separator = strpos($errorUrl, '?') === false ? '?' : '&';
return $response->withHeader('Location', url($errorUrl . $separator . 'error=loan_not_active'))->withStatus(302);
}
- // State- OR date-based overdue, same predicate as the pre-lock check.
- if ($lockedLoan['stato'] === 'in_ritardo'
- || ($lockedLoan['stato'] === 'in_corso' && (string) $lockedLoan['data_scadenza'] < $todayApp)) {
+ if (in_array($lockedLoan['stato'], ['in_corso', 'in_ritardo'], true)
+ && \App\Support\LibraryCalendar::isPastDue($db, (string) $lockedLoan['data_scadenza'], isset($lockedLoan['copia_id']) ? (int) $lockedLoan['copia_id'] : null, $todayApp)) {
$db->rollback();
$errorUrl = $redirectTo ?? url('/admin/loans');
$separator = strpos($errorUrl, '?') === false ? '?' : '&';
@@ -2399,10 +2427,12 @@ public function renew(Request $request, Response $response, mysqli $db, int $id)
// Use the locked values for calculations (in case they changed)
$currentDueDate = $lockedLoan['data_scadenza'];
- $proposedNewDueDate = \App\Services\CirculationPolicy::dueDateFor((string) $currentDueDate, $renewDays, $db, $lockedLoan['copia_id'] !== null ? (int) $lockedLoan['copia_id'] : null);
+ $lockedCopyId = $lockedLoan['copia_id'] !== null ? (int) $lockedLoan['copia_id'] : null;
+ $usableDueDate = \App\Support\LibraryCalendar::dueDate($db, (string) $currentDueDate, $lockedCopyId);
+ $proposedNewDueDate = \App\Services\CirculationPolicy::dueDateFor($usableDueDate, $renewDays, $db, $lockedCopyId);
if ($requestedDue !== '') {
// Re-bound under the lock: the due date may have moved meanwhile.
- if ($requestedDue <= (string) $currentDueDate || $requestedDue > $proposedNewDueDate) {
+ if ($requestedDue <= $usableDueDate || $requestedDue > $proposedNewDueDate) {
$db->rollback();
$errorUrl = $redirectTo ?? url('/admin/loans');
$separator = strpos($errorUrl, '?') === false ? '?' : '&';
@@ -2430,9 +2460,11 @@ public function renew(Request $request, Response $response, mysqli $db, int $id)
$capacity = new \App\Services\CapacityService($db);
// A refusal names the longest renewal still possible (LoanWindow),
// and which loan it is about, so the page can offer "renew until D".
- $explainRefusal = static function (array $blocked) use ($id, $currentDueDate, $db, $copiaId): string {
+ // The offered "renew until D" counts from the usable return day, the
+ // same base the renewal bound above accepts.
+ $explainRefusal = static function (array $blocked) use ($id, $currentDueDate, $usableDueDate, $db, $copiaId): string {
return http_build_query(['error' => 'extension_conflicts', 'loan_id' => $id]
- + \App\Support\LoanWindow::renewalRedirectParams((string) $currentDueDate, $blocked, null, \App\Support\LibraryCalendar::forDb($db), \App\Support\LibraryCalendar::branchOfCopy($db, $copiaId)));
+ + \App\Support\LoanWindow::renewalRedirectParams((string) $currentDueDate, $blocked, $usableDueDate, \App\Support\LibraryCalendar::forDb($db), \App\Support\LibraryCalendar::branchOfCopy($db, $copiaId)));
};
if (!$capacity->hasFreeCapacity($libroId, $extensionStart, $extensionEnd, excludePrestitoId: $id)) {
$blocked = $capacity->renewalConflictDates($libroId, $id, $copiaId, (string) $currentDueDate, $extensionEnd);
@@ -2483,10 +2515,12 @@ public function renew(Request $request, Response $response, mysqli $db, int $id)
// non scade un prestito già in corso e rinnovato (F2). Azzera anche i
// flag di notifica (M5a): la nuova scadenza deve ricevere il suo
// promemoria e l'eventuale sollecito, altrimenti un prestito rinnovato
- // dopo il warning non verrebbe mai più avvisato.
+ // dopo il warning non verrebbe mai più avvisato. Arrivano qui solo
+ // prestiti non in ritardo per isPastDue(): un vecchio stato 'in_ritardo'
+ // con la nuova scadenza futura non lo correggerebbe nessun altro.
$updateStmt = $db->prepare("
UPDATE prestiti
- SET data_scadenza = ?, renewals = ?, pickup_deadline = NULL,
+ SET data_scadenza = ?, renewals = ?, stato = 'in_corso', pickup_deadline = NULL,
warning_sent = 0, overdue_notification_sent = 0,
recall_count = 0, last_recall_at = NULL
WHERE id = ?
diff --git a/app/Controllers/ReservationManager.php b/app/Controllers/ReservationManager.php
index 272c33b67..9ec16b615 100644
--- a/app/Controllers/ReservationManager.php
+++ b/app/Controllers/ReservationManager.php
@@ -284,6 +284,9 @@ public function processBookAvailability($bookId)
// deadline; selecting them without normalising the start would
// still make isDateRangeAvailable() reject them forever.
$nextReservation['data_inizio_richiesta'] = $startDate;
+ // Capacity is checked on the day the loan will end, which a
+ // closure can put after the requested one (an open day stays as it is).
+ $endDate = \App\Support\LibraryCalendar::requestDueDate($this->db, (string) $endDate, 0, $bookId);
$nextReservation['data_fine_richiesta'] = $endDate;
// #157: pass the promoted reservation's queue_position so the
@@ -461,7 +464,7 @@ private function createLoanFromReservation($reservation)
$bookId = (int) $reservation['libro_id'];
$startDate = $reservation['data_inizio_richiesta'];
// Closures calendar: the loan's due date moves to the next open day.
- $endDate = \App\Services\CirculationPolicy::dueDateFor((string) $reservation['data_fine_richiesta'], 0, $this->db);
+ $endDate = \App\Support\LibraryCalendar::requestDueDate($this->db, (string) $reservation['data_fine_richiesta'], 0, $bookId);
// Always create as 'pendente' (attivo=0) - requires admin confirmation of physical pickup
// Origin is 'prenotazione' to distinguish from manual requests
@@ -712,6 +715,11 @@ private function maxActiveQueuePosition(int $bookId): int
return $max;
}
+ protected function notificationService(): \App\Support\NotificationService
+ {
+ return new \App\Support\NotificationService($this->db);
+ }
+
/**
* Send notification email when reservation book becomes available
*
@@ -779,7 +787,7 @@ private function sendReservationNotification(array $reservation): bool
// #360 parity: formatta le date nella lingua del DESTINATARIO come
// tutta la pipeline email (warning/overdue/solleciti), non nel
// locale di installazione.
- $notificationService = new \App\Support\NotificationService($this->db);
+ $notificationService = $this->notificationService();
$recipientLocale = $notificationService->resolveRecipientLocale((string) $reservation['email']);
$variables = [
@@ -790,7 +798,8 @@ private function sendReservationNotification(array $reservation): bool
'libro_autore' => $book['autore'] ?: $notificationService->translateInLocale('Autore non specificato', $recipientLocale),
'libro_isbn' => $book['isbn'] ?: 'N/A',
'data_inizio' => $notificationService->formatEmailDate((string) $reservation['data_inizio_richiesta'], false, $recipientLocale),
- 'data_fine' => $notificationService->formatEmailDate((string) $reservation['data_fine_richiesta'], false, $recipientLocale),
+ // The day createLoanFromReservation() stores: a closed end day moves to the next open day.
+ 'data_fine' => $notificationService->formatEmailDate(\App\Support\LibraryCalendar::requestDueDate($this->db, (string) $reservation['data_fine_richiesta'], 0, (int) $reservation['libro_id']), false, $recipientLocale),
'book_url' => absoluteUrl($bookLink),
'profile_url' => absoluteUrl(RouteTranslator::route('profile'))
];
diff --git a/app/Controllers/ReservationsAdminController.php b/app/Controllers/ReservationsAdminController.php
index c6b1bdbd1..f485f36b9 100644
--- a/app/Controllers/ReservationsAdminController.php
+++ b/app/Controllers/ReservationsAdminController.php
@@ -516,7 +516,7 @@ public function store(Request $request, Response $response, mysqli $db): Respons
$loanDays = (new \App\Services\CirculationPolicy($db))->forLoan($utenteId, $libroId)->durationDays;
// The default duration starts from the requested start date, not
// from today (critical for future reservations).
- $dataScadenzaDate = \App\Services\CirculationPolicy::dueDateFor($dataPrenotazioneDate, $loanDays, $db);
+ $dataScadenzaDate = \App\Support\LibraryCalendar::requestDueDate($db, $dataPrenotazioneDate, $loanDays, (int) $libroId);
$dataScadenza = $dataScadenzaDate . ' 23:59:59';
} else {
$dataScadenzaDate = $dataScadenza; // Keep date only for loan period
diff --git a/app/Controllers/ReservationsController.php b/app/Controllers/ReservationsController.php
index d5e75bc6e..6b4962add 100644
--- a/app/Controllers/ReservationsController.php
+++ b/app/Controllers/ReservationsController.php
@@ -81,8 +81,9 @@ private function calculateAvailability($currentLoans, $existingReservations, int
if ($loanStatus === 'da_ritirare') {
// Block the full loan period: the copy is committed to this user
// even though they haven't picked it up yet
- $endDateLoan = $loan['data_scadenza']
- ?? (new DateTime($startDateLoan))->add(new DateInterval('P7D'))->format('Y-m-d');
+ $endDateLoan = $loan['data_scadenza'] !== null
+ ? $this->holdingEnd($loan)
+ : (new DateTime($startDateLoan))->add(new DateInterval('P7D'))->format('Y-m-d');
} elseif (
empty($loan['data_restituzione'])
&& (
@@ -99,7 +100,8 @@ private function calculateAvailability($currentLoans, $existingReservations, int
$endDateLoan = '9999-12-31';
} else {
// For other states: data_restituzione > data_scadenza > null
- $endDateLoan = $loan['data_restituzione'] ?? $loan['data_scadenza'] ?? null;
+ $endDateLoan = $loan['data_restituzione']
+ ?? ($loanStatus !== 'pendente' && !empty($loan['data_scadenza']) ? $this->holdingEnd($loan) : ($loan['data_scadenza'] ?? null));
}
// Fallback: if still no end date, use start date (single day block)
@@ -293,7 +295,7 @@ public function createReservation($request, $response, $args)
// A start day the library is closed moves to the next open day, as the
// request calendar proposes it (a crafted request cannot start on a
// closure); identical without closures.
- $startDate = \App\Support\LibraryCalendar::forDb($this->db)->nextOpenDay($startDate);
+ $startDate = \App\Support\LibraryCalendar::requestStartDay($this->db, $startDate, $bookId);
if ($endDate && $endDate < $startDate) {
$response->getBody()->write(json_encode(['success' => false, 'error' => 'invalid_range', 'message' => __('La data di fine non può precedere la data di inizio')]));
@@ -314,13 +316,13 @@ public function createReservation($request, $response, $args)
// The longest loan ends on the maximum day, moved forward like every
// due date when it falls on a closed day (closed days are not loan
// days); without closures this is start + maximum, as before.
- if ($endDate > \App\Services\CirculationPolicy::dueDateFor((string) $startDate, $maxDurationDays, $this->db)) {
+ if ($endDate > \App\Support\LibraryCalendar::requestDueDate($this->db, (string) $startDate, $maxDurationDays, (int) $bookId)) {
$response->getBody()->write(json_encode(['success' => false, 'error' => 'max_duration_exceeded', 'message' => __('Il periodo richiesto supera la durata massima consentita di %d giorni', $maxDurationDays)]));
return $response->withHeader('Content-Type', 'application/json')->withStatus(400);
}
// Closures calendar: a return date on a closed day moves to the next
// open day (after the duration cap: closed days are not loan days).
- $endDate = \App\Services\CirculationPolicy::dueDateFor($endDate, 0, $this->db);
+ $endDate = \App\Support\LibraryCalendar::requestDueDate($this->db, (string) $endDate, 0, (int) $bookId);
// Availability decision routes through CapacityService — the single
// capacity authority — so this web loan-request gate matches the mobile
@@ -768,6 +770,13 @@ private function autoApproveLoanRequest($request, int $loanId, ?bool $knownEnabl
return null;
}
+ /** @param array $loan */
+ private function holdingEnd(array $loan): string
+ {
+ $copyId = !empty($loan['copia_id']) ? (int) $loan['copia_id'] : null;
+ return \App\Support\LibraryCalendar::holdingEnd($this->db, substr((string) $loan['data_scadenza'], 0, 10), $copyId);
+ }
+
/**
* Per-day availability payload for a book, or NULL when the book does not
* exist or is soft-deleted. Every caller must 404 on null: without this
@@ -847,7 +856,11 @@ public function getBookAvailabilityData($bookId, ?string $startDate = null, int
$days = $closedHorizon + 1;
}
$closedDates = \App\Support\LibraryCalendar::forDb($this->db)
- ->closedDatesBetween($from, \App\Support\LibraryCalendar::shift($from, $closedHorizon));
+ ->closedDatesBetween(
+ $from,
+ \App\Support\LibraryCalendar::shift($from, $closedHorizon),
+ \App\Support\LibraryCalendar::requestBranch($this->db, (int) $bookId)
+ );
return $this->calculateAvailability($currentLoans, $existingReservations, $totalCopies, $startDate, $days, $excludeUserId) + [
'loan_duration_days' => $policy->requestDurationDays(),
'max_loan_duration_days' => $policy->maxRequestDays,
diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php
index 995cd0102..23b21a8e8 100644
--- a/app/Controllers/SettingsController.php
+++ b/app/Controllers/SettingsController.php
@@ -1694,7 +1694,7 @@ public function updateCatalogHeader(Request $request, Response $response, mysqli
}
/**
- * @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, daily_fine: string, fine_grace_mode: string, card_renewal_base: string, fine_grace_days: int, fine_max_per_loan: string, debt_block_threshold: string, fine_charged_email: bool, currency: string, fines_enabled: bool, closures_calendar_enabled: bool, circulation_rules_enabled: bool}
+ * @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, same_day_handover_after_closure: bool, recall_auto_enabled: bool, recall_interval_days: int, recall_max_count: int, app_timezone: string, daily_fine: string, fine_grace_mode: string, card_renewal_base: string, fine_grace_days: int, fine_max_per_loan: string, debt_block_threshold: string, fine_charged_email: bool, currency: string, fines_enabled: bool, closures_calendar_enabled: bool, circulation_rules_enabled: bool}
*/
public function resolveLoansSettings(SettingsRepository $repository): array
{
@@ -1709,6 +1709,7 @@ public function resolveLoansSettings(SettingsRepository $repository): array
'max_loan_duration_days' => (int) ($repository->get('loans', 'max_loan_duration_days', '90') ?? 90),
'auto_approve_requests' => $repository->autoApproveLoanRequests(),
'allow_multiple_loans_same_book' => $repository->allowsMultipleLoansSameBook(),
+ 'same_day_handover_after_closure' => $repository->allowsSameDayHandover(),
// #360: automatic recall (sollecito) schedule for overdue loans.
'recall_auto_enabled' => (string) ($repository->get('loans', 'recall_auto_enabled', '0') ?? '0') === '1',
'recall_interval_days' => (int) ($repository->get('loans', 'recall_interval_days', '7') ?? 7),
@@ -1783,6 +1784,9 @@ public function updateLoansSettings(Request $request, Response $response, mysqli
$autoApprove = isset($data['auto_approve_requests'])
&& is_scalar($data['auto_approve_requests'])
&& (string) $data['auto_approve_requests'] === '1';
+ $sameDayHandover = isset($data['same_day_handover_after_closure'])
+ && is_scalar($data['same_day_handover_after_closure'])
+ && (string) $data['same_day_handover_after_closure'] === '1';
$allowMultipleLoansSameBook = isset($data['allow_multiple_loans_same_book'])
&& is_scalar($data['allow_multiple_loans_same_book'])
&& (string) $data['allow_multiple_loans_same_book'] === '1';
@@ -1803,6 +1807,7 @@ public function updateLoansSettings(Request $request, Response $response, mysqli
$repository->set('loans', 'max_loan_duration_days', (string) $maxLoanDuration);
$repository->set('loans', 'auto_approve_requests', $autoApprove ? '1' : '0');
$repository->set('loans', 'allow_multiple_loans_same_book', $allowMultipleLoansSameBook ? '1' : '0');
+ $repository->set('loans', 'same_day_handover_after_closure', $sameDayHandover ? '1' : '0');
$repository->set('loans', 'recall_auto_enabled', $recallEnabled ? '1' : '0');
$repository->set('loans', 'recall_interval_days', (string) $recallInterval);
$repository->set('loans', 'recall_max_count', (string) $recallMaxCount);
@@ -1844,6 +1849,11 @@ public function updateLoansSettings(Request $request, Response $response, mysqli
// next open day, as when the calendar itself is saved.
if (\App\Support\Features::enabled('closures_calendar') !== $calendarWasOn) {
\App\Support\LibraryCalendar::reset();
+ try {
+ \App\Support\LibraryCalendar::bumpRevision($db);
+ } catch (\Throwable $e) {
+ \App\Support\SecureLogger::warning('Calendar: revision not recorded: ' . $e->getMessage());
+ }
if (!\App\Support\LibraryCalendar::extendDueDatesAfterSave($db)) {
$_SESSION['error_message'] = __('Il calendario è salvato, ma alcune scadenze dei prestiti non sono state ancora spostate: lo farà la prossima manutenzione automatica.');
}
diff --git a/app/Controllers/UserActionsController.php b/app/Controllers/UserActionsController.php
index 3634d1ca4..58b7da599 100644
--- a/app/Controllers/UserActionsController.php
+++ b/app/Controllers/UserActionsController.php
@@ -55,7 +55,7 @@ public function reservationsPage(Request $request, Response $response, mysqli $d
// e col titolo REALE (convenzione unica con mobile ed email —
// CI-SOFT-DELETE-EXEMPT: prestito proprio dell'utente, che per
// quel titolo riceve anche i solleciti).
- $sql = "SELECT pr.id, pr.libro_id, pr.data_prestito, pr.data_scadenza, pr.stato,
+ $sql = "SELECT pr.id, pr.libro_id, pr.copia_id, pr.data_prestito, pr.data_scadenza, pr.stato,
l.titolo, l.copertina_url,
EXISTS(SELECT 1 FROM recensioni r WHERE r.libro_id = pr.libro_id AND r.utente_id = ?) as has_review
FROM prestiti pr
@@ -69,7 +69,7 @@ public function reservationsPage(Request $request, Response $response, mysqli $d
$activePrestiti = [];
while ($r = $res->fetch_assoc()) {
$r['titolo'] = $r['titolo'] ?? __('Titolo non disponibile');
- $activePrestiti[] = $r;
+ $activePrestiti[] = $r + \App\Support\LibraryCalendar::loanDates($db, $r);
}
$stmt->close();
@@ -560,8 +560,6 @@ public function changeReservationDate(Request $request, Response $response, mysq
}
$uid = (int) $user['id'];
- // A closed start day moves to the next open day (see reserve()).
- $date = \App\Support\LibraryCalendar::forDb($db)->nextOpenDay($date);
$startDate = $date;
$db->begin_transaction();
@@ -582,8 +580,11 @@ public function changeReservationDate(Request $request, Response $response, mysq
}
$libroId = (int) $reservation['libro_id'];
+ // A closed start day moves to the next open day (see reserve()).
+ $date = \App\Support\LibraryCalendar::requestStartDay($db, $date, $libroId);
+ $startDate = $date;
$loanDays = (new \App\Services\CirculationPolicy($db))->forLoan($uid, $libroId)->requestDurationDays();
- $endDate = \App\Services\CirculationPolicy::dueDateFor($date, $loanDays, $db);
+ $endDate = \App\Support\LibraryCalendar::requestDueDate($db, $date, $loanDays, $libroId);
// 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");
@@ -721,15 +722,15 @@ 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.
// A day the library is closed starts on the next open day instead.
- $data_prestito = \App\Support\LibraryCalendar::forDb($db)->nextOpenDay(\App\Support\DateHelper::today());
+ $data_prestito = \App\Support\LibraryCalendar::requestStartDay($db, \App\Support\DateHelper::today(), $libroId);
$circulation = new \App\Services\CirculationPolicy($db);
$loanPolicy = $circulation->forLoan($utenteId, $libroId);
- $data_scadenza = \App\Services\CirculationPolicy::dueDateFor($data_prestito, $loanPolicy->requestDurationDays(), $db);
+ $data_scadenza = \App\Support\LibraryCalendar::requestDueDate($db, $data_prestito, $loanPolicy->requestDurationDays(), $libroId);
// Optional return date (Mobile API 1.8.2): the app proposes the day
// before a scheduled loan or reservation when the default would cross
// it, exactly as the website calendar does. Bounded like the public
// endpoint: a real day, not before the start, within the maximum.
- $endOverride = $this->requestedEndDate($loanPolicy->maxRequestDays, $data, $data_prestito, $db);
+ $endOverride = $this->requestedEndDate($loanPolicy->maxRequestDays, $data, $data_prestito, $db, $libroId);
if ($endOverride === false) {
return $this->back($response, ['loan_error' => 'invalid_date']);
}
@@ -738,7 +739,7 @@ public function loan(Request $request, Response $response, mysqli $db): Response
}
if ($endOverride !== null) {
// A chosen return date on a closed day moves to the next open day.
- $data_scadenza = \App\Services\CirculationPolicy::dueDateFor($endOverride, 0, $db);
+ $data_scadenza = \App\Support\LibraryCalendar::requestDueDate($db, $endOverride, 0, $libroId);
}
// Use transaction + lock to prevent race conditions
@@ -991,11 +992,11 @@ public function reserve(Request $request, Response $response, mysqli $db): Respo
// Calculate date range for availability check. A start day the library
// is closed moves to the next open day, as the request calendar
// proposes it; identical without closures.
- $start = \App\Support\LibraryCalendar::forDb($db)->nextOpenDay(($desired !== '') ? $desired : $today);
+ $start = \App\Support\LibraryCalendar::requestStartDay($db, ($desired !== '') ? $desired : $today, $libroId);
$reservePolicy = (new \App\Services\CirculationPolicy($db))->forLoan($utenteId, $libroId);
- $end = \App\Services\CirculationPolicy::dueDateFor($start, $reservePolicy->requestDurationDays(), $db);
+ $end = \App\Support\LibraryCalendar::requestDueDate($db, $start, $reservePolicy->requestDurationDays(), $libroId);
// Optional return date (Mobile API 1.8.2), bounded as in loan().
- $endOverride = $this->requestedEndDate($reservePolicy->maxRequestDays, $data, $start, $db);
+ $endOverride = $this->requestedEndDate($reservePolicy->maxRequestDays, $data, $start, $db, $libroId);
if ($endOverride === false) {
return $this->back($response, ['reserve_error' => 'invalid_date']);
}
@@ -1004,7 +1005,7 @@ public function reserve(Request $request, Response $response, mysqli $db): Respo
}
if ($endOverride !== null) {
// A chosen end date on a closed day moves to the next open day.
- $end = \App\Services\CirculationPolicy::dueDateFor($endOverride, 0, $db);
+ $end = \App\Support\LibraryCalendar::requestDueDate($db, $endOverride, 0, $libroId);
}
// Start transaction for concurrency control
@@ -1148,7 +1149,7 @@ public function reserve(Request $request, Response $response, mysqli $db): Respo
* @param array $data
* @return string|false|null
*/
- private function requestedEndDate(int $maxDays, array $data, string $startDate, ?\mysqli $db = null): string|false|null
+ private function requestedEndDate(int $maxDays, array $data, string $startDate, \mysqli $db, int $bookId): string|false|null
{
$raw = $data['end_date'] ?? null;
if (!is_scalar($raw)) {
@@ -1164,7 +1165,7 @@ private function requestedEndDate(int $maxDays, array $data, string $startDate,
// The longest loan ends on the maximum day, moved forward like every
// due date when it falls on a closed day (closed days are not loan
// days); without closures this is start + maximum, as before.
- return $end > \App\Services\CirculationPolicy::dueDateFor($startDate, $maxDays, $db) ? 'max_duration_exceeded' : $end;
+ return $end > \App\Support\LibraryCalendar::requestDueDate($db, $startDate, $maxDays, $bookId) ? 'max_duration_exceeded' : $end;
}
private function back(Response $response, array $params): Response
diff --git a/app/Controllers/UserDashboardController.php b/app/Controllers/UserDashboardController.php
index 522e49e37..0c55978e7 100644
--- a/app/Controllers/UserDashboardController.php
+++ b/app/Controllers/UserDashboardController.php
@@ -82,7 +82,7 @@ public function index(Request $request, Response $response, mysqli $db): Respons
// Get user active loans with book titles
$stmt = $db->prepare("
- SELECT p.id, p.libro_id, p.data_scadenza,
+ SELECT p.id, p.libro_id, p.copia_id, p.data_scadenza, p.stato,
l.titolo AS titolo_libro, l.copertina_url
FROM prestiti p
JOIN libri l ON p.libro_id = l.id
@@ -94,7 +94,7 @@ public function index(Request $request, Response $response, mysqli $db): Respons
$stmt->execute();
$res = $stmt->get_result();
while ($row = $res->fetch_assoc()) {
- $prestitiAttivi[] = $row;
+ $prestitiAttivi[] = $row + \App\Support\LibraryCalendar::loanDates($db, $row);
}
$stmt->close();
@@ -153,7 +153,7 @@ public function prenotazioni(Request $request, Response $response, mysqli $db, m
// Active loans
$stmt = $db->prepare("
- SELECT pr.id, pr.libro_id, pr.data_prestito, pr.data_scadenza, pr.stato,
+ SELECT pr.id, pr.libro_id, pr.copia_id, pr.data_prestito, pr.data_scadenza, pr.stato,
l.titolo, l.copertina_url,
EXISTS(SELECT 1 FROM recensioni r WHERE r.libro_id = pr.libro_id AND r.utente_id = ?) AS has_review
FROM prestiti pr
@@ -165,7 +165,7 @@ public function prenotazioni(Request $request, Response $response, mysqli $db, m
$stmt->execute();
$result = $stmt->get_result();
while ($row = $result->fetch_assoc()) {
- $activePrestiti[] = $row;
+ $activePrestiti[] = $row + \App\Support\LibraryCalendar::loanDates($db, $row);
}
$stmt->close();
diff --git a/app/Controllers/UsersController.php b/app/Controllers/UsersController.php
index e4972081e..4d0538f78 100644
--- a/app/Controllers/UsersController.php
+++ b/app/Controllers/UsersController.php
@@ -258,9 +258,14 @@ public function store(Request $request, Response $response, mysqli $db): Respons
$customRoleId,
$digitalAccess
);
- $stmt->execute();
- $userId = (int) $stmt->insert_id;
- \App\Support\PatronTypeAssignment::save($db, $userId, $patronTypeId);
+ // The type may have been removed since form validation. Account
+ // creation and assignment must succeed together; otherwise a
+ // reported failure would still leave a newly created account.
+ \App\Support\DbAtomic::run($db, static function () use ($stmt, $db, $patronTypeId, &$userId): void {
+ $stmt->execute();
+ $userId = (int) $stmt->insert_id;
+ \App\Support\PatronTypeAssignment::save($db, $userId, $patronTypeId);
+ }, 'Users.create');
} catch (\Throwable $e) {
$code = ($e instanceof \mysqli_sql_exception) ? \App\Support\UniqueViolation::errorCode($e) : 'db_error';
if ($code === 'db_error') {
@@ -579,10 +584,14 @@ public function update(Request $request, Response $response, mysqli $db, int $id
$digitalAccess,
$id
);
- $stmt->execute();
- if ($patronTypeId !== $originalPatronType) {
- \App\Support\PatronTypeAssignment::save($db, $id, $patronTypeId);
- }
+ // A failed type assignment must also undo the account fields,
+ // password and privileges saved by this same form.
+ \App\Support\DbAtomic::run($db, static function () use ($stmt, $db, $id, $patronTypeId, $originalPatronType): void {
+ $stmt->execute();
+ if ($patronTypeId !== $originalPatronType) {
+ \App\Support\PatronTypeAssignment::save($db, $id, $patronTypeId);
+ }
+ }, 'Users.update');
} catch (\Throwable $e) {
// $stmt stays null if prepare() itself threw — guard the close().
if ($stmt instanceof \mysqli_stmt) {
diff --git a/app/Models/CopyRepository.php b/app/Models/CopyRepository.php
index e58cb2dee..9d1401e9c 100644
--- a/app/Models/CopyRepository.php
+++ b/app/Models/CopyRepository.php
@@ -73,6 +73,8 @@ public function getByBookId(int $bookId): array
'utente_nome' => null,
'utente_cognome' => null,
'utente_email' => null,
+ 'effective_due_at' => null,
+ 'is_overdue' => false,
];
$copie[] = $row;
}
@@ -84,7 +86,10 @@ public function getByBookId(int $bookId): array
foreach ($copie as $index => $copy) {
$copyIndexes[(int) $copy['id']] = $index;
}
- foreach ($this->getScheduleByBookId($bookId) as $commitment) {
+ $schedule = $this->getScheduleByBookId($bookId);
+ \App\Support\LibraryCalendar::primeCopyBranches($this->db, array_map(static fn (array $c): int => (int) $c['id'], $schedule));
+ $today = \App\Support\DateHelper::today();
+ foreach ($schedule as $commitment) {
$copyId = (int) $commitment['id'];
if (!isset($copyIndexes[$copyId])) {
continue;
@@ -96,6 +101,14 @@ public function getByBookId(int $bookId): array
foreach (['prestito_id', 'utente_id', 'data_prestito', 'data_scadenza', 'prestito_stato', 'utente_nome', 'utente_cognome', 'utente_email'] as $field) {
$copie[$index][$field] = $commitment[$field];
}
+ $dates = \App\Support\LibraryCalendar::loanDates($this->db, [
+ 'data_scadenza' => $commitment['data_scadenza'],
+ 'stato' => $commitment['prestito_stato'],
+ 'attivo' => $commitment['attivo'],
+ 'copia_id' => $copyId,
+ ], $today);
+ $copie[$index]['effective_due_at'] = $dates['effective_due_at'];
+ $copie[$index]['is_overdue'] = $dates['is_overdue'];
}
return $copie;
@@ -111,7 +124,7 @@ public function getScheduleByBookId(int $bookId): array
$stmt = $this->db->prepare("
SELECT c.id, c.numero_inventario,
p.id AS prestito_id, p.utente_id, p.data_prestito,
- p.data_scadenza, p.stato AS prestito_stato,
+ p.data_scadenza, p.stato AS prestito_stato, p.attivo,
u.nome AS utente_nome, u.cognome AS utente_cognome,
u.email AS utente_email
FROM copie c
diff --git a/app/Models/DashboardStats.php b/app/Models/DashboardStats.php
index 202977934..738da374b 100644
--- a/app/Models/DashboardStats.php
+++ b/app/Models/DashboardStats.php
@@ -85,12 +85,7 @@ public function activeLoans(): array
JOIN utenti u ON p.utente_id = u.id
WHERE p.stato IN ('in_corso','in_ritardo') AND p.attivo = 1";
$res = $this->db->query($sql);
- if ($res) {
- while ($row = $res->fetch_assoc()) {
- $rows[] = $row;
- }
- }
- return $rows;
+ return $res ? $this->withLoanDates($res->fetch_all(MYSQLI_ASSOC)) : $rows;
}
public function overdueLoans(): array
@@ -100,14 +95,35 @@ public function overdueLoans(): array
FROM prestiti p
JOIN libri l ON p.libro_id = l.id AND l.deleted_at IS NULL
JOIN utenti u ON p.utente_id = u.id
- WHERE p.stato='in_ritardo' AND p.attivo = 1";
- $res = $this->db->query($sql);
- if ($res) {
- while ($row = $res->fetch_assoc()) {
- $rows[] = $row;
- }
+ WHERE p.stato IN ('in_corso','in_ritardo') AND p.attivo = 1 AND p.data_scadenza < ?";
+ $stmt = $this->db->prepare($sql);
+ if ($stmt === false) {
+ return $rows;
}
- return $rows;
+ // The usable day is never before the stored one, so a loan due today or later
+ // cannot be late. Late by the calendar, whatever the stored flag says: a loan the
+ // sweep has not flagged yet is listed, one flagged before a closure moved its day is not.
+ $today = \App\Support\DateHelper::today();
+ $stmt->bind_param('s', $today);
+ $stmt->execute();
+ return array_values(array_filter(
+ $this->withLoanDates($stmt->get_result()->fetch_all(MYSQLI_ASSOC)),
+ static fn (array $row): bool => (bool) $row['is_overdue']
+ ));
+ }
+
+ /**
+ * Adds the usable return day and the calendar flags to loan rows, with one
+ * branch read for the whole list. The rows stay the ones the caller selected.
+ *
+ * @param list> $rows rows carrying copia_id, stato, attivo, data_scadenza
+ * @return list>
+ */
+ private function withLoanDates(array $rows): array
+ {
+ \App\Support\LibraryCalendar::primeCopyBranches($this->db, array_map(static fn (array $r): int => (int) ($r['copia_id'] ?? 0), $rows));
+ $today = \App\Support\DateHelper::today();
+ return array_map(fn (array $r): array => $r + \App\Support\LibraryCalendar::loanDates($this->db, $r, $today), $rows);
}
public function pendingLoans(int $limit = 4): array
@@ -225,7 +241,7 @@ public function calendarEvents(): array
// Fetch active/scheduled loans (in_corso, prenotato, in_ritardo, pendente)
// Include pendente loans with attivo=0 (waiting for admin approval)
- $loanSql = "SELECT p.id, p.stato, p.data_prestito, p.data_scadenza,
+ $loanSql = "SELECT p.id, p.copia_id, p.attivo, p.stato, p.data_prestito, p.data_scadenza,
l.titolo, CONCAT(u.nome, ' ', u.cognome) AS utente_nome,
'prestito' AS tipo
FROM prestiti p
@@ -241,18 +257,16 @@ public function calendarEvents(): array
$stmt = $this->db->prepare($loanSql);
$stmt->bind_param('sss', $today, $today, $sixMonthsLater);
$stmt->execute();
- $res = $stmt->get_result();
- while ($row = $res->fetch_assoc()) {
- $isOpenEnded = $row['stato'] === 'in_ritardo'
- || ($row['stato'] === 'in_corso' && (string) $row['data_scadenza'] < $today);
+ foreach ($this->withLoanDates($stmt->get_result()->fetch_all(MYSQLI_ASSOC)) as $row) {
+ // A loan out ends on its usable return day, or stays open through
+ // today once that day has gone by. The status stays the stored one.
+ $loanEnd = (string) ($row['effective_due_at'] ?? $row['data_scadenza']);
$events[] = [
'id' => 'loan_' . $row['id'],
'title' => $row['titolo'],
'user' => $row['utente_nome'],
'start' => $row['data_prestito'],
- 'end' => $isOpenEnded
- ? max((string) $row['data_scadenza'], $today)
- : $row['data_scadenza'],
+ 'end' => $row['is_overdue'] ? max($loanEnd, $today) : $loanEnd,
'type' => 'prestito',
'status' => $row['stato']
];
diff --git a/app/Models/LoanRepository.php b/app/Models/LoanRepository.php
index 7316a572b..efbe7edb0 100644
--- a/app/Models/LoanRepository.php
+++ b/app/Models/LoanRepository.php
@@ -17,7 +17,7 @@ public function listRecent(int $limit = 100): array
{
$rows = [];
$sql = "SELECT p.id, l.titolo AS libro_titolo, CONCAT(u.nome,' ',u.cognome) AS utente_nome,
- u.email as utente_email, p.data_prestito, p.data_scadenza, p.data_restituzione, p.stato, p.attivo
+ u.email as utente_email, p.copia_id, p.data_prestito, p.data_scadenza, p.data_restituzione, p.stato, p.attivo
FROM prestiti p
LEFT JOIN libri l ON p.libro_id = l.id AND l.deleted_at IS NULL
LEFT JOIN utenti u ON p.utente_id = u.id
@@ -28,7 +28,7 @@ public function listRecent(int $limit = 100): array
$stmt->execute();
$res = $stmt->get_result();
while ($row = $res->fetch_assoc()) {
- $rows[] = $row;
+ $rows[] = $row + \App\Support\LibraryCalendar::loanDates($this->db, $row);
}
return $rows;
}
@@ -55,7 +55,8 @@ public function getById(int $id): ?array
$stmt->bind_param('i', $id);
$stmt->execute();
$res = $stmt->get_result();
- return $res->fetch_assoc() ?: null;
+ $row = $res->fetch_assoc();
+ return $row ? $row + \App\Support\LibraryCalendar::loanDates($this->db, $row) : null;
}
/**
@@ -130,7 +131,7 @@ public function getActiveLoanByBook(int $bookId): ?array
$loan = $res->fetch_assoc();
$stmt->close();
- return $loan ?: null;
+ return $loan ? $loan + \App\Support\LibraryCalendar::loanDates($this->db, $loan) : null;
}
/**
@@ -238,7 +239,7 @@ public function close(int $id, ?int $expectedBookId = null, ?int $expectedUserId
// mezzanotte gmdate() registrerebbe il giorno sbagliato.
$today = DateHelper::today();
$scadenza = (string) ($lockedRow['data_scadenza'] ?? '');
- $ritardo = ($scadenza !== '' && $scadenza < $today) ? 1 : 0;
+ $ritardo = \App\Support\LibraryCalendar::isPastDue($this->db, $scadenza, $closedCopiaId, $today) ? 1 : 0;
$stmt = $this->db->prepare('UPDATE prestiti SET attivo=0, data_restituzione=?, stato="restituito", restituito_in_ritardo=? WHERE id=? AND attivo=1');
$stmt->bind_param('sii', $today, $ritardo, $id);
if (!$stmt->execute()) {
diff --git a/app/Models/SettingsRepository.php b/app/Models/SettingsRepository.php
index bfc008cad..736a703d5 100644
--- a/app/Models/SettingsRepository.php
+++ b/app/Models/SettingsRepository.php
@@ -107,6 +107,15 @@ public function allowsMultipleLoansSameBook(): bool
return ($this->get('loans', 'allow_multiple_loans_same_book', '0') ?? '0') === '1';
}
+ /**
+ * Whether a loan due on a closed day frees its book on the next open day
+ * itself, for a reader who starts that day, or only after it.
+ */
+ public function allowsSameDayHandover(): bool
+ {
+ return ($this->get('loans', 'same_day_handover_after_closure', '0') ?? '0') === '1';
+ }
+
/**
* Configured default loan duration (global rule, no patron context).
*
diff --git a/app/Services/CapacityService.php b/app/Services/CapacityService.php
index 7f0f0b284..73af55cc4 100644
--- a/app/Services/CapacityService.php
+++ b/app/Services/CapacityService.php
@@ -43,7 +43,12 @@ final class CapacityService
/** Circulation rules, resolved once per instance (the settings rows are cached there). */
private ?CirculationPolicy $circulation = null;
- public function __construct(private mysqli $db) {}
+ /**
+ * $lockingReads is for writers holding the book row in a transaction.
+ * Current reads must see commitments confirmed after an earlier calendar
+ * read established a REPEATABLE READ snapshot on the same connection.
+ */
+ public function __construct(private mysqli $db, private bool $lockingReads = false) {}
/**
* Lendable physical copies of a book (the capacity ceiling). If the book has
@@ -54,7 +59,8 @@ public function __construct(private mysqli $db) {}
*/
public function totalCopies(int $libroId): int
{
- $stmt = $this->db->prepare("SELECT COUNT(*) AS total FROM copie WHERE libro_id = ?");
+ $lock = $this->lockingReads ? ' FOR UPDATE' : '';
+ $stmt = $this->db->prepare("SELECT COUNT(*) AS total FROM copie WHERE libro_id = ?" . $lock);
$stmt->bind_param('i', $libroId);
$stmt->execute();
$copyRows = (int) ($stmt->get_result()->fetch_assoc()['total'] ?? 0);
@@ -65,7 +71,7 @@ public function totalCopies(int $libroId): int
"SELECT COUNT(*) AS total FROM copie
WHERE libro_id = ?
AND stato NOT IN ('perso','danneggiato','manutenzione','in_restauro','in_trasferimento')
- AND " . ($this->circulation ??= new CirculationPolicy($this->db))->lendableCopySql('') . ""
+ AND " . ($this->circulation ??= new CirculationPolicy($this->db))->lendableCopySql('') . $lock
);
$stmt->bind_param('i', $libroId);
$stmt->execute();
@@ -75,7 +81,7 @@ public function totalCopies(int $libroId): int
}
// No per-copy rows: legacy fallback to libri.copie_totali (NULL → 1, 0 → 0).
- $stmt = $this->db->prepare("SELECT IFNULL(copie_totali, 1) AS total FROM libri WHERE id = ? AND deleted_at IS NULL");
+ $stmt = $this->db->prepare("SELECT IFNULL(copie_totali, 1) AS total FROM libri WHERE id = ? AND deleted_at IS NULL" . $lock);
$stmt->bind_param('i', $libroId);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
@@ -401,7 +407,9 @@ private function holdingLoanIntervals(int $libroId, string $start, string $end,
LEAST(CASE
WHEN {$openEnded} THEN ?
ELSE p.data_scadenza
- END, ?) AS e
+ END, ?) AS e,
+ IF(p.attivo = 1, p.data_scadenza, NULL) AS stored_due,
+ p.copia_id AS stored_copy
FROM prestiti p
WHERE p.libro_id = ?
AND p.data_prestito <= ?
@@ -409,7 +417,11 @@ private function holdingLoanIntervals(int $libroId, string $start, string $end,
AND ( (p.attivo = 1 AND p.stato IN ('prenotato','da_ritirare','in_corso','in_ritardo'))
OR (p.attivo = 0 AND p.stato = 'pendente' AND p.copia_id IS NOT NULL) )";
$types = 'ssssisss';
- $params = [$start, $today, $end, $end, $libroId, $end, $today, $start];
+ // A loan whose stored day is closed ends on the next open day, which the
+ // reader is told, even when the move was refused and the row kept its
+ // date. With a calendar, a stored day before the window can still reach it.
+ $hasCalendar = \App\Support\LibraryCalendar::forDb($this->db)->hasRules();
+ $params = [$start, $today, $end, $end, $libroId, $end, $today, $hasCalendar ? '1000-01-01' : $start];
if ($excludePrestitoId !== null) {
$sql .= ' AND p.id <> ?';
$types .= 'i';
@@ -420,7 +432,7 @@ private function holdingLoanIntervals(int $libroId, string $start, string $end,
$types .= 'i';
$params[] = $excludeUserId;
}
- return $this->fetchIntervals($sql, $types, $params);
+ return $this->fetchIntervals($sql, $types, $params, $end);
}
/**
@@ -449,7 +461,7 @@ private function activeReservationIntervals(int $libroId, string $start, string
JOIN utenti u ON u.id = r.utente_id
WHERE r.libro_id = ?
AND r.stato = 'attiva'
- AND " . \App\Support\LoanEligibility::eligibleUserWhere('u', $this->db) . "
+ AND " . \App\Support\LoanEligibility::eligibleUserWhere('u', $this->db, $this->lockingReads) . "
AND $rStart IS NOT NULL
AND $rStart <= ?
AND $rEnd >= ?";
@@ -458,6 +470,10 @@ private function activeReservationIntervals(int $libroId, string $start, string
if ($circulation->anyTotalLoanLimit()) {
// Per-patron limit (CirculationPolicy; 0 = no limit for that patron).
$limitSql = $circulation->totalLoanLimitSql('u');
+ // A locking outer read does not lock a nested subquery, which would
+ // still count from the transaction's snapshot: lock it too, in the
+ // syntax MySQL and MariaDB both accept inside a subquery.
+ $countLock = $this->lockingReads ? ' LOCK IN SHARE MODE' : '';
// 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
@@ -477,7 +493,7 @@ private function activeReservationIntervals(int $libroId, string $start, string
OR (cap.data_prestito <= $rEnd
AND (cap.stato = 'in_ritardo'
OR (cap.stato = 'in_corso' AND cap.data_scadenza < ?)
- OR cap.data_scadenza >= $rStart)))
+ OR cap.data_scadenza >= $rStart))){$countLock}
) < {$limitSql})";
$types .= 'sss';
$params[] = $today;
@@ -487,7 +503,7 @@ private function activeReservationIntervals(int $libroId, string $start, string
// 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);
+ $materialRefused = $circulation->materialRefusedReservationUserIds($libroId, $this->lockingReads);
if ($materialRefused !== []) {
$sql .= ' AND NOT (r.utente_id IN (' . implode(',', array_map('intval', $materialRefused)) . ')
AND ' . \App\Support\LoanEligibility::promotableReservationWhere('r') . ')';
@@ -523,8 +539,11 @@ private function activeReservationIntervals(int $libroId, string $start, string
* @param list $params
* @return list
*/
- private function fetchIntervals(string $sql, string $types, array $params): array
+ private function fetchIntervals(string $sql, string $types, array $params, ?string $windowEnd = null): array
{
+ if ($this->lockingReads) {
+ $sql .= ' FOR UPDATE';
+ }
$stmt = $this->db->prepare($sql);
$refs = [$types];
foreach ($params as $k => $v) {
@@ -537,6 +556,11 @@ private function fetchIntervals(string $sql, string $types, array $params): arra
while ($row = $res->fetch_assoc()) {
$s = (string) $row['s'];
$e = (string) $row['e'];
+ $storedDue = (string) ($row['stored_due'] ?? '');
+ if ($windowEnd !== null && $storedDue !== '' && $e === $storedDue) {
+ $copyId = $row['stored_copy'] !== null ? (int) $row['stored_copy'] : null;
+ $e = min(\App\Support\LibraryCalendar::holdingEnd($this->db, $storedDue, $copyId), $windowEnd);
+ }
if ($s !== '' && $e !== '' && $s <= $e) {
$out[] = [$s, $e];
}
diff --git a/app/Services/CirculationPolicy.php b/app/Services/CirculationPolicy.php
index 622eea63b..cd8634165 100644
--- a/app/Services/CirculationPolicy.php
+++ b/app/Services/CirculationPolicy.php
@@ -281,15 +281,24 @@ public function countActiveLoans(int $userId, ?int $excludeLoanId = null): int
}
/** Open loans of $userId on titles of material $media (same states as countActiveLoans()). */
- public function countActiveLoansForMedia(int $userId, string $media, ?int $excludeLoanId = null): int
+ public function countActiveLoansForMedia(int $userId, string $media, ?int $excludeLoanId = null, bool $lockingReads = false): 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
+ // The material test stays in a nested subquery: an outer locking read
+ // does not lock its rows, so a calendar writer holding one book's lock
+ // takes no lock on the other books this patron has out.
+ $sql = 'SELECT COUNT(*) FROM prestiti p
WHERE p.utente_id = ? AND p.attivo = 1 AND p.stato IN (' . self::ACTIVE_LOAN_STATES_SQL . ')
- AND l.tipo_media = ?';
+ AND EXISTS (SELECT 1 FROM libri l WHERE l.id = p.libro_id AND l.tipo_media = ?)';
if ($excludeLoanId !== null) {
$sql .= ' AND p.id <> ?';
}
+ // Calendar writers may already have a REPEATABLE READ snapshot from
+ // their initial scan. A return committed since then makes a paused
+ // reservation eligible again and must be included in capacity now.
+ if ($lockingReads) {
+ $sql .= ' LOCK IN SHARE MODE';
+ }
$stmt = $this->db->prepare($sql);
if ($excludeLoanId !== null) {
$stmt->bind_param('isi', $userId, $media, $excludeLoanId);
@@ -337,13 +346,13 @@ public function loanLimitError(LoanPolicy $policy, int $userId, ?int $excludeLoa
*
* @return list
*/
- public function materialRefusedReservationUserIds(int $libroId): array
+ public function materialRefusedReservationUserIds(int $libroId, bool $lockingReads = false): 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 = $this->db->prepare("SELECT DISTINCT utente_id FROM prenotazioni WHERE libro_id = ? AND stato = 'attiva'" . ($lockingReads ? ' FOR UPDATE' : ''));
$stmt->bind_param('i', $libroId);
$stmt->execute();
$result = $stmt->get_result();
@@ -358,7 +367,7 @@ public function materialRefusedReservationUserIds(int $libroId): array
$policy = $this->forType($this->patronTypeOf($userId), $media);
if (!$policy->loanable
|| ($policy->maxActiveForMedia > 0 && $media !== self::ALL_MEDIA
- && $this->countActiveLoansForMedia($userId, $media) >= $policy->maxActiveForMedia)) {
+ && $this->countActiveLoansForMedia($userId, $media, lockingReads: $lockingReads) >= $policy->maxActiveForMedia)) {
$refused[] = $userId;
}
}
diff --git a/app/Services/PatronLedger.php b/app/Services/PatronLedger.php
index 9e9a90ff2..016097925 100644
--- a/app/Services/PatronLedger.php
+++ b/app/Services/PatronLedger.php
@@ -158,10 +158,15 @@ public static function resetSchemaCache(): void
self::$schemaReady = null;
}
- /** SQL expression for a patron's balance; $userIdExpr is a fixed column reference. */
- public static function balanceSql(string $userIdExpr): string
+ /**
+ * SQL expression for a patron's balance; $userIdExpr is a fixed column
+ * reference. Capacity writers use a current read so a payment committed
+ * after their initial snapshot can re-enable the reader's reservation.
+ */
+ public static function balanceSql(string $userIdExpr, bool $lockingReads = false): string
{
- return "(SELECT COALESCE(SUM(ml.importo), 0) FROM movimenti_lettore ml WHERE ml.utente_id = {$userIdExpr})";
+ $lock = $lockingReads ? ' LOCK IN SHARE MODE' : '';
+ return "(SELECT COALESCE(SUM(ml.importo), 0) FROM movimenti_lettore ml WHERE ml.utente_id = {$userIdExpr}{$lock})";
}
/**
@@ -187,10 +192,11 @@ public static function legacyPenaltiesPending(mysqli $db): bool
* charge, so adding it gives the balance the patron will have once the
* conversion completes. $userIdExpr is a fixed column reference.
*/
- public static function pendingLegacyPenaltiesSql(string $userIdExpr): string
+ public static function pendingLegacyPenaltiesSql(string $userIdExpr, bool $lockingReads = false): string
{
+ $lock = $lockingReads ? ' LOCK IN SHARE MODE' : '';
return "(SELECT COALESCE(SUM(lp.sanzione), 0) FROM prestiti lp WHERE lp.utente_id = {$userIdExpr} AND lp.sanzione > 0"
- . " AND NOT EXISTS (SELECT 1 FROM movimenti_lettore lm WHERE lm.chiave_idempotenza = CONCAT('penalty:', lp.id)))";
+ . " AND NOT EXISTS (SELECT 1 FROM movimenti_lettore lm WHERE lm.chiave_idempotenza = CONCAT('penalty:', lp.id){$lock}){$lock})";
}
/**
@@ -200,12 +206,12 @@ public static function pendingLegacyPenaltiesSql(string $userIdExpr): string
* patron over the threshold until maintenance finishes it. Once the
* marker is set this is balanceSql() and costs nothing more.
*/
- public static function debtBalanceSql(string $userIdExpr, mysqli $db): string
+ public static function debtBalanceSql(string $userIdExpr, mysqli $db, bool $lockingReads = false): string
{
if (!self::legacyPenaltiesPending($db)) {
- return self::balanceSql($userIdExpr);
+ return self::balanceSql($userIdExpr, $lockingReads);
}
- return '(' . self::balanceSql($userIdExpr) . ' + ' . self::pendingLegacyPenaltiesSql($userIdExpr) . ')';
+ return '(' . self::balanceSql($userIdExpr, $lockingReads) . ' + ' . self::pendingLegacyPenaltiesSql($userIdExpr, $lockingReads) . ')';
}
/** PHP twin of debtBalanceSql() for one patron. */
diff --git a/app/Services/ReservationReassignmentService.php b/app/Services/ReservationReassignmentService.php
index f95039119..6f0c1bde1 100644
--- a/app/Services/ReservationReassignmentService.php
+++ b/app/Services/ReservationReassignmentService.php
@@ -729,7 +729,7 @@ private function notifyUserCopyAvailable(int $prestitoId): void
{
// Recupera dati necessari per la notifica
$stmt = $this->db->prepare("
- SELECT p.id, p.utente_id, p.libro_id, p.data_prestito, p.data_scadenza,
+ SELECT p.id, p.utente_id, p.libro_id, p.copia_id, p.data_prestito, p.data_scadenza,
u.email, u.nome as utente_nome,
l.titolo as libro_titolo, l.isbn13, l.isbn10
FROM prestiti p
@@ -777,7 +777,7 @@ private function notifyUserCopyAvailable(int $prestitoId): void
'libro_autore' => $author['nome'] ?? $this->notificationService->translateInLocale('Autore sconosciuto', $recipientLocale),
'libro_isbn' => $isbn,
'data_inizio' => $data['data_prestito'] ? $this->notificationService->formatEmailDate((string) $data['data_prestito'], false, $recipientLocale) : '',
- 'data_fine' => $data['data_scadenza'] ? $this->notificationService->formatEmailDate((string) $data['data_scadenza'], false, $recipientLocale) : '',
+ 'data_fine' => $data['data_scadenza'] ? $this->notificationService->formatEmailDate(\App\Support\LibraryCalendar::dueDate($this->db, (string) $data['data_scadenza'], $data['copia_id'] !== null ? (int) $data['copia_id'] : null), false, $recipientLocale) : '',
'book_url' => absoluteUrl($bookLink),
'profile_url' => absoluteUrl(RouteTranslator::route('profile'))
];
diff --git a/app/Support/IcsGenerator.php b/app/Support/IcsGenerator.php
index db2423906..468b8b7a1 100644
--- a/app/Support/IcsGenerator.php
+++ b/app/Support/IcsGenerator.php
@@ -80,7 +80,7 @@ public function generateForLoan(int $loanId): ?string
// loan on a record must keep being able to add it to their calendar,
// even in the legacy case of a flagged title that still carries one.
- $stmt = $this->db->prepare("SELECT p.id, p.stato, p.data_prestito, p.data_scadenza, p.updated_at,
+ $stmt = $this->db->prepare("SELECT p.id, p.stato, p.attivo, p.copia_id, p.data_prestito, p.data_scadenza, p.updated_at,
l.titolo, CONCAT(u.nome, ' ', u.cognome) AS utente_nome
FROM prestiti p
JOIN libri l ON p.libro_id = l.id AND l.deleted_at IS NULL
@@ -103,7 +103,11 @@ public function generateForLoan(int $loanId): ?string
// Mirror fetchEvents(): an overdue copy is still physically occupied
// through today, so ending the event on the missed due date would
// falsely render it as already available.
- $endDate = (string) $row['data_scadenza'];
+ $dates = LibraryCalendar::loanDates($this->db, $row);
+ $endDate = (string) ($dates['effective_due_at'] ?? $row['data_scadenza']);
+ if ((int) $row['attivo'] === 1 && in_array($row['stato'], ['in_corso', 'in_ritardo'], true)) {
+ $row['stato'] = $dates['is_overdue'] ? 'in_ritardo' : 'in_corso';
+ }
if ($row['stato'] === 'in_ritardo') {
$endDate = max($endDate, DateHelper::today());
}
@@ -196,7 +200,7 @@ private function fetchEvents(bool $catalogueOnly = false, bool $includePersonalD
: '';
// Fetch active/scheduled loans
- $loanSql = "SELECT p.id, p.stato, p.data_prestito, p.data_scadenza, p.pickup_deadline,
+ $loanSql = "SELECT p.id, p.stato, p.copia_id, p.data_prestito, p.data_scadenza, p.pickup_deadline,
l.titolo, CONCAT(u.nome, ' ', u.cognome) AS utente_nome,
u.email, p.updated_at
FROM prestiti p
@@ -210,7 +214,7 @@ private function fetchEvents(bool $catalogueOnly = false, bool $includePersonalD
THEN p.pickup_deadline
ELSE p.data_scadenza
END) >= ?
- OR p.stato = 'in_ritardo'
+ OR p.stato IN ('in_corso', 'in_ritardo', 'prenotato')
)
ORDER BY p.data_prestito ASC";
$stmt = $this->db->prepare($loanSql);
@@ -222,12 +226,22 @@ private function fetchEvents(bool $catalogueOnly = false, bool $includePersonalD
return $events;
}
$res = $stmt->get_result();
- while ($row = $res->fetch_assoc()) {
+ $loans = $res->fetch_all(MYSQLI_ASSOC);
+ LibraryCalendar::primeCopyBranches($this->db, array_map(static fn (array $r): int => (int) ($r['copia_id'] ?? 0), $loans));
+ foreach ($loans as $row) {
+ $dates = LibraryCalendar::loanDates($this->db, $row, $today);
+ // A booked loan ends on its usable day, which a closure can put after the stored one.
+ if ($row['stato'] === 'prenotato' && (string) ($dates['effective_due_at'] ?? $row['data_scadenza']) < $today) {
+ continue;
+ }
+ if (in_array($row['stato'], ['in_corso', 'in_ritardo'], true)) {
+ $row['stato'] = $dates['is_overdue'] ? 'in_ritardo' : 'in_corso';
+ }
// For da_ritirare state, use pickup_deadline as end date (shows pickup window)
// For other states, use data_scadenza (shows full loan period)
$endDate = ($row['stato'] === 'da_ritirare' && !empty($row['pickup_deadline']))
? $row['pickup_deadline']
- : $row['data_scadenza'];
+ : ($dates['effective_due_at'] ?? $row['data_scadenza']);
if ($row['stato'] === 'in_ritardo') {
// The copy remains physically occupied through today; ending the
// event on the missed due date falsely shows it as available.
diff --git a/app/Support/LibraryCalendar.php b/app/Support/LibraryCalendar.php
index c7211ffaf..45baba744 100644
--- a/app/Support/LibraryCalendar.php
+++ b/app/Support/LibraryCalendar.php
@@ -63,6 +63,8 @@ final class LibraryCalendar
/** @var array per-connection cache, keyed by spl_object_id */
private static array $instances = [];
+ private const REVISION_STEP = 3660;
+
/** @var array branchOfCopy() memo: copy id => sede_id (null when none) */
private array $copyBranches = [];
@@ -211,6 +213,75 @@ public static function isPastDue(\mysqli $db, string $dueDate, ?int $copiaId, st
return $calendar->nextOpenDay($due, self::branchOfCopy($db, $copiaId)) < $today;
}
+ /**
+ * Calendar facts shared by web lists and native clients. The persisted
+ * date remains available for history and editing; only an outstanding
+ * physical loan receives a later usable return day.
+ * @param array $loan
+ * @return array{effective_due_at: ?string, is_overdue: bool, due_attention: bool}
+ */
+ public static function loanDates(\mysqli $db, array $loan, ?string $today = null): array
+ {
+ $today ??= DateHelper::today();
+ $due = !empty($loan['data_scadenza']) ? substr((string) $loan['data_scadenza'], 0, 10) : null;
+ $holding = (int) ($loan['attivo'] ?? 1) === 1 && in_array($loan['stato'] ?? '', ['in_corso', 'in_ritardo'], true);
+ $copyId = isset($loan['copia_id']) ? (int) $loan['copia_id'] : null;
+ // A scheduled loan whose move was refused also ends on the next open day;
+ // it cannot be late or due yet, so only the date follows the calendar.
+ $scheduled = (int) ($loan['attivo'] ?? 1) === 1 && in_array($loan['stato'] ?? '', ['prenotato', 'da_ritirare'], true);
+ $effectiveDue = ($holding || $scheduled) && $due !== null ? self::dueDate($db, $due, $copyId) : $due;
+ return [
+ 'effective_due_at' => $effectiveDue,
+ 'is_overdue' => $holding && $due !== null && self::isPastDue($db, $due, $copyId, $today),
+ 'due_attention' => $holding && $effectiveDue !== null && $effectiveDue <= $today,
+ ];
+ }
+
+ /**
+ * Ids of the loans out that are late on $today by isPastDue(), whatever
+ * their stored stato says. Reads prestiti only: a caller that lists or
+ * counts titles keeps its own join with libri.
+ *
+ * @return list
+ */
+ public static function overdueLoanIds(\mysqli $db, string $today): array
+ {
+ $stmt = $db->prepare(
+ "SELECT id, data_scadenza, copia_id FROM prestiti
+ WHERE attivo = 1 AND stato IN ('in_corso', 'in_ritardo') AND data_scadenza < ?"
+ );
+ if ($stmt === false) {
+ throw new \RuntimeException('LibraryCalendar: overdue loans query not prepared');
+ }
+ $stmt->bind_param('s', $today);
+ $stmt->execute();
+ $rows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
+ $stmt->close();
+ self::primeCopyBranches($db, array_map(static fn (array $r): int => (int) ($r['copia_id'] ?? 0), $rows));
+ $ids = [];
+ foreach ($rows as $row) {
+ if (self::isPastDue($db, (string) $row['data_scadenza'], $row['copia_id'] !== null ? (int) $row['copia_id'] : null, $today)) {
+ $ids[] = (int) $row['id'];
+ }
+ }
+ return $ids;
+ }
+
+ /**
+ * SQL condition, for a row of prestiti already restricted to loans out, that
+ * holds when the loan is late by isPastDue(). With no calendar rules that is
+ * the plain date comparison, served by the data_scadenza indexes; with rules
+ * it is the id list of overdueLoanIds(). Contains no bound parameter.
+ */
+ public static function overdueLoanCondition(\mysqli $db, string $today, string $alias = 'p'): string
+ {
+ if (!self::forDb($db)->hasRules()) {
+ return "{$alias}.data_scadenza < '" . $db->real_escape_string($today) . "'";
+ }
+ $ids = self::overdueLoanIds($db, $today);
+ return $ids === [] ? '1 = 0' : "{$alias}.id IN (" . implode(',', $ids) . ')';
+ }
+
/**
* After a calendar change: an active loan whose due date (today or later)
* now falls on a closed day of the copy's branch is due on the next open
@@ -260,7 +331,7 @@ public static function extendDueDates(\mysqli $db, ?int $operatorId = null): int
$rows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
$stmt->close();
- $capacity = new \App\Services\CapacityService($db);
+ $capacity = new \App\Services\CapacityService($db, lockingReads: true);
$moved = 0;
$notMoved = 0;
foreach ($rows as $row) {
@@ -292,6 +363,34 @@ public static function extendDueDates(\mysqli $db, ?int $operatorId = null): int
$lockBook->execute();
$lockBook->get_result();
$lockBook->close();
+ // The copy of a scheduled loan can be replaced while this
+ // save waits for the book lock (e.g. the original was lost).
+ // Re-read the loan and its current branch with a current read;
+ // applying the scan's branch would move an open-day deadline.
+ $lockLoan = $db->prepare(
+ "SELECT p.data_scadenza, c.sede_id
+ FROM prestiti p LEFT JOIN copie c ON c.id = p.copia_id
+ WHERE p.id = ? AND p.libro_id = ? AND p.attivo = 1
+ AND p.stato IN ('in_corso', 'da_ritirare', 'prenotato')
+ AND p.data_scadenza >= ? FOR UPDATE"
+ );
+ $lockLoan->bind_param('iis', $id, $bookId, $today);
+ $lockLoan->execute();
+ $current = $lockLoan->get_result()->fetch_assoc();
+ $lockLoan->close();
+ if ($current === null) {
+ if ($ownTransaction) { $db->commit(); }
+ continue;
+ }
+ $due = substr((string) $current['data_scadenza'], 0, 10);
+ $next = $calendar->nextOpenDay($due, $current['sede_id'] !== null ? (int) $current['sede_id'] : null);
+ if (!DateHelper::isISODateFormat($due) || $next <= $due) {
+ if ($ownTransaction) { $db->commit(); }
+ continue;
+ }
+ // The calendar scan may already have opened an older MVCC
+ // snapshot. Locking capacity reads see the operator who held
+ // this book lock before us, including copyless reservations.
if ($capacity->hasFreeCapacity($bookId, self::shift($due, 1), $next, excludePrestitoId: $id)) {
$before = ActivityLog::loadLoanSnapshot($db, $id);
$update = $db->prepare(
@@ -316,10 +415,13 @@ public static function extendDueDates(\mysqli $db, ?int $operatorId = null): int
} catch (\Throwable) {
}
}
- $lockConflict = $e instanceof \mysqli_sql_exception && in_array($e->getCode(), [1205, 1213], true);
- // Deadlock / lock wait timeout inside a caller's transaction:
- // MySQL rolled it back (or will), and it must not commit as if
- // the move had worked.
+ $lockConflict = $e instanceof \mysqli_sql_exception && in_array($e->getCode(), [1020, 1205, 1213], true);
+ // Deadlock, lock wait timeout or, on MariaDB with
+ // innodb_snapshot_isolation, a row changed since the snapshot
+ // (1020) inside a caller's transaction: the server rolled it
+ // back (or will), and it must not commit as if the move had
+ // worked. Carrying on would give each remaining loan its own
+ // committed transaction, moved for a closure no longer saved.
if ($lockConflict && !$ownTransaction) {
throw $e;
}
@@ -384,7 +486,7 @@ public static function extendDueDatesAfterSave(\mysqli $db): bool
private static function reconcileOverdue(\mysqli $db, string $today, ?int $operatorId): void
{
$stmt = $db->prepare(
- "SELECT id, data_scadenza, copia_id FROM prestiti
+ "SELECT id, libro_id, data_scadenza, copia_id FROM prestiti
WHERE attivo = 1 AND stato = 'in_ritardo' AND data_scadenza IS NOT NULL AND data_scadenza < ?"
);
if ($stmt === false) {
@@ -400,17 +502,131 @@ private static function reconcileOverdue(\mysqli $db, string $today, ?int $opera
continue;
}
$id = (int) $row['id'];
- $before = ActivityLog::loadLoanSnapshot($db, $id);
- $update = $db->prepare("UPDATE prestiti SET stato = 'in_corso' WHERE id = ? AND attivo = 1 AND stato = 'in_ritardo' AND data_scadenza = ?");
$due = (string) $row['data_scadenza'];
- $update->bind_param('is', $id, $due);
- $update->execute();
- $changed = $update->affected_rows > 0;
- $update->close();
- if ($changed) {
- ActivityLog::recordLoanEvent($db, $id, 'loan.updated', $before, 'aggiornamento', 'calendar', $operatorId);
+ self::withBookLock($db, (int) $row['libro_id'], static function () use ($db, $id, $due, $operatorId): void {
+ $before = ActivityLog::loadLoanSnapshot($db, $id);
+ $update = $db->prepare("UPDATE prestiti SET stato = 'in_corso' WHERE id = ? AND attivo = 1 AND stato = 'in_ritardo' AND data_scadenza = ?");
+ $update->bind_param('is', $id, $due);
+ $update->execute();
+ $changed = $update->affected_rows > 0;
+ $update->close();
+ if ($changed) {
+ ActivityLog::recordLoanEvent($db, $id, 'loan.updated', $before, 'aggiornamento', 'calendar', $operatorId);
+ }
+ });
+ }
+ }
+
+ /**
+ * Runs a change to a loan with its book row locked first, the order desk
+ * operations use. A caller's transaction keeps the lock until it commits;
+ * without one the change gets its own.
+ */
+ private static function withBookLock(\mysqli $db, int $bookId, callable $change): void
+ {
+ $ownTransaction = !DbAtomic::inTransaction($db);
+ if ($ownTransaction) {
+ $db->begin_transaction();
+ }
+ try {
+ // CI-SOFT-DELETE-EXEMPT: a lock, not a read for display; an active
+ // loan may belong to an archived book and its row is locked all the same.
+ // @include-soft-deleted (tests/code-quality.spec.js, check 5).
+ $lock = $db->prepare('SELECT id FROM libri WHERE id = ? FOR UPDATE');
+ $lock->bind_param('i', $bookId);
+ $lock->execute();
+ $lock->get_result();
+ $lock->close();
+ $change();
+ if ($ownTransaction) {
+ $db->commit();
+ }
+ } catch (\Throwable $e) {
+ if ($ownTransaction) {
+ $db->rollback();
}
+ throw $e;
+ }
+ }
+
+ /**
+ * Records that the calendar's rules changed. The personal feed adds this to
+ * SEQUENCE, so it keeps rising when a change brings an event's end back: each
+ * step is larger than the days a closure can have added to that end.
+ */
+ public static function bumpRevision(\mysqli $db): void
+ {
+ $step = self::REVISION_STEP;
+ $db->query(
+ "INSERT INTO system_settings (category, setting_key, setting_value) VALUES ('library_calendar', 'revision', '{$step}')
+ ON DUPLICATE KEY UPDATE setting_value = CAST(setting_value AS UNSIGNED) + {$step}"
+ );
+ }
+
+ /** Last bumpRevision() value, 0 when the calendar has never changed. */
+ public static function revision(\mysqli $db): int
+ {
+ $result = $db->query("SELECT setting_value FROM system_settings WHERE category = 'library_calendar' AND setting_key = 'revision'");
+ $row = $result instanceof \mysqli_result ? $result->fetch_row() : null;
+ return $row !== null ? (int) $row[0] : 0;
+ }
+
+ /**
+ * Last day a loan with this stored due date keeps its book: the usable
+ * return day the reader is told, or the day before it when the library hands
+ * the book over on that day itself. Both capacity and the request calendar read it.
+ */
+ public static function holdingEnd(\mysqli $db, string $storedDue, ?int $copiaId): string
+ {
+ $usable = self::dueDate($db, $storedDue, $copiaId);
+ if ($usable > $storedDue && (new \App\Models\SettingsRepository($db))->allowsSameDayHandover()) {
+ return (new \DateTimeImmutable($usable))->modify('-1 day')->format('Y-m-d');
}
+ return $usable;
+ }
+
+ /**
+ * The next open day a request for this book can start on: its copies'
+ * branch when they all sit in one, otherwise the library as a whole, since
+ * no copy is bound yet.
+ */
+ public static function requestStartDay(\mysqli $db, string $ymd, int $bookId): string
+ {
+ $calendar = self::forDb($db);
+ if (!$calendar->hasRules()) {
+ return $ymd;
+ }
+ return $calendar->nextOpenDay($ymd, self::requestBranch($db, $bookId));
+ }
+
+ /**
+ * Due date of a request for this book starting on $startYmd and lasting
+ * $days: moved past the closures of the same branch requestStartDay() and
+ * the request calendar use, so every return day the page offers is accepted.
+ */
+ public static function requestDueDate(\mysqli $db, string $startYmd, int $days, int $bookId): string
+ {
+ $due = \App\Services\CirculationPolicy::dueDateFor($startYmd, $days);
+ $calendar = self::forDb($db);
+ if (!$calendar->hasRules()) {
+ return $due;
+ }
+ return $calendar->nextOpenDay($due, self::requestBranch($db, $bookId));
+ }
+
+ /** The branch of the book's lendable copies when they all sit in one, otherwise null. */
+ public static function requestBranch(\mysqli $db, int $bookId): ?int
+ {
+ $lendable = (new \App\Services\CirculationPolicy($db))->lendableCopySql('');
+ $stmt = $db->prepare(
+ "SELECT DISTINCT sede_id FROM copie
+ WHERE libro_id = ? AND stato NOT IN ('perso','danneggiato','manutenzione','in_restauro','in_trasferimento') AND {$lendable}"
+ );
+ $stmt->bind_param('i', $bookId);
+ $stmt->execute();
+ $branches = $stmt->get_result()->fetch_all(MYSQLI_NUM);
+ $stmt->close();
+ return count($branches) === 1 && $branches[0][0] !== null ? (int) $branches[0][0] : null;
}
/**
@@ -484,7 +700,7 @@ public static function extendPickupDeadlines(\mysqli $db): int
}
$today = DateHelper::today();
$result = $db->query(
- "SELECT p.id, p.pickup_deadline, p.data_scadenza, c.sede_id
+ "SELECT p.id, p.libro_id, p.pickup_deadline, p.data_scadenza, c.sede_id
FROM prestiti p
LEFT JOIN copie c ON c.id = p.copia_id
WHERE p.stato = 'da_ritirare' AND p.attivo = 1 AND p.pickup_deadline IS NOT NULL"
@@ -496,10 +712,6 @@ public static function extendPickupDeadlines(\mysqli $db): int
$result->free();
$moved = 0;
- $stmt = $db->prepare(
- "UPDATE prestiti SET pickup_deadline = ?
- WHERE id = ? AND stato = 'da_ritirare' AND attivo = 1 AND pickup_deadline = ?"
- );
foreach ($rows as $row) {
$deadline = substr((string) $row['pickup_deadline'], 0, 10);
$next = $calendar->nextOpenDay($deadline, $row['sede_id'] !== null ? (int) $row['sede_id'] : null);
@@ -511,11 +723,17 @@ public static function extendPickupDeadlines(\mysqli $db): int
continue;
}
$id = (int) $row['id'];
- $stmt->bind_param('sis', $next, $id, $deadline);
- $stmt->execute();
- $moved += max(0, $stmt->affected_rows);
+ self::withBookLock($db, (int) $row['libro_id'], static function () use ($db, $next, $id, $deadline, &$moved): void {
+ $stmt = $db->prepare(
+ "UPDATE prestiti SET pickup_deadline = ?
+ WHERE id = ? AND stato = 'da_ritirare' AND attivo = 1 AND pickup_deadline = ?"
+ );
+ $stmt->bind_param('sis', $next, $id, $deadline);
+ $stmt->execute();
+ $moved += max(0, $stmt->affected_rows);
+ $stmt->close();
+ });
}
- $stmt->close();
return $moved;
}
@@ -1137,6 +1355,45 @@ public static function branchOfCopy(\mysqli $db, ?int $copiaId): ?int
return $calendar->copyBranches[$copiaId] = self::queryBranchOfCopy($db, $copiaId);
}
+ /**
+ * Read the branches of many copies in one query (a page of a loan list),
+ * so the per-loan calendar reads that follow hit the cache. Nothing to
+ * read when the calendar has no rules: no date then depends on a branch.
+ * @param list $copiaIds
+ */
+ public static function primeCopyBranches(\mysqli $db, array $copiaIds): void
+ {
+ $calendar = self::forDb($db);
+ if (!$calendar->hasRules()) {
+ return;
+ }
+ $ids = array_values(array_unique(array_filter(
+ array_map('intval', $copiaIds),
+ static fn (int $id): bool => $id > 0 && !array_key_exists($id, $calendar->copyBranches)
+ )));
+ if ($ids === []) {
+ return;
+ }
+ try {
+ $result = $db->query('SELECT id, sede_id FROM copie WHERE id IN (' . implode(',', $ids) . ')');
+ if (!$result instanceof \mysqli_result) {
+ throw new \RuntimeException('copy branches query failed');
+ }
+ $found = [];
+ while (($row = $result->fetch_row()) !== null) {
+ $found[(int) $row[0]] = $row[1] === null ? null : (int) $row[1];
+ }
+ $result->free();
+ } catch (\Throwable $e) {
+ SecureLogger::warning('LibraryCalendar: copy branches not readable: ' . $e->getMessage());
+ throw new \RuntimeException('LibraryCalendar: copy branches not readable', 0, $e);
+ }
+ // A missing copy has no branch, as branchOfCopy() answers for it.
+ foreach ($ids as $id) {
+ $calendar->copyBranches[$id] = $found[$id] ?? null;
+ }
+ }
+
private static function queryBranchOfCopy(\mysqli $db, int $copiaId): ?int
{
try {
diff --git a/app/Support/LoanEligibility.php b/app/Support/LoanEligibility.php
index c484d63be..b362628a0 100644
--- a/app/Support/LoanEligibility.php
+++ b/app/Support/LoanEligibility.php
@@ -163,7 +163,7 @@ public static function promotableReservationWhere(string $alias): string
* for the 0.9.0 tables (code ships before its migration runs): without
* the ledger table the fragment carries no debt predicate at all.
*/
- public static function eligibleUserWhere(string $alias, \mysqli $db): string
+ public static function eligibleUserWhere(string $alias, \mysqli $db, bool $lockingReads = false): string
{
return "(
{$alias}.stato = 'attivo'
@@ -172,7 +172,7 @@ public static function eligibleUserWhere(string $alias, \mysqli $db): string
OR {$alias}.data_scadenza_tessera IS NULL
OR {$alias}.data_scadenza_tessera >= ?
)
- AND " . self::debtBlockSql($alias, $db) . "
+ AND " . self::debtBlockSql($alias, $db, $lockingReads) . "
)";
}
@@ -190,7 +190,7 @@ public static function eligibleUserWhere(string $alias, \mysqli $db): string
* penalty backfill is pending the unconverted prestiti.sanzione amounts
* count too (PatronLedger::debtBalanceSql()).
*/
- public static function debtBlockSql(string $alias, \mysqli $db): string
+ public static function debtBlockSql(string $alias, \mysqli $db, bool $lockingReads = false): string
{
if (!\App\Services\PatronLedger::activeOrFail($db) || !Features::enabled('debt_block')) {
return '1 = 1';
@@ -198,7 +198,7 @@ public static function debtBlockSql(string $alias, \mysqli $db): string
$threshold = self::debtThresholdSql($alias, $db);
return "(
COALESCE({$threshold}, 0) <= 0
- OR " . \App\Services\PatronLedger::debtBalanceSql($alias . '.id', $db) . " <= {$threshold}
+ OR " . \App\Services\PatronLedger::debtBalanceSql($alias . '.id', $db, $lockingReads) . " <= {$threshold}
)";
}
diff --git a/app/Support/LoanPdfGenerator.php b/app/Support/LoanPdfGenerator.php
index 68fb1abc3..49689fcee 100644
--- a/app/Support/LoanPdfGenerator.php
+++ b/app/Support/LoanPdfGenerator.php
@@ -176,7 +176,7 @@ private function renderLoanDetails(TCPDF $pdf, array $loan): void
$this->renderSection($pdf, __('Informazioni Prestito'), [
__('ID Prestito:') => $loan['id'],
__('Data Prestito:') => format_date($loan['data_prestito'], false, '/'),
- __('Data Scadenza:') => format_date($loan['data_scadenza'], false, '/'),
+ __('Data Scadenza:') => format_date($loan['effective_due_at'] ?? $loan['data_scadenza'], false, '/'),
__('Stato:') => $this->translateStatus($loan['stato']),
]);
diff --git a/app/Support/NotificationService.php b/app/Support/NotificationService.php
index 3a11725d5..d5ee2c87c 100644
--- a/app/Support/NotificationService.php
+++ b/app/Support/NotificationService.php
@@ -116,6 +116,20 @@ private function expectedPickupDeadline(string $start, string $end, ?int $copiaI
return LibraryCalendar::pickupDeadline($this->db, $start, $this->pickupExpiryDays(), $end !== '' ? $end : null, $copiaId);
}
+ /**
+ * The return day a notice quotes for a loan row: the first open day on or
+ * after its stored data_scadenza, in the calendar of the copy's branch. The
+ * stored column stays what claims, reverts and late-day counts compare; with
+ * no calendar rules this is the stored date.
+ *
+ * @param array $loan a row carrying data_scadenza and, when the loan has a copy, copia_id
+ */
+ private function usableDueDate(array $loan): string
+ {
+ $copyId = !empty($loan['copia_id']) ? (int) $loan['copia_id'] : null;
+ return LibraryCalendar::dueDate($this->db, substr((string) ($loan['data_scadenza'] ?? ''), 0, 10), $copyId);
+ }
+
/**
* Format date for email templates.
*
@@ -559,7 +573,7 @@ public function notifyLoanRequest(int $loanId): bool {
// Send email to admins — dates formatted per admin locale.
$emailSent = $this->sendToAdmins('loan_request_notification', $variables, [
'data_inizio' => [$loan['data_prestito'], false],
- 'data_fine' => [$loan['data_scadenza'], false],
+ 'data_fine' => [$this->usableDueDate($loan), false],
'data_richiesta' => [$loan['created_at'], true],
]);
@@ -570,7 +584,7 @@ public function notifyLoanRequest(int $loanId): bool {
$loan['libro_titolo'],
$loan['utente_nome'],
format_date($loan['data_prestito'], false, '/'),
- format_date($loan['data_scadenza'], false, '/')
+ format_date($this->usableDueDate($loan), false, '/')
);
$notificationLink = '/admin/loans';
@@ -605,6 +619,7 @@ public function sendLoanExpirationWarnings(): int {
// dipende dalla session timezone del client DB, che differiva tra cron
// (UTC forzato) e web (nessuna impostazione).
$today = DateHelper::today();
+ $warningLimit = (new \DateTimeImmutable($today))->modify('+' . $daysBeforeWarning . ' days')->format('Y-m-d');
// Include every still-unnotified loan from today through the configured
// warning horizon. An exact "today + X" match misses short loans created
@@ -612,18 +627,18 @@ public function sendLoanExpirationWarnings(): int {
// Overdue loans remain handled separately below by the overdue workflow.
// CI-SOFT-DELETE-EXEMPT: the expiry warning must keep firing for active loans whose book was archived — the copy is still out and the borrower must be chased (same rationale as sendLoanRecalls).
$stmt = $this->db->prepare("
- SELECT p.id, p.data_scadenza, l.titolo as libro_titolo,
- CONCAT(u.nome, ' ', u.cognome) as utente_nome, u.email as utente_email,
- DATEDIFF(p.data_scadenza, ?) as giorni_rimasti
+ SELECT p.id, p.utente_id, p.copia_id, c.sede_id, p.data_scadenza, l.titolo as libro_titolo,
+ CONCAT(u.nome, ' ', u.cognome) as utente_nome, u.email as utente_email
FROM prestiti p
JOIN libri l ON p.libro_id = l.id
JOIN utenti u ON p.utente_id = u.id
- WHERE p.stato = 'in_corso'
+ LEFT JOIN copie c ON c.id = p.copia_id
+ WHERE p.stato IN ('in_corso', 'in_ritardo')
AND p.attivo = 1
- AND p.data_scadenza BETWEEN ? AND DATE_ADD(?, INTERVAL ? DAY)
+ AND p.data_scadenza <= DATE_ADD(?, INTERVAL ? DAY)
AND (p.warning_sent IS NULL OR p.warning_sent = 0)
");
- $stmt->bind_param('sssi', $today, $today, $today, $daysBeforeWarning);
+ $stmt->bind_param('si', $today, $daysBeforeWarning);
$stmt->execute();
$result = $stmt->get_result();
@@ -640,8 +655,16 @@ public function sendLoanExpirationWarnings(): int {
// session locale (this runs from the admin-login maintenance path
// as well as cron).
$todayLabels = [];
+ $candidateCalendar = LibraryCalendar::forDb($this->db);
foreach ($loans as $loan) {
+ $copyId = $loan['copia_id'] !== null ? (int) $loan['copia_id'] : null;
+ // The sweep may include many genuinely overdue rows: their
+ // branches come from the same query, without a query per copy.
+ $effectiveDue = $candidateCalendar->nextOpenDay((string) $loan['data_scadenza'], $loan['sede_id'] !== null ? (int) $loan['sede_id'] : null);
+ if ($effectiveDue < $today || $effectiveDue > $warningLimit) {
+ continue;
+ }
$recipientLocale = $this->resolveRecipientLocale((string) $loan['utente_email']);
$todayLabels[$recipientLocale] ??= $this->translateInLocale('oggi', $recipientLocale);
// ATOMIC: Mark warning as sent BEFORE sending email
@@ -649,11 +672,23 @@ public function sendLoanExpirationWarnings(): int {
// Re-assert data_scadenza too: renew()/update() may have moved the
// due date between the SELECT and this claim; without it we'd send a
// warning quoting a now-stale date (#252).
- $updateStmt = $this->db->prepare("UPDATE prestiti SET warning_sent = 1 WHERE id = ? AND attivo = 1 AND stato = 'in_corso' AND data_scadenza = ? AND (warning_sent IS NULL OR warning_sent = 0)");
- $updateStmt->bind_param('is', $loan['id'], $loan['data_scadenza']);
- $updateStmt->execute();
- $claimed = $updateStmt->affected_rows === 1;
- $updateStmt->close();
+ $claimed = (bool) $this->underCalendarLock(function () use (&$loan, $copyId, $today, $warningLimit): bool {
+ // A save may change the usable day without changing the
+ // stored date when capacity blocks the move. Read it again
+ // under the calendar lock, before claiming or rendering.
+ $effectiveDue = LibraryCalendar::dueDate($this->db, (string) $loan['data_scadenza'], $copyId);
+ if ($effectiveDue < $today || $effectiveDue > $warningLimit) {
+ return false;
+ }
+ $loan['effective_due_at'] = $effectiveDue;
+ $loan['giorni_rimasti'] = (int) (new \DateTimeImmutable($today))->diff(new \DateTimeImmutable($effectiveDue))->days;
+ $updateStmt = $this->db->prepare("UPDATE prestiti SET warning_sent = 1 WHERE id = ? AND utente_id = ? AND copia_id <=> ? AND attivo = 1 AND stato IN ('in_corso', 'in_ritardo') AND data_scadenza = ? AND (warning_sent IS NULL OR warning_sent = 0)");
+ $updateStmt->bind_param('iiis', $loan['id'], $loan['utente_id'], $copyId, $loan['data_scadenza']);
+ $updateStmt->execute();
+ $claimed = $updateStmt->affected_rows === 1;
+ $updateStmt->close();
+ return $claimed;
+ }, false);
if (!$claimed) {
// Another process already claimed this loan, skip
@@ -687,7 +722,7 @@ public function sendLoanExpirationWarnings(): int {
$variables = [
'utente_nome' => $loan['utente_nome'],
'libro_titolo' => $loan['libro_titolo'],
- 'data_scadenza' => $this->formatEmailDate($loan['data_scadenza'], false, $recipientLocale),
+ 'data_scadenza' => $this->formatEmailDate($loan['effective_due_at'], false, $recipientLocale),
'giorni_rimasti' => $daysRemaining === 0 ? $todayLabels[$recipientLocale] : (string)$daysRemaining
];
@@ -705,8 +740,8 @@ public function sendLoanExpirationWarnings(): int {
$sentCount++;
} else {
// Email failed after retries, revert the flag so it can be retried next run
- $revertStmt = $this->db->prepare("UPDATE prestiti SET warning_sent = 0 WHERE id = ? AND attivo = 1 AND stato = 'in_corso'");
- $revertStmt->bind_param('i', $loan['id']);
+ $revertStmt = $this->db->prepare("UPDATE prestiti SET warning_sent = 0 WHERE id = ? AND data_scadenza = ? AND utente_id = ? AND copia_id <=> ? AND attivo = 1 AND stato IN ('in_corso', 'in_ritardo')");
+ $revertStmt->bind_param('isii', $loan['id'], $loan['data_scadenza'], $loan['utente_id'], $copyId);
$revertStmt->execute();
$revertStmt->close();
SecureLogger::warning("Failed to send expiration warning for loan {$loan['id']} after retries, flag reverted");
@@ -777,6 +812,7 @@ public function sendOverdueLoanNotifications(): int {
// The e-mail and the in-app notice say the count of the
// calendar as it is now, under the lock.
$loan['giorni_ritardo'] = LibraryCalendar::lateDaysForNotice($this->db, (string) $loan['data_scadenza'], $copyId, $today);
+ $loan['effective_due_at'] = $this->usableDueDate($loan);
// ATOMIC: mark the notice sent BEFORE the e-mail; only an
// affected row proceeds. The state guard (M3) keeps a loan
// returned meanwhile out, and re-asserting data_scadenza
@@ -814,7 +850,7 @@ public function sendOverdueLoanNotifications(): int {
$variables = [
'utente_nome' => $loan['utente_nome'],
'libro_titolo' => $loan['libro_titolo'],
- 'data_scadenza' => $this->formatEmailDate($loan['data_scadenza'], false, $this->resolveRecipientLocale((string) $loan['utente_email'])),
+ 'data_scadenza' => $this->formatEmailDate($loan['effective_due_at'], false, $this->resolveRecipientLocale((string) $loan['utente_email'])),
'giorni_ritardo' => $loan['giorni_ritardo']
];
@@ -1050,7 +1086,7 @@ public function sendPickupReminders(): int {
'utente_nome' => $loan['utente_nome'],
'libro_titolo' => $loan['libro_titolo'],
'data_inizio' => $this->formatEmailDate((string) $loan['data_prestito'], false, $recipientLocale),
- 'data_fine' => $this->formatEmailDate((string) $loan['data_scadenza'], false, $recipientLocale),
+ 'data_fine' => $this->formatEmailDate($this->usableDueDate($loan), false, $recipientLocale),
'giorni_ritiro' => $this->effectivePickupDays((string) $loan['data_prestito'], $deadline, !empty($loan['copia_id']) ? (int) $loan['copia_id'] : null),
'giorni_rimasti' => $daysLeft === 0 ? $todayLabels[$recipientLocale] : (string) $daysLeft,
'scadenza_ritiro' => $formattedDeadline,
@@ -1387,9 +1423,10 @@ private function recalledTodayUnchanged(array $loan): bool
/**
* Shared claim-then-send for one recall (automatic and manual paths).
* Expects a row carrying id, data_scadenza, recall_count, last_recall_at,
- * libro_titolo, utente_nome, utente_email, giorni_ritardo. Returns true iff
- * the recall email actually went out; on failure the claim is reverted so a
- * later run can retry.
+ * libro_titolo, utente_nome, utente_email, giorni_ritardo, and copia_id when
+ * the loan has one (it selects the branch calendar of the date in the e-mail).
+ * Returns true iff the recall email actually went out; on failure the claim
+ * is reverted so a later run can retry.
*
* @param ?callable(): (int|false) $stillDue re-checked under the calendar
* lock right before the claim: the days late by the calendar as it
@@ -1412,6 +1449,7 @@ private function claimAndSendRecall(array $loan, int $maxRetries = 3, int $retry
}
$loan['giorni_ritardo'] = $days;
}
+ $loan['effective_due_at'] = $this->usableDueDate($loan);
$updateStmt = $this->db->prepare("UPDATE prestiti SET recall_count = recall_count + 1, last_recall_at = ? WHERE id = ? AND data_scadenza = ? AND recall_count = ? AND attivo = 1 AND stato IN ('in_corso', 'in_ritardo')");
$updateStmt->bind_param('sisi', $now, $loan['id'], $loan['data_scadenza'], $expectedCount);
$updateStmt->execute();
@@ -1439,7 +1477,7 @@ private function claimAndSendRecall(array $loan, int $maxRetries = 3, int $retry
$variables = [
'utente_nome' => $loan['utente_nome'],
'libro_titolo' => $loan['libro_titolo'],
- 'data_scadenza' => $this->formatEmailDate($loan['data_scadenza'], false, $this->resolveRecipientLocale((string) $loan['utente_email'])),
+ 'data_scadenza' => $this->formatEmailDate($loan['effective_due_at'], false, $this->resolveRecipientLocale((string) $loan['utente_email'])),
'giorni_ritardo' => $loan['giorni_ritardo'],
'numero_sollecito' => $recallNumber,
];
@@ -1527,7 +1565,7 @@ public function sendLoanReceiptEmail(int $loanId): array {
'utente_nome' => (string) ($loan['utente'] ?? ''),
'libro_titolo' => (string) ($loan['libro'] ?? ''),
'data_prestito' => $this->formatEmailDate((string) ($loan['data_prestito'] ?? ''), false, $recipientLocale),
- 'data_scadenza' => $this->formatEmailDate((string) ($loan['data_scadenza'] ?? ''), false, $recipientLocale),
+ 'data_scadenza' => $this->formatEmailDate((string) ($loan['effective_due_at'] ?? $loan['data_scadenza'] ?? ''), false, $recipientLocale),
];
$attachment = [
'content' => $pdfContent,
@@ -1561,7 +1599,7 @@ public function notifyAdminsOverdue(int $loanId): void
// the admin alert for the SAME overdue loan must be too — an archived
// title's outstanding loan is exactly the case staff must chase.
$stmt = $this->db->prepare("
- SELECT p.id, p.data_scadenza, p.data_prestito, l.titolo AS libro_titolo,
+ SELECT p.id, p.copia_id, p.data_scadenza, p.data_prestito, l.titolo AS libro_titolo,
CONCAT(u.nome, ' ', u.cognome) AS utente_nome, u.email AS utente_email
FROM prestiti p
JOIN libri l ON p.libro_id = l.id
@@ -1590,7 +1628,7 @@ public function notifyAdminsOverdue(int $loanId): void
// leggeva solo il PHP hardcoded (GAP-4). Le date sono formattate per la
// lingua di ogni admin.
$this->sendToAdmins('loan_overdue_admin', $variables, [
- 'data_scadenza' => [$loan['data_scadenza'], false],
+ 'data_scadenza' => [$this->usableDueDate($loan), false],
'data_prestito' => [$loan['data_prestito'], false],
]);
@@ -2033,7 +2071,7 @@ public function sendLoanPickedUpNotification(int $loanId, ?string $pickupDate =
'utente_nome' => $loan['utente_nome'],
'libro_titolo' => $loan['libro_titolo'] ?? $this->translateInLocale('Non disponibile', $recipientLocale),
'data_prestito' => $this->formatEmailDate($pickupDate, false, $recipientLocale),
- 'data_scadenza' => $this->formatEmailDate($loan['data_scadenza'], false, $recipientLocale),
+ 'data_scadenza' => $this->formatEmailDate($this->usableDueDate($loan), false, $recipientLocale),
];
return $this->sendWithRetry($loan['utente_email'], 'loan_picked_up', $variables);
@@ -2081,7 +2119,7 @@ public function sendLoanApprovedNotification(int $loanId): bool {
'utente_nome' => $loan['utente_nome'],
'libro_titolo' => $loan['libro_titolo'],
'data_inizio' => $this->formatEmailDate($loan['data_prestito'], false, $recipientLocale),
- 'data_fine' => $this->formatEmailDate($loan['data_scadenza'], false, $recipientLocale),
+ 'data_fine' => $this->formatEmailDate($this->usableDueDate($loan), false, $recipientLocale),
'giorni_prestito' => $days,
// A loan starting later gets its pickup deadline only on the start
// day; say now which one it will be and how many days that is.
@@ -2089,7 +2127,7 @@ public function sendLoanApprovedNotification(int $loanId): bool {
'scadenza_ritiro' => $this->formatEmailDate($this->expectedPickupDeadline((string) $loan['data_prestito'], (string) $loan['data_scadenza'], !empty($loan['copia_id']) ? (int) $loan['copia_id'] : null), false, $recipientLocale),
'pickup_deadline' => $this->formatEmailDate($this->expectedPickupDeadline((string) $loan['data_prestito'], (string) $loan['data_scadenza'], !empty($loan['copia_id']) ? (int) $loan['copia_id'] : null), false, $recipientLocale),
'pickup_instructions' => $this->translateInLocale('Recati in biblioteca durante gli orari di apertura per ritirare il libro.', $recipientLocale),
- 'sezione_calendario' => $this->buildCalendarSection((int)$loan['id'], (string)$loan['libro_titolo'], (string)$loan['data_prestito'], (string)$loan['data_scadenza'], $recipientLocale)
+ 'sezione_calendario' => $this->buildCalendarSection((int)$loan['id'], (string)$loan['libro_titolo'], (string)$loan['data_prestito'], $this->usableDueDate($loan), $recipientLocale)
];
return $this->sendWithRetry($loan['utente_email'], 'loan_approved', $variables);
@@ -2266,7 +2304,7 @@ public function sendPickupReadyNotification(int $loanId): bool {
'utente_nome' => $loan['utente_nome'],
'libro_titolo' => $loan['libro_titolo'],
'data_inizio' => $this->formatEmailDate($loan['data_prestito'], false, $recipientLocale),
- 'data_fine' => $this->formatEmailDate($loan['data_scadenza'], false, $recipientLocale),
+ 'data_fine' => $this->formatEmailDate($this->usableDueDate($loan), false, $recipientLocale),
'giorni_prestito' => $days,
'giorni_ritiro' => $this->effectivePickupDays((string) $loan['data_prestito'], $loan['pickup_deadline'] ? (string) $loan['pickup_deadline'] : null, !empty($loan['copia_id']) ? (int) $loan['copia_id'] : null),
'scadenza_ritiro' => $loan['pickup_deadline'] ? $this->formatEmailDate($loan['pickup_deadline'], false, $recipientLocale) : '',
@@ -2275,7 +2313,7 @@ public function sendPickupReadyNotification(int $loanId): bool {
// resolves as well as the canonical {{scadenza_ritiro}}.
'pickup_deadline' => $loan['pickup_deadline'] ? $this->formatEmailDate($loan['pickup_deadline'], false, $recipientLocale) : '',
'pickup_instructions' => $this->translateInLocale('Recati in biblioteca durante gli orari di apertura per ritirare il libro.', $recipientLocale),
- 'sezione_calendario' => $this->buildCalendarSection((int)$loan['id'], (string)$loan['libro_titolo'], (string)$loan['data_prestito'], (string)$loan['data_scadenza'], $recipientLocale)
+ 'sezione_calendario' => $this->buildCalendarSection((int)$loan['id'], (string)$loan['libro_titolo'], (string)$loan['data_prestito'], $this->usableDueDate($loan), $recipientLocale)
];
$emailSent = $this->sendWithRetry($loan['utente_email'], 'loan_pickup_ready', $variables);
@@ -2635,7 +2673,7 @@ public function sendFineChargedNotification(int $loanId): bool
return false;
}
$stmt = $this->db->prepare("
- SELECT p.utente_id, p.data_scadenza, p.data_restituzione,
+ SELECT p.utente_id, p.copia_id, p.data_scadenza, p.data_restituzione,
l.titolo AS libro_titolo,
CONCAT(u.nome, ' ', u.cognome) AS utente_nome,
u.email AS utente_email
@@ -2654,12 +2692,11 @@ public function sendFineChargedNotification(int $loanId): bool
}
$email = (string) $loan['utente_email'];
$locale = $this->resolveRecipientLocale($email);
- $due = (string) $loan['data_scadenza'];
$returned = (string) ($loan['data_restituzione'] ?: DateHelper::today());
$variables = [
'utente_nome' => $loan['utente_nome'],
'libro_titolo' => $loan['libro_titolo'],
- 'data_scadenza' => $this->formatEmailDate($due, false, $locale),
+ 'data_scadenza' => $this->formatEmailDate($this->usableDueDate($loan), false, $locale),
'data_restituzione' => $this->formatEmailDate($returned, false, $locale),
'giorni_ritardo' => (string) $ledger->lateFineDays($loanId),
'multa' => \App\Services\FineParameters::format($fineCents, $locale),
diff --git a/app/Views/admin/pending_loans.php b/app/Views/admin/pending_loans.php
index c2c07354a..6b5eafb57 100644
--- a/app/Views/admin/pending_loans.php
+++ b/app/Views/admin/pending_loans.php
@@ -113,7 +113,7 @@ class="w-16 h-22 object-cover rounded-lg shadow-sm"