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.
99 lines
4.8 KiB
PHP
99 lines
4.8 KiB
PHP
<?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);
|
|
}
|
|
}
|