This commit is contained in:
Saufi
2026-06-24 18:30:00 +08:00
commit c0c3d7c09c
122 changed files with 16321 additions and 0 deletions

View 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']);
});
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace App\Services;
use App\Models\ImportLog;
use App\Models\Member;
class MemberImportService
{
/**
* Import baris anggota. Tidak crash bila ada duplicate/ralat
* pulangkan ringkasan + simpan ImportLog.
*
* @param array<int, array<string, string>> $rows
*/
public function import(array $rows, string $filename, ?int $userId = null): ImportLog
{
$success = 0;
$duplicate = 0;
$failed = 0;
$errors = [];
// Set pra-muat untuk kesan duplicate dalam fail itu sendiri
$seenKp = [];
$seenPekerja = [];
foreach ($rows as $i => $row) {
$line = $i + 2; // baris fail (header = 1)
$nama = trim($row['nama'] ?? '');
$noKp = $this->clean($row['no_kp'] ?? '');
$noPekerja = $this->clean($row['no_pekerja'] ?? '');
$noAnggota = $this->clean($row['no_anggota'] ?? '');
if ($nama === '') {
$failed++;
$errors[] = "Baris {$line}: nama wajib diisi.";
continue;
}
// Duplicate dalam fail
if ($noKp && isset($seenKp[$noKp])) {
$duplicate++;
$errors[] = "Baris {$line}: no_kp '{$noKp}' duplicate dalam fail.";
continue;
}
if ($noPekerja && isset($seenPekerja[$noPekerja])) {
$duplicate++;
$errors[] = "Baris {$line}: no_pekerja '{$noPekerja}' duplicate dalam fail.";
continue;
}
// Duplicate dalam DB
$exists = Member::query()
->when($noKp, fn ($q) => $q->orWhere('no_kp', $noKp))
->when($noPekerja, fn ($q) => $q->orWhere('no_pekerja', $noPekerja))
->when($noKp || $noPekerja, fn ($q) => $q, fn ($q) => $q->whereRaw('1 = 0'))
->exists();
if ($exists) {
$duplicate++;
$errors[] = "Baris {$line}: anggota '{$nama}' sudah wujud (no_kp/no_pekerja sepadan).";
continue;
}
try {
Member::create([
'no_anggota' => $noAnggota ?: null,
'no_pekerja' => $noPekerja ?: null,
'no_kp' => $noKp ?: null,
'nama' => $nama,
'jabatan' => $this->clean($row['jabatan'] ?? '') ?: null,
'bahagian' => $this->clean($row['bahagian'] ?? '') ?: null,
'telefon' => $this->clean($row['telefon'] ?? '') ?: null,
'status_aktif' => $this->parseAktif($row['status_aktif'] ?? ''),
]);
if ($noKp) {
$seenKp[$noKp] = true;
}
if ($noPekerja) {
$seenPekerja[$noPekerja] = true;
}
$success++;
} catch (\Throwable $e) {
$failed++;
$errors[] = "Baris {$line}: {$e->getMessage()}";
}
}
return ImportLog::create([
'type' => 'members',
'filename' => $filename,
'total_rows' => count($rows),
'success_count' => $success,
'duplicate_count' => $duplicate,
'failed_count' => $failed,
'errors' => $errors ?: null,
'imported_by' => $userId,
]);
}
private function clean(string $value): string
{
return trim($value);
}
private function parseAktif(string $value): bool
{
$value = strtolower(trim($value));
if ($value === '') {
return true; // default aktif
}
return in_array($value, ['1', 'aktif', 'active', 'ya', 'yes', 'true', 'y'], true);
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Services;
use App\Models\ImportLog;
use App\Models\Prize;
class PrizeImportService
{
/**
* Import baris hadiah. Jika kuantiti > 1, generate beberapa item berasingan
* (cth Hamper #1 .. Hamper #5).
*
* @param array<int, array<string, string>> $rows
*/
public function import(array $rows, string $filename, ?int $userId = null): ImportLog
{
$success = 0;
$duplicate = 0;
$failed = 0;
$errors = [];
foreach ($rows as $i => $row) {
$line = $i + 2;
$nama = trim($row['nama_hadiah'] ?? '');
if ($nama === '') {
$failed++;
$errors[] = "Baris {$line}: nama_hadiah wajib diisi.";
continue;
}
$kuantiti = max(1, (int) ($row['kuantiti'] ?? 1));
$drawOrder = (int) ($row['susunan_cabutan'] ?? ($row['draw_order'] ?? 0));
$kodHadiah = trim($row['kod_hadiah'] ?? '');
$batch = $kodHadiah !== '' ? $kodHadiah : 'B' . str_pad((string) ($i + 1), 4, '0', STR_PAD_LEFT);
try {
foreach ($this->expand($nama, $kuantiti) as $index => $itemName) {
Prize::create([
'kod_hadiah' => $kodHadiah ?: null,
'nama_hadiah' => $itemName,
'kategori' => trim($row['kategori'] ?? '') ?: null,
'nilai_anggaran' => $this->parseDecimal($row['nilai_anggaran'] ?? ''),
'draw_order' => $drawOrder,
'item_index' => $index,
'batch_kod' => $batch,
'status' => Prize::STATUS_BELUM,
]);
$success++;
}
} catch (\Throwable $e) {
$failed++;
$errors[] = "Baris {$line}: {$e->getMessage()}";
}
}
return ImportLog::create([
'type' => 'prizes',
'filename' => $filename,
'total_rows' => count($rows),
'success_count' => $success,
'duplicate_count' => $duplicate,
'failed_count' => $failed,
'errors' => $errors ?: null,
'imported_by' => $userId,
]);
}
/**
* @return array<int, string> diindeks dari 1
*/
public function expand(string $nama, int $kuantiti): array
{
if ($kuantiti <= 1) {
return [1 => $nama];
}
$items = [];
for ($n = 1; $n <= $kuantiti; $n++) {
$items[$n] = "{$nama} #{$n}";
}
return $items;
}
private function parseDecimal(string $value): ?float
{
$value = trim(str_replace([',', 'RM', 'rm'], '', $value));
return $value === '' ? null : (float) $value;
}
}