Files
My-KOPKB/be/Modules/MembershipApplication/Services/MembershipApplicationService.php
T
ismailmasseran b05e074456 Feature/phone register (#11)
Co-authored-by: ISMAIL MASSERAN <topaz@ISMAILs-Macbook.local>
Co-authored-by: ISMAIL MASSERAN <topaz@Mac.dlinkrouter.local>
Reviewed-on: #11
2026-07-14 12:03:22 +08:00

646 lines
24 KiB
PHP

<?php
namespace Modules\MembershipApplication\Services;
use App\Models\Document;
use App\Services\DocumentService;
use App\Services\LetterService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Modules\Auth\Entities\User;
use Modules\MembershipApplication\Entities\MembershipApplication;
use Modules\MembershipApplication\Enums\ApplicationStatus;
use Modules\MembershipApplication\Enums\BoardDecision;
use Modules\MembershipApplication\Enums\BoardResult;
use Modules\MembershipApplication\Enums\ManagementDecision;
use Modules\MembershipApplication\Enums\ReferenceType;
use Modules\MembershipApplication\Enums\ReviewStage;
use Modules\MembershipApplication\Emails\MembershipApplicationFailedNotification;
use Modules\MembershipApplication\Emails\MembershipApplicationPassedNotification;
use Modules\MembershipApplication\Emails\MembershipApplicationSubmittedNotification;
use Modules\MembershipApplication\Repositories\Contracts\MembershipApplicationRepositoryInterface;
use Modules\Role\Entities\Role;
class MembershipApplicationService
{
public function __construct(
protected MembershipApplicationRepositoryInterface $repository,
protected DocumentService $documentService,
protected LetterService $letterService,
) {}
/**
* @param array{
* applicant: array<string, mixed>,
* heirs: list<array<string, mixed>>,
* references?: array{proposer_ic_number?: string|null, supporter_ic_number?: string|null}
* } $data
* @param array<string, UploadedFile> $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, $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->update([
'status' => ApplicationStatus::Completed,
'completed_at' => now()->toDateTimeString(),
]);
return $this->repository->findByIdWithRelations($application->id);
});
}
/**
* @param list<string> $applicationIds
* @return array{
* succeeded: list<MembershipApplication>,
* failed: list<array{id: string, application_number: string|null, message: string}>
* }
*/
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<string, mixed>
*/
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<string, mixed>
*/
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);
}
}