Bug: $alphaDate global null saat push tanpa alpha → violation lama tak pernah dihapus (false positive); totalAlpha dihitung utk semua siswa dgn tanggal global → rawan salah siswa/tanggal. Kini iterasi per (student, date) dari push. + test AttendanceSyncViolationFixTest (4 test).
366 lines
15 KiB
PHP
366 lines
15 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\Attendance;
|
|
use App\Models\ParentDevice;
|
|
use App\Models\ParentStudent;
|
|
use App\Models\Setting;
|
|
use App\Models\Student;
|
|
use App\Models\ViolationNotification;
|
|
use App\Models\ViolationType;
|
|
use App\Services\FcmService;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class AttendanceSyncController extends Controller
|
|
{
|
|
/**
|
|
* Receive attendance push from E-Jurnal.
|
|
* Auto-generate violations for alpha students (with notifications).
|
|
*/
|
|
/**
|
|
* Test connection — validate token and return simple status.
|
|
* GET /api/v1/attendance/ping?token=***
|
|
*/
|
|
public function ping(Request $request)
|
|
{
|
|
$expectedToken = Setting::getValue('ejurnal_sync_token', '');
|
|
$providedToken = $request->input('token');
|
|
|
|
if (empty($expectedToken) || $providedToken !== $expectedToken) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Token tidak valid.',
|
|
], 401);
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'Koneksi OK.',
|
|
'data' => [
|
|
'app' => config('app.name', 'Aplikasi Ketertiban'),
|
|
'students' => Student::where('is_active', true)->count(),
|
|
'token_valid' => true,
|
|
],
|
|
]);
|
|
}
|
|
|
|
public function sync(Request $request)
|
|
{
|
|
$expectedToken = Setting::getValue('ejurnal_sync_token', '');
|
|
$providedToken = $request->input('token');
|
|
|
|
if (empty($expectedToken) || $providedToken !== $expectedToken) {
|
|
Log::warning('Attendance sync: invalid token', [
|
|
'ip' => $request->ip(),
|
|
'provided' => substr((string)$providedToken, 0, 10),
|
|
]);
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Invalid or missing sync token.',
|
|
], 401);
|
|
}
|
|
|
|
$request->validate([
|
|
'attendances' => 'required|array|min:1|max:500',
|
|
'attendances.*.source_student_id' => 'required|integer',
|
|
'attendances.*.date' => 'required|date',
|
|
'attendances.*.lesson_hour' => 'required|integer|min:1|max:10',
|
|
'attendances.*.status' => 'required|in:hadir,sakit,izin,alpha,pkl',
|
|
'attendances.*.notes' => 'nullable|string|max:255',
|
|
]);
|
|
|
|
$attendances = $request->input('attendances');
|
|
$results = [
|
|
'created' => 0,
|
|
'updated' => 0,
|
|
'skipped' => 0,
|
|
'errors' => [],
|
|
'violations' => 0,
|
|
];
|
|
|
|
$sourceIds = array_unique(array_column($attendances, 'source_student_id'));
|
|
$students = Student::whereIn('source_id', $sourceIds)
|
|
->where('is_active', true)
|
|
->get(['id', 'source_id', 'class_name']);
|
|
$studentMap = $students->pluck('id', 'source_id');
|
|
$classNameMap = $students->pluck('class_name', 'id');
|
|
|
|
$alphaCounts = [];
|
|
$datesByStudent = []; // student_id => [date => true] — untuk pembersihan violation per tanggal
|
|
|
|
foreach ($attendances as $item) {
|
|
$sourceId = $item['source_student_id'];
|
|
|
|
if (!isset($studentMap[$sourceId])) {
|
|
$results['skipped']++;
|
|
$results['errors'][] = "Student source_id={$sourceId} not found";
|
|
continue;
|
|
}
|
|
|
|
$localStudentId = $studentMap[$sourceId];
|
|
$datesByStudent[$localStudentId][$item['date']] = true;
|
|
|
|
$statusMap = [
|
|
'present' => 'hadir',
|
|
'sick' => 'sakit',
|
|
'permission' => 'izin',
|
|
'absent' => 'alpha',
|
|
'pkl' => 'pkl', // PKL/Magang industri (e-jurnal)
|
|
];
|
|
$localStatus = $statusMap[$item['status']] ?? $item['status'];
|
|
|
|
try {
|
|
$attendance = Attendance::updateOrCreate(
|
|
[
|
|
'student_id' => $localStudentId,
|
|
'date' => $item['date'],
|
|
'lesson_hour' => $item['lesson_hour'],
|
|
],
|
|
[
|
|
'status' => $localStatus,
|
|
'class_name' => $classNameMap[$localStudentId] ?? null,
|
|
'notes' => $item['notes'] ?? null,
|
|
'recorded_by' => null,
|
|
]
|
|
);
|
|
|
|
if ($attendance->wasRecentlyCreated) {
|
|
$results['created']++;
|
|
} else {
|
|
$results['updated']++;
|
|
}
|
|
|
|
if ($localStatus === 'alpha') {
|
|
$alphaCounts[$localStudentId] = ($alphaCounts[$localStudentId] ?? 0) + 1;
|
|
}
|
|
} catch (\Exception $e) {
|
|
$results['errors'][] = "student_id={$sourceId} jam={$item['lesson_hour']}: " . $e->getMessage();
|
|
$results['skipped']++;
|
|
}
|
|
}
|
|
|
|
// 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 ($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 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 ($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']++;
|
|
}
|
|
} catch (\Exception $e) {
|
|
$results['errors'][] = "Violation sync failed for student={$studentId} date={$date}: " . $e->getMessage();
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
$results['errors'][] = 'Violation type "Alpha" not found. Create a violation type with slug "alpha".';
|
|
}
|
|
|
|
// ===== Notifikasi kehadiran ke wali (hadir/izin/sakit — alpha sudah via violation) =====
|
|
try {
|
|
$this->notifyAttendanceToParents($attendances, $studentMap, $classNameMap);
|
|
} catch (\Throwable $e) {
|
|
Log::error('Notifikasi kehadiran wali gagal: '.$e->getMessage());
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => "Sync completed: {$results['created']} created, {$results['updated']} updated, {$results['skipped']} skipped, {$results['violations']} violations.",
|
|
'results' => $results,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Kirim notifikasi kehadiran per sesi ke wali yang tertaut aktif.
|
|
* Dedup: satu notifikasi per (siswa, tanggal, status) per hari.
|
|
*/
|
|
protected function notifyAttendanceToParents(array $attendances, $studentMap, $classNameMap): void
|
|
{
|
|
// Kelompokkan item dalam batch ini: (student_id, date, status) → jam pertama/terakhir
|
|
$groups = [];
|
|
foreach ($attendances as $item) {
|
|
$sourceId = $item['source_student_id'];
|
|
if (! isset($studentMap[$sourceId])) {
|
|
continue;
|
|
}
|
|
$localId = $studentMap[$sourceId];
|
|
$status = $item['status'];
|
|
if ($status === 'absent') {
|
|
continue; // alpha sudah dinotifikasi lewat alur violation
|
|
}
|
|
$key = $localId.'|'.$item['date'].'|'.$status;
|
|
if (! isset($groups[$key])) {
|
|
$groups[$key] = [
|
|
'student_id' => $localId,
|
|
'date' => $item['date'],
|
|
'status' => $status,
|
|
'hours' => [],
|
|
];
|
|
}
|
|
$groups[$key]['hours'][] = (int) $item['lesson_hour'];
|
|
}
|
|
|
|
$statusLabel = ['present' => 'hadir', 'sick' => 'sakit', 'permission' => 'izin'];
|
|
$statusLocal = ['present' => 'hadir', 'sick' => 'sakit', 'permission' => 'izin'];
|
|
|
|
foreach ($groups as $g) {
|
|
$student = Student::find($g['student_id']);
|
|
if (! $student) {
|
|
continue;
|
|
}
|
|
|
|
$links = ParentStudent::where('student_id', $student->id)
|
|
->where('status', 'active')
|
|
->get();
|
|
if ($links->isEmpty()) {
|
|
continue;
|
|
}
|
|
|
|
// Konteks: mapel + rentang waktu dari e-jurnal
|
|
$ctx = $this->attendanceContext($student->class_name, $g['date']);
|
|
$label = $statusLabel[$g['status']] ?? $g['status'];
|
|
$subject = $ctx['subject'] ?? 'pembelajaran';
|
|
$time = $ctx['start'] && $ctx['end'] ? " ({$ctx['start']}-{$ctx['end']})" : '';
|
|
$message = 'Kehadiran: '.$student->full_name.' — '.$label.' di '.$subject.$time;
|
|
|
|
// Dedup: cek notifikasi serupa hari ini
|
|
$exists = ViolationNotification::where('student_id', $student->id)
|
|
->where('channel', 'attendance')
|
|
->where('message', $message)
|
|
->whereDate('created_at', today())
|
|
->exists();
|
|
if ($exists) {
|
|
continue;
|
|
}
|
|
|
|
$data = [
|
|
'student_name' => $student->full_name,
|
|
'status' => $label,
|
|
'type' => 'attendance_'.$label,
|
|
];
|
|
|
|
foreach ($links as $link) {
|
|
ViolationNotification::create([
|
|
'student_id' => $student->id,
|
|
'channel' => 'attendance',
|
|
'recipient' => $link->user?->name ?? 'Wali',
|
|
'message' => $message,
|
|
'status' => 'sent',
|
|
'user_id' => $link->user_id,
|
|
'created_at' => now(),
|
|
]);
|
|
|
|
$tokens = ParentDevice::where('user_id', $link->user_id)->pluck('fcm_token');
|
|
foreach ($tokens as $token) {
|
|
app(FcmService::class)->sendToToken($token, [
|
|
'title' => 'Kehadiran: '.$student->full_name,
|
|
'body' => $label.' di '.$subject.$time,
|
|
], $data);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mapel + rentang waktu untuk kelas & tanggal tertentu (dari e-jurnal).
|
|
*/
|
|
protected function attendanceContext(?string $className, string $date): array
|
|
{
|
|
try {
|
|
$db = DB::connection('ejurnal');
|
|
$yearId = $db->table('academic_years')->where('is_active', 1)->value('id');
|
|
$semesterId = $db->table('semesters')->where('is_active', 1)->value('id');
|
|
$dayKey = strtolower(date('l', strtotime($date)));
|
|
|
|
$subject = $db->table('schedules as s')
|
|
->join('classes as c', 'c.id', '=', 's.class_id')
|
|
->join('subjects as sub', 'sub.id', '=', 's.subject_id')
|
|
->where('c.name', $className)
|
|
->where('s.day_of_week', $dayKey)
|
|
->where('s.academic_year_id', $yearId)
|
|
->where('s.semester_id', $semesterId)
|
|
->where('s.is_active', 1)
|
|
->value('sub.name');
|
|
|
|
$start = $db->table('schedules as s')
|
|
->join('classes as c', 'c.id', '=', 's.class_id')
|
|
->where('c.name', $className)
|
|
->where('s.day_of_week', $dayKey)
|
|
->where('s.academic_year_id', $yearId)
|
|
->where('s.semester_id', $semesterId)
|
|
->where('s.is_active', 1)
|
|
->orderBy('s.start_time')
|
|
->value('s.start_time');
|
|
$end = $db->table('schedules as s')
|
|
->join('classes as c', 'c.id', '=', 's.class_id')
|
|
->where('c.name', $className)
|
|
->where('s.day_of_week', $dayKey)
|
|
->where('s.academic_year_id', $yearId)
|
|
->where('s.semester_id', $semesterId)
|
|
->where('s.is_active', 1)
|
|
->orderByDesc('s.end_time')
|
|
->value('s.end_time');
|
|
|
|
return [
|
|
'subject' => $subject,
|
|
'start' => $start ? substr($start, 0, 5) : null,
|
|
'end' => $end ? substr($end, 0, 5) : null,
|
|
];
|
|
} catch (\Throwable $e) {
|
|
Log::error('attendanceContext gagal: '.$e->getMessage());
|
|
|
|
return ['subject' => null, 'start' => null, 'end' => null];
|
|
}
|
|
}
|
|
}
|