/** * 마스터-디테일 엑셀 처리 서비스 * * 분할 패널 화면의 마스터-디테일 구조를 자동 감지하고 * 엑셀 다운로드/업로드 시 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; // 복합키 지원 (새로운 방식) keys?: Array<{ leftColumn: string; rightColumn: 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 정보가 있으면 우선 사용 // 🔥 keys 배열을 우선 사용 (새로운 복합키 지원 방식) let masterKeyColumn: string | undefined; let detailFkColumn: string | undefined; const relationKeys = splitPanel.rightPanel.relation?.keys; if (relationKeys && relationKeys.length > 0) { // keys 배열에서 첫 번째 키 사용 masterKeyColumn = relationKeys[0].leftColumn; detailFkColumn = relationKeys[0].rightColumn; logger.info(`keys 배열에서 관계 정보 사용: ${masterKeyColumn} -> ${detailFkColumn}`); } else { // 하위 호환성: 기존 leftColumn/foreignKey 사용 masterKeyColumn = splitPanel.rightPanel.relation?.leftColumn; 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; // 조인 컬럼과 일반 컬럼 분리 // 조인 컬럼 형식: "테이블명.컬럼명" (예: customer_mng.customer_name) const entityJoins: Array<{ refTable: string; refColumn: string; sourceColumn: string; alias: string; displayColumn: string; }> = []; // SELECT 절 구성 const selectParts: string[] = []; let aliasIndex = 0; // 마스터 컬럼 처리 for (const col of masterColumns) { if (col.name.includes(".")) { // 조인 컬럼: 테이블명.컬럼명 const [refTable, displayColumn] = col.name.split("."); const alias = `ej${aliasIndex++}`; // column_labels에서 FK 컬럼 찾기 const fkColumn = await this.findForeignKeyColumn(masterTable, refTable); if (fkColumn) { entityJoins.push({ refTable, refColumn: fkColumn.referenceColumn, sourceColumn: fkColumn.sourceColumn, alias, displayColumn, }); selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`); } else { // FK를 못 찾으면 NULL로 처리 selectParts.push(`NULL AS "${col.name}"`); } } else { // 일반 컬럼 selectParts.push(`m."${col.name}"`); } } // 디테일 컬럼 처리 for (const col of detailColumns) { if (col.name.includes(".")) { // 조인 컬럼: 테이블명.컬럼명 const [refTable, displayColumn] = col.name.split("."); const alias = `ej${aliasIndex++}`; // column_labels에서 FK 컬럼 찾기 const fkColumn = await this.findForeignKeyColumn(detailTable, refTable); if (fkColumn) { entityJoins.push({ refTable, refColumn: fkColumn.referenceColumn, sourceColumn: fkColumn.sourceColumn, alias, displayColumn, }); selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`); } else { selectParts.push(`NULL AS "${col.name}"`); } } else { // 일반 컬럼 selectParts.push(`d."${col.name}"`); } } const selectClause = selectParts.join(", "); // 엔티티 조인 절 구성 const entityJoinClauses = entityJoins.map(ej => `LEFT JOIN "${ej.refTable}" ${ej.alias} ON m."${ej.sourceColumn}" = ${ej.alias}."${ej.refColumn}"` ).join("\n "); // 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 !== "") { // 조인 컬럼인지 확인 if (key.includes(".")) continue; // 마스터 테이블 컬럼인지 확인 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 ${entityJoinClauses} ${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; } } /** * 특정 테이블에서 참조 테이블로의 FK 컬럼 찾기 */ private async findForeignKeyColumn( sourceTable: string, referenceTable: string ): Promise<{ sourceColumn: string; referenceColumn: string } | null> { try { const result = await query<{ column_name: string; reference_column: string }>( `SELECT column_name, reference_column FROM column_labels WHERE table_name = $1 AND reference_table = $2 AND input_type = 'entity' LIMIT 1`, [sourceTable, referenceTable] ); if (result.length > 0) { return { sourceColumn: result[0].column_name, referenceColumn: result[0].reference_column, }; } return null; } catch (error) { logger.error(`FK 컬럼 조회 실패: ${sourceTable} -> ${referenceTable}`, error); return null; } } /** * 마스터-디테일 데이터 업로드 (엑셀 업로드용) * * 처리 로직: * 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; } /** * 마스터-디테일 간단 모드 업로드 * * 마스터 정보는 UI에서 선택하고, 엑셀은 디테일 데이터만 포함 * 채번 규칙을 통해 마스터 키 자동 생성 * * @param screenId 화면 ID * @param detailData 디테일 데이터 배열 * @param masterFieldValues UI에서 선택한 마스터 필드 값 * @param numberingRuleId 채번 규칙 ID (optional) * @param companyCode 회사 코드 * @param userId 사용자 ID * @param afterUploadFlowId 업로드 후 실행할 노드 플로우 ID (optional, 하위 호환성) * @param afterUploadFlows 업로드 후 실행할 노드 플로우 배열 (optional) */ async uploadSimple( screenId: number, detailData: Record[], masterFieldValues: Record, numberingRuleId: string | undefined, companyCode: string, userId: string, afterUploadFlowId?: string, afterUploadFlows?: Array<{ flowId: string; order: number }> ): Promise<{ success: boolean; masterInserted: number; detailInserted: number; generatedKey: string; errors: string[]; controlResult?: any; }> { const result: { success: boolean; masterInserted: number; detailInserted: number; generatedKey: string; errors: string[]; controlResult?: any; } = { success: false, masterInserted: 0, detailInserted: 0, generatedKey: "", errors: [] as string[], }; const pool = getPool(); const client = await pool.connect(); try { await client.query("BEGIN"); // 1. 마스터-디테일 관계 정보 조회 const relation = await this.getMasterDetailRelation(screenId); if (!relation) { throw new Error("마스터-디테일 관계 정보를 찾을 수 없습니다."); } const { masterTable, detailTable, masterKeyColumn, detailFkColumn } = relation; // 2. 채번 처리 let generatedKey: string; if (numberingRuleId) { // 채번 규칙으로 키 생성 generatedKey = await this.generateNumberWithRule(client, numberingRuleId, companyCode); } else { // 채번 규칙 없으면 마스터 필드에서 키 값 사용 generatedKey = masterFieldValues[masterKeyColumn]; if (!generatedKey) { throw new Error(`마스터 키(${masterKeyColumn}) 값이 필요합니다.`); } } result.generatedKey = generatedKey; logger.info(`채번 결과: ${generatedKey}`); // 3. 마스터 레코드 생성 const masterData: Record = { ...masterFieldValues, [masterKeyColumn]: generatedKey, company_code: companyCode, writer: userId, }; // 마스터 컬럼명 목록 구성 const masterCols = Object.keys(masterData).filter(k => masterData[k] !== undefined); const masterPlaceholders = masterCols.map((_, i) => `$${i + 1}`); const masterValues = masterCols.map(k => masterData[k]); await client.query( `INSERT INTO "${masterTable}" (${masterCols.map(c => `"${c}"`).join(", ")}, created_date) VALUES (${masterPlaceholders.join(", ")}, NOW())`, masterValues ); result.masterInserted = 1; logger.info(`마스터 레코드 생성: ${masterTable}, key=${generatedKey}`); // 4. 디테일 레코드들 생성 for (const row of detailData) { try { const detailRowData: Record = { ...row, [detailFkColumn]: generatedKey, company_code: companyCode, writer: userId, }; // 빈 값 필터링 및 id 제외 const detailCols = Object.keys(detailRowData).filter(k => k !== "id" && detailRowData[k] !== undefined && detailRowData[k] !== null && detailRowData[k] !== "" ); const detailPlaceholders = detailCols.map((_, i) => `$${i + 1}`); const detailValues = detailCols.map(k => detailRowData[k]); await client.query( `INSERT INTO "${detailTable}" (${detailCols.map(c => `"${c}"`).join(", ")}, created_date) VALUES (${detailPlaceholders.join(", ")}, NOW())`, detailValues ); result.detailInserted++; } catch (error: any) { result.errors.push(`디테일 행 처리 실패: ${error.message}`); logger.error(`디테일 행 처리 실패:`, error); } } await client.query("COMMIT"); result.success = result.errors.length === 0 || result.detailInserted > 0; logger.info(`마스터-디테일 간단 모드 업로드 완료:`, { masterInserted: result.masterInserted, detailInserted: result.detailInserted, generatedKey: result.generatedKey, errors: result.errors.length, }); // 업로드 후 제어 실행 (단일 또는 다중) const flowsToExecute = afterUploadFlows && afterUploadFlows.length > 0 ? afterUploadFlows // 다중 제어 : afterUploadFlowId ? [{ flowId: afterUploadFlowId, order: 1 }] // 단일 (하위 호환성) : []; if (flowsToExecute.length > 0 && result.success) { try { const { NodeFlowExecutionService } = await import("./nodeFlowExecutionService"); // 마스터 데이터를 제어에 전달 const masterData = { ...masterFieldValues, [relation!.masterKeyColumn]: result.generatedKey, company_code: companyCode, }; const controlResults: any[] = []; // 순서대로 제어 실행 for (const flow of flowsToExecute.sort((a, b) => a.order - b.order)) { logger.info(`업로드 후 제어 실행: flowId=${flow.flowId}, order=${flow.order}`); const controlResult = await NodeFlowExecutionService.executeFlow( parseInt(flow.flowId), { sourceData: [masterData], dataSourceType: "formData", buttonId: "excel-upload-button", screenId: screenId, userId: userId, companyCode: companyCode, formData: masterData, } ); controlResults.push({ flowId: flow.flowId, order: flow.order, success: controlResult.success, message: controlResult.message, executedNodes: controlResult.nodes?.length || 0, }); } result.controlResult = { success: controlResults.every(r => r.success), executedFlows: controlResults.length, results: controlResults, }; logger.info(`업로드 후 제어 실행 완료: ${controlResults.length}개 실행`, result.controlResult); } catch (controlError: any) { logger.error(`업로드 후 제어 실행 실패:`, controlError); result.controlResult = { success: false, message: `제어 실행 실패: ${controlError.message}`, }; } } } catch (error: any) { await client.query("ROLLBACK"); result.errors.push(`트랜잭션 실패: ${error.message}`); logger.error(`마스터-디테일 간단 모드 업로드 실패:`, error); } finally { client.release(); } return result; } /** * 채번 규칙으로 번호 생성 (기존 numberingRuleService 사용) */ private async generateNumberWithRule( client: any, ruleId: string, companyCode: string ): Promise { try { // 기존 numberingRuleService를 사용하여 코드 할당 const { numberingRuleService } = await import("./numberingRuleService"); const generatedCode = await numberingRuleService.allocateCode(ruleId, companyCode); logger.info(`채번 생성 (numberingRuleService): rule=${ruleId}, result=${generatedCode}`); return generatedCode; } catch (error: any) { logger.error(`채번 생성 실패: rule=${ruleId}, error=${error.message}`); throw error; } } } export const masterDetailExcelService = new MasterDetailExcelService();