integer('from_academic_year_id') ?: AcademicYear::where('is_active', true)->value('id'); $toAcademicYearId = $request->integer('to_academic_year_id'); $selectedClassId = $request->integer('class_id'); $academicYears = AcademicYear::orderByDesc('start_year')->orderByDesc('end_year')->get(); $availableClasses = collect(); $classSummaries = collect(); $selectedClass = null; $students = collect(); if ($fromAcademicYearId) { $availableClasses = SchoolClass::query() ->with(['department']) ->where('academic_year_id', $fromAcademicYearId) ->where('is_active', true) ->withCount([ 'students as active_students_count' => fn ($query) => $query->where('status', 'active'), ]) ->orderBy('level') ->orderBy('name') ->get() ->filter(fn (SchoolClass $classRoom) => $classRoom->active_students_count > 0) ->values(); $classSummaries = $availableClasses->map(function (SchoolClass $classRoom) use ($toAcademicYearId) { $sampleStudent = Student::query() ->with(['classRoom']) ->where('academic_year_id', $classRoom->academic_year_id) ->where('class_id', $classRoom->id) ->where('status', 'active') ->orderBy('full_name') ->first(); $recommendation = $sampleStudent ? $this->buildPromotionRecommendation($sampleStudent, $toAcademicYearId) : [ 'label' => 'Belum ada siswa aktif', 'target_class_name' => null, ]; return (object) [ 'id' => $classRoom->id, 'name' => $classRoom->name, 'level' => $classRoom->level, 'department_name' => $classRoom->department?->name, 'active_students_count' => $classRoom->active_students_count, 'recommendation_label' => $recommendation['label'] ?? '-', 'recommendation_target_name' => $recommendation['target_class_name'] ?? null, ]; })->values(); if ($selectedClassId) { $selectedClass = $availableClasses->firstWhere('id', $selectedClassId); if ($selectedClass) { $students = Student::query() ->with(['academicYear', 'department', 'classRoom']) ->where('academic_year_id', $fromAcademicYearId) ->where('class_id', $selectedClassId) ->where('status', 'active') ->orderBy('full_name') ->get() ->map(function (Student $student) use ($toAcademicYearId) { $student->promotion_recommendation = $this->buildPromotionRecommendation($student, $toAcademicYearId); return $student; }); } } } return view('student-promotions.index', [ 'academicYears' => $academicYears, 'availableClasses' => $availableClasses, 'classSummaries' => $classSummaries, 'selectedClass' => $selectedClass, 'students' => $students, 'filters' => [ 'from_academic_year_id' => $fromAcademicYearId, 'to_academic_year_id' => $toAcademicYearId, 'class_id' => $selectedClassId, ], ]); } public function store(Request $request): RedirectResponse { $data = $request->validate([ 'from_academic_year_id' => ['required', 'exists:academic_years,id'], 'to_academic_year_id' => ['required', 'exists:academic_years,id', 'different:from_academic_year_id'], 'class_id' => ['nullable', 'exists:classes,id'], 'promotions' => ['required', 'array', 'min:1'], 'promotions.*.student_id' => ['required', 'exists:students,id'], 'promotions.*.target_class_id' => ['nullable', 'exists:classes,id'], 'promotions.*.action' => ['required', 'in:promote,repeat,graduate,skip'], 'promotions.*.notes' => ['nullable', 'string'], ]); $students = Student::query() ->with(['classRoom']) ->whereIn('id', collect($data['promotions'])->pluck('student_id')->all()) ->get() ->keyBy('id'); $targetClassIds = collect($data['promotions']) ->pluck('target_class_id') ->filter() ->unique() ->values(); $targetClasses = SchoolClass::query() ->whereIn('id', $targetClassIds) ->get() ->keyBy('id'); $plannedClassAssignments = collect($data['promotions']) ->filter(fn (array $promotion) => in_array($promotion['action'] ?? 'skip', ['promote', 'repeat'], true) && ! empty($promotion['target_class_id'])) ->countBy('target_class_id'); foreach ($data['promotions'] as $index => $promotion) { if (in_array($promotion['action'], ['promote', 'repeat'], true) && empty($promotion['target_class_id'])) { return back() ->withErrors(["promotions.$index.target_class_id" => 'Kelas tujuan wajib dipilih untuk aksi naik kelas atau tinggal kelas.']) ->withInput(); } $student = $students->get($promotion['student_id']); if (! $student) { return back() ->withErrors(["promotions.$index.student_id" => 'Data siswa tidak ditemukan.']) ->withInput(); } if ((int) $student->academic_year_id !== (int) $data['from_academic_year_id']) { return back() ->withErrors(["promotions.$index.student_id" => "Siswa {$student->full_name} tidak berasal dari tahun ajaran asal yang dipilih."]) ->withInput(); } if (! empty($data['class_id']) && (int) $student->class_id !== (int) $data['class_id']) { return back() ->withErrors(["promotions.$index.student_id" => "Siswa {$student->full_name} tidak berasal dari kelas asal yang dipilih."]) ->withInput(); } if ($student->status !== 'active') { return back() ->withErrors(["promotions.$index.student_id" => "Siswa {$student->full_name} tidak berstatus aktif sehingga tidak bisa diproses promosi."]) ->withInput(); } if (($promotion['action'] ?? 'skip') === 'graduate') { if (strtoupper((string) $student->classRoom?->level) !== 'XII') { return back() ->withErrors(["promotions.$index.action" => "Siswa {$student->full_name} hanya bisa diluluskan jika berada di kelas XII."]) ->withInput(); } continue; } if (($promotion['action'] ?? 'skip') === 'skip') { continue; } $targetClass = $targetClasses->get($promotion['target_class_id']); if (! $targetClass) { return back() ->withErrors(["promotions.$index.target_class_id" => "Kelas tujuan untuk {$student->full_name} tidak ditemukan."]) ->withInput(); } if ((int) $targetClass->academic_year_id !== (int) $data['to_academic_year_id']) { return back() ->withErrors(["promotions.$index.target_class_id" => "Kelas tujuan {$student->full_name} harus berasal dari tahun ajaran tujuan yang dipilih."]) ->withInput(); } if (! $targetClass->is_active) { return back() ->withErrors(["promotions.$index.target_class_id" => "Kelas tujuan {$student->full_name} tidak aktif."]) ->withInput(); } if ((int) $student->department_id !== (int) $targetClass->department_id) { return back() ->withErrors(["promotions.$index.target_class_id" => "Jurusan kelas tujuan {$student->full_name} harus sama dengan jurusan siswa saat ini."]) ->withInput(); } if (! $this->isTargetClassValidForAction($student, $targetClass, $promotion['action'])) { return back() ->withErrors(["promotions.$index.target_class_id" => "Level kelas tujuan {$student->full_name} tidak sesuai dengan aksi yang dipilih."]) ->withInput(); } $hasHistoryInTargetYear = StudentAcademicHistory::query() ->where('student_id', $student->id) ->where('academic_year_id', $data['to_academic_year_id']) ->exists(); if ($hasHistoryInTargetYear) { return back() ->withErrors(["promotions.$index.student_id" => "Siswa {$student->full_name} sudah memiliki histori akademik pada tahun ajaran tujuan."]) ->withInput(); } } $capacityErrors = $this->validateTargetClassCapacities($plannedClassAssignments, $targetClasses); if ($capacityErrors->isNotEmpty()) { return back() ->withErrors($capacityErrors->all()) ->withInput(); } $processed = 0; DB::transaction(function () use ($data, &$processed) { foreach ($data['promotions'] as $promotion) { if (($promotion['action'] ?? 'skip') === 'skip') { continue; } $student = Student::query()->findOrFail($promotion['student_id']); $action = $promotion['action']; if ($action === 'graduate') { $student->update([ 'status' => 'graduated', ]); StudentAcademicHistory::query() ->where('student_id', $student->id) ->where('is_current', true) ->update([ 'status' => 'graduated', 'is_current' => false, 'end_date' => now()->toDateString(), 'notes' => $promotion['notes'] ?? 'Diproses melalui modul promosi siswa.', ]); StudentGraduation::updateOrCreate( ['student_id' => $student->id], [ 'student_number' => $student->student_number, 'nisn' => $student->nisn, 'full_name' => $student->full_name, 'academic_year_id' => $student->academic_year_id, 'department_id' => $student->department_id, 'class_id' => $student->class_id, 'entry_date' => $student->entry_date, 'graduation_date' => now()->toDateString(), 'graduation_note' => $promotion['notes'] ?? 'Diproses melalui modul promosi siswa.', 'status' => 'graduated', ] ); $processed++; continue; } $targetClass = SchoolClass::query()->findOrFail($promotion['target_class_id']); StudentAcademicHistory::query() ->where('student_id', $student->id) ->where('is_current', true) ->update([ 'is_current' => false, 'end_date' => now()->toDateString(), 'status' => $action === 'repeat' ? 'repeated' : 'promoted', 'notes' => $promotion['notes'] ?? 'Diproses melalui modul promosi siswa.', ]); $student->update([ 'academic_year_id' => $data['to_academic_year_id'], 'department_id' => $targetClass->department_id, 'class_id' => $targetClass->id, 'status' => 'active', ]); StudentAcademicHistory::create([ 'student_id' => $student->id, 'academic_year_id' => $data['to_academic_year_id'], 'department_id' => $targetClass->department_id, 'class_id' => $targetClass->id, 'status' => 'active', 'start_date' => now()->toDateString(), 'end_date' => null, 'notes' => $promotion['notes'] ?? 'Hasil proses promosi siswa.', 'is_current' => true, ]); $processed++; } }); Alert::success('Berhasil', $processed . ' siswa berhasil diproses melalui modul promosi.'); return redirect() ->route('student-promotions.index', [ 'from_academic_year_id' => $data['from_academic_year_id'], 'to_academic_year_id' => $data['to_academic_year_id'], 'class_id' => $data['class_id'] ?? null, ]) ->with('success', $processed . ' siswa berhasil diproses melalui modul promosi.'); } protected function buildPromotionRecommendation(Student $student, ?int $toAcademicYearId): array { $currentLevel = strtoupper((string) ($student->classRoom?->level ?? '')); $nextLevel = match ($currentLevel) { 'X' => 'XI', 'XI' => 'XII', default => null, }; if ($currentLevel === 'XII') { return [ 'action' => 'graduate', 'label' => 'Lulus', 'target_class_id' => null, 'target_class_name' => 'Siswa kelas XII direkomendasikan lulus.', 'available_classes' => [ 'promote' => collect(), 'repeat' => collect(), ], ]; } $promoteClasses = $toAcademicYearId && $nextLevel ? $this->findClassesForLevel($student, $toAcademicYearId, $nextLevel) : collect(); $repeatClasses = $toAcademicYearId && $currentLevel ? $this->findClassesForLevel($student, $toAcademicYearId, $currentLevel) : collect(); $recommendedPromoteClass = $this->pickRecommendedClass($promoteClasses, $student); $recommendedRepeatClass = $this->pickRecommendedClass($repeatClasses, $student); $defaultAction = $recommendedPromoteClass ? 'promote' : ($recommendedRepeatClass ? 'repeat' : 'repeat'); $defaultClass = $defaultAction === 'promote' ? $recommendedPromoteClass : $recommendedRepeatClass; return [ 'action' => $defaultAction, 'label' => $defaultAction === 'promote' ? 'Naik Kelas' : 'Tinggal Kelas', 'target_class_id' => $defaultClass?->id, 'target_class_name' => $defaultClass?->name, 'available_classes' => [ 'promote' => $this->transformClassOptions($promoteClasses), 'repeat' => $this->transformClassOptions($repeatClasses), ], ]; } protected function findClassesForLevel(Student $student, int $academicYearId, string $level) { return SchoolClass::query() ->where('academic_year_id', $academicYearId) ->where('level', $level) ->where('is_active', true) ->orderByRaw('CASE WHEN department_id = ? THEN 0 ELSE 1 END', [$student->department_id]) ->orderBy('name') ->get() ->values(); } protected function pickRecommendedClass($classes, Student $student): ?SchoolClass { $sameDepartmentClasses = $classes->where('department_id', $student->department_id)->values(); $currentClassName = (string) $student->classRoom?->name; $currentNormalized = $this->normalizeClassNameForComparison($currentClassName); $currentParallel = $this->extractClassParallel($currentClassName); return $sameDepartmentClasses->first(function (SchoolClass $classRoom) use ($currentParallel) { return $currentParallel !== null && $this->extractClassParallel((string) $classRoom->name) === $currentParallel; }) ?? $sameDepartmentClasses->first(function (SchoolClass $classRoom) use ($currentNormalized) { return $currentNormalized !== '' && $this->normalizeClassNameForComparison((string) $classRoom->name) === $currentNormalized; }) ?? $sameDepartmentClasses->first(function (SchoolClass $classRoom) use ($student) { return str_contains(strtolower($classRoom->name), strtolower((string) $student->classRoom?->name)); }) ?? $sameDepartmentClasses->first() ?? $classes->first(); } protected function normalizeClassNameForComparison(string $className): string { $normalized = strtoupper(trim($className)); $normalized = preg_replace('/^XII?I?\s+/i', '', $normalized) ?? $normalized; return preg_replace('/\s+/', ' ', trim($normalized)) ?? trim($normalized); } protected function extractClassParallel(string $className): ?string { if (! preg_match('/(\d+)\s*$/', trim($className), $matches)) { return null; } return ltrim($matches[1], '0') ?: '0'; } protected function transformClassOptions(Collection $classes): Collection { return $classes->map(function (SchoolClass $classRoom) { $currentOccupancy = Student::query() ->where('academic_year_id', $classRoom->academic_year_id) ->where('class_id', $classRoom->id) ->where('status', 'active') ->count(); $capacity = $classRoom->capacity; $remainingCapacity = filled($capacity) ? max((int) $capacity - $currentOccupancy, 0) : null; $capacityLabel = filled($capacity) ? "Kuota {$currentOccupancy}/{$capacity} • sisa {$remainingCapacity}" : 'Kuota belum diatur'; return (object) [ 'id' => $classRoom->id, 'name' => $classRoom->name, 'department_id' => $classRoom->department_id, 'capacity' => $capacity, 'current_occupancy' => $currentOccupancy, 'remaining_capacity' => $remainingCapacity, 'capacity_label' => $capacityLabel, 'option_label' => "{$classRoom->name} ({$capacityLabel})", ]; })->values(); } protected function isTargetClassValidForAction(Student $student, SchoolClass $targetClass, string $action): bool { $currentLevel = strtoupper((string) $student->classRoom?->level); $targetLevel = strtoupper((string) $targetClass->level); return match ($action) { 'promote' => $targetLevel === match ($currentLevel) { 'X' => 'XI', 'XI' => 'XII', default => null, }, 'repeat' => $targetLevel === $currentLevel, default => true, }; } protected function validateTargetClassCapacities(Collection $plannedClassAssignments, Collection $targetClasses): Collection { return $plannedClassAssignments->map(function (int $plannedCount, int|string $classId) use ($targetClasses) { /** @var SchoolClass|null $targetClass */ $targetClass = $targetClasses->get($classId); if (! $targetClass || blank($targetClass->capacity)) { return null; } $currentOccupancy = Student::query() ->where('academic_year_id', $targetClass->academic_year_id) ->where('class_id', $targetClass->id) ->where('status', 'active') ->count(); $remainingCapacity = (int) $targetClass->capacity - $currentOccupancy; if ($plannedCount > $remainingCapacity) { return "Kelas {$targetClass->name} melebihi kapasitas. Sisa kuota {$remainingCapacity}, tetapi alokasi promosi/repeat yang dipilih {$plannedCount}."; } return null; })->filter()->values(); } }