wing-ops/backend/src/incidents/incidentsService.ts
jeonghyo.k 1f66723060 feat(incidents): 통합 분석 패널 HNS/구난 연동 및 사고 목록 wing.ACDNT 전환
- 우측 패널에 HNS 대기확산/긴급구난 완료 이력 목록 및 체크박스 연동
- incidents 목록에 hasHnsCompleted/hasRescueCompleted 플래그 추가
- hns/rescue 목록 API에 acdntSn 필터 추가
- /gsc/accidents 셀렉트박스 소스를 gsc.tgs_acdnt_info → wing.ACDNT 로 전환
- gsc → wing.ACDNT 동기화 마이그레이션 032 추가
2026-04-15 17:31:28 +09:00

488 lines
16 KiB
TypeScript

import { wingPool } from '../db/wingDb.js';
// ============================================================
// 인터페이스
// ============================================================
interface IncidentListItem {
acdntSn: number;
acdntCd: string;
acdntNm: string;
acdntTpCd: string;
acdntSttsCd: string;
lat: number;
lng: number;
locDc: string;
occrnDtm: string;
regionNm: string;
officeNm: string;
svrtCd: string | null;
vesselTp: string | null;
phaseCd: string;
analystNm: string | null;
oilTpCd: string | null;
spilQty: number | null;
spilUnitCd: string | null;
fcstHr: number | null;
hasPredCompleted: boolean;
hasHnsCompleted: boolean;
hasRescueCompleted: boolean;
mediaCnt: number;
hasImgAnalysis: boolean;
}
interface PredExecItem {
predExecSn: number;
algoCd: string;
execSttsCd: string;
bgngDtm: string | null;
cmplDtm: string | null;
reqdSec: number | null;
}
interface WeatherInfo {
locNm: string;
obsDtm: string;
icon: string;
temp: string;
weatherDc: string;
wind: string;
wave: string;
humid: string;
vis: string;
sst: string;
tide: string;
highTide: string;
lowTide: string;
forecast: Array<{ hour: string; icon: string; temp: string }>;
impactDc: string;
}
interface MediaInfo {
photoCnt: number;
videoCnt: number;
satCnt: number;
cctvCnt: number;
photoMeta: Record<string, unknown> | null;
droneMeta: Record<string, unknown> | null;
satMeta: Record<string, unknown> | null;
cctvMeta: Record<string, unknown> | null;
}
interface IncidentDetail extends IncidentListItem {
predictions: PredExecItem[];
weather: WeatherInfo | null;
media: MediaInfo | null;
}
// ============================================================
// 사고 목록 조회
// ============================================================
export async function listIncidents(filters: {
status?: string;
region?: string;
search?: string;
startDate?: string;
endDate?: string;
}): Promise<IncidentListItem[]> {
const conditions: string[] = ["a.USE_YN = 'Y'"];
const params: unknown[] = [];
let idx = 1;
if (filters.status) {
conditions.push(`a.ACDNT_STTS_CD = $${idx++}`);
params.push(filters.status);
}
if (filters.region) {
conditions.push(`a.REGION_NM LIKE '%' || $${idx++} || '%'`);
params.push(filters.region);
}
if (filters.search) {
conditions.push(`a.ACDNT_NM LIKE '%' || $${idx++} || '%'`);
params.push(filters.search);
}
if (filters.startDate) {
conditions.push(`a.OCCRN_DTM >= $${idx++}`);
params.push(filters.startDate);
}
if (filters.endDate) {
conditions.push(`a.OCCRN_DTM <= $${idx++}`);
params.push(filters.endDate);
}
const sql = `
SELECT a.ACDNT_SN, a.ACDNT_CD, a.ACDNT_NM, a.ACDNT_TP_CD, a.ACDNT_STTS_CD,
a.LAT, a.LNG, a.LOC_DC, a.OCCRN_DTM, a.REGION_NM, a.OFFICE_NM,
a.SVRT_CD, a.VESSEL_TP, a.PHASE_CD, a.ANALYST_NM,
s.OIL_TP_CD, s.SPIL_QTY, s.SPIL_UNIT_CD, s.FCST_HR,
COALESCE(s.HAS_IMG_ANALYSIS, FALSE) AS has_img_analysis,
EXISTS (
SELECT 1 FROM wing.PRED_EXEC pe
WHERE pe.ACDNT_SN = a.ACDNT_SN AND pe.EXEC_STTS_CD = 'COMPLETED'
) AS has_pred_completed,
EXISTS (
SELECT 1 FROM wing.HNS_ANALYSIS h
WHERE h.ACDNT_SN = a.ACDNT_SN
AND h.EXEC_STTS_CD = 'COMPLETED'
AND h.USE_YN = 'Y'
) AS has_hns_completed,
EXISTS (
SELECT 1 FROM wing.RESCUE_OPS r
WHERE r.ACDNT_SN = a.ACDNT_SN
AND r.STTS_CD = 'RESOLVED'
AND r.USE_YN = 'Y'
) AS has_rescue_completed,
COALESCE(m.PHOTO_CNT, 0) + COALESCE(m.VIDEO_CNT, 0)
+ COALESCE(m.SAT_CNT, 0) + COALESCE(m.CCTV_CNT, 0) AS media_cnt
FROM wing.ACDNT a
LEFT JOIN LATERAL (
SELECT OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, FCST_HR,
IMG_RSLT_DATA IS NOT NULL AS HAS_IMG_ANALYSIS
FROM wing.SPIL_DATA
WHERE ACDNT_SN = a.ACDNT_SN
ORDER BY SPIL_DATA_SN
LIMIT 1
) s ON TRUE
LEFT JOIN wing.ACDNT_MEDIA m ON m.ACDNT_SN = a.ACDNT_SN
WHERE ${conditions.join(' AND ')}
ORDER BY a.OCCRN_DTM DESC
`;
const { rows } = await wingPool.query(sql, params);
return rows.map((r: Record<string, unknown>) => ({
acdntSn: r.acdnt_sn as number,
acdntCd: r.acdnt_cd as string,
acdntNm: r.acdnt_nm as string,
acdntTpCd: r.acdnt_tp_cd as string,
acdntSttsCd: r.acdnt_stts_cd as string,
lat: parseFloat(r.lat as string),
lng: parseFloat(r.lng as string),
locDc: r.loc_dc as string,
occrnDtm: (r.occrn_dtm as Date).toISOString(),
regionNm: r.region_nm as string,
officeNm: r.office_nm as string,
svrtCd: (r.svrt_cd as string) ?? null,
vesselTp: (r.vessel_tp as string) ?? null,
phaseCd: r.phase_cd as string,
analystNm: (r.analyst_nm as string) ?? null,
oilTpCd: (r.oil_tp_cd as string) ?? null,
spilQty: r.spil_qty != null ? parseFloat(r.spil_qty as string) : null,
spilUnitCd: (r.spil_unit_cd as string) ?? null,
fcstHr: (r.fcst_hr as number) ?? null,
hasPredCompleted: r.has_pred_completed as boolean,
hasHnsCompleted: r.has_hns_completed as boolean,
hasRescueCompleted: r.has_rescue_completed as boolean,
mediaCnt: Number(r.media_cnt),
hasImgAnalysis: (r.has_img_analysis as boolean) ?? false,
}));
}
// ============================================================
// 사고 상세 조회
// ============================================================
export async function getIncident(acdntSn: number): Promise<IncidentDetail | null> {
// 기본 정보 + 첫 유출건
const baseSql = `
SELECT a.ACDNT_SN, a.ACDNT_CD, a.ACDNT_NM, a.ACDNT_TP_CD, a.ACDNT_STTS_CD,
a.LAT, a.LNG, a.LOC_DC, a.OCCRN_DTM, a.REGION_NM, a.OFFICE_NM,
a.SVRT_CD, a.VESSEL_TP, a.PHASE_CD, a.ANALYST_NM,
s.OIL_TP_CD, s.SPIL_QTY, s.SPIL_UNIT_CD, s.FCST_HR,
COALESCE(s.HAS_IMG_ANALYSIS, FALSE) AS has_img_analysis,
EXISTS (
SELECT 1 FROM wing.PRED_EXEC pe
WHERE pe.ACDNT_SN = a.ACDNT_SN AND pe.EXEC_STTS_CD = 'COMPLETED'
) AS has_pred_completed,
EXISTS (
SELECT 1 FROM wing.HNS_ANALYSIS h
WHERE h.ACDNT_SN = a.ACDNT_SN
AND h.EXEC_STTS_CD = 'COMPLETED'
AND h.USE_YN = 'Y'
) AS has_hns_completed,
EXISTS (
SELECT 1 FROM wing.RESCUE_OPS r
WHERE r.ACDNT_SN = a.ACDNT_SN
AND r.STTS_CD = 'RESOLVED'
AND r.USE_YN = 'Y'
) AS has_rescue_completed,
COALESCE(m.PHOTO_CNT, 0) + COALESCE(m.VIDEO_CNT, 0)
+ COALESCE(m.SAT_CNT, 0) + COALESCE(m.CCTV_CNT, 0) AS media_cnt
FROM wing.ACDNT a
LEFT JOIN LATERAL (
SELECT OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, FCST_HR,
IMG_RSLT_DATA IS NOT NULL AS HAS_IMG_ANALYSIS
FROM wing.SPIL_DATA
WHERE ACDNT_SN = a.ACDNT_SN
ORDER BY SPIL_DATA_SN
LIMIT 1
) s ON TRUE
LEFT JOIN wing.ACDNT_MEDIA m ON m.ACDNT_SN = a.ACDNT_SN
WHERE a.ACDNT_SN = $1 AND a.USE_YN = 'Y'
`;
const { rows: baseRows } = await wingPool.query(baseSql, [acdntSn]);
if (baseRows.length === 0) return null;
const r = baseRows[0] as Record<string, unknown>;
const predictions = await listIncidentPredictions(acdntSn);
const weather = await getIncidentWeather(acdntSn);
const media = await getIncidentMedia(acdntSn);
return {
acdntSn: r.acdnt_sn as number,
acdntCd: r.acdnt_cd as string,
acdntNm: r.acdnt_nm as string,
acdntTpCd: r.acdnt_tp_cd as string,
acdntSttsCd: r.acdnt_stts_cd as string,
lat: parseFloat(r.lat as string),
lng: parseFloat(r.lng as string),
locDc: r.loc_dc as string,
occrnDtm: (r.occrn_dtm as Date).toISOString(),
regionNm: r.region_nm as string,
officeNm: r.office_nm as string,
svrtCd: (r.svrt_cd as string) ?? null,
vesselTp: (r.vessel_tp as string) ?? null,
phaseCd: r.phase_cd as string,
analystNm: (r.analyst_nm as string) ?? null,
oilTpCd: (r.oil_tp_cd as string) ?? null,
spilQty: r.spil_qty != null ? parseFloat(r.spil_qty as string) : null,
spilUnitCd: (r.spil_unit_cd as string) ?? null,
fcstHr: (r.fcst_hr as number) ?? null,
hasPredCompleted: r.has_pred_completed as boolean,
hasHnsCompleted: r.has_hns_completed as boolean,
hasRescueCompleted: r.has_rescue_completed as boolean,
mediaCnt: Number(r.media_cnt),
hasImgAnalysis: (r.has_img_analysis as boolean) ?? false,
predictions,
weather,
media,
};
}
// ============================================================
// 예측 실행 목록 조회
// ============================================================
export async function listIncidentPredictions(acdntSn: number): Promise<PredExecItem[]> {
const sql = `
SELECT PRED_EXEC_SN, ALGO_CD, EXEC_STTS_CD, BGNG_DTM, CMPL_DTM, REQD_SEC
FROM wing.PRED_EXEC
WHERE ACDNT_SN = $1
ORDER BY ALGO_CD
`;
const { rows } = await wingPool.query(sql, [acdntSn]);
return rows.map((r: Record<string, unknown>) => ({
predExecSn: r.pred_exec_sn as number,
algoCd: r.algo_cd as string,
execSttsCd: r.exec_stts_cd as string,
bgngDtm: r.bgng_dtm ? (r.bgng_dtm as Date).toISOString() : null,
cmplDtm: r.cmpl_dtm ? (r.cmpl_dtm as Date).toISOString() : null,
reqdSec: (r.reqd_sec as number) ?? null,
}));
}
// ============================================================
// 기상정보 조회 (최신 1건)
// ============================================================
export async function getIncidentWeather(acdntSn: number): Promise<WeatherInfo | null> {
const sql = `
SELECT LOC_NM, OBS_DTM, ICON, TEMP, WEATHER_DC, WIND, WAVE,
HUMID, VIS, SST, TIDE, HIGH_TIDE, LOW_TIDE, FORECAST, IMPACT_DC
FROM wing.ACDNT_WEATHER
WHERE ACDNT_SN = $1
ORDER BY OBS_DTM DESC
LIMIT 1
`;
const { rows } = await wingPool.query(sql, [acdntSn]);
if (rows.length === 0) return null;
const r = rows[0] as Record<string, unknown>;
return {
locNm: (r.loc_nm as string | null) ?? '-',
obsDtm: r.obs_dtm ? (r.obs_dtm as Date).toISOString() : '-',
icon: (r.icon as string | null) ?? '',
temp: (r.temp as string | null) ?? '-',
weatherDc: (r.weather_dc as string | null) ?? '-',
wind: (r.wind as string | null) ?? '-',
wave: (r.wave as string | null) ?? '-',
humid: (r.humid as string | null) ?? '-',
vis: (r.vis as string | null) ?? '-',
sst: (r.sst as string | null) ?? '-',
tide: (r.tide as string | null) ?? '-',
highTide: (r.high_tide as string | null) ?? '-',
lowTide: (r.low_tide as string | null) ?? '-',
forecast: (r.forecast as Array<{ hour: string; icon: string; temp: string }>) ?? [],
impactDc: (r.impact_dc as string | null) ?? '-',
};
}
// ============================================================
// 기상정보 저장 (예측 실행 시 스냅샷 저장)
// ============================================================
interface WeatherSnapshotPayload {
stationName?: string;
capturedAt?: string;
wind?: {
speed?: number;
direction?: number;
directionLabel?: string;
speed_1k?: number;
speed_3k?: number;
};
wave?: {
height?: number;
maxHeight?: number;
period?: number;
direction?: string;
};
temperature?: {
current?: number;
feelsLike?: number;
};
pressure?: number;
visibility?: number;
salinity?: number;
astronomy?: {
sunrise?: string;
sunset?: string;
moonrise?: string;
moonset?: string;
moonPhase?: string;
tidalRange?: number;
} | null;
alert?: string | null;
forecast?: unknown[] | null;
}
export async function saveIncidentWeather(
acdntSn: number,
snapshot: WeatherSnapshotPayload,
): Promise<number> {
// 팝업 표시용 포맷 문자열
const windStr = (snapshot.wind?.directionLabel && snapshot.wind?.speed != null)
? `${snapshot.wind.directionLabel} ${snapshot.wind.speed}m/s` : null;
const waveStr = snapshot.wave?.height != null ? `${snapshot.wave.height}m` : null;
const tempStr = snapshot.temperature?.feelsLike != null ? `${snapshot.temperature.feelsLike}°C` : null;
const vis = snapshot.visibility != null ? String(snapshot.visibility) : null;
const sst = snapshot.temperature?.current != null ? String(snapshot.temperature.current) : null;
const highTideStr = snapshot.astronomy?.tidalRange != null
? `조차 ${snapshot.astronomy.tidalRange}m` : null;
// 24h 예보: WeatherSnapshot 형식 → 팝업 표시 형식 변환
type ForecastItem = { time?: string; icon?: string; temperature?: number };
const forecastDisplay = (snapshot.forecast as ForecastItem[] | null)?.map(f => ({
hour: f.time ?? '',
icon: f.icon ?? '⛅',
temp: f.temperature != null ? `${Math.round(f.temperature)}°` : '-',
})) ?? null;
const sql = `
INSERT INTO wing.ACDNT_WEATHER (
ACDNT_SN, LOC_NM, OBS_DTM,
WIND_SPEED, WIND_DIR, WIND_DIR_LBL, WIND_SPEED_1K, WIND_SPEED_3K,
PRESSURE, VIS,
WAVE_HEIGHT, WAVE_MAX_HT, WAVE_PERIOD, WAVE_DIR,
SST, AIR_TEMP, SALINITY,
SUNRISE, SUNSET, MOONRISE, MOONSET, MOON_PHASE, TIDAL_RANGE,
WEATHER_ALERT, FORECAST,
TEMP, WIND, WAVE, ICON, HIGH_TIDE, IMPACT_DC
) VALUES (
$1, $2, NOW(),
$3, $4, $5, $6, $7,
$8, $9,
$10, $11, $12, $13,
$14, $15, $16,
$17, $18, $19, $20, $21, $22,
$23, $24,
$25, $26, $27, $28, $29, $30
)
RETURNING WEATHER_SN
`;
const { rows } = await wingPool.query(sql, [
acdntSn,
snapshot.stationName ?? null,
snapshot.wind?.speed ?? null,
snapshot.wind?.direction ?? null,
snapshot.wind?.directionLabel ?? null,
snapshot.wind?.speed_1k ?? null,
snapshot.wind?.speed_3k ?? null,
snapshot.pressure ?? null,
vis,
snapshot.wave?.height ?? null,
snapshot.wave?.maxHeight ?? null,
snapshot.wave?.period ?? null,
snapshot.wave?.direction ?? null,
sst,
snapshot.temperature?.feelsLike ?? null,
snapshot.salinity ?? null,
snapshot.astronomy?.sunrise ?? null,
snapshot.astronomy?.sunset ?? null,
snapshot.astronomy?.moonrise ?? null,
snapshot.astronomy?.moonset ?? null,
snapshot.astronomy?.moonPhase ?? null,
snapshot.astronomy?.tidalRange ?? null,
snapshot.alert ?? null,
forecastDisplay ? JSON.stringify(forecastDisplay) : null,
tempStr,
windStr,
waveStr,
'🌊',
highTideStr,
snapshot.alert ?? null,
]);
return (rows[0] as Record<string, unknown>).weather_sn as number;
}
// ============================================================
// 미디어 정보 조회
// ============================================================
export async function getIncidentMedia(acdntSn: number): Promise<MediaInfo | null> {
const sql = `
SELECT PHOTO_CNT, VIDEO_CNT, SAT_CNT, CCTV_CNT,
PHOTO_META, DRONE_META, SAT_META, CCTV_META
FROM wing.ACDNT_MEDIA
WHERE ACDNT_SN = $1
LIMIT 1
`;
const { rows } = await wingPool.query(sql, [acdntSn]);
if (rows.length === 0) return null;
const r = rows[0] as Record<string, unknown>;
return {
photoCnt: (r.photo_cnt as number) ?? 0,
videoCnt: (r.video_cnt as number) ?? 0,
satCnt: (r.sat_cnt as number) ?? 0,
cctvCnt: (r.cctv_cnt as number) ?? 0,
photoMeta: (r.photo_meta as Record<string, unknown>) ?? null,
droneMeta: (r.drone_meta as Record<string, unknown>) ?? null,
satMeta: (r.sat_meta as Record<string, unknown>) ?? null,
cctvMeta: (r.cctv_meta as Record<string, unknown>) ?? null,
};
}
// ============================================================
// 이미지 분석 데이터 조회
// ============================================================
export async function getIncidentImageAnalysis(acdntSn: number): Promise<Record<string, unknown> | null> {
const sql = `
SELECT IMG_RSLT_DATA
FROM wing.SPIL_DATA
WHERE ACDNT_SN = $1 AND IMG_RSLT_DATA IS NOT NULL
ORDER BY SPIL_DATA_SN
LIMIT 1
`;
const { rows } = await wingPool.query(sql, [acdntSn]);
if (rows.length === 0) return null;
return (rows[0] as Record<string, unknown>).img_rslt_data as Record<string, unknown>;
}