wing-ops/backend/src/hns/hnsService.ts
jeonghyo.k 1da2553694 feat(incidents): 통합 분석 패널 분할 뷰 및 유출유 확산 요약 API 추가
- Incidents 통합 분석 시 이전 분석 결과를 분할 화면으로 표출
- 유출유/HNS/구난 분석 선택 모달(AnalysisSelectModal) 추가
- prediction /analyses/:acdntSn/oil-summary API 신규 (primary + byModel)
- HNS 분석 생성 시 acdntSn 연결 지원
- GSC 사고 목록 응답에 acdntSn 노출
- 민감자원 누적/카테고리 관리 및 HNS 확산 레이어 유틸(hnsDispersionLayers) 추가
2026-04-16 15:24:06 +09:00

289 lines
8.6 KiB
TypeScript

import { wingPool } from '../db/wingDb.js'
interface HnsSearchParams {
q?: string
type?: 'abbreviation' | 'nameKr' | 'nameEn' | 'casNumber' | 'unNumber' | 'cargoCode'
sebc?: string
page?: number
limit?: number
}
export async function searchSubstances(params: HnsSearchParams) {
const { q, type, sebc, page = 1, limit = 50 } = params
const conditions: string[] = ["USE_YN = 'Y'"]
const values: (string | number)[] = []
let paramIdx = 1
if (q && q.trim()) {
const keyword = q.trim()
switch (type) {
case 'abbreviation':
conditions.push(`ABBREVIATION ILIKE $${paramIdx}`)
values.push(`%${keyword}%`)
break
case 'nameKr':
conditions.push(`NM_KR ILIKE $${paramIdx}`)
values.push(`%${keyword}%`)
break
case 'nameEn':
conditions.push(`NM_EN ILIKE $${paramIdx}`)
values.push(`%${keyword}%`)
break
case 'casNumber':
conditions.push(`CAS_NO ILIKE $${paramIdx}`)
values.push(`%${keyword}%`)
break
case 'unNumber':
conditions.push(`UN_NO = $${paramIdx}`)
values.push(keyword)
break
case 'cargoCode':
conditions.push(`DATA->'cargoCodes' @> $${paramIdx}::jsonb`)
values.push(JSON.stringify([{ code: keyword }]))
break
default:
conditions.push(`(ABBREVIATION ILIKE $${paramIdx} OR NM_KR ILIKE $${paramIdx} OR NM_EN ILIKE $${paramIdx})`)
values.push(`%${keyword}%`)
}
paramIdx++
}
if (sebc && sebc.trim()) {
conditions.push(`SEBC ILIKE $${paramIdx}`)
values.push(`%${sebc.trim()}%`)
paramIdx++
}
const where = conditions.join(' AND ')
const offset = (page - 1) * limit
const countQuery = `SELECT COUNT(*) as total FROM HNS_SUBSTANCE WHERE ${where}`
const dataQuery = `
SELECT SBST_SN, ABBREVIATION, NM_KR, NM_EN, UN_NO, CAS_NO, SEBC, DATA
FROM HNS_SUBSTANCE
WHERE ${where}
ORDER BY SBST_SN
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}
`
const [countResult, dataResult] = await Promise.all([
wingPool.query(countQuery, values),
wingPool.query(dataQuery, [...values, limit, offset]),
])
return {
total: parseInt(countResult.rows[0].total, 10),
page,
limit,
items: dataResult.rows.map(row => ({
id: row.sbst_sn,
abbreviation: row.abbreviation,
nameKr: row.nm_kr,
nameEn: row.nm_en,
unNumber: row.un_no,
casNumber: row.cas_no,
sebc: row.sebc,
...row.data,
})),
}
}
// ============================================================
// HNS 분석 CRUD
// ============================================================
interface HnsAnalysisItem {
hnsAnlysSn: number
acdntSn: number | null
anlysNm: string
acdntDtm: string | null
locNm: string | null
lon: number | null
lat: number | null
sbstNm: string | null
spilQty: number | null
spilUnitCd: string | null
fcstHr: number | null
algoCd: string | null
critMdlCd: string | null
windSpd: number | null
windDir: string | null
execSttsCd: string
riskCd: string | null
analystNm: string | null
rsltData: Record<string, unknown> | null
regDtm: string
}
interface ListAnalysesInput {
status?: string
substance?: string
search?: string
acdntSn?: number
}
function rowToAnalysis(r: Record<string, unknown>): HnsAnalysisItem {
return {
hnsAnlysSn: r.hns_anlys_sn as number,
acdntSn: (r.acdnt_sn as number) ?? null,
anlysNm: r.anlys_nm as string,
acdntDtm: r.acdnt_dtm as string | null,
locNm: r.loc_nm as string | null,
lon: r.lon ? parseFloat(r.lon as string) : null,
lat: r.lat ? parseFloat(r.lat as string) : null,
sbstNm: r.sbst_nm as string | null,
spilQty: r.spil_qty ? parseFloat(r.spil_qty as string) : null,
spilUnitCd: r.spil_unit_cd as string | null,
fcstHr: r.fcst_hr as number | null,
algoCd: r.algo_cd as string | null,
critMdlCd: r.crit_mdl_cd as string | null,
windSpd: r.wind_spd ? parseFloat(r.wind_spd as string) : null,
windDir: r.wind_dir as string | null,
execSttsCd: r.exec_stts_cd as string,
riskCd: r.risk_cd as string | null,
analystNm: r.analyst_nm as string | null,
rsltData: (r.rslt_data as Record<string, unknown>) ?? null,
regDtm: r.reg_dtm as string,
}
}
export async function listAnalyses(input: ListAnalysesInput): Promise<HnsAnalysisItem[]> {
const conditions: string[] = ["USE_YN = 'Y'"]
const params: (string | number)[] = []
let idx = 1
if (input.status) {
conditions.push(`EXEC_STTS_CD = $${idx++}`)
params.push(input.status)
}
if (input.substance) {
conditions.push(`SBST_NM ILIKE '%' || $${idx++} || '%'`)
params.push(input.substance)
}
if (input.search) {
conditions.push(`(ANLYS_NM ILIKE '%' || $${idx} || '%' OR LOC_NM ILIKE '%' || $${idx} || '%')`)
params.push(input.search)
idx++
}
if (input.acdntSn != null) {
conditions.push(`ACDNT_SN = $${idx++}`)
params.push(input.acdntSn)
}
const { rows } = await wingPool.query(
`SELECT HNS_ANLYS_SN, ACDNT_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT,
SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD,
WIND_SPD, WIND_DIR, EXEC_STTS_CD, RISK_CD, ANALYST_NM,
RSLT_DATA, REG_DTM
FROM HNS_ANALYSIS
WHERE ${conditions.join(' AND ')}
ORDER BY ACDNT_DTM DESC NULLS LAST`,
params
)
return rows.map((r: Record<string, unknown>) => rowToAnalysis(r))
}
export async function getAnalysis(sn: number): Promise<HnsAnalysisItem | null> {
const { rows } = await wingPool.query(
`SELECT HNS_ANLYS_SN, ACDNT_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT,
SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD,
WIND_SPD, WIND_DIR, TEMP, HUMID, ATM_STBL_CD,
EXEC_STTS_CD, RISK_CD, ANALYST_NM,
RSLT_DATA, REG_DTM
FROM HNS_ANALYSIS
WHERE HNS_ANLYS_SN = $1 AND USE_YN = 'Y'`,
[sn]
)
if (rows.length === 0) return null
return rowToAnalysis(rows[0] as Record<string, unknown>)
}
export async function createAnalysis(input: {
anlysNm: string
acdntSn?: number
acdntDtm?: string
locNm?: string
lon?: number
lat?: number
sbstNm?: string
spilQty?: number
spilUnitCd?: string
fcstHr?: number
algoCd?: string
critMdlCd?: string
windSpd?: number
windDir?: string
temp?: number
humid?: number
atmStblCd?: string
analystNm?: string
}): Promise<{ hnsAnlysSn: number }> {
const { rows } = await wingPool.query(
`INSERT INTO HNS_ANALYSIS (
ACDNT_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT,
GEOM, LOC_DC,
SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD,
WIND_SPD, WIND_DIR, TEMP, HUMID, ATM_STBL_CD,
ANALYST_NM, EXEC_STTS_CD
) VALUES (
$1, $2, $3, $4, $5::numeric, $6::numeric,
CASE WHEN $5 IS NOT NULL AND $6 IS NOT NULL THEN ST_SetSRID(ST_MakePoint($5::double precision, $6::double precision), 4326) END,
CASE WHEN $5 IS NOT NULL AND $6 IS NOT NULL THEN $5::text || ' + ' || $6::text END,
$7, $8, $9, $10, $11, $12,
$13, $14, $15, $16, $17,
$18, 'PENDING'
) RETURNING HNS_ANLYS_SN`,
[
input.acdntSn || null, input.anlysNm, input.acdntDtm || null, input.locNm || null, input.lon || null, input.lat || null,
input.sbstNm || null, input.spilQty || null, input.spilUnitCd || 'KL',
input.fcstHr || null, input.algoCd || null, input.critMdlCd || null,
input.windSpd || null, input.windDir || null, input.temp || null, input.humid || null, input.atmStblCd || null,
input.analystNm || null,
]
)
return { hnsAnlysSn: rows[0].hns_anlys_sn }
}
export async function updateAnalysisResult(sn: number, input: {
rsltData: Record<string, unknown>
execSttsCd?: string
riskCd?: string
}): Promise<void> {
await wingPool.query(
`UPDATE HNS_ANALYSIS
SET RSLT_DATA = $1, EXEC_STTS_CD = $2, RISK_CD = $3, MDFCN_DTM = NOW()
WHERE HNS_ANLYS_SN = $4 AND USE_YN = 'Y'`,
[JSON.stringify(input.rsltData), input.execSttsCd || 'COMPLETED', input.riskCd || null, sn]
)
}
export async function deleteAnalysis(sn: number): Promise<void> {
await wingPool.query(
`UPDATE HNS_ANALYSIS SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE HNS_ANLYS_SN = $1`,
[sn]
)
}
export async function getSubstanceById(id: number) {
const { rows } = await wingPool.query(
`SELECT SBST_SN, ABBREVIATION, NM_KR, NM_EN, UN_NO, CAS_NO, SEBC, DATA
FROM HNS_SUBSTANCE WHERE SBST_SN = $1`,
[id]
)
if (rows.length === 0) return null
const row = rows[0]
return {
id: row.sbst_sn,
abbreviation: row.abbreviation,
nameKr: row.nm_kr,
nameEn: row.nm_en,
unNumber: row.un_no,
casNumber: row.cas_no,
sebc: row.sebc,
...row.data,
}
}