/** * 리포트 관리 서비스 */ import { v4 as uuidv4 } from "uuid"; import { query, queryOne, transaction } from "../database/db"; import { ReportMaster, ReportLayout, ReportQuery, ReportTemplate, ReportDetail, GetReportsParams, GetReportsResponse, CreateReportRequest, UpdateReportRequest, SaveLayoutRequest, GetTemplatesResponse, CreateTemplateRequest, } from "../types/report"; export class ReportService { /** * 리포트 목록 조회 */ async getReports(params: GetReportsParams): Promise { const { page = 1, limit = 20, searchText = "", reportType = "", useYn = "Y", sortBy = "created_at", sortOrder = "DESC", } = params; const offset = (page - 1) * limit; // WHERE 조건 동적 생성 const conditions: string[] = []; const values: any[] = []; let paramIndex = 1; if (useYn) { conditions.push(`use_yn = $${paramIndex++}`); values.push(useYn); } if (searchText) { conditions.push( `(report_name_kor LIKE $${paramIndex} OR report_name_eng LIKE $${paramIndex})` ); values.push(`%${searchText}%`); paramIndex++; } if (reportType) { conditions.push(`report_type = $${paramIndex++}`); values.push(reportType); } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; // 전체 개수 조회 const countQuery = ` SELECT COUNT(*) as total FROM report_master ${whereClause} `; const countResult = await queryOne<{ total: string }>(countQuery, values); const total = parseInt(countResult?.total || "0", 10); // 목록 조회 const listQuery = ` SELECT report_id, report_name_kor, report_name_eng, template_id, report_type, company_code, description, use_yn, created_at, created_by, updated_at, updated_by FROM report_master ${whereClause} ORDER BY ${sortBy} ${sortOrder} LIMIT $${paramIndex++} OFFSET $${paramIndex} `; const items = await query(listQuery, [ ...values, limit, offset, ]); return { items, total, page, limit, }; } /** * 리포트 상세 조회 */ async getReportById(reportId: string): Promise { // 리포트 마스터 조회 const reportQuery = ` SELECT report_id, report_name_kor, report_name_eng, template_id, report_type, company_code, description, use_yn, created_at, created_by, updated_at, updated_by FROM report_master WHERE report_id = $1 `; const report = await queryOne(reportQuery, [reportId]); if (!report) { return null; } // 레이아웃 조회 const layoutQuery = ` SELECT layout_id, report_id, canvas_width, canvas_height, page_orientation, margin_top, margin_bottom, margin_left, margin_right, components, created_at, created_by, updated_at, updated_by FROM report_layout WHERE report_id = $1 `; const layout = await queryOne(layoutQuery, [reportId]); // 쿼리 조회 const queriesQuery = ` SELECT query_id, report_id, query_name, query_type, sql_query, parameters, display_order, created_at, created_by, updated_at, updated_by FROM report_query WHERE report_id = $1 ORDER BY display_order, created_at `; const queries = await query(queriesQuery, [reportId]); return { report, layout, queries: queries || [], }; } /** * 리포트 생성 */ async createReport( data: CreateReportRequest, userId: string ): Promise { const reportId = `RPT_${uuidv4().replace(/-/g, "").substring(0, 20)}`; return transaction(async (client) => { // 리포트 마스터 생성 const insertReportQuery = ` INSERT INTO report_master ( report_id, report_name_kor, report_name_eng, template_id, report_type, company_code, description, use_yn, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, 'Y', $8) `; await client.query(insertReportQuery, [ reportId, data.reportNameKor, data.reportNameEng || null, data.templateId || null, data.reportType, data.companyCode || null, data.description || null, userId, ]); // 템플릿이 있으면 해당 템플릿의 레이아웃 복사 if (data.templateId) { const templateQuery = ` SELECT layout_config FROM report_template WHERE template_id = $1 `; const template = await client.query(templateQuery, [data.templateId]); if (template.rows.length > 0 && template.rows[0].layout_config) { const layoutConfig = JSON.parse(template.rows[0].layout_config); const layoutId = `LAY_${uuidv4().replace(/-/g, "").substring(0, 20)}`; const insertLayoutQuery = ` INSERT INTO report_layout ( layout_id, report_id, canvas_width, canvas_height, page_orientation, margin_top, margin_bottom, margin_left, margin_right, components, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) `; await client.query(insertLayoutQuery, [ layoutId, reportId, layoutConfig.width || 210, layoutConfig.height || 297, layoutConfig.orientation || "portrait", 20, 20, 20, 20, JSON.stringify([]), userId, ]); } } return reportId; }); } /** * 리포트 수정 */ async updateReport( reportId: string, data: UpdateReportRequest, userId: string ): Promise { const setClauses: string[] = []; const values: any[] = []; let paramIndex = 1; if (data.reportNameKor !== undefined) { setClauses.push(`report_name_kor = $${paramIndex++}`); values.push(data.reportNameKor); } if (data.reportNameEng !== undefined) { setClauses.push(`report_name_eng = $${paramIndex++}`); values.push(data.reportNameEng); } if (data.reportType !== undefined) { setClauses.push(`report_type = $${paramIndex++}`); values.push(data.reportType); } if (data.description !== undefined) { setClauses.push(`description = $${paramIndex++}`); values.push(data.description); } if (data.useYn !== undefined) { setClauses.push(`use_yn = $${paramIndex++}`); values.push(data.useYn); } if (setClauses.length === 0) { return false; } setClauses.push(`updated_at = CURRENT_TIMESTAMP`); setClauses.push(`updated_by = $${paramIndex++}`); values.push(userId); values.push(reportId); const updateQuery = ` UPDATE report_master SET ${setClauses.join(", ")} WHERE report_id = $${paramIndex} `; const result = await query(updateQuery, values); return true; } /** * 리포트 삭제 */ async deleteReport(reportId: string): Promise { return transaction(async (client) => { // 쿼리 삭제 (CASCADE로 자동 삭제되지만 명시적으로) await client.query(`DELETE FROM report_query WHERE report_id = $1`, [ reportId, ]); // 레이아웃 삭제 await client.query(`DELETE FROM report_layout WHERE report_id = $1`, [ reportId, ]); // 리포트 마스터 삭제 const result = await client.query( `DELETE FROM report_master WHERE report_id = $1`, [reportId] ); return (result.rowCount ?? 0) > 0; }); } /** * 리포트 복사 */ async copyReport(reportId: string, userId: string): Promise { return transaction(async (client) => { // 원본 리포트 조회 const originalQuery = ` SELECT * FROM report_master WHERE report_id = $1 `; const originalResult = await client.query(originalQuery, [reportId]); if (originalResult.rows.length === 0) { return null; } const original = originalResult.rows[0]; const newReportId = `RPT_${uuidv4().replace(/-/g, "").substring(0, 20)}`; // 리포트 마스터 복사 const copyReportQuery = ` INSERT INTO report_master ( report_id, report_name_kor, report_name_eng, template_id, report_type, company_code, description, use_yn, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) `; await client.query(copyReportQuery, [ newReportId, `${original.report_name_kor} (복사)`, original.report_name_eng ? `${original.report_name_eng} (Copy)` : null, original.template_id, original.report_type, original.company_code, original.description, original.use_yn, userId, ]); // 레이아웃 복사 const layoutQuery = ` SELECT * FROM report_layout WHERE report_id = $1 `; const layoutResult = await client.query(layoutQuery, [reportId]); if (layoutResult.rows.length > 0) { const originalLayout = layoutResult.rows[0]; const newLayoutId = `LAY_${uuidv4().replace(/-/g, "").substring(0, 20)}`; const copyLayoutQuery = ` INSERT INTO report_layout ( layout_id, report_id, canvas_width, canvas_height, page_orientation, margin_top, margin_bottom, margin_left, margin_right, components, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) `; await client.query(copyLayoutQuery, [ newLayoutId, newReportId, originalLayout.canvas_width, originalLayout.canvas_height, originalLayout.page_orientation, originalLayout.margin_top, originalLayout.margin_bottom, originalLayout.margin_left, originalLayout.margin_right, originalLayout.components, userId, ]); } // 쿼리 복사 const queriesQuery = ` SELECT * FROM report_query WHERE report_id = $1 ORDER BY display_order `; const queriesResult = await client.query(queriesQuery, [reportId]); if (queriesResult.rows.length > 0) { const copyQuerySql = ` INSERT INTO report_query ( query_id, report_id, query_name, query_type, sql_query, parameters, display_order, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) `; for (const originalQuery of queriesResult.rows) { const newQueryId = `QRY_${uuidv4().replace(/-/g, "").substring(0, 20)}`; await client.query(copyQuerySql, [ newQueryId, newReportId, originalQuery.query_name, originalQuery.query_type, originalQuery.sql_query, originalQuery.parameters, originalQuery.display_order, userId, ]); } } return newReportId; }); } /** * 레이아웃 조회 */ async getLayout(reportId: string): Promise { const layoutQuery = ` SELECT layout_id, report_id, canvas_width, canvas_height, page_orientation, margin_top, margin_bottom, margin_left, margin_right, components, created_at, created_by, updated_at, updated_by FROM report_layout WHERE report_id = $1 `; return queryOne(layoutQuery, [reportId]); } /** * 레이아웃 저장 (쿼리 포함) */ async saveLayout( reportId: string, data: SaveLayoutRequest, userId: string ): Promise { return transaction(async (client) => { // 1. 레이아웃 저장 const existingQuery = ` SELECT layout_id FROM report_layout WHERE report_id = $1 `; const existing = await client.query(existingQuery, [reportId]); if (existing.rows.length > 0) { // 업데이트 const updateQuery = ` UPDATE report_layout SET canvas_width = $1, canvas_height = $2, page_orientation = $3, margin_top = $4, margin_bottom = $5, margin_left = $6, margin_right = $7, components = $8, updated_at = CURRENT_TIMESTAMP, updated_by = $9 WHERE report_id = $10 `; await client.query(updateQuery, [ data.canvasWidth, data.canvasHeight, data.pageOrientation, data.marginTop, data.marginBottom, data.marginLeft, data.marginRight, JSON.stringify(data.components), userId, reportId, ]); } else { // 생성 const layoutId = `LAY_${uuidv4().replace(/-/g, "").substring(0, 20)}`; const insertQuery = ` INSERT INTO report_layout ( layout_id, report_id, canvas_width, canvas_height, page_orientation, margin_top, margin_bottom, margin_left, margin_right, components, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) `; await client.query(insertQuery, [ layoutId, reportId, data.canvasWidth, data.canvasHeight, data.pageOrientation, data.marginTop, data.marginBottom, data.marginLeft, data.marginRight, JSON.stringify(data.components), userId, ]); } // 2. 쿼리 저장 (있는 경우) if (data.queries && data.queries.length > 0) { // 기존 쿼리 모두 삭제 await client.query(`DELETE FROM report_query WHERE report_id = $1`, [ reportId, ]); // 새 쿼리 삽입 const insertQuerySql = ` INSERT INTO report_query ( query_id, report_id, query_name, query_type, sql_query, parameters, display_order, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) `; for (let i = 0; i < data.queries.length; i++) { const q = data.queries[i]; await client.query(insertQuerySql, [ q.id, reportId, q.name, q.type, q.sqlQuery, JSON.stringify(q.parameters), i, userId, ]); } } return true; }); } /** * 쿼리 실행 */ async executeQuery( reportId: string, queryId: string, parameters: Record, sqlQuery?: string ): Promise<{ fields: string[]; rows: any[] }> { let sql_query: string; let queryParameters: string[] = []; // 테스트 모드 (sqlQuery 직접 전달) if (sqlQuery) { sql_query = sqlQuery; // 파라미터 순서 추출 const matches = sqlQuery.match(/\$\d+/g); if (matches) { queryParameters = Array.from(new Set(matches)).sort((a, b) => { return parseInt(a.substring(1)) - parseInt(b.substring(1)); }); } } else { // DB에서 쿼리 조회 const queryResult = await queryOne( `SELECT * FROM report_query WHERE query_id = $1 AND report_id = $2`, [queryId, reportId] ); if (!queryResult) { throw new Error("쿼리를 찾을 수 없습니다."); } sql_query = queryResult.sql_query; queryParameters = Array.isArray(queryResult.parameters) ? queryResult.parameters : []; } // 파라미터 배열 생성 ($1, $2 순서대로) const paramArray: any[] = []; for (const param of queryParameters) { paramArray.push(parameters[param] || null); } try { // 쿼리 실행 const result = await query(sql_query, paramArray); // 필드명 추출 const fields = result.length > 0 ? Object.keys(result[0]) : []; return { fields, rows: result, }; } catch (error: any) { throw new Error(`쿼리 실행 오류: ${error.message}`); } } /** * 템플릿 목록 조회 */ async getTemplates(): Promise { const templateQuery = ` SELECT template_id, template_name_kor, template_name_eng, template_type, is_system, thumbnail_url, description, layout_config, default_queries, use_yn, sort_order, created_at, created_by, updated_at, updated_by FROM report_template WHERE use_yn = 'Y' ORDER BY is_system DESC, sort_order ASC `; const templates = await query(templateQuery); const system = templates.filter((t) => t.is_system === "Y"); const custom = templates.filter((t) => t.is_system === "N"); return { system, custom }; } /** * 템플릿 생성 (사용자 정의) */ async createTemplate( data: CreateTemplateRequest, userId: string ): Promise { const templateId = `TPL_${uuidv4().replace(/-/g, "").substring(0, 20)}`; const insertQuery = ` INSERT INTO report_template ( template_id, template_name_kor, template_name_eng, template_type, is_system, description, layout_config, default_queries, use_yn, created_by ) VALUES ($1, $2, $3, $4, 'N', $5, $6, $7, 'Y', $8) `; await query(insertQuery, [ templateId, data.templateNameKor, data.templateNameEng || null, data.templateType, data.description || null, data.layoutConfig ? JSON.stringify(data.layoutConfig) : null, data.defaultQueries ? JSON.stringify(data.defaultQueries) : null, userId, ]); return templateId; } /** * 템플릿 삭제 (사용자 정의만 가능) */ async deleteTemplate(templateId: string): Promise { const deleteQuery = ` DELETE FROM report_template WHERE template_id = $1 AND is_system = 'N' `; const result = await query(deleteQuery, [templateId]); return true; } } export default new ReportService();