first
This commit is contained in:
180
app/Services/DrawService.php
Normal file
180
app/Services/DrawService.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?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']);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user