From 48aa004a7fce3efb002d9da5f760fc08786d652a Mon Sep 17 00:00:00 2001 From: hjjeong Date: Fri, 9 Jan 2026 14:11:51 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20SplitPanelLayout=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=ED=81=B4=EB=A6=AD=20=EC=8B=9C=20=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9=20=EB=A0=88=EC=BD=94=EB=93=9C=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 수정 버튼 클릭 시 groupByColumns 기준으로 모든 관련 레코드 조회 - search 대신 dataFilter(equals) 사용하여 정확 매칭 조회 - deduplication 명시적 비활성화로 모든 레코드 반환 - supplier_mng, customer_mng 등 회사별 데이터 테이블 DB 조인 강제 (캐시 미사용) - entityJoinController에 deduplication 파라미터 처리 추가 - ScreenModal에서 배열 형태 editData 처리 지원 --- .../src/controllers/entityJoinController.ts | 126 +++++++++++++++++- .../src/services/tableManagementService.ts | 16 +++ frontend/components/common/ScreenModal.tsx | 12 +- frontend/lib/api/entityJoin.ts | 7 + .../SplitPanelLayoutComponent.tsx | 104 +++++++++++++-- 5 files changed, 249 insertions(+), 16 deletions(-) diff --git a/backend-node/src/controllers/entityJoinController.ts b/backend-node/src/controllers/entityJoinController.ts index fbb88750..013b2034 100644 --- a/backend-node/src/controllers/entityJoinController.ts +++ b/backend-node/src/controllers/entityJoinController.ts @@ -30,6 +30,7 @@ export class EntityJoinController { autoFilter, // 🔒 멀티테넌시 자동 필터 dataFilter, // 🆕 데이터 필터 (JSON 문자열) excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외 + deduplication, // 🆕 중복 제거 설정 (JSON 문자열) userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함 ...otherParams } = req.query; @@ -139,6 +140,24 @@ export class EntityJoinController { } } + // 🆕 중복 제거 설정 처리 + let parsedDeduplication: { + enabled: boolean; + groupByColumn: string; + keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; + sortColumn?: string; + } | undefined = undefined; + if (deduplication) { + try { + parsedDeduplication = + typeof deduplication === "string" ? JSON.parse(deduplication) : deduplication; + logger.info("중복 제거 설정 파싱 완료:", parsedDeduplication); + } catch (error) { + logger.warn("중복 제거 설정 파싱 오류:", error); + parsedDeduplication = undefined; + } + } + const result = await tableManagementService.getTableDataWithEntityJoins( tableName, { @@ -156,13 +175,26 @@ export class EntityJoinController { screenEntityConfigs: parsedScreenEntityConfigs, dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달 excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달 + deduplication: parsedDeduplication, // 🆕 중복 제거 설정 전달 } ); + // 🆕 중복 제거 처리 (결과 데이터에 적용) + let finalData = result; + if (parsedDeduplication?.enabled && parsedDeduplication.groupByColumn && Array.isArray(result.data)) { + logger.info(`🔄 중복 제거 시작: 기준 컬럼 = ${parsedDeduplication.groupByColumn}, 전략 = ${parsedDeduplication.keepStrategy}`); + const originalCount = result.data.length; + finalData = { + ...result, + data: this.deduplicateData(result.data, parsedDeduplication), + }; + logger.info(`✅ 중복 제거 완료: ${originalCount}개 → ${finalData.data.length}개`); + } + res.status(200).json({ success: true, message: "Entity 조인 데이터 조회 성공", - data: result, + data: finalData, }); } catch (error) { logger.error("Entity 조인 데이터 조회 실패", error); @@ -537,6 +569,98 @@ export class EntityJoinController { }); } } + + /** + * 중복 데이터 제거 (메모리 내 처리) + */ + private deduplicateData( + data: any[], + config: { + groupByColumn: string; + keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; + sortColumn?: string; + } + ): any[] { + if (!data || data.length === 0) return data; + + // 그룹별로 데이터 분류 + const groups: Record = {}; + + for (const row of data) { + const groupKey = row[config.groupByColumn]; + if (groupKey === undefined || groupKey === null) continue; + + if (!groups[groupKey]) { + groups[groupKey] = []; + } + groups[groupKey].push(row); + } + + // 각 그룹에서 하나의 행만 선택 + const result: any[] = []; + + for (const [groupKey, rows] of Object.entries(groups)) { + if (rows.length === 0) continue; + + let selectedRow: any; + + switch (config.keepStrategy) { + case "latest": + // 정렬 컬럼 기준 최신 (가장 큰 값) + if (config.sortColumn) { + rows.sort((a, b) => { + const aVal = a[config.sortColumn!]; + const bVal = b[config.sortColumn!]; + if (aVal === bVal) return 0; + if (aVal > bVal) return -1; + return 1; + }); + } + selectedRow = rows[0]; + break; + + case "earliest": + // 정렬 컬럼 기준 최초 (가장 작은 값) + if (config.sortColumn) { + rows.sort((a, b) => { + const aVal = a[config.sortColumn!]; + const bVal = b[config.sortColumn!]; + if (aVal === bVal) return 0; + if (aVal < bVal) return -1; + return 1; + }); + } + selectedRow = rows[0]; + break; + + case "base_price": + // base_price가 true인 행 선택 + selectedRow = rows.find((r) => r.base_price === true || r.base_price === "true") || rows[0]; + break; + + case "current_date": + // 오늘 날짜 기준 유효 기간 내 행 선택 + const today = new Date().toISOString().split("T")[0]; + selectedRow = rows.find((r) => { + const startDate = r.start_date; + const endDate = r.end_date; + if (!startDate) return true; + if (startDate <= today && (!endDate || endDate >= today)) return true; + return false; + }) || rows[0]; + break; + + default: + selectedRow = rows[0]; + } + + if (selectedRow) { + result.push(selectedRow); + } + } + + return result; + } } export const entityJoinController = new EntityJoinController(); diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 7df10fdb..6ae8f696 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -3769,6 +3769,15 @@ export class TableManagementService { const cacheableJoins: EntityJoinConfig[] = []; const dbJoins: EntityJoinConfig[] = []; + // 🔒 멀티테넌시: 회사별 데이터 테이블은 캐시 사용 불가 (company_code 필터링 필요) + const companySpecificTables = [ + "supplier_mng", + "customer_mng", + "item_info", + "dept_info", + // 필요시 추가 + ]; + for (const config of joinConfigs) { // table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인 if (config.referenceTable === "table_column_category_values") { @@ -3777,6 +3786,13 @@ export class TableManagementService { continue; } + // 🔒 회사별 데이터 테이블은 캐시 사용 불가 (멀티테넌시) + if (companySpecificTables.includes(config.referenceTable)) { + dbJoins.push(config); + console.log(`🔗 DB 조인 (멀티테넌시): ${config.referenceTable}`); + continue; + } + // 캐시 가능성 확인 const cachedData = await referenceCacheService.getCachedReference( config.referenceTable, diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index f41c62af..f7926f43 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -174,8 +174,16 @@ export const ScreenModal: React.FC = ({ className }) => { // 🆕 editData가 있으면 formData와 originalData로 설정 (수정 모드) if (editData) { console.log("📝 [ScreenModal] 수정 데이터 설정:", editData); - setFormData(editData); - setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용) + + // 🆕 배열인 경우 (그룹 레코드) vs 단일 객체 처리 + if (Array.isArray(editData)) { + console.log(`📝 [ScreenModal] 그룹 레코드 ${editData.length}개 설정`); + setFormData(editData as any); // 배열 그대로 전달 (SelectedItemsDetailInput에서 처리) + setOriginalData(editData[0] || null); // 첫 번째 레코드를 원본으로 저장 + } else { + setFormData(editData); + setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용) + } } else { // 🆕 신규 등록 모드: 분할 패널 부모 데이터가 있으면 미리 설정 // 🔧 중요: 신규 등록 시에는 연결 필드(equipment_code 등)만 전달해야 함 diff --git a/frontend/lib/api/entityJoin.ts b/frontend/lib/api/entityJoin.ts index a3206df9..1e84588d 100644 --- a/frontend/lib/api/entityJoin.ts +++ b/frontend/lib/api/entityJoin.ts @@ -77,6 +77,12 @@ export const entityJoinApi = { filterColumn?: string; filterValue?: any; }; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외) + deduplication?: { + enabled: boolean; + groupByColumn: string; + keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; + sortColumn?: string; + }; // 🆕 중복 제거 설정 } = {}, ): Promise => { // 🔒 멀티테넌시: company_code 자동 필터링 활성화 @@ -99,6 +105,7 @@ export const entityJoinApi = { autoFilter: JSON.stringify(autoFilter), // 🔒 멀티테넌시 필터링 dataFilter: params.dataFilter ? JSON.stringify(params.dataFilter) : undefined, // 🆕 데이터 필터 excludeFilter: params.excludeFilter ? JSON.stringify(params.excludeFilter) : undefined, // 🆕 제외 필터 + deduplication: params.deduplication ? JSON.stringify(params.deduplication) : undefined, // 🆕 중복 제거 설정 }, }); return response.data.data; diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index c3991ae3..079579de 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -953,11 +953,12 @@ export const SplitPanelLayoutComponent: React.FC console.log("🔗 [분할패널] 복합키 조건:", searchConditions); - // 엔티티 조인 API로 데이터 조회 + // 엔티티 조인 API로 데이터 조회 (🆕 deduplication 전달) const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { search: searchConditions, enableEntityJoin: true, size: 1000, + deduplication: componentConfig.rightPanel?.deduplication, // 🆕 중복 제거 설정 전달 }); console.log("🔗 [분할패널] 복합키 조회 결과:", result); @@ -1442,7 +1443,7 @@ export const SplitPanelLayoutComponent: React.FC // 수정 버튼 핸들러 const handleEditClick = useCallback( - (panel: "left" | "right", item: any) => { + async (panel: "left" | "right", item: any) => { // 🆕 우측 패널 수정 버튼 설정 확인 if (panel === "right" && componentConfig.rightPanel?.editButton?.mode === "modal") { const modalScreenId = componentConfig.rightPanel?.editButton?.modalScreenId; @@ -1465,11 +1466,86 @@ export const SplitPanelLayoutComponent: React.FC // 🆕 groupByColumns 추출 const groupByColumns = componentConfig.rightPanel?.editButton?.groupByColumns || []; - console.log("🔧 [SplitPanel] 수정 버튼 클릭 - groupByColumns 확인:", { - groupByColumns, - editButtonConfig: componentConfig.rightPanel?.editButton, - hasGroupByColumns: groupByColumns.length > 0, - }); + console.log("========================================"); + console.log("🔧 [SplitPanel] 수정 버튼 클릭!"); + console.log("🔧 groupByColumns:", groupByColumns); + console.log("🔧 item:", item); + console.log("🔧 rightData:", rightData); + console.log("🔧 rightData length:", rightData?.length); + console.log("========================================"); + + // 🆕 groupByColumns 기준으로 모든 관련 레코드 조회 (API 직접 호출) + let allRelatedRecords = [item]; // 기본값: 현재 아이템만 + + if (groupByColumns.length > 0) { + // groupByColumns 값으로 검색 조건 생성 + const matchConditions: Record = {}; + groupByColumns.forEach((col: string) => { + if (item[col] !== undefined && item[col] !== null) { + matchConditions[col] = item[col]; + } + }); + + console.log("🔍 [SplitPanel] 그룹 레코드 조회 시작:", { + 테이블: rightTableName, + 조건: matchConditions, + }); + + if (Object.keys(matchConditions).length > 0) { + // 🆕 deduplication 없이 원본 데이터 다시 조회 (API 직접 호출) + try { + const { entityJoinApi } = await import("@/lib/api/entityJoin"); + + // 🔧 dataFilter로 정확 매칭 조건 생성 (search는 LIKE 검색이라 부정확) + const exactMatchFilters = Object.entries(matchConditions).map(([key, value]) => ({ + id: `exact-${key}`, + columnName: key, + operator: "equals", + value: value, + valueType: "text", + })); + + console.log("🔍 [SplitPanel] 정확 매칭 필터:", exactMatchFilters); + + const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { + // search 대신 dataFilter 사용 (정확 매칭) + dataFilter: { + enabled: true, + matchType: "all", + filters: exactMatchFilters, + }, + enableEntityJoin: true, + size: 1000, + // 🔧 명시적으로 deduplication 비활성화 (모든 레코드 가져오기) + deduplication: { enabled: false, groupByColumn: "", keepStrategy: "latest" }, + }); + + // 🔍 디버깅: API 응답 구조 확인 + console.log("🔍 [SplitPanel] API 응답 전체:", result); + console.log("🔍 [SplitPanel] result.data:", result.data); + console.log("🔍 [SplitPanel] result 타입:", typeof result); + + // result 자체가 배열일 수도 있음 (entityJoinApi 응답 구조에 따라) + const dataArray = Array.isArray(result) ? result : (result.data || []); + + if (dataArray.length > 0) { + allRelatedRecords = dataArray; + console.log("✅ [SplitPanel] 그룹 레코드 조회 완료:", { + 조건: matchConditions, + 결과수: allRelatedRecords.length, + 레코드들: allRelatedRecords.map((r: any) => ({ id: r.id, supplier_item_code: r.supplier_item_code })), + }); + } else { + console.warn("⚠️ [SplitPanel] 그룹 레코드 조회 결과 없음, 현재 아이템만 사용"); + } + } catch (error) { + console.error("❌ [SplitPanel] 그룹 레코드 조회 실패:", error); + allRelatedRecords = [item]; + } + } else { + console.warn("⚠️ [SplitPanel] groupByColumns 값이 없음, 현재 아이템만 사용"); + } + } // 🔧 수정: URL 파라미터 대신 editData로 직접 전달 // 이렇게 하면 테이블의 Primary Key가 무엇이든 상관없이 데이터가 정확히 전달됨 @@ -1477,19 +1553,21 @@ export const SplitPanelLayoutComponent: React.FC new CustomEvent("openScreenModal", { detail: { screenId: modalScreenId, - editData: item, // 전체 데이터를 직접 전달 - ...(groupByColumns.length > 0 && { - urlParams: { + editData: allRelatedRecords, // 🆕 모든 관련 레코드 전달 (배열) + urlParams: { + mode: "edit", // 🆕 수정 모드 표시 + ...(groupByColumns.length > 0 && { groupByColumns: JSON.stringify(groupByColumns), - }, - }), + }), + }, }, }), ); console.log("✅ [SplitPanel] openScreenModal 이벤트 발생 (editData 직접 전달):", { screenId: modalScreenId, - editData: item, + editData: allRelatedRecords, + recordCount: allRelatedRecords.length, groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음", });