This commit is contained in:
Saufi
2026-06-03 08:51:22 +08:00
commit a14d43fe34
347 changed files with 38197 additions and 0 deletions

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