diff --git a/backend-node/src/controllers/buttonDataflowController.ts b/backend-node/src/controllers/buttonDataflowController.ts index 69d623a6..e55fc08d 100644 --- a/backend-node/src/controllers/buttonDataflowController.ts +++ b/backend-node/src/controllers/buttonDataflowController.ts @@ -759,3 +759,45 @@ export async function getAllRelationships( }); } } + +/** + * 두 테이블 간의 조인 관계 조회 (마스터-디테일 저장용) + */ +export async function getJoinRelationship( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { mainTable, detailTable } = req.params; + const companyCode = req.user?.companyCode || "*"; + + if (!mainTable || !detailTable) { + res.status(400).json({ + success: false, + message: "메인 테이블과 디테일 테이블이 필요합니다.", + }); + return; + } + + // DataflowService에서 조인 관계 조회 + const { DataflowService } = await import("../services/dataflowService"); + const dataflowService = new DataflowService(); + + const result = await dataflowService.getJoinRelationshipBetweenTables( + mainTable, + detailTable, + companyCode + ); + + res.json({ + success: true, + data: result, + }); + } catch (error) { + logger.error("조인 관계 조회 실패:", error); + res.status(500).json({ + success: false, + message: error instanceof Error ? error.message : "조인 관계 조회 실패", + }); + } +} diff --git a/backend-node/src/routes/buttonDataflowRoutes.ts b/backend-node/src/routes/buttonDataflowRoutes.ts index 0d98189c..f86df8a0 100644 --- a/backend-node/src/routes/buttonDataflowRoutes.ts +++ b/backend-node/src/routes/buttonDataflowRoutes.ts @@ -14,6 +14,7 @@ import { executeOptimizedButton, executeSimpleDataflow, getJobStatus, + getJoinRelationship, } from "../controllers/buttonDataflowController"; import { authenticateToken } from "../middleware/authMiddleware"; @@ -61,6 +62,13 @@ router.post("/execute-simple", executeSimpleDataflow); // 백그라운드 작업 상태 조회 router.get("/job-status/:jobId", getJobStatus); +// ============================================================================ +// 🔥 테이블 관계 조회 (마스터-디테일 저장용) +// ============================================================================ + +// 두 테이블 간의 조인 관계 조회 +router.get("/join-relationship/:mainTable/:detailTable", getJoinRelationship); + // ============================================================================ // 🔥 레거시 호환성 (기존 API와 호환) // ============================================================================ diff --git a/backend-node/src/services/dataflowService.ts b/backend-node/src/services/dataflowService.ts index b1bd5965..05ee0f45 100644 --- a/backend-node/src/services/dataflowService.ts +++ b/backend-node/src/services/dataflowService.ts @@ -337,6 +337,110 @@ export class DataflowService { } } + /** + * 두 테이블 간의 조인 관계 조회 (마스터-디테일 저장용) + * @param mainTable 메인 테이블명 (마스터) + * @param detailTable 디테일 테이블명 (리피터) + * @param companyCode 회사코드 + * @returns 조인 컬럼 매핑 정보 + */ + async getJoinRelationshipBetweenTables( + mainTable: string, + detailTable: string, + companyCode: string + ): Promise<{ + found: boolean; + mainColumn?: string; + detailColumn?: string; + relationshipType?: string; + }> { + try { + logger.info( + `DataflowService: 테이블 간 조인 관계 조회 - 메인: ${mainTable}, 디테일: ${detailTable}` + ); + + // 양방향 조회 (from → to 또는 to → from) + let queryText = ` + SELECT + from_table_name, + from_column_name, + to_table_name, + to_column_name, + relationship_type, + settings + FROM table_relationships + WHERE is_active = 'Y' + AND ( + (from_table_name = $1 AND to_table_name = $2) + OR (from_table_name = $2 AND to_table_name = $1) + ) + `; + const params: any[] = [mainTable, detailTable]; + + // 관리자가 아닌 경우 회사코드 제한 + if (companyCode !== "*") { + queryText += ` AND (company_code = $3 OR company_code = '*')`; + params.push(companyCode); + } + + queryText += ` LIMIT 1`; + + const result = await queryOne<{ + from_table_name: string; + from_column_name: string; + to_table_name: string; + to_column_name: string; + relationship_type: string; + settings: any; + }>(queryText, params); + + if (!result) { + logger.info( + `DataflowService: 테이블 간 조인 관계 없음 - ${mainTable} ↔ ${detailTable}` + ); + return { found: false }; + } + + // 방향에 따라 컬럼 매핑 결정 + // mainTable이 from_table이면 그대로, 아니면 반대로 + let mainColumn: string; + let detailColumn: string; + + if (result.from_table_name === mainTable) { + // from → to 방향: mainTable.from_column → detailTable.to_column + mainColumn = result.from_column_name; + detailColumn = result.to_column_name; + } else { + // to → from 방향: mainTable.to_column → detailTable.from_column + mainColumn = result.to_column_name; + detailColumn = result.from_column_name; + } + + // 쉼표로 구분된 다중 컬럼인 경우 첫 번째 컬럼만 사용 + // (추후 다중 컬럼 지원 필요시 확장) + if (mainColumn.includes(",")) { + mainColumn = mainColumn.split(",")[0].trim(); + } + if (detailColumn.includes(",")) { + detailColumn = detailColumn.split(",")[0].trim(); + } + + logger.info( + `DataflowService: 조인 관계 발견 - ${mainTable}.${mainColumn} → ${detailTable}.${detailColumn}` + ); + + return { + found: true, + mainColumn, + detailColumn, + relationshipType: result.relationship_type, + }; + } catch (error) { + logger.error("DataflowService: 테이블 간 조인 관계 조회 실패", error); + return { found: false }; + } + } + /** * 연결 타입별 관계 조회 */ diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 8420e9c3..cfc0fc31 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -1237,6 +1237,9 @@ export class ButtonActionExecutor { console.log("🔎 [handleSave] formData 키 목록:", Object.keys(context.formData)); console.log("🔎 [handleSave] formData 전체:", context.formData); + // 🆕 마스터-디테일 저장: 테이블 간 조인 관계 캐시 + const joinRelationshipCache: Record = {}; + for (const [fieldKey, fieldValue] of Object.entries(context.formData)) { console.log(`🔎 [handleSave] 필드 검사: ${fieldKey}`, { type: typeof fieldValue, @@ -1266,6 +1269,34 @@ export class ButtonActionExecutor { itemCount: parsedData.length, }); + // 🆕 마스터-디테일 조인 관계 조회 (메인 테이블 → 리피터 테이블) + let joinRelationship: { mainColumn: string; detailColumn: string } | null = null; + const cacheKey = `${tableName}:${repeaterTargetTable}`; + + if (tableName && repeaterTargetTable && tableName !== repeaterTargetTable) { + // 캐시에서 먼저 확인 + if (cacheKey in joinRelationshipCache) { + joinRelationship = joinRelationshipCache[cacheKey]; + } else { + try { + const joinResponse = await apiClient.get( + `/button-dataflow/join-relationship/${tableName}/${repeaterTargetTable}` + ); + if (joinResponse.data?.success && joinResponse.data?.data?.found) { + joinRelationship = { + mainColumn: joinResponse.data.data.mainColumn, + detailColumn: joinResponse.data.data.detailColumn, + }; + console.log(`🔗 [handleSave] 조인 관계 발견: ${tableName}.${joinRelationship.mainColumn} → ${repeaterTargetTable}.${joinRelationship.detailColumn}`); + } + } catch (joinError) { + console.warn(`⚠️ [handleSave] 조인 관계 조회 실패:`, joinError); + } + // 결과를 캐시에 저장 (없어도 null로 저장하여 재조회 방지) + joinRelationshipCache[cacheKey] = joinRelationship; + } + } + // 🆕 범용 폼 모달의 공통 필드 추출 (order_no, manager_id 등) // "범용_폼_모달" 키에서 공통 필드를 가져옴 const universalFormData = context.formData["범용_폼_모달"] as Record | undefined; @@ -1303,6 +1334,18 @@ export class ButtonActionExecutor { } console.log("📋 [handleSave] 최종 공통 필드 (규칙 기반 자동 추출):", commonFields); + // 🆕 마스터-디테일 조인: 메인 테이블의 조인 컬럼 값을 commonFields에 추가 + if (joinRelationship) { + const mainColumnValue = context.formData[joinRelationship.mainColumn]; + if (mainColumnValue !== undefined && mainColumnValue !== null && mainColumnValue !== "") { + // 리피터 테이블의 조인 컬럼에 메인 테이블의 값 주입 + commonFields[joinRelationship.detailColumn] = mainColumnValue; + console.log(`🔗 [handleSave] 조인 컬럼 값 주입: ${joinRelationship.detailColumn} = ${mainColumnValue}`); + } else { + console.warn(`⚠️ [handleSave] 조인 컬럼 값이 없음: ${joinRelationship.mainColumn}`); + } + } + for (const item of parsedData) { // 메타 필드 제거 (eslint 경고 무시 - 의도적으로 분리) @@ -1455,15 +1498,51 @@ export class ButtonActionExecutor { if (!Array.isArray(rows) || rows.length === 0) continue; + // 🆕 마스터-디테일 조인 관계 조회 (메인 테이블 → RepeatScreenModal 테이블) + let joinRelationship: { mainColumn: string; detailColumn: string } | null = null; + if (tableName && targetTable && tableName !== targetTable) { + const cacheKey = `${tableName}:${targetTable}`; + if (cacheKey in joinRelationshipCache) { + joinRelationship = joinRelationshipCache[cacheKey]; + } else { + try { + const joinResponse = await apiClient.get( + `/button-dataflow/join-relationship/${tableName}/${targetTable}` + ); + if (joinResponse.data?.success && joinResponse.data?.data?.found) { + joinRelationship = { + mainColumn: joinResponse.data.data.mainColumn, + detailColumn: joinResponse.data.data.detailColumn, + }; + console.log(`🔗 [handleSave] RepeatScreenModal 조인 관계 발견: ${tableName}.${joinRelationship.mainColumn} → ${targetTable}.${joinRelationship.detailColumn}`); + } + } catch (joinError) { + console.warn(`⚠️ [handleSave] RepeatScreenModal 조인 관계 조회 실패:`, joinError); + } + joinRelationshipCache[cacheKey] = joinRelationship; + } + } + + // 🆕 조인 컬럼 값 준비 (메인 테이블에서 가져옴) + let joinColumnData: Record = {}; + if (joinRelationship) { + const mainColumnValue = context.formData[joinRelationship.mainColumn]; + if (mainColumnValue !== undefined && mainColumnValue !== null && mainColumnValue !== "") { + joinColumnData[joinRelationship.detailColumn] = mainColumnValue; + console.log(`🔗 [handleSave] RepeatScreenModal 조인 컬럼 값: ${joinRelationship.detailColumn} = ${mainColumnValue}`); + } + } + console.log(`📦 [handleSave] ${targetTable} 테이블 저장:`, rows); for (const row of rows) { const { _isNew, _targetTable, id, ...dataToSave } = row; - // 사용자 정보 추가 + 채번 규칙 값 병합 + // 사용자 정보 추가 + 채번 규칙 값 병합 + 조인 컬럼 값 추가 const dataWithMeta = { ...dataToSave, ...numberingFields, // 채번 규칙 값 (shipment_plan_no 등) + ...joinColumnData, // 🆕 조인 컬럼 값 (마스터-디테일 관계) created_by: context.userId, updated_by: context.userId, company_code: context.companyCode, @@ -1497,6 +1576,96 @@ export class ButtonActionExecutor { } } + // 🆕 v2-repeat-container 데이터 저장 처리 (_repeatContainerTables에 그룹화된 데이터) + const repeatContainerTables = context.formData._repeatContainerTables as Record | undefined; + if (repeatContainerTables && Object.keys(repeatContainerTables).length > 0) { + console.log("📦 [handleSave] v2-RepeatContainer 데이터 저장 시작:", Object.keys(repeatContainerTables)); + + for (const [targetTable, rows] of Object.entries(repeatContainerTables)) { + if (!Array.isArray(rows) || rows.length === 0) continue; + + // 🆕 마스터-디테일 조인 관계 조회 + let joinRelationship: { mainColumn: string; detailColumn: string } | null = null; + if (tableName && targetTable && tableName !== targetTable) { + const cacheKey = `${tableName}:${targetTable}`; + if (cacheKey in joinRelationshipCache) { + joinRelationship = joinRelationshipCache[cacheKey]; + } else { + try { + const joinResponse = await apiClient.get( + `/button-dataflow/join-relationship/${tableName}/${targetTable}` + ); + if (joinResponse.data?.success && joinResponse.data?.data?.found) { + joinRelationship = { + mainColumn: joinResponse.data.data.mainColumn, + detailColumn: joinResponse.data.data.detailColumn, + }; + console.log(`🔗 [handleSave] RepeatContainer 조인 관계 발견: ${tableName}.${joinRelationship.mainColumn} → ${targetTable}.${joinRelationship.detailColumn}`); + } + } catch (joinError) { + console.warn(`⚠️ [handleSave] RepeatContainer 조인 관계 조회 실패:`, joinError); + } + joinRelationshipCache[cacheKey] = joinRelationship; + } + } + + // 조인 컬럼 값 준비 + let joinColumnData: Record = {}; + if (joinRelationship) { + const mainColumnValue = context.formData[joinRelationship.mainColumn]; + if (mainColumnValue !== undefined && mainColumnValue !== null && mainColumnValue !== "") { + joinColumnData[joinRelationship.detailColumn] = mainColumnValue; + console.log(`🔗 [handleSave] RepeatContainer 조인 컬럼 값: ${joinRelationship.detailColumn} = ${mainColumnValue}`); + } + } + + console.log(`📦 [handleSave] ${targetTable} 테이블 저장 (RepeatContainer): ${rows.length}건`); + + for (const row of rows) { + const { _isDirty, _sectionIndex, _targetTable, id, ...dataToSave } = row; + + // 변경되지 않은 행은 건너뛰기 + if (_isDirty === false) { + console.log(`⏭️ [handleSave] ${targetTable} 변경 없음 건너뜀 (index: ${_sectionIndex})`); + continue; + } + + // 사용자 정보 추가 + 조인 컬럼 값 추가 + const dataWithMeta = { + ...dataToSave, + ...joinColumnData, // 조인 컬럼 값 + created_by: context.userId, + updated_by: context.userId, + company_code: context.companyCode, + }; + + try { + if (!id) { + // INSERT (id가 없으면 새 레코드) + console.log(`📝 [handleSave] ${targetTable} INSERT (RepeatContainer):`, dataWithMeta); + const insertResult = await apiClient.post( + `/table-management/tables/${targetTable}/add`, + dataWithMeta, + ); + console.log(`✅ [handleSave] ${targetTable} INSERT 완료:`, insertResult.data); + } else { + // UPDATE (id가 있으면 기존 레코드) + const originalData = { id }; + const updatedData = { ...dataWithMeta, id }; + console.log(`📝 [handleSave] ${targetTable} UPDATE (RepeatContainer):`, { originalData, updatedData }); + const updateResult = await apiClient.put(`/table-management/tables/${targetTable}/edit`, { + originalData, + updatedData, + }); + console.log(`✅ [handleSave] ${targetTable} UPDATE 완료:`, updateResult.data); + } + } catch (error: any) { + console.error(`❌ [handleSave] ${targetTable} 저장 실패 (RepeatContainer):`, error.response?.data || error.message); + } + } + } + } + // 🆕 v3.9: RepeatScreenModal 집계 저장 처리 const aggregationConfigs = context.formData._repeatScreenModal_aggregations as Array<{ resultField: string;