Skip to content
Open
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
187 changes: 187 additions & 0 deletions app/Controllers/PrestitiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,28 @@ 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.
$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 +1270,171 @@ 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.
*/
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 === [] || $days < 1 || $days > 365) {
return $response->withHeader('Location', $backUrl . '?error=bulk_extend_invalid')->withStatus(302);
}

$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) {
$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');
}
$newDueDate = $dueDate->modify('+' . $days . ' days')->format('Y-m-d');
$loanStart = (string) $loan['data_prestito'];

// Apply each accepted extension immediately inside this transaction:
// the next capacity check therefore sees all earlier proposed
// extensions too. A single conflict rolls the entire batch back.
if (!$capacity->hasFreeCapacity($bookId, $loanStart, $newDueDate, excludePrestitoId: $loanId)) {
$copyOverlap->close();
$update->close();
$db->rollback();
return $response->withHeader('Location', $backUrl . '?error=bulk_extend_conflict')->withStatus(302);
}

$copyId = $loan['copia_id'] !== null ? (int) $loan['copia_id'] : null;
if ($copyId !== null) {
$copyOverlap->bind_param('iiss', $copyId, $loanId, $newDueDate, $loanStart);
$copyOverlap->execute();
$hasCopyOverlap = (bool) $copyOverlap->get_result()->fetch_row();
if ($hasCopyOverlap) {
$copyOverlap->close();
$update->close();
$db->rollback();
return $response->withHeader('Location', $backUrl . '?error=bulk_extend_conflict')->withStatus(302);
}
}

$update->bind_param(
'sssssssi',
$newDueDate,
$newDueDate,
$today,
$newDueDate,
$today,
$newDueDate,
$today,
$loanId
);
$update->execute();
$extended += max(0, $update->affected_rows);
}
$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);
}

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