From 70fe8a0b5c4858090a75d7e4be7e2adef9e51c92 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Thu, 2 Oct 2025 12:12:54 +0000 Subject: (임수민) S-ERP 테이블 2개 추가, INSERT 로직을 UPDATE 로 변경, 복합 pk 로직 추가 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/s-erp-import/actions.ts | 289 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 272 insertions(+), 17 deletions(-) (limited to 'lib') diff --git a/lib/s-erp-import/actions.ts b/lib/s-erp-import/actions.ts index d4d2e1ec..5dfc1116 100644 --- a/lib/s-erp-import/actions.ts +++ b/lib/s-erp-import/actions.ts @@ -20,6 +20,15 @@ export async function getSapTableCounts(tableNames: string[]) { return result } +// 단일 테이블 카운트 조회 함수 +export async function getSingleTableCount(tableName: string): Promise { + const table = (schema as any)[camelFromTable(tableName)] + if (!table) return 0 + + const rows = await db.select({ c: sql`count(*)` }).from(table) + return Number(rows?.[0]?.c ?? 0) +} + export async function exportTemplate(tableName: string): Promise { const table = (schema as any)[camelFromTable(tableName)] if (!table) throw new Error(`Unknown table: ${tableName}`) @@ -46,9 +55,19 @@ export async function importExcel(tableName: string, file: File) { const sheet = workbook.worksheets[0] const rawHeader = (sheet.getRow(1).values as any[]).slice(1) as string[] - // 1) 스키마 컬럼 수집 및 정규화 맵 구성 + // 1) 스키마 컬럼 수집 및 정규화 맵 구성 (Drizzle 내부 메타데이터 제외) const schemaKeys = Object.keys(table) - .filter((k) => !k.startsWith('_') && k !== 'getSQL' && k !== '[$$schema]' && k !== 'id') + .filter((k) => { + // Drizzle ORM 내부 메타데이터 및 시스템 컬럼 제외 + return !k.startsWith('_') && + k !== 'getSQL' && + k !== '[$$schema]' && + k !== 'id' && + k !== 'enableRLS' && + k !== '$inferSelect' && + k !== '$inferInsert' && + typeof (table as any)[k] !== 'function' + }) const normalize = (s: string) => (s || '') .replace(/\./g, '') .replace(/\s+/g, '') @@ -69,10 +88,39 @@ export async function importExcel(tableName: string, file: File) { .map(({ h }) => String(h)) const mappedColumns = mappedHeader.filter(Boolean) as string[] - if (mappedColumns.length === 0) { + + // EVCP 스타일 헤더 검증 + const headerErrors: string[] = [] + + // 1) 누락된 스키마 컬럼 확인 (필수 컬럼이 파일에 없는 경우) + schemaKeys.forEach(schemaKey => { + if (!mappedColumns.includes(schemaKey)) { + headerErrors.push(`필수 컬럼 "${schemaKey}"가 파일에 없습니다`) + } + }) + + // 2) 예상치 못한 컬럼 확인 (파일에 있지만 스키마에 없는 컬럼) + unknownHeaders.forEach(header => { + headerErrors.push(`예상치 못한 컬럼 "${header}"가 파일에 있습니다`) + }) + + // 헤더 검증 실패 시 에러 반환 + if (headerErrors.length > 0) { return { success: false, - message: `헤더가 테이블 컬럼과 일치하지 않습니다. unknownHeaders=${JSON.stringify(unknownHeaders)}`, + message: `헤더 검증 실패: ${headerErrors.join(', ')}`, + } + } + + // PK 컬럼이 매핑되었는지 확인 + const primaryKeys = getPrimaryKeysFromSchema(tableName) + if (primaryKeys.length > 0) { + const mappedPKs = primaryKeys.filter(pk => mappedColumns.includes(pk)) + if (mappedPKs.length === 0) { + return { + success: false, + message: `필수 컬럼(Primary Key)이 없습니다. 필요한 PK: [${primaryKeys.join(', ')}]`, + } } } @@ -85,7 +133,7 @@ export async function importExcel(tableName: string, file: File) { mappedHeader.forEach((schemaKey, idx) => { if (!schemaKey) return const v = values[idx] - obj[schemaKey] = normalizeCellValue(schemaKey, v) + obj[schemaKey] = normalizeCellValue(schemaKey, v, table) }) // 전체가 비어있는 행은 제외 if (Object.values(obj).some((v) => v !== null && v !== '')) { @@ -127,10 +175,93 @@ export async function importExcel(tableName: string, file: File) { } try { - // @ts-expect-error drizzle insert - await db.insert(table).values(rows) - revalidatePath('/sap-import') - return { success: true, inserted: rows.length, unknownHeaders } + const primaryKeys = getPrimaryKeysFromSchema(tableName) + + if (primaryKeys.length > 0 && rows.length > 0) { + // UPSERT 전 기존 데이터 개수 확인 + const beforeCount = await getSingleTableCount(tableName) + + // 모든 PK 값이 존재하는 행만 처리 + const validRows = rows.filter(row => { + const pkValues = primaryKeys.map(pk => row[pk]).filter(val => val != null && val !== '') + return pkValues.length === primaryKeys.length + }) + + let actualProcessedCount = 0 + + if (validRows.length > 0) { + if (primaryKeys.length === 1) { + // 단일 PK: Drizzle의 onConflictDoUpdate 사용 + const pkColumn = primaryKeys[0] + const targetColumn = (table as any)[pkColumn] + + if (targetColumn) { + const firstRow = validRows[0] + const updateFields = Object.keys(firstRow) + .filter(key => key !== pkColumn && key !== 'id') + .reduce((acc, key) => { + acc[key] = sql`excluded.${sql.identifier(key)}` + return acc + }, {} as Record) + + // 배치 내 중복 PK 제거 (마지막 행만 유지) + const deduplicatedRows = validRows.reduce((acc, row) => { + const pkValue = row[pkColumn] + acc[pkValue] = row // 같은 PK면 마지막 행으로 덮어씀 + return acc + }, {} as Record) + + const uniqueRows = Object.values(deduplicatedRows) + actualProcessedCount = uniqueRows.length + console.log(`[DEBUG] 단일 PK 중복 제거: ${validRows.length}행 -> ${uniqueRows.length}행`) + + // 배치 처리 (500개씩) + const chunkSize = 500 + for (let i = 0; i < uniqueRows.length; i += chunkSize) { + const chunk = uniqueRows.slice(i, i + chunkSize) + await db.insert(table) + .values(chunk as any) + .onConflictDoUpdate({ + target: targetColumn, + set: updateFields, + }) + } + } + } else { + // 복합 PK: 진짜 UPSERT 사용 + const processedCount = await handleCompositeKeyProperUpsert(table, tableName, validRows, primaryKeys) + actualProcessedCount = processedCount + } + } + + // UPSERT 후 실제 데이터 개수 확인 + const afterCount = await getSingleTableCount(tableName) + + // 실제 추가된 행 수 vs 업데이트된 행 수 계산 (중복 제거 후 개수 사용) + const actualInserted = afterCount - beforeCount + const actualUpdated = actualProcessedCount - actualInserted + const skipped = rows.length - validRows.length + const duplicatesRemoved = validRows.length - actualProcessedCount + + revalidatePath('/sap-import') + return { + success: true, + processed: actualProcessedCount, + inserted: actualInserted, + updated: actualUpdated, + skipped: skipped, + duplicatesRemoved: duplicatesRemoved, + total: rows.length, + unknownHeaders, + primaryKeys, + upsertMode: true + } + } else { + // PK가 정의되지 않은 테이블은 기존 INSERT 방식 + await db.insert(table).values(rows as any) + revalidatePath('/sap-import') + return { success: true, inserted: rows.length, unknownHeaders, upsertMode: false } + } } catch (e: any) { return { success: false, message: e?.message ?? 'DB 입력 실패', unknownHeaders } } @@ -145,13 +276,136 @@ function camelFromTable(name: string) { return camel } +/** + * 스키마에서 PK 코멘트(// pk)를 분석하여 Primary Key 컬럼들을 추출 + */ +function getPrimaryKeysFromSchema(tableName: string): string[] { + const pkMap: Record = { + // 단일 PK 테이블들 + 'TB_SAP_EquipInfo': ['Equipment'], + 'TB_SAP_Order': ['Order'], + 'TB_SAP_OrderNotice': ['Notification'], + 'TB_SAP_OrderBreakdown': ['Order'], // pk 코멘트 확인됨 + 'TB_SAP_MainternanceBOM': ['Date', 'Order', 'Component'], // 복합 PK + 'TB_SAP_MaterialRepair': ['Order'], + + // 복합 PK 테이블들 + 'TB_SAP_OrderConfirm': ['Confirmation', 'Count'], + 'TB_SAP_MaterialInfo': ['Plnt', 'Material', 'Sloc'], // 복합 PK + 'TB_SAP_MaterialStock': ['Material', 'Batch', 'StorageLo'], // 복합 PK + 'TB_SAP_MaterialRelease': ['PostingGIdate', 'Plant', 'StorageLoc', 'MaterialDocument', 'DocumentYear'], // 복합 PK + 'TB_SAP_MaterialReceiving': ['PostingGRDate', 'Plant', 'StorageLoc', 'MatDoc', 'Year'], // 복합 PK + + // 새로 추가된 테이블들 + 'Technical_Guidance_Investment': ['FUMNR', 'FUMIT'], // 복합 PK + 'Technical_Guidance_Order': ['AUFNR', 'VORNR'], // 복합 PK + } + + return pkMap[tableName] || [] +} + +/** + * 복합 PK를 위한 안전한 UPSERT (ON CONFLICT DO UPDATE) + */ +async function handleCompositeKeyProperUpsert( + table: any, + tableName: string, + rows: any[], + primaryKeys: string[] +): Promise { + // 복합 PK 컬럼들을 가져오기 + const targetColumns = primaryKeys.map(pk => (table as any)[pk]).filter(Boolean) + + if (targetColumns.length !== primaryKeys.length) { + throw new Error(`[${tableName}] PK 컬럼을 찾을 수 없습니다: ${primaryKeys.join(', ')}`) + } + + // UPDATE할 필드들 (PK와 id 제외) + const firstRow = rows[0] + const updateFields = Object.keys(firstRow) + .filter(key => !primaryKeys.includes(key) && key !== 'id') + .reduce((acc, key) => { + acc[key] = sql`excluded.${sql.identifier(key)}` + return acc + }, {} as Record) + + // 배치 내 중복 PK 제거 (마지막 행만 유지) + const deduplicatedRows = rows.reduce((acc, row) => { + const pkKey = primaryKeys.map(pk => row[pk]).join('|') + acc[pkKey] = row // 같은 PK면 마지막 행으로 덮어씀 + return acc + }, {} as Record) + + const uniqueRows = Object.values(deduplicatedRows) + console.log(`[DEBUG] 중복 제거: ${rows.length}행 -> ${uniqueRows.length}행`) + + // 배치 처리 (500개씩) + const chunkSize = 500 + for (let i = 0; i < uniqueRows.length; i += chunkSize) { + const chunk = uniqueRows.slice(i, i + chunkSize) + await db.insert(table) + .values(chunk as any) + .onConflictDoUpdate({ + target: targetColumns, + set: updateFields, + }) + } + + return uniqueRows.length +} + + // 날짜/숫자/문자 값을 컬럼 특성에 맞게 문자열로 정규화 -function normalizeCellValue(column: string, v: unknown) { +function normalizeCellValue(column: string, v: unknown, table?: any) { if (v == null) return null + + // 빈 문자열 처리 (real, integer, bigint 타입에서는 null로 변환) + const s = String(v).trim() + if (s === '') { + return null + } + + // 헤더 행 감지: 컬럼명과 동일한 값이 오면 null 처리 (헤더가 데이터로 잘못 인식된 경우) + if (s === column) { + return null + } + + // 컬럼 타입 확인 + const columnDef = table?.[column] + const actualDataType = columnDef?.config?.columnType + + // 숫자 타입 컬럼인지 확인 (bigint, integer 등) + const isNumericColumn = actualDataType?.includes('BigInt') || actualDataType?.includes('Integer') + + if (isNumericColumn) { + // 숫자로 변환 가능한지 확인 + const numericValue = s.replace(/[,\s]/g, '') // 쉼표와 공백 제거 + if (!/^\d+$/.test(numericValue)) { + return null // 숫자가 아니면 null + } + return numericValue + } + // Date 객체거나 날짜처럼 보이는 문자열/숫자는 yyyy-MM-dd로 if (v instanceof Date) { return formatDate10(v) } + + // 시간 형식 처리 (예: "9시 42분 0초" -> "09:42:00") + const timeFormatMatch = s.match(/^(\d{1,2})시\s*(\d{1,2})분\s*(\d{1,2})초$/) + if (timeFormatMatch) { + const hour = String(Number(timeFormatMatch[1])).padStart(2, '0') + const minute = String(Number(timeFormatMatch[2])).padStart(2, '0') + const second = String(Number(timeFormatMatch[3])).padStart(2, '0') + return `${hour}:${minute}:${second}` + } + + // 쉼표가 포함된 숫자 처리 (예: "117,733" -> "117733") + const commaNumberMatch = s.match(/^"?([0-9,]+)"?$/) + if (commaNumberMatch) { + return commaNumberMatch[1].replace(/,/g, '') + } + // 엑셀에서 날짜가 숫자(시리얼)로 올 수 있음 if (typeof v === 'number' && v > 20000 && v < 80000) { // Excel serial date (roughly) @@ -159,15 +413,16 @@ function normalizeCellValue(column: string, v: unknown) { const d = new Date(epoch.getTime() + v * 86400000) return formatDate10(d) } - const s = String(v).trim() - // 간단한 날짜 패턴들: 2025-06-09, 2025/06/09, 2025.06.09 - const m = s.match(/^(\d{4})[-\/.](\d{1,2})[-\/.](\d{1,2})$/) - if (m) { - const y = Number(m[1]) - const mm = String(Number(m[2])).padStart(2, '0') - const dd = String(Number(m[3])).padStart(2, '0') + + // 날짜 패턴들: 2025-06-09, 2025/06/09, 2025.06.09 + const dateMatch = s.match(/^(\d{4})[-\/.](\d{1,2})[-\/.](\d{1,2})$/) + if (dateMatch) { + const y = Number(dateMatch[1]) + const mm = String(Number(dateMatch[2])).padStart(2, '0') + const dd = String(Number(dateMatch[3])).padStart(2, '0') return `${y}-${mm}-${dd}` } + return s } -- cgit v1.2.3