The accumulated data starts predicting instead of just recording: - Time pattern (weekday x hour density grid): WHEN the site is dangerous, on one card - shift-change, post-lunch, Thursday-afternoon hotspots become visible. Bucketing runs in PHP on the server-local clock (site timezone) so the SQL stays portable (the sqlite harness has no FROM_UNIXTIME). - Precursor-spike alert: last-7-days count of precursor signals (near-miss, zone entries, occupancy violations - signals, not injuries) vs the prior 4-week weekly baseline. Ratio >= 1.5 with at least 5 samples raises a banner above the KPIs: an alarm on the PRECURSOR of an accident, with a concrete suggestion (staffing/route/rest review). Below the sample floor it never fires - a ratio over 2 events is noise, not statistics. - Period-over-period trends on every KPI: reportSummary gained an optional upper bound so the identical aggregation runs over the preceding window. Worsening turns red, improving turns accent - except the near-miss count, which stays NEUTRAL: more near-misses may mean better detection, not more danger, and the report must not editorialize that. Verified: PHPUnit 140 pass (2 new: window separation + pattern bucketing, spike ratio + sample floor) + Chrome real-render (alert banner, KPI trend lines, Thursday-afternoon hotspot readable in the grid).
162 lines
7.8 KiB
PHP
162 lines
7.8 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
namespace App\Dashboard;
|
|
|
|
use App\Http\{Request, Response};
|
|
use App\Auth\SessionStore;
|
|
use App\Repository\EventRepository;
|
|
|
|
/**
|
|
* GET /api/report?days=N — the safety operations report: the numbers a 안전관리자 takes
|
|
* into the weekly safety meeting. Everything the system collects, folded into management
|
|
* indicators over one window: incident counts, response time, the near-miss pyramid,
|
|
* false-positive rate, posture-load exposure, occupancy-rule violations.
|
|
*/
|
|
final class ReportController
|
|
{
|
|
private const DEFAULT_DAYS = 7;
|
|
private const MAX_DAYS = 90;
|
|
private const TOP_CAMERAS = 8;
|
|
private const OCCUPANCY_TYPES = ['lone_worker', 'overcapacity', 'overstay'];
|
|
private const ERGO_KEYS = ['observed_s', 'trunk_high_s', 'trunk_mod_s', 'arm_high_s', 'squat_s'];
|
|
// 전조(precursor) 그룹: 부상 없이 끝났지만 사고로 이어질 수 있던 신호 — 급증 경보와
|
|
// 시간 패턴의 대상. 사고 자체(fall/no_motion/ghost/immobility/pinch)는 제외.
|
|
private const PRECURSOR_TYPES = ['near_fall', 'zone_warning', 'zone_restricted',
|
|
'lone_worker', 'overcapacity', 'overstay'];
|
|
private const SPIKE_BASELINE_WEEKS = 4;
|
|
private const SPIKE_MIN_COUNT = 5; // 이보다 적으면 통계가 아니라 소음
|
|
private const SPIKE_RATIO = 1.5;
|
|
private const PATTERN_LIMIT = 20000;
|
|
|
|
public function __construct(private EventRepository $events, private SessionStore $sess) {}
|
|
|
|
public function json(Request $req): Response
|
|
{
|
|
if ($this->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));
|
|
}
|
|
}
|