Initial commit
This commit is contained in:
@@ -0,0 +1,392 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\AcademicYear;
|
||||
use App\Models\SchoolClass;
|
||||
use App\Models\Student;
|
||||
use App\Models\StudentAcademicHistory;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
use RealRashid\SweetAlert\Facades\Alert;
|
||||
|
||||
class StudentPromotionController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$fromAcademicYearId = $request->integer('from_academic_year_id') ?: AcademicYear::where('is_active', true)->value('id');
|
||||
$toAcademicYearId = $request->integer('to_academic_year_id');
|
||||
|
||||
$academicYears = AcademicYear::orderByDesc('start_year')->orderByDesc('end_year')->get();
|
||||
|
||||
$students = collect();
|
||||
|
||||
if ($fromAcademicYearId) {
|
||||
$students = Student::query()
|
||||
->with(['academicYear', 'department', 'classRoom'])
|
||||
->where('academic_year_id', $fromAcademicYearId)
|
||||
->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,
|
||||
'students' => $students,
|
||||
'filters' => [
|
||||
'from_academic_year_id' => $fromAcademicYearId,
|
||||
'to_academic_year_id' => $toAcademicYearId,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
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'],
|
||||
'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 ($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.',
|
||||
]);
|
||||
|
||||
$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'],
|
||||
])
|
||||
->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();
|
||||
|
||||
return $sameDepartmentClasses->first(function (SchoolClass $classRoom) use ($student) {
|
||||
return str_contains(strtolower($classRoom->name), strtolower((string) $student->classRoom?->name));
|
||||
})
|
||||
?? $sameDepartmentClasses->first()
|
||||
?? $classes->first();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user