where('type', 'NORMAL') ->with(['members.registration']) ->lockForUpdate() ->get() ->keyBy('id'); $seatOrder = config('pxg.pairing.seat_order', [1,2,3,4]); // Seat availability map $flightSeats = []; foreach ($flights as $f) { $taken = $f->members->pluck('seat_no')->map(fn($x)=> (int)$x)->all(); $available = array_values(array_diff($seatOrder, $taken)); $flightSeats[$f->id] = $available; } $assigned = 0; $notes = []; $getPendingRegs = function () use ($eventId) { return Registration::where('event_id', $eventId) ->where('status', 'CONFIRMED') ->whereDoesntHave('flightMember') ->with(['player','group']) ->get(); }; $regs = $getPendingRegs(); if ($regs->isEmpty()) { return ['assigned' => 0, 'notes' => ['no pending confirmed registrations']]; } // Balancing stats $courseLoad = $this->computeCourseLoad($eventId); $holeHighLikeLoad = $this->computeHoleHighLikeLoad($eventId); // by start_hole (1..18) // 1) Group-of-4 placement $groupIds = $regs->whereNotNull('group_id')->pluck('group_id')->unique()->values(); if ($groupIds->isNotEmpty()) { $groups = Group::whereIn('id', $groupIds)->where('size_target', 4)->get(); foreach ($groups as $g) { $groupRegs = Registration::where('group_id', $g->id) ->where('status', 'CONFIRMED') ->whereDoesntHave('flightMember') ->with('player') ->get(); if ($groupRegs->count() !== 4) continue; $coursePref = 'ANY'; $leader = $groupRegs->firstWhere('type', 'GROUP_LEADER') ?? $groupRegs->first(); if ($leader && in_array($leader->course_pref, ['A','B'])) $coursePref = $leader->course_pref; $flightId = $this->findEmptyFlightBalanced($flights, $flightSeats, $courseLoad, $coursePref); if (!$flightId) { $notes[] = "no empty flight for group {$g->group_code}"; continue; } $seats = $flightSeats[$flightId]; if (count($seats) < 4) continue; $i = 0; foreach ($groupRegs as $r) { FlightMember::create([ 'flight_id' => $flightId, 'registration_id' => $r->id, 'seat_no' => $seats[$i], 'lock_flag' => false, ]); $assigned++; $i++; $courseLoad[$flights[$flightId]->course] = ($courseLoad[$flights[$flightId]->course] ?? 0) + 1; if ($this->isHighLike($r->handicap_band)) { $h = (int)$flights[$flightId]->start_hole; $holeHighLikeLoad[$h] = ($holeHighLikeLoad[$h] ?? 0) + 1; } } $flightSeats[$flightId] = array_values(array_slice($seats, 4)); $this->refreshFlightStatus($flightId); $notes[] = "group {$g->group_code} -> flight {$flights[$flightId]->code}"; } } // 1b) Group-of-2/3 placement (keep same course; prefer same flight) if ($groupIds->isNotEmpty()) { $groups23 = Group::whereIn('id', $groupIds)->whereIn('size_target', [2,3])->get(); foreach ($groups23 as $g) { $need = (int) $g->size_target; $groupRegs = Registration::where('group_id', $g->id) ->where('status', 'CONFIRMED') ->whereDoesntHave('flightMember') ->with('player') ->get(); // Only place when exact members exist (2 or 3) if ($groupRegs->count() !== $need) continue; // derive coursePref from leader if any $coursePref = 'ANY'; $leader = $groupRegs->firstWhere('type', 'GROUP_LEADER') ?? $groupRegs->first(); if ($leader && in_array($leader->course_pref, ['A','B'])) $coursePref = $leader->course_pref; // 1) Try same flight with enough seats $flightId = $this->findFlightWithAtLeastSeats($flights, $flightSeats, $courseLoad, $coursePref, $need); if (!$flightId && $coursePref !== 'ANY') { $flightId = $this->findFlightWithAtLeastSeats($flights, $flightSeats, $courseLoad, 'ANY', $need); } if ($flightId) { $seats = array_slice($flightSeats[$flightId], 0, $need); for ($i=0; $i<$need; $i++) { $r = $groupRegs[$i]; FlightMember::create([ 'flight_id' => $flightId, 'registration_id' => $r->id, 'seat_no' => $seats[$i], 'lock_flag' => false, ]); $assigned++; $courseLoad[$flights[$flightId]->course] = ($courseLoad[$flights[$flightId]->course] ?? 0) + 1; if ($this->isHighLike($r->handicap_band)) { $h = (int)$flights[$flightId]->start_hole; $holeHighLikeLoad[$h] = ($holeHighLikeLoad[$h] ?? 0) + 1; } } // consume seats + status $flightSeats[$flightId] = array_values(array_slice($flightSeats[$flightId], $need)); $this->refreshFlightStatus($flightId); $notes[] = "group {$g->group_code} -> flight {$flights[$flightId]->code}"; continue; } // 2) If no single flight can fit, split but keep SAME COURSE $course = $this->pickCourseForGroupSplit($flights, $flightSeats, $courseLoad, $coursePref, $need); if (!$course) { $notes[] = "no seats available to place group {$g->group_code}"; continue; } $placed = 0; foreach ($flights as $f) { if ($f->course !== $course) continue; if (empty($flightSeats[$f->id] ?? [])) continue; while (!empty($flightSeats[$f->id]) && $placed < $need) { $seat = array_shift($flightSeats[$f->id]); $r = $groupRegs[$placed]; FlightMember::create([ 'flight_id' => $f->id, 'registration_id' => $r->id, 'seat_no' => $seat, 'lock_flag' => false, ]); $assigned++; $courseLoad[$course] = ($courseLoad[$course] ?? 0) + 1; if ($this->isHighLike($r->handicap_band)) { $h = (int)$f->start_hole; $holeHighLikeLoad[$h] = ($holeHighLikeLoad[$h] ?? 0) + 1; } $this->refreshFlightStatus($f->id); $placed++; } if ($placed >= $need) break; } if ($placed >= $need) { $notes[] = "group {$g->group_code} -> split in course {$course}"; } else { $notes[] = "group {$g->group_code} placement incomplete (placed {$placed}/{$need})"; } } } // refresh remaining regs $regs = $getPendingRegs(); // 2) LEVEL by band (semi-hard course_pref) $levelRegs = $regs->where('pairing_mode', 'LEVEL')->values(); foreach (['LOW','MID','HIGH','BEGINNER'] as $band) { foreach ($levelRegs->where('handicap_band', $band)->values() as $r) { $flightId = $this->findBestFlightForLevel($flights, $flightSeats, $courseLoad, $holeHighLikeLoad, $band, $r->course_pref); if (!$flightId) break; $seat = array_shift($flightSeats[$flightId]); FlightMember::create([ 'flight_id' => $flightId, 'registration_id' => $r->id, 'seat_no' => $seat, 'lock_flag' => false, ]); $assigned++; $courseLoad[$flights[$flightId]->course] = ($courseLoad[$flights[$flightId]->course] ?? 0) + 1; if ($this->isHighLike($r->handicap_band)) { $h = (int)$flights[$flightId]->start_hole; $holeHighLikeLoad[$h] = ($holeHighLikeLoad[$h] ?? 0) + 1; } $this->refreshFlightStatus($flightId); // reflect memory $flights[$flightId]->members->push((object)[ 'seat_no' => $seat, 'registration' => $r ]); } } // refresh remaining regs $regs = $getPendingRegs(); // 3) BALANCED - now course_pref aware + hole bottleneck mitigation $balancedTarget = config('pxg.pairing.balanced_target', ['LOW','MID','MID','HIGH']); $maxHighLike = (int) config('pxg.pairing.max_high_like_per_flight', 2); $balancedRegs = $regs->where('pairing_mode', 'BALANCED')->values(); $pool = $this->poolByBand($balancedRegs); // While there are regs, pick next reg to place and choose best flight for that reg band+course_pref while ($this->poolHasAny($pool)) { // pick next band by target cycle foreach ($balancedTarget as $needBand) { if (!$this->poolHasAny($pool)) break; $needBand = strtoupper($needBand); $pickBand = $needBand; if ($needBand === 'HIGH' && empty($pool['HIGH']) && !empty($pool['BEGINNER'])) $pickBand = 'BEGINNER'; $reg = $this->popFromPool($pool, $pickBand); if (!$reg) $reg = $this->popAny($pool); if (!$reg) break; $coursePref = $reg->course_pref ?? 'ANY'; $flightId = $this->findBestFlightForBalanced($flights, $flightSeats, $courseLoad, $holeHighLikeLoad, $reg, $coursePref); if (!$flightId) { // if semi-hard pref blocks, fallback ANY $flightId = $this->findBestFlightForBalanced($flights, $flightSeats, $courseLoad, $holeHighLikeLoad, $reg, 'ANY'); } if (!$flightId) { // put back and stop $this->pushBack($pool, $reg); break; } // Pace guardrail: avoid too many high-like in one flight $curHighLike = $this->countHighLikeInFlight($flights[$flightId]); if ($this->isHighLike($reg->handicap_band) && $curHighLike >= $maxHighLike) { $this->pushBack($pool, $reg); $alt = $this->popFirstNonHighLike($pool); if ($alt) $reg = $alt; else $reg = $this->popAny($pool) ?? $reg; } if (empty($flightSeats[$flightId])) { // no seat left, retry next iteration $this->pushBack($pool, $reg); continue; } $seat = array_shift($flightSeats[$flightId]); FlightMember::create([ 'flight_id' => $flightId, 'registration_id' => $reg->id, 'seat_no' => $seat, 'lock_flag' => false, ]); $assigned++; $courseLoad[$flights[$flightId]->course] = ($courseLoad[$flights[$flightId]->course] ?? 0) + 1; if ($this->isHighLike($reg->handicap_band)) { $h = (int)$flights[$flightId]->start_hole; $holeHighLikeLoad[$h] = ($holeHighLikeLoad[$h] ?? 0) + 1; } $this->refreshFlightStatus($flightId); $flights[$flightId]->members->push((object)[ 'seat_no' => $seat, 'registration' => $reg ]); } } // refresh remaining regs $regs = $getPendingRegs(); // 4) RANDOM - course_pref semi-hard + course balance $randomRegs = $regs->where('pairing_mode', 'RANDOM')->values(); foreach ($randomRegs as $r) { $flightId = $this->findBestFlightForRandom($flights, $flightSeats, $courseLoad, $holeHighLikeLoad, $r, $r->course_pref); if (!$flightId) $flightId = $this->findBestFlightForRandom($flights, $flightSeats, $courseLoad, $holeHighLikeLoad, $r, 'ANY'); if (!$flightId) break; $seat = array_shift($flightSeats[$flightId]); FlightMember::create([ 'flight_id' => $flightId, 'registration_id' => $r->id, 'seat_no' => $seat, 'lock_flag' => false, ]); $assigned++; $courseLoad[$flights[$flightId]->course] = ($courseLoad[$flights[$flightId]->course] ?? 0) + 1; if ($this->isHighLike($r->handicap_band)) { $h = (int)$flights[$flightId]->start_hole; $holeHighLikeLoad[$h] = ($holeHighLikeLoad[$h] ?? 0) + 1; } $this->refreshFlightStatus($flightId); $flights[$flightId]->members->push((object)[ 'seat_no' => $seat, 'registration' => $r ]); } return ['assigned' => $assigned, 'notes' => $notes]; }); } private function computeCourseLoad(int $eventId): array { $rows = DB::table('flight_members') ->join('flights', 'flight_members.flight_id', '=', 'flights.id') ->where('flights.event_id', $eventId) ->select('flights.course', DB::raw('count(*) as cnt')) ->groupBy('flights.course') ->get(); $load = ['A' => 0, 'B' => 0]; foreach ($rows as $r) $load[$r->course] = (int) $r->cnt; return $load; } private function computeHoleHighLikeLoad(int $eventId): array { // count HIGH+BEGINNER assigned per start_hole across courses (shotgun bottleneck proxy) $rows = DB::table('flight_members') ->join('flights', 'flight_members.flight_id', '=', 'flights.id') ->join('registrations', 'flight_members.registration_id', '=', 'registrations.id') ->where('flights.event_id', $eventId) ->whereIn('registrations.handicap_band', ['HIGH','BEGINNER']) ->select('flights.start_hole', DB::raw('count(*) as cnt')) ->groupBy('flights.start_hole') ->get(); $load = []; foreach ($rows as $r) $load[(int)$r->start_hole] = (int) $r->cnt; return $load; } private function refreshFlightStatus(int $flightId): void { $count = FlightMember::where('flight_id', $flightId)->count(); Flight::where('id', $flightId)->update(['status' => $count >= 4 ? 'FULL' : 'OPEN']); } private function isHighLike(?string $band): bool { $b = strtoupper((string)$band); return $b === 'HIGH' || $b === 'BEGINNER'; } private function countHighLikeInFlight(Flight $flight): int { $c = 0; foreach ($flight->members as $m) { $b = $m->registration?->handicap_band ?? null; if ($this->isHighLike($b)) $c++; } return $c; } private function findEmptyFlightBalanced($flights, $flightSeats, array $courseLoad, string $coursePref): ?int { $candidates = []; foreach ($flights as $f) { if (($f->members->count() ?? 0) !== 0) continue; if (count($flightSeats[$f->id] ?? []) !== 4) continue; if (in_array($coursePref, ['A','B']) && $f->course !== $coursePref) continue; $candidates[] = $f; } if (empty($candidates)) { if ($coursePref !== 'ANY') return $this->findEmptyFlightBalanced($flights, $flightSeats, $courseLoad, 'ANY'); return null; } if (!config('pxg.pairing.course_balance', true)) return $candidates[0]->id; usort($candidates, fn($a,$b) => ($courseLoad[$a->course] ?? 0) <=> ($courseLoad[$b->course] ?? 0)); return $candidates[0]->id; } private function findBestFlightForLevel($flights, $flightSeats, array $courseLoad, array $holeHighLikeLoad, string $band, string $coursePref): ?int { // Semi-hard course preference: if preferred has any seats at all, do not choose other course. if (config('pxg.pairing.course_pref_semi_hard', true) && in_array($coursePref, ['A','B'])) { if ($this->courseHasSeats($flights, $flightSeats, $coursePref)) { return $this->findBestFlightForLevel($flights, $flightSeats, $courseLoad, $holeHighLikeLoad, $band, $coursePref === 'A' ? 'A' : 'B'); } // else fallback ANY below $coursePref = 'ANY'; } $band = strtoupper($band); $candidates = []; foreach ($flights as $f) { if (empty($flightSeats[$f->id] ?? [])) continue; if (in_array($coursePref, ['A','B']) && $f->course !== $coursePref) continue; $bands = $f->members->map(fn($m)=> strtoupper((string)($m->registration?->handicap_band)))->filter()->values()->all(); $unique = array_values(array_unique($bands)); $isHomogeneousSame = (count($unique) === 1 && $unique[0] === $band); $count = count($bands); $score = 0; if ($isHomogeneousSame) $score += 140; if ($count === 0) $score += 80; $score += (4 - $count) * 6; if (config('pxg.pairing.course_balance', true) && $coursePref === 'ANY') { $score += (200 - ($courseLoad[$f->course] ?? 0)); } // If assigning HIGH/BEGINNER in level mode, spread across holes if (config('pxg.pairing.hole_balance_high_like', true) && $this->isHighLike($band)) { $pen = (int) config('pxg.pairing.hole_high_like_penalty', 12); $score -= ($holeHighLikeLoad[(int)$f->start_hole] ?? 0) * $pen; } $candidates[] = [$f->id, $score]; } if (empty($candidates)) { if ($coursePref !== 'ANY') return $this->findBestFlightForLevel($flights, $flightSeats, $courseLoad, $holeHighLikeLoad, $band, 'ANY'); return null; } usort($candidates, fn($x,$y) => $y[1] <=> $x[1]); return $candidates[0][0]; } private function findBestFlightForBalanced($flights, $flightSeats, array $courseLoad, array $holeHighLikeLoad, Registration $reg, string $coursePref): ?int { // semi-hard course preference if (config('pxg.pairing.course_pref_semi_hard', true) && in_array($coursePref, ['A','B'])) { if ($this->courseHasSeats($flights, $flightSeats, $coursePref)) { // keep pref } else { $coursePref = 'ANY'; } } $candidates = []; $isHigh = $this->isHighLike($reg->handicap_band); foreach ($flights as $f) { if (empty($flightSeats[$f->id] ?? [])) continue; if (in_array($coursePref, ['A','B']) && $f->course !== $coursePref) continue; $count = $f->members->count(); $score = (4 - $count) * 12; // Avoid flights with many high-like already $score -= $this->countHighLikeInFlight($f) * 10; if (config('pxg.pairing.course_balance', true) && $coursePref === 'ANY') { $score += (200 - ($courseLoad[$f->course] ?? 0)); } if (config('pxg.pairing.hole_balance_high_like', true) && $isHigh) { $pen = (int) config('pxg.pairing.hole_high_like_penalty', 12); $score -= ($holeHighLikeLoad[(int)$f->start_hole] ?? 0) * $pen; } $candidates[] = [$f->id, $score]; } if (empty($candidates)) return null; usort($candidates, fn($x,$y) => $y[1] <=> $x[1]); return $candidates[0][0]; } private function findBestFlightForRandom($flights, $flightSeats, array $courseLoad, array $holeHighLikeLoad, Registration $reg, string $coursePref): ?int { if (config('pxg.pairing.course_pref_semi_hard', true) && in_array($coursePref, ['A','B'])) { if (!$this->courseHasSeats($flights, $flightSeats, $coursePref)) $coursePref = 'ANY'; } $candidates = []; $isHigh = $this->isHighLike($reg->handicap_band); foreach ($flights as $f) { if (empty($flightSeats[$f->id] ?? [])) continue; if (in_array($coursePref, ['A','B']) && $f->course !== $coursePref) continue; $count = $f->members->count(); $score = (4 - $count) * 20; if (config('pxg.pairing.course_balance', true) && $coursePref === 'ANY') { $score += (200 - ($courseLoad[$f->course] ?? 0)); } if (config('pxg.pairing.hole_balance_high_like', true) && $isHigh) { $pen = (int) config('pxg.pairing.hole_high_like_penalty', 12); $score -= ($holeHighLikeLoad[(int)$f->start_hole] ?? 0) * $pen; } $candidates[] = [$f->id, $score]; } if (empty($candidates)) return null; usort($candidates, fn($x,$y) => $y[1] <=> $x[1]); return $candidates[0][0]; } private function courseHasSeats($flights, $flightSeats, string $course): bool { foreach ($flights as $f) { if ($f->course !== $course) continue; if (!empty($flightSeats[$f->id] ?? [])) return true; } return false; } private function poolByBand($regs): array { $pool = ['LOW'=>[], 'MID'=>[], 'HIGH'=>[], 'BEGINNER'=>[]]; foreach ($regs as $r) { $b = strtoupper((string)($r->handicap_band ?? 'MID')); if (!isset($pool[$b])) $pool[$b] = []; $pool[$b][] = $r; } return $pool; } private function poolHasAny(array $pool): bool { foreach ($pool as $arr) if (!empty($arr)) return true; return false; } private function popFromPool(array &$pool, string $band): ?Registration { $band = strtoupper($band); if (empty($pool[$band])) return null; return array_shift($pool[$band]); } private function popAny(array &$pool): ?Registration { foreach (['LOW','MID','HIGH','BEGINNER'] as $b) if (!empty($pool[$b])) return array_shift($pool[$b]); return null; } private function pushBack(array &$pool, Registration $r): void { $b = strtoupper((string)($r->handicap_band ?? 'MID')); $pool[$b][] = $r; } private function popFirstNonHighLike(array &$pool): ?Registration { foreach (['LOW','MID'] as $b) if (!empty($pool[$b])) return array_shift($pool[$b]); return null; } private function findFlightWithAtLeastSeats($flights, array $flightSeats, array $courseLoad, string $coursePref, int $need): ?int { $candidates = []; foreach ($flights as $f) { $avail = count($flightSeats[$f->id] ?? []); if ($avail < $need) continue; if (in_array($coursePref, ['A','B']) && $f->course !== $coursePref) continue; // prefer less-filled flights; if ANY, keep course balanced $count = $f->members->count(); $score = (4 - $count) * 40; if (config('pxg.pairing.course_balance', true) && $coursePref === 'ANY') { $score += (200 - ($courseLoad[$f->course] ?? 0)); } $candidates[] = [$f->id, $score]; } if (empty($candidates)) return null; usort($candidates, fn($x,$y) => $y[1] <=> $x[1]); return $candidates[0][0]; } private function pickCourseForGroupSplit($flights, array $flightSeats, array $courseLoad, string $coursePref, int $need): ?string { // if preferred course has enough total seats, choose it if (in_array($coursePref, ['A','B'])) { $total = 0; foreach ($flights as $f) { if ($f->course !== $coursePref) continue; $total += count($flightSeats[$f->id] ?? []); } if ($total >= $need) return $coursePref; } // else choose course with more available seats (and/or lower load) $seatA = 0; $seatB = 0; foreach ($flights as $f) { $cnt = count($flightSeats[$f->id] ?? []); if ($f->course === 'A') $seatA += $cnt; if ($f->course === 'B') $seatB += $cnt; } if ($seatA === 0 && $seatB === 0) return null; if ($seatA >= $need && $seatB < $need) return 'A'; if ($seatB >= $need && $seatA < $need) return 'B'; // both can fit or neither fully fits -> keep balance (lower load), else more seats if (config('pxg.pairing.course_balance', true)) { $loadA = $courseLoad['A'] ?? 0; $loadB = $courseLoad['B'] ?? 0; return $loadA <= $loadB ? 'A' : 'B'; } return $seatA >= $seatB ? 'A' : 'B'; } }