1ㅊㅏ완료

This commit is contained in:
2026-01-05 02:00:13 +09:00
parent 1bbad6efa7
commit 185161db16
30 changed files with 4331 additions and 837 deletions

View File

@@ -21,9 +21,9 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 404, message: '취합 보고서를 찾을 수 없습니다.' })
}
// 개별 보고서 목록 (새 구조: 마스터 + 프로젝트별 실적 조인)
// 개별 보고서 목록 (Task 기반)
const reports = await query(`
SELECT
SELECT DISTINCT
r.report_id,
r.author_id,
e.employee_name as author_name,
@@ -32,16 +32,49 @@ export default defineEventHandler(async (event) => {
r.vacation_description,
r.remark_description,
r.report_status,
r.submitted_at,
rp.work_description,
rp.plan_description
r.submitted_at
FROM wr_weekly_report r
JOIN wr_weekly_report_project rp ON r.report_id = rp.report_id
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 rp.project_id = $1 AND r.report_year = $2 AND r.report_week = $3
WHERE t.project_id = $1 AND r.report_year = $2 AND r.report_week = $3
ORDER BY e.employee_name
`, [summary.project_id, summary.report_year, summary.report_week])
// 각 보고서의 Task 조회
const reportIds = reports.map((r: any) => r.report_id)
const tasks = reportIds.length > 0 ? await query(`
SELECT
t.report_id,
t.task_type,
t.task_description,
t.task_hours,
t.is_completed
FROM wr_weekly_report_task t
WHERE t.report_id = ANY($1) AND t.project_id = $2
ORDER BY t.report_id, t.task_type
`, [reportIds, summary.project_id]) : []
// Task를 보고서별로 그룹핑
const tasksByReport = new Map<number, { work: any[], plan: any[] }>()
for (const task of tasks) {
if (!tasksByReport.has(task.report_id)) {
tasksByReport.set(task.report_id, { work: [], plan: [] })
}
const group = tasksByReport.get(task.report_id)!
if (task.task_type === 'WORK') {
group.work.push({
description: task.task_description,
hours: parseFloat(task.task_hours) || 0,
isCompleted: task.is_completed
})
} else {
group.plan.push({
description: task.task_description,
hours: parseFloat(task.task_hours) || 0
})
}
}
return {
summary: {
summaryId: summary.summary_id,
@@ -53,24 +86,31 @@ export default defineEventHandler(async (event) => {
weekStartDate: summary.week_start_date,
weekEndDate: summary.week_end_date,
memberCount: summary.member_count,
totalWorkHours: summary.total_work_hours,
reviewerId: summary.reviewer_id,
reviewerName: summary.reviewer_name,
reviewerComment: summary.reviewer_comment,
reviewedAt: summary.reviewed_at,
summaryStatus: summary.summary_status
summaryStatus: summary.summary_status,
aggregatedAt: summary.aggregated_at,
aiSummary: summary.ai_summary,
aiSummaryAt: summary.ai_summary_at
},
reports: reports.map((r: any) => ({
reportId: r.report_id,
authorId: r.author_id,
authorName: r.author_name,
authorPosition: r.employee_position,
workDescription: r.work_description,
planDescription: r.plan_description,
issueDescription: r.issue_description,
vacationDescription: r.vacation_description,
remarkDescription: r.remark_description,
reportStatus: r.report_status,
submittedAt: r.submitted_at
}))
reports: reports.map((r: any) => {
const taskGroup = tasksByReport.get(r.report_id) || { work: [], plan: [] }
return {
reportId: r.report_id,
authorId: r.author_id,
authorName: r.author_name,
authorPosition: r.employee_position,
workTasks: taskGroup.work,
planTasks: taskGroup.plan,
issueDescription: r.issue_description,
vacationDescription: r.vacation_description,
remarkDescription: r.remark_description,
reportStatus: r.report_status,
submittedAt: r.submitted_at
}
})
}
})

View File

