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; mediaCnt: number; } 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(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 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, mediaCnt: Number(r.media_cnt), })); } // ============================================================ // 사고 상세 조회 // ============================================================ 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(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 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, mediaCnt: Number(r.media_cnt), 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, obsDtm: (r.obs_dtm as Date).toISOString(), icon: r.icon as string, temp: r.temp as string, weatherDc: r.weather_dc as string, wind: r.wind as string, wave: r.wave as string, humid: r.humid as string, vis: r.vis as string, sst: r.sst as string, tide: r.tide as string, highTide: r.high_tide as string, lowTide: r.low_tide as string, forecast: (r.forecast as Array<{ hour: string; icon: string; temp: string }>) ?? [], impactDc: r.impact_dc as string, }; } // ============================================================ // 미디어 정보 조회 // ============================================================ 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, }; }