동일 어구 이름이 서로 다른 MMSI 로 같은 5분 사이클에 동시 AIS 송출되는 공존 케이스를 신규 탐지 패턴으로 분리해 기록·분류한다. 부수 효과로 fleet_tracker.track_gear_identity 의 PK 충돌로 인한 사이클 실패도 해소. Prediction - algorithms/gear_identity.py: detect_gear_name_collisions + classify_severity - fleet_tracker.py: 공존/교체 분기 분리, UPSERT helper, savepoint 점수 이전 - output/event_generator.py: run_gear_identity_collision_events 추가 - scheduler.py: track_gear_identity 직후 이벤트 승격 호출 Backend (domain/analysis) - GearIdentityCollision 엔티티 + Repository(Specification+stats) - GearIdentityCollisionService (@Transactional readOnly / @Auditable resolve) - GearCollisionController /api/analysis/gear-collisions (list/stats/detail/resolve) - GearCollisionResponse / StatsResponse / ResolveRequest (record) DB - V030__gear_identity_collision.sql: gear_identity_collisions 테이블 + auth_perm_tree 엔트리(detection:gear-collision nav_sort=950) + 역할별 권한 Frontend - shared/constants/gearCollisionStatuses.ts + catalogRegistry 등록 - services/gearCollisionApi.ts (list/stats/get/resolve) - features/detection/GearCollisionDetection.tsx (PageContainer+Section+DataTable + 분류 액션 폼, design system SSOT 준수) - componentRegistry + feature index + i18n detection.json / common.json(ko/en)
115 lines
3.3 KiB
TypeScript
115 lines
3.3 KiB
TypeScript
/**
|
|
* gear_identity_collisions 조회 + 분류 액션 API 서비스.
|
|
* 백엔드 /api/analysis/gear-collisions 연동.
|
|
*/
|
|
|
|
import type { AnalysisPageResponse } from './analysisApi';
|
|
|
|
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
|
|
|
|
// ─── DTO (백엔드 GearCollisionResponse 1:1 매핑) ─────────────
|
|
|
|
export interface GearCollision {
|
|
id: number;
|
|
name: string;
|
|
mmsiLo: string;
|
|
mmsiHi: string;
|
|
parentName: string | null;
|
|
parentVesselId: number | null;
|
|
firstSeenAt: string;
|
|
lastSeenAt: string;
|
|
coexistenceCount: number;
|
|
swapCount: number;
|
|
maxDistanceKm: number | null;
|
|
lastLatLo: number | null;
|
|
lastLonLo: number | null;
|
|
lastLatHi: number | null;
|
|
lastLonHi: number | null;
|
|
severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | string;
|
|
status: 'OPEN' | 'REVIEWED' | 'CONFIRMED_ILLEGAL' | 'FALSE_POSITIVE' | string;
|
|
resolutionNote: string | null;
|
|
evidence: Array<Record<string, unknown>> | null;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export interface GearCollisionStats {
|
|
total: number;
|
|
byStatus: Record<string, number>;
|
|
bySeverity: Record<string, number>;
|
|
hours: number;
|
|
}
|
|
|
|
export type GearCollisionResolveAction =
|
|
| 'REVIEWED'
|
|
| 'CONFIRMED_ILLEGAL'
|
|
| 'FALSE_POSITIVE'
|
|
| 'REOPEN';
|
|
|
|
export interface GearCollisionResolveRequest {
|
|
action: GearCollisionResolveAction;
|
|
note?: string;
|
|
}
|
|
|
|
// ─── 내부 헬퍼 ─────────────
|
|
|
|
function buildQuery(params: Record<string, unknown>): string {
|
|
const qs = new URLSearchParams();
|
|
for (const [k, v] of Object.entries(params)) {
|
|
if (v === undefined || v === null || v === '') continue;
|
|
qs.set(k, String(v));
|
|
}
|
|
const s = qs.toString();
|
|
return s ? `?${s}` : '';
|
|
}
|
|
|
|
async function apiGet<T>(path: string, params: Record<string, unknown> = {}): Promise<T> {
|
|
const res = await fetch(`${API_BASE}${path}${buildQuery(params)}`, { credentials: 'include' });
|
|
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
|
return res.json();
|
|
}
|
|
|
|
async function apiPost<T>(path: string, body: unknown): Promise<T> {
|
|
const res = await fetch(`${API_BASE}${path}`, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
|
return res.json();
|
|
}
|
|
|
|
// ─── 공개 함수 ─────────────
|
|
|
|
/** 어구 정체성 충돌 목록 조회 (필터 + 페이징). */
|
|
export function listGearCollisions(params?: {
|
|
status?: string;
|
|
severity?: string;
|
|
name?: string;
|
|
hours?: number;
|
|
page?: number;
|
|
size?: number;
|
|
}): Promise<AnalysisPageResponse<GearCollision>> {
|
|
return apiGet('/analysis/gear-collisions', {
|
|
hours: 48, page: 0, size: 50, ...params,
|
|
});
|
|
}
|
|
|
|
/** status/severity 집계 */
|
|
export function getGearCollisionStats(hours = 48): Promise<GearCollisionStats> {
|
|
return apiGet('/analysis/gear-collisions/stats', { hours });
|
|
}
|
|
|
|
/** 단건 상세 조회 */
|
|
export function getGearCollision(id: number): Promise<GearCollision> {
|
|
return apiGet(`/analysis/gear-collisions/${id}`);
|
|
}
|
|
|
|
/** 운영자 분류 액션 */
|
|
export function resolveGearCollision(
|
|
id: number,
|
|
body: GearCollisionResolveRequest,
|
|
): Promise<GearCollision> {
|
|
return apiPost(`/analysis/gear-collisions/${id}/resolve`, body);
|
|
}
|