@@ -1,15 +1,21 @@
import { query, queryOne, insertReturning, execute } from '../../../utils/db'
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 {
projectId: number
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) => {
@@ -22,75 +28,106 @@ export default defineEventHandler(async (event) => {
const clientIp = getClientIp(event)
const userEmail = await getCurrentUserEmail(event)
if (!body.projectId || !body.reportYear || !body.reportWeek) {
throw createError({ statusCode: 400, message: '프로젝트, 연도, 주차를 선택해주세요.' })
if (!body.projectIds || body.projectIds.length === 0) {
throw createError({ statusCode: 400, message: '프로젝트를 선택해주세요.' })
}
if (!body.reportYear || !body.reportWeek) {
throw createError({ statusCode: 400, message: '연도와 주차를 선택해주세요.' })
}
// 해당 프로젝트/주차의 제출된 보고서 조회 (새 구조)
const reports = await query<any>(`
SELECT
r.report_id,
r.author_id,
r.week_start_date,
r.week_end_date,
rp.detail_id
FROM wr_weekly_report r
JOIN wr_weekly_report_project rp ON r.report_id = rp.report_id
WHERE rp.project_id = $1
AND r.report_year = $2
AND r.report_week = $3
AND r.report_status IN ('SUBMITTED', 'AGGREGATED')
ORDER BY r.report_id
`, [body.projectId, body.reportYear, body.reportWeek])
let summaryCount = 0
let totalMembers = 0
const allReportIds: number[] = []
if (reports.length === 0) {
// 각 프로젝트별로 취합 생성
for (const projectId of body.projectIds) {
// 해당 프로젝트/주차의 Task 조회 (작성자 포함)
const tasks = await query<any>(`
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<any>(`
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<any>(`
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 reportIds = [...new Set(reports.map(r => r.report_id))]
const weekStartDate = reports[0].week_start_date
const weekEndDate = reports[0].week_end_date
// 기존 취합 보고서 확인
const existing = await queryOne<any>(`
SELECT summary_id FROM wr_aggregated_report_summary
WHERE project_id = $1 AND report_year = $2 AND report_week = $3
`, [body.projectId, body.reportYear, body.reportWeek])
let summaryId: number
if (existing) {
// 기존 취합 업데이트
await execute(`
UPDATE wr_aggregated_report_summary
SET report_ids = $1,
member_count = $2,
aggregated_at = NOW(),
updated_at = NOW(),
updated_ip = $3,
updated_email = $4
WHERE summary_id = $5
`, [reportIds, reportIds.length, clientIp, userEmail, existing.summary_id])
summaryId = existing.summary_id
} else {
// 새 취합 생성
const newSummary = await insertReturning<any>(`
INSERT INTO wr_aggregated_report_summary (
project_id, report_year, report_week, week_start_date, week_end_date,
report_ids, member_count,
created_ip, created_email, updated_ip, updated_email
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $8, $9)
RETURNING summary_id
`, [
body.projectId, body.reportYear, body.reportWeek,
weekStartDate, weekEndDate,
reportIds, reportIds.length,
clientIp, userEmail
])
summaryId = newSummary.summary_id
}
// 개별 보고서 상태 업데이트
const uniqueReportIds = [...new Set(allReportIds)]
await execute(`
UPDATE wr_weekly_report
SET report_status = 'AGGREGATED',
@@ -98,11 +135,88 @@ export default defineEventHandler(async (event) => {
updated_ip = $1,
updated_email = $2
WHERE report_id = ANY($3)
`, [clientIp, userEmail, reportIds])
`, [clientIp, userEmail, uniqueReportIds])
return {
success: true,
summaryId,
memberCount: reportIds.length
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<any>(`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: '요약 생성 실패'
}
}
}

View File

@@ -0,0 +1,42 @@
import { defineEventHandler, getQuery, createError, getCookie } from 'h3'
import { query } from '../../../utils/db'
export default defineEventHandler(async (event) => {
const userId = getCookie(event, 'user_id')
if (!userId) {
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
}
const { year, week } = getQuery(event)
if (!year || !week) {
throw createError({ statusCode: 400, message: '연도와 주차를 지정해주세요.' })
}
// 해당 주차에 제출된 보고서가 있는 프로젝트 목록
const projects = await query(`
SELECT
p.project_id,
p.project_code,
p.project_name,
COUNT(DISTINCT r.report_id) as report_count
FROM wr_weekly_report r
JOIN wr_weekly_report_task t ON r.report_id = t.report_id
JOIN wr_project_info p ON t.project_id = p.project_id
WHERE r.report_year = $1
AND r.report_week = $2
AND r.report_status IN ('SUBMITTED', 'AGGREGATED')
GROUP BY p.project_id, p.project_code, p.project_name
ORDER BY p.project_name
`, [Number(year), Number(week)])
return {
projects: projects.map((p: any) => ({
projectId: p.project_id,
projectCode: p.project_code,
projectName: p.project_name,
reportCount: parseInt(p.report_count)
}))
}
})

View File

@@ -33,22 +33,26 @@ export default defineEventHandler(async (event) => {
const summaries = await query(sql, params)
return summaries.map((s: any) => ({
summaryId: s.summary_id,
projectId: s.project_id,
projectName: s.project_name,
projectCode: s.project_code,
reportYear: s.report_year,
reportWeek: s.report_week,
weekStartDate: s.week_start_date,
weekEndDate: s.week_end_date,
memberCount: s.member_count,
totalWorkHours: s.total_work_hours,
reviewerId: s.reviewer_id,
reviewerName: s.reviewer_name,
reviewerComment: s.reviewer_comment,
reviewedAt: s.reviewed_at,
summaryStatus: s.summary_status,
aggregatedAt: s.aggregated_at
}))
return {
summaries: summaries.map((s: any) => ({
summaryId: s.summary_id,
projectId: s.project_id,
projectName: s.project_name,
projectCode: s.project_code,
reportYear: s.report_year,
reportWeek: s.report_week,
weekStartDate: s.week_start_date,
weekEndDate: s.week_end_date,
memberCount: s.member_count,
totalWorkHours: s.total_work_hours,
reviewerId: s.reviewer_id,
reviewerName: s.reviewer_name,
reviewerComment: s.reviewer_comment,
reviewedAt: s.reviewed_at,
summaryStatus: s.summary_status,
aggregatedAt: s.aggregated_at,
aiSummary: s.ai_summary,
aiSummaryAt: s.ai_summary_at
}))
}
})

View File

@@ -0,0 +1,152 @@
import { defineEventHandler, createError, getCookie } from 'h3'
import { query, queryOne, execute } from '../../../utils/db'
import OpenAI from 'openai'
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
})
/**
* 기존 취합 보고서에 AI 요약 일괄 생성
* POST /api/report/summary/regenerate-ai
*/
export default defineEventHandler(async (event) => {
const userId = getCookie(event, 'user_id')
if (!userId) {
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
}
// AI 요약이 없는 취합 보고서 조회
const summaries = await query<any>(`
SELECT s.summary_id, s.project_id, s.report_year, s.report_week,
p.project_name
FROM wr_aggregated_report_summary s
JOIN wr_project_info p ON s.project_id = p.project_id
WHERE s.ai_work_summary IS NULL OR s.ai_plan_summary IS NULL
ORDER BY s.summary_id
`, [])
console.log(`AI 요약 생성 대상: ${summaries.length}`)
let successCount = 0
let errorCount = 0
for (const summary of summaries) {
try {
// 해당 프로젝트/주차의 Task 조회
const tasks = await query<any>(`
SELECT
t.task_type,
t.task_description,
t.task_hours,
t.is_completed,
e.employee_name as author_name
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
`, [summary.project_id, summary.report_year, summary.report_week])
if (tasks.length === 0) {
console.log(`Skip ${summary.summary_id}: no tasks`)
continue
}
// AI 요약 생성
const { workSummary, planSummary } = await generateAISummary(
tasks,
summary.project_name,
summary.report_year,
summary.report_week
)
// 업데이트
await execute(`
UPDATE wr_aggregated_report_summary
SET ai_work_summary = $1,
ai_plan_summary = $2,
ai_summary_at = NOW()
WHERE summary_id = $3
`, [workSummary, planSummary, summary.summary_id])
successCount++
console.log(`Generated AI summary for ${summary.project_name} (${summary.report_year}-W${summary.report_week})`)
} catch (e: any) {
console.error(`Error for summary ${summary.summary_id}:`, e.message)
errorCount++
}
}
return {
success: true,
total: summaries.length,
successCount,
errorCount
}
})
async function generateAISummary(tasks: any[], projectName: string, year: number, week: number) {
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. 마크다운 리스트 형식으로 작성`
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 || '요약 없음'
}
}

View File

@@ -0,0 +1,171 @@
import { defineEventHandler, getQuery, createError } from 'h3'
import { query } from '../../../../utils/db'
/**
* 주차별 취합 상세 (프로젝트별 실적/계획 테이블용)
* GET /api/report/summary/week/detail?year=2026&week=1
*/
export default defineEventHandler(async (event) => {
const queryParams = getQuery(event)
const year = queryParams.year ? parseInt(queryParams.year as string) : null
const week = queryParams.week ? parseInt(queryParams.week as string) : null
if (!year || !week) {
throw createError({ statusCode: 400, message: '연도와 주차를 지정해주세요.' })
}
// 해당 주차 취합 보고서 목록
const summaries = await query(`
SELECT
s.summary_id,
s.project_id,
p.project_name,
p.project_code,
s.week_start_date,
s.week_end_date,
s.member_count,
s.total_work_hours,
s.ai_work_summary,
s.ai_plan_summary,
s.ai_summary_at,
s.summary_status,
s.aggregated_at
FROM wr_aggregated_report_summary s
JOIN wr_project_info p ON s.project_id = p.project_id
WHERE s.report_year = $1 AND s.report_week = $2
ORDER BY p.project_name
`, [year, week])
if (summaries.length === 0) {
throw createError({ statusCode: 404, message: '해당 주차의 취합 보고서가 없습니다.' })
}
// 프로젝트 ID 목록
const projectIds = summaries.map((s: any) => s.project_id)
// 해당 주차/프로젝트의 모든 Task 조회
const tasks = await query(`
SELECT
t.project_id,
t.task_type,
t.task_description,
t.task_hours,
t.is_completed,
r.author_id,
e.employee_name as author_name
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 r.report_year = $1
AND r.report_week = $2
AND t.project_id = ANY($3)
AND r.report_status IN ('SUBMITTED', 'AGGREGATED')
ORDER BY t.project_id, t.task_type, e.employee_name
`, [year, week, projectIds])
// Task를 프로젝트별로 그룹핑 + 프로젝트별 인원별 시간 집계 (실적/계획 통합)
const tasksByProject = new Map<number, { work: any[], plan: any[] }>()
const membersByProject = new Map<number, Map<number, { name: string, workHours: number, planHours: number }>>()
for (const task of tasks) {
// Task 그룹핑
if (!tasksByProject.has(task.project_id)) {
tasksByProject.set(task.project_id, { work: [], plan: [] })
}
const group = tasksByProject.get(task.project_id)!
const taskItem = {
description: task.task_description,
hours: parseFloat(task.task_hours) || 0,
isCompleted: task.is_completed,
authorId: task.author_id,
authorName: task.author_name
}
// 프로젝트별 인원별 시간 집계
if (!membersByProject.has(task.project_id)) {
membersByProject.set(task.project_id, new Map())
}
const members = membersByProject.get(task.project_id)!
if (!members.has(task.author_id)) {
members.set(task.author_id, { name: task.author_name, workHours: 0, planHours: 0 })
}
const hours = parseFloat(task.task_hours) || 0
const member = members.get(task.author_id)!
if (task.task_type === 'WORK') {
group.work.push(taskItem)
member.workHours += hours
} else {
group.plan.push(taskItem)
member.planHours += hours
}
}
// 전체 인원별 시간 집계
const memberHours = new Map<number, { name: string, workHours: number, planHours: number }>()
for (const task of tasks) {
const authorId = task.author_id
if (!memberHours.has(authorId)) {
memberHours.set(authorId, { name: task.author_name, workHours: 0, planHours: 0 })
}
const member = memberHours.get(authorId)!
const hours = parseFloat(task.task_hours) || 0
if (task.task_type === 'WORK') {
member.workHours += hours
} else {
member.planHours += hours
}
}
// 첫번째 summary에서 날짜 정보 추출
const weekInfo = {
reportYear: year,
reportWeek: week,
weekStartDate: summaries[0].week_start_date,
weekEndDate: summaries[0].week_end_date,
totalProjects: summaries.length,
totalWorkHours: summaries.reduce((sum: number, s: any) => sum + (parseFloat(s.total_work_hours) || 0), 0)
}
// 프로젝트별 데이터 구성
const projects = summaries.map((s: any) => {
const taskGroup = tasksByProject.get(s.project_id) || { work: [], plan: [] }
const projectMembers = membersByProject.get(s.project_id)
// 프로젝트별 인원 시간 배열 (실적+계획 통합)
const memberHoursList = projectMembers
? Array.from(projectMembers.values()).sort((a, b) => (b.workHours + b.planHours) - (a.workHours + a.planHours))
: []
return {
summaryId: s.summary_id,
projectId: s.project_id,
projectName: s.project_name,
projectCode: s.project_code,
memberCount: s.member_count,
totalWorkHours: parseFloat(s.total_work_hours) || 0,
aiWorkSummary: s.ai_work_summary,
aiPlanSummary: s.ai_plan_summary,
aiSummaryAt: s.ai_summary_at,
workTasks: taskGroup.work,
planTasks: taskGroup.plan,
memberHours: memberHoursList // { name, workHours, planHours }
}
})
// 전체 인원별 시간 배열로 변환
const members = Array.from(memberHours.entries()).map(([id, m]) => ({
employeeId: id,
employeeName: m.name,
workHours: m.workHours,
planHours: m.planHours,
availableHours: Math.max(0, 40 - m.planHours)
})).sort((a, b) => b.availableHours - a.availableHours)
return {
weekInfo,
projects,
members
}
})

View File

@@ -0,0 +1,44 @@
import { defineEventHandler, getQuery } from 'h3'
import { query } from '../../../utils/db'
/**
* 주차별 취합 목록
* GET /api/report/summary/weekly-list
*/
export default defineEventHandler(async (event) => {
const queryParams = getQuery(event)
const year = queryParams.year ? parseInt(queryParams.year as string) : new Date().getFullYear()
// 주차별로 그룹핑
const rows = await query(`
SELECT
s.report_year,
s.report_week,
MIN(s.week_start_date) as week_start_date,
MAX(s.week_end_date) as week_end_date,
COUNT(DISTINCT s.project_id) as project_count,
SUM(s.member_count) as total_members,
SUM(COALESCE(s.total_work_hours, 0)) as total_work_hours,
MAX(s.aggregated_at) as latest_aggregated_at,
ARRAY_AGG(DISTINCT p.project_name ORDER BY p.project_name) as project_names
FROM wr_aggregated_report_summary s
JOIN wr_project_info p ON s.project_id = p.project_id
WHERE s.report_year = $1
GROUP BY s.report_year, s.report_week
ORDER BY s.report_week DESC
`, [year])
return {
weeks: rows.map((r: any) => ({
reportYear: r.report_year,
reportWeek: r.report_week,
weekStartDate: r.week_start_date,
weekEndDate: r.week_end_date,
projectCount: parseInt(r.project_count),
totalMembers: parseInt(r.total_members),
totalWorkHours: parseFloat(r.total_work_hours) || 0,
latestAggregatedAt: r.latest_aggregated_at,
projects: r.project_names || []
}))
}
})