diff --git a/backend/src/main/resources/db/migration/V033__menu_transshipment_detection.sql b/backend/src/main/resources/db/migration/V033__menu_transshipment_detection.sql new file mode 100644 index 0000000..aa3fc4b --- /dev/null +++ b/backend/src/main/resources/db/migration/V033__menu_transshipment_detection.sql @@ -0,0 +1,46 @@ +-- V033: 환적 의심 탐지 (TransshipmentDetection) 메뉴·권한 seed +-- +-- prediction `algorithms/transshipment.py` 의 5단계 필터 파이프라인 결과 +-- (is_transship_suspect=true) 를 전체 목록·집계·상세 수준으로 조회하는 READ 전용 +-- 대시보드. 기존 features/vessel/TransferDetection.tsx 는 선박 상세 수준이고, +-- 이 페이지는 전체 목록·통계 운영 대시보드. +-- +-- 참고: 실제 API `/api/analysis/transship` 는 VesselAnalysisController 에서 권한을 +-- `detection:dark-vessel` 로 가드 중이므로, 이 메뉴 READ 만으로는 API 호출 불가. +-- 현재 운영자(OPERATOR/ANALYST/FIELD) 는 양쪽 READ 를 모두 가지므로 실용상 문제 없음. +-- 향후 VesselAnalysisController.listTransshipSuspects 의 @RequirePermission 을 +-- `detection:transshipment` 로 교체하는 것이 권한 일관성상 바람직 (별도 MR). +-- +-- Phase 0-3: prediction-analysis.md P1 권고의 "UI 미노출 탐지" 해소 중 두 번째. + +-- ────────────────────────────────────────────────────────────────── +-- 1. 권한 트리 / 메뉴 슬롯 (detection 그룹 평탄화: parent_cd=NULL) +-- nav_sort=910 은 chinaFishing(900) 과 illegalFishing(920) 사이 +-- ────────────────────────────────────────────────────────────────── +INSERT INTO kcg.auth_perm_tree + (rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord, + url_path, label_key, component_key, nav_sort, labels) +VALUES + ('detection:transshipment', NULL, '환적 의심 탐지', 1, 48, + '/transshipment', 'nav.transshipment', + 'features/detection/TransshipmentDetection', 910, + '{"ko":"환적 의심 탐지","en":"Transshipment"}'::jsonb) +ON CONFLICT (rsrc_cd) DO NOTHING; + +-- ────────────────────────────────────────────────────────────────── +-- 2. 권한 부여 — READ 전용 대시보드 +-- ADMIN: 5 ops 전부 (메뉴 관리 일관성) +-- 나머지(OPERATOR/ANALYST/FIELD/VIEWER): READ +-- ────────────────────────────────────────────────────────────────── +INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn) +SELECT r.role_sn, 'detection:transshipment', op.oper_cd, 'Y' +FROM kcg.auth_role r +CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd) +WHERE r.role_cd = 'ADMIN' +ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING; + +INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn) +SELECT r.role_sn, 'detection:transshipment', 'READ', 'Y' +FROM kcg.auth_role r +WHERE r.role_cd IN ('OPERATOR', 'ANALYST', 'FIELD', 'VIEWER') +ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING; diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 60c1a87..31744bb 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -5,6 +5,7 @@ ## [Unreleased] ### 추가 +- **환적 의심 전용 탐지 페이지 신설 (Phase 0-3)** — `/transshipment` 경로에 READ 전용 대시보드 추가. prediction `algorithms/transshipment.py` 의 5단계 필터 파이프라인 결과(is_transship_suspect=true)를 전체 목록·집계·상세 수준으로 조회. KPI 5장(Total + Transship tier CRITICAL/HIGH/MEDIUM + 종합 위험 CRITICAL) + DataTable 8컬럼 + 필터(hours/level/mmsi) + features JSON 상세. 기존 `/api/analysis/transship` + `getTransshipSuspects` 재사용해 backend 변경 없음. V033 마이그레이션으로 `detection:transshipment` 권한 트리 + 전 역할 READ 부여. (docs/prediction-analysis.md P1 UI 미노출 탐지 해소 — 2/2) - **불법 조업 이벤트 전용 페이지 신설 (Phase 0-2)** — `/illegal-fishing` 경로에 READ 전용 대시보드 추가. event_generator 가 생산하는 `GEAR_ILLEGAL`(G-01/G-05/G-06) + `EEZ_INTRUSION`(영해·접속수역) + `ZONE_DEPARTURE`(특정수역 진입) 3 카테고리를 한 화면에서 통합 조회. 심각도 KPI 5장 + 카테고리별 3장 + DataTable(7컬럼) + 필터(category/level/mmsi) + JSON features 상세 패널 + EventList 네비게이션. 기존 `/api/events` 를 category 다중 병렬 조회로 래핑하여 backend 변경 없이 구현. V032 마이그레이션으로 `detection:illegal-fishing` 권한 트리 + 전 역할 READ 부여 (운영자 처리 액션은 EventList 경유) ### 수정 diff --git a/frontend/src/app/componentRegistry.ts b/frontend/src/app/componentRegistry.ts index 61141b4..9fc2692 100644 --- a/frontend/src/app/componentRegistry.ts +++ b/frontend/src/app/componentRegistry.ts @@ -45,6 +45,9 @@ export const COMPONENT_REGISTRY: Record = { 'features/detection/IllegalFishingPattern': lazy(() => import('@features/detection').then((m) => ({ default: m.IllegalFishingPattern })), ), + 'features/detection/TransshipmentDetection': lazy(() => + import('@features/detection').then((m) => ({ default: m.TransshipmentDetection })), + ), // ── 단속·이벤트 ── 'features/enforcement/EnforcementHistory': lazy(() => import('@features/enforcement').then((m) => ({ default: m.EnforcementHistory })), diff --git a/frontend/src/features/detection/TransshipmentDetection.tsx b/frontend/src/features/detection/TransshipmentDetection.tsx new file mode 100644 index 0000000..e7c605e --- /dev/null +++ b/frontend/src/features/detection/TransshipmentDetection.tsx @@ -0,0 +1,405 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ArrowLeftRight, RefreshCw } from 'lucide-react'; + +import { PageContainer, PageHeader, Section } from '@shared/components/layout'; +import { Badge } from '@shared/components/ui/badge'; +import { Button } from '@shared/components/ui/button'; +import { Input } from '@shared/components/ui/input'; +import { Select } from '@shared/components/ui/select'; +import { Card, CardContent } from '@shared/components/ui/card'; +import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; + +import { formatDateTime } from '@shared/utils/dateFormat'; +import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels'; +import { getTransshipSuspects, type VesselAnalysis } from '@/services/analysisApi'; +import { useSettingsStore } from '@stores/settingsStore'; + +/** + * 환적(Transshipment) 의심 선박 탐지 페이지. + * + * prediction `algorithms/transshipment.py` 의 5단계 필터 파이프라인 결과 + * (transship_suspect=true) 를 전체 목록으로 조회·집계·상세 확인. + * + * features.transship_tier (CRITICAL/HIGH/MEDIUM) 와 transship_score 로 심각도 판단. + * 기존 `features/vessel/TransferDetection.tsx` 는 선박 상세 수준, 이 페이지는 + * 전체 목록·통계 수준의 운영 대시보드. + */ + +const HOUR_OPTIONS = [1, 6, 12, 24, 48] as const; +const LEVEL_OPTIONS = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as const; +const DEFAULT_HOURS = 6; +const DEFAULT_SIZE = 200; + +type TransshipTier = 'CRITICAL' | 'HIGH' | 'MEDIUM' | string; + +interface TransshipFeatures { + transship_tier?: TransshipTier; + transship_score?: number; + dark_tier?: string; + [key: string]: unknown; +} + +function readTier(row: VesselAnalysis): string { + const f = (row.features ?? {}) as TransshipFeatures; + return f.transship_tier ?? '-'; +} + +function readScore(row: VesselAnalysis): number | null { + const f = (row.features ?? {}) as TransshipFeatures; + return typeof f.transship_score === 'number' ? f.transship_score : null; +} + +export function TransshipmentDetection() { + const { t } = useTranslation('detection'); + const { t: tc } = useTranslation('common'); + const lang = useSettingsStore((s) => s.language) as 'ko' | 'en'; + + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [hours, setHours] = useState(DEFAULT_HOURS); + const [levelFilter, setLevelFilter] = useState(''); + const [mmsiFilter, setMmsiFilter] = useState(''); + const [selected, setSelected] = useState(null); + + const loadData = useCallback(async () => { + setLoading(true); + setError(''); + try { + const resp = await getTransshipSuspects({ hours, page: 0, size: DEFAULT_SIZE }); + setRows(resp.content); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : t('transshipment.error.loadFailed')); + } finally { + setLoading(false); + } + }, [hours, t]); + + useEffect(() => { + loadData(); + }, [loadData]); + + const filteredRows = useMemo(() => { + return rows.filter((r) => { + if (levelFilter && r.riskLevel !== levelFilter) return false; + if (mmsiFilter && !r.mmsi.includes(mmsiFilter)) return false; + return true; + }); + }, [rows, levelFilter, mmsiFilter]); + + const stats = useMemo(() => { + const byLevel: Record = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 }; + const byTier: Record = { CRITICAL: 0, HIGH: 0, MEDIUM: 0 }; + for (const r of rows) { + if (r.riskLevel && byLevel[r.riskLevel] !== undefined) { + byLevel[r.riskLevel]++; + } + const tier = readTier(r); + if (byTier[tier] !== undefined) { + byTier[tier]++; + } + } + return { byLevel, byTier }; + }, [rows]); + + const cols: DataColumn>[] = useMemo( + () => [ + { + key: 'analyzedAt', + label: t('transshipment.columns.analyzedAt'), + width: '140px', + sortable: true, + render: (v) => ( + {formatDateTime(v as string)} + ), + }, + { + key: 'mmsi', + label: t('transshipment.columns.mmsi'), + width: '120px', + sortable: true, + render: (v) => ( + + {v as string} + + ), + }, + { + key: 'transshipPairMmsi', + label: t('transshipment.columns.pairMmsi'), + width: '120px', + render: (v) => ( + {(v as string) || '-'} + ), + }, + { + key: 'transshipDurationMin', + label: t('transshipment.columns.durationMin'), + width: '90px', + align: 'right', + sortable: true, + render: (v) => { + const n = typeof v === 'number' ? v : Number(v ?? 0); + return {n.toFixed(0)}; + }, + }, + { + key: 'features', + label: t('transshipment.columns.tier'), + width: '90px', + align: 'center', + render: (_, row) => { + const tier = readTier(row); + const isKnown = ['CRITICAL', 'HIGH', 'MEDIUM'].includes(tier); + return ( + + {isKnown ? getAlertLevelLabel(tier, tc, lang) : tier} + + ); + }, + }, + { + key: 'riskScore', + label: t('transshipment.columns.riskScore'), + width: '80px', + align: 'right', + sortable: true, + render: (v) => {(v as number) ?? 0}, + }, + { + key: 'riskLevel', + label: t('transshipment.columns.riskLevel'), + width: '90px', + align: 'center', + sortable: true, + render: (v) => ( + + {getAlertLevelLabel(v as string, tc, lang)} + + ), + }, + { + key: 'zoneCode', + label: t('transshipment.columns.zone'), + width: '130px', + render: (v) => {(v as string) || '-'}, + }, + ], + [t, tc, lang], + ); + + return ( + + } + > + {t('transshipment.refresh')} + + } + /> + + {error && ( + + {error} + + )} + +
+
+ + + + + +
+
+ +
+
+ + + setMmsiFilter(e.target.value)} + /> +
+ + {filteredRows.length} / {rows.length} + +
+
+ + {filteredRows.length === 0 && !loading ? ( +

+ {t('transshipment.list.empty', { hours })} +

+ ) : ( + )[]} + columns={cols} + pageSize={20} + showSearch={false} + showExport={false} + showPrint={false} + onRowClick={(row) => setSelected(row as VesselAnalysis)} + /> + )} +
+ + {selected && ( +
+
+
+ + + + + + + +
+
+
+ + {t('transshipment.columns.tier')}: + + {(() => { + const tier = readTier(selected); + const isKnown = ['CRITICAL', 'HIGH', 'MEDIUM'].includes(tier); + return ( + + {isKnown ? getAlertLevelLabel(tier, tc, lang) : tier} + + ); + })()} + + {t('transshipment.detail.transshipScore')}: + + + {readScore(selected)?.toFixed(1) ?? '-'} + +
+ {selected.features && Object.keys(selected.features).length > 0 && ( +
+
+ {t('transshipment.detail.features')} +
+
+                    {JSON.stringify(selected.features, null, 2)}
+                  
+
+ )} +
+ +
+
+
+
+ )} +
+ ); +} + +// ─── 내부 컴포넌트 ───────────── + +interface StatCardProps { + label: string; + value: number; + intent?: 'warning' | 'info' | 'critical' | 'muted'; +} + +function StatCard({ label, value, intent }: StatCardProps) { + return ( + + + {label} + {intent ? ( + + {value} + + ) : ( + {value} + )} + + + ); +} + +interface DetailRowProps { + label: string; + value: string; + mono?: boolean; +} + +function DetailRow({ label, value, mono }: DetailRowProps) { + return ( +
+ {label} + {value} +
+ ); +} + +export default TransshipmentDetection; diff --git a/frontend/src/features/detection/index.ts b/frontend/src/features/detection/index.ts index 36cec99..9f65121 100644 --- a/frontend/src/features/detection/index.ts +++ b/frontend/src/features/detection/index.ts @@ -4,3 +4,4 @@ export { ChinaFishing } from './ChinaFishing'; export { GearIdentification } from './GearIdentification'; export { GearCollisionDetection } from './GearCollisionDetection'; export { IllegalFishingPattern } from './IllegalFishingPattern'; +export { TransshipmentDetection } from './TransshipmentDetection'; diff --git a/frontend/src/lib/i18n/locales/en/common.json b/frontend/src/lib/i18n/locales/en/common.json index fe3b734..ffbd530 100644 --- a/frontend/src/lib/i18n/locales/en/common.json +++ b/frontend/src/lib/i18n/locales/en/common.json @@ -10,6 +10,7 @@ "chinaFishing": "Chinese Vessel", "gearCollision": "Gear Collision", "illegalFishing": "Illegal Fishing", + "transshipment": "Transshipment", "patrolRoute": "Patrol Route", "fleetOptimization": "Fleet Optimize", "enforcementHistory": "History", diff --git a/frontend/src/lib/i18n/locales/en/detection.json b/frontend/src/lib/i18n/locales/en/detection.json index 9ea7470..2d5d68c 100644 --- a/frontend/src/lib/i18n/locales/en/detection.json +++ b/frontend/src/lib/i18n/locales/en/detection.json @@ -15,6 +15,50 @@ "title": "Gear Identification", "desc": "SFR-10 | AI-based gear origin & type automatic identification" }, + "transshipment": { + "title": "Transshipment Suspects", + "desc": "Vessels passing prediction's 5-stage transshipment filter (cross-type pair → monitoring zone → RENDEZVOUS 90min+ → score 50+ → anti-cluster burst). Severity from features.transship_tier", + "refresh": "Refresh", + "stats": { + "title": "Overview", + "total": "Total", + "tierCritical": "Transship CRITICAL", + "tierHigh": "Transship HIGH", + "tierMedium": "Transship MEDIUM", + "riskCritical": "Risk CRITICAL" + }, + "list": { + "title": "Suspect vessels", + "empty": "No transshipment suspects in the last {{hours}} hours." + }, + "columns": { + "analyzedAt": "Analyzed", + "mmsi": "MMSI", + "pairMmsi": "Pair MMSI", + "durationMin": "Duration (min)", + "tier": "Transship tier", + "riskScore": "Risk", + "riskLevel": "Risk level", + "zone": "Zone" + }, + "filters": { + "hours": "Window", + "level": "Risk level", + "mmsi": "MMSI", + "hoursValue": "Last {{h}}h", + "allLevel": "All levels" + }, + "detail": { + "title": "Transshipment detail", + "location": "Location", + "features": "Raw features", + "transshipScore": "Transship score", + "close": "Close" + }, + "error": { + "loadFailed": "Failed to load transshipment suspects." + } + }, "illegalPattern": { "title": "Illegal Fishing Events", "desc": "Integrated view of illegal fishing–related events: zone/gear mismatch, territorial sea intrusion, protected zone entry (READ only — take actions from Event List)", diff --git a/frontend/src/lib/i18n/locales/ko/common.json b/frontend/src/lib/i18n/locales/ko/common.json index 90225fd..b244cf6 100644 --- a/frontend/src/lib/i18n/locales/ko/common.json +++ b/frontend/src/lib/i18n/locales/ko/common.json @@ -10,6 +10,7 @@ "chinaFishing": "중국어선 분석", "gearCollision": "어구 정체성 충돌", "illegalFishing": "불법 조업 이벤트", + "transshipment": "환적 의심 탐지", "patrolRoute": "순찰경로 추천", "fleetOptimization": "다함정 최적화", "enforcementHistory": "단속 이력", diff --git a/frontend/src/lib/i18n/locales/ko/detection.json b/frontend/src/lib/i18n/locales/ko/detection.json index 01d1a05..7bf9712 100644 --- a/frontend/src/lib/i18n/locales/ko/detection.json +++ b/frontend/src/lib/i18n/locales/ko/detection.json @@ -15,6 +15,50 @@ "title": "어구 식별 분석", "desc": "SFR-10 | AI 기반 어구 원산지·유형 자동 식별 및 판정" }, + "transshipment": { + "title": "환적 의심 탐지", + "desc": "prediction 5단계 필터 파이프라인(이종 쌍 → 감시영역 → RENDEZVOUS 90분+ → 점수 50+ → 밀집 방폭) 통과 선박 목록. features.transship_tier 기반 심각도 분류", + "refresh": "새로고침", + "stats": { + "title": "현황 요약", + "total": "전체", + "tierCritical": "환적 CRITICAL", + "tierHigh": "환적 HIGH", + "tierMedium": "환적 MEDIUM", + "riskCritical": "종합위험 CRITICAL" + }, + "list": { + "title": "환적 의심 선박", + "empty": "최근 {{hours}}시간 내 환적 의심 선박이 없습니다." + }, + "columns": { + "analyzedAt": "분석 시각", + "mmsi": "MMSI", + "pairMmsi": "상대 MMSI", + "durationMin": "지속(분)", + "tier": "환적 tier", + "riskScore": "위험도", + "riskLevel": "종합 위험", + "zone": "수역" + }, + "filters": { + "hours": "조회 기간", + "level": "위험도", + "mmsi": "MMSI 검색", + "hoursValue": "최근 {{h}}시간", + "allLevel": "전체 위험도" + }, + "detail": { + "title": "환적 의심 상세", + "location": "좌표", + "features": "분석 피처 원본", + "transshipScore": "환적 점수", + "close": "닫기" + }, + "error": { + "loadFailed": "환적 의심 목록을 불러오지 못했습니다." + } + }, "illegalPattern": { "title": "불법 조업 이벤트", "desc": "수역-어구 위반 / 영해 침범 / 특정수역 진입 등 불법 조업 의심 이벤트 통합 조회 (READ 전용 — 처리 액션은 이벤트 목록에서)",