feat(report): safety operations report page + event-type filter

The benefit made visible: /report folds everything the system collects into
the one printable page a 안전관리자 takes into the weekly safety meeting.

- GET /api/report?days=N (7/30/90): severity totals, per-type counts,
  worst-camera ranking, response time (TTA - acknowledged critical/high only,
  FP-tagged rows excluded: dismissing a false alarm is not an emergency
  response; clock-skew guarded), the near-miss pyramid (near_fall vs
  fall/no_motion - the Heinrich argument in one ratio), FP rate, summed
  posture-load exposure, occupancy-rule violation counts. ergo_report rows
  feed the sums but are excluded from "감지 N건" (bookkeeping, not detection).
  Fail-closed site scoping throughout.
- /report view: KPI band (중대 이벤트 / 평균 대응 / 니어미스:사고 / 오탐율,
  each with its one-line reading), bar lists for types and cameras (critical
  presence highlighted), ergo bands as share-of-observed-time, rule tiles.
  Print CSS strips the app chrome - the 인쇄 button produces the meeting
  handout. New 리포트 nav tab.
- Events console: event-type filter beside severity - the INFO-volume types
  (near_fall, ergo_report) can now be folded out of the live stream, or a
  single type isolated for investigation.

Verified: PHPUnit 138 pass (3 new: aggregation math incl. TTA/FP-exclusion/
ergo sums, site scope, auth) + Chrome real-render of the full report (KPIs,
bar lists, rule tiles, footer) and the type filter isolating one type.
This commit is contained in:
유창욱 2026-08-07 07:17:48 +09:00
parent 0ffc41681a
commit d8cef54fe4
14 changed files with 610 additions and 5 deletions

View file

@ -1,5 +1,9 @@
# INDEX — 작업 기록 (완료 결과만)
- **2026-08-07** 안전 운영 리포트 `/report` (주간 안전회의용 인쇄 가능 한 장: 중대 이벤트·
TTA(FP 제외)·니어미스 피라미드·오탐율·유형/카메라 순위·인체공학 노출·존 규칙 위반) +
이벤트 스트림 유형 필터. PHPUnit 138 + Chrome 실렌더(KPI/바리스트/타일/필터).
- **2026-08-06** 인체공학 자세부담 스코어링 (`ergo_report`/INFO, 카메라 단위 익명·스냅샷
미부착): REBA 밴드 노출시간(허리 20~60°/60°+·팔 90°+·웅크림<110°), 사고 상태 트랙 제외,
검출 공백 dt 상한. 상세뷰 지표 한글 렌더 — `b358822`. pytest 232/PHPUnit 135 + 실렌더.

View file

@ -33,7 +33,8 @@
`GET /api/events` · `GET /api/devices` · `GET /api/heatmap/{camera}`(spatial 집계) ·
`GET /api/fp-stats`(오탐율) · `GET/POST /api/zones*` · `POST /login|/logout` ·
`GET /snap/{uuid}` · `POST /events/{id}/ack` · `POST /events/{id}/false-positive`(오탐 태그,
ack 겸함) · 페이지 `/`, `/login`, `/zones`, `/events/{id}/view`
ack 겸함) · `GET /api/report`(운영 지표 집계) ·
페이지 `/`, `/login`, `/zones`, `/report`(안전 리포트), `/events/{id}/view`
- `src/Ingest/` HMAC+nonce+rate-limit+replay 방지 · `src/Auth/` argon2id/세션/RBAC/CSRF ·
`src/Dashboard/` 이벤트목록·디바이스상태·존편집·스냅샷프록시 · `src/Repository/` 9종 (인터페이스+PDO)
- 프런트: `public/assets/app.js`(이벤트 콘솔 폴링·필터) · `device-tree.js`(장치→카메라 트리 레일) ·

View file

