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 { 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(); 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 { 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(); 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(); 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 { 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 { 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 { 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 { 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 { 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] ); }