181 lines
6.6 KiB
PHP
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']);
|
|
});
|
|
}
|
|
}
|