iran 백엔드 프록시 잔재 제거: - IranBackendClient dead class 삭제, AppProperties/application.yml iran-backend 블록 제거 - Frontend UI 라벨/주석/system-flow manifest deprecated 마킹 - CLAUDE.md 시스템 구성 다이어그램 최신화 백엔드 계층 분리: - AlertController/MasterDataController/AdminStatsController 에서 repository/JdbcTemplate 직접 주입 제거 - AlertService/MasterDataService/AdminStatsService 신규 계층 도입 + @Transactional(readOnly=true) - Proxy controller 의 @PostConstruct RestClient 생성 → RestClientConfig @Bean 으로 통합 감사 로그 보강: - EnforcementService createRecord/updateRecord/createPlan 에 @Auditable 추가 - VesselAnalysisGroupService.resolveParent 에 PARENT_RESOLVE 액션 기록 카탈로그 정합성: - performanceStatus 를 catalogRegistry 에 등록 (쇼케이스 자동 노출) - alertLevels 확장: isValidAlertLevel / isHighSeverity / getAlertLevelOrder - LiveMapView/DarkVesselDetection 시각 매핑(opacity/radius/tier score) 상수로 추출 - GearIdentification/vesselAnomaly 직접 분기를 타입 가드/헬퍼로 치환
185 lines
5.7 KiB
TypeScript
185 lines
5.7 KiB
TypeScript
/**
|
|
* 모선 워크플로우 API 클라이언트.
|
|
* - 후보/리뷰/운영자 결정 모두 자체 백엔드 + 자체 DB(gear_group_parent_resolution) 경유.
|
|
*/
|
|
|
|
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
|
|
|
|
export interface ParentResolution {
|
|
id: number;
|
|
groupKey: string;
|
|
subClusterId: number;
|
|
status: 'UNRESOLVED' | 'MANUAL_CONFIRMED' | 'REVIEW_REQUIRED';
|
|
selectedParentMmsi: string | null;
|
|
rejectedCandidateMmsi: string | null;
|
|
approvedBy: string | null;
|
|
approvedAt: string | null;
|
|
rejectedAt: string | null;
|
|
manualComment: string | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export interface CandidateExclusion {
|
|
id: number;
|
|
scopeType: 'GROUP' | 'GLOBAL';
|
|
groupKey: string | null;
|
|
subClusterId: number | null;
|
|
excludedMmsi: string;
|
|
reason: string | null;
|
|
actor: string | null;
|
|
actorAcnt: string | null;
|
|
createdAt: string;
|
|
releasedAt: string | null;
|
|
releasedByAcnt: string | null;
|
|
}
|
|
|
|
export interface LabelSession {
|
|
id: number;
|
|
groupKey: string;
|
|
subClusterId: number;
|
|
labelParentMmsi: string;
|
|
status: 'ACTIVE' | 'CANCELLED' | 'COMPLETED';
|
|
activeFrom: string;
|
|
activeUntil: string | null;
|
|
createdByAcnt: string | null;
|
|
cancelledAt: string | null;
|
|
cancelReason: string | null;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface ReviewLog {
|
|
id: number;
|
|
groupKey: string;
|
|
subClusterId: number | null;
|
|
action: string;
|
|
selectedParentMmsi: string | null;
|
|
actorAcnt: string | null;
|
|
comment: string | null;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface PageResponse<T> {
|
|
content: T[];
|
|
totalElements: number;
|
|
totalPages: number;
|
|
number: number;
|
|
size: number;
|
|
}
|
|
|
|
async function apiRequest<T>(path: string, init?: RequestInit): Promise<T> {
|
|
const res = await fetch(`${API_BASE}${path}`, {
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
...init,
|
|
});
|
|
if (!res.ok) {
|
|
let errBody = '';
|
|
try { errBody = await res.text(); } catch { /* ignore */ }
|
|
throw new Error(`API ${res.status}: ${path} ${errBody}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
// ============================================================================
|
|
// Resolution
|
|
// ============================================================================
|
|
|
|
export function fetchReviewList(status?: string, page = 0, size = 20) {
|
|
const qs = new URLSearchParams();
|
|
if (status) qs.set('status', status);
|
|
qs.set('page', String(page));
|
|
qs.set('size', String(size));
|
|
return apiRequest<PageResponse<ParentResolution>>(`/parent-inference/review?${qs}`);
|
|
}
|
|
|
|
export function reviewParent(
|
|
groupKey: string,
|
|
subClusterId: number,
|
|
payload: { action: 'CONFIRM' | 'REJECT' | 'RESET'; selectedParentMmsi?: string; comment?: string },
|
|
) {
|
|
return apiRequest<ParentResolution>(
|
|
`/parent-inference/groups/${encodeURIComponent(groupKey)}/${subClusterId}/review`,
|
|
{ method: 'POST', body: JSON.stringify(payload) },
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Exclusions
|
|
// ============================================================================
|
|
|
|
export function fetchExclusions(scopeType?: 'GROUP' | 'GLOBAL', page = 0, size = 20) {
|
|
const qs = new URLSearchParams();
|
|
if (scopeType) qs.set('scopeType', scopeType);
|
|
qs.set('page', String(page));
|
|
qs.set('size', String(size));
|
|
return apiRequest<PageResponse<CandidateExclusion>>(`/parent-inference/exclusions?${qs}`);
|
|
}
|
|
|
|
export function excludeForGroup(
|
|
groupKey: string,
|
|
subClusterId: number,
|
|
payload: { excludedMmsi: string; reason?: string },
|
|
) {
|
|
return apiRequest<CandidateExclusion>(
|
|
`/parent-inference/groups/${encodeURIComponent(groupKey)}/${subClusterId}/exclusions`,
|
|
{ method: 'POST', body: JSON.stringify(payload) },
|
|
);
|
|
}
|
|
|
|
export function excludeGlobal(payload: { excludedMmsi: string; reason?: string }) {
|
|
return apiRequest<CandidateExclusion>(`/parent-inference/exclusions/global`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
|
|
export function releaseExclusion(exclusionId: number, reason?: string) {
|
|
return apiRequest<CandidateExclusion>(`/parent-inference/exclusions/${exclusionId}/release`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ reason }),
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// Label Sessions
|
|
// ============================================================================
|
|
|
|
export function fetchLabelSessions(status?: string, page = 0, size = 20) {
|
|
const qs = new URLSearchParams();
|
|
if (status) qs.set('status', status);
|
|
qs.set('page', String(page));
|
|
qs.set('size', String(size));
|
|
return apiRequest<PageResponse<LabelSession>>(`/parent-inference/label-sessions?${qs}`);
|
|
}
|
|
|
|
export function createLabelSession(
|
|
groupKey: string,
|
|
subClusterId: number,
|
|
payload: { labelParentMmsi: string; anchorSnapshot?: Record<string, unknown> },
|
|
) {
|
|
return apiRequest<LabelSession>(
|
|
`/parent-inference/groups/${encodeURIComponent(groupKey)}/${subClusterId}/label-sessions`,
|
|
{ method: 'POST', body: JSON.stringify(payload) },
|
|
);
|
|
}
|
|
|
|
export function cancelLabelSession(sessionId: number, reason?: string) {
|
|
return apiRequest<LabelSession>(`/parent-inference/label-sessions/${sessionId}/cancel`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ reason }),
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// Review Logs (도메인 액션 이력)
|
|
// ============================================================================
|
|
|
|
export function fetchReviewLogs(groupKey?: string, page = 0, size = 50) {
|
|
const qs = new URLSearchParams();
|
|
if (groupKey) qs.set('groupKey', groupKey);
|
|
qs.set('page', String(page));
|
|
qs.set('size', String(size));
|
|
return apiRequest<PageResponse<ReviewLog>>(`/parent-inference/review-logs?${qs}`);
|
|
}
|