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:
parent
0ffc41681a
commit
d8cef54fe4
14 changed files with 610 additions and 5 deletions
|
|
@ -1,5 +1,9 @@
|
||||||
# INDEX — 작업 기록 (완료 결과만)
|
# INDEX — 작업 기록 (완료 결과만)
|
||||||
|
|
||||||
|
- **2026-08-07** 안전 운영 리포트 `/report` (주간 안전회의용 인쇄 가능 한 장: 중대 이벤트·
|
||||||
|
TTA(FP 제외)·니어미스 피라미드·오탐율·유형/카메라 순위·인체공학 노출·존 규칙 위반) +
|
||||||
|
이벤트 스트림 유형 필터. PHPUnit 138 + Chrome 실렌더(KPI/바리스트/타일/필터).
|
||||||
|
|
||||||
- **2026-08-06** 인체공학 자세부담 스코어링 (`ergo_report`/INFO, 카메라 단위 익명·스냅샷
|
- **2026-08-06** 인체공학 자세부담 스코어링 (`ergo_report`/INFO, 카메라 단위 익명·스냅샷
|
||||||
미부착): REBA 밴드 노출시간(허리 20~60°/60°+·팔 90°+·웅크림<110°), 사고 상태 트랙 제외,
|
미부착): REBA 밴드 노출시간(허리 20~60°/60°+·팔 90°+·웅크림<110°), 사고 상태 트랙 제외,
|
||||||
검출 공백 dt 상한. 상세뷰 지표 한글 렌더 — `b358822`. pytest 232/PHPUnit 135 + 실렌더.
|
검출 공백 dt 상한. 상세뷰 지표 한글 렌더 — `b358822`. pytest 232/PHPUnit 135 + 실렌더.
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,8 @@
|
||||||
`GET /api/events` · `GET /api/devices` · `GET /api/heatmap/{camera}`(spatial 집계) ·
|
`GET /api/events` · `GET /api/devices` · `GET /api/heatmap/{camera}`(spatial 집계) ·
|
||||||
`GET /api/fp-stats`(오탐율) · `GET/POST /api/zones*` · `POST /login|/logout` ·
|
`GET /api/fp-stats`(오탐율) · `GET/POST /api/zones*` · `POST /login|/logout` ·
|
||||||
`GET /snap/{uuid}` · `POST /events/{id}/ack` · `POST /events/{id}/false-positive`(오탐 태그,
|
`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/Ingest/` HMAC+nonce+rate-limit+replay 방지 · `src/Auth/` argon2id/세션/RBAC/CSRF ·
|
||||||
`src/Dashboard/` 이벤트목록·디바이스상태·존편집·스냅샷프록시 · `src/Repository/` 9종 (인터페이스+PDO)
|
`src/Dashboard/` 이벤트목록·디바이스상태·존편집·스냅샷프록시 · `src/Repository/` 9종 (인터페이스+PDO)
|
||||||
- 프런트: `public/assets/app.js`(이벤트 콘솔 폴링·필터) · `device-tree.js`(장치→카메라 트리 레일) ·
|
- 프런트: `public/assets/app.js`(이벤트 콘솔 폴링·필터) · `device-tree.js`(장치→카메라 트리 레일) ·
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
.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 ---------------------------------------------- */
|
/* ---- Reduced motion ---------------------------------------------- */
|
||||||
@media (prefers-reduced-motion:reduce) {
|
@media (prefers-reduced-motion:reduce) {
|
||||||
*, *::before, *::after { animation-duration:.001ms !important; animation-iteration-count:1 !important; transition-duration:.001ms !important; }
|
*, *::before, *::after { animation-duration:.001ms !important; animation-iteration-count:1 !important; transition-duration:.001ms !important; }
|
||||||
|
|
|
||||||
|
|
@ -400,11 +400,12 @@
|
||||||
// The camera filter is a (device, camera) PAIR, not a bare camera_id: camera ids are free-form
|
// 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
|
// 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.
|
// 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');
|
var moreBtn = document.getElementById('load-more');
|
||||||
|
|
||||||
function matchesFilters(ev) {
|
function matchesFilters(ev) {
|
||||||
if (filters.severity && ev.severity !== filters.severity) return false;
|
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.camera && String(ev.camera_id || '') !== filters.camera) return false;
|
||||||
if (filters.device && String(ev.device_id || '') !== filters.device) return false;
|
if (filters.device && String(ev.device_id || '') !== filters.device) return false;
|
||||||
if (filters.unackedOnly && isAcked(ev)) return false;
|
if (filters.unackedOnly && isAcked(ev)) return false;
|
||||||
|
|
@ -439,7 +440,7 @@
|
||||||
root.textContent = '';
|
root.textContent = '';
|
||||||
root.removeAttribute('aria-busy');
|
root.removeAttribute('aria-busy');
|
||||||
if (shown.length === 0) {
|
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');
|
var empty = document.createElement('div');
|
||||||
empty.className = 'stream-empty';
|
empty.className = 'stream-empty';
|
||||||
empty.textContent = anyFilter
|
empty.textContent = anyFilter
|
||||||
|
|
@ -695,6 +696,8 @@
|
||||||
// Filter bar (client-side) + "더 보기" (server-side cursor).
|
// Filter bar (client-side) + "더 보기" (server-side cursor).
|
||||||
var sevSel = document.getElementById('severity-filter');
|
var sevSel = document.getElementById('severity-filter');
|
||||||
if (sevSel) sevSel.addEventListener('change', function () { filters.severity = sevSel.value; renderList(); });
|
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);
|
if (cameraReset) cameraReset.addEventListener('click', clearCamera);
|
||||||
var unackedChk = document.getElementById('unacked-only');
|
var unackedChk = document.getElementById('unacked-only');
|
||||||
if (unackedChk) unackedChk.addEventListener('change', function () { filters.unackedOnly = unackedChk.checked; renderList(); });
|
if (unackedChk) unackedChk.addEventListener('change', function () { filters.unackedOnly = unackedChk.checked; renderList(); });
|
||||||
|
|
|
||||||
166
server/public/assets/report.js
Normal file
166
server/public/assets/report.js
Normal 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();
|
||||||
|
})();
|
||||||
|
|
@ -46,7 +46,7 @@ use App\Ingest\IngestController;
|
||||||
use App\Api\{ConfigController, HeartbeatController};
|
use App\Api\{ConfigController, HeartbeatController};
|
||||||
use App\Auth\{AuthController, CsrfController};
|
use App\Auth\{AuthController, CsrfController};
|
||||||
use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy, ZoneConfigController,
|
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
|
// 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
|
// 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);
|
$deviceStatus = new DeviceStatusController($devices, $sess);
|
||||||
$heatmap = new HeatmapController($events, $sess);
|
$heatmap = new HeatmapController($events, $sess);
|
||||||
$fpStats = new FpStatsController($events, $sess);
|
$fpStats = new FpStatsController($events, $sess);
|
||||||
|
$report = new ReportController($events, $sess);
|
||||||
|
|
||||||
$render = function (string $view): Response {
|
$render = function (string $view): Response {
|
||||||
ob_start(); require APP_ROOT . '/src/View/' . $view . '.php';
|
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/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/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/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.
|
// 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('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));
|
$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', '/', fn() => $render('events'));
|
||||||
$r->add('GET', '/login', fn() => $render('login'));
|
$r->add('GET', '/login', fn() => $render('login'));
|
||||||
$r->add('GET', '/zones', fn() => $render('zones'));
|
$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->add('GET', '/events/{id}/view', fn(Request $q, array $p) => $render('event_detail'));
|
||||||
|
|
||||||
$r->dispatch(Request::fromGlobals())->send();
|
$r->dispatch(Request::fromGlobals())->send();
|
||||||
|
|
|
||||||
86
server/src/Dashboard/ReportController.php
Normal file
86
server/src/Dashboard/ReportController.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -39,4 +39,13 @@ interface EventRepository
|
||||||
* Rows: {camera_id, event_type, total, fp}.
|
* Rows: {camera_id, event_type, total, fp}.
|
||||||
*/
|
*/
|
||||||
public function fpStats(float $sinceOccurredAt, ?string $site): array;
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -207,6 +207,76 @@ final class PdoEventRepository implements EventRepository
|
||||||
], $st->fetchAll());
|
], $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
|
private static function mapRow(array $row): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,25 @@ render_header('이벤트');
|
||||||
<option value="info">정보</option>
|
<option value="info">정보</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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>
|
<label class="filter-check"><input type="checkbox" id="unacked-only"> 미확인만 보기</label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ if (!function_exists('render_header')) {
|
||||||
// has to pass an extra argument. Login/detail titles match neither -> no tab lit.
|
// has to pass an extra argument. Login/detail titles match neither -> no tab lit.
|
||||||
$navEvents = ($title === '이벤트') ? ' is-active' : '';
|
$navEvents = ($title === '이벤트') ? ' is-active' : '';
|
||||||
$navZones = ($title === '구역 설정') ? ' is-active' : '';
|
$navZones = ($title === '구역 설정') ? ' is-active' : '';
|
||||||
|
$navReport = ($title === '안전 리포트') ? ' is-active' : '';
|
||||||
?>
|
?>
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ko">
|
<html lang="ko">
|
||||||
|
|
@ -64,6 +65,7 @@ window.APP_USER = <?= json_encode($username ?? '', JSON_UNESCAPED_UNICODE | JSON
|
||||||
<nav class="app-nav">
|
<nav class="app-nav">
|
||||||
<a href="<?= view_url('/') ?>" class="app-nav-link<?= $navEvents ?>">이벤트</a>
|
<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('/zones') ?>" class="app-nav-link<?= $navZones ?>">구역 설정</a>
|
||||||
|
<a href="<?= view_url('/report') ?>" class="app-nav-link<?= $navReport ?>">리포트</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="app-session">
|
<div class="app-session">
|
||||||
<?php if ($username !== null): ?>
|
<?php if ($username !== null): ?>
|
||||||
|
|
|
||||||
48
server/src/View/report.php
Normal file
48
server/src/View/report.php
Normal 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();
|
||||||
99
server/tests/ReportTest.php
Normal file
99
server/tests/ReportTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -60,7 +60,7 @@ final class InMemoryEventRepository implements EventRepository {
|
||||||
return array_slice(array_values($r), 0, $limit);
|
return array_slice(array_values($r), 0, $limit);
|
||||||
}
|
}
|
||||||
public function find(int $id): ?array { return $this->rows[$id] ?? null; }
|
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 {
|
public function markFalsePositive(int $id, string $user): bool {
|
||||||
if (!isset($this->rows[$id])) return false;
|
if (!isset($this->rows[$id])) return false;
|
||||||
$r = $this->rows[$id];
|
$r = $this->rows[$id];
|
||||||
|
|
@ -84,6 +84,38 @@ final class InMemoryEventRepository implements EventRepository {
|
||||||
usort($out, fn($a, $b) => $b['fp'] <=> $a['fp']);
|
usort($out, fn($a, $b) => $b['fp'] <=> $a['fp']);
|
||||||
return $out;
|
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 {
|
public function heatmapPoints(string $cameraId, float $since, ?string $site, int $limit): array {
|
||||||
$r = array_filter($this->rows, fn($x) => ($x['camera_id'] ?? '') === $cameraId
|
$r = array_filter($this->rows, fn($x) => ($x['camera_id'] ?? '') === $cameraId
|
||||||
&& ($x['occurred_at'] ?? 0) >= $since
|
&& ($x['occurred_at'] ?? 0) >= $since
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue