, * heirs: list>, * references?: array{proposer_ic_number?: string|null, supporter_ic_number?: string|null} * } $data * @param array $documents */ public function submit(array $data, array $documents): MembershipApplication { $application = DB::transaction(function () use ($data, $documents) { // membership_applications table $application = $this->repository->create([ 'application_number' => $this->generateApplicationNumber(), 'status' => ApplicationStatus::Submitted, 'board_result' => '', 'submitted_at' => now()->toDateTimeString(), 'completed_at' => '', ]); // membership_application_applicants table $application->applicant()->create($data['applicant']); // membership_application_heirs table foreach ($data['heirs'] as $heir) { $application->heirs()->create($heir); } // membership_application_references table $this->syncReferences( $application, $data['references'] ?? [], (string) ($data['applicant']['ic_number'] ?? ''), ); // documents table foreach ($documents as $type => $file) { $this->documentService->validateFile($file); $this->documentService->uploadDocument($application, $file, $type); } return $this->repository->findByIdWithRelations($application->id); }); $this->sendSubmissionConfirmation($application); return $application; } public function getPaginatedList(int $perPage, string $search, ?string $status, string $sortBy, string $sortOrder): LengthAwarePaginator { return $this->repository->getAllPaginated($perPage, $search, $status, $sortBy, $sortOrder); } public function getByIdWithRelations(string $id): ?MembershipApplication { return $this->repository->findByIdWithRelations($id); } public function update(MembershipApplication $application, array $data): MembershipApplication { if ($application->status === ApplicationStatus::Completed) { throw ValidationException::withMessages([ 'status' => ['Permohonan yang telah selesai tidak boleh dikemaskini.'], ]); } return DB::transaction(function () use ($application, $data) { $application->loadMissing('applicant'); if (isset($data['applicant'])) { $application->applicant->update($data['applicant']); } if (isset($data['heirs'])) { $application->heirs()->delete(); foreach ($data['heirs'] as $heir) { $application->heirs()->create($heir); } } if (array_key_exists('references', $data)) { $this->syncReferences( $application, $data['references'] ?? [], (string) ($data['applicant']['ic_number'] ?? $application->applicant->ic_number), ); } return $this->repository->findByIdWithRelations($application->id); }); } public function uploadDocument(MembershipApplication $application, string $type, UploadedFile $file): MembershipApplication { if ($application->status === ApplicationStatus::Completed) { throw ValidationException::withMessages([ 'status' => ['Permohonan yang telah selesai tidak boleh dikemaskini.'], ]); } $this->documentService->validateFile($file); if ($type !== MembershipApplication::ADMIN_ATTACHMENT_DOCUMENT_TYPE) { $application->documentsOfType($type)->get()->each( fn ($document) => $this->documentService->deleteDocument($document->id) ); } $this->documentService->uploadDocument($application, $file, $type); return $this->repository->findByIdWithRelations($application->id); } public function deleteDocument(MembershipApplication $application, string $documentId): MembershipApplication { if ($application->status === ApplicationStatus::Completed) { throw ValidationException::withMessages([ 'status' => ['Permohonan yang telah selesai tidak boleh dikemaskini.'], ]); } $document = $application->documents()->findOrFail($documentId); if ($document->type !== MembershipApplication::ADMIN_ATTACHMENT_DOCUMENT_TYPE) { throw ValidationException::withMessages([ 'document' => ['Hanya lampiran pentadbir boleh dipadam.'], ]); } $this->documentService->deleteDocument($document->id); return $this->repository->findByIdWithRelations($application->id); } public function managementReview(MembershipApplication $application, ManagementDecision $decision, ?string $remarks, User $reviewer): MembershipApplication { if ($application->status !== ApplicationStatus::Submitted) { throw ValidationException::withMessages([ 'status' => ['Permohonan ini tidak boleh disemak pada peringkat pentadbiran.'], ]); } return DB::transaction(function () use ($application, $decision, $remarks, $reviewer) { $application->reviews()->create([ 'stage' => ReviewStage::Management, 'decision' => $decision->value, 'remarks' => $remarks, 'reviewer_id' => $reviewer->id, 'reviewed_at' => now(), ]); $application->update([ 'status' => $decision === ManagementDecision::Approved ? ApplicationStatus::PendingBoard : ApplicationStatus::ManagementRejected, ]); return $this->repository->findByIdWithRelations($application->id); }); } public function boardReview(MembershipApplication $application, BoardDecision $decision, ?string $remarks, User $reviewer): MembershipApplication { if ($application->status !== ApplicationStatus::PendingBoard) { throw ValidationException::withMessages([ 'status' => ['Permohonan ini tidak boleh disemak pada peringkat ahli lembaga.'], ]); } return DB::transaction(function () use ($application, $decision, $remarks, $reviewer) { $application->reviews()->create([ 'stage' => ReviewStage::Board, 'decision' => $decision->value, 'remarks' => $remarks, 'reviewer_id' => $reviewer->id, 'reviewed_at' => now(), ]); $application->update([ 'board_result' => $decision === BoardDecision::Pass ? BoardResult::Pass->value : BoardResult::Fail->value, 'status' => ApplicationStatus::PendingNotification, ]); return $this->repository->findByIdWithRelations($application->id); }); } /** * @param array{proposer_id?: string|null, supporter_id?: string|null} $references */ public function assignReferences(MembershipApplication $application, array $references, User $assignedBy): MembershipApplication { return DB::transaction(function () use ($application, $references, $assignedBy) { $referenceMap = [ 'proposer_id' => ReferenceType::Proposer, 'supporter_id' => ReferenceType::Supporter, ]; foreach ($referenceMap as $key => $type) { if (! array_key_exists($key, $references)) { continue; } $application->references()->updateOrCreate( ['reference_type' => $type->value], [ 'user_id' => $references[$key], 'assigned_by' => $references[$key] ? $assignedBy->id : null, 'assigned_at' => $references[$key] ? now() : null, ] ); } return $this->repository->findByIdWithRelations($application->id); }); } public function complete(MembershipApplication $application, User $processedBy, string $boardMeetingReference, string $boardMeetingDate,): MembershipApplication { if ($application->status !== ApplicationStatus::PendingNotification) { throw ValidationException::withMessages([ 'status' => ['Permohonan ini tidak boleh diselesaikan pada masa ini.'], ]); } if (! in_array($application->board_result, [BoardResult::Pass->value, BoardResult::Fail->value], true)) { throw ValidationException::withMessages([ 'board_result' => ['Keputusan lembaga belum ditetapkan.'], ]); } return DB::transaction(function () use ($application, $processedBy, $boardMeetingReference, $boardMeetingDate) { $plainPassword = null; if ($application->board_result === BoardResult::Pass->value && ! $application->user_id) { $member = $this->createMemberFromApplication($application); $application->update(['user_id' => $member['user']->id]); $plainPassword = $member['plainPassword']; $application->refresh(); } $resultLetter = $this->storeResultLetter($application, $boardMeetingReference, $boardMeetingDate); $this->sendResultNotification($application, $resultLetter, $plainPassword); $application->reviews()->create([ 'stage' => ReviewStage::Completion, 'decision' => $application->board_result, 'remarks' => sprintf( 'Rujukan mesyuarat lembaga: %s, Tarikh mesyuarat: %s', $boardMeetingReference, $boardMeetingDate, ), 'reviewer_id' => $processedBy->id, 'reviewed_at' => now(), ]); $application->update([ 'status' => ApplicationStatus::Completed, 'completed_at' => now()->toDateTimeString(), ]); return $this->repository->findByIdWithRelations($application->id); }); } /** * @param list $applicationIds * @return array{ * succeeded: list, * failed: list * } */ public function completeBatch(array $applicationIds, User $processedBy, string $boardMeetingReference, string $boardMeetingDate): array { $succeeded = []; $failed = []; foreach ($applicationIds as $id) { $application = $this->repository->findById($id); if (! $application) { $failed[] = [ 'id' => $id, 'application_number' => null, 'message' => 'Permohonan tidak dijumpai.', ]; continue; } try { $succeeded[] = $this->complete( $application, $processedBy, $boardMeetingReference, $boardMeetingDate, ); } catch (ValidationException $e) { $failed[] = [ 'id' => $id, 'application_number' => $application->application_number, 'message' => collect($e->errors())->flatten()->first() ?? 'Permohonan tidak boleh diselesaikan.', ]; } } return [ 'succeeded' => $succeeded, 'failed' => $failed, ]; } public function generateResultLetter( MembershipApplication $application, string $boardMeetingReference, string $boardMeetingDate, ): MembershipApplication { if ($application->status !== ApplicationStatus::Completed) { throw ValidationException::withMessages([ 'status' => ['Surat keputusan hanya boleh dijana untuk permohonan yang telah selesai.'], ]); } if (! in_array($application->board_result, [BoardResult::Pass->value, BoardResult::Fail->value], true)) { throw ValidationException::withMessages([ 'board_result' => ['Keputusan lembaga belum ditetapkan.'], ]); } return DB::transaction(function () use ($application, $boardMeetingReference, $boardMeetingDate) { $this->storeResultLetter($application, $boardMeetingReference, $boardMeetingDate); return $this->repository->findByIdWithRelations($application->id); }); } protected function storeResultLetter( MembershipApplication $application, string $boardMeetingReference, string $boardMeetingDate, ): Document { $lockedApplication = MembershipApplication::query() ->whereKey($application->id) ->lockForUpdate() ->firstOrFail(); if ($lockedApplication->hasDocumentsOfType(MembershipApplication::RESULT_LETTER_DOCUMENT_TYPE)) { throw ValidationException::withMessages([ 'result_letter' => ['Surat keputusan telah dijana dan tidak boleh dijana semula.'], ]); } $application = $this->repository->findByIdWithRelations($lockedApplication->id); if (! $application || ! $application->applicant) { throw ValidationException::withMessages([ 'application' => ['Maklumat pemohon tidak lengkap.'], ]); } $viewData = $this->buildResultLetterViewData($application, $boardMeetingReference, $boardMeetingDate); $pdf = $this->letterService->renderPdf('membershipapplication::pdf.approved-letter', $viewData); $fileName = 'surat-keputusan-'.$application->application_number.'.pdf'; return $this->documentService->storeGeneratedDocument( $application, $pdf, $fileName, MembershipApplication::RESULT_LETTER_DOCUMENT_TYPE, ); } /** * @return array */ public function getResultLetterViewData( MembershipApplication $application, string $boardMeetingReference, string $boardMeetingDate, ): array { $application = $this->repository->findByIdWithRelations($application->id); if (! $application || ! $application->applicant) { throw ValidationException::withMessages([ 'application' => ['Maklumat pemohon tidak lengkap.'], ]); } return $this->buildResultLetterViewData($application, $boardMeetingReference, $boardMeetingDate); } /** * @return array */ protected function buildResultLetterViewData( MembershipApplication $application, string $boardMeetingReference, string $boardMeetingDate, ): array { $applicant = $application->applicant; $isPassed = $application->board_result === BoardResult::Pass->value; return [ 'application' => $application, 'applicant' => $applicant, 'boardMeetingReference' => $boardMeetingReference, 'boardMeetingDate' => Carbon::parse($boardMeetingDate)->translatedFormat('d F Y'), 'isPassed' => $isPassed, 'letterSubject' => $isPassed ? 'KELULUSAN PERMOHONAN MENJADI ANGGOTA KOPERASI PERMODALAN KELANTAN BERHAD (KoPKB)' : 'KEPUTUSAN PERMOHONAN MENJADI ANGGOTA KOPERASI PERMODALAN KELANTAN BERHAD (KoPKB)', 'memberNumber' => $isPassed ? $application->user?->member_number : null, ]; } /** * @return array{user: User, plainPassword: string} */ protected function createMemberFromApplication(MembershipApplication $application): array { $application->loadMissing(['applicant', 'heirs']); $applicant = $application->applicant; if (User::where('email', $applicant->email)->exists()) { throw ValidationException::withMessages([ 'email' => ['E-mel pemohon sudah didaftarkan dalam sistem.'], ]); } $plainPassword = Str::password(16); $user = User::create([ 'name' => $applicant->name, 'email' => $applicant->email, 'password' => Hash::make($plainPassword), 'ic_number' => $applicant->ic_number, 'phone_number' => $applicant->phone_number, 'position' => $applicant->current_position, 'status' => 'active', 'gender' => $applicant->gender, 'marriage_status' => $applicant->marriage_status, 'member_number' => $this->generateMemberNumber(), 'member_type' => 'Anggota', 'join_date' => now()->toDateString(), 'birth_date' => $applicant->birth_date, 'birth_place' => $applicant->birth_place, ]); $role = Role::where('name', 'Anggota')->first(); if ($role) { $user->assignRole($role); } $user->addresses()->create([ 'address_type' => 'home', 'address_line_1' => $applicant->address, 'postcode' => $applicant->postcode, ]); $user->employments()->create([ 'company_name' => $applicant->employer_name, 'job_title' => $applicant->current_position, 'employment_type' => 'Permanent', 'salary' => 0, 'start_date' => $applicant->start_work_date, 'is_current' => true, ]); foreach ($application->heirs as $index => $heir) { $user->heirs()->create([ 'name' => $heir->name, 'ic_number' => $heir->ic_number, 'relationship' => $heir->relationship, 'phone_number' => $heir->phone_number, 'is_primary' => $index === 0, ]); } return [ 'user' => $user, 'plainPassword' => $plainPassword, ]; } public function lookupMemberByIcNumber(string $icNumber): ?User { $icNumber = trim($icNumber); if ($icNumber === '') { return null; } return User::query() ->where('ic_number', $icNumber) ->where('status', 'active') ->first(); } protected function sendSubmissionConfirmation(MembershipApplication $application): void { $application->loadMissing(['applicant', 'heirs', 'documents', 'references.member']); Notification::route('mail', $application->applicant->email) ->notify(new MembershipApplicationSubmittedNotification($application)); } protected function sendResultNotification( MembershipApplication $application, Document $resultLetter, ?string $plainPassword = null, ): void { $application->loadMissing('applicant'); $notification = $application->board_result === BoardResult::Pass->value ? new MembershipApplicationPassedNotification($application, $resultLetter, $plainPassword) : new MembershipApplicationFailedNotification($application, $resultLetter); Notification::route('mail', $application->applicant->email)->notify($notification); } protected function generateMemberNumber(): int { $latestUser = User::query() ->whereNotNull('member_number') ->orderByDesc('member_number') ->lockForUpdate() ->first(); if ($latestUser === null && DB::getDriverName() === 'pgsql') { DB::selectOne('SELECT pg_advisory_xact_lock(?) AS locked', [742891035]); } return ((int) ($latestUser?->member_number ?? 0)) + 1; } /** * @param array{proposer_ic_number?: string|null, supporter_ic_number?: string|null} $references */ protected function syncReferences(MembershipApplication $application, array $references, string $applicantIcNumber): void { $referenceMap = [ 'references.proposer_ic_number' => ['field' => 'proposer_ic_number', 'type' => ReferenceType::Proposer], 'references.supporter_ic_number' => ['field' => 'supporter_ic_number', 'type' => ReferenceType::Supporter], ]; $resolvedUserIds = []; $applicantIcNumber = trim($applicantIcNumber); foreach ($referenceMap as $errorKey => $config) { if (! array_key_exists($config['field'], $references)) { continue; } $icNumber = trim((string) ($references[$config['field']] ?? '')); if ($icNumber === '') { $application->references()->where('reference_type', $config['type']->value)->delete(); continue; } if ($applicantIcNumber !== '' && strcasecmp($icNumber, $applicantIcNumber) === 0) { throw ValidationException::withMessages([ $errorKey => ['Pencadang/penyokong tidak boleh sama dengan pemohon.'], ]); } $member = $this->lookupMemberByIcNumber($icNumber); if (! $member) { throw ValidationException::withMessages([ $errorKey => ['Ahli dengan nombor kad pengenalan ini tidak dijumpai.'], ]); } if (in_array($member->id, $resolvedUserIds, true)) { throw ValidationException::withMessages([ $errorKey => ['Pencadang dan penyokong mestilah ahli yang berbeza.'], ]); } $resolvedUserIds[] = $member->id; $application->references()->updateOrCreate( ['reference_type' => $config['type']->value], ['user_id' => $member->id] ); } } protected function generateApplicationNumber(): string { $year = now()->year; $prefix = "APP-{$year}-"; $latestNumber = MembershipApplication::query() ->where('application_number', 'like', "{$prefix}%") ->orderByDesc('application_number') ->value('application_number'); $sequence = 1; if ($latestNumber && preg_match('/-(\d+)$/', $latestNumber, $matches)) { $sequence = ((int) $matches[1]) + 1; } return sprintf('%s%05d', $prefix, $sequence); } }