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; 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 | null; droneMeta: Record | null; satMeta: Record | null; cctvMeta: Record | 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 { 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, 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) => ({ 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, mediaCnt: Number(r.media_cnt), hasImgAnalysis: (r.has_img_analysis as boolean) ?? false, })); } // ============================================================ // 사고 상세 조회 // ============================================================ export async function getIncident(acdntSn: number): Promise { // 기본 정보 + 첫 유출건 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, 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; 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, mediaCnt: Number(r.media_cnt), hasImgAnalysis: (r.has_img_analysis as boolean) ?? false, predictions, weather, media, }; } // ============================================================ // 예측 실행 목록 조회 // ============================================================ export async function listIncidentPredictions(acdntSn: number): Promise { 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) => ({ 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 { 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; 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 { // 팝업 표시용 포맷 문자열 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).weather_sn as number; } // ============================================================ // 미디어 정보 조회 // ============================================================ export async function getIncidentMedia(acdntSn: number): Promise { 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; 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) ?? null, droneMeta: (r.drone_meta as Record) ?? null, satMeta: (r.sat_meta as Record) ?? null, cctvMeta: (r.cctv_meta as Record) ?? null, }; } // ============================================================ // 이미지 분석 데이터 조회 // ============================================================ export async function getIncidentImageAnalysis(acdntSn: number): Promise | 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).img_rslt_data as Record; }