647 lines
26 KiB
PHP
647 lines
26 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Flight;
|
|
use App\Models\FlightMember;
|
|
use App\Models\Group;
|
|
use App\Models\Registration;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
class PairingEngine
|
|
{
|
|
/**
|
|
* Pairing Engine v3
|
|
* - Course A/B balancing (soft) + semi-hard course_pref (try preferred first).
|
|
* - Hole bottleneck mitigation for HIGH/BEGINNER: spread high-like players across start holes.
|
|
* - LEVEL: keep homogeneous band flights when possible.
|
|
* - BALANCED: target composition LOW, MID, MID, HIGH with pace guardrails.
|
|
*/
|
|
public function run(int $eventId): array
|
|
{
|
|
return DB::transaction(function () use ($eventId) {
|
|
|
|
$flights = Flight::where('event_id', $eventId)
|
|
->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';
|
|
}
|
|
|
|
}
|