422 lines
13 KiB
TypeScript
422 lines
13 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;
|
|
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<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(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<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,
|
|
mediaCnt: Number(r.media_cnt),
|
|
}));
|
|
}
|
|
|
|
// ============================================================
|
|
// 사고 상세 조회
|
|
// ============================================================
|
|
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(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<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,
|
|
mediaCnt: Number(r.media_cnt),
|
|
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,
|
|
};
|
|
}
|