Files
prn2026/app/Services/Ktm/KtmApplicationService.php
2026-06-03 08:51:22 +08:00

271 lines
11 KiB
PHP

<?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;
}
}