import { wingPool } from '../db/wingDb.js'; interface PredictionAnalysis { acdntSn: number; acdntNm: string; occurredAt: string; analysisDate: string; requestor: string; duration: string; oilType: string; volume: number | null; location: string; lat: number | null; lon: number | null; kospsStatus: string; poseidonStatus: string; opendriftStatus: string; backtrackStatus: string; analyst: string; officeName: string; acdntSttsCd: string; } interface PredictionDetail { acdnt: { acdntSn: number; acdntNm: string; occurredAt: string; lat: number | null; lon: number | null; location: string; analyst: string; officeName: string; }; spill: { oilType: string; volume: number | null; unit: string; fcstHr: number | null; } | null; vessels: Array<{ vesselInfoSn: number; imoNo: string; vesselNm: string; vesselTp: string; loaM: number | null; breadthM: number | null; draftM: number | null; gt: number | null; dwt: number | null; builtYr: number | null; flagCd: string; callsign: string; engineDc: string; insuranceData: unknown; }>; weather: Array<{ obsDtm: string; locNm: string; temp: string; weatherDc: string; wind: string; wave: string; humid: string; vis: string; sst: string; }>; } interface BacktrackResult { backtrackSn: number; acdntSn: number; estSpilDtm: string | null; anlysRange: string | null; lon: number | null; lat: number | null; srchRadiusNm: number | null; totalVessels: number | null; execSttsCd: string; rsltData: unknown; regDtm: string; } interface CreateBacktrackInput { acdntSn: number; lat: number; lon: number; estSpilDtm?: string; anlysRange?: string; srchRadiusNm?: number; } interface SaveBoomLineInput { acdntSn: number; boomNm: string; priorityOrd?: number; geojson: unknown; lengthM?: number; efficiencyPct?: number; } interface BoomLineItem { boomLineSn: number; acdntSn: number; boomNm: string; priorityOrd: number; geom: unknown; lengthM: number | null; efficiencyPct: number | null; sttsCd: string; regDtm: string; } interface ListAnalysesInput { search?: string; } export async function listAnalyses(input: ListAnalysesInput): Promise { const params: unknown[] = []; const conditions: string[] = ["A.USE_YN = 'Y'"]; if (input.search) { params.push(`%${input.search}%`); conditions.push(`(A.ACDNT_NM ILIKE $${params.length} OR A.LOC_DC ILIKE $${params.length})`); } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; const sql = ` SELECT A.ACDNT_SN, A.ACDNT_NM, A.ACDNT_STTS_CD, A.OCCRN_DTM, A.LAT, A.LNG, A.LOC_DC, A.ANALYST_NM, A.OFFICE_NM, A.REGION_NM, S.OIL_TP_CD, S.SPIL_QTY, S.SPIL_UNIT_CD, S.FCST_HR, P.KOSPS_STATUS, P.POSEIDON_STATUS, P.OPENDRIFT_STATUS, B.BACKTRACK_STATUS FROM ACDNT A LEFT JOIN SPIL_DATA S ON S.ACDNT_SN = A.ACDNT_SN LEFT JOIN ( SELECT ACDNT_SN, MAX(CASE WHEN ALGO_CD = 'KOSPS' THEN EXEC_STTS_CD END) AS KOSPS_STATUS, MAX(CASE WHEN ALGO_CD = 'POSEIDON' THEN EXEC_STTS_CD END) AS POSEIDON_STATUS, MAX(CASE WHEN ALGO_CD = 'OPENDRIFT' THEN EXEC_STTS_CD END) AS OPENDRIFT_STATUS FROM PRED_EXEC GROUP BY ACDNT_SN ) P ON P.ACDNT_SN = A.ACDNT_SN LEFT JOIN ( SELECT ACDNT_SN, MAX(CASE WHEN B.EXEC_STTS_CD IS NOT NULL THEN B.EXEC_STTS_CD ELSE 'pending' END) AS BACKTRACK_STATUS FROM BACKTRACK B GROUP BY ACDNT_SN ) B ON B.ACDNT_SN = A.ACDNT_SN ${whereClause} ORDER BY A.OCCRN_DTM DESC `; const { rows } = await wingPool.query(sql, params); return rows.map((row: Record) => ({ acdntSn: Number(row['acdnt_sn']), acdntNm: String(row['acdnt_nm'] ?? ''), occurredAt: row['occrn_dtm'] ? String(row['occrn_dtm']) : '', analysisDate: row['occrn_dtm'] ? String(row['occrn_dtm']) : '', requestor: String(row['analyst_nm'] ?? ''), duration: row['fcst_hr'] != null ? `${row['fcst_hr']}hr` : '', oilType: String(row['oil_tp_cd'] ?? ''), volume: row['spil_qty'] != null ? parseFloat(String(row['spil_qty'])) : null, location: String(row['loc_dc'] ?? ''), lat: row['lat'] != null ? parseFloat(String(row['lat'])) : null, lon: row['lng'] != null ? parseFloat(String(row['lng'])) : null, kospsStatus: String(row['kosps_status'] ?? 'pending').toLowerCase(), poseidonStatus: String(row['poseidon_status'] ?? 'pending').toLowerCase(), opendriftStatus: String(row['opendrift_status'] ?? 'pending').toLowerCase(), backtrackStatus: String(row['backtrack_status'] ?? 'pending').toLowerCase(), analyst: String(row['analyst_nm'] ?? ''), officeName: String(row['office_nm'] ?? ''), acdntSttsCd: String(row['acdnt_stts_cd'] ?? 'ACTIVE'), })); } export async function getAnalysisDetail(acdntSn: number): Promise { const acdntSql = ` SELECT A.ACDNT_SN, A.ACDNT_NM, A.OCCRN_DTM, A.LAT, A.LNG, A.LOC_DC, A.ANALYST_NM, A.OFFICE_NM FROM ACDNT A WHERE A.ACDNT_SN = $1 AND A.USE_YN = 'Y' `; const { rows: acdntRows } = await wingPool.query(acdntSql, [acdntSn]); if (acdntRows.length === 0) return null; const a = acdntRows[0] as Record; const spillSql = ` SELECT OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, FCST_HR FROM SPIL_DATA WHERE ACDNT_SN = $1 ORDER BY SPIL_DATA_SN ASC LIMIT 1 `; const { rows: spillRows } = await wingPool.query(spillSql, [acdntSn]); const vesselSql = ` SELECT VESSEL_INFO_SN, IMO_NO, VESSEL_NM, VESSEL_TP, LOA_M, BREADTH_M, DRAFT_M, GT, DWT, BUILT_YR, FLAG_CD, CALLSIGN, ENGINE_DC, INSURANCE_DATA FROM VESSEL_INFO WHERE ACDNT_SN = $1 ORDER BY VESSEL_INFO_SN ASC `; const { rows: vesselRows } = await wingPool.query(vesselSql, [acdntSn]); const weatherSql = ` SELECT OBS_DTM, LOC_NM, TEMP, WEATHER_DC, WIND, WAVE, HUMID, VIS, SST FROM ACDNT_WEATHER WHERE ACDNT_SN = $1 ORDER BY OBS_DTM ASC `; const { rows: weatherRows } = await wingPool.query(weatherSql, [acdntSn]); const spill = spillRows.length > 0 ? (() => { const s = spillRows[0] as Record; return { oilType: String(s['oil_tp_cd'] ?? ''), volume: s['spil_qty'] != null ? parseFloat(String(s['spil_qty'])) : null, unit: String(s['spil_unit_cd'] ?? ''), fcstHr: s['fcst_hr'] != null ? parseFloat(String(s['fcst_hr'])) : null, }; })() : null; const vessels = vesselRows.map((v: Record) => ({ vesselInfoSn: Number(v['vessel_info_sn']), imoNo: String(v['imo_no'] ?? ''), vesselNm: String(v['vessel_nm'] ?? ''), vesselTp: String(v['vessel_tp'] ?? ''), loaM: v['loa_m'] != null ? parseFloat(String(v['loa_m'])) : null, breadthM: v['breadth_m'] != null ? parseFloat(String(v['breadth_m'])) : null, draftM: v['draft_m'] != null ? parseFloat(String(v['draft_m'])) : null, gt: v['gt'] != null ? parseFloat(String(v['gt'])) : null, dwt: v['dwt'] != null ? parseFloat(String(v['dwt'])) : null, builtYr: v['built_yr'] != null ? Number(v['built_yr']) : null, flagCd: String(v['flag_cd'] ?? ''), callsign: String(v['callsign'] ?? ''), engineDc: String(v['engine_dc'] ?? ''), insuranceData: v['insurance_data'] ?? null, })); const weather = weatherRows.map((w: Record) => ({ obsDtm: w['obs_dtm'] ? String(w['obs_dtm']) : '', locNm: String(w['loc_nm'] ?? ''), temp: String(w['temp'] ?? ''), weatherDc: String(w['weather_dc'] ?? ''), wind: String(w['wind'] ?? ''), wave: String(w['wave'] ?? ''), humid: String(w['humid'] ?? ''), vis: String(w['vis'] ?? ''), sst: String(w['sst'] ?? ''), })); return { acdnt: { acdntSn: Number(a['acdnt_sn']), acdntNm: String(a['acdnt_nm'] ?? ''), occurredAt: a['occrn_dtm'] ? String(a['occrn_dtm']) : '', lat: a['lat'] != null ? parseFloat(String(a['lat'])) : null, lon: a['lng'] != null ? parseFloat(String(a['lng'])) : null, location: String(a['loc_dc'] ?? ''), analyst: String(a['analyst_nm'] ?? ''), officeName: String(a['office_nm'] ?? ''), }, spill, vessels, weather, }; } export async function getBacktrack(sn: number): Promise { const sql = ` SELECT BACKTRACK_SN, ACDNT_SN, EST_SPIL_DTM, ANLYS_RANGE, LON, LAT, SRCH_RADIUS_NM, TOTAL_VESSELS, EXEC_STTS_CD, RSLT_DATA, REG_DTM FROM BACKTRACK WHERE BACKTRACK_SN = $1 AND USE_YN = 'Y' `; const { rows } = await wingPool.query(sql, [sn]); if (rows.length === 0) return null; return rowToBacktrack(rows[0] as Record); } export async function listBacktracksByAcdnt(acdntSn: number): Promise { const sql = ` SELECT BACKTRACK_SN, ACDNT_SN, EST_SPIL_DTM, ANLYS_RANGE, LON, LAT, SRCH_RADIUS_NM, TOTAL_VESSELS, EXEC_STTS_CD, RSLT_DATA, REG_DTM FROM BACKTRACK WHERE ACDNT_SN = $1 AND USE_YN = 'Y' ORDER BY REG_DTM DESC `; const { rows } = await wingPool.query(sql, [acdntSn]); return rows.map((r: Record) => rowToBacktrack(r)); } function rowToBacktrack(r: Record): BacktrackResult { return { backtrackSn: Number(r['backtrack_sn']), acdntSn: Number(r['acdnt_sn']), estSpilDtm: r['est_spil_dtm'] ? String(r['est_spil_dtm']) : null, anlysRange: r['anlys_range'] ? String(r['anlys_range']) : null, lon: r['lon'] != null ? parseFloat(String(r['lon'])) : null, lat: r['lat'] != null ? parseFloat(String(r['lat'])) : null, srchRadiusNm: r['srch_radius_nm'] != null ? parseFloat(String(r['srch_radius_nm'])) : null, totalVessels: r['total_vessels'] != null ? Number(r['total_vessels']) : null, execSttsCd: String(r['exec_stts_cd'] ?? ''), rsltData: r['rslt_data'] ?? null, regDtm: String(r['reg_dtm'] ?? ''), }; } export async function createBacktrack( input: CreateBacktrackInput, ): Promise<{ backtrackSn: number }> { const { acdntSn, lat, lon, estSpilDtm, anlysRange, srchRadiusNm } = input; const sql = ` INSERT INTO BACKTRACK (ACDNT_SN, LAT, LON, GEOM, LOC_DC, EST_SPIL_DTM, ANLYS_RANGE, SRCH_RADIUS_NM, EXEC_STTS_CD) VALUES ( $1, $2, $3, ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326), $3 || ' + ' || $2, $4, $5, $6, 'PENDING' ) RETURNING BACKTRACK_SN `; const { rows } = await wingPool.query(sql, [ acdntSn, lat, lon, estSpilDtm || null, anlysRange || null, srchRadiusNm || null, ]); return { backtrackSn: Number((rows[0] as Record)['backtrack_sn']) }; } export async function saveBoomLine(input: SaveBoomLineInput): Promise<{ boomLineSn: number }> { const { acdntSn, boomNm, priorityOrd = 0, geojson, lengthM, efficiencyPct } = input; const sql = ` INSERT INTO BOOM_LINE (ACDNT_SN, BOOM_NM, PRIORITY_ORD, GEOM, LENGTH_M, EFFICIENCY_PCT) VALUES ($1, $2, $3, ST_GeomFromGeoJSON($4), $5, $6) RETURNING BOOM_LINE_SN `; const { rows } = await wingPool.query(sql, [ acdntSn, boomNm, priorityOrd, JSON.stringify(geojson), lengthM || null, efficiencyPct || null, ]); return { boomLineSn: Number((rows[0] as Record)['boom_line_sn']) }; } interface TrajectoryParticle { lat: number; lon: number; stranded?: 0 | 1; } interface TrajectoryWindPoint { lat: number; lon: number; wind_speed: number; wind_direction: number; } interface TrajectoryHydrGrid { lonInterval: number[]; boundLonLat: { top: number; bottom: number; left: number; right: number }; rows: number; cols: number; latInterval: number[]; } interface TrajectoryTimeStep { particles: TrajectoryParticle[]; remaining_volume_m3: number; weathered_volume_m3: number; pollution_area_km2: number; beached_volume_m3: number; pollution_coast_length_m: number; center_lat?: number; center_lon?: number; wind_data?: TrajectoryWindPoint[]; hydr_data?: [number[][], number[][]]; hydr_grid?: TrajectoryHydrGrid; } interface TrajectoryResult { trajectory: Array<{ lat: number; lon: number; time: number; particle: number; stranded?: 0 | 1 }>; summary: { remainingVolume: number; weatheredVolume: number; pollutionArea: number; beachedVolume: number; pollutionCoastLength: number; }; centerPoints: Array<{ lat: number; lon: number; time: number }>; windData: TrajectoryWindPoint[][]; hydrData: ({ value: [number[][], number[][]]; grid: TrajectoryHydrGrid } | null)[]; } function transformTrajectoryResult(rawResult: TrajectoryTimeStep[]): TrajectoryResult { const trajectory = rawResult.flatMap((step, stepIdx) => step.particles.map((p, i) => ({ lat: p.lat, lon: p.lon, time: stepIdx, particle: i, stranded: p.stranded, })) ); const lastStep = rawResult[rawResult.length - 1]; const summary = { remainingVolume: lastStep.remaining_volume_m3, weatheredVolume: lastStep.weathered_volume_m3, pollutionArea: lastStep.pollution_area_km2, beachedVolume: lastStep.beached_volume_m3, pollutionCoastLength: lastStep.pollution_coast_length_m, }; const centerPoints = rawResult .map((step, stepIdx) => step.center_lat != null && step.center_lon != null ? { lat: step.center_lat, lon: step.center_lon, time: stepIdx } : null ) .filter((p): p is { lat: number; lon: number; time: number } => p !== null); const windData = rawResult.map((step) => step.wind_data ?? []); const hydrData = rawResult.map((step) => step.hydr_data && step.hydr_grid ? { value: step.hydr_data, grid: step.hydr_grid } : null ); return { trajectory, summary, centerPoints, windData, hydrData }; } export async function getAnalysisTrajectory(acdntSn: number): Promise { const sql = ` SELECT RSLT_DATA FROM wing.PRED_EXEC WHERE ACDNT_SN = $1 AND ALGO_CD = 'OPENDRIFT' AND EXEC_STTS_CD = 'COMPLETED' ORDER BY CMPL_DTM DESC LIMIT 1 `; const { rows } = await wingPool.query(sql, [acdntSn]); if (rows.length === 0 || !rows[0].rslt_data) return null; return transformTrajectoryResult(rows[0].rslt_data as TrajectoryTimeStep[]); } export async function listBoomLines(acdntSn: number): Promise { const sql = ` SELECT BOOM_LINE_SN, ACDNT_SN, BOOM_NM, PRIORITY_ORD, ST_AsGeoJSON(GEOM) AS GEOM, LENGTH_M, EFFICIENCY_PCT, STTS_CD, REG_DTM FROM BOOM_LINE WHERE ACDNT_SN = $1 AND USE_YN = 'Y' ORDER BY PRIORITY_ORD ASC `; const { rows } = await wingPool.query(sql, [acdntSn]); return rows.map((r: Record) => ({ boomLineSn: Number(r['boom_line_sn']), acdntSn: Number(r['acdnt_sn']), boomNm: String(r['boom_nm'] ?? ''), priorityOrd: Number(r['priority_ord'] ?? 0), geom: r['geom'] != null ? JSON.parse(String(r['geom'])) : null, lengthM: r['length_m'] != null ? parseFloat(String(r['length_m'])) : null, efficiencyPct: r['efficiency_pct'] != null ? parseFloat(String(r['efficiency_pct'])) : null, sttsCd: String(r['stts_cd'] ?? 'PLANNED'), regDtm: String(r['reg_dtm'] ?? ''), })); }