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
16 changes: 13 additions & 3 deletions app/Controllers/ReservationManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,16 @@ public function processBookAvailability($bookId)
// $reservation['data_fine_richiesta'] for the loan period).
$nextReservation['data_fine_richiesta'] = $endDate;

if ($this->isDateRangeAvailable($bookId, $startDate, $endDate, (int) $nextReservation['id'])) {
// #157: pass the promoted reservation's queue_position so the
// capacity gate ignores waitlist entries BEHIND it (they would
// otherwise block the head when the waitlist fully subscribes the
// copies — e.g. 1 copy + 2 queued reservations promoted 0).
// isset() is false for both an absent key and a NULL value, so it
// covers a legacy NULL queue_position without a redundant null check.
$headQueuePos = isset($nextReservation['queue_position'])
? (int) $nextReservation['queue_position']
: null;
if ($this->isDateRangeAvailable($bookId, $startDate, $endDate, (int) $nextReservation['id'], $headQueuePos)) {
Comment on lines +196 to +199

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Gestisci anche il riordino quando queue_position è NULL.

Qui $headQueuePos può diventare null e la promozione può proseguire; più avanti però updateQueuePositions($bookId, (int) $nextReservation['queue_position']) trasforma NULL in 0, decrementando tutte le posizioni > 0 e creando code con posizione 0. Usa lo stesso valore risolto e riordina da 1 quando manca una posizione valida.

🐛 Fix proposto
-                $headQueuePos = isset($nextReservation['queue_position'])
+                $headQueuePos = isset($nextReservation['queue_position'])
                     ? (int) $nextReservation['queue_position']
                     : null;
                 if ($this->isDateRangeAvailable($bookId, $startDate, $endDate, (int) $nextReservation['id'], $headQueuePos)) {
@@
-                    $this->updateQueuePositions($bookId, (int) $nextReservation['queue_position']);
+                    if ($headQueuePos !== null) {
+                        $this->updateQueuePositions($bookId, $headQueuePos);
+                    } else {
+                        $this->reorderQueuePositions($bookId);
+                    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/Controllers/ReservationManager.php` around lines 196 - 199, In
ReservationManager::promoteNextReservation, the queue reordering path still uses
nextReservation['queue_position'] directly, which turns NULL into 0 and corrupts
positions. Reuse the resolved queue position already stored in $headQueuePos,
and when it is null, treat the promotion/reorder as starting from 1 instead of
passing a zero value into updateQueuePositions. Make the fix in the promotion
flow around isDateRangeAvailable and updateQueuePositions so the same validated
queue position is used consistently.

// Create the loan - check return value to handle race conditions
// Note: createLoanFromReservation() handles its own transaction internally
// when called standalone, but here we're already in a transaction
Expand Down Expand Up @@ -270,7 +279,7 @@ public function processBookAvailability($bookId)
* @param string|null $endDate End date (Y-m-d format)
* @return bool True if at least one copy is available
*/
private function isDateRangeAvailable($bookId, $startDate, $endDate, ?int $excludeReservationId = null)
private function isDateRangeAvailable($bookId, $startDate, $endDate, ?int $excludeReservationId = null, ?int $excludeReservationsAfterQueuePos = null)
{
if (!$startDate || !$endDate) {
return false;
Expand All @@ -287,7 +296,8 @@ private function isDateRangeAvailable($bookId, $startDate, $endDate, ?int $exclu
(int) $bookId,
(string) $startDate,
(string) $endDate,
excludeReservationId: $excludeReservationId
excludeReservationId: $excludeReservationId,
excludeReservationsAfterQueuePos: $excludeReservationsAfterQueuePos
);
}

Expand Down
22 changes: 17 additions & 5 deletions app/Services/CapacityService.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,12 @@ public function occupiedCount(
string $end,
?int $excludePrestitoId = null,
?int $excludeReservationId = null,
?int $excludeUserId = null
?int $excludeUserId = null,
?int $excludeReservationsAfterQueuePos = null
): int {
$intervals = array_merge(
$this->holdingLoanIntervals($libroId, $start, $end, $excludePrestitoId, $excludeUserId),
$this->activeReservationIntervals($libroId, $start, $end, $excludeReservationId, $excludeUserId)
$this->activeReservationIntervals($libroId, $start, $end, $excludeReservationId, $excludeUserId, $excludeReservationsAfterQueuePos)
);
return $this->sweepPeak($intervals);
}
Expand All @@ -109,13 +110,14 @@ public function hasFreeCapacity(
string $end,
?int $excludePrestitoId = null,
?int $excludeReservationId = null,
?int $excludeUserId = null
?int $excludeUserId = null,
?int $excludeReservationsAfterQueuePos = null
): bool {
$total = $this->totalCopies($libroId);
if ($total <= 0) {
return false;
}
$occ = $this->occupiedCount($libroId, $start, $end, $excludePrestitoId, $excludeReservationId, $excludeUserId);
$occ = $this->occupiedCount($libroId, $start, $end, $excludePrestitoId, $excludeReservationId, $excludeUserId, $excludeReservationsAfterQueuePos);
return $occ < $total;
}

Expand Down Expand Up @@ -150,7 +152,7 @@ private function holdingLoanIntervals(int $libroId, string $start, string $end,
* Active reservation intervals overlapping [$start,$end], clamped to the window.
* @return list<array{0:string,1:string}>
*/
private function activeReservationIntervals(int $libroId, string $start, string $end, ?int $excludeReservationId, ?int $excludeUserId): array
private function activeReservationIntervals(int $libroId, string $start, string $end, ?int $excludeReservationId, ?int $excludeUserId, ?int $excludeReservationsAfterQueuePos = null): array
{
// Canonical 3-step coalesce chain for the reservation end (no 2-step variants).
$rEnd = 'COALESCE(r.data_fine_richiesta, DATE(r.data_scadenza_prenotazione), r.data_inizio_richiesta)';
Expand All @@ -173,6 +175,16 @@ private function activeReservationIntervals(int $libroId, string $start, string
$types .= 'i';
$params[] = $excludeUserId;
}
// Promotion gate (#157): when promoting the queue head, the waitlist
// entries BEHIND it must not occupy capacity — they are lower-priority
// and are promoted in later runs as copies free up. Exclude reservations
// with a known queue_position strictly greater than the promoted one.
// NULL queue_position rows still count (conservative — never overbook).
if ($excludeReservationsAfterQueuePos !== null) {
$sql .= ' AND NOT (r.queue_position IS NOT NULL AND r.queue_position > ?)';
$types .= 'i';
$params[] = $excludeReservationsAfterQueuePos;
}
return $this->fetchIntervals($sql, $types, $params);
}

Expand Down
Loading