Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions app/Controllers/PrestitiApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -109,14 +109,15 @@ public function list(Request $request, Response $response, mysqli $db): Response
$orderDir = 'DESC';

// Map column index to database column
// 0: libro, 1: utente, 2: data_prestito, 3: data_scadenza,
// 4: stato; columns 5 (PDF) and 6 (actions) are not orderable.
// 0: selection checkbox (not orderable); 1: libro, 2: utente,
// 3: data_prestito, 4: data_scadenza, 5: stato; columns 6 (PDF)
// and 7 (actions) are not orderable.
$columnMap = [
0 => 'l.titolo', // Libro
1 => 'utente', // Utente (computed column)
2 => 'p.data_prestito',// Data Prestito
3 => 'p.data_scadenza',// Scadenza
4 => 'p.stato' // Stato
1 => 'l.titolo', // Libro
2 => 'utente', // Utente (computed column)
3 => 'p.data_prestito',// Data Prestito
4 => 'p.data_scadenza',// Scadenza
5 => 'p.stato' // Stato
];

// Parse order parameter from DataTables
Expand Down
240 changes: 240 additions & 0 deletions app/Controllers/PrestitiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,37 @@ public function update(Request $request, Response $response, mysqli $db, int $id
return $response->withHeader('Location', url('/admin/loans') . '?error=loan_update_failed')->withStatus(302);
}

// #281: LoanRepository::update() deliberately never touches lifecycle
// columns, and the in_corso -> in_ritardo transition set by the
// maintenance/integrity jobs is one-way — nothing reverts it. So
// extending an overdue loan's due date left stato='in_ritardo' stale
// and the loan kept showing "Overdue". Recompute stato here against
// the new due date (app timezone, same clock as MaintenanceService),
// scoped to the physically-out states so 'prenotato'/'da_ritirare'
// are never affected. When the loan returns to in_corso (extended
// into the future) also clear the reminder flags so the new window
// triggers fresh warning/overdue emails.
// Guard: only recompute when the DUE DATE actually changed. Editing an
// unrelated field (e.g. only utente_id, or only data_prestito) on a
// non-overdue in_corso loan must NOT reset warning_sent/overdue_
// notification_sent to 0 — that would re-arm duplicate 'due soon'
// emails. A data_prestito-only edit does not affect the overdue clock,
// so it is intentionally excluded from this guard (do not reuse the
// combined data_prestito||data_scadenza condition above).
if ($newScadenza !== (string) $current['data_scadenza']) {
$today = \App\Support\DateHelper::today();
$recalcStato = $db->prepare(
"UPDATE prestiti
SET stato = CASE WHEN data_scadenza < ? THEN 'in_ritardo' ELSE 'in_corso' END,
warning_sent = CASE WHEN data_scadenza < ? THEN warning_sent ELSE 0 END,
overdue_notification_sent = CASE WHEN data_scadenza < ? THEN overdue_notification_sent ELSE 0 END
WHERE id = ? AND attivo = 1 AND stato IN ('in_corso', 'in_ritardo')"
);
$recalcStato->bind_param('sssi', $today, $today, $today, $id);
$recalcStato->execute();
$recalcStato->close();
}

// Ricalcola la disponibilità (M6c): spostare le date di un 'prenotato'
// attraverso oggi cambia l'occupazione corrente e lascerebbe
// copie_disponibili/libri.stato stantii fino al prossimo evento.
Expand Down Expand Up @@ -1248,6 +1279,215 @@ public function downloadPdf(Request $request, Response $response, mysqli $db, in
}
}

/**
* #281: bulk-extend the due date of several active out-loans at once.
* Deliberately mirrors the manual Edit-Loan semantics — NOT renew(), which
* refuses overdue loans and enforces the renewal limit. Uwe's request is
* precisely to extend loans that are already overdue, so each selected loan
* that is active and physically out (in_corso / in_ritardo) has its
* data_scadenza pushed forward by N days and its stato recomputed against
* the new date; an overdue loan extended into the future stops showing
* "Overdue" and its reminder flags reset so the new window notifies afresh.
*/
/**
* Upper bound on a single bulk-extend batch. Caps how many libri/prestiti
* rows are held under FOR UPDATE for the whole transaction, so a runaway
* "select all" can't block concurrent loans/returns/reservations.
*/
private const BULK_EXTEND_MAX_LOANS = 500;

