PXG_2026_API/app/Services/PairingEngine.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';
}
}