From 1a171d450cf71a46c9a74ab92bfd1eea1dd05067 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 21 Nov 2025 10:52:51 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20Repeater=20=EB=8F=99=EC=9D=BC=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=A0=80=EC=9E=A5=20=EC=8B=9C=20?= =?UTF-8?q?=EC=9D=B4=EC=A4=91=20INSERT=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제: - targetTable이 메인 테이블과 동일할 때 헤더 단독 저장 + Repeater 병합 저장으로 2번 INSERT 발생 - 같은 수주번호로 헤더만 있는 레코드와 전체 데이터 레코드가 중복 생성됨 해결: - Repeater를 병합/분리 모드로 분류하는 로직 추가 - 병합 모드: 헤더+품목을 통합하여 품목당 1개 레코드로 저장 - 분리 모드: 헤더와 품목을 별도 테이블에 저장 - 헤더 단독 INSERT 제거로 중복 방지 영향: - 단일 테이블 구조에서 품목별 레코드 생성 방식으로 변경 - 확장/축소 UI를 통한 품목별 조회 지원 --- .../src/services/dynamicFormService.ts | 177 ++++++++++++------ frontend/lib/utils/buttonActions.ts | 18 +- 2 files changed, 131 insertions(+), 64 deletions(-) diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index cc2fad77..965d2833 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -338,9 +338,9 @@ export class DynamicFormService { ) { try { parsedArray = JSON.parse(value); - console.log( + console.log( `🔄 JSON 문자열 Repeater 데이터 감지: ${key}, ${parsedArray?.length || 0}개 항목` - ); + ); } catch (parseError) { console.log(`⚠️ JSON 파싱 실패: ${key}`); } @@ -348,25 +348,25 @@ export class DynamicFormService { // 파싱된 배열이 있으면 처리 if (parsedArray && Array.isArray(parsedArray) && parsedArray.length > 0) { - // 컴포넌트 설정에서 targetTable 추출 (컴포넌트 ID를 통해) - // 프론트엔드에서 { data: [...], targetTable: "..." } 형식으로 전달될 수 있음 - let targetTable: string | undefined; - let actualData = parsedArray; + // 컴포넌트 설정에서 targetTable 추출 (컴포넌트 ID를 통해) + // 프론트엔드에서 { data: [...], targetTable: "..." } 형식으로 전달될 수 있음 + let targetTable: string | undefined; + let actualData = parsedArray; - // 첫 번째 항목에 _targetTable이 있는지 확인 (프론트엔드에서 메타데이터 전달) - if (parsedArray[0] && parsedArray[0]._targetTable) { - targetTable = parsedArray[0]._targetTable; - actualData = parsedArray.map( - ({ _targetTable, ...item }) => item - ); - } + // 첫 번째 항목에 _targetTable이 있는지 확인 (프론트엔드에서 메타데이터 전달) + if (parsedArray[0] && parsedArray[0]._targetTable) { + targetTable = parsedArray[0]._targetTable; + actualData = parsedArray.map( + ({ _targetTable, ...item }) => item + ); + } - repeaterData.push({ - data: actualData, - targetTable, - componentId: key, - }); - delete dataToInsert[key]; // 원본 배열 데이터는 제거 + repeaterData.push({ + data: actualData, + targetTable, + componentId: key, + }); + delete dataToInsert[key]; // 원본 배열 데이터는 제거 console.log(`✅ Repeater 데이터 추가: ${key}`, { targetTable: targetTable || "없음 (화면 설계에서 설정 필요)", @@ -376,6 +376,25 @@ export class DynamicFormService { } }); + // 🔥 Repeater targetTable이 메인 테이블과 같으면 분리해서 저장 + const separateRepeaterData: typeof repeaterData = []; + const mergedRepeaterData: typeof repeaterData = []; + + repeaterData.forEach(repeater => { + if (repeater.targetTable && repeater.targetTable !== tableName) { + // 다른 테이블: 나중에 별도 저장 + separateRepeaterData.push(repeater); + } else { + // 같은 테이블: 메인 INSERT와 병합 (헤더+품목을 한 번에) + mergedRepeaterData.push(repeater); + } + }); + + console.log(`🔄 Repeater 데이터 분류:`, { + separate: separateRepeaterData.length, // 별도 테이블 + merged: mergedRepeaterData.length, // 메인 테이블과 병합 + }); + // 존재하지 않는 컬럼 제거 Object.keys(dataToInsert).forEach((key) => { if (!tableColumns.includes(key)) { @@ -386,9 +405,6 @@ export class DynamicFormService { } }); - // RepeaterInput 데이터 처리 로직은 메인 저장 후에 처리 - // (각 Repeater가 다른 테이블에 저장될 수 있으므로) - console.log("🎯 실제 테이블에 삽입할 데이터:", { tableName, dataToInsert, @@ -469,28 +485,95 @@ export class DynamicFormService { const userId = data.updated_by || data.created_by || "system"; const clientIp = ipAddress || "unknown"; - const result = await transaction(async (client) => { - // 세션 변수 설정 - await client.query(`SET LOCAL app.user_id = '${userId}'`); - await client.query(`SET LOCAL app.ip_address = '${clientIp}'`); - - // UPSERT 실행 - const res = await client.query(upsertQuery, values); - return res.rows; - }); - - console.log("✅ 서비스: 실제 테이블 저장 성공:", result); + let result: any[]; + + // 🔥 메인 테이블과 병합할 Repeater가 있으면 각 품목별로 INSERT + if (mergedRepeaterData.length > 0) { + console.log(`🔄 메인 테이블 병합 모드: ${mergedRepeaterData.length}개 Repeater를 개별 레코드로 저장`); + + result = []; + + for (const repeater of mergedRepeaterData) { + for (const item of repeater.data) { + // 헤더 + 품목을 병합 + const mergedData = { ...dataToInsert, ...item }; + + // 타입 변환 + Object.keys(mergedData).forEach((columnName) => { + const column = columnInfo.find((col) => col.column_name === columnName); + if (column) { + mergedData[columnName] = this.convertValueForPostgreSQL( + mergedData[columnName], + column.data_type + ); + } + }); + + const mergedColumns = Object.keys(mergedData); + const mergedValues: any[] = Object.values(mergedData); + const mergedPlaceholders = mergedValues.map((_, index) => `$${index + 1}`).join(", "); + + let mergedUpsertQuery: string; + if (primaryKeys.length > 0) { + const conflictColumns = primaryKeys.join(", "); + const updateSet = mergedColumns + .filter((col) => !primaryKeys.includes(col)) + .map((col) => `${col} = EXCLUDED.${col}`) + .join(", "); + + mergedUpsertQuery = updateSet + ? `INSERT INTO ${tableName} (${mergedColumns.join(", ")}) + VALUES (${mergedPlaceholders}) + ON CONFLICT (${conflictColumns}) + DO UPDATE SET ${updateSet} + RETURNING *` + : `INSERT INTO ${tableName} (${mergedColumns.join(", ")}) + VALUES (${mergedPlaceholders}) + ON CONFLICT (${conflictColumns}) + DO NOTHING + RETURNING *`; + } else { + mergedUpsertQuery = `INSERT INTO ${tableName} (${mergedColumns.join(", ")}) + VALUES (${mergedPlaceholders}) + RETURNING *`; + } + + console.log(`📝 병합 INSERT:`, { mergedData }); + + const itemResult = await transaction(async (client) => { + await client.query(`SET LOCAL app.user_id = '${userId}'`); + await client.query(`SET LOCAL app.ip_address = '${clientIp}'`); + const res = await client.query(mergedUpsertQuery, mergedValues); + return res.rows[0]; + }); + + result.push(itemResult); + } + } + + console.log(`✅ 병합 저장 완료: ${result.length}개 레코드`); + } else { + // 일반 모드: 헤더만 저장 + result = await transaction(async (client) => { + await client.query(`SET LOCAL app.user_id = '${userId}'`); + await client.query(`SET LOCAL app.ip_address = '${clientIp}'`); + const res = await client.query(upsertQuery, values); + return res.rows; + }); + + console.log("✅ 서비스: 실제 테이블 저장 성공:", result); + } // 결과를 표준 형식으로 변환 const insertedRecord = Array.isArray(result) ? result[0] : result; - // 📝 RepeaterInput 데이터 저장 (각 Repeater를 해당 테이블에 저장) - if (repeaterData.length > 0) { + // 📝 별도 테이블 Repeater 데이터 저장 + if (separateRepeaterData.length > 0) { console.log( - `🔄 RepeaterInput 데이터 저장 시작: ${repeaterData.length}개 Repeater` + `🔄 별도 테이블 Repeater 저장 시작: ${separateRepeaterData.length}개` ); - for (const repeater of repeaterData) { + for (const repeater of separateRepeaterData) { const targetTableName = repeater.targetTable || tableName; console.log( `📝 Repeater "${repeater.componentId}" → 테이블 "${targetTableName}"에 ${repeater.data.length}개 항목 저장` @@ -518,24 +601,8 @@ export class DynamicFormService { company_code: data.company_code || company_code, }; - // 🔥 부모 테이블의 데이터를 자동 복사 (외래키 관계) - // targetTable이 메인 테이블과 같으면 부모 데이터 추가 - if (targetTableName === tableName) { - console.log( - `⚠️ [Repeater] targetTable이 메인 테이블과 같음 (${tableName}). 부모 데이터 추가 중...` - ); - // 메인 테이블의 모든 데이터를 Repeater 항목에 복사 - Object.keys(dataToInsert).forEach((key) => { - // 중복되지 않는 필드만 추가 - if (itemData[key] === undefined) { - itemData[key] = dataToInsert[key]; - } - }); - console.log( - `✅ [Repeater] 부모 데이터 병합 완료:`, - Object.keys(itemData) - ); - } + // 🔥 별도 테이블인 경우에만 외래키 추가 + // (같은 테이블이면 이미 병합 모드에서 처리됨) // 대상 테이블에 존재하는 컬럼만 필터링 Object.keys(itemData).forEach((key) => { diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 3edd3e8a..5f825cdc 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -342,15 +342,15 @@ export class ButtonActionExecutor { const writerValue = context.userId; const companyCodeValue = context.companyCode || ""; - console.log("👤 [buttonActions] 사용자 정보:", { - userId: context.userId, - userName: context.userName, - companyCode: context.companyCode, // ✅ 회사 코드 - formDataWriter: formData.writer, // ✅ 폼에서 입력한 writer 값 - formDataCompanyCode: formData.company_code, // ✅ 폼에서 입력한 company_code 값 - defaultWriterValue: writerValue, - companyCodeValue, // ✅ 최종 회사 코드 값 - }); + // console.log("👤 [buttonActions] 사용자 정보:", { + // userId: context.userId, + // userName: context.userName, + // companyCode: context.companyCode, + // formDataWriter: formData.writer, + // formDataCompanyCode: formData.company_code, + // defaultWriterValue: writerValue, + // companyCodeValue, + // }); // 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가) // console.log("🔍 채번 규칙 할당 체크 시작");