The operator's daily clicks become durable assets:
Label export (GET /api/export/labels?days=N, JSONL download):
- Every 확인/오탐 click is a human verdict on a detection, and each verdict
carries a blurred 3-frame evidence timeline. The export turns that into a
dataset: one JSON line per event with type/severity/camera/zone/metrics/
spatial, label (confirmed | false_positive | unreviewed), labeled_by, and
snapshot uuids (images fetched via /snap, which re-checks session + site).
ergo_report rows are excluded - statistics are not labelable detections.
- Admin-only ('manage_compliance'): bulk export of detection history is a
compliance decision, not an operator convenience. Site scope applies.
- This is where the deferred P1-C evaluation set will come from for free.
Improvement actions (move_actions + GET/POST /api/actions):
- Record a site fix against a camera ("미끄럼 방지 매트 설치"); the report's
new card compares detection density over EQUAL windows before/after the
fix (capped 30d; under 3 days shows "측정 중" - two days of data is noise,
not a verdict). Improvement shows accent, worsening shows red - the card
reports honestly in both directions, which is exactly what makes the
improvement numbers credible.
- Future timestamps and >1y-old entries are rejected (the comparison could
not be fair). Recording needs edit_zones + CSRF.
Verified: PHPUnit 145 pass (5 new: export perm gate + line shape/labels/
snapshot join, action validation, equal-window effect math, pending state)
+ Chrome real-render (action card with better/worse/pending rows, export
link, no console errors).
78 lines
3.6 KiB
PHP
78 lines
3.6 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
namespace App\Dashboard;
|
|
|
|
use App\Http\{Request, Response};
|
|
use App\Auth\{SessionStore, Rbac};
|
|
use App\Security\Csrf;
|
|
use App\Repository\{ActionRepository, EventRepository};
|
|
|
|
/**
|
|
* Improvement actions (개선 조치): record a site fix against a camera, and let the data
|
|
* answer "did it work?" — the report shows detection density before vs after the fix
|
|
* over equal windows. This is the only path where a safety investment's ROI comes out
|
|
* of the system as a number.
|
|
*/
|
|
final class ActionsController
|
|
{
|
|
private const LIST_LIMIT = 20;
|
|
private const EFFECT_WINDOW_S = 30 * 86400; // 비교 창 상한: 조치 전후 각 30일
|
|
private const EFFECT_MIN_S = 3 * 86400; // 시행 3일 미만이면 "측정 중" — 성급한 결론 방지
|
|
private const MAX_NOTE = 500;
|
|
|
|
public function __construct(
|
|
private ActionRepository $actions, private EventRepository $events,
|
|
private SessionStore $sess
|
|
) {}
|
|
|
|
public function save(Request $req): Response
|
|
{
|
|
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
|
|
if (!Rbac::can((string)$this->sess->get('role'), 'edit_zones')) return Response::json(['error' => 'forbidden'], 403);
|
|
if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) {
|
|
return Response::json(['error' => 'csrf'], 403);
|
|
}
|
|
$b = $req->json();
|
|
$camera = trim((string)($b['camera_id'] ?? ''));
|
|
$note = trim((string)($b['note'] ?? ''));
|
|
if ($camera === '' || mb_strlen($camera) > 64 || $note === '' || mb_strlen($note) > self::MAX_NOTE) {
|
|
return Response::json(['error' => 'invalid_action'], 400);
|
|
}
|
|
$now = time();
|
|
$takenAt = isset($b['taken_at']) && is_numeric($b['taken_at']) ? (int)$b['taken_at'] : $now;
|
|
// 미래 시각·1년 초과 과거는 입력 실수: 전후 비교가 성립하지 않는다.
|
|
if ($takenAt > $now || $takenAt < $now - 365 * 86400) {
|
|
return Response::json(['error' => 'invalid_taken_at'], 400);
|
|
}
|
|
$id = $this->actions->insert([
|
|
'camera_id' => $camera, 'note' => $note, 'taken_at' => $takenAt,
|
|
'created_by' => (string)$this->sess->get('username'),
|
|
]);
|
|
return Response::json(['action_id' => $id], 201);
|
|
}
|
|
|
|
public function json(Request $req): Response
|
|
{
|
|
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
|
|
$scope = $this->sess->get('site_scope');
|
|
$site = is_string($scope) ? $scope : null;
|
|
$now = time();
|
|
$out = [];
|
|
foreach ($this->actions->recent(self::LIST_LIMIT) as $a) {
|
|
// 동일 길이의 전후 창으로만 비교해야 공정하다: 시행 후 경과가 W보다 짧으면
|
|
// 전 창도 그만큼으로 줄인다. 3일 미만은 표본이 아니라 소음 — "측정 중".
|
|
$elapsed = $now - $a['taken_at'];
|
|
$w = min(self::EFFECT_WINDOW_S, $elapsed);
|
|
$effect = null;
|
|
if ($w >= self::EFFECT_MIN_S) {
|
|
$before = $this->events->countForCamera($a['camera_id'],
|
|
(float)($a['taken_at'] - $w), (float)$a['taken_at'], $site);
|
|
$after = $this->events->countForCamera($a['camera_id'],
|
|
(float)$a['taken_at'], (float)($a['taken_at'] + $w), $site);
|
|
$effect = ['before' => $before, 'after' => $after, 'window_days' => (int)round($w / 86400)];
|
|
}
|
|
$out[] = $a + ['effect' => $effect];
|
|
}
|
|
return Response::json(['actions' => $out], 200);
|
|
}
|
|
}
|