public function bulkExtend(Request $request, Response $response, mysqli $db): Response
{
if ($guard = $this->guardStaffAccess($response)) {
return $guard;
}
$data = (array) $request->getParsedBody();
// CSRF validated by CsrfMiddleware.

$ids = array_values(array_unique(array_filter(
array_map('intval', (array) ($data['ids'] ?? [])),
static fn (int $i): bool => $i > 0
)));
$days = (int) ($data['days'] ?? 0);

$backUrl = url('/admin/loans');
if ($ids === [] || count($ids) > self::BULK_EXTEND_MAX_LOANS || $days < 1 || $days > 365) {
return $response->withHeader('Location', $backUrl . '?error=bulk_extend_invalid')->withStatus(302);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

$today = \App\Support\DateHelper::today();
$db->begin_transaction();
try {
$placeholders = implode(',', array_fill(0, count($ids), '?'));

// Discover the books first without taking loan locks, then lock every
// book in deterministic order. This preserves the application-wide
// canonical lock order (libri -> prestiti) and serializes capacity
// decisions with loan/reservation/copy mutations for the same title.
$bookScan = $db->prepare("SELECT id, libro_id FROM prestiti WHERE id IN ($placeholders)");
$bookScan->bind_param(str_repeat('i', count($ids)), ...$ids);
$bookScan->execute();
$bookResult = $bookScan->get_result();
$expectedBookByLoan = [];
$bookIds = [];
while ($row = $bookResult->fetch_assoc()) {
$loanId = (int) $row['id'];
$bookId = (int) $row['libro_id'];
$expectedBookByLoan[$loanId] = $bookId;
$bookIds[$bookId] = $bookId;
}
$bookScan->close();
sort($bookIds, SORT_NUMERIC);

$lockBook = $db->prepare('SELECT id FROM libri WHERE id = ? FOR UPDATE');
foreach ($bookIds as $bookId) {
$lockBook->bind_param('i', $bookId);
$lockBook->execute();
$lockBook->get_result();
}
$lockBook->close();

// Re-read and lock the eligible rows after their books are locked.
// Rows closed or moved out of an extendable state meanwhile are
// intentionally ignored, matching the previous bulk semantics.
$lockLoans = $db->prepare(
"SELECT id, libro_id, copia_id, data_prestito, data_scadenza
FROM prestiti
WHERE id IN ($placeholders)
AND attivo = 1
AND stato IN ('in_corso', 'in_ritardo')
ORDER BY id
FOR UPDATE"
);
$lockLoans->bind_param(str_repeat('i', count($ids)), ...$ids);
$lockLoans->execute();
$loanResult = $lockLoans->get_result();
$loans = [];
while ($row = $loanResult->fetch_assoc()) {
$loans[] = $row;
}
$lockLoans->close();

$capacity = new \App\Services\CapacityService($db);
$copyOverlap = $db->prepare(
"SELECT 1 FROM prestiti
WHERE copia_id = ? AND id <> ?
AND data_prestito <= ?
AND (stato = 'in_ritardo' OR data_scadenza >= ?)
AND ((attivo = 1 AND stato IN ('prenotato','da_ritirare','in_corso','in_ritardo'))
OR (attivo = 0 AND stato = 'pendente' AND copia_id IS NOT NULL))
LIMIT 1"
);
$update = $db->prepare(
"UPDATE prestiti
SET data_scadenza = ?,
stato = CASE WHEN ? < ? THEN 'in_ritardo' ELSE 'in_corso' END,
warning_sent = CASE WHEN ? < ? THEN warning_sent ELSE 0 END,
overdue_notification_sent = CASE WHEN ? < ? THEN overdue_notification_sent ELSE 0 END
WHERE id = ? AND attivo = 1 AND stato IN ('in_corso', 'in_ritardo')"
);

$extended = 0;
foreach ($loans as $loan) {
// null == a capacity/copy conflict: roll the WHOLE batch back so
// no partial extension is committed (same all-or-nothing contract
// the tests pin). A thrown error is handled by the outer catch.
$applied = $this->applyBulkLoanExtension(
$loan,
$days,
$today,
$capacity,
$copyOverlap,
$update,
$expectedBookByLoan
);
if ($applied === null) {
$copyOverlap->close();
$update->close();
$db->rollback();
return $response->withHeader('Location', $backUrl . '?error=bulk_extend_conflict')->withStatus(302);
}
$extended += $applied;
}
$copyOverlap->close();
$update->close();
$db->commit();
} catch (\Throwable $e) {
$db->rollback();
SecureLogger::error('Bulk loan extend failed: ' . $e->getMessage());
return $response->withHeader('Location', $backUrl . '?error=bulk_extend_failed')->withStatus(302);
}

return $response->withHeader('Location', $backUrl . '?bulk_extended=' . (int) $extended . '&days=' . $days)->withStatus(302);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Apply one loan's extension inside bulkExtend()'s transaction.
*
* @param array<string, mixed> $loan locked prestiti row
* @param array<int, int> $expectedBookByLoan loan id -> book id snapshot taken before locking
* @return int|null affected row count (0/1), or null on a capacity/copy conflict (caller rolls back)
*/
private function applyBulkLoanExtension(
array $loan,
int $days,
string $today,
\App\Services\CapacityService $capacity,
\mysqli_stmt $copyOverlap,
\mysqli_stmt $update,
array $expectedBookByLoan
): ?int {
$loanId = (int) $loan['id'];
$bookId = (int) $loan['libro_id'];
if (($expectedBookByLoan[$loanId] ?? null) !== $bookId) {
throw new \RuntimeException('Loan book changed during bulk extension');
}

$dueDate = \DateTimeImmutable::createFromFormat('!Y-m-d', (string) $loan['data_scadenza']);
if ($dueDate === false) {
throw new \RuntimeException('Invalid due date during bulk extension');
}
// Extend from the LATER of today and the current due date. A loan that is
// already overdue therefore extends from today, so adding N days lands it
// 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.
$todayDate = \DateTimeImmutable::createFromFormat('!Y-m-d', $today);
$base = ($todayDate !== false && $todayDate > $dueDate) ? $todayDate : $dueDate;
$newDueDate = $base->modify('+' . $days . ' days')->format('Y-m-d');
$loanStart = (string) $loan['data_prestito'];

// Apply each accepted extension immediately inside the transaction, so the
// next capacity check sees all earlier proposed extensions too.
if (!$capacity->hasFreeCapacity($bookId, $loanStart, $newDueDate, excludePrestitoId: $loanId)) {
return null;
}

$copyId = $loan['copia_id'] !== null ? (int) $loan['copia_id'] : null;
if ($copyId !== null) {
$copyOverlap->bind_param('iiss', $copyId, $loanId, $newDueDate, $loanStart);
$copyOverlap->execute();
if ((bool) $copyOverlap->get_result()->fetch_row()) {
return null;
}
}

$update->bind_param(
'sssssssi',
$newDueDate,
$newDueDate,
$today,
$newDueDate,
$today,
$newDueDate,
$today,
$loanId
);
$update->execute();

return max(0, $update->affected_rows);
}

public function renew(Request $request, Response $response, mysqli $db, int $id): Response
{
if ($guard = $this->guardStaffAccess($response)) {
Expand Down
9 changes: 9 additions & 0 deletions app/Routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -1860,6 +1860,15 @@
$db = $app->getContainer()->get('db');
return $controller->renew($request, $response, $db, (int) $args['id']);
})->add(new CsrfMiddleware())->add(new AdminAuthMiddleware());
// #281: extend the due date of several selected active loans at once.
$app->post('/admin/loans/bulk-extend', function ($request, $response) use ($app) {
if (\App\Support\ConfigStore::isCatalogueMode()) {
return $response->withHeader('Location', '/admin/dashboard')->withStatus(302);
}
$controller = new PrestitiController();
$db = $app->getContainer()->get('db');
return $controller->bulkExtend($request, $response, $db);
})->add(new CsrfMiddleware())->add(new AdminAuthMiddleware());
// Return a loan by scanning/typing the physical copy code (numero_inventario)
$app->post('/admin/loans/return-by-code', function ($request, $response) use ($app) {
if (\App\Support\ConfigStore::isCatalogueMode()) {
Expand Down
7 changes: 4 additions & 3 deletions app/Views/prestiti/crea_prestito.php
Original file line number Diff line number Diff line change
Expand Up @@ -396,9 +396,10 @@ function updateConsegnaImmediataVisibility(dateStr) {
// Initialize visibility on page load
updateConsegnaImmediataVisibility(dataPrestitoEl.value);

// Get locale for flatpickr
const appLocale = document.documentElement.lang?.startsWith('it') ? 'it' : 'en';
const isItalian = appLocale === 'it';
// Get locale for flatpickr (all shipped UI languages, not just it/en — #281)
const _lang = (document.documentElement.lang || '').slice(0, 2).toLowerCase();
const appLocale = ['it', 'en', 'de', 'fr', 'da'].indexOf(_lang) !== -1 ? _lang : 'en';
const isItalian = appLocale !== 'en'; // day-first display for every European language
const localeObj = window.flatpickrLocales ? window.flatpickrLocales[appLocale] : null;

// Initialize flatpickr for data_prestito
Expand Down
Loading
Loading