feat: Enhance dynamic form service to handle VIEW tables

- Introduced a new method `resolveBaseTable` to determine the original table name for VIEWs, allowing for seamless data operations.
- Updated existing methods (`saveFormData`, `updateFormDataPartial`, `updateFormData`, and `deleteFormData`) to utilize `resolveBaseTable`, ensuring that operations are performed on the correct base table.
- Improved logging to provide clearer insights into the operations being performed, including handling of original table names when dealing with VIEWs.
This commit is contained in:
kjs
2026-02-27 13:00:22 +09:00
parent c1f7f27005
commit 8bfc2ba4f5
2 changed files with 95 additions and 21 deletions

View File

@@ -210,19 +210,62 @@ export class DynamicFormService {
}
}
/**
* VIEW인 경우 원본(base) 테이블명을 반환, 일반 테이블이면 그대로 반환
*/
async resolveBaseTable(tableName: string): Promise<string> {
try {
const result = await query<{ table_type: string }>(
`SELECT table_type FROM information_schema.tables
WHERE table_name = $1 AND table_schema = 'public'`,
[tableName]
);
if (result.length === 0 || result[0].table_type !== 'VIEW') {
return tableName;
}
// VIEW의 FROM 절에서 첫 번째 테이블을 추출
const viewDef = await query<{ view_definition: string }>(
`SELECT view_definition FROM information_schema.views
WHERE table_name = $1 AND table_schema = 'public'`,
[tableName]
);
if (viewDef.length > 0) {
const definition = viewDef[0].view_definition;
// PostgreSQL은 뷰 정의를 "FROM (테이블명 별칭 LEFT JOIN ...)" 형태로 저장
const fromMatch = definition.match(/FROM\s+\(?(?:public\.)?(\w+)\s/i);
if (fromMatch) {
const baseTable = fromMatch[1];
console.log(`🔄 VIEW ${tableName} → 원본 테이블 ${baseTable} 으로 전환`);
return baseTable;
}
}
return tableName;
} catch (error) {
console.error(`❌ VIEW 원본 테이블 조회 실패:`, error);
return tableName;
}
}
/**
* 폼 데이터 저장 (실제 테이블에 직접 저장)
*/
async saveFormData(
screenId: number,
tableName: string,
tableNameInput: string,
data: Record<string, any>,
ipAddress?: string
): Promise<FormDataResult> {
// VIEW인 경우 원본 테이블로 전환
const tableName = await this.resolveBaseTable(tableNameInput);
try {
console.log("💾 서비스: 실제 테이블에 폼 데이터 저장 시작:", {
screenId,
tableName,
originalTable: tableNameInput !== tableName ? tableNameInput : undefined,
data,
});
@@ -813,14 +856,17 @@ export class DynamicFormService {
*/
async updateFormDataPartial(
id: string | number, // 🔧 UUID 문자열도 지원
tableName: string,
tableNameInput: string,
originalData: Record<string, any>,
newData: Record<string, any>
): Promise<PartialUpdateResult> {
// VIEW인 경우 원본 테이블로 전환
const tableName = await this.resolveBaseTable(tableNameInput);
try {
console.log("🔄 서비스: 부분 업데이트 시작:", {
id,
tableName,
originalTable: tableNameInput !== tableName ? tableNameInput : undefined,
originalData,
newData,
});
@@ -1008,13 +1054,16 @@ export class DynamicFormService {
*/
async updateFormData(
id: string | number,
tableName: string,
tableNameInput: string,
data: Record<string, any>
): Promise<FormDataResult> {
// VIEW인 경우 원본 테이블로 전환
const tableName = await this.resolveBaseTable(tableNameInput);
try {
console.log("🔄 서비스: 실제 테이블에서 폼 데이터 업데이트 시작:", {
id,
tableName,
originalTable: tableNameInput !== tableName ? tableNameInput : undefined,
data,
});
@@ -1212,9 +1261,13 @@ export class DynamicFormService {
screenId?: number
): Promise<void> {
try {
// VIEW인 경우 원본 테이블로 전환 (VIEW에는 기본키가 없으므로)
const actualTable = await this.resolveBaseTable(tableName);
console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", {
id,
tableName,
tableName: actualTable,
originalTable: tableName !== actualTable ? tableName : undefined,
});
// 1. 먼저 테이블의 기본키 컬럼명과 데이터 타입을 동적으로 조회
@@ -1232,15 +1285,15 @@ export class DynamicFormService {
`;
console.log("🔍 기본키 조회 SQL:", primaryKeyQuery);
console.log("🔍 테이블명:", tableName);
console.log("🔍 테이블명:", actualTable);
const primaryKeyResult = await query<{
column_name: string;
data_type: string;
}>(primaryKeyQuery, [tableName]);
}>(primaryKeyQuery, [actualTable]);
if (!primaryKeyResult || primaryKeyResult.length === 0) {
throw new Error(`테이블 ${tableName}의 기본키를 찾을 수 없습니다.`);
throw new Error(`테이블 ${actualTable}의 기본키를 찾을 수 없습니다.`);
}
const primaryKeyInfo = primaryKeyResult[0];
@@ -1272,7 +1325,7 @@ export class DynamicFormService {
// 3. 동적으로 발견된 기본키와 타입 캐스팅을 사용한 DELETE SQL 생성
const deleteQuery = `
DELETE FROM ${tableName}
DELETE FROM ${actualTable}
WHERE ${primaryKeyColumn} = $1${typeCastSuffix}
RETURNING *
`;
@@ -1292,7 +1345,7 @@ export class DynamicFormService {
// 삭제된 행이 없으면 레코드를 찾을 수 없는 것
if (!result || !Array.isArray(result) || result.length === 0) {
throw new Error(`테이블 ${tableName}에서 ID '${id}'에 해당하는 레코드를 찾을 수 없습니다.`);
throw new Error(`테이블 ${actualTable}에서 ID '${id}'에 해당하는 레코드를 찾을 수 없습니다.`);
}
console.log("✅ 서비스: 실제 테이블 삭제 성공:", result);

View File

@@ -903,7 +903,8 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
}
}
for (const row of filteredData) {
for (let rowIdx = 0; rowIdx < filteredData.length; rowIdx++) {
const row = filteredData[rowIdx];
try {
let dataToSave = { ...row };
let shouldSkip = false;
@@ -925,15 +926,16 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
if (existingDataMap.has(key)) {
existingRow = existingDataMap.get(key);
// 중복 발견 - 전역 설정에 따라 처리
if (duplicateAction === "skip") {
shouldSkip = true;
skipCount++;
console.log(`⏭️ 중복으로 건너뛰기: ${key}`);
console.log(`⏭️ [행 ${rowIdx + 1}] 중복으로 건너뛰기: ${key}`);
} else {
shouldUpdate = true;
console.log(`🔄 중복으로 덮어쓰기: ${key}`);
console.log(`🔄 [행 ${rowIdx + 1}] 중복으로 덮어쓰기: ${key}`);
}
} else {
console.log(`✅ [행 ${rowIdx + 1}] 중복 아님 (신규 데이터): ${key}`);
}
}
@@ -943,7 +945,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
}
// 채번 적용: 엑셀에 값이 없거나 빈 값이면 채번 규칙으로 자동 생성, 값이 있으면 그대로 사용
if (hasNumbering && numberingInfo && uploadMode === "insert" && !shouldUpdate) {
if (hasNumbering && numberingInfo && (uploadMode === "insert" || uploadMode === "upsert") && !shouldUpdate) {
const existingValue = dataToSave[numberingInfo.columnName];
const hasExcelValue = existingValue !== undefined && existingValue !== null && String(existingValue).trim() !== "";
@@ -968,24 +970,34 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
tableName,
data: dataToSave,
};
console.log(`📝 [행 ${rowIdx + 1}] 덮어쓰기 시도: id=${existingRow.id}`, dataToSave);
const result = await DynamicFormApi.updateFormData(existingRow.id, formData);
if (result.success) {
overwriteCount++;
successCount++;
} else {
console.error(`❌ [행 ${rowIdx + 1}] 덮어쓰기 실패:`, result.message);
failCount++;
}
} else if (uploadMode === "insert") {
// 신규 등록
} else if (uploadMode === "insert" || uploadMode === "upsert") {
// 신규 등록 (insert, upsert 모드)
const formData = { screenId: 0, tableName, data: dataToSave };
console.log(`📝 [행 ${rowIdx + 1}] 신규 등록 시도 (mode: ${uploadMode}):`, dataToSave);
const result = await DynamicFormApi.saveFormData(formData);
if (result.success) {
successCount++;
console.log(`✅ [행 ${rowIdx + 1}] 신규 등록 성공`);
} else {
console.error(`❌ [행 ${rowIdx + 1}] 신규 등록 실패:`, result.message);
failCount++;
}
} else if (uploadMode === "update") {
// update 모드에서 기존 데이터가 없는 행은 건너뛰기
console.log(`⏭️ [행 ${rowIdx + 1}] update 모드: 기존 데이터 없음, 건너뛰기`);
skipCount++;
}
} catch (error) {
} catch (error: any) {
console.error(`❌ [행 ${rowIdx + 1}] 업로드 처리 오류:`, error?.response?.data || error?.message || error);
failCount++;
}
}
@@ -1008,8 +1020,9 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
}
}
console.log(`📊 엑셀 업로드 결과 요약: 성공=${successCount}, 건너뛰기=${skipCount}, 덮어쓰기=${overwriteCount}, 실패=${failCount}`);
if (successCount > 0 || skipCount > 0) {
// 상세 결과 메시지 생성
let message = "";
if (successCount > 0) {
message += `${successCount}개 행 업로드`;
@@ -1022,15 +1035,23 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
message += `중복 건너뛰기 ${skipCount}`;
}
if (failCount > 0) {
message += ` (실패: ${failCount})`;
message += `, 실패 ${failCount}`;
}
toast.success(message);
if (failCount > 0 && successCount === 0) {
toast.warning(message);
} else {
toast.success(message);
}
// 매핑 템플릿 저장
await saveMappingTemplateInternal();
onSuccess?.();
if (successCount > 0 || overwriteCount > 0) {
onSuccess?.();
}
} else if (failCount > 0) {
toast.error(`업로드 실패: ${failCount}개 행 저장에 실패했습니다. 브라우저 콘솔에서 상세 오류를 확인하세요.`);
} else {
toast.error("업로드에 실패했습니다.");
}