fix: Repeater 동일 테이블 저장 시 이중 INSERT 문제 해결

문제:
- targetTable이 메인 테이블과 동일할 때 헤더 단독 저장 + Repeater 병합 저장으로 2번 INSERT 발생
- 같은 수주번호로 헤더만 있는 레코드와 전체 데이터 레코드가 중복 생성됨

해결:
- Repeater를 병합/분리 모드로 분류하는 로직 추가
- 병합 모드: 헤더+품목을 통합하여 품목당 1개 레코드로 저장
- 분리 모드: 헤더와 품목을 별도 테이블에 저장
- 헤더 단독 INSERT 제거로 중복 방지

영향:
- 단일 테이블 구조에서 품목별 레코드 생성 방식으로 변경
- 확장/축소 UI를 통한 품목별 조회 지원
This commit is contained in:
SeongHyun Kim
2025-11-21 10:52:51 +09:00
parent fa426625cc
commit 1a171d450c
2 changed files with 131 additions and 64 deletions

View File

@@ -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) => {

View File

@@ -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("🔍 채번 규칙 할당 체크 시작");