*/ public function eligibleMembers(DrawSession $session): Collection { $query = Member::query() ->hadir() ->whereDoesntHave('drawResults', function ($q) { $q->where('status', DrawResult::STATUS_CONFIRMED); }); if (! $session->return_cancelled_to_pool) { // Setting: keluarkan terus peserta yang pernah dibatalkan $query->whereDoesntHave('drawResults', function ($q) { $q->where('status', DrawResult::STATUS_CANCELLED); }); } return $query->orderBy('id')->get(); } /** * Pusing roda: pilih seorang anggota layak secara rawak & cipta rekod * pending_confirmation untuk hadiah yang dipilih. */ public function spin(Prize $prize, DrawSession $session, User $user): DrawResult { if (! $prize->isAvailable()) { throw new RuntimeException('Hadiah ini sudah ada pemenang disahkan atau sedang diproses.'); } $pool = $this->eligibleMembers($session); if ($pool->isEmpty()) { throw new RuntimeException('Tiada peserta layak yang masih ada dalam pool cabutan.'); } return DB::transaction(function () use ($prize, $session, $user, $pool) { $prize = Prize::whereKey($prize->getKey())->lockForUpdate()->firstOrFail(); if (! $prize->isAvailable()) { throw new RuntimeException('Hadiah ini sudah ada pemenang disahkan.'); } // Tandakan sebarang pending sedia ada untuk hadiah ini sebagai redrawn DrawResult::where('prize_id', $prize->id) ->where('status', DrawResult::STATUS_PENDING) ->update([ 'status' => DrawResult::STATUS_REDRAWN, 'updated_at' => now(), ]); $member = $pool->random(); $attempt = DrawResult::where('prize_id', $prize->id)->count() + 1; $result = DrawResult::create([ 'draw_session_id' => $session->id, 'prize_id' => $prize->id, 'member_id' => $member->id, 'status' => DrawResult::STATUS_PENDING, 'attempt_number' => $attempt, 'spun_at' => now(), ]); $prize->update(['status' => Prize::STATUS_SEDANG]); AuditLog::record('draw.spin', "Spin hadiah '{$prize->nama_hadiah}' -> {$member->nama}", $result, [ 'prize_id' => $prize->id, 'member_id' => $member->id, 'attempt' => $attempt, ]); return $result->load('member', 'prize'); }); } /** * Sahkan pemenang. Dilindungi unique index DB supaya tiada * double-confirm (race condition) walaupun dua operator tekan serentak. */ public function confirm(DrawResult $result, User $user): DrawResult { return DB::transaction(function () use ($result, $user) { $result = DrawResult::whereKey($result->getKey())->lockForUpdate()->firstOrFail(); if ($result->status === DrawResult::STATUS_CONFIRMED) { return $result; // idempotent } if ($result->status !== DrawResult::STATUS_PENDING) { throw new RuntimeException('Cabutan ini tidak lagi menunggu pengesahan.'); } $member = Member::findOrFail($result->member_id); if ($member->hasConfirmedWin()) { throw new RuntimeException("{$member->nama} sudah pun memenangi hadiah lain dan disahkan."); } try { $result->update([ 'status' => DrawResult::STATUS_CONFIRMED, 'confirmed_at' => now(), 'confirmed_by' => $user->id, 'confirmed_member_id' => $result->member_id, 'confirmed_prize_id' => $result->prize_id, ]); } catch (UniqueConstraintViolationException|QueryException $e) { // Unique index DB menghalang double winner / member menang dua kali throw new RuntimeException('Pengesahan gagal: pemenang atau hadiah ini sudah disahkan oleh operator lain.'); } Prize::whereKey($result->prize_id)->update(['status' => Prize::STATUS_DISAHKAN]); AuditLog::record('draw.confirm', "Sahkan pemenang '{$member->nama}' untuk hadiah #{$result->prize_id}", $result, [ 'prize_id' => $result->prize_id, 'member_id' => $result->member_id, ]); return $result->fresh(['member', 'prize']); }); } /** * Batalkan pemenang (tiada di dewan). Hadiah jadi 'redraw_required' * dan boleh dicabut semula. */ public function cancel(DrawResult $result, User $user, ?string $reason = null): DrawResult { return DB::transaction(function () use ($result, $user, $reason) { $result = DrawResult::whereKey($result->getKey())->lockForUpdate()->firstOrFail(); if ($result->status !== DrawResult::STATUS_PENDING) { throw new RuntimeException('Hanya cabutan yang menunggu pengesahan boleh dibatalkan.'); } $result->update([ 'status' => DrawResult::STATUS_CANCELLED, 'cancelled_at' => now(), 'cancelled_by' => $user->id, 'sebab_batal' => $reason ?: 'Pemenang tiada di dewan', ]); $prize = Prize::findOrFail($result->prize_id); $prize->update([ 'status' => Prize::STATUS_REDRAW, 'redraw_count' => $prize->redraw_count + 1, ]); AuditLog::record('draw.cancel', "Batal cabutan hadiah #{$result->prize_id} (member #{$result->member_id})", $result, [ 'prize_id' => $result->prize_id, 'member_id' => $result->member_id, 'sebab' => $result->sebab_batal, ]); return $result->fresh(['member', 'prize']); }); } }