CCTV 실시간 영상: - CCTVPlayer 컴포넌트 (hls.js 기반 HLS/MJPEG/MP4 재생) - 백엔드 HLS 프록시 엔드포인트 (CORS 우회, m3u8 URL 재작성) - KHOA 15개 + KBS 6개 실제 해안 CCTV 연동 - Vite dev proxy, 스트림 타입 자동 감지 유틸리티 HNS 분석: - HNS 시나리오 저장/불러오기/재계산 기능 - 물질 DB 검색 및 상세 정보 연동 - 좌표/파라미터 입력 UI 개선 - Python 확산 모델 스크립트 (hns_dispersion.py) 공통: - 3D 지도 토글, 보고서 생성 개선 - useSubMenu 훅, mapUtils 확장 - ESLint set-state-in-effect 수정 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
281 lines
8.3 KiB
TypeScript
281 lines
8.3 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
|
|
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
|
|
}
|
|
|
|
function rowToAnalysis(r: Record<string, unknown>): HnsAnalysisItem {
|
|
return {
|
|
hnsAnlysSn: r.hns_anlys_sn as number,
|
|
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[] = []
|
|
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++
|
|
}
|
|
|
|
const { rows } = await wingPool.query(
|
|
`SELECT HNS_ANLYS_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, 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
|
|
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 (
|
|
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,
|
|
CASE WHEN $4 IS NOT NULL AND $5 IS NOT NULL THEN ST_SetSRID(ST_MakePoint($4::float, $5::float), 4326) END,
|
|
CASE WHEN $4 IS NOT NULL AND $5 IS NOT NULL THEN $4 || ' + ' || $5 END,
|
|
$6, $7, $8, $9, $10, $11,
|
|
$12, $13, $14, $15, $16,
|
|
$17, 'PENDING'
|
|
) RETURNING HNS_ANLYS_SN`,
|
|
[
|
|
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,
|
|
}
|
|
}
|