diff --git a/server/api/maintenance/upload.post.ts b/server/api/maintenance/upload.post.ts deleted file mode 100644 index c248fb3..0000000 --- a/server/api/maintenance/upload.post.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { insertReturning, query, queryOne } from '../../utils/db' -import { callOpenAI } from '../../utils/openai' -import { getCurrentUserId } from '../../utils/user' -import * as XLSX from 'xlsx' - -/** - * 유지보수 업무 엑셀/CSV 업로드 및 AI 파싱 - * POST /api/maintenance/upload - */ -export default defineEventHandler(async (event) => { - const userId = await getCurrentUserId(event) - - // multipart/form-data 처리 - const formData = await readMultipartFormData(event) - if (!formData || formData.length === 0) { - throw createError({ statusCode: 400, message: '파일을 업로드해주세요.' }) - } - - const fileField = formData.find(f => f.name === 'file') - const projectIdField = formData.find(f => f.name === 'projectId') - - if (!fileField || !fileField.data) { - throw createError({ statusCode: 400, message: '파일이 필요합니다.' }) - } - - const projectId = projectIdField?.data ? Number(projectIdField.data.toString()) : null - - // 파일 확장자 확인 - const filename = fileField.filename || '' - const ext = filename.split('.').pop()?.toLowerCase() - - if (!['xlsx', 'xls', 'csv'].includes(ext || '')) { - throw createError({ statusCode: 400, message: '엑셀(.xlsx, .xls) 또는 CSV 파일만 지원합니다.' }) - } - - // SheetJS로 파싱 - let rows: any[] = [] - try { - const workbook = XLSX.read(fileField.data, { type: 'buffer' }) - const sheetName = workbook.SheetNames[0] - const sheet = workbook.Sheets[sheetName] - rows = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '' }) - } catch (e) { - throw createError({ statusCode: 400, message: '파일 파싱에 실패했습니다.' }) - } - - if (rows.length < 2) { - throw createError({ statusCode: 400, message: '데이터가 없습니다. (헤더 + 최소 1행)' }) - } - - // 헤더와 데이터 분리 - const headers = rows[0] as string[] - const dataRows = rows.slice(1).filter((r: any[]) => r.some(cell => cell !== '')) - - if (dataRows.length === 0) { - throw createError({ statusCode: 400, message: '데이터 행이 없습니다.' }) - } - - // OpenAI로 컬럼 매핑 분석 - const prompt = `다음은 유지보수 업무 목록 엑셀의 헤더입니다: -${JSON.stringify(headers)} - -그리고 샘플 데이터 3행입니다: -${JSON.stringify(dataRows.slice(0, 3))} - -각 컬럼이 다음 필드 중 어느 것에 해당하는지 매핑해주세요: -- request_date: 요청일 (날짜) -- request_title: 요청 제목/건명 -- request_content: 요청 내용/상세 -- requester_name: 요청자 이름 -- requester_contact: 요청자 연락처/이메일/전화 -- task_type: 유형 (bug/feature/inquiry/other) -- priority: 우선순위 (high/medium/low) -- resolution_content: 조치 내용/처리 결과 - -JSON 형식으로 응답 (인덱스 기반): -{ - "mapping": { - "request_date": 0, - "request_title": 1, - ... - }, - "confidence": 0.9 -}` - - let mapping: Record = {} - try { - const response = await callOpenAI([ - { role: 'system', content: '엑셀 컬럼 매핑 전문가입니다. 정확하게 필드를 매핑합니다.' }, - { role: 'user', content: prompt } - ], true) - const parsed = JSON.parse(response) - mapping = parsed.mapping || {} - } catch (e) { - console.error('OpenAI mapping error:', e) - // 기본 매핑 시도 (순서대로) - mapping = { request_date: 0, request_title: 1, request_content: 2 } - } - - // 배치 ID 생성 - const batchResult = await queryOne<{ nextval: string }>(`SELECT nextval('wr_maintenance_batch_seq')`) - const batchId = Number(batchResult?.nextval || Date.now()) - - // 데이터 변환 - const parsedTasks = dataRows.map((row: any[], idx: number) => { - const getValue = (field: string) => { - const colIdx = mapping[field] - if (colIdx === undefined || colIdx === null) return null - return row[colIdx]?.toString().trim() || null - } - - // 날짜 파싱 - let requestDate = getValue('request_date') - if (requestDate) { - // 엑셀 시리얼 넘버 처리 - if (!isNaN(Number(requestDate))) { - const excelDate = XLSX.SSF.parse_date_code(Number(requestDate)) - if (excelDate) { - requestDate = `${excelDate.y}-${String(excelDate.m).padStart(2,'0')}-${String(excelDate.d).padStart(2,'0')}` - } - } - } - - // 유형 정규화 - let taskType = getValue('task_type')?.toLowerCase() || 'other' - if (taskType.includes('버그') || taskType.includes('오류') || taskType.includes('bug')) taskType = 'bug' - else if (taskType.includes('기능') || taskType.includes('개선') || taskType.includes('feature')) taskType = 'feature' - else if (taskType.includes('문의') || taskType.includes('inquiry')) taskType = 'inquiry' - else taskType = 'other' - - // 우선순위 정규화 - let priority = getValue('priority')?.toLowerCase() || 'medium' - if (priority.includes('높') || priority.includes('긴급') || priority.includes('high')) priority = 'high' - else if (priority.includes('낮') || priority.includes('low')) priority = 'low' - else priority = 'medium' - - return { - rowIndex: idx + 2, // 1-based + 헤더 - requestDate, - requestTitle: getValue('request_title') || `업무 ${idx + 1}`, - requestContent: getValue('request_content'), - requesterName: getValue('requester_name'), - requesterContact: getValue('requester_contact'), - taskType, - priority, - resolutionContent: getValue('resolution_content'), - isDuplicate: false - } - }) - - // 중복 감지 (같은 제목 + 같은 날짜) - if (projectId) { - for (const task of parsedTasks) { - if (task.requestTitle && task.requestDate) { - const dup = await queryOne(` - SELECT task_id FROM wr_maintenance_task - WHERE project_id = $1 AND request_title = $2 AND request_date = $3 - `, [projectId, task.requestTitle, task.requestDate]) - if (dup) { - task.isDuplicate = true - } - } - } - } - - return { - success: true, - batchId, - filename, - totalRows: dataRows.length, - mapping, - headers, - tasks: parsedTasks - } -})