diff --git a/app/Http/Controllers/Api/AttendanceSyncController.php b/app/Http/Controllers/Api/AttendanceSyncController.php index e00a388..551d860 100644 --- a/app/Http/Controllers/Api/AttendanceSyncController.php +++ b/app/Http/Controllers/Api/AttendanceSyncController.php @@ -90,8 +90,7 @@ class AttendanceSyncController extends Controller $classNameMap = $students->pluck('class_name', 'id'); $alphaCounts = []; - $alphaDate = null; - $allStudentIdsInPush = []; // track all students for cleanup + $datesByStudent = []; // student_id => [date => true] — untuk pembersihan violation per tanggal foreach ($attendances as $item) { $sourceId = $item['source_student_id']; @@ -103,7 +102,7 @@ class AttendanceSyncController extends Controller } $localStudentId = $studentMap[$sourceId]; - $allStudentIdsInPush[$localStudentId] = true; + $datesByStudent[$localStudentId][$item['date']] = true; $statusMap = [ 'present' => 'hadir', @@ -137,7 +136,6 @@ class AttendanceSyncController extends Controller if ($localStatus === 'alpha') { $alphaCounts[$localStudentId] = ($alphaCounts[$localStudentId] ?? 0) + 1; - $alphaDate = $item['date']; } } catch (\Exception $e) { $results['errors'][] = "student_id={$sourceId} jam={$item['lesson_hour']}: " . $e->getMessage(); @@ -145,57 +143,63 @@ class AttendanceSyncController extends Controller } } - // Auto-generate / update / hapus violations untuk alpha siswa di push ini + // Auto-generate / update / hapus violations alpha per (siswa, tanggal) dari push ini. + // FIX 2026-08-31: sebelumnya memakai $alphaDate global (null saat push tanpa alpha) + // sehingga violation lama tidak pernah dihapus → false positive; dan $totalAlpha + // dihitung untuk semua siswa memakai satu tanggal global → rawan salah tanggal/siswa. + // Sekarang iterasi per (student, date) yang benar-benar muncul di push. $alphaType = ViolationType::where('slug', 'alpha')->first(); if ($alphaType) { $violationService = app(\App\Services\ViolationService::class); - foreach ($allStudentIdsInPush as $studentId => $_) { - try { - // Hitung total alpha di DB (akumulasi dari semua push, bukan cuma push ini) - $totalAlpha = \App\Models\Attendance::where('student_id', $studentId) - ->where('date', $alphaDate) - ->where('status', 'alpha') - ->count(); + foreach ($datesByStudent as $studentId => $dates) { + foreach (array_keys($dates) as $date) { + try { + // Hitung total alpha di DB per (siswa, tanggal) — akumulasi dari semua push + $totalAlpha = \App\Models\Attendance::where('student_id', $studentId) + ->where('date', $date) + ->where('status', 'alpha') + ->count(); - // Cari existing system-generated violation - $existing = \App\Models\Violation::where('student_id', $studentId) - ->where('violation_type_id', $alphaType->id) - ->where('violation_date', $alphaDate) - ->whereNull('recorded_by') - ->first(); + // Cari existing system-generated violation untuk (siswa, tanggal) yang sama + $existing = \App\Models\Violation::where('student_id', $studentId) + ->where('violation_type_id', $alphaType->id) + ->where('violation_date', $date) + ->whereNull('recorded_by') + ->first(); - if ($totalAlpha > 0) { - // Ada alpha → buat/update violation - $points = max(1, (int) round(($alphaType->points / 10) * $totalAlpha)); - $desc = "Alpha - Tidak hadir tanpa keterangan ({$totalAlpha} jam pelajaran)"; + if ($totalAlpha > 0) { + // Ada alpha → buat/update violation + $points = max(1, (int) round(($alphaType->points / 10) * $totalAlpha)); + $desc = "Alpha - Tidak hadir tanpa keterangan ({$totalAlpha} jam pelajaran)"; - if ($existing) { - $existing->update([ - 'points' => $points, - 'description' => $desc, - 'notes' => 'Diperbarui otomatis dari sinkron E-Jurnal.', - ]); - } else { - $violationService->recordViolation([ - 'student_id' => $studentId, - 'violation_type_id' => $alphaType->id, - 'violation_date' => $alphaDate, - 'points' => $points, - 'description' => $desc, - 'notes' => 'Dibuat otomatis dari sinkron E-Jurnal.', - 'evidences' => [], - ], null); + if ($existing) { + $existing->update([ + 'points' => $points, + 'description' => $desc, + 'notes' => 'Diperbarui otomatis dari sinkron E-Jurnal.', + ]); + } else { + $violationService->recordViolation([ + 'student_id' => $studentId, + 'violation_type_id' => $alphaType->id, + 'violation_date' => $date, + 'points' => $points, + 'description' => $desc, + 'notes' => 'Dibuat otomatis dari sinkron E-Jurnal.', + 'evidences' => [], + ], null); + } + $results['violations']++; + } elseif ($existing) { + // Tidak ada alpha di tanggal ini tapi ada violation system lama → hapus (ganti status ke hadir) + $existing->delete(); + $results['violations']++; } - $results['violations']++; - } elseif ($existing) { - // Tidak ada alpha tapi ada violation lama → hapus (ganti status ke hadir) - $existing->delete(); - $results['violations']++; + } catch (\Exception $e) { + $results['errors'][] = "Violation sync failed for student={$studentId} date={$date}: " . $e->getMessage(); } - } catch (\Exception $e) { - $results['errors'][] = "Violation sync failed for student={$studentId}: " . $e->getMessage(); } } } else { diff --git a/tests/Feature/AttendanceSyncViolationFixTest.php b/tests/Feature/AttendanceSyncViolationFixTest.php new file mode 100644 index 0000000..a85ffb7 --- /dev/null +++ b/tests/Feature/AttendanceSyncViolationFixTest.php @@ -0,0 +1,171 @@ + 'Kehadiran', + 'slug' => 'kehadiran', + 'color' => '#EF4444', + 'is_active' => true, + ]); + + ViolationType::create([ + 'category_id' => $category->id, + 'name' => 'Membolos/tidak masuk sekolah tanpa keterangan', + 'slug' => 'alpha', + 'points' => 20, // poin per 10 JP (2 poin/JP) + 'description' => 'Tidak hadir tanpa keterangan', + 'is_active' => true, + 'is_system' => true, + ]); + } + + protected function makeStudent(int $sourceId, string $name): Student + { + return Student::create([ + 'source_id' => (string) $sourceId, + 'nisn' => '00' . $sourceId, + 'student_number' => 'TEST/' . $sourceId, + 'full_name' => $name, + 'class_name' => 'XII TKJ 3', + 'class_level' => 'XII', + 'department_name' => 'TKJ', + 'status' => 'active', + 'is_active' => true, + ]); + } + + protected function sync(array $attendances): \Illuminate\Testing\TestResponse + { + return $this->post('/api/v1/attendance/sync', [ + 'token' => 'test-token', + 'attendances' => $attendances, + ]); + } + + public function test_push_alpha_membuat_violation_hanya_untuk_siswa_alpha(): void + { + $s1 = $this->makeStudent(1001, 'SISWA ALPHA'); + $s2 = $this->makeStudent(1002, 'SISWA HADIR'); + + $this->sync([ + ['source_student_id' => 1001, 'date' => '2026-08-26', 'lesson_hour' => 1, 'status' => 'alpha'], + ['source_student_id' => 1001, 'date' => '2026-08-26', 'lesson_hour' => 2, 'status' => 'alpha'], + ['source_student_id' => 1002, 'date' => '2026-08-26', 'lesson_hour' => 1, 'status' => 'hadir'], + ])->assertOk(); + + // Siswa alpha → violation 2 JP = 4 poin (2 poin/JP) + $this->assertDatabaseHas('violations', [ + 'student_id' => $s1->id, + 'violation_date' => '2026-08-26', + 'points' => 4, + ]); + + // Siswa hadir → TIDAK boleh kena violation + $this->assertDatabaseMissing('violations', [ + 'student_id' => $s2->id, + 'violation_date' => '2026-08-26', + ]); + } + + public function test_push_koreksi_tanpa_alpha_menghapus_violation_lama(): void + { + $s1 = $this->makeStudent(1001, 'SISWA KOREKSI'); + $alphaType = ViolationType::where('slug', 'alpha')->first(); + + // Simulasikan violation system-generated lama (salah: siswa sebenarnya sakit) + Violation::create([ + 'student_id' => $s1->id, + 'student_class' => 'XII TKJ 3', + 'violation_type_id' => $alphaType->id, + 'recorded_by' => null, + 'description' => 'Alpha - Tidak hadir tanpa keterangan (4 jam pelajaran)', + 'points' => 8, + 'violation_date' => '2026-08-26', + 'notes' => 'Dibuat otomatis dari sinkron E-Jurnal.', + ]); + + // Push koreksi: siswa kini SAKIT (tidak ada alpha sama sekali dalam push) + $this->sync([ + ['source_student_id' => 1001, 'date' => '2026-08-26', 'lesson_hour' => 1, 'status' => 'sakit'], + ['source_student_id' => 1001, 'date' => '2026-08-26', 'lesson_hour' => 2, 'status' => 'sakit'], + ])->assertOk(); + + // Violation lama harus TERHAPUS (bug lama: tidak terhapus karena $alphaDate null) + $this->assertNull( + Violation::where('student_id', $s1->id)->where('violation_date', '2026-08-26')->first(), + 'Violation alpha lama harus terhapus setelah push koreksi tanpa alpha.' + ); + } + + public function test_push_multi_tanggal_tidak_membuat_violation_salah_tanggal(): void + { + $s1 = $this->makeStudent(1001, 'SISWA CAMPURAN'); + + // Push berisi 2 tanggal: 26-08 hadir, 27-08 alpha. + // Bug lama: $alphaDate global = 27-08 → siswa dihitung alpha di 27-08 untuk semua siswa. + // Harusnya violation HANYA untuk 27-08. + $this->sync([ + ['source_student_id' => 1001, 'date' => '2026-08-26', 'lesson_hour' => 1, 'status' => 'hadir'], + ['source_student_id' => 1001, 'date' => '2026-08-27', 'lesson_hour' => 1, 'status' => 'alpha'], + ['source_student_id' => 1001, 'date' => '2026-08-27', 'lesson_hour' => 2, 'status' => 'alpha'], + ])->assertOk(); + + $this->assertDatabaseHas('violations', [ + 'student_id' => $s1->id, + 'violation_date' => '2026-08-27', + 'points' => 4, + ]); + $this->assertDatabaseMissing('violations', [ + 'student_id' => $s1->id, + 'violation_date' => '2026-08-26', + ]); + } + + public function test_push_siswa_yang_bukan_alpha_tidak_kena_violation_saat_push_berisi_alpha_siswa_lain(): void + { + $s1 = $this->makeStudent(1001, 'SISWA ALPHA'); + $s2 = $this->makeStudent(1002, 'SISWA HADIR'); + + // Bug lama: totalAlpha dihitung utk SEMUA siswa dgn tanggal global → s2 ikut kena. + $this->sync([ + ['source_student_id' => 1001, 'date' => '2026-08-26', 'lesson_hour' => 1, 'status' => 'alpha'], + ['source_student_id' => 1002, 'date' => '2026-08-26', 'lesson_hour' => 1, 'status' => 'hadir'], + ['source_student_id' => 1002, 'date' => '2026-08-26', 'lesson_hour' => 2, 'status' => 'hadir'], + ])->assertOk(); + + $this->assertDatabaseHas('violations', [ + 'student_id' => $s1->id, + 'violation_date' => '2026-08-26', + ]); + $this->assertDatabaseMissing('violations', [ + 'student_id' => $s2->id, + 'violation_date' => '2026-08-26', + ]); + } +}