diff --git a/backend/pom.xml b/backend/pom.xml
index 7a0d549..7384f52 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -110,6 +110,11 @@
spring-security-test
test
+
+
+ org.hibernate.orm
+ hibernate-spatial
+
diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml
index 9fdcbd0..af78fe1 100644
--- a/backend/src/main/resources/application.yml
+++ b/backend/src/main/resources/application.yml
@@ -19,6 +19,7 @@ spring:
hibernate:
default_schema: kcg
format_sql: true
+ dialect: org.hibernate.spatial.dialect.postgis.PostgisPG10Dialect
jdbc:
time_zone: Asia/Seoul
open-in-view: false
diff --git a/backend/src/main/resources/db/migration/V008__code_master.sql b/backend/src/main/resources/db/migration/V008__code_master.sql
new file mode 100644
index 0000000..d981603
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V008__code_master.sql
@@ -0,0 +1,163 @@
+-- ============================================================
+-- V008: code_master (계층형 트리 코드 테이블 + 시드)
+-- auth_perm_tree와 동일한 self-referencing 패턴
+-- ============================================================
+
+CREATE TABLE kcg.code_master (
+ code_id VARCHAR(100) PRIMARY KEY,
+ parent_id VARCHAR(100) REFERENCES kcg.code_master(code_id),
+ group_code VARCHAR(50) NOT NULL, -- 루트 그룹 (빠른 필터용 비정규화)
+ code VARCHAR(50) NOT NULL, -- 이 레벨의 코드값
+ depth INT NOT NULL DEFAULT 0,
+ name_ko VARCHAR(100) NOT NULL,
+ name_en VARCHAR(100),
+ sort_order INT DEFAULT 0,
+ color_hex VARCHAR(10),
+ icon VARCHAR(30),
+ metadata JSONB,
+ is_active BOOLEAN DEFAULT true,
+ created_at TIMESTAMPTZ DEFAULT now()
+);
+
+CREATE INDEX idx_code_group ON kcg.code_master(group_code, depth);
+CREATE INDEX idx_code_parent ON kcg.code_master(parent_id);
+
+-- ============================================================
+-- 위반 유형 (VIOLATION_TYPE)
+-- ============================================================
+INSERT INTO kcg.code_master (code_id, parent_id, group_code, code, depth, name_ko, name_en, sort_order, color_hex) VALUES
+('VIOLATION_TYPE', NULL, 'VIOLATION_TYPE', 'VIOLATION_TYPE', 0, '위반 유형', 'Violation Type', 10, NULL),
+('VIOLATION_TYPE:EEZ_VIOLATION', 'VIOLATION_TYPE', 'VIOLATION_TYPE', 'EEZ_VIOLATION', 1, 'EEZ 침범', 'EEZ Violation', 10, '#EF4444'),
+('VIOLATION_TYPE:DARK_VESSEL', 'VIOLATION_TYPE', 'VIOLATION_TYPE', 'DARK_VESSEL', 1, '다크베셀', 'Dark Vessel', 20, '#7C3AED'),
+('VIOLATION_TYPE:MMSI_TAMPERING', 'VIOLATION_TYPE', 'VIOLATION_TYPE', 'MMSI_TAMPERING', 1, 'MMSI 변조', 'MMSI Tampering', 30, '#F59E0B'),
+('VIOLATION_TYPE:ILLEGAL_TRANSSHIP', 'VIOLATION_TYPE', 'VIOLATION_TYPE', 'ILLEGAL_TRANSSHIP',1,'불법환적', 'Illegal Transship', 40, '#EC4899'),
+('VIOLATION_TYPE:ILLEGAL_GEAR', 'VIOLATION_TYPE', 'VIOLATION_TYPE', 'ILLEGAL_GEAR', 1, '어구 불법', 'Illegal Gear', 50, '#F97316'),
+('VIOLATION_TYPE:ZONE_DEPARTURE', 'VIOLATION_TYPE', 'VIOLATION_TYPE', 'ZONE_DEPARTURE', 1, '조업구역 이탈', 'Zone Departure', 60, '#06B6D4'),
+('VIOLATION_TYPE:RISK_BEHAVIOR', 'VIOLATION_TYPE', 'VIOLATION_TYPE', 'RISK_BEHAVIOR', 1, '위험 행동', 'Risk Behavior', 70, '#64748B');
+
+-- ============================================================
+-- 이벤트 레벨 (EVENT_LEVEL)
+-- ============================================================
+INSERT INTO kcg.code_master (code_id, parent_id, group_code, code, depth, name_ko, name_en, sort_order, color_hex) VALUES
+('EVENT_LEVEL', NULL, 'EVENT_LEVEL', 'EVENT_LEVEL', 0, '이벤트 심각도', 'Event Level', 20, NULL),
+('EVENT_LEVEL:CRITICAL', 'EVENT_LEVEL', 'EVENT_LEVEL', 'CRITICAL', 1, '심각', 'Critical', 10, '#EF4444'),
+('EVENT_LEVEL:HIGH', 'EVENT_LEVEL', 'EVENT_LEVEL', 'HIGH', 1, '높음', 'High', 20, '#F59E0B'),
+('EVENT_LEVEL:MEDIUM', 'EVENT_LEVEL', 'EVENT_LEVEL', 'MEDIUM', 1, '보통', 'Medium', 30, '#3B82F6'),
+('EVENT_LEVEL:LOW', 'EVENT_LEVEL', 'EVENT_LEVEL', 'LOW', 1, '낮음', 'Low', 40, '#6B7280');
+
+-- ============================================================
+-- 이벤트 상태 (EVENT_STATUS)
+-- ============================================================
+INSERT INTO kcg.code_master (code_id, parent_id, group_code, code, depth, name_ko, name_en, sort_order, color_hex) VALUES
+('EVENT_STATUS', NULL, 'EVENT_STATUS', 'EVENT_STATUS', 0, '이벤트 상태', 'Event Status', 30, NULL),
+('EVENT_STATUS:NEW', 'EVENT_STATUS', 'EVENT_STATUS', 'NEW', 1, '신규', 'New', 10, '#EF4444'),
+('EVENT_STATUS:ACK', 'EVENT_STATUS', 'EVENT_STATUS', 'ACK', 1, '확인', 'Acknowledged', 20, '#F59E0B'),
+('EVENT_STATUS:IN_PROGRESS', 'EVENT_STATUS', 'EVENT_STATUS', 'IN_PROGRESS', 1, '처리중', 'In Progress', 30, '#3B82F6'),
+('EVENT_STATUS:RESOLVED', 'EVENT_STATUS', 'EVENT_STATUS', 'RESOLVED', 1, '완료', 'Resolved', 40, '#22C55E'),
+('EVENT_STATUS:FALSE_POSITIVE', 'EVENT_STATUS', 'EVENT_STATUS', 'FALSE_POSITIVE',1, '오탐', 'False Positive', 50, '#6B7280');
+
+-- ============================================================
+-- 이벤트 카테고리 (EVENT_CATEGORY) — 이벤트 유형 세분화
+-- ============================================================
+INSERT INTO kcg.code_master (code_id, parent_id, group_code, code, depth, name_ko, name_en, sort_order, color_hex) VALUES
+('EVENT_CATEGORY', NULL, 'EVENT_CATEGORY', 'EVENT_CATEGORY', 0, '이벤트 유형', 'Event Category', 35, NULL),
+('EVENT_CATEGORY:EEZ_INTRUSION', 'EVENT_CATEGORY', 'EVENT_CATEGORY', 'EEZ_INTRUSION', 1, 'EEZ 침범', 'EEZ Intrusion', 10, '#EF4444'),
+('EVENT_CATEGORY:DARK_VESSEL', 'EVENT_CATEGORY', 'EVENT_CATEGORY', 'DARK_VESSEL', 1, '다크베셀', 'Dark Vessel', 20, '#7C3AED'),
+('EVENT_CATEGORY:FLEET_CLUSTER', 'EVENT_CATEGORY', 'EVENT_CATEGORY', 'FLEET_CLUSTER', 1, '선단밀집', 'Fleet Cluster', 30, '#F97316'),
+('EVENT_CATEGORY:ILLEGAL_TRANSSHIP', 'EVENT_CATEGORY', 'EVENT_CATEGORY', 'ILLEGAL_TRANSSHIP',1, '불법환적', 'Illegal Transship',40, '#EC4899'),
+('EVENT_CATEGORY:MMSI_TAMPERING', 'EVENT_CATEGORY', 'EVENT_CATEGORY', 'MMSI_TAMPERING', 1, 'MMSI 변조', 'MMSI Tampering', 50, '#F59E0B'),
+('EVENT_CATEGORY:AIS_LOSS', 'EVENT_CATEGORY', 'EVENT_CATEGORY', 'AIS_LOSS', 1, 'AIS 소실', 'AIS Loss', 60, '#64748B'),
+('EVENT_CATEGORY:SPEED_ANOMALY', 'EVENT_CATEGORY', 'EVENT_CATEGORY', 'SPEED_ANOMALY', 1, '속력 이상', 'Speed Anomaly', 70, '#06B6D4'),
+('EVENT_CATEGORY:ZONE_DEPARTURE', 'EVENT_CATEGORY', 'EVENT_CATEGORY', 'ZONE_DEPARTURE', 1, '구역 이탈', 'Zone Departure', 80, '#8B5CF6'),
+('EVENT_CATEGORY:GEAR_ILLEGAL', 'EVENT_CATEGORY', 'EVENT_CATEGORY', 'GEAR_ILLEGAL', 1, '불법어구', 'Illegal Gear', 90, '#F97316'),
+('EVENT_CATEGORY:AIS_RESUME', 'EVENT_CATEGORY', 'EVENT_CATEGORY', 'AIS_RESUME', 1, 'AIS 재송출', 'AIS Resume', 100, '#22C55E');
+
+-- ============================================================
+-- 단속 조치 유형 (ENFORCEMENT_ACTION)
+-- ============================================================
+INSERT INTO kcg.code_master (code_id, parent_id, group_code, code, depth, name_ko, name_en, sort_order, color_hex) VALUES
+('ENFORCEMENT_ACTION', NULL, 'ENFORCEMENT_ACTION', 'ENFORCEMENT_ACTION', 0, '단속 조치', 'Enforcement Action', 40, NULL),
+('ENFORCEMENT_ACTION:CAPTURE', 'ENFORCEMENT_ACTION', 'ENFORCEMENT_ACTION', 'CAPTURE', 1, '나포', 'Capture', 10, '#EF4444'),
+('ENFORCEMENT_ACTION:INSPECT', 'ENFORCEMENT_ACTION', 'ENFORCEMENT_ACTION', 'INSPECT', 1, '검문', 'Inspect', 20, '#F59E0B'),
+('ENFORCEMENT_ACTION:WARN', 'ENFORCEMENT_ACTION', 'ENFORCEMENT_ACTION', 'WARN', 1, '경고', 'Warn', 30, '#3B82F6'),
+('ENFORCEMENT_ACTION:DISPERSE', 'ENFORCEMENT_ACTION', 'ENFORCEMENT_ACTION', 'DISPERSE', 1, '퇴거', 'Disperse', 40, '#8B5CF6'),
+('ENFORCEMENT_ACTION:TRACK', 'ENFORCEMENT_ACTION', 'ENFORCEMENT_ACTION', 'TRACK', 1, '추적', 'Track', 50, '#06B6D4'),
+('ENFORCEMENT_ACTION:EVIDENCE', 'ENFORCEMENT_ACTION', 'ENFORCEMENT_ACTION', 'EVIDENCE', 1, '증거수집', 'Evidence', 60, '#64748B');
+
+-- ============================================================
+-- 단속 결과 (ENFORCEMENT_RESULT)
+-- ============================================================
+INSERT INTO kcg.code_master (code_id, parent_id, group_code, code, depth, name_ko, name_en, sort_order, color_hex) VALUES
+('ENFORCEMENT_RESULT', NULL, 'ENFORCEMENT_RESULT', 'ENFORCEMENT_RESULT', 0, '단속 결과', 'Enforcement Result', 50, NULL),
+('ENFORCEMENT_RESULT:PUNISHED', 'ENFORCEMENT_RESULT', 'ENFORCEMENT_RESULT', 'PUNISHED', 1, '처벌', 'Punished', 10, '#EF4444'),
+('ENFORCEMENT_RESULT:WARNED', 'ENFORCEMENT_RESULT', 'ENFORCEMENT_RESULT', 'WARNED', 1, '경고', 'Warned', 20, '#F59E0B'),
+('ENFORCEMENT_RESULT:RELEASED', 'ENFORCEMENT_RESULT', 'ENFORCEMENT_RESULT', 'RELEASED', 1, '훈방', 'Released', 30, '#22C55E'),
+('ENFORCEMENT_RESULT:REFERRED', 'ENFORCEMENT_RESULT', 'ENFORCEMENT_RESULT', 'REFERRED', 1, '수사의뢰', 'Referred', 40, '#7C3AED'),
+('ENFORCEMENT_RESULT:FALSE_POSITIVE', 'ENFORCEMENT_RESULT', 'ENFORCEMENT_RESULT', 'FALSE_POSITIVE', 1, '오탐(정상)', 'False Positive', 50, '#6B7280');
+
+-- ============================================================
+-- AI 일치도 (AI_MATCH)
+-- ============================================================
+INSERT INTO kcg.code_master (code_id, parent_id, group_code, code, depth, name_ko, name_en, sort_order, color_hex) VALUES
+('AI_MATCH', NULL, 'AI_MATCH', 'AI_MATCH', 0, 'AI 일치도', 'AI Match', 60, NULL),
+('AI_MATCH:MATCH', 'AI_MATCH', 'AI_MATCH', 'MATCH', 1, '일치', 'Match', 10, '#22C55E'),
+('AI_MATCH:PARTIAL', 'AI_MATCH', 'AI_MATCH', 'PARTIAL', 1, '부분일치', 'Partial', 20, '#F59E0B'),
+('AI_MATCH:MISMATCH', 'AI_MATCH', 'AI_MATCH', 'MISMATCH', 1, '불일치', 'Mismatch', 30, '#EF4444');
+
+-- ============================================================
+-- 다크베셀 패턴 (DARK_PATTERN)
+-- ============================================================
+INSERT INTO kcg.code_master (code_id, parent_id, group_code, code, depth, name_ko, name_en, sort_order, color_hex) VALUES
+('DARK_PATTERN', NULL, 'DARK_PATTERN', 'DARK_PATTERN', 0, '다크베셀 패턴', 'Dark Pattern', 70, NULL),
+('DARK_PATTERN:COMPLETE_BLACKOUT', 'DARK_PATTERN', 'DARK_PATTERN', 'COMPLETE_BLACKOUT', 1, 'AIS 완전차단', 'Complete Blackout', 10, '#EF4444'),
+('DARK_PATTERN:INTERMITTENT', 'DARK_PATTERN', 'DARK_PATTERN', 'INTERMITTENT', 1, '간헐 송출', 'Intermittent', 20, '#F59E0B'),
+('DARK_PATTERN:MMSI_SPOOFING', 'DARK_PATTERN', 'DARK_PATTERN', 'MMSI_SPOOFING', 1, 'MMSI 변경 의심', 'MMSI Spoofing', 30, '#F97316'),
+('DARK_PATTERN:ZONE_BOUNDARY_DISAPPEAR', 'DARK_PATTERN', 'DARK_PATTERN', 'ZONE_BOUNDARY_DISAPPEAR',1,'수역 경계 소실', 'Zone Boundary Disappear',40, '#7C3AED'),
+('DARK_PATTERN:RAPID_REAPPEAR', 'DARK_PATTERN', 'DARK_PATTERN', 'RAPID_REAPPEAR', 1, '급재출현', 'Rapid Reappear', 50, '#EC4899'),
+('DARK_PATTERN:SCHEDULED_BLACKOUT', 'DARK_PATTERN', 'DARK_PATTERN', 'SCHEDULED_BLACKOUT', 1, '정기 차단(저위험)','Scheduled Blackout', 60, '#6B7280');
+
+-- ============================================================
+-- 허가 상태 (PERMIT_STATUS)
+-- ============================================================
+INSERT INTO kcg.code_master (code_id, parent_id, group_code, code, depth, name_ko, name_en, sort_order, color_hex) VALUES
+('PERMIT_STATUS', NULL, 'PERMIT_STATUS', 'PERMIT_STATUS', 0, '허가 상태', 'Permit Status', 80, NULL),
+('PERMIT_STATUS:PERMITTED', 'PERMIT_STATUS', 'PERMIT_STATUS', 'PERMITTED', 1, '유효', 'Permitted', 10, '#22C55E'),
+('PERMIT_STATUS:EXPIRED', 'PERMIT_STATUS', 'PERMIT_STATUS', 'EXPIRED', 1, '기간 초과', 'Expired', 20, '#F59E0B'),
+('PERMIT_STATUS:NONE', 'PERMIT_STATUS', 'PERMIT_STATUS', 'NONE', 1, '무허가', 'None', 30, '#EF4444'),
+('PERMIT_STATUS:REVOKED', 'PERMIT_STATUS', 'PERMIT_STATUS', 'REVOKED', 1, '취소', 'Revoked', 40, '#7C3AED'),
+('PERMIT_STATUS:UNKNOWN', 'PERMIT_STATUS', 'PERMIT_STATUS', 'UNKNOWN', 1, '미상', 'Unknown', 50, '#6B7280');
+
+-- ============================================================
+-- 어구 판정 (GEAR_JUDGMENT) — illegal_gear_classifier 산출
+-- ============================================================
+INSERT INTO kcg.code_master (code_id, parent_id, group_code, code, depth, name_ko, name_en, sort_order, color_hex) VALUES
+('GEAR_JUDGMENT', NULL, 'GEAR_JUDGMENT', 'GEAR_JUDGMENT', 0, '어구 판정', 'Gear Judgment', 90, NULL),
+('GEAR_JUDGMENT:LEGAL', 'GEAR_JUDGMENT', 'GEAR_JUDGMENT', 'LEGAL', 1, '합법', 'Legal', 10, '#22C55E'),
+('GEAR_JUDGMENT:NO_PERMIT', 'GEAR_JUDGMENT', 'GEAR_JUDGMENT', 'NO_PERMIT', 1, '무허가', 'No Permit', 20, '#EF4444'),
+('GEAR_JUDGMENT:GEAR_MISMATCH', 'GEAR_JUDGMENT', 'GEAR_JUDGMENT', 'GEAR_MISMATCH', 1, '어구 불일치', 'Gear Mismatch', 30, '#F59E0B'),
+('GEAR_JUDGMENT:ZONE_VIOLATION', 'GEAR_JUDGMENT', 'GEAR_JUDGMENT', 'ZONE_VIOLATION', 1, '해역 위반', 'Zone Violation',40, '#F97316'),
+('GEAR_JUDGMENT:SEASON_VIOLATION', 'GEAR_JUDGMENT', 'GEAR_JUDGMENT', 'SEASON_VIOLATION',1, '금어기 위반', 'Season Violation',50,'#7C3AED');
+
+-- ============================================================
+-- 함정 상태 (PATROL_STATUS)
+-- ============================================================
+INSERT INTO kcg.code_master (code_id, parent_id, group_code, code, depth, name_ko, name_en, sort_order, color_hex) VALUES
+('PATROL_STATUS', NULL, 'PATROL_STATUS', 'PATROL_STATUS', 0, '함정 상태', 'Patrol Status', 100, NULL),
+('PATROL_STATUS:AVAILABLE', 'PATROL_STATUS', 'PATROL_STATUS', 'AVAILABLE', 1, '가용', 'Available', 10, '#22C55E'),
+('PATROL_STATUS:ON_PATROL', 'PATROL_STATUS', 'PATROL_STATUS', 'ON_PATROL', 1, '초계중', 'On Patrol', 20, '#3B82F6'),
+('PATROL_STATUS:IN_PURSUIT', 'PATROL_STATUS', 'PATROL_STATUS', 'IN_PURSUIT', 1, '추적중', 'In Pursuit', 30, '#EF4444'),
+('PATROL_STATUS:INSPECTING', 'PATROL_STATUS', 'PATROL_STATUS', 'INSPECTING', 1, '검문중', 'Inspecting', 40, '#F59E0B'),
+('PATROL_STATUS:RETURNING', 'PATROL_STATUS', 'PATROL_STATUS', 'RETURNING', 1, '귀항중', 'Returning', 50, '#8B5CF6'),
+('PATROL_STATUS:STANDBY', 'PATROL_STATUS', 'PATROL_STATUS', 'STANDBY', 1, '대기', 'Standby', 60, '#64748B'),
+('PATROL_STATUS:MAINTENANCE', 'PATROL_STATUS', 'PATROL_STATUS', 'MAINTENANCE', 1, '정비중', 'Maintenance', 70, '#6B7280');
+
+-- ============================================================
+-- 선박 국적 (VESSEL_FLAG)
+-- ============================================================
+INSERT INTO kcg.code_master (code_id, parent_id, group_code, code, depth, name_ko, name_en, sort_order, color_hex) VALUES
+('VESSEL_FLAG', NULL, 'VESSEL_FLAG', 'VESSEL_FLAG', 0, '선박 국적', 'Vessel Flag', 110, NULL),
+('VESSEL_FLAG:CN', 'VESSEL_FLAG', 'VESSEL_FLAG', 'CN', 1, '중국', 'China', 10, '#EF4444'),
+('VESSEL_FLAG:KR', 'VESSEL_FLAG', 'VESSEL_FLAG', 'KR', 1, '한국', 'Korea', 20, '#3B82F6'),
+('VESSEL_FLAG:JP', 'VESSEL_FLAG', 'VESSEL_FLAG', 'JP', 1, '일본', 'Japan', 30, '#22C55E'),
+('VESSEL_FLAG:RU', 'VESSEL_FLAG', 'VESSEL_FLAG', 'RU', 1, '러시아', 'Russia', 40, '#F59E0B'),
+('VESSEL_FLAG:UNKNOWN', 'VESSEL_FLAG', 'VESSEL_FLAG', 'UNKNOWN', 1, '미상', 'Unknown', 50, '#6B7280');
diff --git a/backend/src/main/resources/db/migration/V009__gear_type_master.sql b/backend/src/main/resources/db/migration/V009__gear_type_master.sql
new file mode 100644
index 0000000..22b8aeb
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V009__gear_type_master.sql
@@ -0,0 +1,60 @@
+-- ============================================================
+-- V009: gear_type_master (어구 유형 마스터)
+-- prediction classifier 출력값과 1:1 매칭
+-- GearIdentification 화면에서 관리자가 룰 편집
+-- ============================================================
+
+CREATE TABLE kcg.gear_type_master (
+ gear_code VARCHAR(20) PRIMARY KEY,
+ gear_name_ko VARCHAR(50) NOT NULL,
+ gear_name_en VARCHAR(50),
+ category VARCHAR(20), -- NET, TRAP, LINE
+ -- 분류 룰 (prediction classifier가 사용)
+ speed_min_kn NUMERIC(5,2), -- 조업 속도 범위
+ speed_max_kn NUMERIC(5,2),
+ duration_min_minutes INT, -- 최소 지속시간
+ pattern_signature JSONB, -- 추가 분류 규칙 (확장용)
+ polygon_shape_hint VARCHAR(20), -- LINEAR, CIRCULAR, CLUSTERED
+ -- 합법성 기준
+ legal_zones TEXT[], -- zone_polygon_master.zone_code 참조
+ legal_seasons JSONB, -- [{"start":"03-01","end":"06-30"}]
+ permit_required BOOLEAN DEFAULT true,
+ -- 표시
+ display_color VARCHAR(7),
+ display_icon VARCHAR(30),
+ display_order INT DEFAULT 0,
+ description TEXT,
+ is_active BOOLEAN DEFAULT true,
+ created_by UUID,
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- ============================================================
+-- 시드: 프론트엔드 더미 기반 6종
+-- ============================================================
+INSERT INTO kcg.gear_type_master (gear_code, gear_name_ko, gear_name_en, category,
+ speed_min_kn, speed_max_kn, duration_min_minutes, polygon_shape_hint,
+ permit_required, display_color, display_order, description) VALUES
+('TRAWL_BOTTOM', '저층트롤', 'Bottom Trawl', 'NET',
+ 2.0, 5.0, 60, 'LINEAR',
+ true, '#EF4444', 10, '저층을 끌어 조업하는 그물. 해저 생태계 영향 크고 대부분의 수역에서 규제됨'),
+
+('GILLNET', '유자망', 'Gill Net', 'NET',
+ 0.0, 2.0, 120, 'LINEAR',
+ true, '#F59E0B', 20, '그물을 설치하고 기다리는 수동 어구. 부유/저층 구분'),
+
+('TRAP', '통발', 'Trap/Pot', 'TRAP',
+ 0.0, 1.0, 240, 'CLUSTERED',
+ true, '#3B82F6', 30, '바닥에 설치하는 덫 방식 어구. 특정 어종 대상'),
+
+('PURSE_SEINE', '선망', 'Purse Seine', 'NET',
+ 3.0, 8.0, 30, 'CIRCULAR',
+ true, '#22C55E', 40, '원형으로 그물을 던져 감싸는 대규모 어법. 선단 규모 필요'),
+
+('LONGLINE', '연승', 'Long Line', 'LINE',
+ 1.0, 4.0, 180, 'LINEAR',
+ true, '#8B5CF6', 50, '긴 줄에 낚시바늘을 다수 부착하는 어법. 궤적이 선형'),
+
+('UNKNOWN', '미분류', 'Unknown', NULL,
+ NULL, NULL, NULL, NULL,
+ false, '#6B7280', 99, '분류 불가능한 어구 또는 어구 미사용 선박');
diff --git a/backend/src/main/resources/db/migration/V010__zone_polygon_master.sql b/backend/src/main/resources/db/migration/V010__zone_polygon_master.sql
new file mode 100644
index 0000000..c1a0b9a
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V010__zone_polygon_master.sql
@@ -0,0 +1,80 @@
+-- ============================================================
+-- V010: zone_polygon_master (해역 폴리곤 마스터)
+-- PostGIS GEOMETRY 사용, MapControl 화면에서 관리자가 편집
+-- prediction location.py가 매 사이클 참조
+-- ============================================================
+
+CREATE TABLE kcg.zone_polygon_master (
+ zone_code VARCHAR(30) PRIMARY KEY,
+ zone_name_ko VARCHAR(100) NOT NULL,
+ zone_name_en VARCHAR(100),
+ zone_type VARCHAR(30) NOT NULL, -- TERRITORIAL, EEZ, SPECIAL_FISHING, NLL, BUFFER, PROHIBITED, PATROL_SECTOR
+ parent_zone_code VARCHAR(30) REFERENCES kcg.zone_polygon_master(zone_code),
+ polygon_geom GEOMETRY(MULTIPOLYGON, 4326),
+ -- 단속 정책
+ baseline_distance_nm NUMERIC(8,2),
+ enforcement_priority INT DEFAULT 5, -- 1=최우선
+ default_risk_level VARCHAR(20), -- 진입만으로 부여되는 기본 위험 레벨
+ -- 어구 정책
+ allowed_gear_codes TEXT[],
+ prohibited_gear_codes TEXT[],
+ -- 표시
+ display_color VARCHAR(7),
+ display_opacity NUMERIC(3,2) DEFAULT 0.3,
+ display_order INT DEFAULT 0,
+ description TEXT,
+ metadata JSONB, -- 관할서, 면적, 기타
+ is_active BOOLEAN DEFAULT true,
+ created_by UUID,
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+CREATE INDEX idx_zone_geom ON kcg.zone_polygon_master USING GIST(polygon_geom);
+CREATE INDEX idx_zone_type ON kcg.zone_polygon_master(zone_type);
+CREATE INDEX idx_zone_parent ON kcg.zone_polygon_master(parent_zone_code);
+
+-- ============================================================
+-- 시드: 주요 한국 해역 (간략 폴리곤 — 향후 정밀 GeoJSON import)
+-- prediction의 data/zones/ GeoJSON이 정밀 데이터 소스
+-- ============================================================
+
+-- 한국 영해 (12해리, 간략 바운딩)
+INSERT INTO kcg.zone_polygon_master (zone_code, zone_name_ko, zone_name_en, zone_type,
+ enforcement_priority, default_risk_level, display_color, display_opacity, display_order, description)
+VALUES
+('TERRITORIAL_SEA', '영해', 'Territorial Sea', 'TERRITORIAL',
+ 3, NULL, '#3B82F6', 0.15, 10, '기선으로부터 12해리 이내');
+
+-- 한국 EEZ
+INSERT INTO kcg.zone_polygon_master (zone_code, zone_name_ko, zone_name_en, zone_type,
+ enforcement_priority, default_risk_level, display_color, display_opacity, display_order, description)
+VALUES
+('EEZ_KR', '한국 EEZ', 'Korea EEZ', 'EEZ',
+ 2, 'LOW', '#06B6D4', 0.1, 20, '한국 배타적 경제수역');
+
+-- NLL
+INSERT INTO kcg.zone_polygon_master (zone_code, zone_name_ko, zone_name_en, zone_type,
+ enforcement_priority, default_risk_level, display_color, display_opacity, display_order, description)
+VALUES
+('NLL', '북방한계선', 'Northern Limit Line', 'NLL',
+ 1, 'CRITICAL', '#EF4444', 0.3, 5, '서해 북방한계선. 최우선 감시 구역');
+
+-- 특정어업수역 Ⅰ~Ⅳ (iran prediction의 zones/ GeoJSON 참조)
+INSERT INTO kcg.zone_polygon_master (zone_code, zone_name_ko, zone_name_en, zone_type,
+ parent_zone_code, enforcement_priority, default_risk_level,
+ display_color, display_opacity, display_order, description) VALUES
+('SPECIAL_FISHING_1', '특정어업수역 Ⅰ', 'Special Fishing Zone 1', 'SPECIAL_FISHING',
+ 'EEZ_KR', 2, 'HIGH', '#F59E0B', 0.25, 30, '한중 잠정조치수역 인접'),
+('SPECIAL_FISHING_2', '특정어업수역 Ⅱ', 'Special Fishing Zone 2', 'SPECIAL_FISHING',
+ 'EEZ_KR', 2, 'HIGH', '#F97316', 0.25, 31, '서해 중부 어업 수역'),
+('SPECIAL_FISHING_3', '특정어업수역 Ⅲ', 'Special Fishing Zone 3', 'SPECIAL_FISHING',
+ 'EEZ_KR', 3, 'MEDIUM', '#8B5CF6', 0.2, 32, '남해 어업 수역'),
+('SPECIAL_FISHING_4', '특정어업수역 Ⅳ', 'Special Fishing Zone 4', 'SPECIAL_FISHING',
+ 'EEZ_KR', 3, 'MEDIUM', '#EC4899', 0.2, 33, '동해 어업 수역');
+
+-- 서해 5도 (patrol store 더미 참조)
+INSERT INTO kcg.zone_polygon_master (zone_code, zone_name_ko, zone_name_en, zone_type,
+ enforcement_priority, default_risk_level, display_color, display_opacity, display_order, description)
+VALUES
+('WEST_5_ISLANDS', '서해 5도', 'West Sea 5 Islands', 'PATROL_SECTOR',
+ 1, 'HIGH', '#EF4444', 0.2, 6, '백령도/대청도/소청도/연평도/우도 인근');
diff --git a/backend/src/main/resources/db/migration/V011__vessel_permit_patrol.sql b/backend/src/main/resources/db/migration/V011__vessel_permit_patrol.sql
new file mode 100644
index 0000000..b0496b3
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V011__vessel_permit_patrol.sql
@@ -0,0 +1,104 @@
+-- ============================================================
+-- V011: vessel_permit_master + patrol_ship_master + fleet_companies + 시드
+-- ============================================================
+
+-- ============================================================
+-- 선단 회사 (중국 선단 회사 레지스트리)
+-- ============================================================
+CREATE TABLE kcg.fleet_companies (
+ id BIGSERIAL PRIMARY KEY,
+ name_cn VARCHAR(200),
+ name_en VARCHAR(200),
+ name_ko VARCHAR(200),
+ country VARCHAR(10) DEFAULT 'CN',
+ is_active BOOLEAN DEFAULT true,
+ created_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- ============================================================
+-- 어선 허가/등록 마스터
+-- ============================================================
+CREATE TABLE kcg.vessel_permit_master (
+ mmsi VARCHAR(20) PRIMARY KEY,
+ vessel_name VARCHAR(100),
+ vessel_name_cn VARCHAR(100),
+ flag_country VARCHAR(10), -- KR, CN, JP, RU, UNKNOWN
+ vessel_type VARCHAR(30), -- FISHING, CARGO, TANKER, PATROL, UNKNOWN
+ tonnage NUMERIC(10,2),
+ length_m NUMERIC(6,2),
+ build_year INT,
+ -- 허가 상태
+ permit_status VARCHAR(20) DEFAULT 'UNKNOWN', -- PERMITTED, EXPIRED, NONE, REVOKED, UNKNOWN
+ permit_no VARCHAR(50),
+ permitted_gear_codes TEXT[], -- gear_type_master.gear_code 참조
+ permitted_zones TEXT[], -- zone_polygon_master.zone_code 참조
+ permit_valid_from DATE,
+ permit_valid_to DATE,
+ -- 소속
+ company_id BIGINT REFERENCES kcg.fleet_companies(id),
+ -- 메타
+ data_source VARCHAR(50) DEFAULT 'MANUAL', -- KR_FISHERIES, CHINA_REGISTRY, IRAN_REGISTRY, MANUAL
+ last_synced_at TIMESTAMPTZ,
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+CREATE INDEX idx_vessel_flag ON kcg.vessel_permit_master(flag_country);
+CREATE INDEX idx_vessel_permit ON kcg.vessel_permit_master(permit_status);
+CREATE INDEX idx_vessel_company ON kcg.vessel_permit_master(company_id);
+
+-- ============================================================
+-- 함정 마스터
+-- ============================================================
+CREATE TABLE kcg.patrol_ship_master (
+ ship_id BIGSERIAL PRIMARY KEY,
+ ship_code VARCHAR(20) UNIQUE NOT NULL, -- '3001함'
+ ship_name VARCHAR(100),
+ ship_class VARCHAR(50), -- 태극급, 참수리급, 삼봉급
+ tonnage NUMERIC(10,2),
+ max_speed_kn NUMERIC(5,2),
+ fuel_capacity_l NUMERIC(10,2),
+ base_port VARCHAR(50),
+ -- 현재 상태 (실시간 갱신)
+ current_status VARCHAR(20) DEFAULT 'STANDBY', -- code_master PATROL_STATUS 참조
+ current_lat DOUBLE PRECISION,
+ current_lon DOUBLE PRECISION,
+ current_zone_code VARCHAR(30), -- zone_polygon_master FK
+ fuel_pct INT,
+ crew_count INT,
+ -- 메타
+ is_active BOOLEAN DEFAULT true,
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- ============================================================
+-- 시드: 프론트엔드 더미 기반 선박 (9척)
+-- ============================================================
+INSERT INTO kcg.fleet_companies (id, name_cn, name_en, name_ko, country) VALUES
+(1, '荣成远洋渔业', 'Rongcheng Ocean Fishery', '영성원양어업', 'CN'),
+(2, '大连海洋', 'Dalian Ocean', '대련해양', 'CN');
+SELECT setval('kcg.fleet_companies_id_seq', 10);
+
+INSERT INTO kcg.vessel_permit_master (mmsi, vessel_name, vessel_name_cn, flag_country, vessel_type,
+ tonnage, permit_status, permitted_gear_codes, company_id, data_source) VALUES
+('412345678', '鲁荣渔56555', '鲁荣渔56555', 'CN', 'FISHING', 450.0, 'NONE', '{TRAWL_BOTTOM}', 1, 'IRAN_REGISTRY'),
+('412345679', '鲁荣渔56556', '鲁荣渔56556', 'CN', 'FISHING', 380.0, 'EXPIRED', '{GILLNET}', 1, 'IRAN_REGISTRY'),
+('412345680', '辽大渔42881', '辽大渔42881', 'CN', 'FISHING', 520.0, 'NONE', '{PURSE_SEINE}', 2, 'IRAN_REGISTRY'),
+('412345681', '浙象渔23166', '浙象渔23166', 'CN', 'FISHING', 290.0, 'NONE', '{LONGLINE}', NULL, 'IRAN_REGISTRY'),
+('412345682', '闽霞渔09876', '闽霞渔09876', 'CN', 'FISHING', 410.0, 'NONE', '{TRAP}', NULL, 'IRAN_REGISTRY'),
+('412345683', '苏赣渔05512', '苏赣渔05512', 'CN', 'FISHING', 350.0, 'NONE', '{GILLNET}', NULL, 'IRAN_REGISTRY'),
+('440012345', '제주해양호', NULL, 'KR', 'FISHING', 85.0, 'PERMITTED','{LONGLINE,TRAP}',NULL, 'KR_FISHERIES'),
+('440012346', '통영수산호', NULL, 'KR', 'FISHING', 120.0, 'PERMITTED','{GILLNET}', NULL, 'KR_FISHERIES'),
+('000000001', 'Unknown-001', NULL, 'UNKNOWN','UNKNOWN',NULL,'UNKNOWN', NULL, NULL, 'MANUAL');
+
+-- ============================================================
+-- 시드: 함정 6척 (프론트엔드 patrolStore 기반)
+-- ============================================================
+INSERT INTO kcg.patrol_ship_master (ship_code, ship_name, ship_class, tonnage, max_speed_kn,
+ fuel_capacity_l, base_port, current_status, current_lat, current_lon,
+ current_zone_code, fuel_pct, crew_count) VALUES
+('3001', '3001함', '태극급', 3000.0, 30.0, 500000, '인천', 'IN_PURSUIT', 37.52, 124.78, 'NLL', 78, 120),
+('3005', '3005함', '삼봉급', 1500.0, 25.0, 300000, '목포', 'ON_PATROL', 34.85, 125.42, 'SPECIAL_FISHING_1', 92, 80),
+('3009', '3009함', '참수리급', 500.0, 35.0, 100000, '속초', 'AVAILABLE', 38.12, 128.95, NULL, 100, 35),
+('5001', '5001함', '태극급', 3000.0, 30.0, 500000, '부산', 'ON_PATROL', 34.42, 129.38, 'SPECIAL_FISHING_3', 65, 120),
+('1502', '1502함', '참수리급', 500.0, 35.0, 100000, '인천', 'INSPECTING', 37.38, 124.55, 'WEST_5_ISLANDS', 45, 35),
+('2003', '2003함', '삼봉급', 1500.0, 25.0, 300000, '서귀포', 'STANDBY', 33.15, 126.58, NULL, 88, 80);
diff --git a/backend/src/main/resources/db/migration/V012__prediction_events_stats.sql b/backend/src/main/resources/db/migration/V012__prediction_events_stats.sql
new file mode 100644
index 0000000..b82c849
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V012__prediction_events_stats.sql
@@ -0,0 +1,252 @@
+-- ============================================================
+-- V012: prediction 산출 테이블 (이벤트 허브 + 알림 + 통계)
+-- prediction이 write, backend가 상태만 update
+-- ============================================================
+
+-- ============================================================
+-- 분석 결과 확장 (vessel_analysis_results)
+-- prediction이 직접 INSERT. 기존 iran 28컬럼 + 확장
+-- ============================================================
+CREATE TABLE kcg.vessel_analysis_results (
+ id BIGSERIAL NOT NULL,
+ mmsi VARCHAR(20) NOT NULL,
+ analyzed_at TIMESTAMPTZ NOT NULL,
+ -- 분류
+ vessel_type VARCHAR(30),
+ confidence NUMERIC(5,4),
+ fishing_pct NUMERIC(5,4),
+ cluster_id INT,
+ season VARCHAR(20),
+ -- 위치
+ lat DOUBLE PRECISION,
+ lon DOUBLE PRECISION,
+ zone_code VARCHAR(30), -- zone_polygon_master FK
+ dist_to_baseline_nm NUMERIC(8,2),
+ -- 행동 분석
+ activity_state VARCHAR(20), -- STATIONARY, FISHING, SAILING
+ ucaf_score NUMERIC(5,4),
+ ucft_score NUMERIC(5,4),
+ -- 위협 탐지
+ is_dark BOOLEAN DEFAULT false,
+ gap_duration_min INT,
+ dark_pattern VARCHAR(30), -- code_master DARK_PATTERN 참조
+ spoofing_score NUMERIC(5,4),
+ bd09_offset_m NUMERIC(8,2),
+ speed_jump_count INT,
+ -- 환적
+ transship_suspect BOOLEAN DEFAULT false,
+ transship_pair_mmsi VARCHAR(20),
+ transship_duration_min INT,
+ -- 선단
+ fleet_cluster_id INT,
+ fleet_role VARCHAR(20), -- LEADER, FOLLOWER, NOISE
+ fleet_is_leader BOOLEAN DEFAULT false,
+ -- 위험도
+ risk_score INT, -- 0~100
+ risk_level VARCHAR(20), -- CRITICAL, HIGH, MEDIUM, LOW
+ -- ★ 확장 컬럼
+ gear_code VARCHAR(20), -- gear_type_master FK (분류 결과)
+ violation_categories TEXT[], -- code_master VIOLATION_TYPE 참조
+ gear_judgment VARCHAR(30), -- code_master GEAR_JUDGMENT 참조
+ permit_status VARCHAR(20), -- 분석 시점 허가 상태 스냅샷
+ -- 특징 벡터 (선택)
+ features JSONB,
+ -- 메타
+ created_at TIMESTAMPTZ DEFAULT now(),
+ PRIMARY KEY (id, analyzed_at)
+) PARTITION BY RANGE (analyzed_at);
+
+-- 파티션 (prediction partition_manager가 자동 생성하지만, 초기 1개 생성)
+CREATE TABLE kcg.vessel_analysis_results_default PARTITION OF kcg.vessel_analysis_results DEFAULT;
+
+CREATE INDEX idx_var_mmsi ON kcg.vessel_analysis_results(mmsi, analyzed_at DESC);
+CREATE INDEX idx_var_risk ON kcg.vessel_analysis_results(risk_level, analyzed_at DESC);
+CREATE INDEX idx_var_dark ON kcg.vessel_analysis_results(is_dark) WHERE is_dark = true;
+CREATE INDEX idx_var_zone ON kcg.vessel_analysis_results(zone_code, analyzed_at DESC);
+CREATE INDEX idx_var_gear ON kcg.vessel_analysis_results(gear_code) WHERE gear_code IS NOT NULL;
+CREATE INDEX idx_var_violation ON kcg.vessel_analysis_results USING GIN(violation_categories);
+
+-- ============================================================
+-- 이벤트 허브 (★ 모든 운영 흐름의 단일 진입점)
+-- prediction event_generator가 INSERT, backend가 status만 UPDATE
+-- ============================================================
+CREATE TABLE kcg.prediction_events (
+ id BIGSERIAL PRIMARY KEY,
+ event_uid VARCHAR(50) UNIQUE NOT NULL, -- EVT-YYYYMMDD-NNNN
+ occurred_at TIMESTAMPTZ NOT NULL,
+ level VARCHAR(20) NOT NULL, -- CRITICAL, HIGH, MEDIUM, LOW
+ category VARCHAR(50) NOT NULL, -- code_master EVENT_CATEGORY 참조
+ title VARCHAR(200) NOT NULL,
+ detail TEXT,
+ -- 대상 선박
+ vessel_mmsi VARCHAR(20),
+ vessel_name VARCHAR(100),
+ -- 위치
+ area_name VARCHAR(100),
+ zone_code VARCHAR(30),
+ lat DOUBLE PRECISION,
+ lon DOUBLE PRECISION,
+ speed_kn NUMERIC(5,2),
+ -- 분석 출처
+ source_type VARCHAR(50), -- VESSEL_ANALYSIS, GEAR_GROUP, TRANSSHIP, FLEET
+ source_ref_id BIGINT, -- 원본 분석결과 PK
+ ai_confidence NUMERIC(5,4),
+ -- 운영 상태 (backend가 갱신)
+ status VARCHAR(20) DEFAULT 'NEW', -- code_master EVENT_STATUS 참조
+ assignee_id UUID,
+ assignee_name VARCHAR(100),
+ acked_at TIMESTAMPTZ,
+ resolved_at TIMESTAMPTZ,
+ resolution_note TEXT,
+ -- dedup
+ dedup_key VARCHAR(200), -- mmsi + category + window
+ -- 메타
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+CREATE INDEX idx_event_status ON kcg.prediction_events(status, occurred_at DESC);
+CREATE INDEX idx_event_level ON kcg.prediction_events(level, occurred_at DESC);
+CREATE INDEX idx_event_category ON kcg.prediction_events(category, occurred_at DESC);
+CREATE INDEX idx_event_mmsi ON kcg.prediction_events(vessel_mmsi, occurred_at DESC);
+CREATE INDEX idx_event_dedup ON kcg.prediction_events(dedup_key, occurred_at DESC);
+
+-- ============================================================
+-- 이벤트 처리 워크플로우 (상태 변경 이력)
+-- ============================================================
+CREATE TABLE kcg.event_workflow (
+ id BIGSERIAL PRIMARY KEY,
+ event_id BIGINT NOT NULL REFERENCES kcg.prediction_events(id),
+ prev_status VARCHAR(20),
+ new_status VARCHAR(20) NOT NULL,
+ actor_id UUID,
+ actor_name VARCHAR(100),
+ comment TEXT,
+ created_at TIMESTAMPTZ DEFAULT now()
+);
+
+CREATE INDEX idx_ew_event ON kcg.event_workflow(event_id, created_at DESC);
+
+-- ============================================================
+-- AI 알림 발송 이력
+-- ============================================================
+CREATE TABLE kcg.prediction_alerts (
+ id BIGSERIAL PRIMARY KEY,
+ event_id BIGINT NOT NULL REFERENCES kcg.prediction_events(id),
+ channel VARCHAR(20) NOT NULL, -- DASHBOARD, MOBILE, SMS, EMAIL
+ recipient VARCHAR(200),
+ sent_at TIMESTAMPTZ DEFAULT now(),
+ delivery_status VARCHAR(20) DEFAULT 'SENT', -- SENT, DELIVERED, READ, FAILED
+ ai_confidence NUMERIC(5,4),
+ metadata JSONB
+);
+
+CREATE INDEX idx_alert_event ON kcg.prediction_alerts(event_id);
+
+-- ============================================================
+-- 사전 집계 통계 — 시간별 (최근 48h 보존)
+-- ============================================================
+CREATE TABLE kcg.prediction_stats_hourly (
+ stat_hour TIMESTAMPTZ PRIMARY KEY,
+ total_detections INT DEFAULT 0,
+ by_category JSONB, -- {"EEZ_VIOLATION": 3, "DARK_VESSEL": 1, ...}
+ by_zone JSONB, -- {"NLL": 5, "EEZ_KR": 8, ...}
+ by_risk_level JSONB, -- {"CRITICAL": 2, "HIGH": 5, ...}
+ event_count INT DEFAULT 0,
+ critical_count INT DEFAULT 0,
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- ============================================================
+-- 사전 집계 통계 — 일별
+-- ============================================================
+CREATE TABLE kcg.prediction_stats_daily (
+ stat_date DATE PRIMARY KEY,
+ total_detections INT DEFAULT 0,
+ by_category JSONB,
+ by_zone JSONB,
+ by_risk_level JSONB,
+ by_gear_type JSONB, -- {"TRAWL_BOTTOM": 12, "GILLNET": 8, ...}
+ by_violation_type JSONB, -- {"EEZ_VIOLATION": 15, ...}
+ event_count INT DEFAULT 0,
+ critical_event_count INT DEFAULT 0,
+ enforcement_count INT DEFAULT 0, -- 단속 건수 (backend가 주입)
+ false_positive_count INT DEFAULT 0,
+ ai_accuracy_pct NUMERIC(5,2),
+ manual_confirmed_parents INT DEFAULT 0, -- 운영자 모선 확정 건수
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- ============================================================
+-- 사전 집계 통계 — 월별
+-- ============================================================
+CREATE TABLE kcg.prediction_stats_monthly (
+ stat_month CHAR(7) PRIMARY KEY, -- 'YYYY-MM'
+ total_detections INT DEFAULT 0,
+ total_enforcements INT DEFAULT 0,
+ by_category JSONB,
+ by_zone JSONB,
+ by_risk_level JSONB,
+ by_gear_type JSONB,
+ by_violation_type JSONB,
+ event_count INT DEFAULT 0,
+ critical_event_count INT DEFAULT 0,
+ false_positive_count INT DEFAULT 0,
+ ai_accuracy_pct NUMERIC(5,2),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- ============================================================
+-- 실시간 KPI (프론트엔드 더미 kpiStore 대체)
+-- ============================================================
+CREATE TABLE kcg.prediction_kpi_realtime (
+ kpi_key VARCHAR(50) PRIMARY KEY,
+ kpi_label VARCHAR(100) NOT NULL,
+ value INT DEFAULT 0,
+ trend VARCHAR(10), -- up, down, flat
+ delta_pct NUMERIC(5,2),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- 시드: 프론트엔드 kpiStore 기반 6개 KPI
+INSERT INTO kcg.prediction_kpi_realtime (kpi_key, kpi_label, value, trend, delta_pct) VALUES
+('realtime_detection', '실시간 탐지', 47, 'up', 8.2),
+('eez_violation', 'EEZ 침범', 18, 'up', 12.5),
+('dark_vessel', '다크베셀', 12, 'down', -5.3),
+('illegal_transship', '불법환적 의심',8, 'flat', 0.0),
+('tracking_active', '추적 중', 15, 'up', 3.1),
+('captured_inspected', '나포/검문', 3, 'flat', 0.0);
+
+-- ============================================================
+-- 위험도 격자 (RiskMap용, 1시간 단위)
+-- ============================================================
+CREATE TABLE kcg.prediction_risk_grid (
+ cell_id VARCHAR(20) NOT NULL, -- 'lat_lon' 형식 (e.g., '3400_12500')
+ stat_hour TIMESTAMPTZ NOT NULL,
+ avg_risk NUMERIC(5,2),
+ max_risk INT,
+ vessel_count INT DEFAULT 0,
+ critical_count INT DEFAULT 0,
+ metadata JSONB,
+ PRIMARY KEY (cell_id, stat_hour)
+);
+
+CREATE INDEX idx_grid_hour ON kcg.prediction_risk_grid(stat_hour DESC);
+
+-- ============================================================
+-- prediction 학습 피드백 입력 (backend가 write, prediction이 read)
+-- ============================================================
+CREATE TABLE kcg.prediction_label_input (
+ id BIGSERIAL PRIMARY KEY,
+ input_type VARCHAR(30) NOT NULL, -- PARENT_CONFIRM, PARENT_REJECT, FALSE_POSITIVE, GEAR_CORRECTION
+ group_key VARCHAR(255),
+ sub_cluster_id INT,
+ mmsi VARCHAR(20),
+ label_value VARCHAR(100), -- 확정된 모선 MMSI, 수정된 어구 코드 등
+ confidence NUMERIC(5,4),
+ actor_id UUID,
+ consumed_at TIMESTAMPTZ, -- prediction이 사용하면 timestamp 기록
+ created_at TIMESTAMPTZ DEFAULT now()
+);
+
+CREATE INDEX idx_label_unconsumed ON kcg.prediction_label_input(consumed_at) WHERE consumed_at IS NULL;
diff --git a/backend/src/main/resources/db/migration/V013__enforcement_operations.sql b/backend/src/main/resources/db/migration/V013__enforcement_operations.sql
new file mode 100644
index 0000000..4a4a8ba
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V013__enforcement_operations.sql
@@ -0,0 +1,272 @@
+-- ============================================================
+-- V013: 운영 도메인 테이블 (단속/계획/배치/AI모델)
+-- 백엔드 전용 — 운영자 의사결정 데이터
+-- ============================================================
+
+-- ============================================================
+-- 단속 이력 (EnforcementHistory 화면)
+-- enforcement_records.event_id → prediction_events.id (자동 매칭 + 수동 변경)
+-- ============================================================
+CREATE TABLE kcg.enforcement_records (
+ id BIGSERIAL PRIMARY KEY,
+ enf_uid VARCHAR(50) UNIQUE NOT NULL, -- ENF-YYYYMMDD-NNNN
+ event_id BIGINT REFERENCES kcg.prediction_events(id),
+ -- 시간/위치
+ enforced_at TIMESTAMPTZ NOT NULL,
+ zone_code VARCHAR(30), -- zone_polygon_master FK
+ area_name VARCHAR(100),
+ lat DOUBLE PRECISION,
+ lon DOUBLE PRECISION,
+ -- 대상 선박
+ vessel_mmsi VARCHAR(20),
+ vessel_name VARCHAR(100),
+ flag_country VARCHAR(10),
+ -- 단속 내용
+ violation_type VARCHAR(50), -- code_master VIOLATION_TYPE 참조
+ action VARCHAR(50) NOT NULL, -- code_master ENFORCEMENT_ACTION 참조
+ result VARCHAR(50), -- code_master ENFORCEMENT_RESULT 참조
+ -- AI 일치도
+ ai_match_status VARCHAR(20), -- code_master AI_MATCH 참조
+ ai_confidence NUMERIC(5,4),
+ -- 수행 함정
+ patrol_ship_id BIGINT REFERENCES kcg.patrol_ship_master(ship_id),
+ -- 담당
+ enforced_by UUID,
+ enforced_by_name VARCHAR(100),
+ remarks TEXT,
+ -- 메타
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+CREATE INDEX idx_enf_date ON kcg.enforcement_records(enforced_at DESC);
+CREATE INDEX idx_enf_event ON kcg.enforcement_records(event_id);
+CREATE INDEX idx_enf_mmsi ON kcg.enforcement_records(vessel_mmsi);
+CREATE INDEX idx_enf_type ON kcg.enforcement_records(violation_type);
+
+-- ============================================================
+-- 단속 계획 (EnforcementPlan 화면)
+-- ============================================================
+CREATE TABLE kcg.enforcement_plans (
+ id BIGSERIAL PRIMARY KEY,
+ plan_uid VARCHAR(50) UNIQUE NOT NULL, -- PLN-YYYYMMDD-NNNN
+ title VARCHAR(200) NOT NULL,
+ zone_code VARCHAR(30),
+ area_name VARCHAR(100),
+ lat DOUBLE PRECISION,
+ lon DOUBLE PRECISION,
+ -- 일정
+ planned_date DATE NOT NULL,
+ planned_from TIMESTAMPTZ,
+ planned_to TIMESTAMPTZ,
+ -- 위험도 평가
+ risk_level VARCHAR(20),
+ risk_score INT,
+ -- 배치
+ assigned_ship_count INT DEFAULT 0,
+ assigned_crew INT DEFAULT 0,
+ -- 상태
+ status VARCHAR(20) DEFAULT 'DRAFT', -- DRAFT, APPROVED, IN_PROGRESS, COMPLETED, CANCELLED
+ alert_status VARCHAR(20), -- 경보 발령 여부
+ -- 담당
+ created_by UUID,
+ approved_by UUID,
+ remarks TEXT,
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+CREATE INDEX idx_plan_date ON kcg.enforcement_plans(planned_date DESC);
+CREATE INDEX idx_plan_status ON kcg.enforcement_plans(status);
+
+-- ============================================================
+-- 함정 배치 (PatrolRoute, FleetOptimization)
+-- ============================================================
+CREATE TABLE kcg.patrol_assignments (
+ id BIGSERIAL PRIMARY KEY,
+ ship_id BIGINT NOT NULL REFERENCES kcg.patrol_ship_master(ship_id),
+ plan_id BIGINT REFERENCES kcg.enforcement_plans(id),
+ -- 배치 해역
+ zone_code VARCHAR(30),
+ -- 기간
+ assigned_at TIMESTAMPTZ DEFAULT now(),
+ completed_at TIMESTAMPTZ,
+ -- 경로
+ waypoints JSONB, -- [{lat, lon, name, eta}]
+ route_distance_nm NUMERIC(8,2),
+ estimated_hours NUMERIC(5,2),
+ fuel_estimate_l NUMERIC(10,2),
+ -- 상태
+ status VARCHAR(20) DEFAULT 'ASSIGNED',-- ASSIGNED, EN_ROUTE, ON_STATION, COMPLETED, CANCELLED
+ -- 담당
+ assigned_by UUID,
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+CREATE INDEX idx_pa_ship ON kcg.patrol_assignments(ship_id, assigned_at DESC);
+CREATE INDEX idx_pa_plan ON kcg.patrol_assignments(plan_id);
+CREATE INDEX idx_pa_status ON kcg.patrol_assignments(status);
+
+-- ============================================================
+-- AI 모델 버전 (AIModelManagement)
+-- ============================================================
+CREATE TABLE kcg.ai_model_versions (
+ id BIGSERIAL PRIMARY KEY,
+ model_name VARCHAR(100) NOT NULL, -- 'gear_classifier', 'risk_scorer', 'parent_inference'
+ version VARCHAR(50) NOT NULL,
+ description TEXT,
+ -- 성능 메트릭
+ accuracy_pct NUMERIC(5,2),
+ precision_pct NUMERIC(5,2),
+ recall_pct NUMERIC(5,2),
+ f1_score NUMERIC(5,4),
+ -- 상태
+ status VARCHAR(20) DEFAULT 'TRAINING', -- TRAINING, EVALUATING, DEPLOYED, ARCHIVED
+ deployed_at TIMESTAMPTZ,
+ -- 메타
+ train_config JSONB,
+ eval_metrics JSONB,
+ created_by UUID,
+ created_at TIMESTAMPTZ DEFAULT now()
+);
+
+CREATE TABLE kcg.ai_model_metrics (
+ id BIGSERIAL PRIMARY KEY,
+ model_id BIGINT NOT NULL REFERENCES kcg.ai_model_versions(id),
+ metric_date DATE NOT NULL,
+ metric_name VARCHAR(50) NOT NULL, -- accuracy, precision, recall, f1, latency_ms
+ metric_value NUMERIC(10,4),
+ metadata JSONB,
+ created_at TIMESTAMPTZ DEFAULT now()
+);
+
+CREATE INDEX idx_amm_model ON kcg.ai_model_metrics(model_id, metric_date DESC);
+
+-- ============================================================
+-- 시드: 단속 이력 6건 (프론트 enforcementStore 기반)
+-- ============================================================
+INSERT INTO kcg.enforcement_records (enf_uid, enforced_at, zone_code, area_name,
+ vessel_mmsi, vessel_name, flag_country, violation_type, action, result,
+ ai_match_status, ai_confidence, patrol_ship_id, remarks) VALUES
+('ENF-20260403-0001', '2026-04-03 14:30:00+09', 'NLL', '서해 NLL 인근',
+ '412345678', '鲁荣渔56555', 'CN', 'EEZ_VIOLATION', 'CAPTURE', 'PUNISHED',
+ 'MATCH', 0.95, (SELECT ship_id FROM kcg.patrol_ship_master WHERE ship_code='3001'),
+ 'NLL 인근 불법조업 현행범'),
+('ENF-20260402-0001', '2026-04-02 09:15:00+09', 'SPECIAL_FISHING_1', '서해 중부',
+ '412345680', '辽大渔42881', 'CN', 'ILLEGAL_GEAR', 'INSPECT', 'WARNED',
+ 'PARTIAL', 0.72, (SELECT ship_id FROM kcg.patrol_ship_master WHERE ship_code='3005'),
+ '무허가 선망 사용'),
+('ENF-20260401-0001', '2026-04-01 16:45:00+09', 'SPECIAL_FISHING_2', '서해 5도 인근',
+ '412345679', '鲁荣渔56556', 'CN', 'DARK_VESSEL', 'TRACK', 'REFERRED',
+ 'MATCH', 0.88, (SELECT ship_id FROM kcg.patrol_ship_master WHERE ship_code='1502'),
+ 'AIS 차단 후 도주, 수사의뢰'),
+('ENF-20260331-0001', '2026-03-31 11:20:00+09', 'EEZ_KR', 'EEZ 남부',
+ '412345682', '闽霞渔09876', 'CN', 'ILLEGAL_TRANSSHIP', 'EVIDENCE', 'PUNISHED',
+ 'MATCH', 0.91, (SELECT ship_id FROM kcg.patrol_ship_master WHERE ship_code='5001'),
+ '해상 환적 현장 증거 확보'),
+('ENF-20260330-0001', '2026-03-30 08:00:00+09', 'WEST_5_ISLANDS', '연평도 서방',
+ '412345681', '浙象渔23166', 'CN', 'ZONE_DEPARTURE', 'DISPERSE', 'RELEASED',
+ 'MISMATCH', 0.45, (SELECT ship_id FROM kcg.patrol_ship_master WHERE ship_code='1502'),
+ '조업구역 이탈, 퇴거 조치 (오탐 가능성)'),
+('ENF-20260329-0001', '2026-03-29 22:30:00+09', 'NLL', 'NLL 동부',
+ '412345683', '苏赣渔05512', 'CN', 'EEZ_VIOLATION', 'CAPTURE', 'PUNISHED',
+ 'MATCH', 0.97, (SELECT ship_id FROM kcg.patrol_ship_master WHERE ship_code='3001'),
+ '야간 EEZ 침범, 고속 도주 후 나포');
+
+-- ============================================================
+-- 시드: 이벤트 15건 (프론트 eventStore 기반)
+-- ============================================================
+INSERT INTO kcg.prediction_events (event_uid, occurred_at, level, category, title, detail,
+ vessel_mmsi, vessel_name, area_name, zone_code, lat, lon, speed_kn,
+ source_type, ai_confidence, status) VALUES
+('EVT-20260407-0001', '2026-04-07 06:12:00+09', 'CRITICAL', 'EEZ_INTRUSION',
+ 'EEZ 침범 탐지', '중국 어선 鲁荣渔56555 EEZ 침범, 위험도 96',
+ '412345678', '鲁荣渔56555', '서해 NLL', 'NLL', 37.52, 124.78, 8.5,
+ 'VESSEL_ANALYSIS', 0.96, 'IN_PROGRESS'),
+('EVT-20260407-0002', '2026-04-07 05:48:00+09', 'HIGH', 'DARK_VESSEL',
+ '다크베셀 장기 소실', 'AIS 신호 180분 소실, 완전차단 패턴',
+ '412345680', '辽大渔42881', '서해 중부', 'SPECIAL_FISHING_1', 34.85, 125.42, 0.0,
+ 'VESSEL_ANALYSIS', 0.88, 'ACK'),
+('EVT-20260407-0003', '2026-04-07 05:30:00+09', 'HIGH', 'FLEET_CLUSTER',
+ '선단 밀집 감지', '중국어선 12척 선단 밀집, 리더 선박 식별',
+ '412345681', '浙象渔23166', 'EEZ 북부', 'EEZ_KR', 36.90, 124.20, 6.2,
+ 'VESSEL_ANALYSIS', 0.82, 'NEW'),
+('EVT-20260407-0004', '2026-04-07 04:55:00+09', 'HIGH', 'ILLEGAL_TRANSSHIP',
+ '불법환적 의심', '2척 60분 이상 근접 정박, 환적 의심',
+ '412345682', '闽霞渔09876', '남해', 'SPECIAL_FISHING_3', 34.42, 129.38, 0.5,
+ 'VESSEL_ANALYSIS', 0.79, 'NEW'),
+('EVT-20260407-0005', '2026-04-07 04:30:00+09', 'CRITICAL', 'MMSI_TAMPERING',
+ 'MMSI 3회 변경', 'MMSI 변경 3회 탐지, GPS 스푸핑 의심',
+ '412345683', '苏赣渔05512', '서해 5도', 'WEST_5_ISLANDS', 37.38, 124.55, 12.3,
+ 'VESSEL_ANALYSIS', 0.93, 'IN_PROGRESS'),
+('EVT-20260407-0006', '2026-04-07 04:10:00+09', 'MEDIUM', 'ZONE_DEPARTURE',
+ '구역 이탈', '허가 해역 이탈 감지',
+ '440012345', '제주해양호', 'EEZ 남부', 'EEZ_KR', 33.15, 126.58, 5.1,
+ 'VESSEL_ANALYSIS', 0.65, 'NEW'),
+('EVT-20260407-0007', '2026-04-07 03:45:00+09', 'LOW', 'AIS_RESUME',
+ 'AIS 재송출', 'AIS 신호 회복, 120분 소실 후',
+ '412345679', '鲁荣渔56556', '서해 NLL', 'NLL', 37.45, 124.65, 3.2,
+ 'VESSEL_ANALYSIS', 0.55, 'RESOLVED'),
+('EVT-20260407-0008', '2026-04-07 03:20:00+09', 'MEDIUM', 'SPEED_ANOMALY',
+ '속력 이상', '급격한 속력 변화 탐지 (2kn → 18kn)',
+ '412345678', '鲁荣渔56555', '서해 NLL', 'NLL', 37.50, 124.80, 18.0,
+ 'VESSEL_ANALYSIS', 0.71, 'ACK'),
+('EVT-20260407-0009', '2026-04-07 02:55:00+09', 'HIGH', 'AIS_LOSS',
+ 'AIS 소실', '45분간 AIS 신호 없음, 간헐송출 패턴',
+ '412345681', '浙象渔23166', 'EEZ 북부', 'EEZ_KR', 36.88, 124.18, 0.0,
+ 'VESSEL_ANALYSIS', 0.75, 'NEW'),
+('EVT-20260407-0010', '2026-04-07 02:30:00+09', 'MEDIUM', 'GEAR_ILLEGAL',
+ 'EEZ 내 어구 설치', '무허가 저층트롤 어구 그룹 탐지',
+ '412345680', '辽大渔42881', '서해 중부', 'SPECIAL_FISHING_1', 34.90, 125.40, 2.8,
+ 'GEAR_GROUP', 0.68, 'NEW'),
+('EVT-20260407-0011', '2026-04-07 02:00:00+09', 'MEDIUM', 'FLEET_CLUSTER',
+ '어구 그룹 신규 탐지', '4척 어구 그룹 신규 형성',
+ NULL, NULL, '서해 5도', 'WEST_5_ISLANDS', 37.40, 124.50, 0.0,
+ 'GEAR_GROUP', 0.62, 'NEW'),
+('EVT-20260407-0012', '2026-04-07 01:30:00+09', 'LOW', 'ZONE_DEPARTURE',
+ '접안 후 출항', '검문 대상 선박 출항',
+ '412345679', '鲁荣渔56556', '인천항', NULL, 37.45, 126.60, 5.0,
+ 'VESSEL_ANALYSIS', 0.40, 'RESOLVED'),
+('EVT-20260407-0013', '2026-04-07 01:00:00+09', 'CRITICAL', 'EEZ_INTRUSION',
+ 'NLL 근접 EEZ 침범', '위험도 최상위, 야간 침입',
+ '412345683', '苏赣渔05512', 'NLL 동부', 'NLL', 37.55, 124.90, 11.0,
+ 'VESSEL_ANALYSIS', 0.98, 'RESOLVED'),
+('EVT-20260406-0001', '2026-04-06 22:00:00+09', 'HIGH', 'DARK_VESSEL',
+ '다크베셀 탐지', 'MMSI 변조 후 AIS 차단',
+ '412345682', '闽霞渔09876', '남해', 'SPECIAL_FISHING_3', 34.40, 129.35, 0.0,
+ 'VESSEL_ANALYSIS', 0.85, 'RESOLVED'),
+('EVT-20260406-0002', '2026-04-06 20:30:00+09', 'MEDIUM', 'ILLEGAL_TRANSSHIP',
+ '환적 의심', '야간 근접 정박 90분',
+ '412345681', '浙象渔23166', 'EEZ 서부', 'EEZ_KR', 35.20, 124.80, 0.3,
+ 'VESSEL_ANALYSIS', 0.70, 'FALSE_POSITIVE');
+
+-- ============================================================
+-- 시드: 단속 계획 5건 (프론트 enforcementStore 기반)
+-- ============================================================
+INSERT INTO kcg.enforcement_plans (plan_uid, title, zone_code, area_name,
+ lat, lon, planned_date, risk_level, risk_score,
+ assigned_ship_count, assigned_crew, status, alert_status) VALUES
+('PLN-20260408-0001', 'NLL 집중 단속', 'NLL', '서해 NLL', 37.52, 124.78,
+ '2026-04-08', 'CRITICAL', 92, 3, 180, 'APPROVED', '경보 발령'),
+('PLN-20260409-0001', 'EEZ 북부 순찰', 'EEZ_KR', 'EEZ 북부', 36.90, 124.20,
+ '2026-04-09', 'HIGH', 78, 2, 120, 'DRAFT', NULL),
+('PLN-20260410-0001', '서해 5도 초계', 'WEST_5_ISLANDS', '서해 5도', 37.38, 124.55,
+ '2026-04-10', 'HIGH', 85, 2, 100, 'APPROVED', '주의'),
+('PLN-20260411-0001', '남해 환적 감시', 'SPECIAL_FISHING_3', '남해', 34.42, 129.38,
+ '2026-04-11', 'MEDIUM', 65, 1, 60, 'DRAFT', NULL),
+('PLN-20260412-0001', '서해 중부 야간 작전', 'SPECIAL_FISHING_1', '서해 중부', 34.85, 125.42,
+ '2026-04-12', 'HIGH', 80, 2, 140, 'APPROVED', '경보 발령');
+
+-- ============================================================
+-- 시드: 일별/월별 통계 (프론트 kpiStore/monthlyTrends 기반)
+-- ============================================================
+INSERT INTO kcg.prediction_stats_monthly (stat_month, total_detections, total_enforcements,
+ by_violation_type, event_count, critical_event_count, false_positive_count, ai_accuracy_pct) VALUES
+('2025-10', 128, 42, '{"EEZ_VIOLATION":45,"DARK_VESSEL":32,"MMSI_TAMPERING":23,"ILLEGAL_TRANSSHIP":15,"ILLEGAL_GEAR":13}', 85, 12, 16, 81.0),
+('2025-11', 145, 38, '{"EEZ_VIOLATION":51,"DARK_VESSEL":36,"MMSI_TAMPERING":26,"ILLEGAL_TRANSSHIP":17,"ILLEGAL_GEAR":15}', 97, 15, 14, 84.0),
+('2025-12', 167, 55, '{"EEZ_VIOLATION":59,"DARK_VESSEL":42,"MMSI_TAMPERING":30,"ILLEGAL_TRANSSHIP":20,"ILLEGAL_GEAR":16}', 112, 18, 12, 86.0),
+('2026-01', 189, 61, '{"EEZ_VIOLATION":66,"DARK_VESSEL":47,"MMSI_TAMPERING":34,"ILLEGAL_TRANSSHIP":23,"ILLEGAL_GEAR":19}', 126, 22, 10, 88.0),
+('2026-02', 156, 48, '{"EEZ_VIOLATION":55,"DARK_VESSEL":39,"MMSI_TAMPERING":28,"ILLEGAL_TRANSSHIP":19,"ILLEGAL_GEAR":15}', 104, 17, 9, 89.0),
+('2026-03', 172, 52, '{"EEZ_VIOLATION":60,"DARK_VESSEL":43,"MMSI_TAMPERING":31,"ILLEGAL_TRANSSHIP":21,"ILLEGAL_GEAR":17}', 115, 19, 8, 90.0),
+('2026-04', 67, 15, '{"EEZ_VIOLATION":24,"DARK_VESSEL":17,"MMSI_TAMPERING":12,"ILLEGAL_TRANSSHIP":8,"ILLEGAL_GEAR":6}', 45, 8, 2, 93.0);
diff --git a/database/migration/README.md b/database/migration/README.md
index 98f608e..c3df544 100644
--- a/database/migration/README.md
+++ b/database/migration/README.md
@@ -1,30 +1,73 @@
# Database Migrations
-PostgreSQL 마이그레이션 (Flyway 형식).
+> ⚠️ **실제 SQL 파일 위치**: [`backend/src/main/resources/db/migration/`](../../backend/src/main/resources/db/migration/)
+>
+> Spring Boot Flyway 표준 위치를 따르므로 SQL 파일은 백엔드 모듈 안에 있습니다.
+> Spring Boot 기동 시 Flyway가 자동으로 적용합니다.
## DB 정보
-- DB Name: `kcgaidb`
-- User: `kcg-app`
-- Schema: `kcg`
+- **DB Name**: `kcgaidb`
+- **User**: `kcg-app`
+- **Schema**: `kcg`
+- **Host**: `211.208.115.83:5432`
-## 마이그레이션 파일 (Phase 2에서 작성)
+## 적용된 마이그레이션 (V001~V013)
+
+### Phase 1~8: 인증/권한/감사 (V001~V007)
| 파일 | 내용 |
|---|---|
-| `V001__auth_init.sql` | 사용자, 조직, 역할, 로그인 이력 |
+| `V001__auth_init.sql` | 인증/조직/역할/사용자-역할/로그인 이력 |
| `V002__perm_tree.sql` | 권한 트리 + 권한 매트릭스 |
-| `V003__perm_seed.sql` | 초기 역할 + 트리 노드 시드 |
-| `V004__access_logs.sql` | 감사로그, 접근 이력 |
-| `V005__parent_workflow.sql` | 모선 워크플로우 (운영자 결정/제외/학습 세션) |
+| `V003__perm_seed.sql` | 초기 역할 5종 + 트리 노드 45개 + 권한 매트릭스 시드 |
+| `V004__access_logs.sql` | 감사로그/접근이력 |
+| `V005__parent_workflow.sql` | 모선 워크플로우 (resolution/review_log/exclusions/label_sessions) |
+| `V006__demo_accounts.sql` | 데모 계정 5종 |
+| `V007__perm_tree_label_align.sql` | 트리 노드 명칭을 사이드바 i18n 라벨과 일치 |
+
+### S1: 마스터 데이터 + Prediction 기반 (V008~V013)
+
+| 파일 | 내용 |
+|---|---|
+| `V008__code_master.sql` | 계층형 코드 마스터 (12그룹, 72코드: 위반유형/이벤트/단속/허가/함정 등) |
+| `V009__gear_type_master.sql` | 어구 유형 마스터 6종 (분류 룰 + 합법성 기준) |
+| `V010__zone_polygon_master.sql` | 해역 폴리곤 마스터 (PostGIS GEOMETRY, 8개 해역 시드) |
+| `V011__vessel_permit_patrol.sql` | 어선 허가 마스터 + 함정 마스터 + fleet_companies (선박 9척, 함정 6척) |
+| `V012__prediction_events_stats.sql` | vessel_analysis_results(파티션) + 이벤트 허브 + 알림 + 통계(시/일/월) + KPI + 위험격자 + 학습피드백 |
+| `V013__enforcement_operations.sql` | 단속 이력/계획 + 함정 배치 + AI모델 버전/메트릭 (시드 포함) |
## 실행 방법
-```bash
-# DB 생성 (1회)
-psql -U postgres -c "CREATE DATABASE kcgaidb;"
-psql -U postgres -c "CREATE USER \"kcg-app\" WITH PASSWORD 'Kcg2026ai';"
-psql -U postgres -c "GRANT ALL PRIVILEGES ON DATABASE kcgaidb TO \"kcg-app\";"
+### 최초 1회 - DB/사용자 생성 (관리자 권한 필요)
+```sql
+-- snp 관리자 계정으로 접속
+psql -h 211.208.115.83 -U snp -d postgres
-# 마이그레이션은 backend Spring Boot가 기동 시 자동 실행 (Flyway)
+CREATE DATABASE kcgaidb;
+CREATE USER "kcg-app" WITH PASSWORD 'Kcg2026ai';
+GRANT ALL PRIVILEGES ON DATABASE kcgaidb TO "kcg-app";
+
+\c kcgaidb
+CREATE SCHEMA IF NOT EXISTS kcg AUTHORIZATION "kcg-app";
+GRANT ALL ON SCHEMA kcg TO "kcg-app";
+ALTER DATABASE kcgaidb OWNER TO "kcg-app";
+```
+
+### 마이그레이션 실행 (자동)
+백엔드 기동 시 Flyway가 자동 적용:
+```bash
cd backend && ./mvnw spring-boot:run
```
+
+### 수동 적용
+```bash
+cd backend && ./mvnw flyway:migrate -Dflyway.url=jdbc:postgresql://211.208.115.83:5432/kcgaidb -Dflyway.user=kcg-app -Dflyway.password=Kcg2026ai -Dflyway.schemas=kcg
+```
+
+### Checksum 불일치 시 (마이그레이션 파일 수정 후)
+```bash
+cd backend && ./mvnw flyway:repair -Dflyway.url=... (위와 동일)
+```
+
+## 신규 마이그레이션 추가
+[`backend/src/main/resources/db/migration/`](../../backend/src/main/resources/db/migration/)에 `V00N__설명.sql` 형식으로 추가하면 다음 기동 시 자동 적용됩니다.