first
This commit is contained in:
67
app/Services/Admin/ElectionSettingsService.php
Normal file
67
app/Services/Admin/ElectionSettingsService.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Admin;
|
||||
|
||||
use App\Models\Election;
|
||||
use App\Models\ElectionSetting;
|
||||
use App\Models\User;
|
||||
|
||||
class ElectionSettingsService
|
||||
{
|
||||
public function create(Election $election, array $data, User $actingUser): ElectionSetting
|
||||
{
|
||||
$settings = $election->settings()->create([
|
||||
'registration_start_date' => $data['registration_start_date'],
|
||||
'registration_end_date' => $data['registration_end_date'],
|
||||
'polling_date' => $data['polling_date'],
|
||||
'is_attendance_active' => $data['is_attendance_active'],
|
||||
'is_registration_open_override' => $data['is_registration_open_override'],
|
||||
]);
|
||||
|
||||
activity('admin_settings')
|
||||
->performedOn($settings)
|
||||
->causedBy($actingUser)
|
||||
->withProperties(['election_id' => $election->id, 'after' => $data])
|
||||
->log('election_settings_created');
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
public function update(Election $election, array $data, User $actingUser): void
|
||||
{
|
||||
/** @var ElectionSetting $settings */
|
||||
$settings = $election->settings;
|
||||
|
||||
$before = $settings->only([
|
||||
'registration_start_date',
|
||||
'registration_end_date',
|
||||
'polling_date',
|
||||
'is_attendance_active',
|
||||
'is_registration_open_override',
|
||||
]);
|
||||
|
||||
$settings->update([
|
||||
'registration_start_date' => $data['registration_start_date'],
|
||||
'registration_end_date' => $data['registration_end_date'],
|
||||
'polling_date' => $data['polling_date'],
|
||||
'is_attendance_active' => $data['is_attendance_active'],
|
||||
'is_registration_open_override' => $data['is_registration_open_override'],
|
||||
]);
|
||||
|
||||
activity('admin_settings')
|
||||
->performedOn($settings)
|
||||
->causedBy($actingUser)
|
||||
->withProperties([
|
||||
'election_id' => $election->id,
|
||||
'before' => $before,
|
||||
'after' => $settings->only([
|
||||
'registration_start_date',
|
||||
'registration_end_date',
|
||||
'polling_date',
|
||||
'is_attendance_active',
|
||||
'is_registration_open_override',
|
||||
]),
|
||||
])
|
||||
->log('election_settings_updated');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Admin\Management;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\BankVerification;
|
||||
use App\Models\Election;
|
||||
use App\Models\Position;
|
||||
use App\Models\PusatMengundi;
|
||||
use App\Models\SaluranMengundi;
|
||||
use App\Models\StaffAssignment;
|
||||
use App\Services\Admin\OperationalAccountService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class AdminApplicationManagementService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AdminPostCloseNoteService $postCloseNoteService,
|
||||
private readonly OperationalAccountService $operationalAccountService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function create(User $actor, array $data): Application
|
||||
{
|
||||
return DB::transaction(function () use ($actor, $data): Application {
|
||||
$election = Election::query()->findOrFail($data['election_id']);
|
||||
$this->postCloseNoteService->requireIfClosed($election, $data['note'] ?? null);
|
||||
/** @var PusatMengundi $pusat */
|
||||
$pusat = PusatMengundi::query()->findOrFail($data['pusat_mengundi_id']);
|
||||
|
||||
if ((int) $pusat->election_id !== (int) $election->id) {
|
||||
throw ValidationException::withMessages([
|
||||
'pusat_mengundi_id' => 'Pusat Mengundi tidak berada dalam pilihanraya yang sama.',
|
||||
]);
|
||||
}
|
||||
|
||||
$application = Application::query()->create([
|
||||
'election_id' => $election->id,
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'selected_ktm_assignment_id' => $data['selected_ktm_assignment_id'] ?? null,
|
||||
'public_uuid' => (string) Str::uuid(),
|
||||
'source' => 'admin_manual',
|
||||
'status' => 'submitted',
|
||||
'name' => $data['name'],
|
||||
'ic_number' => $this->normalizeIc($data['ic_number']),
|
||||
'phone_number' => $data['phone_number'],
|
||||
'email' => $data['email'] ?? null,
|
||||
'address' => $data['address'] ?? null,
|
||||
'requested_position_id' => $data['requested_position_id'],
|
||||
'bank_name' => $data['bank_name'] ?? null,
|
||||
'bank_account_number' => $data['bank_account_number'] ?? null,
|
||||
'created_by_user_id' => $actor->id,
|
||||
]);
|
||||
|
||||
$application->statusHistories()->create([
|
||||
'from_status' => null,
|
||||
'to_status' => 'submitted',
|
||||
'changed_by_user_id' => $actor->id,
|
||||
'note' => $data['note'] ?? 'Manual entry by Admin.',
|
||||
]);
|
||||
|
||||
BankVerification::query()->firstOrCreate(
|
||||
['application_id' => $application->id],
|
||||
['status' => 'pending'],
|
||||
);
|
||||
|
||||
$this->postCloseNoteService->recordIfClosed($election, $application, $actor, $data['note'] ?? null, 'admin_manual_entry_after_close');
|
||||
|
||||
activity('admin_management')
|
||||
->performedOn($application)
|
||||
->causedBy($actor)
|
||||
->withProperties(['source' => 'admin_manual'])
|
||||
->log('application_created_by_admin');
|
||||
|
||||
return $application;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function update(Application $application, User $actor, array $data): Application
|
||||
{
|
||||
return DB::transaction(function () use ($application, $actor, $data): Application {
|
||||
$application->loadMissing('election');
|
||||
$election = $application->election;
|
||||
if (! $election instanceof Election) {
|
||||
throw ValidationException::withMessages([
|
||||
'election_id' => 'Pilihanraya permohonan tidak sah.',
|
||||
]);
|
||||
}
|
||||
$this->postCloseNoteService->requireIfClosed($election, $data['note'] ?? null);
|
||||
|
||||
$before = $application->only([
|
||||
'pusat_mengundi_id',
|
||||
'selected_ktm_assignment_id',
|
||||
'name',
|
||||
'ic_number',
|
||||
'phone_number',
|
||||
'email',
|
||||
'address',
|
||||
'requested_position_id',
|
||||
'approved_position_id',
|
||||
'bank_name',
|
||||
'bank_account_number',
|
||||
'status',
|
||||
]);
|
||||
|
||||
$application->forceFill([
|
||||
'pusat_mengundi_id' => $data['pusat_mengundi_id'],
|
||||
'selected_ktm_assignment_id' => $data['selected_ktm_assignment_id'] ?? null,
|
||||
'name' => $data['name'],
|
||||
'ic_number' => $this->normalizeIc($data['ic_number']),
|
||||
'phone_number' => $data['phone_number'],
|
||||
'email' => $data['email'] ?? null,
|
||||
'address' => $data['address'] ?? null,
|
||||
'requested_position_id' => $data['requested_position_id'],
|
||||
'approved_position_id' => $data['approved_position_id'] ?? null,
|
||||
'bank_name' => $data['bank_name'] ?? null,
|
||||
'bank_account_number' => $data['bank_account_number'] ?? null,
|
||||
'status' => $data['status'],
|
||||
])->save();
|
||||
|
||||
$after = $application->only(array_keys($before));
|
||||
|
||||
$application->statusHistories()->create([
|
||||
'from_status' => $before['status'],
|
||||
'to_status' => $application->status,
|
||||
'changed_by_user_id' => $actor->id,
|
||||
'note' => $data['note'] ?? 'Application updated by Admin.',
|
||||
'metadata' => [
|
||||
'before' => $before,
|
||||
'after' => $after,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->postCloseNoteService->recordIfClosed($election, $application, $actor, $data['note'] ?? null, 'admin_application_edit_after_close');
|
||||
|
||||
activity('admin_management')
|
||||
->performedOn($application)
|
||||
->causedBy($actor)
|
||||
->withProperties(['before' => $before, 'after' => $after])
|
||||
->log('application_updated_by_admin');
|
||||
|
||||
return $application;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function assign(Application $application, User $actor, array $data): StaffAssignment
|
||||
{
|
||||
return DB::transaction(function () use ($application, $actor, $data): StaffAssignment {
|
||||
$application->loadMissing('election');
|
||||
$election = $application->election;
|
||||
if (! $election instanceof Election) {
|
||||
throw ValidationException::withMessages([
|
||||
'election_id' => 'Pilihanraya permohonan tidak sah.',
|
||||
]);
|
||||
}
|
||||
$this->postCloseNoteService->requireIfClosed($election, $data['note'] ?? null);
|
||||
|
||||
/** @var Position $position */
|
||||
$position = Position::query()->findOrFail($data['position_id']);
|
||||
/** @var PusatMengundi $pusat */
|
||||
$pusat = PusatMengundi::query()->findOrFail($data['pusat_mengundi_id']);
|
||||
|
||||
if ((int) $pusat->election_id !== (int) $election->id) {
|
||||
throw ValidationException::withMessages([
|
||||
'pusat_mengundi_id' => 'Pusat Mengundi tidak berada dalam pilihanraya yang sama.',
|
||||
]);
|
||||
}
|
||||
|
||||
$reportsToAssignmentId = $data['reports_to_assignment_id'] ?? null;
|
||||
$saluranMengundiId = $data['saluran_mengundi_id'] ?? null;
|
||||
|
||||
if ($saluranMengundiId) {
|
||||
/** @var SaluranMengundi $saluran */
|
||||
$saluran = SaluranMengundi::query()->findOrFail($saluranMengundiId);
|
||||
if ((int) $saluran->pusat_mengundi_id !== (int) $pusat->id) {
|
||||
throw ValidationException::withMessages([
|
||||
'saluran_mengundi_id' => 'Saluran Mengundi tidak berada di bawah Pusat Mengundi yang dipilih.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($reportsToAssignmentId) {
|
||||
/** @var StaffAssignment $ktmAssignment */
|
||||
$ktmAssignment = StaffAssignment::query()
|
||||
->with('position')
|
||||
->whereKey($reportsToAssignmentId)
|
||||
->where('election_id', $election->id)
|
||||
->where('pusat_mengundi_id', $pusat->id)
|
||||
->where('status', 'active')
|
||||
->firstOrFail();
|
||||
|
||||
$ktmPosition = $ktmAssignment->position;
|
||||
if (! $ktmPosition instanceof Position || $ktmPosition->code !== 'KTM') {
|
||||
throw ValidationException::withMessages([
|
||||
'reports_to_assignment_id' => 'Ketua rujukan mestilah tugasan KTM aktif.',
|
||||
]);
|
||||
}
|
||||
|
||||
$saluranMengundiId = $ktmAssignment->saluran_mengundi_id;
|
||||
}
|
||||
|
||||
$previousStatus = $application->status;
|
||||
$previousPositionId = $application->approved_position_id ?: $application->requested_position_id;
|
||||
|
||||
$application->forceFill([
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'selected_ktm_assignment_id' => $reportsToAssignmentId,
|
||||
'approved_position_id' => $position->id,
|
||||
'approved_by_user_id' => $actor->id,
|
||||
'approved_at' => now(),
|
||||
'status' => 'assigned',
|
||||
])->save();
|
||||
|
||||
$assignment = StaffAssignment::query()->updateOrCreate(
|
||||
['application_id' => $application->id],
|
||||
[
|
||||
'election_id' => $election->id,
|
||||
'user_id' => $application->user_id,
|
||||
'position_id' => $position->id,
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'saluran_mengundi_id' => $saluranMengundiId,
|
||||
'reports_to_assignment_id' => $reportsToAssignmentId,
|
||||
'status' => $data['status'] ?? 'active',
|
||||
'assigned_by_user_id' => $actor->id,
|
||||
'assigned_at' => now(),
|
||||
'source' => 'admin_direct',
|
||||
],
|
||||
);
|
||||
|
||||
$application->statusHistories()->create([
|
||||
'from_status' => $previousStatus,
|
||||
'to_status' => 'assigned',
|
||||
'changed_by_user_id' => $actor->id,
|
||||
'note' => $data['note'] ?? 'Direct assignment by Admin.',
|
||||
'metadata' => [
|
||||
'approved_position_id' => $position->id,
|
||||
'previous_position_id' => $previousPositionId,
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'saluran_mengundi_id' => $saluranMengundiId,
|
||||
'reports_to_assignment_id' => $reportsToAssignmentId,
|
||||
],
|
||||
]);
|
||||
|
||||
$assignment->histories()->create([
|
||||
'from_position_id' => $previousPositionId,
|
||||
'to_position_id' => $position->id,
|
||||
'to_pusat_mengundi_id' => $pusat->id,
|
||||
'to_saluran_mengundi_id' => $saluranMengundiId,
|
||||
'to_reports_to_assignment_id' => $reportsToAssignmentId,
|
||||
'changed_by_user_id' => $actor->id,
|
||||
'note' => $data['note'] ?? 'Direct assignment by Admin.',
|
||||
]);
|
||||
|
||||
$this->operationalAccountService->provisionForApplication($application, $position);
|
||||
$this->postCloseNoteService->recordIfClosed($election, $assignment, $actor, $data['note'] ?? null, 'admin_assignment_after_close');
|
||||
|
||||
activity('admin_management')
|
||||
->performedOn($assignment)
|
||||
->causedBy($actor)
|
||||
->withProperties([
|
||||
'application_id' => $application->id,
|
||||
'position_code' => $position->code,
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'saluran_mengundi_id' => $saluranMengundiId,
|
||||
'reports_to_assignment_id' => $reportsToAssignmentId,
|
||||
])
|
||||
->log('staff_assigned_by_admin');
|
||||
|
||||
return $assignment;
|
||||
});
|
||||
}
|
||||
|
||||
private function normalizeIc(mixed $icNumber): string
|
||||
{
|
||||
return preg_replace('/\D+/', '', (string) $icNumber) ?? '';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Admin\Management;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Election;
|
||||
use App\Models\Position;
|
||||
use App\Models\PusatMengundi;
|
||||
use App\Models\SaluranMengundi;
|
||||
use App\Models\StaffAssignment;
|
||||
use App\Services\Admin\OperationalAccountService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class AdminAssignmentManagementService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AdminPostCloseNoteService $postCloseNoteService,
|
||||
private readonly OperationalAccountService $operationalAccountService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function update(StaffAssignment $assignment, User $actor, array $data): StaffAssignment
|
||||
{
|
||||
return DB::transaction(function () use ($assignment, $actor, $data): StaffAssignment {
|
||||
$assignment->loadMissing(['election', 'position']);
|
||||
$election = $assignment->election;
|
||||
if (! $election instanceof Election) {
|
||||
throw ValidationException::withMessages([
|
||||
'election_id' => 'Pilihanraya tugasan tidak sah.',
|
||||
]);
|
||||
}
|
||||
$this->postCloseNoteService->requireIfClosed($election, $data['note'] ?? null);
|
||||
|
||||
/** @var Position $position */
|
||||
$position = Position::query()->findOrFail($data['position_id']);
|
||||
/** @var PusatMengundi $pusat */
|
||||
$pusat = PusatMengundi::query()->findOrFail($data['pusat_mengundi_id']);
|
||||
|
||||
if ((int) $pusat->election_id !== (int) $election->id) {
|
||||
throw ValidationException::withMessages([
|
||||
'pusat_mengundi_id' => 'Pusat Mengundi tidak berada dalam pilihanraya yang sama.',
|
||||
]);
|
||||
}
|
||||
|
||||
$before = [
|
||||
'position_id' => $assignment->position_id,
|
||||
'pusat_mengundi_id' => $assignment->pusat_mengundi_id,
|
||||
'saluran_mengundi_id' => $assignment->saluran_mengundi_id,
|
||||
'reports_to_assignment_id' => $assignment->reports_to_assignment_id,
|
||||
'status' => $assignment->status,
|
||||
];
|
||||
|
||||
$reportsToAssignmentId = $data['reports_to_assignment_id'] ?? null;
|
||||
$saluranMengundiId = $data['saluran_mengundi_id'] ?? null;
|
||||
|
||||
if ($saluranMengundiId) {
|
||||
/** @var SaluranMengundi $saluran */
|
||||
$saluran = SaluranMengundi::query()->findOrFail($saluranMengundiId);
|
||||
if ((int) $saluran->pusat_mengundi_id !== (int) $pusat->id) {
|
||||
throw ValidationException::withMessages([
|
||||
'saluran_mengundi_id' => 'Saluran Mengundi tidak berada di bawah Pusat Mengundi yang dipilih.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($reportsToAssignmentId) {
|
||||
/** @var StaffAssignment $ktmAssignment */
|
||||
$ktmAssignment = StaffAssignment::query()
|
||||
->with('position')
|
||||
->whereKey($reportsToAssignmentId)
|
||||
->where('election_id', $election->id)
|
||||
->where('pusat_mengundi_id', $pusat->id)
|
||||
->where('status', 'active')
|
||||
->firstOrFail();
|
||||
|
||||
$ktmPosition = $ktmAssignment->position;
|
||||
if (! $ktmPosition instanceof Position || $ktmPosition->code !== 'KTM') {
|
||||
throw ValidationException::withMessages([
|
||||
'reports_to_assignment_id' => 'Ketua rujukan mestilah tugasan KTM aktif.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ((int) $ktmAssignment->id === (int) $assignment->id) {
|
||||
throw ValidationException::withMessages([
|
||||
'reports_to_assignment_id' => 'KTM tidak boleh melapor kepada tugasan sendiri.',
|
||||
]);
|
||||
}
|
||||
|
||||
$saluranMengundiId = $ktmAssignment->saluran_mengundi_id;
|
||||
}
|
||||
|
||||
$assignment->forceFill([
|
||||
'position_id' => $position->id,
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'saluran_mengundi_id' => $saluranMengundiId,
|
||||
'reports_to_assignment_id' => $reportsToAssignmentId,
|
||||
'status' => $data['status'],
|
||||
'assigned_by_user_id' => $actor->id,
|
||||
'assigned_at' => now(),
|
||||
'source' => 'admin_update',
|
||||
])->save();
|
||||
|
||||
$assignment->histories()->create([
|
||||
'from_position_id' => $before['position_id'],
|
||||
'to_position_id' => $position->id,
|
||||
'from_pusat_mengundi_id' => $before['pusat_mengundi_id'],
|
||||
'to_pusat_mengundi_id' => $pusat->id,
|
||||
'from_saluran_mengundi_id' => $before['saluran_mengundi_id'],
|
||||
'to_saluran_mengundi_id' => $saluranMengundiId,
|
||||
'from_reports_to_assignment_id' => $before['reports_to_assignment_id'],
|
||||
'to_reports_to_assignment_id' => $reportsToAssignmentId,
|
||||
'changed_by_user_id' => $actor->id,
|
||||
'note' => $data['note'] ?? 'Assignment updated by Admin.',
|
||||
]);
|
||||
|
||||
$application = $assignment->application;
|
||||
if ($application instanceof Application) {
|
||||
$application->forceFill([
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'approved_position_id' => $position->id,
|
||||
'selected_ktm_assignment_id' => $reportsToAssignmentId,
|
||||
'status' => $data['status'] === 'active' ? 'assigned' : $application->status,
|
||||
])->save();
|
||||
}
|
||||
|
||||
$this->operationalAccountService->provisionForAssignment($assignment, $position);
|
||||
|
||||
$this->postCloseNoteService->recordIfClosed($election, $assignment, $actor, $data['note'] ?? null, 'admin_assignment_edit_after_close');
|
||||
|
||||
activity('admin_management')
|
||||
->performedOn($assignment)
|
||||
->causedBy($actor)
|
||||
->withProperties([
|
||||
'before' => $before,
|
||||
'after' => [
|
||||
'position_id' => $assignment->position_id,
|
||||
'pusat_mengundi_id' => $assignment->pusat_mengundi_id,
|
||||
'saluran_mengundi_id' => $assignment->saluran_mengundi_id,
|
||||
'reports_to_assignment_id' => $assignment->reports_to_assignment_id,
|
||||
'status' => $assignment->status,
|
||||
],
|
||||
])
|
||||
->log('staff_assignment_updated_by_admin');
|
||||
|
||||
return $assignment;
|
||||
});
|
||||
}
|
||||
}
|
||||
46
app/Services/Admin/Management/AdminPostCloseNoteService.php
Normal file
46
app/Services/Admin/Management/AdminPostCloseNoteService.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Admin\Management;
|
||||
|
||||
use App\Models\Election;
|
||||
use App\Models\SystemNote;
|
||||
use App\Models\User;
|
||||
use App\Services\RegistrationPeriodService;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class AdminPostCloseNoteService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RegistrationPeriodService $registrationPeriodService,
|
||||
) {}
|
||||
|
||||
public function requireIfClosed(Election $election, ?string $note): void
|
||||
{
|
||||
if ($this->registrationPeriodService->isOpen($election)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (blank($note)) {
|
||||
throw ValidationException::withMessages([
|
||||
'note' => 'Catatan wajib diisi untuk perubahan selepas tempoh pendaftaran ditutup.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function recordIfClosed(Election $election, Model $noteable, User $actor, ?string $note, string $type): ?SystemNote
|
||||
{
|
||||
if ($this->registrationPeriodService->isOpen($election) || blank($note)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return SystemNote::query()->create([
|
||||
'election_id' => $election->id,
|
||||
'noteable_type' => $noteable::class,
|
||||
'noteable_id' => $noteable->getKey(),
|
||||
'note_type' => $type,
|
||||
'note' => $note,
|
||||
'created_by_user_id' => $actor->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
111
app/Services/Admin/Management/AdminRepresentativeService.php
Normal file
111
app/Services/Admin/Management/AdminRepresentativeService.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Admin\Management;
|
||||
|
||||
use App\Models\Election;
|
||||
use App\Models\JkmRepresentative;
|
||||
use App\Models\KkmRepresentative;
|
||||
use App\Models\PoliceEscort;
|
||||
use App\Models\PusatMengundi;
|
||||
use App\Models\SaluranMengundi;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AdminRepresentativeService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AdminPostCloseNoteService $postCloseNoteService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function store(User $actor, array $data): Model
|
||||
{
|
||||
return DB::transaction(function () use ($actor, $data): Model {
|
||||
$election = Election::query()->findOrFail($data['election_id']);
|
||||
$this->postCloseNoteService->requireIfClosed($election, $data['note'] ?? null);
|
||||
/** @var PusatMengundi $pusat */
|
||||
$pusat = PusatMengundi::query()->findOrFail($data['pusat_mengundi_id']);
|
||||
|
||||
if ((int) $pusat->election_id !== (int) $election->id) {
|
||||
throw new \InvalidArgumentException('Pusat Mengundi tidak berada dalam pilihanraya yang sama.');
|
||||
}
|
||||
|
||||
if (! empty($data['saluran_mengundi_id'])) {
|
||||
/** @var SaluranMengundi $saluran */
|
||||
$saluran = SaluranMengundi::query()->findOrFail($data['saluran_mengundi_id']);
|
||||
if ((int) $saluran->pusat_mengundi_id !== (int) $pusat->id) {
|
||||
throw new \InvalidArgumentException('Saluran Mengundi tidak berada di bawah Pusat Mengundi yang dipilih.');
|
||||
}
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'election_id' => $election->id,
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'name' => $data['name'],
|
||||
'ic_number' => $data['ic_number'] ?? null,
|
||||
'phone_number' => $data['phone_number'] ?? null,
|
||||
'notes' => $data['notes'] ?? null,
|
||||
];
|
||||
|
||||
$type = (string) $data['type'];
|
||||
|
||||
$representative = match ($type) {
|
||||
'police' => PoliceEscort::query()->create([
|
||||
...$payload,
|
||||
'saluran_mengundi_id' => $data['saluran_mengundi_id'] ?? null,
|
||||
'rank' => $data['rank'] ?? null,
|
||||
'station' => $data['station'] ?? null,
|
||||
]),
|
||||
'kkm' => KkmRepresentative::query()->create([
|
||||
...$payload,
|
||||
'agency' => $data['agency'] ?? null,
|
||||
]),
|
||||
'jkm' => JkmRepresentative::query()->create([
|
||||
...$payload,
|
||||
'agency' => $data['agency'] ?? null,
|
||||
]),
|
||||
default => throw new \InvalidArgumentException('Jenis wakil agensi tidak sah.'),
|
||||
};
|
||||
|
||||
$this->postCloseNoteService->recordIfClosed($election, $representative, $actor, $data['note'] ?? null, 'admin_representative_after_close');
|
||||
|
||||
activity('admin_management')
|
||||
->performedOn($representative)
|
||||
->causedBy($actor)
|
||||
->withProperties(['type' => $type])
|
||||
->log('representative_created_by_admin');
|
||||
|
||||
return $representative;
|
||||
});
|
||||
}
|
||||
|
||||
public function delete(Model $representative, User $actor, ?string $note = null): void
|
||||
{
|
||||
DB::transaction(function () use ($representative, $actor, $note): void {
|
||||
$election = match (true) {
|
||||
$representative instanceof PoliceEscort => $representative->election,
|
||||
$representative instanceof KkmRepresentative => $representative->election,
|
||||
$representative instanceof JkmRepresentative => $representative->election,
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (! $election instanceof Election) {
|
||||
throw new \InvalidArgumentException('Pilihanraya wakil agensi tidak sah.');
|
||||
}
|
||||
$this->postCloseNoteService->requireIfClosed($election, $note);
|
||||
|
||||
$this->postCloseNoteService->recordIfClosed($election, $representative, $actor, $note, 'admin_representative_delete_after_close');
|
||||
|
||||
activity('admin_management')
|
||||
->performedOn($representative)
|
||||
->causedBy($actor)
|
||||
->withProperties(['note' => $note])
|
||||
->log('representative_deleted_by_admin');
|
||||
|
||||
$representative->delete();
|
||||
});
|
||||
}
|
||||
}
|
||||
131
app/Services/Admin/Management/PennempatanService.php
Normal file
131
app/Services/Admin/Management/PennempatanService.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Admin\Management;
|
||||
|
||||
use App\Models\Position;
|
||||
use App\Models\PositionQuota;
|
||||
use App\Models\PusatMengundi;
|
||||
use App\Models\SaluranMengundi;
|
||||
use App\Models\StaffAssignment;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Collection as BaseCollection;
|
||||
|
||||
class PennempatanService
|
||||
{
|
||||
/**
|
||||
* Summary per pusat for the dashboard vacancy table.
|
||||
*
|
||||
* @return Collection<int, PusatMengundi>
|
||||
*/
|
||||
public function pusatsWithVacancySummary(): Collection
|
||||
{
|
||||
return PusatMengundi::query()
|
||||
->with([
|
||||
'dun',
|
||||
'election',
|
||||
'saluranMengundis',
|
||||
'positionQuotas',
|
||||
])
|
||||
->withCount(['saluranMengundis'])
|
||||
->get()
|
||||
->each(function (PusatMengundi $pusat): void {
|
||||
$totalQuota = $pusat->positionQuotas->sum('quota');
|
||||
$filled = StaffAssignment::query()
|
||||
->where('pusat_mengundi_id', $pusat->id)
|
||||
->where('status', 'active')
|
||||
->count();
|
||||
|
||||
$pusat->setAttribute('total_quota', $totalQuota);
|
||||
$pusat->setAttribute('filled', $filled);
|
||||
$pusat->setAttribute('vacant', max(0, $totalQuota - $filled));
|
||||
|
||||
// PPM name
|
||||
$ppmPosition = Position::query()->where('code', 'PPM')->first();
|
||||
$ppmAssignment = $ppmPosition
|
||||
? StaffAssignment::query()
|
||||
->with('application')
|
||||
->where('pusat_mengundi_id', $pusat->id)
|
||||
->where('position_id', $ppmPosition->id)
|
||||
->where('status', 'active')
|
||||
->whereNull('saluran_mengundi_id')
|
||||
->first()
|
||||
: null;
|
||||
|
||||
$pusat->setAttribute('ppm_name', $ppmAssignment?->application?->name ?? '-');
|
||||
})
|
||||
->filter(fn (PusatMengundi $p): bool => $p->vacant > 0)
|
||||
->sortByDesc('vacant')
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured breakdown of quotas + assignments for a specific pusat.
|
||||
*
|
||||
* @return BaseCollection<int, array{saluran: SaluranMengundi|null, label: string, entries: BaseCollection}>
|
||||
*/
|
||||
public function detailForPusat(PusatMengundi $pusat): BaseCollection
|
||||
{
|
||||
$quotas = PositionQuota::query()
|
||||
->with('position')
|
||||
->where('pusat_mengundi_id', $pusat->id)
|
||||
->get()
|
||||
->groupBy(fn (PositionQuota $q): string => $q->saluran_mengundi_id ?? 'pusat');
|
||||
|
||||
$assignments = StaffAssignment::query()
|
||||
->with(['application', 'position'])
|
||||
->where('pusat_mengundi_id', $pusat->id)
|
||||
->where('status', 'active')
|
||||
->get()
|
||||
->groupBy(fn (StaffAssignment $a): string => ($a->saluran_mengundi_id ?? 'pusat').'_'.$a->position_id);
|
||||
|
||||
$salurans = SaluranMengundi::query()
|
||||
->where('pusat_mengundi_id', $pusat->id)
|
||||
->orderBy('number')
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$sections = collect();
|
||||
|
||||
// Pusat-level section first
|
||||
if ($quotas->has('pusat')) {
|
||||
$sections->push($this->buildSection(null, 'Peringkat Pusat (Saluran 0)', $quotas->get('pusat'), $assignments));
|
||||
}
|
||||
|
||||
// Per-saluran sections
|
||||
foreach ($salurans as $saluran) {
|
||||
$saluranQuotas = $quotas->get((string) $saluran->id, collect());
|
||||
$sections->push($this->buildSection($saluran, 'Saluran '.$saluran->number, $saluranQuotas, $assignments));
|
||||
}
|
||||
|
||||
return $sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, PositionQuota> $quotas
|
||||
* @param BaseCollection<string, Collection> $assignments
|
||||
* @return array{saluran: SaluranMengundi|null, label: string, entries: BaseCollection}
|
||||
*/
|
||||
private function buildSection(?SaluranMengundi $saluran, string $label, $quotas, BaseCollection $assignments): array
|
||||
{
|
||||
$scopeKey = $saluran ? (string) $saluran->id : 'pusat';
|
||||
|
||||
$entries = $quotas->map(function (PositionQuota $quota) use ($scopeKey, $assignments) {
|
||||
$key = $scopeKey.'_'.$quota->position_id;
|
||||
$filled = $assignments->get($key, collect());
|
||||
$vacant = max(0, $quota->quota - $filled->count());
|
||||
|
||||
return [
|
||||
'quota' => $quota,
|
||||
'position' => $quota->position,
|
||||
'assignments' => $filled,
|
||||
'vacant' => $vacant,
|
||||
];
|
||||
})->values();
|
||||
|
||||
return [
|
||||
'saluran' => $saluran,
|
||||
'label' => $label,
|
||||
'entries' => $entries,
|
||||
];
|
||||
}
|
||||
}
|
||||
139
app/Services/Admin/Management/WheelchairManagementService.php
Normal file
139
app/Services/Admin/Management/WheelchairManagementService.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Admin\Management;
|
||||
|
||||
use App\Models\Election;
|
||||
use App\Models\PusatMengundi;
|
||||
use App\Models\User;
|
||||
use App\Models\WheelchairAllocation;
|
||||
use App\Models\WheelchairTransaction;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class WheelchairManagementService
|
||||
{
|
||||
/**
|
||||
* @param array{election_id: int, pusat_mengundi_id: int, allocated_quantity: int, notes?: string|null} $data
|
||||
*/
|
||||
public function upsertAllocation(User $actor, array $data): WheelchairAllocation
|
||||
{
|
||||
return DB::transaction(function () use ($actor, $data): WheelchairAllocation {
|
||||
/** @var Election $election */
|
||||
$election = Election::query()->findOrFail($data['election_id']);
|
||||
/** @var PusatMengundi $pusat */
|
||||
$pusat = PusatMengundi::query()->findOrFail($data['pusat_mengundi_id']);
|
||||
|
||||
if ((int) $pusat->election_id !== (int) $election->id) {
|
||||
throw ValidationException::withMessages([
|
||||
'pusat_mengundi_id' => 'Pusat Mengundi tidak berada dalam pilihanraya yang sama.',
|
||||
]);
|
||||
}
|
||||
|
||||
$allocation = WheelchairAllocation::query()->firstOrNew([
|
||||
'election_id' => $election->id,
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
]);
|
||||
|
||||
$outstanding = $allocation->exists ? $this->outstandingQuantity($allocation) : 0;
|
||||
|
||||
if ($data['allocated_quantity'] < $outstanding) {
|
||||
throw ValidationException::withMessages([
|
||||
'allocated_quantity' => 'Peruntukan tidak boleh kurang daripada jumlah kerusi roda yang masih belum dipulang.',
|
||||
]);
|
||||
}
|
||||
|
||||
$before = $allocation->exists ? $allocation->only(['allocated_quantity', 'notes']) : null;
|
||||
|
||||
$allocation->forceFill([
|
||||
'allocated_quantity' => $data['allocated_quantity'],
|
||||
'notes' => $data['notes'] ?? null,
|
||||
])->save();
|
||||
|
||||
activity('wheelchairs')
|
||||
->performedOn($allocation)
|
||||
->causedBy($actor)
|
||||
->withProperties([
|
||||
'before' => $before,
|
||||
'after' => $allocation->only(['allocated_quantity', 'notes']),
|
||||
])
|
||||
->log('wheelchair_allocation_upserted');
|
||||
|
||||
return $allocation;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{transaction_type: string, quantity: int, taken_at?: string|null, taken_by_name?: string|null, returned_at?: string|null, return_condition?: string|null, notes?: string|null} $data
|
||||
*/
|
||||
public function recordTransaction(WheelchairAllocation $allocation, User $actor, array $data): WheelchairTransaction
|
||||
{
|
||||
return DB::transaction(function () use ($allocation, $actor, $data): WheelchairTransaction {
|
||||
$allocation->refresh();
|
||||
$type = $data['transaction_type'];
|
||||
$quantity = $data['quantity'];
|
||||
|
||||
if ($type === 'taken' && $quantity > $this->availableQuantity($allocation)) {
|
||||
throw ValidationException::withMessages([
|
||||
'quantity' => 'Jumlah diambil melebihi baki peruntukan kerusi roda.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($type === 'returned' && $quantity > $this->outstandingQuantity($allocation)) {
|
||||
throw ValidationException::withMessages([
|
||||
'quantity' => 'Jumlah dipulang melebihi jumlah kerusi roda yang masih belum dipulang.',
|
||||
]);
|
||||
}
|
||||
|
||||
$transaction = WheelchairTransaction::query()->create([
|
||||
'wheelchair_allocation_id' => $allocation->id,
|
||||
'transaction_type' => $type,
|
||||
'quantity' => $quantity,
|
||||
'taken_at' => $type === 'taken' ? ($data['taken_at'] ?? now()) : null,
|
||||
'taken_by_name' => $type === 'taken' ? ($data['taken_by_name'] ?? null) : null,
|
||||
'returned_at' => $type === 'returned' ? ($data['returned_at'] ?? now()) : null,
|
||||
'return_condition' => $type === 'returned' ? ($data['return_condition'] ?? null) : null,
|
||||
'returned_by_name' => $type === 'returned' ? ($data['returned_by_name'] ?? null) : null,
|
||||
'recorded_by_user_id' => $actor->id,
|
||||
'notes' => $data['notes'] ?? null,
|
||||
]);
|
||||
|
||||
activity('wheelchairs')
|
||||
->performedOn($transaction)
|
||||
->causedBy($actor)
|
||||
->withProperties([
|
||||
'allocation_id' => $allocation->id,
|
||||
'transaction_type' => $type,
|
||||
'quantity' => $quantity,
|
||||
'available_after' => $this->availableQuantity($allocation->fresh()),
|
||||
'outstanding_after' => $this->outstandingQuantity($allocation->fresh()),
|
||||
])
|
||||
->log('wheelchair_transaction_recorded');
|
||||
|
||||
return $transaction;
|
||||
});
|
||||
}
|
||||
|
||||
public function takenQuantity(WheelchairAllocation $allocation): int
|
||||
{
|
||||
return (int) $allocation->transactions()
|
||||
->where('transaction_type', 'taken')
|
||||
->sum('quantity');
|
||||
}
|
||||
|
||||
public function returnedQuantity(WheelchairAllocation $allocation): int
|
||||
{
|
||||
return (int) $allocation->transactions()
|
||||
->where('transaction_type', 'returned')
|
||||
->sum('quantity');
|
||||
}
|
||||
|
||||
public function outstandingQuantity(WheelchairAllocation $allocation): int
|
||||
{
|
||||
return max(0, $this->takenQuantity($allocation) - $this->returnedQuantity($allocation));
|
||||
}
|
||||
|
||||
public function availableQuantity(WheelchairAllocation $allocation): int
|
||||
{
|
||||
return max(0, (int) $allocation->allocated_quantity - $this->outstandingQuantity($allocation));
|
||||
}
|
||||
}
|
||||
89
app/Services/Admin/OperationalAccountService.php
Normal file
89
app/Services/Admin/OperationalAccountService.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Admin;
|
||||
|
||||
use App\Mail\OperationalAccountCreatedMail;
|
||||
use App\Models\Application;
|
||||
use App\Models\Position;
|
||||
use App\Models\StaffAssignment;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class OperationalAccountService
|
||||
{
|
||||
private const ROLES_NEEDING_ACCOUNT = ['PPM', 'KTM'];
|
||||
|
||||
public function provisionForApplication(Application $application, Position $position): void
|
||||
{
|
||||
if (! in_array($position->code, self::ROLES_NEEDING_ACCOUNT, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $application->email) {
|
||||
return;
|
||||
}
|
||||
|
||||
[$user, $tempPassword] = $this->findOrCreateUser($application->name, $application->email);
|
||||
|
||||
// Link user to application if not already linked
|
||||
if ($application->user_id !== $user->id) {
|
||||
$application->forceFill(['user_id' => $user->id])->save();
|
||||
}
|
||||
|
||||
$user->assignRole($position->code);
|
||||
|
||||
if ($tempPassword !== null) {
|
||||
Mail::to($user->email)->queue(
|
||||
new OperationalAccountCreatedMail($user, $position->code, $tempPassword)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function provisionForAssignment(StaffAssignment $assignment, Position $position): void
|
||||
{
|
||||
if (! in_array($position->code, self::ROLES_NEEDING_ACCOUNT, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$application = $assignment->application;
|
||||
|
||||
if ($application) {
|
||||
$this->provisionForApplication($application, $position);
|
||||
|
||||
// Re-link user_id on assignment after provisioning
|
||||
$assignment->loadMissing('application');
|
||||
if ($assignment->application?->user_id && $assignment->user_id !== $assignment->application->user_id) {
|
||||
$assignment->forceFill(['user_id' => $assignment->application->user_id])->save();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Assignment without application (direct assignment) — skip provisioning
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{User, string|null} [$user, $tempPassword|null]
|
||||
*/
|
||||
private function findOrCreateUser(string $name, string $email): array
|
||||
{
|
||||
$existing = User::query()->where('email', $email)->first();
|
||||
|
||||
if ($existing) {
|
||||
return [$existing, null];
|
||||
}
|
||||
|
||||
$tempPassword = Str::random(10);
|
||||
|
||||
$user = User::query()->create([
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'password' => Hash::make($tempPassword),
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
return [$user, $tempPassword];
|
||||
}
|
||||
}
|
||||
65
app/Services/Admin/PpmAssignmentService.php
Normal file
65
app/Services/Admin/PpmAssignmentService.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Admin;
|
||||
|
||||
use App\Models\Position;
|
||||
use App\Models\PusatMengundi;
|
||||
use App\Models\StaffAssignment;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class PpmAssignmentService
|
||||
{
|
||||
public function assign(PusatMengundi $pusatMengundi, User $user): StaffAssignment
|
||||
{
|
||||
return DB::transaction(function () use ($pusatMengundi, $user): StaffAssignment {
|
||||
$position = Position::query()->where('code', 'PPM')->firstOrFail();
|
||||
|
||||
StaffAssignment::query()
|
||||
->where('election_id', $pusatMengundi->election_id)
|
||||
->where('pusat_mengundi_id', $pusatMengundi->id)
|
||||
->where('position_id', $position->id)
|
||||
->whereNull('saluran_mengundi_id')
|
||||
->where('status', 'active')
|
||||
->where('user_id', '!=', $user->id)
|
||||
->update(['status' => 'replaced']);
|
||||
|
||||
$assignment = StaffAssignment::query()->updateOrCreate(
|
||||
[
|
||||
'election_id' => $pusatMengundi->election_id,
|
||||
'pusat_mengundi_id' => $pusatMengundi->id,
|
||||
'position_id' => $position->id,
|
||||
'saluran_mengundi_id' => null,
|
||||
'user_id' => $user->id,
|
||||
],
|
||||
[
|
||||
'status' => 'active',
|
||||
'assigned_by_user_id' => auth()->id(),
|
||||
'assigned_at' => now(),
|
||||
'source' => 'admin_setup',
|
||||
],
|
||||
);
|
||||
|
||||
$assignment->histories()->create([
|
||||
'to_position_id' => $position->id,
|
||||
'to_pusat_mengundi_id' => $pusatMengundi->id,
|
||||
'changed_by_user_id' => auth()->id(),
|
||||
'note' => 'PPM assigned from Admin setup module.',
|
||||
]);
|
||||
|
||||
$user->assignRole('PPM');
|
||||
|
||||
activity('admin_setup')
|
||||
->performedOn($assignment)
|
||||
->causedBy(auth()->user())
|
||||
->withProperties([
|
||||
'pusat_mengundi_id' => $pusatMengundi->id,
|
||||
'user_id' => $user->id,
|
||||
'position_code' => 'PPM',
|
||||
])
|
||||
->log('ppm_assigned');
|
||||
|
||||
return $assignment;
|
||||
});
|
||||
}
|
||||
}
|
||||
180
app/Services/Admin/PusatImportService.php
Normal file
180
app/Services/Admin/PusatImportService.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Admin;
|
||||
|
||||
use App\Models\Dun;
|
||||
use App\Models\Election;
|
||||
use App\Models\Position;
|
||||
use App\Models\PositionQuota;
|
||||
use App\Models\PusatMengundi;
|
||||
use App\Models\SaluranMengundi;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use OpenSpout\Reader\XLSX\Reader;
|
||||
|
||||
class PusatImportService
|
||||
{
|
||||
// Column indexes (0-based) in the XLSX
|
||||
private const COL_NAMA_PUSAT = 0;
|
||||
private const COL_NAMA_PPM = 1;
|
||||
private const COL_SALURAN = 2;
|
||||
// 3 KTM, 4 POLIS, 5 KP — formula columns, computed from saluran count
|
||||
private const COL_PPM_COUNT = 6;
|
||||
// 7 KPDP — computed from saluran count
|
||||
private const COL_PAPM = 8;
|
||||
private const COL_JKM = 9;
|
||||
private const COL_KKM = 10;
|
||||
// 11 TAMBAHAN — computed from saluran count
|
||||
private const COL_PPM_PHONE = 12;
|
||||
|
||||
/**
|
||||
* @return array{imported: int, salurans: int, errors: list<string>}
|
||||
*/
|
||||
public function import(Election $election, Dun $dun, UploadedFile $file): array
|
||||
{
|
||||
$rows = $this->parseXlsx($file->getRealPath());
|
||||
$errors = [];
|
||||
$imported = 0;
|
||||
$saluranTotal = 0;
|
||||
|
||||
DB::transaction(function () use ($election, $dun, $rows, &$imported, &$saluranTotal, &$errors): void {
|
||||
// Replace: delete all existing pusat for this DUN (cascade to saluran + quotas).
|
||||
PusatMengundi::query()
|
||||
->where('dun_id', $dun->id)
|
||||
->each(function (PusatMengundi $pusat): void {
|
||||
$pusat->saluranMengundis()->delete();
|
||||
$pusat->positionQuotas()->delete();
|
||||
$pusat->delete();
|
||||
});
|
||||
|
||||
$positions = Position::query()->get()->keyBy('code');
|
||||
|
||||
foreach ($rows as $index => $row) {
|
||||
$rowNum = $index + 2; // +2: 1-based + skip header
|
||||
|
||||
$namaPusat = trim((string) ($row[self::COL_NAMA_PUSAT] ?? ''));
|
||||
if ($namaPusat === '') {
|
||||
continue; // skip empty rows
|
||||
}
|
||||
|
||||
$saluranCount = (int) ($row[self::COL_SALURAN] ?? 0);
|
||||
if ($saluranCount < 1) {
|
||||
$errors[] = "Baris {$rowNum}: Bilangan saluran mesti sekurang-kurangnya 1 untuk '{$namaPusat}'.";
|
||||
continue;
|
||||
}
|
||||
|
||||
$code = sprintf('PM%03d', $imported + 1);
|
||||
|
||||
$pusat = PusatMengundi::query()->create([
|
||||
'election_id' => $election->id,
|
||||
'dun_id' => $dun->id,
|
||||
'public_uuid' => (string) Str::uuid(),
|
||||
'code' => $code,
|
||||
'name' => $namaPusat,
|
||||
'address' => null,
|
||||
'ppm_name' => trim((string) ($row[self::COL_NAMA_PPM] ?? '')),
|
||||
'ppm_phone' => trim((string) ($row[self::COL_PPM_PHONE] ?? '')),
|
||||
'registration_url' => null,
|
||||
'qr_code_path' => null,
|
||||
]);
|
||||
|
||||
// Generate QR code for this pusat
|
||||
app(PusatQrCodeService::class)->generate($pusat);
|
||||
|
||||
// Create numbered salurans
|
||||
$salurans = [];
|
||||
for ($n = 1; $n <= $saluranCount; $n++) {
|
||||
$salurans[] = SaluranMengundi::query()->create([
|
||||
'election_id' => $election->id,
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'number' => (string) $n,
|
||||
'name' => null,
|
||||
'voter_count' => null,
|
||||
]);
|
||||
}
|
||||
$saluranTotal += $saluranCount;
|
||||
|
||||
// Pusat-level quotas
|
||||
$pusatQuotas = [
|
||||
'PPM' => (int) ($row[self::COL_PPM_COUNT] ?? 1),
|
||||
'PAPM' => (int) ($row[self::COL_PAPM] ?? 0),
|
||||
'JKM' => (int) ($row[self::COL_JKM] ?? 1),
|
||||
'KKM' => (int) ($row[self::COL_KKM] ?? 2),
|
||||
];
|
||||
|
||||
foreach ($pusatQuotas as $code => $quota) {
|
||||
$position = $positions->get($code);
|
||||
if ($position) {
|
||||
PositionQuota::query()->create([
|
||||
'election_id' => $election->id,
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'saluran_mengundi_id' => null,
|
||||
'position_id' => $position->id,
|
||||
'quota' => $quota,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Saluran-level quotas (standard per saluran)
|
||||
$saluranQuotaCodes = [
|
||||
'KTM' => 1,
|
||||
'POLIS' => 1,
|
||||
'KP' => 4,
|
||||
'KPDP' => 1,
|
||||
'CALON_TAMBAHAN' => 1,
|
||||
];
|
||||
|
||||
foreach ($salurans as $saluran) {
|
||||
foreach ($saluranQuotaCodes as $posCode => $quota) {
|
||||
$position = $positions->get($posCode);
|
||||
if ($position) {
|
||||
PositionQuota::query()->create([
|
||||
'election_id' => $election->id,
|
||||
'pusat_mengundi_id' => $pusat->id,
|
||||
'saluran_mengundi_id' => $saluran->id,
|
||||
'position_id' => $position->id,
|
||||
'quota' => $quota,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$imported++;
|
||||
}
|
||||
});
|
||||
|
||||
return [
|
||||
'imported' => $imported,
|
||||
'salurans' => $saluranTotal,
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<list<mixed>>
|
||||
*/
|
||||
private function parseXlsx(string $path): array
|
||||
{
|
||||
$reader = new Reader();
|
||||
$reader->open($path);
|
||||
|
||||
$rows = [];
|
||||
$isHeader = true;
|
||||
|
||||
foreach ($reader->getSheetIterator() as $sheet) {
|
||||
foreach ($sheet->getRowIterator() as $row) {
|
||||
if ($isHeader) {
|
||||
$isHeader = false;
|
||||
continue;
|
||||
}
|
||||
$rows[] = $row->toArray();
|
||||
}
|
||||
break; // only first sheet
|
||||
}
|
||||
|
||||
$reader->close();
|
||||
|
||||
return $rows;
|
||||
}
|
||||
}
|
||||
96
app/Services/Admin/PusatOperationalSetupService.php
Normal file
96
app/Services/Admin/PusatOperationalSetupService.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Admin;
|
||||
|
||||
use App\Models\Position;
|
||||
use App\Models\PositionQuota;
|
||||
use App\Models\PusatMengundi;
|
||||
use App\Models\SaluranMengundi;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class PusatOperationalSetupService
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function configure(PusatMengundi $pusatMengundi, array $data): void
|
||||
{
|
||||
DB::transaction(function () use ($pusatMengundi, $data): void {
|
||||
$salurans = collect();
|
||||
|
||||
foreach (range(1, (int) $data['saluran_count']) as $number) {
|
||||
$salurans->push(SaluranMengundi::query()->updateOrCreate(
|
||||
[
|
||||
'pusat_mengundi_id' => $pusatMengundi->id,
|
||||
'number' => (string) $number,
|
||||
],
|
||||
[
|
||||
'election_id' => $pusatMengundi->election_id,
|
||||
'name' => null,
|
||||
],
|
||||
));
|
||||
}
|
||||
|
||||
$this->upsertPusatQuotas($pusatMengundi, $data);
|
||||
|
||||
foreach ($salurans as $saluran) {
|
||||
$this->upsertSaluranQuotas($pusatMengundi, $saluran, $data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function upsertPusatQuotas(PusatMengundi $pusatMengundi, array $data): void
|
||||
{
|
||||
foreach ([
|
||||
'PPM' => 'ppm_count',
|
||||
'PAPM' => 'papm_count',
|
||||
'JKM' => 'jkm_count',
|
||||
'KKM' => 'kkm_count',
|
||||
] as $code => $field) {
|
||||
$this->upsertQuota($pusatMengundi, null, $code, (int) $data[$field]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function upsertSaluranQuotas(PusatMengundi $pusatMengundi, SaluranMengundi $saluran, array $data): void
|
||||
{
|
||||
foreach ([
|
||||
'KTM' => 'ktm_count',
|
||||
'POLIS' => 'polis_iring_count',
|
||||
'KP' => 'kp_count',
|
||||
'KPDP' => 'kpdp_count',
|
||||
'CALON_TAMBAHAN' => 'calon_tambahan_count',
|
||||
] as $code => $field) {
|
||||
$this->upsertQuota($pusatMengundi, $saluran, $code, (int) $data[$field]);
|
||||
}
|
||||
}
|
||||
|
||||
private function upsertQuota(PusatMengundi $pusatMengundi, ?SaluranMengundi $saluran, string $positionCode, int $quota): void
|
||||
{
|
||||
$position = Position::query()->where('code', $positionCode)->first();
|
||||
|
||||
if (! $position instanceof Position) {
|
||||
throw ValidationException::withMessages([
|
||||
'positions' => 'Jawatan '.$positionCode.' belum dikonfigurasi.',
|
||||
]);
|
||||
}
|
||||
|
||||
PositionQuota::query()->updateOrCreate(
|
||||
[
|
||||
'election_id' => $pusatMengundi->election_id,
|
||||
'pusat_mengundi_id' => $pusatMengundi->id,
|
||||
'saluran_mengundi_id' => $saluran?->id,
|
||||
'position_id' => $position->id,
|
||||
],
|
||||
[
|
||||
'quota' => $quota,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
40
app/Services/Admin/PusatQrCodeService.php
Normal file
40
app/Services/Admin/PusatQrCodeService.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Admin;
|
||||
|
||||
use App\Models\PusatMengundi;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||
|
||||
class PusatQrCodeService
|
||||
{
|
||||
public function generate(PusatMengundi $pusatMengundi): PusatMengundi
|
||||
{
|
||||
$url = route('public.applications.create', [
|
||||
'pusatMengundi' => $pusatMengundi->public_uuid,
|
||||
]);
|
||||
|
||||
$path = 'qrcodes/pusat-mengundi/'.$pusatMengundi->public_uuid.'.svg';
|
||||
|
||||
Storage::disk('local')->put($path, QrCode::format('svg')
|
||||
->size(320)
|
||||
->margin(2)
|
||||
->generate($url));
|
||||
|
||||
$pusatMengundi->forceFill([
|
||||
'registration_url' => $url,
|
||||
'qr_code_path' => $path,
|
||||
])->save();
|
||||
|
||||
activity('admin_setup')
|
||||
->performedOn($pusatMengundi)
|
||||
->causedBy(auth()->user())
|
||||
->withProperties([
|
||||
'registration_url' => $url,
|
||||
'qr_code_path' => $path,
|
||||
])
|
||||
->log('pusat_qr_generated');
|
||||
|
||||
return $pusatMengundi;
|
||||
}
|
||||
}
|
||||
62
app/Services/Attendance/AttendanceExportService.php
Normal file
62
app/Services/Attendance/AttendanceExportService.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Attendance;
|
||||
|
||||
use App\Exports\AttendanceDetailExport;
|
||||
use App\Models\ExportLog;
|
||||
use App\Models\PusatMengundi;
|
||||
use App\Models\StaffAssignment;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class AttendanceExportService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AttendanceDetailExport $export,
|
||||
) {}
|
||||
|
||||
public function exportPusat(User $actor, PusatMengundi $pusatMengundi): ExportLog
|
||||
{
|
||||
$assignments = StaffAssignment::query()
|
||||
->with(['application', 'user', 'position', 'pusatMengundi', 'saluranMengundi', 'attendance'])
|
||||
->where('pusat_mengundi_id', $pusatMengundi->id)
|
||||
->where('status', 'active')
|
||||
->orderBy('position_id')
|
||||
->orderBy('saluran_mengundi_id')
|
||||
->get();
|
||||
|
||||
$fileName = 'attendance-'.$pusatMengundi->code.'-'.now()->format('Ymd-His').'.xlsx';
|
||||
$path = 'exports/attendance/'.$fileName;
|
||||
|
||||
Storage::disk('local')->makeDirectory('exports/attendance');
|
||||
$absolutePath = Storage::disk('local')->path($path);
|
||||
|
||||
$this->export->write($assignments, $absolutePath);
|
||||
|
||||
$log = ExportLog::query()->create([
|
||||
'election_id' => $pusatMengundi->election_id,
|
||||
'generated_by_user_id' => $actor->id,
|
||||
'generated_at' => now(),
|
||||
'report_type' => 'attendance_detail_by_pusat',
|
||||
'filter_parameters' => [
|
||||
'pusat_mengundi_id' => $pusatMengundi->id,
|
||||
'pusat_mengundi_code' => $pusatMengundi->code,
|
||||
],
|
||||
'file_name' => $fileName,
|
||||
'disk' => 'local',
|
||||
'path' => $path,
|
||||
]);
|
||||
|
||||
activity('exports')
|
||||
->performedOn($log)
|
||||
->causedBy($actor)
|
||||
->withProperties([
|
||||
'report_type' => 'attendance_detail_by_pusat',
|
||||
'pusat_mengundi_id' => $pusatMengundi->id,
|
||||
'row_count' => $assignments->count(),
|
||||
])
|
||||
->log('attendance_export_generated');
|
||||
|
||||
return $log;
|
||||
}
|
||||
}
|
||||
100
app/Services/Attendance/AttendanceRecordingService.php
Normal file
100
app/Services/Attendance/AttendanceRecordingService.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Attendance;
|
||||
|
||||
use App\Models\Attendance;
|
||||
use App\Models\Election;
|
||||
use App\Models\ElectionSetting;
|
||||
use App\Models\PusatMengundi;
|
||||
use App\Models\StaffAssignment;
|
||||
use App\Models\User;
|
||||
use App\Services\Ppm\PpmScopeService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class AttendanceRecordingService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PpmScopeService $ppmScopeService,
|
||||
) {}
|
||||
|
||||
public function ensureActive(PusatMengundi $pusatMengundi): void
|
||||
{
|
||||
$election = $pusatMengundi->election;
|
||||
$settings = $election instanceof Election ? $election->settings : null;
|
||||
|
||||
if (! $settings instanceof ElectionSetting || ! $settings->is_attendance_active) {
|
||||
throw ValidationException::withMessages([
|
||||
'attendance' => 'Modul kehadiran belum diaktifkan untuk pilihanraya ini.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function ensurePpmCanRecord(User $actor, PusatMengundi $pusatMengundi): void
|
||||
{
|
||||
if (! $this->ppmScopeService->assignedPusatIds($actor)->contains((int) $pusatMengundi->id)) {
|
||||
throw ValidationException::withMessages([
|
||||
'pusat_mengundi_id' => 'PPM hanya boleh merekod kehadiran untuk Pusat Mengundi sendiri.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{status: string, check_in_time?: string|null, note?: string|null}> $records
|
||||
*/
|
||||
public function recordBulk(PusatMengundi $pusatMengundi, User $actor, array $records): void
|
||||
{
|
||||
$this->ensureActive($pusatMengundi);
|
||||
$this->ensurePpmCanRecord($actor, $pusatMengundi);
|
||||
|
||||
DB::transaction(function () use ($pusatMengundi, $actor, $records): void {
|
||||
$assignments = StaffAssignment::query()
|
||||
->with(['position'])
|
||||
->where('pusat_mengundi_id', $pusatMengundi->id)
|
||||
->where('status', 'active')
|
||||
->whereIn('id', array_keys($records))
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
foreach ($records as $assignmentId => $record) {
|
||||
/** @var StaffAssignment|null $assignment */
|
||||
$assignment = $assignments->get((int) $assignmentId);
|
||||
|
||||
if (! $assignment instanceof StaffAssignment) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$status = $record['status'];
|
||||
$checkInTime = $status === 'present'
|
||||
? ($record['check_in_time'] ?? now())
|
||||
: null;
|
||||
|
||||
$attendance = Attendance::query()->updateOrCreate(
|
||||
[
|
||||
'election_id' => $assignment->election_id,
|
||||
'staff_assignment_id' => $assignment->id,
|
||||
],
|
||||
[
|
||||
'pusat_mengundi_id' => $assignment->pusat_mengundi_id,
|
||||
'saluran_mengundi_id' => $assignment->saluran_mengundi_id,
|
||||
'position_id' => $assignment->position_id,
|
||||
'status' => $status,
|
||||
'check_in_time' => $checkInTime,
|
||||
'recorded_by_user_id' => $actor->id,
|
||||
'note' => $record['note'] ?? null,
|
||||
],
|
||||
);
|
||||
|
||||
activity('attendance')
|
||||
->performedOn($attendance)
|
||||
->causedBy($actor)
|
||||
->withProperties([
|
||||
'staff_assignment_id' => $assignment->id,
|
||||
'status' => $status,
|
||||
'check_in_time' => $checkInTime,
|
||||
])
|
||||
->log('attendance_updated_by_ppm');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
72
app/Services/Attendance/AttendanceSummaryService.php
Normal file
72
app/Services/Attendance/AttendanceSummaryService.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Attendance;
|
||||
|
||||
use App\Models\Attendance;
|
||||
use App\Models\Position;
|
||||
use App\Models\PusatMengundi;
|
||||
use App\Models\StaffAssignment;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class AttendanceSummaryService
|
||||
{
|
||||
/**
|
||||
* @return array{total_staff: int, present: int, absent: int, not_recorded: int}
|
||||
*/
|
||||
public function totalsForPusat(PusatMengundi $pusatMengundi): array
|
||||
{
|
||||
$totalStaff = StaffAssignment::query()
|
||||
->where('pusat_mengundi_id', $pusatMengundi->id)
|
||||
->where('status', 'active')
|
||||
->count();
|
||||
|
||||
$present = Attendance::query()
|
||||
->where('pusat_mengundi_id', $pusatMengundi->id)
|
||||
->where('status', 'present')
|
||||
->count();
|
||||
|
||||
$absent = Attendance::query()
|
||||
->where('pusat_mengundi_id', $pusatMengundi->id)
|
||||
->where('status', 'absent')
|
||||
->count();
|
||||
|
||||
return [
|
||||
'total_staff' => $totalStaff,
|
||||
'present' => $present,
|
||||
'absent' => $absent,
|
||||
'not_recorded' => max(0, $totalStaff - $present - $absent),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, mixed>
|
||||
*/
|
||||
public function roleTotalsForPusat(PusatMengundi $pusatMengundi): Collection
|
||||
{
|
||||
$assignments = StaffAssignment::query()
|
||||
->with(['position', 'attendance'])
|
||||
->where('pusat_mengundi_id', $pusatMengundi->id)
|
||||
->where('status', 'active')
|
||||
->get();
|
||||
|
||||
return $assignments
|
||||
->groupBy(function (StaffAssignment $assignment): string {
|
||||
$position = $assignment->position;
|
||||
|
||||
return $position instanceof Position ? $position->code : 'UNKNOWN';
|
||||
})
|
||||
->map(function (Collection $items, string $role): array {
|
||||
$present = $items->filter(fn (StaffAssignment $assignment): bool => $assignment->attendance instanceof Attendance && $assignment->attendance->status === 'present')->count();
|
||||
$absent = $items->filter(fn (StaffAssignment $assignment): bool => $assignment->attendance instanceof Attendance && $assignment->attendance->status === 'absent')->count();
|
||||
|
||||
return [
|
||||
'role' => $role,
|
||||
'total_staff' => $items->count(),
|
||||
'present' => $present,
|
||||
'absent' => $absent,
|
||||
'not_recorded' => max(0, $items->count() - $present - $absent),
|
||||
];
|
||||
})
|
||||
->values();
|
||||
}
|
||||
}
|
||||
39
app/Services/Finance/BankVerificationService.php
Normal file
39
app/Services/Finance/BankVerificationService.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Finance;
|
||||
|
||||
use App\Models\BankVerification;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class BankVerificationService
|
||||
{
|
||||
/**
|
||||
* @param array{status: string, finance_note?: string|null} $data
|
||||
*/
|
||||
public function update(BankVerification $verification, User $actor, array $data): BankVerification
|
||||
{
|
||||
return DB::transaction(function () use ($verification, $actor, $data): BankVerification {
|
||||
$before = $verification->only(['status', 'finance_note', 'verified_by_user_id', 'verified_at']);
|
||||
|
||||
$verification->forceFill([
|
||||
'status' => $data['status'],
|
||||
'finance_note' => $data['finance_note'] ?? null,
|
||||
'verified_by_user_id' => $actor->id,
|
||||
'verified_at' => now(),
|
||||
])->save();
|
||||
|
||||
activity('finance')
|
||||
->performedOn($verification)
|
||||
->causedBy($actor)
|
||||
->withProperties([
|
||||
'before' => $before,
|
||||
'after' => $verification->only(['status', 'finance_note', 'verified_by_user_id', 'verified_at']),
|
||||
'application_id' => $verification->application_id,
|
||||
])
|
||||
->log('bank_verification_updated');
|
||||
|
||||
return $verification;
|
||||
});
|
||||
}
|
||||
}
|
||||
58
app/Services/Finance/FinanceExportService.php
Normal file
58
app/Services/Finance/FinanceExportService.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Finance;
|
||||
|
||||
use App\Exports\FinanceVerificationExport;
|
||||
use App\Models\Application;
|
||||
use App\Models\ExportLog;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class FinanceExportService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FinanceVerificationQuery $verificationQuery,
|
||||
private readonly FinanceVerificationExport $export,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
*/
|
||||
public function export(User $actor, array $filters): ExportLog
|
||||
{
|
||||
$verifications = $this->verificationQuery->build($filters)->get();
|
||||
$fileName = 'finance-verification-'.now()->format('Ymd-His').'.xlsx';
|
||||
$path = 'exports/finance/'.$fileName;
|
||||
|
||||
Storage::disk('local')->makeDirectory('exports/finance');
|
||||
$absolutePath = Storage::disk('local')->path($path);
|
||||
|
||||
$this->export->write($verifications, $absolutePath);
|
||||
|
||||
$firstApplication = $verifications->first()?->application;
|
||||
$electionId = $firstApplication instanceof Application ? $firstApplication->election_id : null;
|
||||
|
||||
$log = ExportLog::query()->create([
|
||||
'election_id' => $electionId,
|
||||
'generated_by_user_id' => $actor->id,
|
||||
'generated_at' => now(),
|
||||
'report_type' => 'finance_verification_list',
|
||||
'filter_parameters' => $filters,
|
||||
'file_name' => $fileName,
|
||||
'disk' => 'local',
|
||||
'path' => $path,
|
||||
]);
|
||||
|
||||
activity('exports')
|
||||
->performedOn($log)
|
||||
->causedBy($actor)
|
||||
->withProperties([
|
||||
'report_type' => 'finance_verification_list',
|
||||
'filters' => $filters,
|
||||
'row_count' => $verifications->count(),
|
||||
])
|
||||
->log('finance_verification_export_generated');
|
||||
|
||||
return $log;
|
||||
}
|
||||
}
|
||||
58
app/Services/Finance/FinanceVerificationQuery.php
Normal file
58
app/Services/Finance/FinanceVerificationQuery.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Finance;
|
||||
|
||||
use App\Models\BankVerification;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class FinanceVerificationQuery
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
* @return Builder<BankVerification>
|
||||
*/
|
||||
public function build(array $filters = []): Builder
|
||||
{
|
||||
return BankVerification::query()
|
||||
->with([
|
||||
'application.election',
|
||||
'application.pusatMengundi',
|
||||
'application.approvedPosition',
|
||||
'application.requestedPosition',
|
||||
'application.documents',
|
||||
'application.staffAssignments.position',
|
||||
'verifiedBy',
|
||||
])
|
||||
->whereHas('application', function (Builder $query) use ($filters): void {
|
||||
$query->whereIn('status', ['approved', 'assigned'])
|
||||
->when(! empty($filters['pusat_mengundi_id']), fn (Builder $nested) => $nested->where('pusat_mengundi_id', $filters['pusat_mengundi_id']))
|
||||
->when(! empty($filters['role_id']), function (Builder $nested) use ($filters): void {
|
||||
$nested->where(function (Builder $roleQuery) use ($filters): void {
|
||||
$roleQuery->where('approved_position_id', $filters['role_id'])
|
||||
->orWhere(function (Builder $fallback) use ($filters): void {
|
||||
$fallback->whereNull('approved_position_id')
|
||||
->where('requested_position_id', $filters['role_id']);
|
||||
});
|
||||
});
|
||||
})
|
||||
->when(! empty($filters['missing_account_number']), function (Builder $nested): void {
|
||||
$nested->where(function (Builder $accountQuery): void {
|
||||
$accountQuery->whereNull('bank_account_number')
|
||||
->orWhere('bank_account_number', '');
|
||||
});
|
||||
})
|
||||
->when(! empty($filters['missing_bank_statement']), function (Builder $nested): void {
|
||||
$nested->whereDoesntHave('documents', fn (Builder $documentQuery) => $documentQuery->where('document_type', 'bank_statement'));
|
||||
})
|
||||
->when(! empty($filters['q']), function (Builder $nested) use ($filters): void {
|
||||
$term = '%'.$filters['q'].'%';
|
||||
$nested->where(function (Builder $keywordQuery) use ($term): void {
|
||||
$keywordQuery->where('name', 'like', $term)
|
||||
->orWhere('ic_number', 'like', $term)
|
||||
->orWhere('email', 'like', $term);
|
||||
});
|
||||
});
|
||||
})
|
||||
->when(! empty($filters['status']), fn (Builder $query) => $query->where('status', $filters['status']));
|
||||
}
|
||||
}
|
||||
270
app/Services/Ktm/KtmApplicationService.php
Normal file
270
app/Services/Ktm/KtmApplicationService.php
Normal file
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Ktm;
|
||||
|
||||
use App\Mail\KtmCreatedApplicantMail;
|
||||
use App\Models\Application;
|
||||
use App\Models\Election;
|
||||
use App\Models\Position;
|
||||
use App\Models\PusatMengundi;
|
||||
use App\Models\StaffAssignment;
|
||||
use App\Models\User;
|
||||
use App\Services\Public\KtmVacancyService;
|
||||
use App\Services\RegistrationPeriodService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class KtmApplicationService
|
||||
{
|
||||
/**
|
||||
* @param array{name: string, ic_number: string, phone_number: string, email: string, address?: string|null} $data
|
||||
*/
|
||||
public function registerKp(StaffAssignment $ktmAssignment, User $actor, array $data): Application
|
||||
{
|
||||
return DB::transaction(function () use ($ktmAssignment, $actor, $data): Application {
|
||||
$ktmAssignment->loadMissing(['election.settings', 'pusatMengundi', 'user']);
|
||||
|
||||
if (! app(KtmScopeService::class)->ownsAssignment($actor, $ktmAssignment)) {
|
||||
throw ValidationException::withMessages([
|
||||
'assignment' => 'Anda tidak dibenarkan mendaftar KP untuk saluran ini.',
|
||||
]);
|
||||
}
|
||||
|
||||
$election = $this->assignmentElection($ktmAssignment);
|
||||
$pusatMengundi = $this->assignmentPusat($ktmAssignment);
|
||||
|
||||
if (! app(RegistrationPeriodService::class)->isOpen($election)) {
|
||||
throw ValidationException::withMessages([
|
||||
'registration' => app(RegistrationPeriodService::class)->closedMessage($election),
|
||||
]);
|
||||
}
|
||||
|
||||
$kpPosition = Position::query()->where('code', 'KP')->firstOrFail();
|
||||
|
||||
if (! app(KtmVacancyService::class)->hasVacancyForAssignment($ktmAssignment->id, $pusatMengundi, 'KP')) {
|
||||
throw ValidationException::withMessages([
|
||||
'ktm_assignment_id' => 'Kekosongan KP untuk saluran ini telah penuh.',
|
||||
]);
|
||||
}
|
||||
|
||||
// SoftDeletes scope excludes soft-deleted records. whereNotIn covers 'cancelled' records
|
||||
// (not soft-deleted) and 'deleted_by_ktm' as an explicit redundant safety layer.
|
||||
$existing = Application::query()
|
||||
->where('election_id', $ktmAssignment->election_id)
|
||||
->where('ic_number', $data['ic_number'])
|
||||
->whereNotIn('status', ['cancelled', 'deleted_by_ktm'])
|
||||
->first();
|
||||
|
||||
if ($existing instanceof Application) {
|
||||
throw ValidationException::withMessages([
|
||||
'ic_number' => 'No. kad pengenalan ini telah mempunyai rekod permohonan aktif untuk pilihanraya semasa.',
|
||||
]);
|
||||
}
|
||||
|
||||
$application = Application::query()->create([
|
||||
'election_id' => $ktmAssignment->election_id,
|
||||
'pusat_mengundi_id' => $ktmAssignment->pusat_mengundi_id,
|
||||
'selected_ktm_assignment_id' => $ktmAssignment->id,
|
||||
'public_uuid' => (string) Str::uuid(),
|
||||
'source' => 'created_by_ktm',
|
||||
'status' => 'submitted',
|
||||
'name' => $data['name'],
|
||||
'ic_number' => $data['ic_number'],
|
||||
'phone_number' => $data['phone_number'],
|
||||
'email' => $data['email'],
|
||||
'address' => $data['address'] ?? null,
|
||||
'requested_position_id' => $kpPosition->id,
|
||||
'created_by_user_id' => $actor->id,
|
||||
]);
|
||||
|
||||
$application->statusHistories()->create([
|
||||
'to_status' => 'submitted',
|
||||
'changed_by_user_id' => $actor->id,
|
||||
'note' => 'KP applicant created by KTM.',
|
||||
'metadata' => [
|
||||
'source' => 'created_by_ktm',
|
||||
'ktm_assignment_id' => $ktmAssignment->id,
|
||||
],
|
||||
]);
|
||||
|
||||
Mail::to($application->email)->queue(new KtmCreatedApplicantMail($application->load('pusatMengundi'), $ktmAssignment));
|
||||
|
||||
activity('ktm_flow')
|
||||
->performedOn($application)
|
||||
->causedBy($actor)
|
||||
->withProperties([
|
||||
'ktm_assignment_id' => $ktmAssignment->id,
|
||||
'position_code' => 'KP',
|
||||
])
|
||||
->log('kp_applicant_created_by_ktm');
|
||||
|
||||
return $application;
|
||||
});
|
||||
}
|
||||
|
||||
public function approveKp(Application $application, User $actor): StaffAssignment
|
||||
{
|
||||
return DB::transaction(function () use ($application, $actor): StaffAssignment {
|
||||
$application->loadMissing(['selectedKtmAssignment.pusatMengundi', 'selectedKtmAssignment.election.settings', 'requestedPosition']);
|
||||
$ktmAssignment = $application->selectedKtmAssignment;
|
||||
|
||||
if (! $ktmAssignment instanceof StaffAssignment || ! app(KtmScopeService::class)->ownsAssignment($actor, $ktmAssignment)) {
|
||||
throw ValidationException::withMessages([
|
||||
'application' => 'Anda tidak dibenarkan meluluskan permohonan ini.',
|
||||
]);
|
||||
}
|
||||
|
||||
$election = $this->assignmentElection($ktmAssignment);
|
||||
$pusatMengundi = $this->assignmentPusat($ktmAssignment);
|
||||
|
||||
if (! app(RegistrationPeriodService::class)->isOpen($election)) {
|
||||
throw ValidationException::withMessages([
|
||||
'registration' => app(RegistrationPeriodService::class)->closedMessage($election),
|
||||
]);
|
||||
}
|
||||
|
||||
$kpPosition = Position::query()->where('code', 'KP')->firstOrFail();
|
||||
|
||||
if ((int) $application->requested_position_id !== (int) $kpPosition->id) {
|
||||
throw ValidationException::withMessages([
|
||||
'requested_position_id' => 'KTM hanya boleh meluluskan permohonan KP.',
|
||||
]);
|
||||
}
|
||||
|
||||
if (! in_array($application->status, ['submitted', 'under_ppm_review', 'approved'], true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => 'Status permohonan tidak membenarkan kelulusan KTM.',
|
||||
]);
|
||||
}
|
||||
|
||||
if (! app(KtmVacancyService::class)->hasVacancyForAssignment($ktmAssignment->id, $pusatMengundi, 'KP', $application->id)) {
|
||||
throw ValidationException::withMessages([
|
||||
'ktm_assignment_id' => 'Kekosongan KP untuk saluran ini telah penuh.',
|
||||
]);
|
||||
}
|
||||
|
||||
$previousStatus = $application->status;
|
||||
|
||||
$application->forceFill([
|
||||
'status' => 'assigned',
|
||||
'approved_position_id' => $kpPosition->id,
|
||||
'approved_by_user_id' => $actor->id,
|
||||
'approved_at' => now(),
|
||||
])->save();
|
||||
|
||||
$assignment = StaffAssignment::query()->updateOrCreate(
|
||||
['application_id' => $application->id],
|
||||
[
|
||||
'election_id' => $application->election_id,
|
||||
'user_id' => $application->user_id,
|
||||
'position_id' => $kpPosition->id,
|
||||
'pusat_mengundi_id' => $application->pusat_mengundi_id,
|
||||
'saluran_mengundi_id' => $ktmAssignment->saluran_mengundi_id,
|
||||
'reports_to_assignment_id' => $ktmAssignment->id,
|
||||
'status' => 'active',
|
||||
'assigned_by_user_id' => $actor->id,
|
||||
'assigned_at' => now(),
|
||||
'source' => 'ktm_approval',
|
||||
],
|
||||
);
|
||||
|
||||
$application->statusHistories()->create([
|
||||
'from_status' => $previousStatus,
|
||||
'to_status' => 'assigned',
|
||||
'changed_by_user_id' => $actor->id,
|
||||
'note' => 'KP application approved by KTM.',
|
||||
'metadata' => [
|
||||
'ktm_assignment_id' => $ktmAssignment->id,
|
||||
'position_code' => 'KP',
|
||||
],
|
||||
]);
|
||||
|
||||
$assignment->histories()->create([
|
||||
'to_position_id' => $kpPosition->id,
|
||||
'to_pusat_mengundi_id' => $application->pusat_mengundi_id,
|
||||
'to_saluran_mengundi_id' => $ktmAssignment->saluran_mengundi_id,
|
||||
'to_reports_to_assignment_id' => $ktmAssignment->id,
|
||||
'changed_by_user_id' => $actor->id,
|
||||
'note' => 'KP assigned by KTM approval.',
|
||||
]);
|
||||
|
||||
activity('ktm_flow')
|
||||
->performedOn($application)
|
||||
->causedBy($actor)
|
||||
->withProperties([
|
||||
'assignment_id' => $assignment->id,
|
||||
'ktm_assignment_id' => $ktmAssignment->id,
|
||||
])
|
||||
->log('kp_application_approved_by_ktm');
|
||||
|
||||
return $assignment;
|
||||
});
|
||||
}
|
||||
|
||||
public function deleteCreatedApplication(Application $application, User $actor): Application
|
||||
{
|
||||
return DB::transaction(function () use ($application, $actor): Application {
|
||||
if (! app(KtmScopeService::class)->canAccessApplication($actor, $application)) {
|
||||
throw ValidationException::withMessages([
|
||||
'application' => 'Anda tidak dibenarkan memadam rekod ini.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($application->source !== 'created_by_ktm') {
|
||||
throw ValidationException::withMessages([
|
||||
'application' => 'Hanya rekod yang dicipta oleh KTM boleh dipadam oleh KTM.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($application->status === 'assigned') {
|
||||
throw ValidationException::withMessages([
|
||||
'application' => 'Rekod yang telah diassign tidak boleh dipadam oleh KTM.',
|
||||
]);
|
||||
}
|
||||
|
||||
$previousStatus = $application->status;
|
||||
|
||||
$application->forceFill([
|
||||
'status' => 'deleted_by_ktm',
|
||||
])->save();
|
||||
|
||||
$application->statusHistories()->create([
|
||||
'from_status' => $previousStatus,
|
||||
'to_status' => 'deleted_by_ktm',
|
||||
'changed_by_user_id' => $actor->id,
|
||||
'note' => 'KTM deleted pre-created applicant record.',
|
||||
]);
|
||||
|
||||
activity('ktm_flow')
|
||||
->performedOn($application)
|
||||
->causedBy($actor)
|
||||
->withProperties([
|
||||
'from_status' => $previousStatus,
|
||||
'to_status' => 'deleted_by_ktm',
|
||||
])
|
||||
->log('ktm_created_applicant_deleted');
|
||||
|
||||
$application->delete();
|
||||
|
||||
return $application;
|
||||
});
|
||||
}
|
||||
|
||||
private function assignmentElection(StaffAssignment $assignment): Election
|
||||
{
|
||||
/** @var Election $election */
|
||||
$election = $assignment->election()->with('settings')->firstOrFail();
|
||||
|
||||
return $election;
|
||||
}
|
||||
|
||||
private function assignmentPusat(StaffAssignment $assignment): PusatMengundi
|
||||
{
|
||||
/** @var PusatMengundi $pusatMengundi */
|
||||
$pusatMengundi = $assignment->pusatMengundi()->firstOrFail();
|
||||
|
||||
return $pusatMengundi;
|
||||
}
|
||||
}
|
||||
55
app/Services/Ktm/KtmScopeService.php
Normal file
55
app/Services/Ktm/KtmScopeService.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Ktm;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Position;
|
||||
use App\Models\StaffAssignment;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class KtmScopeService
|
||||
{
|
||||
/**
|
||||
* @return EloquentCollection<int, StaffAssignment>
|
||||
*/
|
||||
public function assignmentsFor(User $user): EloquentCollection
|
||||
{
|
||||
$ktmPosition = Position::query()->where('code', 'KTM')->first();
|
||||
|
||||
if ($ktmPosition === null) {
|
||||
return new EloquentCollection;
|
||||
}
|
||||
|
||||
return StaffAssignment::query()
|
||||
->with(['pusatMengundi', 'saluranMengundi', 'teamMembers.application', 'teamMembers.position'])
|
||||
->where('user_id', $user->id)
|
||||
->where('position_id', $ktmPosition->id)
|
||||
->where('status', 'active')
|
||||
->whereNotNull('saluran_mengundi_id')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, int>
|
||||
*/
|
||||
public function assignmentIdsFor(User $user): Collection
|
||||
{
|
||||
return $this->assignmentsFor($user)
|
||||
->pluck('id')
|
||||
->map(fn (mixed $id): int => (int) $id)
|
||||
->values();
|
||||
}
|
||||
|
||||
public function ownsAssignment(User $user, StaffAssignment $assignment): bool
|
||||
{
|
||||
return $this->assignmentIdsFor($user)->contains((int) $assignment->id);
|
||||
}
|
||||
|
||||
public function canAccessApplication(User $user, Application $application): bool
|
||||
{
|
||||
return $application->selected_ktm_assignment_id !== null
|
||||
&& $this->assignmentIdsFor($user)->contains((int) $application->selected_ktm_assignment_id);
|
||||
}
|
||||
}
|
||||
98
app/Services/Ppm/AssignmentVacancyService.php
Normal file
98
app/Services/Ppm/AssignmentVacancyService.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Ppm;
|
||||
|
||||
use App\Models\Position;
|
||||
use App\Models\PositionQuota;
|
||||
use App\Models\PusatMengundi;
|
||||
use App\Models\SaluranMengundi;
|
||||
use App\Models\StaffAssignment;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class AssignmentVacancyService
|
||||
{
|
||||
public function hasPusatVacancy(PusatMengundi $pusatMengundi, string $roleCode): bool
|
||||
{
|
||||
$position = Position::query()->where('code', $roleCode)->first();
|
||||
|
||||
if ($position === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$quota = PositionQuota::query()
|
||||
->where('election_id', $pusatMengundi->election_id)
|
||||
->where('pusat_mengundi_id', $pusatMengundi->id)
|
||||
->whereNull('saluran_mengundi_id')
|
||||
->where('position_id', $position->id)
|
||||
->value('quota');
|
||||
|
||||
if ($quota === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$used = StaffAssignment::query()
|
||||
->where('election_id', $pusatMengundi->election_id)
|
||||
->where('pusat_mengundi_id', $pusatMengundi->id)
|
||||
->whereNull('saluran_mengundi_id')
|
||||
->where('position_id', $position->id)
|
||||
->where('status', 'active')
|
||||
->count();
|
||||
|
||||
return $used < (int) $quota;
|
||||
}
|
||||
|
||||
public function hasSaluranVacancy(PusatMengundi $pusatMengundi, int $saluranMengundiId, string $roleCode): bool
|
||||
{
|
||||
$position = Position::query()->where('code', $roleCode)->first();
|
||||
|
||||
if ($position === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$saluran = SaluranMengundi::query()
|
||||
->where('id', $saluranMengundiId)
|
||||
->where('pusat_mengundi_id', $pusatMengundi->id)
|
||||
->first();
|
||||
|
||||
if (! $saluran instanceof SaluranMengundi) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$quota = PositionQuota::query()
|
||||
->where('election_id', $pusatMengundi->election_id)
|
||||
->where('pusat_mengundi_id', $pusatMengundi->id)
|
||||
->where('saluran_mengundi_id', $saluran->id)
|
||||
->where('position_id', $position->id)
|
||||
->value('quota');
|
||||
|
||||
if ($quota === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$used = StaffAssignment::query()
|
||||
->where('election_id', $pusatMengundi->election_id)
|
||||
->where('pusat_mengundi_id', $pusatMengundi->id)
|
||||
->where('saluran_mengundi_id', $saluran->id)
|
||||
->where('position_id', $position->id)
|
||||
->where('status', 'active')
|
||||
->count();
|
||||
|
||||
return $used < (int) $quota;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, SaluranMengundi>
|
||||
*/
|
||||
public function availableSaluransForRole(PusatMengundi $pusatMengundi, string $roleCode): Collection
|
||||
{
|
||||
/** @var Collection<int, SaluranMengundi> $salurans */
|
||||
$salurans = $pusatMengundi->saluranMengundis()
|
||||
->orderBy('number')
|
||||
->get()
|
||||
->filter(fn (mixed $saluran): bool => $saluran instanceof SaluranMengundi
|
||||
&& $this->hasSaluranVacancy($pusatMengundi, $saluran->id, $roleCode))
|
||||
->values();
|
||||
|
||||
return $salurans;
|
||||
}
|
||||
}
|
||||
228
app/Services/Ppm/PpmApplicationReviewService.php
Normal file
228
app/Services/Ppm/PpmApplicationReviewService.php
Normal file
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Ppm;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Position;
|
||||
use App\Models\PusatMengundi;
|
||||
use App\Models\StaffAssignment;
|
||||
use App\Models\User;
|
||||
use App\Services\Public\KtmVacancyService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class PpmApplicationReviewService
|
||||
{
|
||||
/**
|
||||
* @param array{approved_position_code: string, saluran_mengundi_id?: int|null, ktm_assignment_id?: int|null, note?: string|null} $data
|
||||
*/
|
||||
public function approve(Application $application, User $actor, array $data): StaffAssignment
|
||||
{
|
||||
return DB::transaction(function () use ($application, $actor, $data): StaffAssignment {
|
||||
$application->loadMissing(['documents', 'pusatMengundi', 'requestedPosition']);
|
||||
|
||||
if (! $this->hasRequiredDocuments($application)) {
|
||||
throw ValidationException::withMessages([
|
||||
'documents' => 'Permohonan hanya boleh diluluskan jika dokumen IC dan penyata bank telah dimuat naik.',
|
||||
]);
|
||||
}
|
||||
|
||||
if (! in_array($application->status, ['submitted', 'under_ppm_review', 'approved'], true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => 'Status permohonan tidak membenarkan kelulusan.',
|
||||
]);
|
||||
}
|
||||
|
||||
$position = Position::query()
|
||||
->where('code', $data['approved_position_code'])
|
||||
->whereIn('code', ['KTM', 'KP', 'KPDP', 'PAPM'])
|
||||
->firstOrFail();
|
||||
|
||||
$assignmentContext = $this->assignmentContext($application, $position, $data);
|
||||
$previousStatus = $application->status;
|
||||
$previousPositionId = $application->approved_position_id ?: $application->requested_position_id;
|
||||
|
||||
$application->forceFill([
|
||||
'status' => 'assigned',
|
||||
'approved_position_id' => $position->id,
|
||||
'approved_by_user_id' => $actor->id,
|
||||
'approved_at' => now(),
|
||||
'selected_ktm_assignment_id' => $assignmentContext['reports_to_assignment_id'],
|
||||
])->save();
|
||||
|
||||
$assignment = StaffAssignment::query()->updateOrCreate(
|
||||
['application_id' => $application->id],
|
||||
[
|
||||
'election_id' => $application->election_id,
|
||||
'user_id' => $application->user_id,
|
||||
'position_id' => $position->id,
|
||||
'pusat_mengundi_id' => $application->pusat_mengundi_id,
|
||||
'saluran_mengundi_id' => $assignmentContext['saluran_mengundi_id'],
|
||||
'reports_to_assignment_id' => $assignmentContext['reports_to_assignment_id'],
|
||||
'status' => 'active',
|
||||
'assigned_by_user_id' => $actor->id,
|
||||
'assigned_at' => now(),
|
||||
'source' => 'ppm_approval',
|
||||
],
|
||||
);
|
||||
|
||||
$application->statusHistories()->create([
|
||||
'from_status' => $previousStatus,
|
||||
'to_status' => 'assigned',
|
||||
'changed_by_user_id' => $actor->id,
|
||||
'note' => $data['note'] ?? null,
|
||||
'metadata' => [
|
||||
'approved_position_code' => $position->code,
|
||||
'previous_position_id' => $previousPositionId,
|
||||
'saluran_mengundi_id' => $assignmentContext['saluran_mengundi_id'],
|
||||
'reports_to_assignment_id' => $assignmentContext['reports_to_assignment_id'],
|
||||
],
|
||||
]);
|
||||
|
||||
$assignment->histories()->create([
|
||||
'from_position_id' => $previousPositionId,
|
||||
'to_position_id' => $position->id,
|
||||
'to_pusat_mengundi_id' => $application->pusat_mengundi_id,
|
||||
'to_saluran_mengundi_id' => $assignmentContext['saluran_mengundi_id'],
|
||||
'to_reports_to_assignment_id' => $assignmentContext['reports_to_assignment_id'],
|
||||
'changed_by_user_id' => $actor->id,
|
||||
'note' => $data['note'] ?? 'Application approved by PPM.',
|
||||
]);
|
||||
|
||||
if ($previousPositionId !== $position->id) {
|
||||
activity('ppm_review')
|
||||
->performedOn($application)
|
||||
->causedBy($actor)
|
||||
->withProperties([
|
||||
'from_position_id' => $previousPositionId,
|
||||
'to_position_id' => $position->id,
|
||||
])
|
||||
->log('application_role_changed_by_ppm');
|
||||
}
|
||||
|
||||
activity('ppm_review')
|
||||
->performedOn($application)
|
||||
->causedBy($actor)
|
||||
->withProperties([
|
||||
'assignment_id' => $assignment->id,
|
||||
'approved_position_code' => $position->code,
|
||||
'saluran_mengundi_id' => $assignmentContext['saluran_mengundi_id'],
|
||||
'reports_to_assignment_id' => $assignmentContext['reports_to_assignment_id'],
|
||||
])
|
||||
->log('application_approved_assigned_by_ppm');
|
||||
|
||||
return $assignment;
|
||||
});
|
||||
}
|
||||
|
||||
public function reject(Application $application, User $actor, string $reason): Application
|
||||
{
|
||||
return DB::transaction(function () use ($application, $actor, $reason): Application {
|
||||
if (! in_array($application->status, ['submitted', 'under_ppm_review', 'approved'], true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => 'Status permohonan tidak membenarkan penolakan.',
|
||||
]);
|
||||
}
|
||||
|
||||
$previousStatus = $application->status;
|
||||
|
||||
$application->forceFill([
|
||||
'status' => 'rejected',
|
||||
'rejected_by_user_id' => $actor->id,
|
||||
'rejected_at' => now(),
|
||||
'rejection_reason' => $reason,
|
||||
])->save();
|
||||
|
||||
$application->statusHistories()->create([
|
||||
'from_status' => $previousStatus,
|
||||
'to_status' => 'rejected',
|
||||
'changed_by_user_id' => $actor->id,
|
||||
'note' => $reason,
|
||||
]);
|
||||
|
||||
activity('ppm_review')
|
||||
->performedOn($application)
|
||||
->causedBy($actor)
|
||||
->withProperties([
|
||||
'reason' => $reason,
|
||||
])
|
||||
->log('application_rejected_by_ppm');
|
||||
|
||||
return $application;
|
||||
});
|
||||
}
|
||||
|
||||
private function hasRequiredDocuments(Application $application): bool
|
||||
{
|
||||
$types = $application->documents()
|
||||
->whereIn('document_type', ['ic_document', 'bank_statement'])
|
||||
->pluck('document_type')
|
||||
->unique();
|
||||
|
||||
return $types->contains('ic_document') && $types->contains('bank_statement');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{approved_position_code: string, saluran_mengundi_id?: int|null, ktm_assignment_id?: int|null, note?: string|null} $data
|
||||
* @return array{saluran_mengundi_id: int|null, reports_to_assignment_id: int|null}
|
||||
*/
|
||||
private function assignmentContext(Application $application, Position $position, array $data): array
|
||||
{
|
||||
$pusatMengundi = $application->pusatMengundi;
|
||||
|
||||
if (! $pusatMengundi instanceof PusatMengundi) {
|
||||
throw ValidationException::withMessages([
|
||||
'pusat_mengundi_id' => 'Pusat Mengundi permohonan tidak sah.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($position->code === 'PAPM') {
|
||||
if (! app(AssignmentVacancyService::class)->hasPusatVacancy($pusatMengundi, 'PAPM')) {
|
||||
throw ValidationException::withMessages([
|
||||
'approved_position_code' => 'Kekosongan PAPM untuk Pusat Mengundi ini telah penuh.',
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'saluran_mengundi_id' => null,
|
||||
'reports_to_assignment_id' => null,
|
||||
];
|
||||
}
|
||||
|
||||
if ($position->code === 'KTM') {
|
||||
$saluranId = (int) ($data['saluran_mengundi_id'] ?? 0);
|
||||
|
||||
if (! app(AssignmentVacancyService::class)->hasSaluranVacancy($pusatMengundi, $saluranId, 'KTM')) {
|
||||
throw ValidationException::withMessages([
|
||||
'saluran_mengundi_id' => 'Saluran Mengundi yang dipilih tidak sah atau kekosongan KTM telah penuh.',
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'saluran_mengundi_id' => $saluranId,
|
||||
'reports_to_assignment_id' => null,
|
||||
];
|
||||
}
|
||||
|
||||
if (in_array($position->code, ['KP', 'KPDP'], true)) {
|
||||
$ktmAssignmentId = (int) ($data['ktm_assignment_id'] ?? 0);
|
||||
|
||||
if (! app(KtmVacancyService::class)->hasVacancyForAssignment($ktmAssignmentId, $pusatMengundi, $position->code, $application->id)) {
|
||||
throw ValidationException::withMessages([
|
||||
'ktm_assignment_id' => 'KTM yang dipilih tidak sah atau kekosongan jawatan ini telah penuh.',
|
||||
]);
|
||||
}
|
||||
|
||||
$ktmAssignment = StaffAssignment::query()->findOrFail($ktmAssignmentId);
|
||||
|
||||
return [
|
||||
'saluran_mengundi_id' => $ktmAssignment->saluran_mengundi_id,
|
||||
'reports_to_assignment_id' => $ktmAssignment->id,
|
||||
];
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'approved_position_code' => 'Jawatan yang dipilih tidak sah untuk kelulusan PPM.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
46
app/Services/Ppm/PpmScopeService.php
Normal file
46
app/Services/Ppm/PpmScopeService.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Ppm;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Position;
|
||||
use App\Models\StaffAssignment;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class PpmScopeService
|
||||
{
|
||||
/**
|
||||
* @return Collection<int, int>
|
||||
*/
|
||||
public function assignedPusatIds(User $user): Collection
|
||||
{
|
||||
$ppmPosition = Position::query()->where('code', 'PPM')->first();
|
||||
|
||||
if ($ppmPosition === null) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return StaffAssignment::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('position_id', $ppmPosition->id)
|
||||
->where('status', 'active')
|
||||
->whereNull('saluran_mengundi_id')
|
||||
->pluck('pusat_mengundi_id')
|
||||
->map(fn (mixed $id): int => (int) $id)
|
||||
->values();
|
||||
}
|
||||
|
||||
public function canAccessApplication(User $user, Application $application): bool
|
||||
{
|
||||
if ($user->hasRole('Admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! $user->hasRole('PPM')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->assignedPusatIds($user)->contains((int) $application->pusat_mengundi_id);
|
||||
}
|
||||
}
|
||||
152
app/Services/Public/KtmVacancyService.php
Normal file
152
app/Services/Public/KtmVacancyService.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Public;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Position;
|
||||
use App\Models\PositionQuota;
|
||||
use App\Models\PusatMengundi;
|
||||
use App\Models\SaluranMengundi;
|
||||
use App\Models\StaffAssignment;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class KtmVacancyService
|
||||
{
|
||||
private const RESERVED_APPLICATION_STATUSES = [
|
||||
'submitted',
|
||||
'under_ppm_review',
|
||||
'approved',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param array<int, string> $roleCodes
|
||||
* @return array<string, array<int, array{id: int, label: string, remaining: int}>>
|
||||
*/
|
||||
public function optionsForRoles(PusatMengundi $pusatMengundi, array $roleCodes = ['KP', 'KPDP'], ?int $ignoreApplicationId = null): array
|
||||
{
|
||||
$options = [];
|
||||
|
||||
foreach ($roleCodes as $roleCode) {
|
||||
$options[$roleCode] = $this->availableKtmAssignments($pusatMengundi, $roleCode, $ignoreApplicationId)
|
||||
->map(function (StaffAssignment $assignment) use ($roleCode, $ignoreApplicationId): array {
|
||||
$user = $assignment->user;
|
||||
$saluranMengundi = $assignment->saluranMengundi;
|
||||
|
||||
return [
|
||||
'id' => $assignment->id,
|
||||
'label' => trim(
|
||||
($user instanceof User ? $user->name : 'KTM tanpa nama')
|
||||
.' - Saluran '
|
||||
.($saluranMengundi instanceof SaluranMengundi ? $saluranMengundi->number : '-'),
|
||||
),
|
||||
'remaining' => $this->remainingVacancy($assignment, $roleCode, $ignoreApplicationId),
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
public function hasVacancyForAssignment(int $assignmentId, PusatMengundi $pusatMengundi, string $roleCode, ?int $ignoreApplicationId = null): bool
|
||||
{
|
||||
$ktmPosition = Position::query()->where('code', 'KTM')->first();
|
||||
|
||||
if ($ktmPosition === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$assignment = StaffAssignment::query()
|
||||
->with(['saluranMengundi', 'user'])
|
||||
->where('id', $assignmentId)
|
||||
->where('election_id', $pusatMengundi->election_id)
|
||||
->where('pusat_mengundi_id', $pusatMengundi->id)
|
||||
->where('position_id', $ktmPosition->id)
|
||||
->where('status', 'active')
|
||||
->whereNotNull('saluran_mengundi_id')
|
||||
->first();
|
||||
|
||||
return $assignment instanceof StaffAssignment
|
||||
&& $this->remainingVacancy($assignment, $roleCode, $ignoreApplicationId) > 0;
|
||||
}
|
||||
|
||||
public function remainingForAssignment(StaffAssignment $assignment, string $roleCode, ?int $ignoreApplicationId = null): int
|
||||
{
|
||||
return $this->remainingVacancy($assignment, $roleCode, $ignoreApplicationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, StaffAssignment>
|
||||
*/
|
||||
private function availableKtmAssignments(PusatMengundi $pusatMengundi, string $roleCode, ?int $ignoreApplicationId = null): Collection
|
||||
{
|
||||
return $this->ktmAssignments($pusatMengundi)
|
||||
->filter(fn (StaffAssignment $assignment): bool => $this->remainingVacancy($assignment, $roleCode, $ignoreApplicationId) > 0)
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return EloquentCollection<int, StaffAssignment>
|
||||
*/
|
||||
private function ktmAssignments(PusatMengundi $pusatMengundi): EloquentCollection
|
||||
{
|
||||
$ktmPosition = Position::query()->where('code', 'KTM')->first();
|
||||
|
||||
if ($ktmPosition === null) {
|
||||
return new EloquentCollection;
|
||||
}
|
||||
|
||||
return StaffAssignment::query()
|
||||
->with(['saluranMengundi', 'user'])
|
||||
->where('election_id', $pusatMengundi->election_id)
|
||||
->where('pusat_mengundi_id', $pusatMengundi->id)
|
||||
->where('position_id', $ktmPosition->id)
|
||||
->where('status', 'active')
|
||||
->whereNotNull('saluran_mengundi_id')
|
||||
->whereNotNull('user_id')
|
||||
->orderBy('saluran_mengundi_id')
|
||||
->get();
|
||||
}
|
||||
|
||||
private function remainingVacancy(StaffAssignment $ktmAssignment, string $roleCode, ?int $ignoreApplicationId = null): int
|
||||
{
|
||||
$position = Position::query()->where('code', $roleCode)->first();
|
||||
|
||||
if ($position === null || $ktmAssignment->saluran_mengundi_id === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$quota = PositionQuota::query()
|
||||
->where('election_id', $ktmAssignment->election_id)
|
||||
->where('pusat_mengundi_id', $ktmAssignment->pusat_mengundi_id)
|
||||
->where('saluran_mengundi_id', $ktmAssignment->saluran_mengundi_id)
|
||||
->where('position_id', $position->id)
|
||||
->value('quota');
|
||||
|
||||
if ($quota === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$assignedCount = StaffAssignment::query()
|
||||
->where('reports_to_assignment_id', $ktmAssignment->id)
|
||||
->where('position_id', $position->id)
|
||||
->where('status', 'active')
|
||||
->count();
|
||||
|
||||
$reservedQuery = Application::query()
|
||||
->where('selected_ktm_assignment_id', $ktmAssignment->id)
|
||||
->where('requested_position_id', $position->id)
|
||||
->whereIn('status', self::RESERVED_APPLICATION_STATUSES);
|
||||
|
||||
if ($ignoreApplicationId !== null) {
|
||||
$reservedQuery->where('id', '!=', $ignoreApplicationId);
|
||||
}
|
||||
|
||||
$reservedCount = $reservedQuery->count();
|
||||
|
||||
return max(0, (int) $quota - $assignedCount - $reservedCount);
|
||||
}
|
||||
}
|
||||
106
app/Services/Public/PublicApplicationSubmissionService.php
Normal file
106
app/Services/Public/PublicApplicationSubmissionService.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Public;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Position;
|
||||
use App\Models\PusatMengundi;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
class PublicApplicationSubmissionService
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function submit(PusatMengundi $pusatMengundi, array $data): Application
|
||||
{
|
||||
return DB::transaction(function () use ($pusatMengundi, $data): Application {
|
||||
$position = Position::query()
|
||||
->where('code', $data['requested_position_code'])
|
||||
->where('is_public_applyable', true)
|
||||
->firstOrFail();
|
||||
|
||||
$application = Application::query()->create([
|
||||
'election_id' => $pusatMengundi->election_id,
|
||||
'pusat_mengundi_id' => $pusatMengundi->id,
|
||||
'selected_ktm_assignment_id' => in_array($position->code, ['KP', 'KPDP'], true)
|
||||
? $data['selected_ktm_assignment_id']
|
||||
: null,
|
||||
'public_uuid' => (string) Str::uuid(),
|
||||
'source' => 'public',
|
||||
'status' => 'submitted',
|
||||
'name' => $data['name'],
|
||||
'ic_number' => $data['ic_number'],
|
||||
'phone_number' => $data['phone_number'],
|
||||
'email' => $data['email'] ?? null,
|
||||
'address' => $data['address'],
|
||||
'requested_position_id' => $position->id,
|
||||
'bank_name' => $data['bank_name'],
|
||||
'bank_account_number' => $data['bank_account_number'],
|
||||
]);
|
||||
|
||||
$this->storeDocument($application, 'ic_document', $data['ic_document']);
|
||||
$this->storeDocument($application, 'bank_statement', $data['bank_statement']);
|
||||
|
||||
$application->statusHistories()->create([
|
||||
'to_status' => 'submitted',
|
||||
'note' => 'Public QR application submitted.',
|
||||
'metadata' => [
|
||||
'source' => 'public',
|
||||
'requested_position_code' => $position->code,
|
||||
],
|
||||
]);
|
||||
|
||||
$application->bankVerification()->create([
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
activity('public_application')
|
||||
->performedOn($application)
|
||||
->withProperties([
|
||||
'pusat_mengundi_id' => $pusatMengundi->id,
|
||||
'requested_position_code' => $position->code,
|
||||
'document_types' => ['ic_document', 'bank_statement'],
|
||||
])
|
||||
->log('application_submitted');
|
||||
|
||||
return $application;
|
||||
});
|
||||
}
|
||||
|
||||
private function storeDocument(Application $application, string $documentType, mixed $file): void
|
||||
{
|
||||
if (! $file instanceof UploadedFile) {
|
||||
throw new RuntimeException('Invalid uploaded file.');
|
||||
}
|
||||
|
||||
$path = $file->storeAs(
|
||||
'applications/'.$application->public_uuid,
|
||||
$documentType.'-'.$file->hashName(),
|
||||
'local',
|
||||
);
|
||||
|
||||
if (! is_string($path)) {
|
||||
throw new RuntimeException('Document upload failed.');
|
||||
}
|
||||
|
||||
$application->documents()->create([
|
||||
'document_type' => $documentType,
|
||||
'disk' => 'local',
|
||||
'path' => $path,
|
||||
'original_name' => $file->getClientOriginalName(),
|
||||
'mime_type' => $file->getClientMimeType() ?: $file->getMimeType(),
|
||||
'size' => $file->getSize(),
|
||||
]);
|
||||
|
||||
activity('public_application')
|
||||
->performedOn($application)
|
||||
->withProperties([
|
||||
'document_type' => $documentType,
|
||||
])
|
||||
->log('application_document_uploaded');
|
||||
}
|
||||
}
|
||||
53
app/Services/RegistrationPeriodService.php
Normal file
53
app/Services/RegistrationPeriodService.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Election;
|
||||
use App\Models\ElectionSetting;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class RegistrationPeriodService
|
||||
{
|
||||
public function isOpen(Election $election, ?Carbon $date = null): bool
|
||||
{
|
||||
/** @var ElectionSetting|null $setting */
|
||||
$setting = $election->settings;
|
||||
|
||||
if ($setting === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($setting->is_registration_open_override !== null) {
|
||||
return $setting->is_registration_open_override;
|
||||
}
|
||||
|
||||
if (! $setting->is_registration_open) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$currentDate = ($date ?? now())->toDateString();
|
||||
|
||||
return Carbon::parse($setting->registration_start_date)->toDateString() <= $currentDate
|
||||
&& Carbon::parse($setting->registration_end_date)->toDateString() >= $currentDate;
|
||||
}
|
||||
|
||||
public function closedMessage(Election $election): string
|
||||
{
|
||||
/** @var ElectionSetting|null $setting */
|
||||
$setting = $election->settings;
|
||||
|
||||
if ($setting === null) {
|
||||
return 'Pendaftaran belum dibuka untuk pilihanraya ini.';
|
||||
}
|
||||
|
||||
if ($setting->is_registration_open_override === false || ! $setting->is_registration_open) {
|
||||
return 'Pendaftaran permohonan petugas telah ditutup.';
|
||||
}
|
||||
|
||||
return 'Pendaftaran dibuka dari '
|
||||
.Carbon::parse($setting->registration_start_date)->format('d/m/Y')
|
||||
.' hingga '
|
||||
.Carbon::parse($setting->registration_end_date)->format('d/m/Y')
|
||||
.'.';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user