diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts index b984b7c1..f0809386 100644 --- a/backend-node/src/controllers/screenGroupController.ts +++ b/backend-node/src/controllers/screenGroupController.ts @@ -132,17 +132,37 @@ export const getScreenGroup = async (req: Request, res: Response) => { // 화면 그룹 생성 export const createScreenGroup = async (req: Request, res: Response) => { try { - const companyCode = (req.user as any).companyCode; + const userCompanyCode = (req.user as any).companyCode; const userId = (req.user as any).userId; - const { group_name, group_code, main_table_name, description, icon, display_order, is_active } = req.body; + const { group_name, group_code, main_table_name, description, icon, display_order, is_active, parent_group_id, target_company_code } = req.body; if (!group_name || !group_code) { return res.status(400).json({ success: false, message: "그룹명과 그룹코드는 필수입니다." }); } + // 회사 코드 결정: 최고 관리자가 특정 회사를 선택한 경우 해당 회사로, 아니면 사용자 회사 + let finalCompanyCode = userCompanyCode; + if (userCompanyCode === "*" && target_company_code) { + // 최고 관리자가 특정 회사를 선택한 경우 + finalCompanyCode = target_company_code; + } + + // 부모 그룹이 있으면 group_level과 hierarchy_path 계산 + let groupLevel = 0; + let parentHierarchyPath = ""; + + if (parent_group_id) { + const parentQuery = `SELECT id, group_level, hierarchy_path FROM screen_groups WHERE id = $1`; + const parentResult = await pool.query(parentQuery, [parent_group_id]); + if (parentResult.rows.length > 0) { + groupLevel = (parentResult.rows[0].group_level || 0) + 1; + parentHierarchyPath = parentResult.rows[0].hierarchy_path || `/${parent_group_id}/`; + } + } + const query = ` - INSERT INTO screen_groups (group_name, group_code, main_table_name, description, icon, display_order, is_active, company_code, writer) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + INSERT INTO screen_groups (group_name, group_code, main_table_name, description, icon, display_order, is_active, company_code, writer, parent_group_id, group_level) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING * `; const params = [ @@ -153,15 +173,27 @@ export const createScreenGroup = async (req: Request, res: Response) => { icon || null, display_order || 0, is_active || 'Y', - companyCode === "*" ? "*" : companyCode, - userId + finalCompanyCode, + userId, + parent_group_id || null, + groupLevel ]; const result = await pool.query(query, params); + const newGroupId = result.rows[0].id; - logger.info("화면 그룹 생성", { companyCode, groupId: result.rows[0].id, groupName: group_name }); + // hierarchy_path 업데이트 + const hierarchyPath = parent_group_id + ? `${parentHierarchyPath}${newGroupId}/`.replace('//', '/') + : `/${newGroupId}/`; + await pool.query(`UPDATE screen_groups SET hierarchy_path = $1 WHERE id = $2`, [hierarchyPath, newGroupId]); - res.json({ success: true, data: result.rows[0], message: "화면 그룹이 생성되었습니다." }); + // 업데이트된 데이터 반환 + const updatedResult = await pool.query(`SELECT * FROM screen_groups WHERE id = $1`, [newGroupId]); + + logger.info("화면 그룹 생성", { userCompanyCode, finalCompanyCode, groupId: newGroupId, groupName: group_name, parentGroupId: parent_group_id }); + + res.json({ success: true, data: updatedResult.rows[0], message: "화면 그룹이 생성되었습니다." }); } catch (error: any) { logger.error("화면 그룹 생성 실패:", error); if (error.code === '23505') { @@ -175,21 +207,72 @@ export const createScreenGroup = async (req: Request, res: Response) => { export const updateScreenGroup = async (req: Request, res: Response) => { try { const { id } = req.params; - const companyCode = (req.user as any).companyCode; - const { group_name, group_code, main_table_name, description, icon, display_order, is_active } = req.body; + const userCompanyCode = (req.user as any).companyCode; + const { group_name, group_code, main_table_name, description, icon, display_order, is_active, parent_group_id, target_company_code } = req.body; - let query = ` - UPDATE screen_groups - SET group_name = $1, group_code = $2, main_table_name = $3, description = $4, - icon = $5, display_order = $6, is_active = $7, updated_date = NOW() - WHERE id = $8 - `; - const params: any[] = [group_name, group_code, main_table_name, description, icon, display_order, is_active, id]; + // 회사 코드 결정: 최고 관리자가 특정 회사를 선택한 경우 해당 회사로, 아니면 현재 그룹의 회사 유지 + let finalCompanyCode = target_company_code || null; - // 멀티테넌시 필터링 - if (companyCode !== "*") { - query += ` AND company_code = $9`; - params.push(companyCode); + // 부모 그룹이 변경되면 group_level과 hierarchy_path 재계산 + let groupLevel = 0; + let hierarchyPath = `/${id}/`; + + if (parent_group_id !== undefined && parent_group_id !== null) { + // 자기 자신을 부모로 지정하는 것 방지 + if (Number(parent_group_id) === Number(id)) { + return res.status(400).json({ success: false, message: "자기 자신을 상위 그룹으로 지정할 수 없습니다." }); + } + + const parentQuery = `SELECT id, group_level, hierarchy_path FROM screen_groups WHERE id = $1`; + const parentResult = await pool.query(parentQuery, [parent_group_id]); + if (parentResult.rows.length > 0) { + // 순환 참조 방지: 부모의 hierarchy_path에 현재 그룹 ID가 포함되어 있으면 오류 + if (parentResult.rows[0].hierarchy_path && parentResult.rows[0].hierarchy_path.includes(`/${id}/`)) { + return res.status(400).json({ success: false, message: "하위 그룹을 상위 그룹으로 지정할 수 없습니다." }); + } + groupLevel = (parentResult.rows[0].group_level || 0) + 1; + const parentPath = parentResult.rows[0].hierarchy_path || `/${parent_group_id}/`; + hierarchyPath = `${parentPath}${id}/`.replace('//', '/'); + } + } + + // 쿼리 구성: 회사 코드 변경 포함 여부 + let query: string; + let params: any[]; + + if (userCompanyCode === "*" && finalCompanyCode) { + // 최고 관리자가 회사를 변경하는 경우 + query = ` + UPDATE screen_groups + SET group_name = $1, group_code = $2, main_table_name = $3, description = $4, + icon = $5, display_order = $6, is_active = $7, updated_date = NOW(), + parent_group_id = $8, group_level = $9, hierarchy_path = $10, company_code = $11 + WHERE id = $12 + `; + params = [ + group_name, group_code, main_table_name, description, icon, display_order, is_active, + parent_group_id || null, groupLevel, hierarchyPath, finalCompanyCode, id + ]; + } else { + // 회사 코드 변경 없음 + query = ` + UPDATE screen_groups + SET group_name = $1, group_code = $2, main_table_name = $3, description = $4, + icon = $5, display_order = $6, is_active = $7, updated_date = NOW(), + parent_group_id = $8, group_level = $9, hierarchy_path = $10 + WHERE id = $11 + `; + params = [ + group_name, group_code, main_table_name, description, icon, display_order, is_active, + parent_group_id || null, groupLevel, hierarchyPath, id + ]; + } + + // 멀티테넌시 필터링 (최고 관리자가 아닌 경우) + if (userCompanyCode !== "*") { + const paramIndex = params.length + 1; + query += ` AND company_code = $${paramIndex}`; + params.push(userCompanyCode); } query += " RETURNING *"; @@ -200,7 +283,7 @@ export const updateScreenGroup = async (req: Request, res: Response) => { return res.status(404).json({ success: false, message: "화면 그룹을 찾을 수 없거나 권한이 없습니다." }); } - logger.info("화면 그룹 수정", { companyCode, groupId: id }); + logger.info("화면 그룹 수정", { userCompanyCode, groupId: id, parentGroupId: parent_group_id, targetCompanyCode: finalCompanyCode }); res.json({ success: true, data: result.rows[0], message: "화면 그룹이 수정되었습니다." }); } catch (error: any) { @@ -904,6 +987,7 @@ export const getMultipleScreenLayoutSummary = async (req: Request, res: Response // 여러 화면의 컴포넌트 정보 (좌표 포함) 한번에 조회 // componentType이 더 정확한 위젯 종류 (table-list, button-primary 등) + // 다양한 컴포넌트 타입에서 사용 컬럼 추출 const query = ` SELECT screen_id, @@ -914,7 +998,15 @@ export const getMultipleScreenLayoutSummary = async (req: Request, res: Response height, properties->>'componentType' as component_kind, properties->>'widgetType' as widget_type, - properties->>'label' as label + properties->>'label' as label, + COALESCE( + properties->'componentConfig'->>'bindField', + properties->>'bindField', + properties->'componentConfig'->>'field', + properties->>'field' + ) as bind_field, + -- componentConfig 전체 (JavaScript에서 다양한 패턴 파싱용) + properties->'componentConfig' as component_config FROM screen_layouts WHERE screen_id = ANY($1) AND component_type = 'component' @@ -944,6 +1036,75 @@ export const getMultipleScreenLayoutSummary = async (req: Request, res: Response // componentKind가 더 정확한 타입 (table-list, button-primary, table-search-widget 등) const componentKind = row.component_kind || row.widget_type || 'text'; const widgetType = row.widget_type || 'text'; + const componentConfig = row.component_config || {}; + + // 다양한 컴포넌트 타입에서 usedColumns, joinColumns 추출 + let usedColumns: string[] = []; + let joinColumns: string[] = []; + + // 1. 기본 columns 배열에서 추출 (table-list 등) + if (Array.isArray(componentConfig.columns)) { + componentConfig.columns.forEach((col: any) => { + const colName = col.columnName || col.field || col.name; + if (colName && !usedColumns.includes(colName)) { + usedColumns.push(colName); + } + if (col.isEntityJoin === true && colName && !joinColumns.includes(colName)) { + joinColumns.push(colName); + } + }); + } + + // 2. split-panel-layout의 leftPanel.columns, rightPanel.columns 추출 + if (componentKind === 'split-panel-layout') { + if (componentConfig.leftPanel?.columns && Array.isArray(componentConfig.leftPanel.columns)) { + componentConfig.leftPanel.columns.forEach((col: any) => { + const colName = col.name || col.columnName || col.field; + if (colName && !usedColumns.includes(colName)) { + usedColumns.push(colName); + } + }); + } + if (componentConfig.rightPanel?.columns && Array.isArray(componentConfig.rightPanel.columns)) { + componentConfig.rightPanel.columns.forEach((col: any) => { + const colName = col.name || col.columnName || col.field; + if (colName) { + // customer_mng.customer_name 같은 경우 조인 컬럼으로 처리 + if (colName.includes('.')) { + if (!joinColumns.includes(colName)) { + joinColumns.push(colName); + } + } else { + if (!usedColumns.includes(colName)) { + usedColumns.push(colName); + } + } + } + }); + } + } + + // 3. selected-items-detail-input의 additionalFields, displayColumns 추출 + if (componentKind === 'selected-items-detail-input') { + if (componentConfig.additionalFields && Array.isArray(componentConfig.additionalFields)) { + componentConfig.additionalFields.forEach((field: any) => { + const fieldName = field.name || field.field; + if (fieldName && !usedColumns.includes(fieldName)) { + usedColumns.push(fieldName); + } + }); + } + // displayColumns는 연관 테이블에서 가져오는 표시용 컬럼이므로 + // 메인 테이블의 joinColumns가 아님 (parentDataMapping에서 별도 추출됨) + // 단, 참조용으로 usedColumns에는 추가 가능 + if (componentConfig.displayColumns && Array.isArray(componentConfig.displayColumns)) { + componentConfig.displayColumns.forEach((col: any) => { + const colName = col.name || col.columnName || col.field; + // displayColumns는 연관 테이블 컬럼이므로 메인 테이블 usedColumns에 추가하지 않음 + // 조인 컬럼은 parentDataMapping.targetField에서 추출됨 + }); + } + } if (summaryMap[screenId]) { summaryMap[screenId].widgetCounts[componentKind] = @@ -959,6 +1120,9 @@ export const getMultipleScreenLayoutSummary = async (req: Request, res: Response componentKind: componentKind, // 정확한 컴포넌트 종류 widgetType: widgetType, label: row.label, + bindField: row.bind_field || null, // 바인딩된 컬럼명 + usedColumns: usedColumns, // 이 컴포넌트에서 사용하는 컬럼 목록 + joinColumns: joinColumns, // 이 컴포넌트에서 조인 컬럼 목록 }); // 캔버스 크기 계산 (최대 좌표 기준) @@ -1009,9 +1173,21 @@ export const getScreenSubTables = async (req: Request, res: Response) => { return res.status(400).json({ success: false, message: "screenIds 배열이 필요합니다." }); } - // 화면별 메인 테이블과 서브 테이블 관계 조회 - // componentConfig에서 tableName, sourceTable 추출 - const query = ` + // 화면별 서브 테이블 그룹화 + const screenSubTables: Record; + }>; + }> = {}; + + // 1. 기존 방식: componentConfig에서 tableName, sourceTable, fieldMappings 추출 + const componentQuery = ` SELECT DISTINCT sd.screen_id, sd.screen_name, @@ -1021,7 +1197,9 @@ export const getScreenSubTables = async (req: Request, res: Response) => { sl.properties->'componentConfig'->>'sourceTable' ) as sub_table, sl.properties->>'componentType' as component_type, - sl.properties->'componentConfig'->>'targetTable' as target_table + sl.properties->'componentConfig'->>'targetTable' as target_table, + sl.properties->'componentConfig'->'fieldMappings' as field_mappings, + sl.properties->'componentConfig'->'columns' as columns_config FROM screen_definitions sd JOIN screen_layouts sl ON sd.screen_id = sl.screen_id WHERE sd.screen_id = ANY($1) @@ -1032,21 +1210,50 @@ export const getScreenSubTables = async (req: Request, res: Response) => { ORDER BY sd.screen_id `; - const result = await pool.query(query, [screenIds]); + const componentResult = await pool.query(componentQuery, [screenIds]); - // 화면별 서브 테이블 그룹화 - const screenSubTables: Record; - }> = {}; + // fieldMappings의 한글 컬럼명을 조회하기 위한 테이블-컬럼 쌍 수집 + const columnLabelLookups: Array<{ table: string; column: string }> = []; + componentResult.rows.forEach((row: any) => { + if (row.field_mappings && Array.isArray(row.field_mappings)) { + row.field_mappings.forEach((fm: any) => { + const mainTable = row.main_table; + const subTable = row.sub_table; + if (fm.sourceField && subTable) { + columnLabelLookups.push({ table: subTable, column: fm.sourceField }); + } + if (fm.targetField && mainTable) { + columnLabelLookups.push({ table: mainTable, column: fm.targetField }); + } + }); + } + }); - result.rows.forEach((row: any) => { + // 한글 컬럼명 조회 + const columnLabelMap = new Map(); // "table.column" -> "한글명" + if (columnLabelLookups.length > 0) { + const uniqueLookups = [...new Set(columnLabelLookups.map(l => `${l.table}|${l.column}`))]; + const conditions = uniqueLookups.map((lookup, i) => { + const [table, column] = lookup.split('|'); + return `(table_name = $${i * 2 + 1} AND column_name = $${i * 2 + 2})`; + }); + const params = uniqueLookups.flatMap(lookup => lookup.split('|')); + + if (conditions.length > 0) { + const labelQuery = ` + SELECT table_name, column_name, column_label + FROM column_labels + WHERE ${conditions.join(' OR ')} + `; + const labelResult = await pool.query(labelQuery, params); + labelResult.rows.forEach((row: any) => { + const key = `${row.table_name}.${row.column_name}`; + columnLabelMap.set(key, row.column_label || row.column_name); + }); + } + } + + componentResult.rows.forEach((row: any) => { const screenId = row.screen_id; const mainTable = row.main_table; const subTable = row.sub_table; @@ -1082,15 +1289,442 @@ export const getScreenSubTables = async (req: Request, res: Response) => { relationType = 'join'; } + // fieldMappings 파싱 (JSON 배열 또는 null) + let fieldMappings: Array<{ sourceField: string; targetField: string; sourceDisplayName?: string; targetDisplayName?: string }> | undefined; + + if (row.field_mappings && Array.isArray(row.field_mappings)) { + // 1. 직접 fieldMappings가 있는 경우 + fieldMappings = row.field_mappings.map((fm: any) => { + const sourceField = fm.sourceField || fm.source_field || ''; + const targetField = fm.targetField || fm.target_field || ''; + + // 한글 컬럼명 조회 (sourceField는 서브테이블 컬럼, targetField는 메인테이블 컬럼) + const sourceKey = `${subTable}.${sourceField}`; + const targetKey = `${mainTable}.${targetField}`; + + return { + sourceField, + targetField, + // sourceField(서브테이블 컬럼)의 한글명 + sourceDisplayName: columnLabelMap.get(sourceKey) || sourceField, + // targetField(메인테이블 컬럼)의 한글명 + targetDisplayName: columnLabelMap.get(targetKey) || targetField, + }; + }).filter((fm: any) => fm.sourceField || fm.targetField); + } else if (row.columns_config && Array.isArray(row.columns_config)) { + // 2. columns_config.mapping에서 추출 (item_info 같은 경우) + // mapping.type === 'source'인 경우: sourceField(서브테이블) → field(메인테이블) + fieldMappings = []; + row.columns_config.forEach((col: any) => { + if (col.mapping && col.mapping.type === 'source' && col.mapping.sourceField) { + fieldMappings!.push({ + sourceField: col.field || '', // 메인 테이블 컬럼 + targetField: col.mapping.sourceField || '', // 서브 테이블 컬럼 + sourceDisplayName: col.label || col.field || '', // 한글 라벨 + targetDisplayName: col.mapping.sourceField || '', // 서브 테이블은 영문만 + }); + } + }); + if (fieldMappings.length === 0) { + fieldMappings = undefined; + } + } + screenSubTables[screenId].subTables.push({ tableName: subTable, componentType: componentType, relationType: relationType, + fieldMappings: fieldMappings, }); } }); - logger.info("화면 서브 테이블 정보 조회", { screenIds, resultCount: Object.keys(screenSubTables).length }); + // 2. 추가 방식: 화면에서 사용하는 컬럼 중 column_labels.reference_table이 설정된 경우 + // 화면의 usedColumns/joinColumns에서 reference_table 조회 + const referenceQuery = ` + WITH screen_used_columns AS ( + -- 화면별 사용 컬럼 추출 (componentConfig.columns에서) + SELECT + sd.screen_id, + sd.screen_name, + sd.table_name as main_table, + jsonb_array_elements_text( + COALESCE( + sl.properties->'componentConfig'->'columns', + '[]'::jsonb + ) + )::jsonb->>'columnName' as column_name + FROM screen_definitions sd + JOIN screen_layouts sl ON sd.screen_id = sl.screen_id + WHERE sd.screen_id = ANY($1) + AND sl.properties->'componentConfig'->'columns' IS NOT NULL + AND jsonb_array_length(sl.properties->'componentConfig'->'columns') > 0 + + UNION + + -- bindField도 포함 + SELECT + sd.screen_id, + sd.screen_name, + sd.table_name as main_table, + COALESCE( + sl.properties->'componentConfig'->>'bindField', + sl.properties->>'bindField', + sl.properties->'componentConfig'->>'field', + sl.properties->>'field' + ) as column_name + FROM screen_definitions sd + JOIN screen_layouts sl ON sd.screen_id = sl.screen_id + WHERE sd.screen_id = ANY($1) + AND ( + sl.properties->'componentConfig'->>'bindField' IS NOT NULL + OR sl.properties->>'bindField' IS NOT NULL + OR sl.properties->'componentConfig'->>'field' IS NOT NULL + OR sl.properties->>'field' IS NOT NULL + ) + ) + SELECT DISTINCT + suc.screen_id, + suc.screen_name, + suc.main_table, + suc.column_name, + cl.column_label as source_display_name, + cl.reference_table, + cl.reference_column, + ref_cl.column_label as target_display_name + FROM screen_used_columns suc + JOIN column_labels cl ON cl.table_name = suc.main_table AND cl.column_name = suc.column_name + LEFT JOIN column_labels ref_cl ON ref_cl.table_name = cl.reference_table AND ref_cl.column_name = cl.reference_column + WHERE cl.reference_table IS NOT NULL + AND cl.reference_table != '' + AND cl.reference_table != suc.main_table + ORDER BY suc.screen_id + `; + + const referenceResult = await pool.query(referenceQuery, [screenIds]); + + logger.info("column_labels reference_table 조회 결과", { + screenIds, + referenceCount: referenceResult.rows.length, + references: referenceResult.rows.map((r: any) => ({ + screenId: r.screen_id, + column: r.column_name, + refTable: r.reference_table + })) + }); + + referenceResult.rows.forEach((row: any) => { + const screenId = row.screen_id; + const mainTable = row.main_table; + const referenceTable = row.reference_table; + + if (!referenceTable || referenceTable === mainTable) { + return; + } + + if (!screenSubTables[screenId]) { + screenSubTables[screenId] = { + screenId, + screenName: row.screen_name, + mainTable: mainTable || '', + subTables: [], + }; + } + + // 중복 체크 + const exists = screenSubTables[screenId].subTables.some( + (st) => st.tableName === referenceTable + ); + + if (!exists) { + screenSubTables[screenId].subTables.push({ + tableName: referenceTable, + componentType: 'column_reference', + relationType: 'reference', + fieldMappings: [{ + sourceField: row.column_name, + targetField: row.reference_column || 'id', + sourceDisplayName: row.source_display_name || row.column_name, + targetDisplayName: row.target_display_name || row.reference_column || 'id', + }], + }); + } else { + // 이미 존재하면 fieldMappings에 추가 + const existingSubTable = screenSubTables[screenId].subTables.find( + (st) => st.tableName === referenceTable + ); + if (existingSubTable && existingSubTable.fieldMappings) { + const mappingExists = existingSubTable.fieldMappings.some( + (fm) => fm.sourceField === row.column_name + ); + if (!mappingExists) { + existingSubTable.fieldMappings.push({ + sourceField: row.column_name, + targetField: row.reference_column || 'id', + sourceDisplayName: row.source_display_name || row.column_name, + targetDisplayName: row.target_display_name || row.reference_column || 'id', + }); + } + } + } + }); + + // 3. parentDataMapping 파싱 (selected-items-detail-input 등에서 사용) + const parentMappingQuery = ` + SELECT + sd.screen_id, + sd.screen_name, + sd.table_name as main_table, + sl.properties->>'componentType' as component_type, + sl.properties->'componentConfig'->'parentDataMapping' as parent_data_mapping + FROM screen_definitions sd + JOIN screen_layouts sl ON sd.screen_id = sl.screen_id + WHERE sd.screen_id = ANY($1) + AND sl.properties->'componentConfig'->'parentDataMapping' IS NOT NULL + `; + + const parentMappingResult = await pool.query(parentMappingQuery, [screenIds]); + + parentMappingResult.rows.forEach((row: any) => { + const screenId = row.screen_id; + const mainTable = row.main_table; + const componentType = row.component_type || 'parentDataMapping'; + const parentDataMapping = row.parent_data_mapping; + + if (!Array.isArray(parentDataMapping)) return; + + if (!screenSubTables[screenId]) { + screenSubTables[screenId] = { + screenId, + screenName: row.screen_name, + mainTable: mainTable || '', + subTables: [], + }; + } + + parentDataMapping.forEach((mapping: any) => { + const sourceTable = mapping.sourceTable; + if (!sourceTable || sourceTable === mainTable) return; + + // 중복 체크 + const existingSubTable = screenSubTables[screenId].subTables.find( + (st) => st.tableName === sourceTable + ); + + const newMapping = { + sourceTable: sourceTable, // 연관 테이블 정보 추가 + sourceField: mapping.sourceField || '', + targetField: mapping.targetField || '', + sourceDisplayName: mapping.sourceField || '', + targetDisplayName: mapping.targetField || '', + }; + + if (existingSubTable) { + // 이미 존재하면 fieldMappings에 추가 + if (!existingSubTable.fieldMappings) { + existingSubTable.fieldMappings = []; + } + const mappingExists = existingSubTable.fieldMappings.some( + (fm: any) => fm.sourceField === newMapping.sourceField && fm.targetField === newMapping.targetField + ); + if (!mappingExists) { + existingSubTable.fieldMappings.push(newMapping); + } + } else { + screenSubTables[screenId].subTables.push({ + tableName: sourceTable, + componentType: componentType, + relationType: 'parentMapping', + fieldMappings: [newMapping], + }); + } + }); + }); + + logger.info("parentDataMapping 파싱 완료", { + screenIds, + parentMappingCount: parentMappingResult.rows.length + }); + + // 4. rightPanel.relation 파싱 (split-panel-layout 등에서 사용) + const rightPanelQuery = ` + SELECT + sd.screen_id, + sd.screen_name, + sd.table_name as main_table, + sl.properties->>'componentType' as component_type, + sl.properties->'componentConfig'->'rightPanel'->'relation' as right_panel_relation, + sl.properties->'componentConfig'->'rightPanel'->'tableName' as right_panel_table + FROM screen_definitions sd + JOIN screen_layouts sl ON sd.screen_id = sl.screen_id + WHERE sd.screen_id = ANY($1) + AND sl.properties->'componentConfig'->'rightPanel'->'relation' IS NOT NULL + `; + + const rightPanelResult = await pool.query(rightPanelQuery, [screenIds]); + + rightPanelResult.rows.forEach((row: any) => { + const screenId = row.screen_id; + const mainTable = row.main_table; + const componentType = row.component_type || 'split-panel-layout'; + const relation = row.right_panel_relation; + const rightPanelTable = row.right_panel_table; + + // relation 객체에서 테이블 및 필드 매핑 추출 + const subTable = rightPanelTable || relation?.targetTable || relation?.tableName; + if (!subTable || subTable === mainTable) return; + + if (!screenSubTables[screenId]) { + screenSubTables[screenId] = { + screenId, + screenName: row.screen_name, + mainTable: mainTable || '', + subTables: [], + }; + } + + // 중복 체크 + const existingSubTable = screenSubTables[screenId].subTables.find( + (st) => st.tableName === subTable + ); + + // relation에서 필드 매핑 추출 + const fieldMappings: Array<{ sourceField: string; targetField: string; sourceDisplayName?: string; targetDisplayName?: string }> = []; + + if (relation?.sourceField && relation?.targetField) { + fieldMappings.push({ + sourceField: relation.sourceField, + targetField: relation.targetField, + sourceDisplayName: relation.sourceField, + targetDisplayName: relation.targetField, + }); + } + + // fieldMappings 배열이 있는 경우 + if (relation?.fieldMappings && Array.isArray(relation.fieldMappings)) { + relation.fieldMappings.forEach((fm: any) => { + fieldMappings.push({ + sourceField: fm.sourceField || fm.source_field || '', + targetField: fm.targetField || fm.target_field || '', + sourceDisplayName: fm.sourceField || fm.source_field || '', + targetDisplayName: fm.targetField || fm.target_field || '', + }); + }); + } + + if (existingSubTable) { + // 이미 존재하면 fieldMappings에 추가 + if (!existingSubTable.fieldMappings) { + existingSubTable.fieldMappings = []; + } + fieldMappings.forEach((newMapping) => { + const mappingExists = existingSubTable.fieldMappings!.some( + (fm) => fm.sourceField === newMapping.sourceField && fm.targetField === newMapping.targetField + ); + if (!mappingExists) { + existingSubTable.fieldMappings!.push(newMapping); + } + }); + } else { + screenSubTables[screenId].subTables.push({ + tableName: subTable, + componentType: componentType, + relationType: 'rightPanelRelation', + fieldMappings: fieldMappings.length > 0 ? fieldMappings : undefined, + }); + } + }); + + logger.info("rightPanel.relation 파싱 완료", { + screenIds, + rightPanelCount: rightPanelResult.rows.length + }); + + // 5. 모든 fieldMappings의 한글명을 column_labels에서 가져와서 적용 + // 모든 테이블/컬럼 조합을 수집 + const columnLookups: Array<{ tableName: string; columnName: string }> = []; + Object.values(screenSubTables).forEach((screenData: any) => { + screenData.subTables.forEach((subTable: any) => { + if (subTable.fieldMappings) { + subTable.fieldMappings.forEach((mapping: any) => { + // sourceTable + sourceField (연관 테이블의 컬럼) + if (mapping.sourceTable && mapping.sourceField) { + columnLookups.push({ tableName: mapping.sourceTable, columnName: mapping.sourceField }); + } + // mainTable + targetField (메인 테이블의 컬럼) + if (screenData.mainTable && mapping.targetField) { + columnLookups.push({ tableName: screenData.mainTable, columnName: mapping.targetField }); + } + }); + } + }); + }); + + // 중복 제거 + const uniqueColumnLookups = columnLookups.filter((item, index, self) => + index === self.findIndex((t) => t.tableName === item.tableName && t.columnName === item.columnName) + ); + + // column_labels에서 한글명 조회 + const columnLabelsMap: { [key: string]: string } = {}; + if (uniqueColumnLookups.length > 0) { + const columnLabelsQuery = ` + SELECT + table_name, + column_name, + column_label + FROM column_labels + WHERE (table_name, column_name) IN ( + ${uniqueColumnLookups.map((_, i) => `($${i * 2 + 1}, $${i * 2 + 2})`).join(', ')} + ) + `; + const columnLabelsParams = uniqueColumnLookups.flatMap(item => [item.tableName, item.columnName]); + + try { + const columnLabelsResult = await pool.query(columnLabelsQuery, columnLabelsParams); + columnLabelsResult.rows.forEach((row: any) => { + const key = `${row.table_name}.${row.column_name}`; + columnLabelsMap[key] = row.column_label; + }); + logger.info("column_labels 한글명 조회 완료", { count: columnLabelsResult.rows.length }); + } catch (error: any) { + logger.warn("column_labels 한글명 조회 실패 (무시하고 계속 진행):", error.message); + } + } + + // 각 fieldMappings에 한글명 적용 + Object.values(screenSubTables).forEach((screenData: any) => { + screenData.subTables.forEach((subTable: any) => { + if (subTable.fieldMappings) { + subTable.fieldMappings.forEach((mapping: any) => { + // sourceDisplayName: 연관 테이블의 컬럼 한글명 + if (mapping.sourceTable && mapping.sourceField) { + const sourceKey = `${mapping.sourceTable}.${mapping.sourceField}`; + if (columnLabelsMap[sourceKey]) { + mapping.sourceDisplayName = columnLabelsMap[sourceKey]; + } + } + // targetDisplayName: 메인 테이블의 컬럼 한글명 + if (screenData.mainTable && mapping.targetField) { + const targetKey = `${screenData.mainTable}.${mapping.targetField}`; + if (columnLabelsMap[targetKey]) { + mapping.targetDisplayName = columnLabelsMap[targetKey]; + } + } + }); + } + }); + }); + + logger.info("화면 서브 테이블 정보 조회 완료", { + screenIds, + resultCount: Object.keys(screenSubTables).length, + details: Object.values(screenSubTables).map(s => ({ + screenId: s.screenId, + mainTable: s.mainTable, + subTables: s.subTables.map(st => st.tableName) + })) + }); res.json({ success: true,