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
20 changes: 1 addition & 19 deletions app/Controllers/CopyController.php
Original file line number Diff line number Diff line change
Expand Up @@ -289,25 +289,7 @@ public function updateCopy(Request $request, Response $response, mysqli $db, int
$reservationManager = null;

if (in_array($stato, ['perso', 'danneggiato', 'manutenzione', 'in_restauro', 'in_trasferimento'], true)) {
// One copy may host several non-overlapping future holds.
for ($guard = 0; $guard < 1000; $guard++) {
$held = $db->prepare("
SELECT 1 FROM prestiti
WHERE copia_id = ? AND (
(attivo = 1 AND stato IN ('prenotato','da_ritirare'))
OR (attivo = 0 AND stato = 'pendente' AND origine = 'prenotazione')
)
LIMIT 1
");
$held->bind_param('i', $copyId);
$held->execute();
$stillHeld = (bool) $held->get_result()->fetch_row();
$held->close();
if (!$stillHeld) {
break;
}
$reassignmentService->reassignOnCopyLost($copyId);
}
$reassignmentService->reassignAllOnCopyLost($copyId);
} elseif ($stato === 'disponibile') {
$reassignmentService->reassignOnReturn($copyId);
$reservationManager = new \App\Controllers\ReservationManager($db);
Expand Down
184 changes: 89 additions & 95 deletions app/Controllers/LoanApprovalController.php

Large diffs are not rendered by default.

17 changes: 1 addition & 16 deletions app/Controllers/PrestitiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -1684,22 +1684,7 @@ public function processReturn(Request $request, Response $response, mysqli $db,
// come fa CopyController::updateCopy per lo stesso evento.
$reassignmentService = new \App\Services\ReservationReassignmentService($db);
$reassignmentService->setExternalTransaction(true);
// A lost copy can hold MORE THAN ONE non-overlapping future reservation
// (period-based pre-booking, see the comment above). reassignOnCopyLost()
// handles a single hold, so loop until none is left pointing at the dead
// copy — each call either reassigns the hold to another copy or nulls its
// copia_id, so it always makes progress (bounded guard for safety).
for ($reassignGuard = 0; $reassignGuard < 1000; $reassignGuard++) {
$stillHeld = $db->query(
"SELECT 1 FROM prestiti WHERE copia_id = " . (int) $copia_id
. " AND ((attivo = 1 AND stato IN ('prenotato','da_ritirare'))"
. " OR (attivo = 0 AND stato = 'pendente' AND origine = 'prenotazione')) LIMIT 1"
);
if (!$stillHeld || $stillHeld->num_rows === 0) {
break;
}
$reassignmentService->reassignOnCopyLost((int) $copia_id);
}
$reassignmentService->reassignAllOnCopyLost((int) $copia_id);
}

// Ricalcola le copie disponibili DOPO l'eventuale riassegnazione/conversione
Expand Down
83 changes: 22 additions & 61 deletions app/Controllers/ReservationsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public function __construct(?mysqli $db = null)
// suo payload esponeva gli utente_id dei prenotanti (le rotte reali passano
// da getBookAvailabilityData(), che filtra i campi sensibili).

private function calculateAvailability($currentLoans, $existingReservations, int $totalCopies, ?string $startDate = null, int $days = 730, ?int $excludeUserId = null, ?array $capacityReservationIntervals = null)
private function calculateAvailability(array $currentLoans, array $reservationIntervals, int $totalCopies, ?string $startDate, int $days, bool $hasActiveReservation, array $closedDates, string $startUntil)
{
// Default start = "today" in the APP timezone (DateHelper), not the PHP
// process TZ (usually UTC): a bare `new DateTime()` made the mobile
Expand Down Expand Up @@ -112,47 +112,7 @@ private function calculateAvailability($currentLoans, $existingReservations, int
$loanIntervals[] = [$startDateLoan, $endDateLoan];
}

// F040: does the excluded user already hold an active reservation on this
// book? Same predicate as the date-less duplicate guard in
// createReservation (prenotazioni WHERE libro_id AND utente_id AND
// stato='attiva'). Surfaced so the picker can warn instead of showing an
// all-green calendar that the guard would reject for every date.
$hasActiveReservation = false;

$reservationIntervals = [];
foreach ($existingReservations as $reservation) {
// Skip reservation if it belongs to the excluded user (e.g. the user making the request)
if ($excludeUserId !== null && isset($reservation['utente_id']) && (int) $reservation['utente_id'] === $excludeUserId) {
$hasActiveReservation = true;
continue;
}
if ($capacityReservationIntervals !== null) {
continue;
}

// Occupancy start mirrors CapacityService's COALESCE fallback: a legacy
// 'attiva' row whose data_inizio_richiesta is NULL still occupies its
// copy, using the reservation deadline (data_scadenza_prenotazione) as
// the start. It must NOT be skipped — otherwise the calendars would
// free a slot the canonical capacity gate still counts. Only drop the
// row if neither a start nor a deadline is known.
$resStart = $reservation['data_inizio_richiesta'] ?? null;
if (!$resStart && !empty($reservation['data_scadenza_prenotazione'])) {
$resStart = substr((string) $reservation['data_scadenza_prenotazione'], 0, 10);
}
if (!$resStart) {
continue;
}
$resEnd = $reservation['data_fine_richiesta'] ?? null;
if (!$resEnd && !empty($reservation['data_scadenza_prenotazione'])) {
$resEnd = substr((string) $reservation['data_scadenza_prenotazione'], 0, 10);
}
if (!$resEnd || $resEnd < $resStart) {
$resEnd = $resStart;
}
$reservationIntervals[] = [$resStart, $resEnd];
}
$reservationIntervals = $capacityReservationIntervals ?? $reservationIntervals;
$closedDays = array_fill_keys($closedDates, true);

$unavailableDates = [];
$daysData = [];
Expand Down Expand Up @@ -184,10 +144,8 @@ private function calculateAvailability($currentLoans, $existingReservations, int
if ($available <= 0) {
$state = $loaned > 0 ? 'borrowed' : 'reserved';
$unavailableDates[] = $d;
} else {
if ($earliestAvailable === null) {
$earliestAvailable = $d;
}
} elseif ($earliestAvailable === null && $d <= $startUntil && !isset($closedDays[$d])) {
$earliestAvailable = $d;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

$daysData[] = [
Expand Down Expand Up @@ -778,7 +736,10 @@ private function autoApproveLoanRequest($request, int $loanId, ?bool $knownEnabl
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);
// A scheduled hold without a copy reads the title's branches, as
// CapacityService and DataIntegrity do, so both see the same end.
$scheduledBookId = ($loan['stato'] ?? '') === 'prenotato' && isset($loan['libro_id']) ? (int) $loan['libro_id'] : null;
return \App\Support\LibraryCalendar::holdingEnd($this->db, substr((string) $loan['data_scadenza'], 0, 10), $copyId, $scheduledBookId);
}

/**
Expand Down Expand Up @@ -813,7 +774,7 @@ public function getBookAvailabilityData($bookId, ?string $startDate = null, int
// copia_id) also holds its copy until pickup confirmation (#157, model
// A-refined). Bare 'pendente' requests with no copy are excluded.
$stmt = $this->db->prepare("
SELECT data_prestito, data_scadenza, data_restituzione, pickup_deadline, stato, copia_id
SELECT libro_id, data_prestito, data_scadenza, data_restituzione, pickup_deadline, stato, copia_id
FROM prestiti
WHERE libro_id = ? AND (
(attivo = 1 AND stato IN ('in_corso', 'in_ritardo', 'da_ritirare', 'prenotato'))
Expand All @@ -826,17 +787,16 @@ public function getBookAvailabilityData($bookId, ?string $startDate = null, int
$loansResult = $stmt->get_result();
$currentLoans = $loansResult->fetch_all(MYSQLI_ASSOC);

// Get existing reservations
$stmt = $this->db->prepare("
SELECT data_inizio_richiesta, data_fine_richiesta, data_scadenza_prenotazione, stato, queue_position, utente_id
FROM prenotazioni
WHERE libro_id = ? AND stato = 'attiva'
ORDER BY queue_position ASC
");
$stmt->bind_param('i', $bookId);
$stmt->execute();
$reservationsResult = $stmt->get_result();
$existingReservations = $reservationsResult->fetch_all(MYSQLI_ASSOC);
// Surface the same duplicate guard used by createReservation even
// when the requesting patron's commitment is excluded from capacity.
$hasActiveReservation = false;
if ($excludeUserId !== null) {
$stmt = $this->db->prepare("SELECT 1 FROM prenotazioni WHERE libro_id = ? AND utente_id = ? AND stato = 'attiva' LIMIT 1");
$stmt->bind_param('ii', $bookId, $excludeUserId);
$stmt->execute();
$hasActiveReservation = $stmt->get_result()->num_rows > 0;
$stmt->close();
}

// The durations every calendar proposes with (public book page, admin
// loan and reservation forms, the app): the configured default, capped
Expand All @@ -863,12 +823,13 @@ public function getBookAvailabilityData($bookId, ?string $startDate = null, int
->closedDatesBetween(
$from,
\App\Support\LibraryCalendar::shift($from, $closedHorizon),
\App\Support\LibraryCalendar::requestBranch($this->db, (int) $bookId)
\App\Support\LibraryCalendar::requestScope($this->db, (int) $bookId),
$closedHorizon + 1
);
$reservationIntervals = (new \App\Services\CapacityService($this->db))->reservationIntervalsInRange(
(int) $bookId, $from, \App\Support\LibraryCalendar::shift($from, max(0, $days - 1)), $excludeUserId
);
return $this->calculateAvailability($currentLoans, $existingReservations, $totalCopies, $startDate, $days, $excludeUserId, $reservationIntervals) + [
return $this->calculateAvailability($currentLoans, $reservationIntervals, $totalCopies, $startDate, $days, $hasActiveReservation, $closedDates, $startUntil) + [
'loan_duration_days' => $policy->requestDurationDays(),
'max_loan_duration_days' => $policy->maxRequestDays,
'staff_loan_duration_days' => $policy->durationDays,
Expand Down
6 changes: 4 additions & 2 deletions app/Controllers/UserActionsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ public function cancelLoan(Request $request, Response $response, mysqli $db): Re
// bloccante, quindi un'approvazione/annullamento concorrente può
// averne cambiato lo stato (o, TOCTOU, il libro) nel frattempo.
$stmt = $db->prepare("
SELECT id, copia_id, stato, libro_id, pickup_deadline
SELECT id, copia_id, stato, libro_id, pickup_deadline, data_scadenza
FROM prestiti
WHERE id = ? AND utente_id = ? AND (
(attivo = 0 AND stato = 'pendente')
Expand Down Expand Up @@ -224,7 +224,9 @@ public function cancelLoan(Request $request, Response $response, mysqli $db): Re
// ciò che il cron avrebbe chiuso come 'scaduto'. Solo 'da_ritirare'
// porta una deadline attiva; prenotato/pendente restano 'annullato'.
$todayApp = \App\Support\DateHelper::today();
$pickupDeadline = !empty($loan['pickup_deadline']) ? (string) $loan['pickup_deadline'] : null;
$pickupDeadline = \App\Support\LibraryCalendar::effectivePickupDeadline(
$db, $loan['pickup_deadline'], $loan['data_scadenza'], $loan['copia_id'] !== null ? (int) $loan['copia_id'] : null
);
$pickupExpired = (string) $loan['stato'] === 'da_ritirare'
&& $pickupDeadline !== null
&& $pickupDeadline < $todayApp;
Expand Down
29 changes: 25 additions & 4 deletions app/Services/CapacityService.php
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ public function firstAvailableDate(int $libroId, string $from): ?string
return null;
}
$calendar = \App\Support\LibraryCalendar::forDb($this->db);
$branch = $calendar->hasRules() ? \App\Support\LibraryCalendar::requestBranch($this->db, $libroId) : null;
$branch = $calendar->hasRules() ? \App\Support\LibraryCalendar::requestScope($this->db, $libroId) : null;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
$copyBranches = $calendar->hasRules() ? $this->mixedLendingCopyBranches($libroId) : [];
$copyIntervals = [];
$trackCopy = $copyBranches === [] ? null : static function (int $copyId, string $start, string $end) use (&$copyIntervals): void {
Expand Down Expand Up @@ -506,9 +506,30 @@ private function holdingLoanIntervals(int $libroId, string $start, string $end,
}

/** Whether this physical copy has a commitment in the inclusive window. */
public function copyHasHoldingLoan(int $libroId, int $copyId, string $start, string $end): bool
public function copyHasHoldingLoan(int $libroId, int $copyId, string $start, string $end, ?int $excludeLoanId = null): bool
{
return $this->holdingLoanIntervals($libroId, $start, $end, null, null, $copyId) !== [];
return $this->holdingLoanIntervals($libroId, $start, $end, $excludeLoanId, null, $copyId) !== [];
}

/** Effective due day when both this copy and the title can honor the window. */
public function reservationDueDateForCopy(int $bookId, int $copyId, string $start, string $storedDue, ?int $excludeLoanId = null): ?string
{
$calendar = \App\Support\LibraryCalendar::forDb($this->db);
if (!$calendar->hasRules()) {
// Without rules nextOpenDay() is the identity: the same inverted
// window the calendar branch refuses below is refused here too.
return $storedDue < $start ? null : $storedDue;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
$branch = \App\Support\LibraryCalendar::branchOfCopy($this->db, $copyId);
$due = $calendar->nextOpenDay($storedDue, $branch);
if ($due < $start || !$calendar->isOpen($due, $branch)
|| $this->copyHasHoldingLoan($bookId, $copyId, $start, $due, $excludeLoanId)) {
return null;
}
if ($due > $storedDue && !$this->hasFreeCapacity($bookId, \App\Support\LibraryCalendar::shift($storedDue, 1), $due, excludePrestitoId: $excludeLoanId)) {
return null;
}
return $due;
}

/**
Expand Down Expand Up @@ -679,7 +700,7 @@ private function fetchIntervals(string $sql, string $types, array $params, ?stri
$out = [];
$calendar = \App\Support\LibraryCalendar::forDb($this->db);
$branch = $reservationBookId !== null && $calendar->hasRules()
? \App\Support\LibraryCalendar::requestBranch($this->db, $reservationBookId, $this->lockingReads)
? \App\Support\LibraryCalendar::requestScope($this->db, $reservationBookId, $this->lockingReads)
: null;
while ($row = $res->fetch_assoc()) {
$s = (string) $row['s'];
Expand Down
Loading