@ -428,6 +428,69 @@ a.event-thumb-slot:hover { border-color:var(--accent); box-shadow:0 0 0 3px var(
.severity-badge { min-width:0; }
}
/* ---- Safety operations report (/report) --------------------------- */
.report { max-width:960px; margin:0 auto; }
.report-head { display:flex; align-items:flex-end; justify-content:space-between; gap:1rem; margin-bottom:1.1rem; }
.report-head h1 { margin:0; }
.report-range { margin:.3rem 0 0; font-size:.8rem; color:var(--ink-3); font-family:var(--mono); }
.report-controls { display:flex; align-items:flex-end; gap:.6rem; }
/* KPI band — the focal strip: four numbers that summarize the period at a glance. */
.report-kpis { display:grid; grid-template-columns:repeat(4, 1fr); gap:.7rem; margin-bottom:1rem; }
.kpi {
display:flex; flex-direction:column; gap:.18rem; padding:.75rem .85rem;
background:var(--card); border:1px solid var(--line); border-radius:10px; box-shadow:var(--shadow-sm);
}
.kpi-label { font-size:.72rem; font-weight:700; color:var(--ink-3); letter-spacing:.05em; text-transform:uppercase; }
.kpi-value { font-size:1.55rem; font-weight:750; letter-spacing:-.02em; font-variant-numeric:tabular-nums; }
.kpi-note { font-size:.72rem; color:var(--ink-3); }
.kpi.is-severe { background:var(--crit-bg); border-color:var(--crit-line); }
.kpi.is-severe .kpi-value { color:var(--crit-ink); }
.kpi.is-warn .kpi-value { color:var(--warn-ink); }
.report-grid { display:grid; grid-template-columns:1fr 1fr; gap:.9rem; }
.report-card {
padding:.9rem 1rem 1rem; background:var(--card);
border:1px solid var(--line); border-radius:10px; box-shadow:var(--shadow-sm);
}
.report-card h2 { margin:0 0 .75rem; font-size:.85rem; font-weight:750; letter-spacing:.02em; }
.report-empty { margin:.4rem 0 0; font-size:.76rem; color:var(--ink-3); }
.bar-list { display:flex; flex-direction:column; gap:.55rem; }
.bar-head { display:flex; justify-content:space-between; align-items:baseline; gap:.6rem; font-size:.78rem; }
.bar-label { color:var(--ink-2); font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.bar-value { flex:none; font-family:var(--mono); font-size:.73rem; color:var(--ink-3); }
.bar-value.is-hot { color:var(--crit-ink); font-weight:700; }
.bar-track { margin-top:.22rem; height:6px; border-radius:999px; background:#e8ecf1; overflow:hidden; }
.bar-fill { height:100%; border-radius:999px; background:var(--accent); }
.bar-fill.is-hot { background:var(--crit-ink); }
.rule-stats { display:grid; grid-template-columns:repeat(2, 1fr); gap:.6rem; }
.rule-stat {
display:flex; flex-direction:column; gap:.1rem; padding:.6rem .7rem;
border:1px solid var(--line); border-radius:8px; background:var(--panel-2);
}
.rule-stat strong { font-size:1.25rem; font-weight:750; font-variant-numeric:tabular-nums; color:var(--ink-3); }
.rule-stat span { font-size:.72rem; color:var(--ink-3); }
.rule-stat.is-nonzero strong { color:var(--ink); }
.rule-stat.is-nonzero { background:var(--card); }
.report-foot { margin:1.1rem 0 0; font-size:.72rem; color:var(--ink-3); }
@media (max-width:860px) {
.report-kpis { grid-template-columns:repeat(2, 1fr); }
.report-grid { grid-template-columns:1fr; }
}
/* 인쇄: 앱 크롬을 걷어내고 리포트만 A4에 얹는다 — 주간 안전회의 배포물이 목적. */
@media print {
.app-header, .report-controls { display:none !important; }
body { background:#fff; }
.app-main { padding:0; }
.kpi, .report-card, .rule-stat { box-shadow:none; }
.report-grid, .report-kpis { break-inside:avoid; }
}
/* ---- Reduced motion ---------------------------------------------- */
@media (prefers-reduced-motion:reduce) {
*, *::before, *::after { animation-duration:.001ms !important; animation-iteration-count:1 !important; transition-duration:.001ms !important; }

View file

@ -400,11 +400,12 @@
// The camera filter is a (device, camera) PAIR, not a bare camera_id: camera ids are free-form
// strings from the edge, so two devices may both run a "cam-01". Selecting the pair is what the
// rail tree hands us, and it keeps one device's events out of another's filtered view.
var filters = { severity: '', device: '', camera: '', unackedOnly: false };
var filters = { severity: '', type: '', device: '', camera: '', unackedOnly: false };
var moreBtn = document.getElementById('load-more');
function matchesFilters(ev) {
if (filters.severity && ev.severity !== filters.severity) return false;
if (filters.type && ev.event_type !== filters.type) return false;
if (filters.camera && String(ev.camera_id || '') !== filters.camera) return false;
if (filters.device && String(ev.device_id || '') !== filters.device) return false;
if (filters.unackedOnly && isAcked(ev)) return false;
@ -439,7 +440,7 @@
root.textContent = '';
root.removeAttribute('aria-busy');
if (shown.length === 0) {
var anyFilter = filters.severity || filters.camera || filters.unackedOnly;
var anyFilter = filters.severity || filters.type || filters.camera || filters.unackedOnly;
var empty = document.createElement('div');
empty.className = 'stream-empty';
empty.textContent = anyFilter
@ -695,6 +696,8 @@
// Filter bar (client-side) + "더 보기" (server-side cursor).
var sevSel = document.getElementById('severity-filter');
if (sevSel) sevSel.addEventListener('change', function () { filters.severity = sevSel.value; renderList(); });
var typeSel = document.getElementById('type-filter');
if (typeSel) typeSel.addEventListener('change', function () { filters.type = typeSel.value; renderList(); });
if (cameraReset) cameraReset.addEventListener('click', clearCamera);
var unackedChk = document.getElementById('unacked-only');
if (unackedChk) unackedChk.addEventListener('change', function () { filters.unackedOnly = unackedChk.checked; renderList(); });

View file

@ -0,0 +1,166 @@
/**
* Safety operations report: fetches GET /api/report?days=N and renders the management
* indicators a 안전관리자 takes into the weekly safety meeting. Read-only; the 인쇄 button
* prints the page (print CSS strips the app chrome).
*
* XSS-safe: every dynamic value goes through textContent; the only inline style is a
* numeric bar width.
*/
(function () {
'use strict';
var B = window.APP_BASE || '';
var TYPE_LABELS = {
fall: '넘어짐', no_motion: '넘어짐 후 미동 없음', immobility: '장시간 부동',
ghost: '위험 상태 소실', zone_warning: '경고구역 진입', zone_restricted: '제한구역 침입',
pinch: '협착구역 침입', monitoring_degraded: '감시 성능 저하', device_fault: '장치 이상',
lone_worker: '위험구역 단독 작업', overcapacity: '정원 초과', overstay: '체류 상한 초과',
near_fall: '넘어질 뻔함 (니어미스)', ergo_report: '작업자세 부담 리포트'
};
var root = document.getElementById('report-kpis');
if (!root) return;
var daysSel = document.getElementById('report-days');
var printBtn = document.getElementById('report-print');
var rangeEl = document.getElementById('report-range');
var typesEl = document.getElementById('report-types');
var camerasEl = document.getElementById('report-cameras');
var ergoEl = document.getElementById('report-ergo');
var rulesEl = document.getElementById('report-rules');
var footEl = document.getElementById('report-foot');
function el(tag, cls, text) {
var n = document.createElement(tag);
if (cls) n.className = cls;
if (text !== undefined && text !== null) n.textContent = text;
return n;
}
function fmtDuration(s) {
if (s === null || s === undefined) return '—';
if (s < 60) return Math.round(s) + '초';
if (s < 3600) return Math.floor(s / 60) + '분 ' + Math.round(s % 60) + '초';
return Math.floor(s / 3600) + '시간 ' + Math.floor((s % 3600) / 60) + '분';
}
function fmtHours(s) {
if (!s) return '0분';
if (s < 3600) return Math.round(s / 60) + '분';
return (s / 3600).toFixed(1) + '시간';
}
// KPI tile: the headline number plus the one-line reading of what it means.
function kpi(label, value, note, cls) {
var t = el('div', 'kpi' + (cls ? ' ' + cls : ''));
t.appendChild(el('span', 'kpi-label', label));
t.appendChild(el('strong', 'kpi-value', value));
if (note) t.appendChild(el('span', 'kpi-note', note));
return t;
}
function barRow(label, value, max, text, hot) {
var row = el('div', 'bar-row');
var head = el('div', 'bar-head');
head.appendChild(el('span', 'bar-label', label));
head.appendChild(el('span', 'bar-value' + (hot ? ' is-hot' : ''), text));
row.appendChild(head);
var track = el('div', 'bar-track');
var fill = el('div', 'bar-fill' + (hot ? ' is-hot' : ''));
fill.style.width = (max > 0 ? Math.max(2, Math.round(value / max * 100)) : 0) + '%';
track.appendChild(fill);
row.appendChild(track);
return row;
}
function render(r) {
var t = r.totals;
var severe = t.critical + t.high;
rangeEl.textContent = '최근 ' + r.days + '일 · '
+ new Date((r.generated_at - r.days * 86400) * 1000).toLocaleDateString('ko-KR')
+ ' ~ ' + new Date(r.generated_at * 1000).toLocaleDateString('ko-KR');
root.textContent = '';
root.appendChild(kpi('중대 이벤트', String(severe),
'위급 ' + t.critical + ' · 높음 ' + t.high, severe > 0 ? 'is-severe' : ''));
root.appendChild(kpi('평균 대응 시간', fmtDuration(r.tta.avg_s),
r.tta.acked > 0 ? '중대 ' + r.tta.acked + '건 확인 · 최장 ' + fmtDuration(r.tta.max_s) : '확인된 중대 이벤트 없음'));
// 하인리히 지표: 니어미스가 사고보다 얼마나 앞서 잡히고 있나 — 예방이 작동한다는 증거.
var nm = r.near_miss;
root.appendChild(kpi('니어미스 : 사고', nm.near_fall + ' : ' + nm.incidents,
nm.near_fall > 0 ? '사고 전 신호가 먼저 잡히고 있습니다' : '기록된 니어미스 없음'));
var fpPct = r.fp.total > 0 ? Math.round(r.fp.tagged / r.fp.total * 100) : 0;
root.appendChild(kpi('오탐율', fpPct + '%',
'전체 ' + r.fp.total + '건 중 오탐 태그 ' + r.fp.tagged + '건', fpPct >= 30 ? 'is-warn' : ''));
typesEl.textContent = '';
var typeKeys = Object.keys(r.by_type);
if (typeKeys.length === 0) typesEl.appendChild(el('p', 'report-empty', '기간 내 감지가 없습니다.'));
var maxType = typeKeys.reduce(function (m, k) { return Math.max(m, r.by_type[k]); }, 0);
typeKeys.forEach(function (k) {
typesEl.appendChild(barRow(TYPE_LABELS[k] || k, r.by_type[k], maxType, r.by_type[k] + '건'));
});
camerasEl.textContent = '';
if (r.by_camera.length === 0) camerasEl.appendChild(el('p', 'report-empty', '기간 내 감지가 없습니다.'));
var maxCam = r.by_camera.reduce(function (m, c) { return Math.max(m, c.count); }, 0);
r.by_camera.forEach(function (c) {
camerasEl.appendChild(barRow(c.camera_id, c.count, maxCam,
c.count + '건 · 중대 ' + c.critical_high, c.critical_high > 0));
});
// 인체공학: 관측 시간 대비 각 밴드 노출 — 절대시간과 비율을 함께.
ergoEl.textContent = '';
var eg = r.ergo;
if (!eg.observed_s) {
ergoEl.appendChild(el('p', 'report-empty', '기간 내 자세 관측 데이터가 없습니다.'));
} else {
[['trunk_high_s', '허리 고위험 (60°+)', true],
['trunk_mod_s', '허리 중위험 (20~60°)', false],
['arm_high_s', '팔 거상 (90°+)', false],
['squat_s', '깊은 웅크림', false]].forEach(function (row) {
var v = eg[row[0]] || 0;
var pct = Math.round(v / eg.observed_s * 100);
ergoEl.appendChild(barRow(row[1], v, eg.observed_s,
fmtHours(v) + ' (' + pct + '%)', row[2] && pct >= 20));
});
ergoEl.appendChild(el('p', 'report-empty', '총 관측 ' + fmtHours(eg.observed_s)
+ ' — 비율은 관측 시간 대비입니다.'));
}
rulesEl.textContent = '';
var oc = r.occupancy;
var rules = el('div', 'rule-stats');
[['단독 작업', oc.lone_worker], ['정원 초과', oc.overcapacity], ['체류 상한 초과', oc.overstay],
['미확인 잔여', t.unacked]].forEach(function (pair) {
var box = el('div', 'rule-stat' + (pair[1] > 0 ? ' is-nonzero' : ''));
box.appendChild(el('strong', null, String(pair[1])));
box.appendChild(el('span', null, pair[0]));
rules.appendChild(box);
});
rulesEl.appendChild(rules);
footEl.textContent = '생성 ' + new Date(r.generated_at * 1000).toLocaleString('ko-KR')
+ ' · 본 리포트는 AI 감지 기반 보조 지표이며 공식 재해 통계를 대체하지 않습니다.';
}
function load() {
var days = daysSel ? daysSel.value : '7';
fetch(B + '/api/report?days=' + encodeURIComponent(days), { headers: { Accept: 'application/json' } })
.then(function (res) {
if (res.status === 401) { window.location.href = B + '/login'; return null; }
if (!res.ok) throw new Error('report_failed:' + res.status);
return res.json();
})
.then(function (data) { if (data) render(data); })
.catch(function () {
rangeEl.textContent = '리포트를 불러오지 못했습니다. 새로고침해 주세요.';
});
}
if (daysSel) daysSel.addEventListener('change', load);
if (printBtn) printBtn.addEventListener('click', function () { window.print(); });
load();
})();

View file

@ -46,7 +46,7 @@ use App\Ingest\IngestController;
use App\Api\{ConfigController, HeartbeatController};
use App\Auth\{AuthController, CsrfController};
use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy, ZoneConfigController,
DeviceStatusController, HeatmapController, FpStatsController};
DeviceStatusController, HeatmapController, FpStatsController, ReportController};
// Never leak internals to the client. Cafe24 shared hosting often ships display_errors=On, so
// an uncaught PDOException would otherwise render the DSN + db_user in a raw 500. Turn it off
@ -97,6 +97,7 @@ $zoneCtl = new ZoneConfigController($zones, $devices, $audit, $sess, $snaps);
$deviceStatus = new DeviceStatusController($devices, $sess);
$heatmap = new HeatmapController($events, $sess);
$fpStats = new FpStatsController($events, $sess);
$report = new ReportController($events, $sess);
$render = function (string $view): Response {
ob_start(); require APP_ROOT . '/src/View/' . $view . '.php';
@ -118,6 +119,7 @@ $r->add('POST', '/events/{id}/false-positive', fn(Request $q, array $p) => $deta
$r->add('GET', '/api/zones/{camera}', fn(Request $q, array $p) => $zoneCtl->list($q, $p));
$r->add('GET', '/api/heatmap/{camera}', fn(Request $q, array $p) => $heatmap->json($q, $p));
$r->add('GET', '/api/fp-stats', fn(Request $q) => $fpStats->json($q));
$r->add('GET', '/api/report', fn(Request $q) => $report->json($q));
// Background frame for the zone editor: the newest snapshot this camera produced.
$r->add('GET', '/api/cameras/{camera}/latest-snapshot', fn(Request $q, array $p) => $zoneCtl->latestSnapshot($q, $p));
$r->add('POST', '/api/zones', fn(Request $q) => $zoneCtl->save($q));
@ -126,6 +128,7 @@ $r->add('GET', '/snap/{uuid}', fn(Request $q, array $p) => $proxy->serve($q, $p)
$r->add('GET', '/', fn() => $render('events'));
$r->add('GET', '/login', fn() => $render('login'));
$r->add('GET', '/zones', fn() => $render('zones'));
$r->add('GET', '/report', fn() => $render('report'));
$r->add('GET', '/events/{id}/view', fn(Request $q, array $p) => $render('event_detail'));
$r->dispatch(Request::fromGlobals())->send();

View file

@ -0,0 +1,86 @@
<?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'];
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;
$s = $this->events->reportSummary((float)(time() - $days * 86400), $site, self::TOP_CAMERAS);
$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);
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,
], 200);
}
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));
}
}

