Files
cabutan-bertuah-koipb/app/Services/DrawService.php
2026-06-24 18:30:00 +08:00

181 lines
6.6 KiB
PHP

<?php
namespace App\Services;
use App\Models\AuditLog;
use App\Models\DrawResult;
use App\Models\DrawSession;
use App\Models\Member;
use App\Models\Prize;
use App\Models\User;
use Illuminate\Database\QueryException;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use RuntimeException;
class DrawService
{
/**
* Senarai anggota yang LAYAK masuk cabutan untuk sesi ini:
* - sudah hadir
* - belum menang & disahkan
* - (ikut setting) yang dibatalkan boleh kekal/dikeluarkan dari pool
*
* @return Collection<int, Member>
*/
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']);
});
}
}