wing-ops/backend/src/incidents/incidentsService.ts
htlee 46c7307ab9 feat(incidents): 사고관리 탭 mock → DB/API 전환
- 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>
2026-02-28 22:20:37 +09:00

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,
};
}