/** * 마스터-디테일 엑셀 처리 서비스 * * 분할 패널 화면의 마스터-디테일 구조를 자동 감지하고 * 엑셀 다운로드/업로드 시 JOIN 및 그룹화 처리를 수행합니다. */ import { query, queryOne, transaction, getPool } from "../database/db"; import { logger } from "../utils/logger"; // ================================ // 인터페이스 정의 // ================================ /** * 마스터-디테일 관계 정보 */ export interface MasterDetailRelation { masterTable: string; detailTable: string; masterKeyColumn: string; // 마스터 테이블의 키 컬럼 (예: order_no) detailFkColumn: string; // 디테일 테이블의 FK 컬럼 (예: order_no) masterColumns: ColumnInfo[]; detailColumns: ColumnInfo[]; } /** * 컬럼 정보 */ export interface ColumnInfo { name: string; label: string; inputType: string; isFromMaster: boolean; } /** * 분할 패널 설정 */ export interface SplitPanelConfig { leftPanel: { tableName: string; columns: Array<{ name: string; label: string; width?: number }>; }; rightPanel: { tableName: string; columns: Array<{ name: string; label: string; width?: number }>; relation?: { type: string; foreignKey: string; leftColumn: string; }; }; } /** * 엑셀 다운로드 결과 */ export interface ExcelDownloadData { headers: string[]; // 컬럼 라벨들 columns: string[]; // 컬럼명들 data: Record[]; masterColumns: string[]; // 마스터 컬럼 목록 detailColumns: string[]; // 디테일 컬럼 목록 joinKey: string; // 조인 키 } /** * 엑셀 업로드 결과 */ export interface ExcelUploadResult { success: boolean; masterInserted: number; masterUpdated: number; detailInserted: number; detailDeleted: number; errors: string[]; } // ================================ // 서비스 클래스 // ================================ class MasterDetailExcelService { /** * 화면 ID로 분할 패널 설정 조회 */ async getSplitPanelConfig(screenId: number): Promise { try { logger.info(`분할 패널 설정 조회: screenId=${screenId}`); // screen_layouts에서 split-panel-layout 컴포넌트 찾기 const result = await queryOne( `SELECT properties->>'componentConfig' as config FROM screen_layouts WHERE screen_id = $1 AND component_type = 'component' AND properties->>'componentType' = 'split-panel-layout' LIMIT 1`, [screenId] ); if (!result || !result.config) { logger.info(`분할 패널 없음: screenId=${screenId}`); return null; } const config = typeof result.config === "string" ? JSON.parse(result.config) : result.config; logger.info(`분할 패널 설정 발견:`, { leftTable: config.leftPanel?.tableName, rightTable: config.rightPanel?.tableName, relation: config.rightPanel?.relation, }); return { leftPanel: config.leftPanel, rightPanel: config.rightPanel, }; } catch (error: any) { logger.error(`분할 패널 설정 조회 실패: ${error.message}`); return null; } } /** * column_labels에서 Entity 관계 정보 조회 * 디테일 테이블에서 마스터 테이블을 참조하는 컬럼 찾기 */ async getEntityRelation( detailTable: string, masterTable: string ): Promise<{ detailFkColumn: string; masterKeyColumn: string } | null> { try { logger.info(`Entity 관계 조회: ${detailTable} -> ${masterTable}`); const result = await queryOne( `SELECT column_name, reference_column FROM column_labels WHERE table_name = $1 AND input_type = 'entity' AND reference_table = $2 LIMIT 1`, [detailTable, masterTable] ); if (!result) { logger.warn(`Entity 관계 없음: ${detailTable} -> ${masterTable}`); return null; } logger.info(`Entity 관계 발견: ${detailTable}.${result.column_name} -> ${masterTable}.${result.reference_column}`); return { detailFkColumn: result.column_name, masterKeyColumn: result.reference_column, }; } catch (error: any) { logger.error(`Entity 관계 조회 실패: ${error.message}`); return null; } } /** * 테이블의 컬럼 라벨 정보 조회 */ async getColumnLabels(tableName: string): Promise> { try { const result = await query( `SELECT column_name, column_label FROM column_labels WHERE table_name = $1`, [tableName] ); const labelMap = new Map(); for (const row of result) { labelMap.set(row.column_name, row.column_label || row.column_name); } return labelMap; } catch (error: any) { logger.error(`컬럼 라벨 조회 실패: ${error.message}`); return new Map(); } } /** * 마스터-디테일 관계 정보 조합 */ async getMasterDetailRelation( screenId: number ): Promise { try { // 1. 분할 패널 설정 조회 const splitPanel = await this.getSplitPanelConfig(screenId); if (!splitPanel) { return null; } const masterTable = splitPanel.leftPanel.tableName; const detailTable = splitPanel.rightPanel.tableName; if (!masterTable || !detailTable) { logger.warn("마스터 또는 디테일 테이블명 없음"); return null; } // 2. 분할 패널의 relation 정보가 있으면 우선 사용 let masterKeyColumn = splitPanel.rightPanel.relation?.leftColumn; let detailFkColumn = splitPanel.rightPanel.relation?.foreignKey; // 3. relation 정보가 없으면 column_labels에서 Entity 관계 조회 if (!masterKeyColumn || !detailFkColumn) { const entityRelation = await this.getEntityRelation(detailTable, masterTable); if (entityRelation) { masterKeyColumn = entityRelation.masterKeyColumn; detailFkColumn = entityRelation.detailFkColumn; } } if (!masterKeyColumn || !detailFkColumn) { logger.warn("조인 키 정보를 찾을 수 없음"); return null; } // 4. 컬럼 라벨 정보 조회 const masterLabels = await this.getColumnLabels(masterTable); const detailLabels = await this.getColumnLabels(detailTable); // 5. 마스터 컬럼 정보 구성 const masterColumns: ColumnInfo[] = splitPanel.leftPanel.columns.map(col => ({ name: col.name, label: masterLabels.get(col.name) || col.label || col.name, inputType: "text", isFromMaster: true, })); // 6. 디테일 컬럼 정보 구성 (FK 컬럼 제외) const detailColumns: ColumnInfo[] = splitPanel.rightPanel.columns .filter(col => col.name !== detailFkColumn) // FK 컬럼 제외 .map(col => ({ name: col.name, label: detailLabels.get(col.name) || col.label || col.name, inputType: "text", isFromMaster: false, })); logger.info(`마스터-디테일 관계 구성 완료:`, { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumnCount: masterColumns.length, detailColumnCount: detailColumns.length, }); return { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns, }; } catch (error: any) { logger.error(`마스터-디테일 관계 조회 실패: ${error.message}`); return null; } } /** * 마스터-디테일 JOIN 데이터 조회 (엑셀 다운로드용) */ async getJoinedData( relation: MasterDetailRelation, companyCode: string, filters?: Record ): Promise { try { const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation; // SELECT 절 구성 const masterSelectCols = masterColumns.map(col => `m."${col.name}"`); const detailSelectCols = detailColumns.map(col => `d."${col.name}"`); const selectClause = [...masterSelectCols, ...detailSelectCols].join(", "); // WHERE 절 구성 const whereConditions: string[] = []; const params: any[] = []; let paramIndex = 1; // 회사 코드 필터 (최고 관리자 제외) if (companyCode && companyCode !== "*") { whereConditions.push(`m.company_code = $${paramIndex}`); params.push(companyCode); paramIndex++; } // 추가 필터 적용 if (filters) { for (const [key, value] of Object.entries(filters)) { if (value !== undefined && value !== null && value !== "") { // 마스터 테이블 컬럼인지 확인 const isMasterCol = masterColumns.some(c => c.name === key); const tableAlias = isMasterCol ? "m" : "d"; whereConditions.push(`${tableAlias}."${key}" = $${paramIndex}`); params.push(value); paramIndex++; } } } const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : ""; // JOIN 쿼리 실행 const sql = ` SELECT ${selectClause} FROM "${masterTable}" m LEFT JOIN "${detailTable}" d ON m."${masterKeyColumn}" = d."${detailFkColumn}" AND m.company_code = d.company_code ${whereClause} ORDER BY m."${masterKeyColumn}", d.id `; logger.info(`마스터-디테일 JOIN 쿼리:`, { sql, params }); const data = await query(sql, params); // 헤더 및 컬럼 정보 구성 const headers = [...masterColumns.map(c => c.label), ...detailColumns.map(c => c.label)]; const columns = [...masterColumns.map(c => c.name), ...detailColumns.map(c => c.name)]; logger.info(`마스터-디테일 데이터 조회 완료: ${data.length}행`); return { headers, columns, data, masterColumns: masterColumns.map(c => c.name), detailColumns: detailColumns.map(c => c.name), joinKey: masterKeyColumn, }; } catch (error: any) { logger.error(`마스터-디테일 데이터 조회 실패: ${error.message}`); throw error; } } /** * 마스터-디테일 데이터 업로드 (엑셀 업로드용) * * 처리 로직: * 1. 엑셀 데이터를 마스터 키로 그룹화 * 2. 각 그룹의 첫 번째 행에서 마스터 데이터 추출 → UPSERT * 3. 해당 마스터 키의 기존 디테일 삭제 * 4. 새 디테일 데이터 INSERT */ async uploadJoinedData( relation: MasterDetailRelation, data: Record[], companyCode: string, userId?: string ): Promise { const result: ExcelUploadResult = { success: false, masterInserted: 0, masterUpdated: 0, detailInserted: 0, detailDeleted: 0, errors: [], }; const pool = getPool(); const client = await pool.connect(); try { await client.query("BEGIN"); const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation; // 1. 데이터를 마스터 키로 그룹화 const groupedData = new Map[]>(); for (const row of data) { const masterKey = row[masterKeyColumn]; if (!masterKey) { result.errors.push(`마스터 키(${masterKeyColumn}) 값이 없는 행이 있습니다.`); continue; } if (!groupedData.has(masterKey)) { groupedData.set(masterKey, []); } groupedData.get(masterKey)!.push(row); } logger.info(`데이터 그룹화 완료: ${groupedData.size}개 마스터 그룹`); // 2. 각 그룹 처리 for (const [masterKey, rows] of groupedData.entries()) { try { // 2a. 마스터 데이터 추출 (첫 번째 행에서) const masterData: Record = {}; for (const col of masterColumns) { if (rows[0][col.name] !== undefined) { masterData[col.name] = rows[0][col.name]; } } // 회사 코드, 작성자 추가 masterData.company_code = companyCode; if (userId) { masterData.writer = userId; } // 2b. 마스터 UPSERT const existingMaster = await client.query( `SELECT id FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`, [masterKey, companyCode] ); if (existingMaster.rows.length > 0) { // UPDATE const updateCols = Object.keys(masterData) .filter(k => k !== masterKeyColumn && k !== "id") .map((k, i) => `"${k}" = $${i + 1}`); const updateValues = Object.keys(masterData) .filter(k => k !== masterKeyColumn && k !== "id") .map(k => masterData[k]); if (updateCols.length > 0) { await client.query( `UPDATE "${masterTable}" SET ${updateCols.join(", ")}, updated_date = NOW() WHERE "${masterKeyColumn}" = $${updateValues.length + 1} AND company_code = $${updateValues.length + 2}`, [...updateValues, masterKey, companyCode] ); } result.masterUpdated++; } else { // INSERT const insertCols = Object.keys(masterData); const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`); const insertValues = insertCols.map(k => masterData[k]); await client.query( `INSERT INTO "${masterTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date) VALUES (${insertPlaceholders.join(", ")}, NOW())`, insertValues ); result.masterInserted++; } // 2c. 기존 디테일 삭제 const deleteResult = await client.query( `DELETE FROM "${detailTable}" WHERE "${detailFkColumn}" = $1 AND company_code = $2`, [masterKey, companyCode] ); result.detailDeleted += deleteResult.rowCount || 0; // 2d. 새 디테일 INSERT for (const row of rows) { const detailData: Record = {}; // FK 컬럼 추가 detailData[detailFkColumn] = masterKey; detailData.company_code = companyCode; if (userId) { detailData.writer = userId; } // 디테일 컬럼 데이터 추출 for (const col of detailColumns) { if (row[col.name] !== undefined) { detailData[col.name] = row[col.name]; } } const insertCols = Object.keys(detailData); const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`); const insertValues = insertCols.map(k => detailData[k]); await client.query( `INSERT INTO "${detailTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date) VALUES (${insertPlaceholders.join(", ")}, NOW())`, insertValues ); result.detailInserted++; } } catch (error: any) { result.errors.push(`마스터 키 ${masterKey} 처리 실패: ${error.message}`); logger.error(`마스터 키 ${masterKey} 처리 실패:`, error); } } await client.query("COMMIT"); result.success = result.errors.length === 0 || result.masterInserted + result.masterUpdated > 0; logger.info(`마스터-디테일 업로드 완료:`, { masterInserted: result.masterInserted, masterUpdated: result.masterUpdated, detailInserted: result.detailInserted, detailDeleted: result.detailDeleted, errors: result.errors.length, }); } catch (error: any) { await client.query("ROLLBACK"); result.errors.push(`트랜잭션 실패: ${error.message}`); logger.error(`마스터-디테일 업로드 트랜잭션 실패:`, error); } finally { client.release(); } return result; } } export const masterDetailExcelService = new MasterDetailExcelService();