wing-ops/backend/src/reports/reportsService.ts
htlee 844eebb7cc feat(reports): 보고서 탭 localStorage → DB/API 전환
- DB 마이그레이션 007_reports.sql: 7개 테이블 (REPORT_TMPL, REPORT_TMPL_SECT,
  REPORT_ANALYSIS_CTGR, REPORT_CTGR_SECT, REPORT, REPORT_SECT_DATA 등)
  + 초기 데이터 (5개 템플릿, 3개 카테고리, 섹션 정의)
- 백엔드 reportsService.ts: 템플릿/카테고리 조회, 보고서 CRUD, 섹션 UPSERT
- 백엔드 reportsRouter.ts: GET/POST only 패턴 (보안취약점 가이드 준수)
  - GET /api/reports, GET /api/reports/:sn (조회)
  - POST /api/reports (생성), POST /:sn/update (수정), POST /:sn/delete (삭제)
  - POST /:sn/sections/:sectCd (개별 섹션 수정)
- 프론트 reportsApi.ts: API 호출 + OilSpillReportData ↔ API 변환 + 캐싱
- 프론트 4개 컴포넌트 localStorage → API 전환:
  ReportsView, OilSpillReportTemplate, TemplateFormEditor, ReportGenerator

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:59:11 +09:00

522 lines
14 KiB
TypeScript