View file

@ -39,4 +39,13 @@ interface EventRepository
* Rows: {camera_id, event_type, total, fp}.
*/
public function fpStats(float $sinceOccurredAt, ?string $site): array;
/**
* Aggregations for the safety operations report, all over the same window + site scope:
* - by_type: [{event_type, severity, count, unacked, fp}]
* - by_camera: [{camera_id, count, critical_high}] worst-first, top $topCameras
* - tta: {acked, avg_s, max_s} for acknowledged critical+high (FP-tagged rows excluded —
* a dismissed false alarm is not an emergency response)
* - ergo_metrics: raw metrics arrays of ergo_report rows (caller sums)
*/
public function reportSummary(float $sinceOccurredAt, ?string $site, int $topCameras): array;
}

View file

@ -207,6 +207,76 @@ final class PdoEventRepository implements EventRepository
], $st->fetchAll());
}
public function reportSummary(float $sinceOccurredAt, ?string $site, int $topCameras): array
{
$sinceStr = sprintf('%.6f', $sinceOccurredAt);
$scope = $site !== null ? ' AND site = ?' : '';
$scopeBind = $site !== null ? [$site] : [];
$byType = $this->pdo->prepare(
"SELECT event_type, severity, COUNT(*) AS count,
SUM(acknowledged_by IS NULL) AS unacked,
SUM(false_positive_by IS NOT NULL) AS fp
FROM move_events WHERE occurred_at >= ?$scope
GROUP BY event_type, severity"
);
$byType->execute([$sinceStr, ...$scopeBind]);
$byCamera = $this->pdo->prepare(
"SELECT camera_id, COUNT(*) AS count,
SUM(severity IN ('critical','high')) AS critical_high
FROM move_events WHERE occurred_at >= ?$scope
GROUP BY camera_id ORDER BY critical_high DESC, count DESC LIMIT ?"
);
$pos = 1;
$byCamera->bindValue($pos++, $sinceStr, PDO::PARAM_STR);
foreach ($scopeBind as $v) $byCamera->bindValue($pos++, $v, PDO::PARAM_STR);
$byCamera->bindValue($pos, $topCameras, PDO::PARAM_INT);
$byCamera->execute();
// Response time counts only real emergencies someone actually confirmed: critical/high,
// acknowledged, not FP-tagged (dismissing a false alarm is not an emergency response).
// acknowledged_at >= occurred_at guards clock-skewed rows from producing negative TTA.
$tta = $this->pdo->prepare(
"SELECT COUNT(*) AS acked,
AVG(acknowledged_at - occurred_at) AS avg_s,
MAX(acknowledged_at - occurred_at) AS max_s
FROM move_events
WHERE occurred_at >= ?$scope AND severity IN ('critical','high')
AND acknowledged_at IS NOT NULL AND false_positive_by IS NULL
AND acknowledged_at >= occurred_at"
);
$tta->execute([$sinceStr, ...$scopeBind]);
$ttaRow = $tta->fetch() ?: ['acked' => 0, 'avg_s' => null, 'max_s' => null];
$ergo = $this->pdo->prepare(
"SELECT metrics_json FROM move_events
WHERE occurred_at >= ?$scope AND event_type = 'ergo_report'
ORDER BY occurred_at DESC LIMIT 2000"
);
$ergo->execute([$sinceStr, ...$scopeBind]);
return [
'by_type' => array_map(static fn ($r) => [
'event_type' => $r['event_type'], 'severity' => $r['severity'],
'count' => (int)$r['count'], 'unacked' => (int)$r['unacked'], 'fp' => (int)$r['fp'],
], $byType->fetchAll()),
'by_camera' => array_map(static fn ($r) => [
'camera_id' => $r['camera_id'], 'count' => (int)$r['count'],
'critical_high' => (int)$r['critical_high'],
], $byCamera->fetchAll()),
'tta' => [
'acked' => (int)$ttaRow['acked'],
'avg_s' => $ttaRow['avg_s'] !== null ? (float)$ttaRow['avg_s'] : null,
'max_s' => $ttaRow['max_s'] !== null ? (float)$ttaRow['max_s'] : null,
],
'ergo_metrics' => array_map(
static fn ($r) => json_decode((string)$r['metrics_json'], true) ?: [],
$ergo->fetchAll()
),
];
}
private static function mapRow(array $row): array
{
return [

View file

@ -26,6 +26,25 @@ render_header('이벤트');
<option value="info">정보</option>
</select>
</label>
<label class="field">유형
<select id="type-filter" aria-label="이벤트 유형 필터">
<option value="">전체 유형</option>
<option value="fall">넘어짐</option>
<option value="no_motion">넘어짐 후 미동 없음</option>
<option value="immobility">장시간 부동</option>
<option value="ghost">위험 상태 소실</option>
<option value="zone_warning">경고구역 진입</option>
<option value="zone_restricted">제한구역 침입</option>
<option value="pinch">협착구역 침입</option>
<option value="lone_worker">위험구역 단독 작업</option>
<option value="overcapacity">정원 초과</option>
<option value="overstay">체류 상한 초과</option>
<option value="near_fall">넘어질 뻔함 (니어미스)</option>
<option value="ergo_report">작업자세 부담 리포트</option>
<option value="monitoring_degraded">감시 성능 저하</option>
<option value="device_fault">장치 이상</option>
</select>
</label>
<label class="filter-check"><input type="checkbox" id="unacked-only"> 미확인만 보기</label>
</div>
</section>

View file

@ -30,6 +30,7 @@ if (!function_exists('render_header')) {
// has to pass an extra argument. Login/detail titles match neither -> no tab lit.
$navEvents = ($title === '이벤트') ? ' is-active' : '';
$navZones = ($title === '구역 설정') ? ' is-active' : '';
$navReport = ($title === '안전 리포트') ? ' is-active' : '';
?>
<!doctype html>
<html lang="ko">
@ -64,6 +65,7 @@ window.APP_USER = <?= json_encode($username ?? '', JSON_UNESCAPED_UNICODE | JSON
<nav class="app-nav">
<a href="<?= view_url('/') ?>" class="app-nav-link<?= $navEvents ?>">이벤트</a>
<a href="<?= view_url('/zones') ?>" class="app-nav-link<?= $navZones ?>">구역 설정</a>
<a href="<?= view_url('/report') ?>" class="app-nav-link<?= $navReport ?>">리포트</a>
</nav>
<div class="app-session">
<?php if ($username !== null): ?>

View file

@ -0,0 +1,48 @@
<?php
require __DIR__ . '/layout.php';
render_header('안전 리포트');
?>
<div class="report">
<header class="report-head">
<div>
<h1>안전 운영 리포트</h1>
<p id="report-range" class="report-range">기간 집계 중…</p>
</div>
<div class="report-controls">
<label class="field">기간
<select id="report-days" aria-label="리포트 기간">
<option value="7">최근 7일</option>
<option value="30">최근 30일</option>
<option value="90">최근 90일</option>
</select>
</label>
<button type="button" id="report-print">인쇄</button>
</div>
</header>
<div id="report-kpis" class="report-kpis" aria-label="핵심 지표"></div>
<div class="report-grid">
<section class="report-card">
<h2>유형별 감지</h2>
<div id="report-types" class="bar-list"></div>
</section>
<section class="report-card">
<h2>카메라별 위험 순위</h2>
<div id="report-cameras" class="bar-list"></div>
</section>
<section class="report-card">
<h2>작업자세 부담 노출</h2>
<div id="report-ergo" class="bar-list"></div>
</section>
<section class="report-card">
<h2>존 규칙 위반 · 처리 현황</h2>
<div id="report-rules"></div>
</section>
</div>
<p id="report-foot" class="report-foot"></p>
</div>
<script src="<?= view_url('/assets/report.js') ?>"></script>
<?php
render_footer();

View file

@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Http\Request;
use App\Auth\ArraySessionStore;
use App\Dashboard\ReportController;
use App\Tests\Support\InMemoryEventRepository;
use PHPUnit\Framework\TestCase;
final class ReportTest extends TestCase
{
private function sess(?string $site = null): ArraySessionStore
{
$s = new ArraySessionStore();
$s->set('user_id', 1);
if ($site !== null) $s->set('site_scope', $site);
return $s;
}
private function repo(): InMemoryEventRepository
{
$r = new InMemoryEventRepository();
$now = (float)time();
// 확인까지 60초/120초 걸린 중대 이벤트 2건 (TTA 평균 90초)
$a = $r->insert(['camera_id' => 'cam-1', 'event_type' => 'fall', 'severity' => 'high',
'site' => 'a', 'occurred_at' => $now - 600, 'metrics' => []]);
$r->rows[$a]['acknowledged_by'] = 'u'; $r->rows[$a]['acknowledged_at'] = ($now - 600) + 60;
$b = $r->insert(['camera_id' => 'cam-1', 'event_type' => 'no_motion', 'severity' => 'critical',
'site' => 'a', 'occurred_at' => $now - 500, 'metrics' => []]);
$r->rows[$b]['acknowledged_by'] = 'u'; $r->rows[$b]['acknowledged_at'] = ($now - 500) + 120;
// FP 태그된 중대 이벤트 — TTA에서 제외되어야 한다
$c = $r->insert(['camera_id' => 'cam-2', 'event_type' => 'fall', 'severity' => 'high',
'site' => 'a', 'occurred_at' => $now - 400, 'metrics' => []]);
$r->markFalsePositive($c, 'u');
// 니어미스 1건, 미확인
$r->insert(['camera_id' => 'cam-1', 'event_type' => 'near_fall', 'severity' => 'info',
'site' => 'a', 'occurred_at' => $now - 300, 'metrics' => []]);
// 존 규칙 위반
$r->insert(['camera_id' => 'cam-2', 'event_type' => 'lone_worker', 'severity' => 'high',
'site' => 'a', 'occurred_at' => $now - 200, 'metrics' => []]);
// ergo 리포트 2건 — 합산되고, "감지 N건"에는 포함되지 않아야 한다
$r->insert(['camera_id' => 'cam-1', 'event_type' => 'ergo_report', 'severity' => 'info',
'site' => 'a', 'occurred_at' => $now - 100,
'metrics' => ['observed_s' => 1000.0, 'trunk_high_s' => 400.0]]);
$r->insert(['camera_id' => 'cam-1', 'event_type' => 'ergo_report', 'severity' => 'info',
'site' => 'a', 'occurred_at' => $now - 50,
'metrics' => ['observed_s' => 500.0, 'trunk_high_s' => 100.0, 'squat_s' => 30.0]]);
// 다른 사이트의 이벤트 — 스코프 밖
$r->insert(['camera_id' => 'cam-9', 'event_type' => 'fall', 'severity' => 'high',
'site' => 'b', 'occurred_at' => $now - 100, 'metrics' => []]);
return $r;
}
private function get(ReportController $c, string $days = '7'): array
{
$res = $c->json(new Request('GET', '/api/report', [], '', ['days' => $days], []));
$this->assertSame(200, $res->status());
return json_decode($res->body(), true);
}
public function testRequiresLogin(): void
{
$c = new ReportController($this->repo(), new ArraySessionStore());
$this->assertSame(401, $c->json(new Request('GET', '/api/report', [], '', [], []))->status());
}
public function testAggregatesManagementIndicators(): void
{
$data = $this->get(new ReportController($this->repo(), $this->sess()));
$this->assertSame(7, $data['days']);
// 감지 총계: ergo_report 2건 제외, 사이트 b 포함(전역 스코프) → 6건
$this->assertSame(6, $data['totals']['events']);
$this->assertSame(1, $data['totals']['critical']);
$this->assertSame(4, $data['totals']['high']);
// TTA: FP 제외한 확인된 중대 2건, 평균 90초
$this->assertSame(2, $data['tta']['acked']);
$this->assertEquals(90.0, $data['tta']['avg_s']);
$this->assertEquals(120.0, $data['tta']['max_s']);
// 니어미스 피라미드와 규칙 위반
$this->assertSame(1, $data['near_miss']['near_fall']);
$this->assertSame(1, $data['occupancy']['lone_worker']);
// 오탐 1건
$this->assertSame(1, $data['fp']['tagged']);
// ergo 합산 (건수 아님)
$this->assertEquals(1500.0, $data['ergo']['observed_s']);
$this->assertEquals(500.0, $data['ergo']['trunk_high_s']);
$this->assertEquals(30.0, $data['ergo']['squat_s']);
$this->assertArrayNotHasKey('ergo_report', $data['by_type']);
}
public function testSiteScopeFiltersReport(): void
{
$data = $this->get(new ReportController($this->repo(), $this->sess('a')));
$this->assertSame(5, $data['totals']['events']); // 사이트 b의 fall 제외
$cams = array_column($data['by_camera'], 'camera_id');
$this->assertNotContains('cam-9', $cams);
}
}

View file

@ -60,7 +60,7 @@ final class InMemoryEventRepository implements EventRepository {
return array_slice(array_values($r), 0, $limit);
}
public function find(int $id): ?array { return $this->rows[$id] ?? null; }
public function acknowledge(int $id, string $u): bool { if (!isset($this->rows[$id])) return false; $this->rows[$id]['acknowledged_by'] = $u; return true; }
public function acknowledge(int $id, string $u): bool { if (!isset($this->rows[$id])) return false; $this->rows[$id]['acknowledged_by'] = $u; $this->rows[$id]['acknowledged_at'] = $this->rows[$id]['acknowledged_at'] ?? time(); return true; }
public function markFalsePositive(int $id, string $user): bool {
if (!isset($this->rows[$id])) return false;
$r = $this->rows[$id];
@ -84,6 +84,38 @@ final class InMemoryEventRepository implements EventRepository {
usort($out, fn($a, $b) => $b['fp'] <=> $a['fp']);
return $out;
}
public function reportSummary(float $since, ?string $site, int $topCameras): array {
$rows = array_filter($this->rows, fn($r) => ($r['occurred_at'] ?? 0) >= $since
&& ($site === null || ($r['site'] ?? null) === $site));
$byType = []; $byCam = []; $ttaDeltas = []; $ergo = [];
foreach ($rows as $r) {
$tk = ($r['event_type'] ?? '') . '|' . ($r['severity'] ?? '');
$byType[$tk] = $byType[$tk] ?? ['event_type' => $r['event_type'], 'severity' => $r['severity'], 'count' => 0, 'unacked' => 0, 'fp' => 0];
$byType[$tk]['count']++;
if (($r['acknowledged_by'] ?? null) === null) $byType[$tk]['unacked']++;
if (($r['false_positive_by'] ?? null) !== null) $byType[$tk]['fp']++;
$cam = $r['camera_id'] ?? '';
$byCam[$cam] = $byCam[$cam] ?? ['camera_id' => $cam, 'count' => 0, 'critical_high' => 0];
$byCam[$cam]['count']++;
if (in_array($r['severity'] ?? '', ['critical', 'high'], true)) $byCam[$cam]['critical_high']++;
if (in_array($r['severity'] ?? '', ['critical', 'high'], true)
&& ($r['acknowledged_at'] ?? null) !== null && ($r['false_positive_by'] ?? null) === null
&& $r['acknowledged_at'] >= ($r['occurred_at'] ?? 0)) {
$ttaDeltas[] = $r['acknowledged_at'] - $r['occurred_at'];
}
if (($r['event_type'] ?? '') === 'ergo_report') $ergo[] = $r['metrics'] ?? [];
}
$cams = array_values($byCam);
usort($cams, fn($a, $b) => [$b['critical_high'], $b['count']] <=> [$a['critical_high'], $a['count']]);
return [
'by_type' => array_values($byType),
'by_camera' => array_slice($cams, 0, $topCameras),
'tta' => ['acked' => count($ttaDeltas),
'avg_s' => $ttaDeltas ? array_sum($ttaDeltas) / count($ttaDeltas) : null,
'max_s' => $ttaDeltas ? max($ttaDeltas) : null],
'ergo_metrics' => $ergo,
];
}
public function heatmapPoints(string $cameraId, float $since, ?string $site, int $limit): array {
$r = array_filter($this->rows, fn($x) => ($x['camera_id'] ?? '') === $cameraId
&& ($x['occurred_at'] ?? 0) >= $since