MIF_E31230892/sim-pkpps/app/Services/EpposGLogParser.php

432 lines
18 KiB
PHP

<?php
/**
* EpposGLogParser.php — versi 2
* app/Services/EpposGLogParser.php
*/
namespace App\Services;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Carbon\Carbon;
class EpposGLogParser
{
const IOMD_MASUK = 2;
const IOMD_PULANG = 4;
const SLOT_KEYS = [
'jk1_msuk',
'jk1_kluar',
'jk2_msuk',
'jk2_kluar',
'lb_msuk',
'lb_kluar',
];
const SLOT_IOMD = [
'jk1_msuk' => self::IOMD_MASUK,
'jk1_kluar' => self::IOMD_PULANG,
'jk2_msuk' => self::IOMD_MASUK,
'jk2_kluar' => self::IOMD_PULANG,
'lb_msuk' => self::IOMD_MASUK,
'lb_kluar' => self::IOMD_PULANG,
];
// ─────────────────────────────────────────────────────────────
// PARSE INFO.XLS
// ─────────────────────────────────────────────────────────────
public function parseInfoFile(string $path): array
{
$spreadsheet = IOFactory::load($path);
return [
'shifts' => $this->parseShifts($spreadsheet->getSheetByName('Shift')),
'jadwal' => $this->parseJadwal($spreadsheet->getSheetByName('Jadwal')),
];
}
private function parseShifts($sheet): array
{
$shifts = [];
for ($row = 6; $row <= $sheet->getHighestRow(); $row++) {
$no = $sheet->getCell("A{$row}")->getValue();
if (!is_numeric($no)) continue;
$s = [
'jk1_msuk' => $this->readTime($sheet->getCell("B{$row}")->getValue()),
'jk1_kluar' => $this->readTime($sheet->getCell("C{$row}")->getValue()),
'jk2_msuk' => $this->readTime($sheet->getCell("D{$row}")->getValue()),
'jk2_kluar' => $this->readTime($sheet->getCell("E{$row}")->getValue()),
'lb_msuk' => $this->readTime($sheet->getCell("F{$row}")->getValue()),
'lb_kluar' => $this->readTime($sheet->getCell("G{$row}")->getValue()),
];
$adaIsi = array_filter($s);
if (empty($adaIsi)) continue;
$shifts[(int)$no] = $s;
}
return $shifts;
}
private function parseJadwal($sheet): array
{
$jadwal = [];
for ($row = 3; $row <= $sheet->getHighestRow(); $row++) {
$no = $sheet->getCell("A{$row}")->getValue();
$nama = $sheet->getCell("B{$row}")->getValue();
if (!is_numeric($no) || empty($nama)) continue;
$jadwal[(string)(int)$no] = [
'nama' => trim((string)$nama),
'dept' => trim((string)($sheet->getCell("C{$row}")->getValue() ?? '')),
'shift' => (int)($sheet->getCell("D{$row}")->getValue() ?? 1),
];
}
return $jadwal;
}
// ─────────────────────────────────────────────────────────────
// PARSE GLOG.TXT
// ─────────────────────────────────────────────────────────────
public function parseGLog(string $path): array
{
$content = file_get_contents($path);
$content = str_replace(["\r\n", "\r"], "\n", $content);
$lines = explode("\n", trim($content));
$records = [];
foreach ($lines as $line) {
$line = trim($line);
if (empty($line)) continue;
$cols = explode("\t", $line);
$cols = array_values(array_filter(array_map('trim', $cols), fn($v) => $v !== ''));
if (count($cols) < 7) continue;
if ($cols[0] === 'No') continue;
$enno = $cols[2] ?? '';
$namaMesin = $cols[3] ?? '';
$iomdRaw = $cols[5] ?? '';
$dtRaw = $cols[6] ?? '';
if (!is_numeric(ltrim($enno, '0') ?: '0')) continue;
if (empty($dtRaw)) continue;
$iomd = (int)$iomdRaw;
if (!in_array($iomd, [self::IOMD_MASUK, self::IOMD_PULANG])) continue;
$dtRaw = preg_replace('/\s+/', ' ', trim($dtRaw));
$parts = explode(' ', $dtRaw);
if (count($parts) < 2) continue;
$tglStr = $parts[0];
$jamStr = substr($parts[1], 0, 5);
if (!preg_match('/^\d{4}\/\d{2}\/\d{2}$/', $tglStr)) continue;
if (!preg_match('/^\d{2}:\d{2}$/', $jamStr)) continue;
$tanggal = str_replace('/', '-', $tglStr);
$idMesin = (string)(int)ltrim($enno, '0') ?: '0';
$records[] = [
'id_mesin' => $idMesin,
'nama_mesin' => trim($namaMesin),
'tanggal' => $tanggal,
'jam' => $jamStr,
'iomd' => $iomd,
'dt_raw' => $dtRaw,
];
}
return $records;
}
// ─────────────────────────────────────────────────────────────
// GROUP BY DAY
// ─────────────────────────────────────────────────────────────
public function groupGLogByDay(array $records): array
{
$grouped = [];
foreach ($records as $r) {
$key = "{$r['id_mesin']}_{$r['tanggal']}";
if (!isset($grouped[$key])) {
$grouped[$key] = [
'id_mesin' => $r['id_mesin'],
'nama_mesin' => $r['nama_mesin'],
'tanggal' => $r['tanggal'],
'scans' => [],
];
}
$duplikat = array_filter(
$grouped[$key]['scans'],
fn($s) => $s['jam'] === $r['jam'] && $s['iomd'] === $r['iomd']
);
if (!empty($duplikat)) continue;
$grouped[$key]['scans'][] = [
'jam' => $r['jam'],
'iomd' => $r['iomd'],
];
}
foreach ($grouped as &$g) {
usort($g['scans'], fn($a, $b) => strcmp($a['jam'], $b['jam']));
}
return $grouped;
}
// ─────────────────────────────────────────────────────────────
// MATCH TO KEGIATAN
// ─────────────────────────────────────────────────────────────
/**
* Cocokkan setiap scan ke kegiatan web.
*
* STATUS Hadir / Terlambat ditentukan berdasarkan $tolSesudah:
* ─────────────────────────────────────────────────────────────
* scan <= waktu_mulai + $tolSesudah → Hadir
* scan > waktu_mulai + $tolSesudah → Terlambat
*
* Contoh: tolSesudah=30, kegiatan mulai 04:00
* Scan 04:07 (+7 menit) → Hadir ✅ (7 ≤ 30)
* Scan 04:35 (+35 menit) → Terlambat ⏰ (35 > 30)
* ─────────────────────────────────────────────────────────────
*
* @param array $glogGrouped Output groupGLogByDay()
* @param array $infoData Output parseInfoFile()
* @param array $kegiatans Dari DB
* @param int $tolSebelum Menit toleransi SEBELUM waktu_mulai (window masuk)
* @param int $tolSesudah Menit toleransi SESUDAH waktu_mulai (Hadir/Terlambat cut-off)
*/
public function matchToKegiatan(
array $glogGrouped,
array $infoData,
array $kegiatans,
int $tolSebelum = 15,
int $tolSesudah = 10
): array {
$hasil = [];
foreach ($glogGrouped as $dayData) {
$tanggal = $dayData['tanggal'];
$idMesin = $dayData['id_mesin'];
$scans = $dayData['scans'];
$hari = $this->tanggalToHari($tanggal);
$kegHariIni = array_values(
array_filter($kegiatans, fn($k) => $k['hari'] === $hari)
);
$jadwalInfo = $infoData['jadwal'][$idMesin] ?? null;
$nomorShift = $jadwalInfo ? ($jadwalInfo['shift'] ?? 1) : null;
$shiftData = ($nomorShift && isset($infoData['shifts'][$nomorShift]))
? $infoData['shifts'][$nomorShift]
: null;
$slotWindows = $shiftData
? $this->buildSlotWindows($shiftData)
: [];
$matchedKg = [];
$usedScans = [];
$rowMap = [];
foreach ($scans as $idx => $scan) {
$scanJam = $scan['jam'];
$scanIomd = $scan['iomd'];
$scanMnt = $this->toMinutes($scanJam);
$bestKg = null;
$bestSelisih = PHP_INT_MAX;
$bestSlot = null;
if (!empty($slotWindows)) {
// ── MODE UTAMA: cocokkan pakai IOMd dari shift ──────
foreach ($slotWindows as $sw) {
if ($sw['iomd'] !== $scanIomd) continue;
if ($sw['jam'] === null) continue;
$slotMnt = $this->toMinutes($sw['jam']);
$windowMulai = $slotMnt - $tolSebelum;
$windowAkhir = $slotMnt + $tolSesudah;
if ($scanMnt < $windowMulai || $scanMnt > $windowAkhir) continue;
foreach ($kegHariIni as $kg) {
if (isset($matchedKg[$kg['kegiatan_id']])) continue;
$kgMulaiMnt = $this->toMinutes($kg['waktu_mulai']);
$kgSelesaiMnt = $this->toMinutes($kg['waktu_selesai'] ?: $kg['waktu_mulai']);
$kgWindowMul = $kgMulaiMnt - $tolSebelum;
$kgWindowAkh = $kgSelesaiMnt + $tolSesudah;
if ($slotMnt < $kgWindowMul || $slotMnt > $kgWindowAkh) continue;
$selisih = abs($slotMnt - $kgMulaiMnt);
if ($selisih < $bestSelisih) {
$bestSelisih = $selisih;
$bestKg = $kg;
$bestSlot = $sw;
}
}
}
} else {
// ── FALLBACK: shifts kosong, matching hanya berdasar jam ──
foreach ($kegHariIni as $kg) {
if (isset($matchedKg[$kg['kegiatan_id']])) continue;
$kgMulaiMnt = $this->toMinutes($kg['waktu_mulai']);
$kgSelesaiMnt = $this->toMinutes($kg['waktu_selesai'] ?: $kg['waktu_mulai']);
$kgWindowMul = $kgMulaiMnt - $tolSebelum;
$kgWindowAkh = $kgSelesaiMnt + $tolSesudah;
if ($scanMnt < $kgWindowMul || $scanMnt > $kgWindowAkh) continue;
$selisih = abs($scanMnt - $kgMulaiMnt);
if ($selisih < $bestSelisih) {
$bestSelisih = $selisih;
$bestKg = $kg;
}
}
}
if ($bestKg) {
$kgMulaiMnt = $this->toMinutes($bestKg['waktu_mulai']);
$selisih = $scanMnt - $kgMulaiMnt;
// ─────────────────────────────────────────────────────
// STATUS Hadir vs Terlambat:
// Hadir → scan tiba SEBELUM atau tepat waktu +$tolSesudah menit
// Terlambat → scan tiba LEBIH dari $tolSesudah menit setelah mulai
//
// Dengan begitu: toleransi dari form LANGSUNG menentukan batas
// Hadir/Terlambat — tidak ada nilai hardcode di sini.
// ─────────────────────────────────────────────────────
$status = $selisih <= $tolSesudah ? 'Hadir' : 'Terlambat';
$selisihTampil = $selisih > 0 ? $selisih : 0; // menit telat (hanya jika positif)
$matchedKg[$bestKg['kegiatan_id']] = true;
$usedScans[] = $idx;
$rowMap[$bestKg['kegiatan_id']] = [
'kegiatan_id' => $bestKg['kegiatan_id'],
'nama_kegiatan' => $bestKg['nama'],
'waktu_mulai' => $bestKg['waktu_mulai'],
'jam_scan' => $scanJam,
'iomd_scan' => $scanIomd,
'label_iomd' => $scanIomd === self::IOMD_MASUK ? 'Masuk' : 'Pulang',
'status' => $status,
'selisih_menit' => $selisihTampil,
'matched' => true,
];
}
}
// ── Alpa untuk kegiatan tanpa scan ───────────────────
foreach ($kegHariIni as $kg) {
if (!isset($rowMap[$kg['kegiatan_id']])) {
$rowMap[$kg['kegiatan_id']] = [
'kegiatan_id' => $kg['kegiatan_id'],
'nama_kegiatan' => $kg['nama'],
'waktu_mulai' => $kg['waktu_mulai'],
'jam_scan' => null,
'iomd_scan' => null,
'label_iomd' => null,
'status' => 'Alpa',
'selisih_menit' => null,
'matched' => false,
];
}
}
$unmatchedScans = [];
foreach ($scans as $idx => $scan) {
if (!in_array($idx, $usedScans)) {
$unmatchedScans[] = $scan['jam']
. ' (' . ($scan['iomd'] === 2 ? 'Masuk' : 'Pulang') . ')';
}
}
$rows = collect($rowMap)->sortBy('waktu_mulai')->values()->toArray();
$hasil[] = [
'id_mesin' => $idMesin,
'nama_mesin' => $dayData['nama_mesin'],
'tanggal' => $tanggal,
'hari' => $hari,
'all_scans' => $scans,
'unmatched_scans' => $unmatchedScans,
'shift_dipakai' => $nomorShift,
'rows' => $rows,
];
}
return $hasil;
}
// ─────────────────────────────────────────────────────────────
// BUILD SLOT WINDOWS
// ─────────────────────────────────────────────────────────────
private function buildSlotWindows(array $shiftData): array
{
$windows = [];
foreach (self::SLOT_KEYS as $slotKey) {
$windows[] = [
'slot' => $slotKey,
'jam' => $shiftData[$slotKey] ?? null,
'iomd' => self::SLOT_IOMD[$slotKey],
];
}
return $windows;
}
// ─────────────────────────────────────────────────────────────
// HELPERS
// ─────────────────────────────────────────────────────────────
private function toMinutes(string $hhmm): int
{
if (!str_contains($hhmm, ':')) return 0;
[$h, $m] = explode(':', $hhmm);
return (int)$h * 60 + (int)$m;
}
public function tanggalToHari(string $tanggal): string
{
return [
'Monday' => 'Senin',
'Tuesday' => 'Selasa',
'Wednesday' => 'Rabu',
'Thursday' => 'Kamis',
'Friday' => 'Jumat',
'Saturday' => 'Sabtu',
'Sunday' => 'Ahad',
][Carbon::parse($tanggal)->format('l')] ?? 'Senin';
}
private function readTime($val): ?string
{
if ($val === null || $val === '') return null;
if (is_float($val) || (is_string($val) && is_numeric($val) && str_contains($val, '.'))) {
$totalMin = round((float)$val * 24 * 60);
return sprintf('%02d:%02d', intdiv($totalMin, 60), $totalMin % 60);
}
$str = preg_replace('/\s+/', '', trim((string)$val));
if (preg_match('/^(\d{1,2}):(\d{2})$/', $str, $m)) {
return sprintf('%02d:%02d', (int)$m[1], (int)$m[2]);
}
return null;
}
}