From d8cef54fe446a6d5b0f6c300d3a0d68148b2afc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EC=B0=BD=EC=9A=B1?= Date: Fri, 7 Aug 2026 07:17:48 +0900 Subject: [PATCH] feat(report): safety operations report page + event-type filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/INDEX.md | 4 + docs/PROJECT_MAP.md | 3 +- server/public/assets/app.css | 63 +++++++ server/public/assets/app.js | 7 +- server/public/assets/report.js | 166 ++++++++++++++++++ server/public/index.php | 5 +- server/src/Dashboard/ReportController.php | 86 +++++++++ server/src/Repository/EventRepository.php | 9 + .../src/Repository/Pdo/PdoEventRepository.php | 70 ++++++++ server/src/View/events.php | 19 ++ server/src/View/layout.php | 2 + server/src/View/report.php | 48 +++++ server/tests/ReportTest.php | 99 +++++++++++ server/tests/Support/InMemoryRepositories.php | 34 +++- 14 files changed, 610 insertions(+), 5 deletions(-) create mode 100644 server/public/assets/report.js create mode 100644 server/src/Dashboard/ReportController.php create mode 100644 server/src/View/report.php create mode 100644 server/tests/ReportTest.php diff --git a/docs/INDEX.md b/docs/INDEX.md index e798ffa..03d8e7c 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -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 + 실렌더. diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index 2959515..577c974 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -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`(장치→카메라 트리 레일) · diff --git a/server/public/assets/app.css b/server/public/assets/app.css index ea2f553..a5832e0 100644 --- a/server/public/assets/app.css +++ b/server/public/assets/app.css @@ -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; } diff --git a/server/public/assets/app.js b/server/public/assets/app.js index 3496f15..3e68909 100644 --- a/server/public/assets/app.js +++ b/server/public/assets/app.js @@ -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(); }); diff --git a/server/public/assets/report.js b/server/public/assets/report.js new file mode 100644 index 0000000..ce3ae06 --- /dev/null +++ b/server/public/assets/report.js @@ -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(); +})(); diff --git a/server/public/index.php b/server/public/index.php index 533bc83..3b107d8 100644 --- a/server/public/index.php +++ b/server/public/index.php @@ -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(); diff --git a/server/src/Dashboard/ReportController.php b/server/src/Dashboard/ReportController.php new file mode 100644 index 0000000..f349042 --- /dev/null +++ b/server/src/Dashboard/ReportController.php @@ -0,0 +1,86 @@ +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)); + } +} diff --git a/server/src/Repository/EventRepository.php b/server/src/Repository/EventRepository.php index 56ae28a..211c853 100644 --- a/server/src/Repository/EventRepository.php +++ b/server/src/Repository/EventRepository.php @@ -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; } diff --git a/server/src/Repository/Pdo/PdoEventRepository.php b/server/src/Repository/Pdo/PdoEventRepository.php index 2876810..54076ae 100644 --- a/server/src/Repository/Pdo/PdoEventRepository.php +++ b/server/src/Repository/Pdo/PdoEventRepository.php @@ -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 [ diff --git a/server/src/View/events.php b/server/src/View/events.php index f9af50b..658f698 100644 --- a/server/src/View/events.php +++ b/server/src/View/events.php @@ -26,6 +26,25 @@ render_header('이벤트'); + diff --git a/server/src/View/layout.php b/server/src/View/layout.php index 08645ea..a38ec81 100644 --- a/server/src/View/layout.php +++ b/server/src/View/layout.php @@ -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' : ''; ?> @@ -64,6 +65,7 @@ window.APP_USER = 이벤트 구역 설정 + 리포트
diff --git a/server/src/View/report.php b/server/src/View/report.php new file mode 100644 index 0000000..1404508 --- /dev/null +++ b/server/src/View/report.php @@ -0,0 +1,48 @@ + +
+
+
+

안전 운영 리포트

+

기간 집계 중…

+
+
+ + +
+
+ +
+ +
+
+

유형별 감지

+
+
+
+

카메라별 위험 순위

+
+
+
+

작업자세 부담 노출

+
+
+
+

존 규칙 위반 · 처리 현황

+
+
+
+ +

+
+ +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); + } +} diff --git a/server/tests/Support/InMemoryRepositories.php b/server/tests/Support/InMemoryRepositories.php index 530fc94..fdc4d10 100644 --- a/server/tests/Support/InMemoryRepositories.php +++ b/server/tests/Support/InMemoryRepositories.php @@ -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