From 4c1fa6cf2191a625e0f516039421639c3bad26e1 Mon Sep 17 00:00:00 2001 From: Husnu Setiawan Date: Fri, 20 Feb 2026 14:31:46 +0700 Subject: [PATCH] update pairing engine --- app/Services/PairingEngine.php | 162 +++++++++++++++++++++++++++++++++ composer.json | 1 - composer.lock | 65 +------------ 3 files changed, 163 insertions(+), 65 deletions(-) diff --git a/app/Services/PairingEngine.php b/app/Services/PairingEngine.php index a986fe8..a023adf 100644 --- a/app/Services/PairingEngine.php +++ b/app/Services/PairingEngine.php @@ -102,6 +102,108 @@ class PairingEngine } } +// 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(); @@ -481,4 +583,64 @@ class PairingEngine 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'; +} + } diff --git a/composer.json b/composer.json index 3055c45..8a3d72d 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,6 @@ }, "require-dev": { "fakerphp/faker": "^1.9.1", - "laravel/envoy": "^2.10", "laravel/pint": "^1.0", "laravel/sail": "^1.18", "mockery/mockery": "^1.4.4", diff --git a/composer.lock b/composer.lock index 0211e84..4086ce0 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1c9becfc91324d2c5f77f109db0ebbab", + "content-hash": "9c491b8531eec05ba41a11d9276a5749", "packages": [ { "name": "brick/math", @@ -5816,69 +5816,6 @@ }, "time": "2025-04-30T06:54:44+00:00" }, - { - "name": "laravel/envoy", - "version": "v2.10.2", - "source": { - "type": "git", - "url": "https://github.com/laravel/envoy.git", - "reference": "819a519e3d86b056c7aa3bd5d0801952a6fc14fd" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laravel/envoy/zipball/819a519e3d86b056c7aa3bd5d0801952a6fc14fd", - "reference": "819a519e3d86b056c7aa3bd5d0801952a6fc14fd", - "shasum": "" - }, - "require": { - "guzzlehttp/guzzle": "^6.0|^7.0", - "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "php": "^7.2|^8.0", - "symfony/console": "^4.3|^5.0|^6.0|^7.0", - "symfony/process": "^4.3|^5.0|^6.0|^7.0" - }, - "require-dev": { - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^8.0|^9.0|^10.4" - }, - "suggest": { - "ext-posix": "Required to determine the System user on Unix systems." - }, - "bin": [ - "bin/envoy" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.x-dev" - } - }, - "autoload": { - "psr-4": { - "Laravel\\Envoy\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - } - ], - "description": "Elegant SSH tasks for PHP.", - "keywords": [ - "laravel", - "ssh" - ], - "support": { - "issues": "https://github.com/laravel/envoy/issues", - "source": "https://github.com/laravel/envoy/tree/v2.10.2" - }, - "time": "2025-01-28T15:47:18+00:00" - }, { "name": "laravel/pint", "version": "v1.20.0",