import { defineEventHandler, readBody, createError, getCookie } from 'h3' import { query, queryOne, execute, insertReturning } from '../../../utils/db' import { getClientIp } from '../../../utils/ip' import { getCurrentUserEmail } from '../../../utils/user' import OpenAI from 'openai' interface AggregateBody { projectIds: number[] reportYear: number reportWeek: number } const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }) /** * 다중 프로젝트 취합 실행 (OpenAI 요약 포함) * POST /api/report/summary/aggregate */ export default defineEventHandler(async (event) => { const userId = getCookie(event, 'user_id') if (!userId) { throw createError({ statusCode: 401, message: '로그인이 필요합니다.' }) } const body = await readBody(event) const clientIp = getClientIp(event) const userEmail = await getCurrentUserEmail(event) if (!body.projectIds || body.projectIds.length === 0) { throw createError({ statusCode: 400, message: '프로젝트를 선택해주세요.' }) } if (!body.reportYear || !body.reportWeek) { throw createError({ statusCode: 400, message: '연도와 주차를 선택해주세요.' }) } let summaryCount = 0 let totalMembers = 0 const allReportIds: number[] = [] // 각 프로젝트별로 취합 생성 for (const projectId of body.projectIds) { // 해당 프로젝트/주차의 Task 조회 (작성자 포함) const tasks = await query(` SELECT t.task_id, t.task_type, t.task_description, t.task_hours, t.is_completed, r.report_id, r.author_id, e.employee_name as author_name, r.week_start_date, r.week_end_date FROM wr_weekly_report r JOIN wr_weekly_report_task t ON r.report_id = t.report_id JOIN wr_employee_info e ON r.author_id = e.employee_id WHERE t.project_id = $1 AND r.report_year = $2 AND r.report_week = $3 AND r.report_status IN ('SUBMITTED', 'AGGREGATED') ORDER BY t.task_type, e.employee_name `, [projectId, body.reportYear, body.reportWeek]) if (tasks.length === 0) continue const reportIds = [...new Set(tasks.map(t => t.report_id))] const weekStartDate = tasks[0].week_start_date const weekEndDate = tasks[0].week_end_date // 총 시간 계산 const totalWorkHours = tasks .filter(t => t.task_type === 'WORK') .reduce((sum, t) => sum + (parseFloat(t.task_hours) || 0), 0) // OpenAI로 요약 생성 (금주 실적 / 차주 계획 분리) const { workSummary, planSummary } = await generateAISummary(tasks, projectId, body.reportYear, body.reportWeek) // 기존 취합 보고서 확인 const existing = await queryOne(` SELECT summary_id FROM wr_aggregated_report_summary WHERE project_id = $1 AND report_year = $2 AND report_week = $3 `, [projectId, body.reportYear, body.reportWeek]) if (existing) { // 기존 취합 업데이트 await execute(` UPDATE wr_aggregated_report_summary SET report_ids = $1, member_count = $2, total_work_hours = $3, ai_work_summary = $4, ai_plan_summary = $5, ai_summary_at = NOW(), aggregated_at = NOW(), updated_at = NOW(), updated_ip = $6, updated_email = $7 WHERE summary_id = $8 `, [reportIds, reportIds.length, totalWorkHours, workSummary, planSummary, clientIp, userEmail, existing.summary_id]) } else { // 새 취합 생성 await insertReturning(` INSERT INTO wr_aggregated_report_summary ( project_id, report_year, report_week, week_start_date, week_end_date, report_ids, member_count, total_work_hours, ai_work_summary, ai_plan_summary, ai_summary_at, created_ip, created_email, updated_ip, updated_email ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), $11, $12, $11, $12) RETURNING summary_id `, [ projectId, body.reportYear, body.reportWeek, weekStartDate, weekEndDate, reportIds, reportIds.length, totalWorkHours, workSummary, planSummary, clientIp, userEmail ]) } summaryCount++ totalMembers += reportIds.length allReportIds.push(...reportIds) } if (summaryCount === 0) { throw createError({ statusCode: 400, message: '취합할 보고서가 없습니다.' }) } // 개별 보고서 상태 업데이트 const uniqueReportIds = [...new Set(allReportIds)] await execute(` UPDATE wr_weekly_report SET report_status = 'AGGREGATED', updated_at = NOW(), updated_ip = $1, updated_email = $2 WHERE report_id = ANY($3) `, [clientIp, userEmail, uniqueReportIds]) return { success: true, summaryCount, totalMembers: uniqueReportIds.length } }) // OpenAI로 금주 실적/차주 계획 분리 요약 생성 async function generateAISummary(tasks: any[], projectId: number, year: number, week: number): Promise<{ workSummary: string, planSummary: string }> { // 프로젝트명 조회 const project = await queryOne(`SELECT project_name FROM wr_project_info WHERE project_id = $1`, [projectId]) const projectName = project?.project_name || '프로젝트' // Task를 실적/계획으로 분류 const workTasks = tasks.filter(t => t.task_type === 'WORK') const planTasks = tasks.filter(t => t.task_type === 'PLAN') // 금주 실적 요약 const workPrompt = `당신은 주간보고 취합 전문가입니다. 아래 금주 실적을 간결하게 요약해주세요. ## 프로젝트: ${projectName} ## 기간: ${year}년 ${week}주차 ## 금주 실적 (${workTasks.length}건) ${workTasks.map(t => `- [${t.author_name}] ${t.is_completed ? '완료' : '진행중'} | ${t.task_description} (${t.task_hours}h)`).join('\n') || '없음'} ## 요약 규칙 1. 완료된 작업과 진행 중인 작업을 구분하여 핵심만 요약 2. 동일/유사한 작업은 하나로 통합 3. 담당자 이름은 생략하고 내용 위주로 작성 4. 3~5줄 이내로 간결하게 5. 마크다운 리스트 형식으로 작성` // 차주 계획 요약 const planPrompt = `당신은 주간보고 취합 전문가입니다. 아래 차주 계획을 간결하게 요약해주세요. ## 프로젝트: ${projectName} ## 기간: ${year}년 ${week+1}주차 계획 ## 차주 계획 (${planTasks.length}건) ${planTasks.map(t => `- [${t.author_name}] ${t.task_description} (${t.task_hours}h)`).join('\n') || '없음'} ## 요약 규칙 1. 주요 계획을 우선순위에 따라 요약 2. 동일/유사한 작업은 하나로 통합 3. 담당자 이름은 생략하고 내용 위주로 작성 4. 2~4줄 이내로 간결하게 5. 마크다운 리스트 형식으로 작성` try { const [workRes, planRes] = await Promise.all([ openai.chat.completions.create({ model: 'gpt-4o-mini', messages: [ { role: 'system', content: '주간보고 취합 전문가입니다. 간결하게 요약합니다.' }, { role: 'user', content: workPrompt } ], temperature: 0.3, max_tokens: 500 }), openai.chat.completions.create({ model: 'gpt-4o-mini', messages: [ { role: 'system', content: '주간보고 취합 전문가입니다. 간결하게 요약합니다.' }, { role: 'user', content: planPrompt } ], temperature: 0.3, max_tokens: 500 }) ]) return { workSummary: workRes.choices[0]?.message?.content || '요약 없음', planSummary: planRes.choices[0]?.message?.content || '요약 없음' } } catch (error) { console.error('OpenAI 요약 생성 실패:', error) return { workSummary: '요약 생성 실패', planSummary: '요약 생성 실패' } } }