sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401); $days = self::clampDays($req->query('days')); $scope = $this->sess->get('site_scope'); $site = is_string($scope) ? $scope : null; $now = time(); $since = (float)($now - $days * 86400); $s = $this->events->reportSummary($since, $site, self::TOP_CAMERAS); // 직전 동일 길이 기간 — KPI의 "지난 기간 대비" 추이. 같은 집계를 상한만 걸어 재사용. $prev = $this->events->reportSummary((float)($now - 2 * $days * 86400), $site, self::TOP_CAMERAS, $since); $severityTotals = ['critical' => 0, 'high' => 0, 'warning' => 0, 'info' => 0]; $typeCounts = []; $unacked = 0; $fpTagged = 0; $total = 0; $nearFall = 0; $falls = 0; $occupancy = array_fill_keys(self::OCCUPANCY_TYPES, 0); foreach ($s['by_type'] as $row) { $total += $row['count']; $unacked += $row['unacked']; $fpTagged += $row['fp']; if (isset($severityTotals[$row['severity']])) $severityTotals[$row['severity']] += $row['count']; $typeCounts[$row['event_type']] = ($typeCounts[$row['event_type']] ?? 0) + $row['count']; if ($row['event_type'] === 'near_fall') $nearFall += $row['count']; if (in_array($row['event_type'], ['fall', 'no_motion'], true)) $falls += $row['count']; if (in_array($row['event_type'], self::OCCUPANCY_TYPES, true)) { $occupancy[$row['event_type']] += $row['count']; } } // ergo_report is bookkeeping, not a detection — it must not inflate "감지 N건". $ergoReports = $typeCounts['ergo_report'] ?? 0; $total -= $ergoReports; if (isset($severityTotals['info'])) $severityTotals['info'] = max(0, $severityTotals['info'] - $ergoReports); unset($typeCounts['ergo_report']); arsort($typeCounts); $ergo = array_fill_keys(self::ERGO_KEYS, 0.0); foreach ($s['ergo_metrics'] as $m) { foreach (self::ERGO_KEYS as $k) $ergo[$k] += (float)($m[$k] ?? 0); } $ergo = array_map(static fn ($v) => round($v, 1), $ergo); // 이전 기간 요약 — KPI 추이에 필요한 축만 (전체 by_type 재전송은 낭비). $prevSeverity = ['critical' => 0, 'high' => 0]; $prevTotal = 0; $prevFp = 0; $prevNearFall = 0; foreach ($prev['by_type'] as $row) { if ($row['event_type'] === 'ergo_report') continue; $prevTotal += $row['count']; $prevFp += $row['fp']; if (isset($prevSeverity[$row['severity']])) $prevSeverity[$row['severity']] += $row['count']; if ($row['event_type'] === 'near_fall') $prevNearFall += $row['count']; } // 시간 패턴 + 전조 급증. 버킷팅은 PHP에서(서버 로컬 시각 = 현장 시간대) — SQL은 // 이식성 유지. 급증 기준선은 리포트 기간과 무관하게 최근 5주로 고정한다. $pattern = self::buildPattern($this->events->occurrenceTimes($since, $site, self::PATTERN_LIMIT)); $spike = self::precursorSpike( $this->events->occurrenceTimes((float)($now - (self::SPIKE_BASELINE_WEEKS + 1) * 7 * 86400), $site, self::PATTERN_LIMIT), $now); return Response::json([ 'days' => $days, 'generated_at' => time(), 'totals' => ['events' => $total, 'unacked' => $unacked] + $severityTotals, 'by_type' => $typeCounts, 'by_camera' => $s['by_camera'], 'tta' => [ 'acked' => $s['tta']['acked'], 'avg_s' => $s['tta']['avg_s'] !== null ? round($s['tta']['avg_s'], 1) : null, 'max_s' => $s['tta']['max_s'] !== null ? round($s['tta']['max_s'], 1) : null, ], 'near_miss' => ['near_fall' => $nearFall, 'incidents' => $falls], 'fp' => ['tagged' => $fpTagged, 'total' => $total], 'ergo' => $ergo, 'occupancy' => $occupancy, 'previous' => [ 'events' => $prevTotal, 'critical' => $prevSeverity['critical'], 'high' => $prevSeverity['high'], 'near_fall' => $prevNearFall, 'fp' => ['tagged' => $prevFp, 'total' => $prevTotal], 'tta_avg_s' => $prev['tta']['avg_s'] !== null ? round($prev['tta']['avg_s'], 1) : null, ], 'pattern' => $pattern, 'precursor_alert' => $spike, ], 200); } /** 요일(0=월)×시간(0..23) 발생 밀도 그리드 — 서버 로컬 시각 기준. */ private static function buildPattern(array $times): array { $grid = array_fill(0, 7, array_fill(0, 24, 0)); $max = 0; foreach ($times as $t) { $ts = (int)$t['occurred_at']; $d = (int)date('N', $ts) - 1; $h = (int)date('G', $ts); $grid[$d][$h]++; if ($grid[$d][$h] > $max) $max = $grid[$d][$h]; } return ['grid' => $grid, 'max' => $max]; } /** * 전조 급증: 최근 7일 전조 건수를 그 이전 4주의 주평균과 비교. 사고가 나기 전에 올리는 * 경보이므로 보수적으로 — 표본이 SPIKE_MIN_COUNT 미만이면 절대 울리지 않는다. */ private static function precursorSpike(array $times, int $now): array { $weekAgo = $now - 7 * 86400; $thisWeek = 0; $baselineTotal = 0; foreach ($times as $t) { if (!in_array($t['event_type'], self::PRECURSOR_TYPES, true)) continue; if ($t['occurred_at'] >= $weekAgo) $thisWeek++; else $baselineTotal++; } $baselineAvg = $baselineTotal / self::SPIKE_BASELINE_WEEKS; $ratio = $baselineAvg > 0 ? $thisWeek / $baselineAvg : null; $alert = $thisWeek >= self::SPIKE_MIN_COUNT && ($baselineAvg == 0.0 || $ratio >= self::SPIKE_RATIO); return ['this_week' => $thisWeek, 'baseline_weekly_avg' => round($baselineAvg, 1), 'ratio' => $ratio !== null ? round($ratio, 2) : null, 'alert' => $alert]; } private static function clampDays(mixed $raw): int { if ($raw === null || $raw === '' || !is_numeric($raw)) return self::DEFAULT_DAYS; return max(1, min(self::MAX_DAYS, (int)$raw)); } }