176 lines
6.0 KiB
TypeScript
176 lines
6.0 KiB
TypeScript
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<string, number> = {}
|
|
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
|
|
}
|
|
})
|