import { wingPool } from '../db/wingDb.js';
import { AuthError } from '../auth/authService.js';
// ============================================================
// 인터페이스
// ============================================================
interface TemplateSectionDef {
sectCd: string;
sectNm: string;
fieldDef: unknown[];
sortOrd: number;
}
interface TemplateItem {
tmplSn: number;
tmplCd: string;
tmplNm: string;
icon: string;
tmplDc: string;
sections: TemplateSectionDef[];
}
interface CategoryTemplateDef {
icon: string;
label: string;
}
interface CategorySectionDef {
sectCd: string;
sectNm: string;
icon: string;
sectDc: string;
dfltYn: string;
sortOrd: number;
}
interface CategoryItem {
ctgrSn: number;
ctgrCd: string;
ctgrNm: string;
icon: string;
ctgrDc: string;
colorCd: string;
borderColor: string;
bgActive: string;
reportNm: string;
templates: CategoryTemplateDef[];
sections: CategorySectionDef[];
}
interface ReportListItem {
reportSn: number;
tmplCd: string | null;
tmplNm: string | null;
ctgrCd: string | null;
ctgrNm: string | null;
title: string;
jrsdCd: string | null;
sttsCd: string;
authorId: string;
authorName: string;
regDtm: string;
mdfcnDtm: string | null;
}
interface SectionData {
sectCd: string;
includeYn: string;
sectData: unknown;
sortOrd: number;
}
interface ReportDetail extends ReportListItem {
acdntSn: number | null;
sections: SectionData[];
}
interface ListReportsInput {
jrsdCd?: string;
tmplCd?: string;
sttsCd?: string;
search?: string;
page?: number;
size?: number;
}
interface ListReportsResult {
items: ReportListItem[];
totalCount: number;
page: number;
size: number;
}
interface CreateReportInput {
tmplSn?: number;
ctgrSn?: number;
acdntSn?: number;
title: string;
jrsdCd?: string;
sttsCd?: string;
authorId: string;
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
}
interface UpdateReportInput {
title?: string;
jrsdCd?: string;
sttsCd?: string;
acdntSn?: number | null;
sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[];
}
// ============================================================
// 템플릿/카테고리 조회
// ============================================================
export async function getTemplates(): Promise<TemplateItem[]> {
const tmplRes = await wingPool.query(
`SELECT TMPL_SN, TMPL_CD, TMPL_NM, ICON, TMPL_DC
FROM REPORT_TMPL WHERE USE_YN = 'Y' ORDER BY SORT_ORD`
);
const sectRes = await wingPool.query(
`SELECT s.TMPL_SN, s.SECT_CD, s.SECT_NM, s.FIELD_DEF, s.SORT_ORD
FROM REPORT_TMPL_SECT s
JOIN REPORT_TMPL t ON t.TMPL_SN = s.TMPL_SN
WHERE s.USE_YN = 'Y' AND t.USE_YN = 'Y'
ORDER BY s.TMPL_SN, s.SORT_ORD`
);
const sectMap = new Map<number, TemplateSectionDef[]>();
for (const row of sectRes.rows) {
const sn = row.tmpl_sn;
if (!sectMap.has(sn)) sectMap.set(sn, []);
sectMap.get(sn)!.push({
sectCd: row.sect_cd,
sectNm: row.sect_nm,
fieldDef: row.field_def,
sortOrd: row.sort_ord,
});
}
return tmplRes.rows.map((r) => ({
tmplSn: r.tmpl_sn,
tmplCd: r.tmpl_cd,
tmplNm: r.tmpl_nm,
icon: r.icon || '',
tmplDc: r.tmpl_dc || '',
sections: sectMap.get(r.tmpl_sn) || [],
}));
}
export async function getCategories(): Promise<CategoryItem[]> {
const ctgrRes = await wingPool.query(
`SELECT CTGR_SN, CTGR_CD, CTGR_NM, ICON, CTGR_DC,
COLOR_CD, BORDER_COLOR, BG_ACTIVE, REPORT_NM
FROM REPORT_ANALYSIS_CTGR WHERE USE_YN = 'Y' ORDER BY SORT_ORD`
);
const tmplRes = await wingPool.query(
`SELECT ct.CTGR_SN, ct.ICON, ct.LABEL
FROM REPORT_CTGR_TMPL ct
JOIN REPORT_ANALYSIS_CTGR c ON c.CTGR_SN = ct.CTGR_SN
WHERE c.USE_YN = 'Y'
ORDER BY ct.CTGR_SN, ct.SORT_ORD`
);
const sectRes = await wingPool.query(
`SELECT cs.CTGR_SN, cs.SECT_CD, cs.SECT_NM, cs.ICON, cs.SECT_DC, cs.DFLT_YN, cs.SORT_ORD
FROM REPORT_CTGR_SECT cs
JOIN REPORT_ANALYSIS_CTGR c ON c.CTGR_SN = cs.CTGR_SN
WHERE c.USE_YN = 'Y'
ORDER BY cs.CTGR_SN, cs.SORT_ORD`
);
const tmplMap = new Map<number, CategoryTemplateDef[]>();
for (const row of tmplRes.rows) {
const sn = row.ctgr_sn;
if (!tmplMap.has(sn)) tmplMap.set(sn, []);
tmplMap.get(sn)!.push({ icon: row.icon || '', label: row.label });
}
const sectMap = new Map<number, CategorySectionDef[]>();
for (const row of sectRes.rows) {
const sn = row.ctgr_sn;
if (!sectMap.has(sn)) sectMap.set(sn, []);
sectMap.get(sn)!.push({
sectCd: row.sect_cd,
sectNm: row.sect_nm,
icon: row.icon || '',
sectDc: row.sect_dc || '',
dfltYn: row.dflt_yn,
sortOrd: row.sort_ord,
});
}
return ctgrRes.rows.map((r) => ({
ctgrSn: r.ctgr_sn,
ctgrCd: r.ctgr_cd,
ctgrNm: r.ctgr_nm,
icon: r.icon || '',
ctgrDc: r.ctgr_dc || '',
colorCd: r.color_cd || '',
borderColor: r.border_color || '',
bgActive: r.bg_active || '',
reportNm: r.report_nm || '',
templates: tmplMap.get(r.ctgr_sn) || [],
sections: sectMap.get(r.ctgr_sn) || [],
}));
}
// ============================================================
// 보고서 CRUD
// ============================================================
export async function listReports(input: ListReportsInput): Promise<ListReportsResult> {
const page = Math.max(1, input.page || 1);
const size = Math.min(100, Math.max(1, input.size || 20));
const offset = (page - 1) * size;
const conditions: string[] = ["r.USE_YN = 'Y'"];
const params: unknown[] = [];
let idx = 1;
if (input.jrsdCd) {
conditions.push(`r.JRSD_CD = $${idx++}`);
params.push(input.jrsdCd);
}
if (input.tmplCd) {
conditions.push(`t.TMPL_CD = $${idx++}`);
params.push(input.tmplCd);
}
if (input.sttsCd) {
conditions.push(`r.STTS_CD = $${idx++}`);
params.push(input.sttsCd);
}
if (input.search) {
conditions.push(`r.TITLE ILIKE $${idx++}`);
params.push(`%${input.search}%`);
}
const where = conditions.join(' AND ');
const countRes = await wingPool.query(
`SELECT COUNT(*) AS cnt
FROM REPORT r
LEFT JOIN REPORT_TMPL t ON t.TMPL_SN = r.TMPL_SN
WHERE ${where}`,
params
);
const totalCount = parseInt(countRes.rows[0].cnt, 10);
const dataRes = await wingPool.query(
`SELECT r.REPORT_SN, t.TMPL_CD, t.TMPL_NM,
c.CTGR_CD, c.CTGR_NM,
r.TITLE, r.JRSD_CD, r.STTS_CD,
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
r.REG_DTM, r.MDFCN_DTM
FROM REPORT r
LEFT JOIN REPORT_TMPL t ON t.TMPL_SN = r.TMPL_SN
LEFT JOIN REPORT_ANALYSIS_CTGR c ON c.CTGR_SN = r.CTGR_SN
LEFT JOIN AUTH_USER u ON u.USER_ID = r.AUTHOR_ID
WHERE ${where}
ORDER BY r.REG_DTM DESC
LIMIT $${idx++} OFFSET $${idx++}`,
[...params, size, offset]
);
return {
items: dataRes.rows.map((r) => ({
reportSn: r.report_sn,
tmplCd: r.tmpl_cd,
tmplNm: r.tmpl_nm,
ctgrCd: r.ctgr_cd,
ctgrNm: r.ctgr_nm,
title: r.title,
jrsdCd: r.jrsd_cd,
sttsCd: r.stts_cd,
authorId: r.author_id,
authorName: r.author_name || '',
regDtm: r.reg_dtm,
mdfcnDtm: r.mdfcn_dtm,
})),
totalCount,
page,
size,
};
}
export async function getReport(reportSn: number): Promise<ReportDetail> {
const res = await wingPool.query(
`SELECT r.REPORT_SN, t.TMPL_CD, t.TMPL_NM,
c.CTGR_CD, c.CTGR_NM,
r.TITLE, r.JRSD_CD, r.STTS_CD, r.ACDNT_SN,
r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME,
r.REG_DTM, r.MDFCN_DTM
FROM REPORT r
LEFT JOIN REPORT_TMPL t ON t.TMPL_SN = r.TMPL_SN
LEFT JOIN REPORT_ANALYSIS_CTGR c ON c.CTGR_SN = r.CTGR_SN
LEFT JOIN AUTH_USER u ON u.USER_ID = r.AUTHOR_ID
WHERE r.REPORT_SN = $1 AND r.USE_YN = 'Y'`,
[reportSn]
);
if (res.rows.length === 0) {
throw new AuthError('보고서를 찾을 수 없습니다.', 404);
}
const r = res.rows[0];
const sectRes = await wingPool.query(
`SELECT SECT_CD, INCLUDE_YN, SECT_DATA, SORT_ORD
FROM REPORT_SECT_DATA
WHERE REPORT_SN = $1
ORDER BY SORT_ORD`,
[reportSn]
);
return {
reportSn: r.report_sn,
tmplCd: r.tmpl_cd,
tmplNm: r.tmpl_nm,
ctgrCd: r.ctgr_cd,
ctgrNm: r.ctgr_nm,
title: r.title,
jrsdCd: r.jrsd_cd,
sttsCd: r.stts_cd,
acdntSn: r.acdnt_sn,
authorId: r.author_id,
authorName: r.author_name || '',
regDtm: r.reg_dtm,
mdfcnDtm: r.mdfcn_dtm,
sections: sectRes.rows.map((s) => ({
sectCd: s.sect_cd,
includeYn: s.include_yn,
sectData: s.sect_data,
sortOrd: s.sort_ord,
})),
};
}
export async function createReport(input: CreateReportInput): Promise<{ sn: number }> {
if (!input.title?.trim()) {
throw new AuthError('보고서 제목은 필수입니다.', 400);
}
const client = await wingPool.connect();
try {
await client.query('BEGIN');
const res = await client.query(
`INSERT INTO REPORT (TMPL_SN, CTGR_SN, ACDNT_SN, TITLE, JRSD_CD, STTS_CD, AUTHOR_ID)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING REPORT_SN`,
[
input.tmplSn || null,
input.ctgrSn || null,
input.acdntSn || null,
input.title.trim(),
input.jrsdCd || null,
input.sttsCd || 'DRAFT',
input.authorId,
]
);
const reportSn = res.rows[0].report_sn;
if (input.sections && input.sections.length > 0) {
for (const sect of input.sections) {
await client.query(
`INSERT INTO REPORT_SECT_DATA (REPORT_SN, SECT_CD, INCLUDE_YN, SECT_DATA, SORT_ORD)
VALUES ($1, $2, $3, $4, $5)`,
[
reportSn,
sect.sectCd,
sect.includeYn || 'Y',
JSON.stringify(sect.sectData || {}),
sect.sortOrd || 0,
]
);
}
}
await client.query('COMMIT');
return { sn: reportSn };
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
export async function updateReport(
reportSn: number,
input: UpdateReportInput,
requesterId: string
): Promise<void> {
const existing = await wingPool.query(
`SELECT AUTHOR_ID FROM REPORT WHERE REPORT_SN = $1 AND USE_YN = 'Y'`,
[reportSn]
);
if (existing.rows.length === 0) {
throw new AuthError('보고서를 찾을 수 없습니다.', 404);
}
if (existing.rows[0].author_id !== requesterId) {
throw new AuthError('본인의 보고서만 수정할 수 있습니다.', 403);
}
const client = await wingPool.connect();
try {
await client.query('BEGIN');
// 메타데이터 업데이트
const sets: string[] = ['MDFCN_DTM = NOW()'];
const params: unknown[] = [];
let idx = 1;
if (input.title !== undefined) {
sets.push(`TITLE = $${idx++}`);
params.push(input.title.trim());
}
if (input.jrsdCd !== undefined) {
sets.push(`JRSD_CD = $${idx++}`);
params.push(input.jrsdCd);
}
if (input.sttsCd !== undefined) {
sets.push(`STTS_CD = $${idx++}`);
params.push(input.sttsCd);
}
if (input.acdntSn !== undefined) {
sets.push(`ACDNT_SN = $${idx++}`);
params.push(input.acdntSn);
}
params.push(reportSn);
await client.query(
`UPDATE REPORT SET ${sets.join(', ')} WHERE REPORT_SN = $${idx}`,
params
);
// 섹션 데이터 upsert
if (input.sections && input.sections.length > 0) {
for (const sect of input.sections) {
await client.query(
`INSERT INTO REPORT_SECT_DATA (REPORT_SN, SECT_CD, INCLUDE_YN, SECT_DATA, SORT_ORD)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (REPORT_SN, SECT_CD) DO UPDATE
SET INCLUDE_YN = EXCLUDED.INCLUDE_YN,
SECT_DATA = EXCLUDED.SECT_DATA,
SORT_ORD = EXCLUDED.SORT_ORD`,
[
reportSn,
sect.sectCd,
sect.includeYn || 'Y',
JSON.stringify(sect.sectData || {}),
sect.sortOrd || 0,
]
);
}
}
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
export async function deleteReport(reportSn: number, requesterId: string): Promise<void> {
const existing = await wingPool.query(
`SELECT AUTHOR_ID FROM REPORT WHERE REPORT_SN = $1 AND USE_YN = 'Y'`,
[reportSn]
);
if (existing.rows.length === 0) {
throw new AuthError('보고서를 찾을 수 없습니다.', 404);
}
if (existing.rows[0].author_id !== requesterId) {
throw new AuthError('본인의 보고서만 삭제할 수 있습니다.', 403);
}
await wingPool.query(
`UPDATE REPORT SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE REPORT_SN = $1`,
[reportSn]
);
}
export async function updateReportSection(
reportSn: number,
sectCd: string,
sectData: unknown,
includeYn: string,
requesterId: string
): Promise<void> {
const existing = await wingPool.query(
`SELECT AUTHOR_ID FROM REPORT WHERE REPORT_SN = $1 AND USE_YN = 'Y'`,
[reportSn]
);
if (existing.rows.length === 0) {
throw new AuthError('보고서를 찾을 수 없습니다.', 404);
}
if (existing.rows[0].author_id !== requesterId) {
throw new AuthError('본인의 보고서만 수정할 수 있습니다.', 403);
}
await wingPool.query(
`INSERT INTO REPORT_SECT_DATA (REPORT_SN, SECT_CD, INCLUDE_YN, SECT_DATA)
VALUES ($1, $2, $3, $4)
ON CONFLICT (REPORT_SN, SECT_CD) DO UPDATE
SET INCLUDE_YN = EXCLUDED.INCLUDE_YN,
SECT_DATA = EXCLUDED.SECT_DATA`,
[reportSn, sectCd, includeYn || 'Y', JSON.stringify(sectData || {})]
);
await wingPool.query(
`UPDATE REPORT SET MDFCN_DTM = NOW() WHERE REPORT_SN = $1`,
[reportSn]
);
}