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, VisualQuery, } from "../types/report"; import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory"; import { ExternalDbConnectionService } from "./externalDbConnectionService"; import { logger } from "../utils/logger"; const REPORT_TYPE_LABELS: Record = { ORDER: "발주서", INVOICE: "청구서", STATEMENT: "거래명세서", RECEIPT: "영수증", BASIC: "기본", }; const ALLOWED_SORT_COLUMNS = [ "created_at", "updated_at", "report_name_kor", "report_name_eng", "report_type", "use_yn", ] as const; const ALLOWED_SORT_ORDERS = ["ASC", "DESC"] as const; const DEFAULT_MARGINS = { top: 20, bottom: 20, left: 20, right: 20 }; const DEFAULT_CANVAS_WIDTH = 210; const DEFAULT_CANVAS_HEIGHT = 297; function generateReportId(): string { return `RPT_${uuidv4().replace(/-/g, "").substring(0, 20)}`; } function generateLayoutId(): string { return `LAY_${uuidv4().replace(/-/g, "").substring(0, 20)}`; } function generateQueryId(): string { return `QRY_${uuidv4().replace(/-/g, "").substring(0, 20)}`; } function generateTemplateId(): string { return `TPL_${uuidv4().replace(/-/g, "").substring(0, 20)}`; } function findTypeCodesByLabel(searchText: string): string[] { const lower = searchText.toLowerCase(); return Object.entries(REPORT_TYPE_LABELS) .filter(([code, label]) => label.includes(searchText) || code.toLowerCase().includes(lower)) .map(([code]) => code); } function parseJsonComponents(raw: string | Record | null): Record | null { if (raw === null || raw === undefined) return null; if (typeof raw === "string") { try { return JSON.parse(raw); } catch { return null; } } return raw; } function sanitizeSortBy(sortBy: string): string { if ((ALLOWED_SORT_COLUMNS as readonly string[]).includes(sortBy)) { return sortBy; } return "created_at"; } function sanitizeSortOrder(sortOrder: string): "ASC" | "DESC" { const upper = sortOrder.toUpperCase(); if ((ALLOWED_SORT_ORDERS as readonly string[]).includes(upper)) { return upper as "ASC" | "DESC"; } return "DESC"; } export class ReportService { private validateQuerySafety(sql: string): void { const dangerousKeywords = [ "DELETE", "DROP", "TRUNCATE", "INSERT", "UPDATE", "ALTER", "CREATE", "REPLACE", "MERGE", "GRANT", "REVOKE", "EXECUTE", "EXEC", "CALL", ]; const upperSql = sql.toUpperCase().trim(); for (const keyword of dangerousKeywords) { const regex = new RegExp(`\\b${keyword}\\b`, "i"); if (regex.test(upperSql)) { throw new Error( `보안상의 이유로 ${keyword} 명령어는 사용할 수 없습니다. SELECT 쿼리만 허용됩니다.` ); } } if (!upperSql.startsWith("SELECT") && !upperSql.startsWith("WITH")) { throw new Error( "SELECT 쿼리만 허용됩니다. 데이터 조회 용도로만 사용할 수 있습니다." ); } const semicolonCount = (sql.match(/;/g) || []).length; if ( semicolonCount > 1 || (semicolonCount === 1 && !sql.trim().endsWith(";")) ) { throw new Error( "보안상의 이유로 여러 개의 쿼리를 동시에 실행할 수 없습니다." ); } } async getReports(params: GetReportsParams, companyCode: string): Promise { const { page = 1, limit = 20, searchText = "", searchField, startDate, endDate, reportType = "", useYn = "Y", sortBy = "created_at", sortOrder = "DESC", } = params; const offset = (page - 1) * limit; const safeSortBy = sanitizeSortBy(sortBy); const safeSortOrder = sanitizeSortOrder(sortOrder); const conditions: string[] = []; const values: (string | number)[] = []; let paramIndex = 1; this.applyCompanyCodeFilter(conditions, values, paramIndex, companyCode, "rm"); paramIndex = values.length + 1; if (useYn) { conditions.push(`rm.use_yn = $${paramIndex++}`); values.push(useYn); } paramIndex = this.applySearchConditions( conditions, values, paramIndex, searchText, searchField, startDate, endDate ); if (reportType) { conditions.push(`rm.report_type = $${paramIndex++}`); values.push(reportType); } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; const countResult = await queryOne<{ total: string }>( `SELECT COUNT(*) as total FROM report_master rm ${whereClause}`, values ); const total = parseInt(countResult?.total || "0", 10); const listQuery = ` SELECT rm.report_id, rm.report_name_kor, rm.report_name_eng, rm.template_id, rt.template_name_kor AS template_name, rm.report_type, rm.company_code, rm.description, rm.use_yn, rm.created_at, rm.created_by, rm.updated_at, rm.updated_by FROM report_master rm LEFT JOIN report_template rt ON rm.template_id = rt.template_id ${whereClause} ORDER BY rm.${safeSortBy} ${safeSortOrder} LIMIT $${paramIndex++} OFFSET $${paramIndex} `; const items = await query(listQuery, [...values, limit, offset]); const { typeSummary, allTypes, recentActivity, recentTotal } = await this.getReportStatistics(companyCode); return { items, total, page, limit, typeSummary, allTypes, recentActivity, recentTotal, }; } private applyCompanyCodeFilter( conditions: string[], values: (string | number)[], paramIndex: number, companyCode: string, alias: string ): void { if (companyCode !== "*") { conditions.push(`${alias}.company_code = $${paramIndex}`); values.push(companyCode); } } private applySearchConditions( conditions: string[], values: (string | number)[], paramIndex: number, searchText: string, searchField?: string, startDate?: string, endDate?: string ): number { const isDateRangeSearch = (searchField === "created_at" || searchField === "updated_at") && startDate && endDate; if (isDateRangeSearch) { const dateColumn = searchField === "created_at" ? "rm.created_at" : "COALESCE(rm.updated_at, rm.created_at)"; conditions.push(`${dateColumn} >= $${paramIndex}::date`); values.push(startDate!); paramIndex++; conditions.push(`${dateColumn} < ($${paramIndex}::date + INTERVAL '1 day')`); values.push(endDate!); paramIndex++; } else if (searchText) { paramIndex = this.applyTextSearch(conditions, values, paramIndex, searchText, searchField); } return paramIndex; } private applyTextSearch( conditions: string[], values: (string | number)[], paramIndex: number, searchText: string, searchField?: string ): number { if (searchField === "created_by") { conditions.push(`rm.created_by LIKE $${paramIndex}`); values.push(`%${searchText}%`); paramIndex++; } else if (searchField === "report_type") { const matchedCodes = findTypeCodesByLabel(searchText); if (matchedCodes.length > 0) { const placeholders = matchedCodes.map(() => `$${paramIndex++}`).join(", "); conditions.push(`rm.report_type IN (${placeholders})`); values.push(...matchedCodes); } else { conditions.push(`rm.report_type LIKE $${paramIndex}`); values.push(`%${searchText}%`); paramIndex++; } } else if (searchField === "updated_at") { conditions.push(`CAST(rm.updated_at AS TEXT) LIKE $${paramIndex}`); values.push(`%${searchText}%`); paramIndex++; } else { conditions.push( `(rm.report_name_kor LIKE $${paramIndex} OR rm.report_name_eng LIKE $${paramIndex})` ); values.push(`%${searchText}%`); paramIndex++; } return paramIndex; } private async getReportStatistics(companyCode: string) { const companyFilter = companyCode !== "*" ? " AND company_code = $1" : ""; const companyParams = companyCode !== "*" ? [companyCode] : []; const typeSummaryRows = await query<{ report_type: string; count: string }>( `SELECT report_type, COUNT(*) as count FROM report_master WHERE use_yn = 'Y' AND report_type IS NOT NULL AND report_type != ''${companyFilter} GROUP BY report_type ORDER BY count DESC`, companyParams ); const typeSummary = typeSummaryRows.map((r) => ({ type: r.report_type, count: parseInt(r.count, 10), })); const allTypes = typeSummary.map((t) => t.type).sort(); const recentActivityRows = await query<{ date_label: string; date_raw: string; count: string }>( `SELECT TO_CHAR(COALESCE(updated_at, created_at), 'MM/DD') AS date_label, MAX(COALESCE(updated_at, created_at)) AS date_raw, COUNT(*) AS count FROM report_master WHERE use_yn = 'Y' AND COALESCE(updated_at, created_at) >= NOW() - INTERVAL '30 days'${companyFilter} GROUP BY date_label ORDER BY count DESC, date_raw DESC LIMIT 3`, companyParams ); const recentActivity = recentActivityRows .map((r) => ({ date: r.date_label, count: parseInt(r.count, 10) })) .sort((a, b) => a.count - b.count); const recentCountResult = await queryOne<{ count: string }>( `SELECT COUNT(*) AS count FROM report_master WHERE use_yn = 'Y' AND COALESCE(updated_at, created_at) >= NOW() - INTERVAL '30 days'${companyFilter}`, companyParams ); const recentTotal = parseInt(recentCountResult?.count || "0", 10); return { typeSummary, allTypes, recentActivity, recentTotal }; } async getReportById(reportId: string, companyCode: string): Promise { const companyCondition = companyCode !== "*" ? " AND company_code = $2" : ""; const reportParams = companyCode !== "*" ? [reportId, companyCode] : [reportId]; const report = await queryOne( `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${companyCondition}`, reportParams ); if (!report) return null; const layout = await this.getLayoutInternal(reportId); const queries = await query( `SELECT query_id, report_id, query_name, query_type, sql_query, parameters, external_connection_id, display_order, created_at, created_by, updated_at, updated_by FROM report_query WHERE report_id = $1 ORDER BY display_order, created_at`, [reportId] ); const menuMappings = await query<{ menu_objid: number }>( `SELECT menu_objid FROM report_menu_mapping WHERE report_id = $1 ORDER BY created_at`, [reportId] ); const menuObjids = menuMappings?.map((m) => Number(m.menu_objid)) || []; return { report, layout, queries: queries || [], menuObjids }; } async createReport( data: CreateReportRequest, userId: string ): Promise { const reportId = generateReportId(); return transaction(async (client) => { await client.query( `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)`, [ reportId, data.reportNameKor, data.reportNameEng || null, data.templateId || null, data.reportType, data.companyCode || null, data.description || null, userId, ] ); if (data.templateId) { await this.createLayoutFromTemplate(client, data.templateId, reportId, userId); } return reportId; }); } private async createLayoutFromTemplate( client: { query: (sql: string, params: unknown[]) => Promise<{ rows: Record[] }> }, templateId: string, reportId: string, userId: string ): Promise { const template = await client.query( `SELECT layout_config FROM report_template WHERE template_id = $1`, [templateId] ); if (template.rows.length === 0 || !template.rows[0].layout_config) return; const layoutConfig = JSON.parse(template.rows[0].layout_config as string); await client.query( `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)`, [ generateLayoutId(), reportId, layoutConfig.width || DEFAULT_CANVAS_WIDTH, layoutConfig.height || DEFAULT_CANVAS_HEIGHT, layoutConfig.orientation || "portrait", DEFAULT_MARGINS.top, DEFAULT_MARGINS.bottom, DEFAULT_MARGINS.left, DEFAULT_MARGINS.right, JSON.stringify([]), userId, ] ); } async updateReport( reportId: string, data: UpdateReportRequest, userId: string, companyCode: string ): Promise { const setClauses: string[] = []; const values: (string | number | null)[] = []; let paramIndex = 1; const fieldMap: Array<[keyof UpdateReportRequest, string]> = [ ["reportNameKor", "report_name_kor"], ["reportNameEng", "report_name_eng"], ["reportType", "report_type"], ["description", "description"], ["useYn", "use_yn"], ]; for (const [key, column] of fieldMap) { if (data[key] !== undefined) { setClauses.push(`${column} = $${paramIndex++}`); values.push(data[key] as string); } } if (setClauses.length === 0) return false; setClauses.push(`updated_at = CURRENT_TIMESTAMP`); setClauses.push(`updated_by = $${paramIndex++}`); values.push(userId); values.push(reportId); let whereClause = `WHERE report_id = $${paramIndex}`; if (companyCode !== "*") { paramIndex++; values.push(companyCode); whereClause += ` AND company_code = $${paramIndex}`; } await query(`UPDATE report_master SET ${setClauses.join(", ")} ${whereClause}`, values); return true; } async deleteReport(reportId: string, companyCode: string): Promise { return transaction(async (client) => { const companyCondition = companyCode !== "*" ? " AND company_code = $2" : ""; const params = companyCode !== "*" ? [reportId, companyCode] : [reportId]; const existing = await client.query( `SELECT report_id FROM report_master WHERE report_id = $1${companyCondition}`, params ); if (existing.rows.length === 0) return false; await client.query(`DELETE FROM report_menu_mapping WHERE report_id = $1`, [reportId]); 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${companyCondition}`, params ); return (result.rowCount ?? 0) > 0; }); } async copyReport( reportId: string, userId: string, companyCode: string, newName?: string ): Promise { return transaction(async (client) => { const companyCondition = companyCode !== "*" ? " AND company_code = $2" : ""; const params = companyCode !== "*" ? [reportId, companyCode] : [reportId]; const originalResult = await client.query( `SELECT * FROM report_master WHERE report_id = $1${companyCondition}`, params ); if (originalResult.rows.length === 0) return null; const original = originalResult.rows[0]; const newReportId = generateReportId(); await client.query( `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)`, [ newReportId, newName || `${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, ] ); await this.copyLayoutData(client, reportId, newReportId, userId); await this.copyQueryData(client, reportId, newReportId, userId); return newReportId; }); } private async copyLayoutData( client: { query: (sql: string, params: unknown[]) => Promise<{ rows: Record[] }> }, sourceReportId: string, targetReportId: string, userId: string ): Promise { const layoutResult = await client.query( `SELECT * FROM report_layout WHERE report_id = $1`, [sourceReportId] ); if (layoutResult.rows.length === 0) return; const originalLayout = layoutResult.rows[0]; const componentsData = typeof originalLayout.components === "string" ? originalLayout.components : JSON.stringify(originalLayout.components); await client.query( `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)`, [ generateLayoutId(), targetReportId, originalLayout.canvas_width, originalLayout.canvas_height, originalLayout.page_orientation, originalLayout.margin_top, originalLayout.margin_bottom, originalLayout.margin_left, originalLayout.margin_right, componentsData, userId, ] ); } private async copyQueryData( client: { query: (sql: string, params: unknown[]) => Promise<{ rows: Record[] }> }, sourceReportId: string, targetReportId: string, userId: string ): Promise { const queriesResult = await client.query( `SELECT * FROM report_query WHERE report_id = $1 ORDER BY display_order`, [sourceReportId] ); for (const originalQuery of queriesResult.rows) { await client.query( `INSERT INTO report_query ( query_id, report_id, query_name, query_type, sql_query, parameters, external_connection_id, display_order, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, [ generateQueryId(), targetReportId, originalQuery.query_name, originalQuery.query_type, originalQuery.sql_query, JSON.stringify(originalQuery.parameters), originalQuery.external_connection_id || null, originalQuery.display_order, userId, ] ); } } async getLayout(reportId: string, companyCode?: string): Promise { if (companyCode && companyCode !== "*") { const ownerCheck = await queryOne<{ report_id: string }>( `SELECT report_id FROM report_master WHERE report_id = $1 AND company_code = $2`, [reportId, companyCode] ); if (!ownerCheck) return null; } return this.getLayoutInternal(reportId); } private async getLayoutInternal(reportId: string): Promise { const layoutRaw = await queryOne( `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`, [reportId] ); if (!layoutRaw) return null; return { ...layoutRaw, components: parseJsonComponents(layoutRaw.components as string | Record | null) as unknown as string, }; } async saveLayout( reportId: string, data: SaveLayoutRequest, userId: string, companyCode: string ): Promise { return transaction(async (client) => { if (companyCode !== "*") { const ownerCheck = await client.query( `SELECT report_id FROM report_master WHERE report_id = $1 AND company_code = $2`, [reportId, companyCode] ); if (ownerCheck.rows.length === 0) return false; } const firstPage = data.layoutConfig.pages[0]; const canvasWidth = firstPage?.width || DEFAULT_CANVAS_WIDTH; const canvasHeight = firstPage?.height || DEFAULT_CANVAS_HEIGHT; const pageOrientation = canvasWidth > canvasHeight ? "landscape" : "portrait"; const margins = firstPage?.margins || DEFAULT_MARGINS; await this.upsertLayout(client, reportId, { canvasWidth, canvasHeight, pageOrientation, margins, componentsJson: JSON.stringify(data.layoutConfig), userId, }); if (data.queries && data.queries.length > 0) { await this.replaceQueries(client, reportId, data.queries, userId); } if (data.menuObjids !== undefined) { await this.replaceMenuMappings(client, reportId, data.menuObjids, companyCode, userId); } await client.query( `UPDATE report_master SET updated_at = CURRENT_TIMESTAMP, updated_by = $1 WHERE report_id = $2`, [userId, reportId] ); return true; }); } private async upsertLayout( client: { query: (sql: string, params: unknown[]) => Promise<{ rows: Record[] }> }, reportId: string, opts: { canvasWidth: number; canvasHeight: number; pageOrientation: string; margins: { top: number; bottom: number; left: number; right: number }; componentsJson: string; userId: string; } ): Promise { const existing = await client.query( `SELECT layout_id FROM report_layout WHERE report_id = $1`, [reportId] ); const layoutParams = [ opts.canvasWidth, opts.canvasHeight, opts.pageOrientation, opts.margins.top, opts.margins.bottom, opts.margins.left, opts.margins.right, opts.componentsJson, opts.userId, ]; if (existing.rows.length > 0) { await client.query( `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`, [...layoutParams, reportId] ); } else { await client.query( `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)`, [generateLayoutId(), reportId, ...layoutParams] ); } } private async replaceQueries( client: { query: (sql: string, params: unknown[]) => Promise<{ rows: Record[] }> }, reportId: string, queries: NonNullable, userId: string ): Promise { await client.query(`DELETE FROM report_query WHERE report_id = $1`, [reportId]); for (let i = 0; i < queries.length; i++) { const q = queries[i]; await client.query( `INSERT INTO report_query ( query_id, report_id, query_name, query_type, sql_query, parameters, external_connection_id, display_order, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, [ q.id, reportId, q.name, q.type, q.sqlQuery, JSON.stringify(q.parameters), q.externalConnectionId || null, i, userId, ] ); } } private async replaceMenuMappings( client: { query: (sql: string, params: unknown[]) => Promise<{ rows: Record[] }> }, reportId: string, menuObjids: number[], companyCode: string, userId: string ): Promise { await client.query(`DELETE FROM report_menu_mapping WHERE report_id = $1`, [reportId]); if (menuObjids.length === 0) return; const reportResult = await client.query( `SELECT company_code FROM report_master WHERE report_id = $1`, [reportId] ); const resolvedCompanyCode = (reportResult.rows[0]?.company_code as string) || companyCode; for (const menuObjid of menuObjids) { await client.query( `INSERT INTO report_menu_mapping (report_id, menu_objid, company_code, created_by) VALUES ($1, $2, $3, $4)`, [reportId, menuObjid, resolvedCompanyCode, userId] ); } } async executeQuery( reportId: string, queryId: string, parameters: Record, sqlQuery?: string, externalConnectionId?: number | null ): Promise<{ fields: string[]; rows: Record[] }> { let sqlToExecute: string; let queryParameters: string[] = []; let connectionId: number | null = externalConnectionId ?? null; if (sqlQuery) { sqlToExecute = sqlQuery; queryParameters = this.extractUniqueParams(sqlQuery); } else { const storedQuery = await queryOne( `SELECT * FROM report_query WHERE query_id = $1 AND report_id = $2`, [queryId, reportId] ); if (!storedQuery) { throw new Error("쿼리를 찾을 수 없습니다."); } sqlToExecute = storedQuery.sql_query; queryParameters = Array.isArray(storedQuery.parameters) ? storedQuery.parameters : []; connectionId = storedQuery.external_connection_id; } this.validateQuerySafety(sqlToExecute); const paramArray = queryParameters.map((param) => parameters[param] ?? null); const { sql: finalSql, params: finalParams } = this.buildPreviewSqlIfNeeded(sqlToExecute, queryParameters, paramArray); try { const result = connectionId ? await this.executeOnExternalDb(finalSql, connectionId) : await query(finalSql, finalParams); const fields = result.length > 0 ? Object.keys(result[0]) : []; return { fields, rows: result as Record[] }; } catch (error: unknown) { const message = error instanceof Error ? error.message : "알 수 없는 오류"; throw new Error(`쿼리 실행 오류: ${message}`); } } private extractUniqueParams(sql: string): string[] { const matches = sql.match(/\$\d+/g); if (!matches) return []; return [...new Set(matches)]; } private buildPreviewSqlIfNeeded( sql: string, queryParameters: string[], paramArray: (string | number | null)[] ): { sql: string; params: (string | number | null)[] } { const allParamsNull = paramArray.length > 0 && paramArray.every((p) => p === null); if (!allParamsNull) return { sql, params: paramArray }; let previewSql = sql; for (const param of queryParameters) { const escapedParam = param.replace("$", "\\$"); const conditionPattern = new RegExp( `\\S+\\s*(?:=|!=|<>|>=|<=|>|<|LIKE|ILIKE|IN\\s*\\()\\s*${escapedParam}\\)?`, "gi" ); previewSql = previewSql.replace(conditionPattern, "TRUE"); } if (!/\bLIMIT\b/i.test(previewSql)) { previewSql = previewSql.replace(/;?\s*$/, " LIMIT 100"); } return { sql: previewSql, params: [] }; } private async executeOnExternalDb( sql: string, connectionId: number ): Promise[]> { const connectionResult = await ExternalDbConnectionService.getConnectionById(connectionId); if (!connectionResult.success || !connectionResult.data) { throw new Error("외부 DB 연결 정보를 찾을 수 없습니다."); } const connection = connectionResult.data; const connector = await DatabaseConnectorFactory.createConnector( connection.db_type, { host: connection.host, port: connection.port, database: connection.database_name, user: connection.username, password: connection.password, connectionTimeoutMillis: connection.connection_timeout || 30000, queryTimeoutMillis: connection.query_timeout || 30000, }, connectionId ); await connector.connect(); try { const queryResult = await connector.executeQuery(sql); return (queryResult.rows || []) as Record[]; } finally { await connector.disconnect(); } } async getReportsByMenuObjid( menuObjid: number, companyCode: string ): Promise<{ items: ReportMaster[]; total: number }> { // 매핑 없는 리포트(글로벌)는 어느 메뉴에서나 보이고, // 매핑 있는 리포트는 해당 menu_objid에 매핑된 경우에만 보임. const companyFilter = companyCode !== "*" ? " AND rm.company_code = $2" : ""; const params = companyCode !== "*" ? [menuObjid, companyCode] : [menuObjid]; const items = await query( `SELECT DISTINCT rm.report_id, rm.report_name_kor, rm.report_name_eng, rm.template_id, rt.template_name_kor AS template_name, rm.report_type, rm.company_code, rm.description, rm.use_yn, rm.created_at, rm.created_by, rm.updated_at, rm.updated_by FROM report_master rm LEFT JOIN report_template rt ON rm.template_id = rt.template_id WHERE rm.use_yn = 'Y'${companyFilter} AND ( NOT EXISTS (SELECT 1 FROM report_menu_mapping WHERE report_id = rm.report_id) OR EXISTS (SELECT 1 FROM report_menu_mapping WHERE report_id = rm.report_id AND menu_objid = $1) ) ORDER BY rm.report_name_kor ASC`, params ); return { items: items || [], total: (items || []).length }; } 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 = generateTemplateId(); 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' `; await query(deleteQuery, [templateId]); return true; } async saveAsTemplate( reportId: string, templateNameKor: string, templateNameEng: string | null | undefined, description: string | null | undefined, userId: string ): Promise { return transaction(async (client) => { const reportQuery = ` SELECT report_type FROM report_master WHERE report_id = $1 `; const reportResult = await client.query(reportQuery, [reportId]); if (reportResult.rows.length === 0) { throw new Error("리포트를 찾을 수 없습니다."); } const reportType = reportResult.rows[0].report_type; const layoutQuery = ` SELECT canvas_width, canvas_height, page_orientation, margin_top, margin_bottom, margin_left, margin_right, components FROM report_layout WHERE report_id = $1 `; const layoutResult = await client.query(layoutQuery, [reportId]); if (layoutResult.rows.length === 0) { throw new Error("레이아웃을 찾을 수 없습니다."); } const layout = layoutResult.rows[0]; const queriesQuery = ` SELECT query_name, query_type, sql_query, parameters, external_connection_id, display_order FROM report_query WHERE report_id = $1 ORDER BY display_order `; const queriesResult = await client.query(queriesQuery, [reportId]); const layoutConfig = { width: layout.canvas_width, height: layout.canvas_height, orientation: layout.page_orientation, margins: { top: layout.margin_top, bottom: layout.margin_bottom, left: layout.margin_left, right: layout.margin_right, }, components: typeof layout.components === "string" ? JSON.parse(layout.components || "[]") : (layout.components || []), }; const defaultQueries = queriesResult.rows.map((q) => ({ name: q.query_name, type: q.query_type, sqlQuery: q.sql_query, parameters: Array.isArray(q.parameters) ? q.parameters : [], externalConnectionId: q.external_connection_id, displayOrder: q.display_order, })); const templateId = generateTemplateId(); await client.query( `INSERT INTO report_template ( template_id, template_name_kor, template_name_eng, template_type, is_system, description, layout_config, default_queries, use_yn, sort_order, created_by ) VALUES ($1, $2, $3, $4, 'N', $5, $6, $7, 'Y', 999, $8)`, [ templateId, templateNameKor, templateNameEng || null, reportType, description || null, JSON.stringify(layoutConfig), JSON.stringify(defaultQueries), userId, ] ); return templateId; }); } async createTemplateFromLayout( templateNameKor: string, templateNameEng: string | null | undefined, templateType: string, description: string | null | undefined, layoutConfig: { width: number; height: number; orientation: string; margins: { top: number; bottom: number; left: number; right: number }; components: Record[]; }, defaultQueries: Array<{ name: string; type: "MASTER" | "DETAIL"; sqlQuery: string; parameters: string[]; externalConnectionId?: number | null; displayOrder?: number; }>, userId: string ): Promise { const templateId = generateTemplateId(); await query( `INSERT INTO report_template ( template_id, template_name_kor, template_name_eng, template_type, is_system, description, layout_config, default_queries, use_yn, sort_order, created_by ) VALUES ($1, $2, $3, $4, 'N', $5, $6, $7, 'Y', 999, $8) RETURNING template_id`, [ templateId, templateNameKor, templateNameEng || null, templateType, description || null, JSON.stringify(layoutConfig), JSON.stringify(defaultQueries), userId, ] ); return templateId; } // ─── 비주얼 쿼리 빌더 ───────────────────────────────────────────────────────── /** information_schema에서 사용자 테이블 목록 조회 */ async getSchemaTables(): Promise> { const sql = ` SELECT table_name, table_type FROM information_schema.tables WHERE table_schema = 'public' AND table_type IN ('BASE TABLE', 'VIEW') ORDER BY table_name `; return query<{ table_name: string; table_type: string }>(sql, []); } /** 특정 테이블의 컬럼 정보 조회 */ async getSchemaTableColumns( tableName: string ): Promise> { this.validateTableName(tableName); const sql = ` SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 ORDER BY ordinal_position `; return query<{ column_name: string; data_type: string; is_nullable: string }>(sql, [tableName]); } /** VisualQuery → SELECT SQL 문자열 빌드 (순수 함수) */ buildVisualQuerySql(vq: VisualQuery): string { this.validateTableName(vq.tableName); const selectParts: string[] = []; for (const col of vq.columns) { this.validateIdentifier(col); selectParts.push(`"${col}"`); } for (const fc of vq.formulaColumns) { this.validateFormulaExpression(fc.expression); this.validateIdentifier(fc.alias); selectParts.push(`(${fc.expression}) AS "${fc.alias}"`); } if (selectParts.length === 0) { throw new Error("최소 1개 이상의 컬럼을 선택해야 합니다."); } const limit = Math.min(Math.max(vq.limit ?? 100, 1), 10000); return `SELECT ${selectParts.join(", ")} FROM "${vq.tableName}" LIMIT ${limit}`; } async executeVisualQuery(vq: VisualQuery): Promise<{ fields: string[]; rows: Record[] }> { const sql = this.buildVisualQuerySql(vq); this.validateQuerySafety(sql); const result = await query(sql, []); const fields = result.length > 0 ? Object.keys(result[0]) : []; return { fields, rows: result as Record[] }; } // ─── 비주얼 쿼리 검증 헬퍼 ───────────────────────────────────────────────────── /** 테이블/컬럼명 화이트리스트 검증 — 영문+숫자+밑줄만 허용 */ private validateTableName(name: string): void { if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) { throw new Error(`유효하지 않은 테이블명입니다: ${name}`); } } private validateIdentifier(name: string): void { if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) { throw new Error(`유효하지 않은 식별자입니다: ${name}`); } } /** 수식 표현식 안전성 검증 — 세미콜론, 주석, 서브쿼리 금지 */ private validateFormulaExpression(expr: string): void { const forbidden = [";", "--", "/*", "*/", "SELECT", "INSERT", "UPDATE", "DELETE", "DROP", "TRUNCATE"]; const upper = expr.toUpperCase(); for (const keyword of forbidden) { if (upper.includes(keyword)) { throw new Error(`수식에 사용할 수 없는 키워드가 포함되어 있습니다: ${keyword}`); } } } // ─── 카테고리(report_type) 관리 ───────────────────────────────────────────────── /** DB에 저장된 모든 카테고리(report_type) 목록 조회 (중복 제거, 정렬) */ async getCategories(): Promise { const sql = ` SELECT DISTINCT report_type FROM report_master WHERE report_type IS NOT NULL AND report_type != '' ORDER BY report_type ASC `; const rows = await query<{ report_type: string }>(sql, []); return rows.map((r) => r.report_type); } } export default new ReportService();