- DB: ACDNT, SPIL_DATA, PRED_EXEC, ACDNT_WEATHER, ACDNT_MEDIA 5개 테이블 생성 - 시드: 사고 12건, 유출정보 12건, 예측실행 18건, 기상 6건, 미디어 6건 - 백엔드: incidentsService + incidentsRouter (사고 목록/상세/예측/기상/미디어 5개 API) - 프론트: IncidentsView, IncidentTable, IncidentsLeftPanel, MediaModal mock → API 전환 - mockIncidents, WEATHER_DATA, MEDIA_DATA 3개 mock 완전 제거 - SECTION_DATA, MOCK_SENSITIVE, mockVessels는 별도 도메인으로 유지 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
303 lines
9.5 KiB
TypeScript
303 lines
9.5 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,
|
|
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<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,
|
|
};
|
|
}
|