1ㅊㅏ완료
This commit is contained in:
@@ -1,29 +1,27 @@
|
||||
import { query, queryOne, insertReturning, execute } from '../../utils/db'
|
||||
import { getClientIp } from '../../utils/ip'
|
||||
import { query, execute, queryOne } from '../../utils/db'
|
||||
|
||||
const ADMIN_EMAIL = 'coziny@gmail.com'
|
||||
|
||||
interface TaskInput {
|
||||
description: string
|
||||
hours: number
|
||||
}
|
||||
|
||||
interface ProjectInput {
|
||||
projectId: number | null // null이면 신규 생성
|
||||
projectId: number | null
|
||||
projectName: string
|
||||
workDescription: string | null
|
||||
planDescription: string | null
|
||||
workTasks: TaskInput[]
|
||||
planTasks: TaskInput[]
|
||||
}
|
||||
|
||||
interface ReportInput {
|
||||
employeeId: number
|
||||
employeeId: number | null
|
||||
employeeName: string
|
||||
employeeEmail: string
|
||||
projects: ProjectInput[]
|
||||
issueDescription: string | null
|
||||
vacationDescription: string | null
|
||||
remarkDescription: string | null
|
||||
}
|
||||
|
||||
interface BulkRegisterBody {
|
||||
reportYear: number
|
||||
reportWeek: number
|
||||
weekStartDate: string
|
||||
weekEndDate: string
|
||||
reports: ReportInput[]
|
||||
issueDescription?: string
|
||||
vacationDescription?: string
|
||||
remarkDescription?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,167 +35,152 @@ export default defineEventHandler(async (event) => {
|
||||
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
||||
}
|
||||
|
||||
const currentUser = await queryOne<any>(`
|
||||
const clientIp = getHeader(event, 'x-forwarded-for') || 'unknown'
|
||||
|
||||
const currentUser = await query<any>(`
|
||||
SELECT employee_email FROM wr_employee_info WHERE employee_id = $1
|
||||
`, [userId])
|
||||
|
||||
if (!currentUser || currentUser.employee_email !== ADMIN_EMAIL) {
|
||||
if (!currentUser[0] || currentUser[0].employee_email !== ADMIN_EMAIL) {
|
||||
throw createError({ statusCode: 403, message: '관리자만 사용할 수 있습니다.' })
|
||||
}
|
||||
|
||||
const body = await readBody<BulkRegisterBody>(event)
|
||||
const clientIp = getClientIp(event)
|
||||
const adminEmail = currentUser[0].employee_email
|
||||
|
||||
if (!body.reports || body.reports.length === 0) {
|
||||
throw createError({ statusCode: 400, message: '등록할 보고서가 없습니다.' })
|
||||
}
|
||||
const body = await readBody<{
|
||||
reportYear: number
|
||||
reportWeek: number
|
||||
weekStartDate: string
|
||||
weekEndDate: string
|
||||
reports: ReportInput[]
|
||||
}>(event)
|
||||
|
||||
const results: any[] = []
|
||||
|
||||
for (const report of body.reports) {
|
||||
try {
|
||||
// 1. 프로젝트 처리 (신규 생성 또는 기존 사용)
|
||||
const projectIds: number[] = []
|
||||
let employeeId = report.employeeId
|
||||
let isNewEmployee = false
|
||||
const newProjects: string[] = []
|
||||
|
||||
// 신규 직원 생성
|
||||
if (!employeeId && report.employeeName && report.employeeEmail) {
|
||||
const newEmp = await queryOne<any>(`
|
||||
INSERT INTO wr_employee_info (employee_name, employee_email, is_active, created_ip, created_email, updated_ip, updated_email)
|
||||
VALUES ($1, $2, true, $3, $4, $3, $4)
|
||||
RETURNING employee_id
|
||||
`, [report.employeeName, report.employeeEmail, clientIp, adminEmail])
|
||||
employeeId = newEmp.employee_id
|
||||
isNewEmployee = true
|
||||
}
|
||||
|
||||
if (!employeeId) {
|
||||
results.push({
|
||||
success: false,
|
||||
employeeName: report.employeeName,
|
||||
employeeEmail: report.employeeEmail,
|
||||
error: '직원 정보가 없습니다.'
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// 기존 보고서 확인 및 삭제 (덮어쓰기)
|
||||
const existing = await queryOne<any>(`
|
||||
SELECT report_id FROM wr_weekly_report
|
||||
WHERE author_id = $1 AND report_year = $2 AND report_week = $3
|
||||
`, [employeeId, body.reportYear, body.reportWeek])
|
||||
|
||||
let isUpdate = false
|
||||
if (existing) {
|
||||
await execute(`DELETE FROM wr_weekly_report_task WHERE report_id = $1`, [existing.report_id])
|
||||
await execute(`DELETE FROM wr_weekly_report WHERE report_id = $1`, [existing.report_id])
|
||||
isUpdate = true
|
||||
}
|
||||
|
||||
// 주간보고 마스터 등록
|
||||
const newReport = await queryOne<any>(`
|
||||
INSERT INTO wr_weekly_report (
|
||||
author_id, report_year, report_week, week_start_date, week_end_date,
|
||||
issue_description, vacation_description, remark_description,
|
||||
report_status, submitted_at, created_ip, created_email, updated_ip, updated_email
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'SUBMITTED', NOW(), $9, $10, $9, $10)
|
||||
RETURNING report_id
|
||||
`, [
|
||||
employeeId, body.reportYear, body.reportWeek, body.weekStartDate, body.weekEndDate,
|
||||
report.issueDescription || null, report.vacationDescription || null, report.remarkDescription || null,
|
||||
clientIp, adminEmail
|
||||
])
|
||||
|
||||
const reportId = newReport.report_id
|
||||
|
||||
// 프로젝트별 Task 등록
|
||||
for (const proj of report.projects) {
|
||||
let projectId = proj.projectId
|
||||
|
||||
if (!projectId) {
|
||||
// 신규 프로젝트 생성
|
||||
// 신규 프로젝트 생성
|
||||
if (!projectId && proj.projectName) {
|
||||
const year = new Date().getFullYear()
|
||||
const lastProject = await queryOne<any>(`
|
||||
SELECT project_code FROM wr_project_info
|
||||
WHERE project_code LIKE $1
|
||||
ORDER BY project_code DESC LIMIT 1
|
||||
const codeResult = await queryOne<any>(`
|
||||
SELECT COALESCE(MAX(CAST(SUBSTRING(project_code FROM 6) AS INTEGER)), 0) + 1 as next_num
|
||||
FROM wr_project_info WHERE project_code LIKE $1
|
||||
`, [`${year}-%`])
|
||||
const projectCode = `${year}-${String(codeResult.next_num).padStart(3, '0')}`
|
||||
|
||||
let nextNum = 1
|
||||
if (lastProject?.project_code) {
|
||||
const lastNum = parseInt(lastProject.project_code.split('-')[1]) || 0
|
||||
nextNum = lastNum + 1
|
||||
}
|
||||
const newCode = `${year}-${String(nextNum).padStart(3, '0')}`
|
||||
|
||||
const newProject = await insertReturning(`
|
||||
INSERT INTO wr_project_info (
|
||||
project_code, project_name, project_type,
|
||||
created_ip, created_email, updated_ip, updated_email
|
||||
) VALUES ($1, $2, 'SI', $3, $4, $3, $4)
|
||||
const newProj = await queryOne<any>(`
|
||||
INSERT INTO wr_project_info (project_code, project_name, project_status, created_ip, created_email, updated_ip, updated_email)
|
||||
VALUES ($1, $2, 'IN_PROGRESS', $3, $4, $3, $4)
|
||||
RETURNING project_id
|
||||
`, [newCode, proj.projectName, clientIp, ADMIN_EMAIL])
|
||||
|
||||
projectId = newProject.project_id
|
||||
`, [projectCode, proj.projectName, clientIp, adminEmail])
|
||||
projectId = newProj.project_id
|
||||
newProjects.push(proj.projectName)
|
||||
}
|
||||
|
||||
projectIds.push(projectId)
|
||||
}
|
||||
|
||||
// 2. 기존 주간보고 확인 (덮어쓰기)
|
||||
const existingReport = await queryOne<any>(`
|
||||
SELECT report_id FROM wr_weekly_report
|
||||
WHERE author_id = $1 AND report_year = $2 AND report_week = $3
|
||||
`, [report.employeeId, body.reportYear, body.reportWeek])
|
||||
|
||||
let reportId: number
|
||||
|
||||
if (existingReport) {
|
||||
// 기존 보고서 업데이트
|
||||
reportId = existingReport.report_id
|
||||
if (!projectId) continue
|
||||
|
||||
// 기존 프로젝트 실적 삭제
|
||||
await execute(`DELETE FROM wr_weekly_report_project WHERE report_id = $1`, [reportId])
|
||||
// 금주실적 Task 등록
|
||||
for (const task of proj.workTasks || []) {
|
||||
await execute(`
|
||||
INSERT INTO wr_weekly_report_task (
|
||||
report_id, project_id, task_type, task_description, task_hours, is_completed,
|
||||
created_ip, created_email, updated_ip, updated_email
|
||||
) VALUES ($1, $2, 'WORK', $3, $4, $5, $6, $7, $6, $7)
|
||||
`, [reportId, projectId, task.description, task.hours || 0, task.isCompleted !== false, clientIp, adminEmail])
|
||||
}
|
||||
|
||||
// 마스터 업데이트
|
||||
await execute(`
|
||||
UPDATE wr_weekly_report SET
|
||||
issue_description = $1,
|
||||
vacation_description = $2,
|
||||
remark_description = $3,
|
||||
report_status = 'SUBMITTED',
|
||||
submitted_at = NOW(),
|
||||
updated_at = NOW(),
|
||||
updated_ip = $4,
|
||||
updated_email = $5
|
||||
WHERE report_id = $6
|
||||
`, [
|
||||
report.issueDescription,
|
||||
report.vacationDescription,
|
||||
report.remarkDescription,
|
||||
clientIp,
|
||||
ADMIN_EMAIL,
|
||||
reportId
|
||||
])
|
||||
} else {
|
||||
// 신규 보고서 생성
|
||||
const newReport = await insertReturning(`
|
||||
INSERT INTO wr_weekly_report (
|
||||
author_id, report_year, report_week,
|
||||
week_start_date, week_end_date,
|
||||
issue_description, vacation_description, remark_description,
|
||||
report_status, submitted_at,
|
||||
created_ip, created_email, updated_ip, updated_email
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'SUBMITTED', NOW(), $9, $10, $9, $10)
|
||||
RETURNING report_id
|
||||
`, [
|
||||
report.employeeId,
|
||||
body.reportYear,
|
||||
body.reportWeek,
|
||||
body.weekStartDate,
|
||||
body.weekEndDate,
|
||||
report.issueDescription,
|
||||
report.vacationDescription,
|
||||
report.remarkDescription,
|
||||
clientIp,
|
||||
ADMIN_EMAIL
|
||||
])
|
||||
reportId = newReport.report_id
|
||||
// 차주계획 Task 등록
|
||||
for (const task of proj.planTasks || []) {
|
||||
await execute(`
|
||||
INSERT INTO wr_weekly_report_task (
|
||||
report_id, project_id, task_type, task_description, task_hours,
|
||||
created_ip, created_email, updated_ip, updated_email
|
||||
) VALUES ($1, $2, 'PLAN', $3, $4, $5, $6, $5, $6)
|
||||
`, [reportId, projectId, task.description, task.hours || 0, clientIp, adminEmail])
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 프로젝트별 실적 등록
|
||||
for (let i = 0; i < report.projects.length; i++) {
|
||||
const proj = report.projects[i]
|
||||
const projectId = projectIds[i]
|
||||
|
||||
await execute(`
|
||||
INSERT INTO wr_weekly_report_project (
|
||||
report_id, project_id, work_description, plan_description,
|
||||
created_ip, created_email, updated_ip, updated_email
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $5, $6)
|
||||
`, [
|
||||
reportId,
|
||||
projectId,
|
||||
proj.workDescription,
|
||||
proj.planDescription,
|
||||
clientIp,
|
||||
ADMIN_EMAIL
|
||||
])
|
||||
}
|
||||
|
||||
// 직원 정보 조회
|
||||
const employee = await queryOne<any>(`
|
||||
SELECT employee_name FROM wr_employee_info WHERE employee_id = $1
|
||||
`, [report.employeeId])
|
||||
|
||||
results.push({
|
||||
success: true,
|
||||
employeeId: report.employeeId,
|
||||
employeeName: employee?.employee_name,
|
||||
employeeId,
|
||||
employeeName: report.employeeName,
|
||||
employeeEmail: report.employeeEmail,
|
||||
reportId,
|
||||
isUpdate: !!existingReport
|
||||
isUpdate,
|
||||
isNewEmployee,
|
||||
newProjects
|
||||
})
|
||||
|
||||
} catch (err: any) {
|
||||
} catch (e: any) {
|
||||
results.push({
|
||||
success: false,
|
||||
employeeId: report.employeeId,
|
||||
error: err.message
|
||||
employeeName: report.employeeName,
|
||||
employeeEmail: report.employeeEmail,
|
||||
error: e.message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
totalCount: body.reports.length,
|
||||
totalCount: results.length,
|
||||
successCount: results.filter(r => r.success).length,
|
||||
results
|
||||
}
|
||||
|
||||
169
backend/api/admin/parse-image.post.ts
Normal file
169
backend/api/admin/parse-image.post.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { query } from '../../utils/db'
|
||||
import { callOpenAIVision, REPORT_PARSE_SYSTEM_PROMPT } from '../../utils/openai'
|
||||
|
||||
const ADMIN_EMAIL = 'coziny@gmail.com'
|
||||
|
||||
interface ParsedTask {
|
||||
description: string
|
||||
hours: number
|
||||
}
|
||||
|
||||
interface ParsedProject {
|
||||
projectName: string
|
||||
workTasks: ParsedTask[]
|
||||
planTasks: ParsedTask[]
|
||||
}
|
||||
|
||||
interface ParsedReport {
|
||||
employeeName: string
|
||||
employeeEmail: string | null
|
||||
projects: ParsedProject[]
|
||||
issueDescription: string | null
|
||||
vacationDescription: string | null
|
||||
remarkDescription: string | null
|
||||
}
|
||||
|
||||
interface ParsedResult {
|
||||
reportYear: number
|
||||
reportWeek: number
|
||||
weekStartDate: string
|
||||
weekEndDate: string
|
||||
reports: ParsedReport[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지에서 주간보고 분석 (OpenAI Vision)
|
||||
* POST /api/admin/parse-image
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 관리자 권한 체크
|
||||
const userId = getCookie(event, 'user_id')
|
||||
if (!userId) {
|
||||
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
||||
}
|
||||
|
||||
const currentUser = await query<any>(`
|
||||
SELECT employee_email FROM wr_employee_info WHERE employee_id = $1
|
||||
`, [userId])
|
||||
|
||||
if (!currentUser[0] || currentUser[0].employee_email !== ADMIN_EMAIL) {
|
||||
throw createError({ statusCode: 403, message: '관리자만 사용할 수 있습니다.' })
|
||||
}
|
||||
|
||||
const body = await readBody<{ images: string[] }>(event)
|
||||
|
||||
if (!body.images || body.images.length === 0) {
|
||||
throw createError({ statusCode: 400, message: '분석할 이미지를 업로드해주세요.' })
|
||||
}
|
||||
|
||||
if (body.images.length > 10) {
|
||||
throw createError({ statusCode: 400, message: '이미지는 최대 10장까지 업로드 가능합니다.' })
|
||||
}
|
||||
|
||||
// OpenAI Vision 분석
|
||||
const aiResponse = await callOpenAIVision(REPORT_PARSE_SYSTEM_PROMPT, body.images)
|
||||
|
||||
let parsed: ParsedResult
|
||||
try {
|
||||
parsed = JSON.parse(aiResponse)
|
||||
} catch (e) {
|
||||
throw createError({ statusCode: 500, message: 'AI 응답 파싱 실패' })
|
||||
}
|
||||
|
||||
// 주차 정보 기본값 설정 (AI가 파싱 못한 경우)
|
||||
const now = new Date()
|
||||
if (!parsed.reportYear) {
|
||||
parsed.reportYear = now.getFullYear()
|
||||
}
|
||||
if (!parsed.reportWeek) {
|
||||
// ISO 주차 계산
|
||||
const startOfYear = new Date(now.getFullYear(), 0, 1)
|
||||
const days = Math.floor((now.getTime() - startOfYear.getTime()) / (24 * 60 * 60 * 1000))
|
||||
parsed.reportWeek = Math.ceil((days + startOfYear.getDay() + 1) / 7)
|
||||
}
|
||||
if (!parsed.weekStartDate || !parsed.weekEndDate) {
|
||||
// 현재 주의 월요일~일요일 계산
|
||||
const day = now.getDay()
|
||||
const monday = new Date(now)
|
||||
monday.setDate(now.getDate() - (day === 0 ? 6 : day - 1))
|
||||
const sunday = new Date(monday)
|
||||
sunday.setDate(monday.getDate() + 6)
|
||||
parsed.weekStartDate = monday.toISOString().split('T')[0]
|
||||
parsed.weekEndDate = sunday.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
// 기존 직원 목록 조회
|
||||
const employees = await query<any>(`
|
||||
SELECT employee_id, employee_name, employee_email
|
||||
FROM wr_employee_info
|
||||
WHERE is_active = true
|
||||
`)
|
||||
|
||||
// 기존 프로젝트 목록 조회
|
||||
const projects = await query<any>(`
|
||||
SELECT project_id, project_code, project_name
|
||||
FROM wr_project_info
|
||||
WHERE project_status != 'COMPLETED'
|
||||
`)
|
||||
|
||||
// 직원 및 프로젝트 매칭
|
||||
const matchedReports = parsed.reports.map(report => {
|
||||
let matchedEmployee = null
|
||||
if (report.employeeEmail) {
|
||||
matchedEmployee = employees.find(
|
||||
(e: any) => e.employee_email.toLowerCase() === report.employeeEmail?.toLowerCase()
|
||||
)
|
||||
}
|
||||
if (!matchedEmployee) {
|
||||
matchedEmployee = employees.find(
|
||||
(e: any) => e.employee_name === report.employeeName
|
||||
)
|
||||
}
|
||||
|
||||
const matchedProjects = report.projects.map(proj => {
|
||||
const existingProject = projects.find((p: any) =>
|
||||
p.project_name.includes(proj.projectName) ||
|
||||
proj.projectName.includes(p.project_name)
|
||||
)
|
||||
|
||||
return {
|
||||
...proj,
|
||||
matchedProjectId: existingProject?.project_id || null,
|
||||
matchedProjectCode: existingProject?.project_code || null,
|
||||
matchedProjectName: existingProject?.project_name || null,
|
||||
isNewProject: !existingProject
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
...report,
|
||||
matchedEmployeeId: matchedEmployee?.employee_id || null,
|
||||
matchedEmployeeName: matchedEmployee?.employee_name || null,
|
||||
matchedEmployeeEmail: matchedEmployee?.employee_email || null,
|
||||
isEmployeeMatched: !!matchedEmployee,
|
||||
isNewEmployee: !matchedEmployee && !!report.employeeEmail,
|
||||
projects: matchedProjects
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
parsed: {
|
||||
reportYear: parsed.reportYear,
|
||||
reportWeek: parsed.reportWeek,
|
||||
weekStartDate: parsed.weekStartDate,
|
||||
weekEndDate: parsed.weekEndDate,
|
||||
reports: matchedReports
|
||||
},
|
||||
employees: employees.map((e: any) => ({
|
||||
employeeId: e.employee_id,
|
||||
employeeName: e.employee_name,
|
||||
employeeEmail: e.employee_email
|
||||
})),
|
||||
projects: projects.map((p: any) => ({
|
||||
projectId: p.project_id,
|
||||
projectCode: p.project_code,
|
||||
projectName: p.project_name
|
||||
}))
|
||||
}
|
||||
})
|
||||
@@ -3,10 +3,15 @@ import { callOpenAI, buildParseReportPrompt } from '../../utils/openai'
|
||||
|
||||
const ADMIN_EMAIL = 'coziny@gmail.com'
|
||||
|
||||
interface ParsedTask {
|
||||
description: string
|
||||
hours: number
|
||||
}
|
||||
|
||||
interface ParsedProject {
|
||||
projectName: string
|
||||
workDescription: string | null
|
||||
planDescription: string | null
|
||||
workTasks: ParsedTask[]
|
||||
planTasks: ParsedTask[]
|
||||
}
|
||||
|
||||
interface ParsedReport {
|
||||
@@ -62,6 +67,28 @@ export default defineEventHandler(async (event) => {
|
||||
throw createError({ statusCode: 500, message: 'AI 응답 파싱 실패' })
|
||||
}
|
||||
|
||||
// 주차 정보 기본값 설정 (AI가 파싱 못한 경우)
|
||||
const now = new Date()
|
||||
if (!parsed.reportYear) {
|
||||
parsed.reportYear = now.getFullYear()
|
||||
}
|
||||
if (!parsed.reportWeek) {
|
||||
// ISO 주차 계산
|
||||
const startOfYear = new Date(now.getFullYear(), 0, 1)
|
||||
const days = Math.floor((now.getTime() - startOfYear.getTime()) / (24 * 60 * 60 * 1000))
|
||||
parsed.reportWeek = Math.ceil((days + startOfYear.getDay() + 1) / 7)
|
||||
}
|
||||
if (!parsed.weekStartDate || !parsed.weekEndDate) {
|
||||
// 현재 주의 월요일~일요일 계산
|
||||
const day = now.getDay()
|
||||
const monday = new Date(now)
|
||||
monday.setDate(now.getDate() - (day === 0 ? 6 : day - 1))
|
||||
const sunday = new Date(monday)
|
||||
sunday.setDate(monday.getDate() + 6)
|
||||
parsed.weekStartDate = monday.toISOString().split('T')[0]
|
||||
parsed.weekEndDate = sunday.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
// 기존 직원 목록 조회
|
||||
const employees = await query<any>(`
|
||||
SELECT employee_id, employee_name, employee_email
|
||||
@@ -76,7 +103,7 @@ export default defineEventHandler(async (event) => {
|
||||
WHERE project_status != 'COMPLETED'
|
||||
`)
|
||||
|
||||
// 직원 매칭
|
||||
// 직원 및 프로젝트 매칭
|
||||
const matchedReports = parsed.reports.map(report => {
|
||||
// 이메일로 정확 매칭 시도
|
||||
let matchedEmployee = null
|
||||
@@ -114,6 +141,7 @@ export default defineEventHandler(async (event) => {
|
||||
matchedEmployeeName: matchedEmployee?.employee_name || null,
|
||||
matchedEmployeeEmail: matchedEmployee?.employee_email || null,
|
||||
isEmployeeMatched: !!matchedEmployee,
|
||||
isNewEmployee: !matchedEmployee && !!report.employeeEmail,
|
||||
projects: matchedProjects
|
||||
}
|
||||
})
|
||||
@@ -127,7 +155,6 @@ export default defineEventHandler(async (event) => {
|
||||
weekEndDate: parsed.weekEndDate,
|
||||
reports: matchedReports
|
||||
},
|
||||
// 선택용 목록
|
||||
employees: employees.map((e: any) => ({
|
||||
employeeId: e.employee_id,
|
||||
employeeName: e.employee_name,
|
||||
|
||||
@@ -13,9 +13,14 @@ export default defineEventHandler(async (event) => {
|
||||
// 내가 주간보고를 작성한 프로젝트 + 전체 활성 프로젝트
|
||||
const projects = await query(`
|
||||
SELECT DISTINCT p.*,
|
||||
CASE WHEN r.author_id IS NOT NULL THEN true ELSE false END as has_my_report
|
||||
CASE WHEN t.project_id IS NOT NULL THEN true ELSE false END as has_my_report
|
||||
FROM wr_project_info p
|
||||
LEFT JOIN wr_weekly_report_detail r ON p.project_id = r.project_id AND r.author_id = $1
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT t.project_id
|
||||
FROM wr_weekly_report_task t
|
||||
JOIN wr_weekly_report r ON t.report_id = r.report_id
|
||||
WHERE r.author_id = $1
|
||||
) t ON p.project_id = t.project_id
|
||||
WHERE p.project_status = 'ACTIVE'
|
||||
ORDER BY has_my_report DESC, p.project_name
|
||||
`, [parseInt(userId)])
|
||||
|
||||
167
backend/api/report/review.post.ts
Normal file
167
backend/api/report/review.post.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { defineEventHandler, readBody, createError } from 'h3'
|
||||
import { query } from '../../utils/db'
|
||||
import OpenAI from 'openai'
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY
|
||||
})
|
||||
|
||||
/**
|
||||
* 주간보고 PMO AI 리뷰
|
||||
* POST /api/report/review
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
const { reportId } = body
|
||||
|
||||
if (!reportId) {
|
||||
throw createError({ statusCode: 400, message: 'reportId가 필요합니다.' })
|
||||
}
|
||||
|
||||
// 주간보고 조회
|
||||
const reports = await query(`
|
||||
SELECT
|
||||
r.report_id,
|
||||
r.report_year,
|
||||
r.report_week,
|
||||
e.employee_name as author_name
|
||||
FROM wr_weekly_report r
|
||||
JOIN wr_employee_info e ON r.author_id = e.employee_id
|
||||
WHERE r.report_id = $1
|
||||
`, [reportId])
|
||||
|
||||
if (reports.length === 0) {
|
||||
throw createError({ statusCode: 404, message: '주간보고를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
const report = reports[0]
|
||||
|
||||
// Task 조회
|
||||
const tasks = await query(`
|
||||
SELECT
|
||||
t.task_type,
|
||||
t.task_description,
|
||||
t.task_hours,
|
||||
t.is_completed,
|
||||
p.project_name
|
||||
FROM wr_weekly_report_task t
|
||||
JOIN wr_project_info p ON t.project_id = p.project_id
|
||||
WHERE t.report_id = $1
|
||||
ORDER BY t.task_type, p.project_name
|
||||
`, [reportId])
|
||||
|
||||
if (tasks.length === 0) {
|
||||
throw createError({ statusCode: 400, message: '등록된 Task가 없습니다.' })
|
||||
}
|
||||
|
||||
// Task를 실적/계획으로 분리
|
||||
const workTasks = tasks.filter((t: any) => t.task_type === 'WORK')
|
||||
const planTasks = tasks.filter((t: any) => t.task_type === 'PLAN')
|
||||
|
||||
// 프롬프트용 텍스트 생성
|
||||
let taskText = `[작성자] ${report.author_name}\n[기간] ${report.report_year}년 ${report.report_week}주차\n\n`
|
||||
|
||||
if (workTasks.length > 0) {
|
||||
taskText += `[금주 실적]\n`
|
||||
workTasks.forEach((t: any) => {
|
||||
const status = t.is_completed ? '완료' : '진행중'
|
||||
taskText += `- ${t.project_name} / ${t.task_description} / ${t.task_hours}h / ${status}\n`
|
||||
})
|
||||
taskText += '\n'
|
||||
}
|
||||
|
||||
if (planTasks.length > 0) {
|
||||
taskText += `[차주 계획]\n`
|
||||
planTasks.forEach((t: any) => {
|
||||
taskText += `- ${t.project_name} / ${t.task_description} / ${t.task_hours}h\n`
|
||||
})
|
||||
}
|
||||
|
||||
// OpenAI PMO 리뷰 요청
|
||||
const systemPrompt = `당신은 SI 프로젝트의 PMO(Project Management Officer)이자 주간보고 작성 코치입니다.
|
||||
개발자들이 더 나은 주간보고를 작성할 수 있도록 구체적인 피드백과 가이드를 제공해주세요.
|
||||
|
||||
[주간보고 작성의 목적]
|
||||
- 프로젝트 진행 현황을 명확히 파악
|
||||
- 일정 지연이나 리스크를 사전에 감지
|
||||
- 팀원 간 업무 공유 및 협업 촉진
|
||||
|
||||
[검토 기준 - 엄격하게 적용]
|
||||
|
||||
1. **실적의 구체성** (가장 중요!)
|
||||
- "DB 작업", "화면 개발", "API 개발" 같은 모호한 표현 지양
|
||||
- 좋은 예시: "사용자 관리 테이블 3개(user, role, permission) 설계 및 생성"
|
||||
- 좋은 예시: "로그인 API 개발 - JWT 토큰 발급, 리프레시 토큰 구현"
|
||||
- 좋은 예시: "검색 화면 UI 구현 - 필터 조건 5개, 페이징, 엑셀 다운로드"
|
||||
- 어떤 기능/모듈/화면인지, 무엇을 구체적으로 했는지 명시되어야 함
|
||||
|
||||
2. **일정의 명확성**
|
||||
- "진행중"만 있고 완료 예정일이 없으면 부족
|
||||
- 언제 완료될 예정인지, 진척률은 얼마인지 표기 권장
|
||||
- 좋은 예시: "사용자 관리 화면 개발 (70% 완료, 1/10 완료 예정)"
|
||||
|
||||
3. **시간 산정의 적절성**
|
||||
- 8시간(1일) 이상 작업은 세부 내역이 필요
|
||||
- 16시간(2일) 이상인데 내용이 한 줄이면 분리 필요
|
||||
- "회의", "검토" 등은 별도 기재 권장
|
||||
|
||||
4. **차주 계획의 실현 가능성**
|
||||
- 계획이 너무 추상적이면 실행하기 어려움
|
||||
- 구체적인 목표와 예상 산출물 명시 필요
|
||||
- 좋은 예시: "결제 모듈 연동 - PG사 API 연동, 결제 테스트 완료 목표"
|
||||
|
||||
[피드백 작성 규칙]
|
||||
- 각 Task별로 구체적인 개선 제안 제시
|
||||
- 잘 작성된 부분은 "✅" 로 인정
|
||||
- 보완이 필요한 부분은 "📝" 로 개선 방향 제시
|
||||
- 일정 관련 질문은 "📅" 로 표시
|
||||
- 리스크/우려사항은 "⚠️" 로 경고
|
||||
- **반드시 어떻게 수정하면 좋을지 예시를 들어 설명**
|
||||
- 친절하지만 명확하게, 구체적인 작성 예시를 포함
|
||||
- 마지막에 전체적인 작성 팁 1-2개 추가
|
||||
|
||||
[피드백 톤]
|
||||
- 비난하지 않고 코칭하는 느낌으로
|
||||
- "~하면 더 좋겠습니다", "~로 수정해보시면 어떨까요?" 형태로
|
||||
- 개선점뿐 아니라 잘한 점도 언급`
|
||||
|
||||
const userPrompt = `다음 주간보고를 PMO 관점에서 상세히 리뷰해주세요.
|
||||
특히 실적과 계획이 구체적으로 작성되었는지, 일정이 명확한지 중점적으로 검토해주세요.
|
||||
모호한 표현이 있다면 어떻게 수정하면 좋을지 예시와 함께 피드백해주세요.
|
||||
|
||||
${taskText}`
|
||||
|
||||
try {
|
||||
const response = await openai.chat.completions.create({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt }
|
||||
],
|
||||
max_tokens: 1500,
|
||||
temperature: 0.7
|
||||
})
|
||||
|
||||
const review = response.choices[0]?.message?.content || '리뷰를 생성할 수 없습니다.'
|
||||
const reviewedAt = new Date().toISOString()
|
||||
|
||||
// DB에 저장
|
||||
await query(`
|
||||
UPDATE wr_weekly_report
|
||||
SET ai_review = $1, ai_review_at = $2
|
||||
WHERE report_id = $3
|
||||
`, [review, reviewedAt, reportId])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
review,
|
||||
reviewedAt
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('OpenAI API error:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: 'AI 리뷰 생성 중 오류가 발생했습니다: ' + error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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: '요약 생성 실패'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
42
backend/api/report/summary/available-projects.get.ts
Normal file
42
backend/api/report/summary/available-projects.get.ts
Normal 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)
|
||||
}))
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
152
backend/api/report/summary/regenerate-ai.post.ts
Normal file
152
backend/api/report/summary/regenerate-ai.post.ts
Normal 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 || '요약 없음'
|
||||
}
|
||||
}
|
||||
171
backend/api/report/summary/week/detail.get.ts
Normal file
171
backend/api/report/summary/week/detail.get.ts
Normal 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
|
||||
}
|
||||
})
|
||||
44
backend/api/report/summary/weekly-list.get.ts
Normal file
44
backend/api/report/summary/weekly-list.get.ts
Normal 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 || []
|
||||
}))
|
||||
}
|
||||
})
|
||||
50
backend/api/report/weekly/[id]/delete.delete.ts
Normal file
50
backend/api/report/weekly/[id]/delete.delete.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { query, execute } from '../../../../utils/db'
|
||||
|
||||
const ADMIN_EMAIL = 'coziny@gmail.com'
|
||||
|
||||
/**
|
||||
* 주간보고 삭제
|
||||
* DELETE /api/report/weekly/[id]/delete
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = getCookie(event, 'user_id')
|
||||
if (!userId) {
|
||||
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
||||
}
|
||||
|
||||
const reportId = getRouterParam(event, 'id')
|
||||
if (!reportId) {
|
||||
throw createError({ statusCode: 400, message: '보고서 ID가 필요합니다.' })
|
||||
}
|
||||
|
||||
// 현재 사용자 정보 조회
|
||||
const currentUser = await query<any>(`
|
||||
SELECT employee_email FROM wr_employee_info WHERE employee_id = $1
|
||||
`, [userId])
|
||||
const isAdmin = currentUser[0]?.employee_email === ADMIN_EMAIL
|
||||
|
||||
// 보고서 정보 조회
|
||||
const report = await query<any>(`
|
||||
SELECT report_id, author_id FROM wr_weekly_report WHERE report_id = $1
|
||||
`, [reportId])
|
||||
|
||||
if (!report[0]) {
|
||||
throw createError({ statusCode: 404, message: '보고서를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 권한 체크: 본인 또는 관리자만 삭제 가능
|
||||
if (report[0].author_id !== parseInt(userId) && !isAdmin) {
|
||||
throw createError({ statusCode: 403, message: '삭제 권한이 없습니다.' })
|
||||
}
|
||||
|
||||
// 프로젝트 실적 먼저 삭제
|
||||
await execute(`DELETE FROM wr_weekly_report_project WHERE report_id = $1`, [reportId])
|
||||
|
||||
// 주간보고 삭제
|
||||
await execute(`DELETE FROM wr_weekly_report WHERE report_id = $1`, [reportId])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '주간보고가 삭제되었습니다.'
|
||||
}
|
||||
})
|
||||
@@ -27,21 +27,71 @@ export default defineEventHandler(async (event) => {
|
||||
throw createError({ statusCode: 404, message: '보고서를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 프로젝트별 실적 조회
|
||||
const projects = await query<any>(`
|
||||
// 같은 주차의 이전/다음 보고서 조회
|
||||
const prevReport = await queryOne<any>(`
|
||||
SELECT r.report_id, e.employee_name
|
||||
FROM wr_weekly_report r
|
||||
JOIN wr_employee_info e ON r.author_id = e.employee_id
|
||||
WHERE r.report_year = $1 AND r.report_week = $2 AND r.report_id < $3
|
||||
ORDER BY r.report_id DESC
|
||||
LIMIT 1
|
||||
`, [report.report_year, report.report_week, reportId])
|
||||
|
||||
const nextReport = await queryOne<any>(`
|
||||
SELECT r.report_id, e.employee_name
|
||||
FROM wr_weekly_report r
|
||||
JOIN wr_employee_info e ON r.author_id = e.employee_id
|
||||
WHERE r.report_year = $1 AND r.report_week = $2 AND r.report_id > $3
|
||||
ORDER BY r.report_id ASC
|
||||
LIMIT 1
|
||||
`, [report.report_year, report.report_week, reportId])
|
||||
|
||||
// Task 조회
|
||||
const tasks = await query<any>(`
|
||||
SELECT
|
||||
rp.detail_id,
|
||||
rp.project_id,
|
||||
t.task_id,
|
||||
t.project_id,
|
||||
p.project_code,
|
||||
p.project_name,
|
||||
rp.work_description,
|
||||
rp.plan_description
|
||||
FROM wr_weekly_report_project rp
|
||||
JOIN wr_project_info p ON rp.project_id = p.project_id
|
||||
WHERE rp.report_id = $1
|
||||
ORDER BY rp.detail_id
|
||||
t.task_type,
|
||||
t.task_description,
|
||||
t.task_hours,
|
||||
t.is_completed
|
||||
FROM wr_weekly_report_task t
|
||||
JOIN wr_project_info p ON t.project_id = p.project_id
|
||||
WHERE t.report_id = $1
|
||||
ORDER BY t.project_id, t.task_type, t.task_id
|
||||
`, [reportId])
|
||||
|
||||
// 프로젝트별로 그룹핑
|
||||
const projectMap = new Map<number, any>()
|
||||
|
||||
for (const task of tasks) {
|
||||
if (!projectMap.has(task.project_id)) {
|
||||
projectMap.set(task.project_id, {
|
||||
projectId: task.project_id,
|
||||
projectCode: task.project_code,
|
||||
projectName: task.project_name,
|
||||
workTasks: [],
|
||||
planTasks: []
|
||||
})
|
||||
}
|
||||
|
||||
const proj = projectMap.get(task.project_id)
|
||||
const taskItem = {
|
||||
taskId: task.task_id,
|
||||
description: task.task_description,
|
||||
hours: parseFloat(task.task_hours) || 0,
|
||||
isCompleted: task.is_completed
|
||||
}
|
||||
|
||||
if (task.task_type === 'WORK') {
|
||||
proj.workTasks.push(taskItem)
|
||||
} else {
|
||||
proj.planTasks.push(taskItem)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
report: {
|
||||
reportId: report.report_id,
|
||||
@@ -50,23 +100,39 @@ export default defineEventHandler(async (event) => {
|
||||
authorEmail: report.author_email,
|
||||
reportYear: report.report_year,
|
||||
reportWeek: report.report_week,
|
||||
weekStartDate: report.week_start_date,
|
||||
weekEndDate: report.week_end_date,
|
||||
weekStartDate: formatDateOnly(report.week_start_date),
|
||||
weekEndDate: formatDateOnly(report.week_end_date),
|
||||
issueDescription: report.issue_description,
|
||||
vacationDescription: report.vacation_description,
|
||||
remarkDescription: report.remark_description,
|
||||
reportStatus: report.report_status,
|
||||
submittedAt: report.submitted_at,
|
||||
createdAt: report.created_at,
|
||||
updatedAt: report.updated_at
|
||||
updatedAt: report.updated_at,
|
||||
aiReview: report.ai_review,
|
||||
aiReviewAt: report.ai_review_at
|
||||
},
|
||||
projects: projects.map((p: any) => ({
|
||||
detailId: p.detail_id,
|
||||
projectId: p.project_id,
|
||||
projectCode: p.project_code,
|
||||
projectName: p.project_name,
|
||||
workDescription: p.work_description,
|
||||
planDescription: p.plan_description
|
||||
prevReport: prevReport ? { reportId: prevReport.report_id, authorName: prevReport.employee_name } : null,
|
||||
nextReport: nextReport ? { reportId: nextReport.report_id, authorName: nextReport.employee_name } : null,
|
||||
projects: Array.from(projectMap.values()),
|
||||
tasks: tasks.map((t: any) => ({
|
||||
taskId: t.task_id,
|
||||
projectId: t.project_id,
|
||||
projectCode: t.project_code,
|
||||
projectName: t.project_name,
|
||||
taskType: t.task_type,
|
||||
taskDescription: t.task_description,
|
||||
taskHours: parseFloat(t.task_hours) || 0,
|
||||
isCompleted: t.is_completed
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
// 날짜를 YYYY-MM-DD 형식으로 변환 (타임존 보정)
|
||||
function formatDateOnly(date: Date | string | null): string {
|
||||
if (!date) return ''
|
||||
const d = new Date(date)
|
||||
const kstOffset = 9 * 60 * 60 * 1000
|
||||
const kstDate = new Date(d.getTime() + kstOffset)
|
||||
return kstDate.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
@@ -1,19 +1,6 @@
|
||||
import { execute, query, queryOne } from '../../../../utils/db'
|
||||
import { getClientIp } from '../../../../utils/ip'
|
||||
import { getCurrentUserEmail } from '../../../../utils/user'
|
||||
import { query, execute, queryOne } from '../../../../utils/db'
|
||||
|
||||
interface ProjectItem {
|
||||
projectId: number
|
||||
workDescription?: string
|
||||
planDescription?: string
|
||||
}
|
||||
|
||||
interface UpdateReportBody {
|
||||
projects?: ProjectItem[]
|
||||
issueDescription?: string
|
||||
vacationDescription?: string
|
||||
remarkDescription?: string
|
||||
}
|
||||
const ADMIN_EMAIL = 'coziny@gmail.com'
|
||||
|
||||
/**
|
||||
* 주간보고 수정
|
||||
@@ -26,67 +13,94 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
const reportId = getRouterParam(event, 'id')
|
||||
const body = await readBody<UpdateReportBody>(event)
|
||||
const clientIp = getClientIp(event)
|
||||
const userEmail = await getCurrentUserEmail(event)
|
||||
const clientIp = getHeader(event, 'x-forwarded-for') || 'unknown'
|
||||
const user = await queryOne<any>(`SELECT employee_email FROM wr_employee_info WHERE employee_id = $1`, [userId])
|
||||
const userEmail = user?.employee_email || ''
|
||||
const isAdmin = userEmail === ADMIN_EMAIL
|
||||
|
||||
// 보고서 조회 및 권한 확인
|
||||
// 보고서 조회 및 권한 체크
|
||||
const report = await queryOne<any>(`
|
||||
SELECT * FROM wr_weekly_report WHERE report_id = $1
|
||||
SELECT report_id, author_id, report_status FROM wr_weekly_report WHERE report_id = $1
|
||||
`, [reportId])
|
||||
|
||||
if (!report) {
|
||||
throw createError({ statusCode: 404, message: '보고서를 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
if (report.author_id !== parseInt(userId)) {
|
||||
// 관리자가 아니면 본인 보고서만 수정 가능
|
||||
if (!isAdmin && report.author_id !== parseInt(userId)) {
|
||||
throw createError({ statusCode: 403, message: '본인의 보고서만 수정할 수 있습니다.' })
|
||||
}
|
||||
|
||||
if (report.report_status === 'SUBMITTED' || report.report_status === 'AGGREGATED') {
|
||||
throw createError({ statusCode: 400, message: '제출된 보고서는 수정할 수 없습니다.' })
|
||||
// 취합완료된 보고서는 수정 불가 (관리자도)
|
||||
if (report.report_status === 'AGGREGATED') {
|
||||
throw createError({ statusCode: 400, message: '취합완료된 보고서는 수정할 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 마스터 업데이트
|
||||
const body = await readBody<{
|
||||
reportYear?: number
|
||||
reportWeek?: number
|
||||
weekStartDate?: string
|
||||
weekEndDate?: string
|
||||
tasks: {
|
||||
projectId: number
|
||||
taskType: 'WORK' | 'PLAN'
|
||||
taskDescription: string
|
||||
taskHours: number
|
||||
isCompleted?: boolean
|
||||
}[]
|
||||
issueDescription?: string
|
||||
vacationDescription?: string
|
||||
remarkDescription?: string
|
||||
}>(event)
|
||||
|
||||
if (!body.tasks || body.tasks.length === 0) {
|
||||
throw createError({ statusCode: 400, message: '최소 1개 이상의 Task가 필요합니다.' })
|
||||
}
|
||||
|
||||
// 마스터 수정
|
||||
await execute(`
|
||||
UPDATE wr_weekly_report SET
|
||||
issue_description = $1,
|
||||
vacation_description = $2,
|
||||
remark_description = $3,
|
||||
report_year = COALESCE($1, report_year),
|
||||
report_week = COALESCE($2, report_week),
|
||||
week_start_date = COALESCE($3, week_start_date),
|
||||
week_end_date = COALESCE($4, week_end_date),
|
||||
issue_description = $5,
|
||||
vacation_description = $6,
|
||||
remark_description = $7,
|
||||
updated_at = NOW(),
|
||||
updated_ip = $4,
|
||||
updated_email = $5
|
||||
WHERE report_id = $6
|
||||
updated_ip = $8,
|
||||
updated_email = $9
|
||||
WHERE report_id = $10
|
||||
`, [
|
||||
body.issueDescription ?? report.issue_description,
|
||||
body.vacationDescription ?? report.vacation_description,
|
||||
body.remarkDescription ?? report.remark_description,
|
||||
clientIp,
|
||||
userEmail,
|
||||
reportId
|
||||
body.reportYear || null,
|
||||
body.reportWeek || null,
|
||||
body.weekStartDate || null,
|
||||
body.weekEndDate || null,
|
||||
body.issueDescription || null,
|
||||
body.vacationDescription || null,
|
||||
body.remarkDescription || null,
|
||||
clientIp, userEmail, reportId
|
||||
])
|
||||
|
||||
// 프로젝트별 실적 업데이트
|
||||
if (body.projects && body.projects.length > 0) {
|
||||
// 기존 삭제 후 재등록
|
||||
await execute(`DELETE FROM wr_weekly_report_project WHERE report_id = $1`, [reportId])
|
||||
|
||||
for (const proj of body.projects) {
|
||||
await execute(`
|
||||
INSERT INTO wr_weekly_report_project (
|
||||
report_id, project_id, work_description, plan_description,
|
||||
created_ip, created_email, updated_ip, updated_email
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $5, $6)
|
||||
`, [
|
||||
reportId,
|
||||
proj.projectId,
|
||||
proj.workDescription || null,
|
||||
proj.planDescription || null,
|
||||
clientIp,
|
||||
userEmail
|
||||
])
|
||||
}
|
||||
// 기존 Task 삭제 후 재등록
|
||||
await execute(`DELETE FROM wr_weekly_report_task WHERE report_id = $1`, [reportId])
|
||||
|
||||
for (const task of body.tasks) {
|
||||
await execute(`
|
||||
INSERT INTO wr_weekly_report_task (
|
||||
report_id, project_id, task_type, task_description, task_hours, is_completed,
|
||||
created_ip, created_email, updated_ip, updated_email
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $7, $8)
|
||||
`, [
|
||||
reportId, task.projectId, task.taskType, task.taskDescription, task.taskHours || 0,
|
||||
task.taskType === 'WORK' ? (task.isCompleted !== false) : null,
|
||||
clientIp, userEmail
|
||||
])
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
return {
|
||||
success: true,
|
||||
message: '주간보고가 수정되었습니다.'
|
||||
}
|
||||
})
|
||||
|
||||
131
backend/api/report/weekly/aggregate.get.ts
Normal file
131
backend/api/report/weekly/aggregate.get.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { defineEventHandler, getQuery, createError } from 'h3'
|
||||
import { query } from '../../../utils/db'
|
||||
|
||||
const ADMIN_EMAIL = 'admin@turbosoft.co.kr'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userEmail = event.node.req.headers['x-user-email'] as string
|
||||
|
||||
if (!userEmail) {
|
||||
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
||||
}
|
||||
|
||||
// 관리자만 취합 가능
|
||||
if (userEmail !== ADMIN_EMAIL) {
|
||||
throw createError({ statusCode: 403, message: '관리자만 취합할 수 있습니다.' })
|
||||
}
|
||||
|
||||
const { year, week, projectIds } = getQuery(event)
|
||||
|
||||
if (!year || !week) {
|
||||
throw createError({ statusCode: 400, message: '연도와 주차를 지정해주세요.' })
|
||||
}
|
||||
|
||||
// 프로젝트 ID 파싱
|
||||
let projectIdList: number[] = []
|
||||
if (projectIds) {
|
||||
projectIdList = String(projectIds).split(',').map(Number).filter(n => !isNaN(n))
|
||||
}
|
||||
|
||||
// 해당 주차의 모든 프로젝트별 Task 조회
|
||||
let projectFilter = ''
|
||||
const params: any[] = [Number(year), Number(week)]
|
||||
|
||||
if (projectIdList.length > 0) {
|
||||
projectFilter = `AND t.project_id = ANY($3)`
|
||||
params.push(projectIdList)
|
||||
}
|
||||
|
||||
const tasks = await query(`
|
||||
SELECT
|
||||
t.task_id,
|
||||
t.project_id,
|
||||
p.project_code,
|
||||
p.project_name,
|
||||
t.task_type,
|
||||
t.task_description,
|
||||
t.task_hours,
|
||||
t.is_completed,
|
||||
r.report_id,
|
||||
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_project_info p ON t.project_id = p.project_id
|
||||
JOIN wr_employee_info e ON r.author_id = e.employee_id
|
||||
WHERE r.report_year = $1 AND r.report_week = $2
|
||||
${projectFilter}
|
||||
ORDER BY p.project_name, t.task_type, e.employee_name
|
||||
`, params)
|
||||
|
||||
// 프로젝트별로 그룹핑
|
||||
const projectMap = new Map<number, {
|
||||
projectId: number
|
||||
projectCode: string
|
||||
projectName: string
|
||||
workTasks: any[]
|
||||
planTasks: any[]
|
||||
totalWorkHours: number
|
||||
totalPlanHours: number
|
||||
}>()
|
||||
|
||||
for (const task of tasks) {
|
||||
if (!projectMap.has(task.project_id)) {
|
||||
projectMap.set(task.project_id, {
|
||||
projectId: task.project_id,
|
||||
projectCode: task.project_code,
|
||||
projectName: task.project_name,
|
||||
workTasks: [],
|
||||
planTasks: [],
|
||||
totalWorkHours: 0,
|
||||
totalPlanHours: 0
|
||||
})
|
||||
}
|
||||
|
||||
const proj = projectMap.get(task.project_id)!
|
||||
const taskItem = {
|
||||
taskId: task.task_id,
|
||||
description: task.task_description,
|
||||
hours: parseFloat(task.task_hours) || 0,
|
||||
isCompleted: task.is_completed,
|
||||
authorName: task.author_name
|
||||
}
|
||||
|
||||
if (task.task_type === 'WORK') {
|
||||
proj.workTasks.push(taskItem)
|
||||
proj.totalWorkHours += taskItem.hours
|
||||
} else {
|
||||
proj.planTasks.push(taskItem)
|
||||
proj.totalPlanHours += taskItem.hours
|
||||
}
|
||||
}
|
||||
|
||||
// 해당 주차에 보고서가 있는 모든 프로젝트 목록
|
||||
const allProjects = await query(`
|
||||
SELECT DISTINCT p.project_id, p.project_code, p.project_name
|
||||
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
|
||||
ORDER BY p.project_name
|
||||
`, [Number(year), Number(week)])
|
||||
|
||||
// 해당 주차 보고서 수
|
||||
const reportCount = await query(`
|
||||
SELECT COUNT(DISTINCT report_id) as cnt
|
||||
FROM wr_weekly_report
|
||||
WHERE report_year = $1 AND report_week = $2
|
||||
`, [Number(year), Number(week)])
|
||||
|
||||
return {
|
||||
year: Number(year),
|
||||
week: Number(week),
|
||||
reportCount: parseInt(reportCount[0]?.cnt || '0'),
|
||||
availableProjects: allProjects.map((p: any) => ({
|
||||
projectId: p.project_id,
|
||||
projectCode: p.project_code,
|
||||
projectName: p.project_name
|
||||
})),
|
||||
projects: Array.from(projectMap.values())
|
||||
}
|
||||
})
|
||||
@@ -1,22 +1,4 @@
|
||||
import { query, insertReturning, execute } from '../../../utils/db'
|
||||
import { getWeekInfo } from '../../../utils/week-calc'
|
||||
import { getClientIp } from '../../../utils/ip'
|
||||
import { getCurrentUserEmail } from '../../../utils/user'
|
||||
|
||||
interface ProjectItem {
|
||||
projectId: number
|
||||
workDescription?: string
|
||||
planDescription?: string
|
||||
}
|
||||
|
||||
interface CreateReportBody {
|
||||
reportYear?: number
|
||||
reportWeek?: number
|
||||
projects: ProjectItem[]
|
||||
issueDescription?: string
|
||||
vacationDescription?: string
|
||||
remarkDescription?: string
|
||||
}
|
||||
import { query, execute, queryOne } from '../../../utils/db'
|
||||
|
||||
/**
|
||||
* 주간보고 작성
|
||||
@@ -28,73 +10,79 @@ export default defineEventHandler(async (event) => {
|
||||
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
||||
}
|
||||
|
||||
const body = await readBody<CreateReportBody>(event)
|
||||
const clientIp = getClientIp(event)
|
||||
const userEmail = await getCurrentUserEmail(event)
|
||||
const clientIp = getHeader(event, 'x-forwarded-for') || 'unknown'
|
||||
const user = await queryOne<any>(`SELECT employee_email FROM wr_employee_info WHERE employee_id = $1`, [userId])
|
||||
const userEmail = user?.employee_email || ''
|
||||
|
||||
if (!body.projects || body.projects.length === 0) {
|
||||
throw createError({ statusCode: 400, message: '최소 1개 이상의 프로젝트를 추가해주세요.' })
|
||||
const body = await readBody<{
|
||||
reportYear: number
|
||||
reportWeek: number
|
||||
weekStartDate: string
|
||||
weekEndDate: string
|
||||
tasks: {
|
||||
projectId: number
|
||||
taskType: 'WORK' | 'PLAN'
|
||||
taskDescription: string
|
||||
taskHours: number
|
||||
isCompleted?: boolean
|
||||
}[]
|
||||
issueDescription?: string
|
||||
vacationDescription?: string
|
||||
remarkDescription?: string
|
||||
}>(event)
|
||||
|
||||
// 필수값 체크
|
||||
if (!body.reportYear || !body.reportWeek || !body.weekStartDate || !body.weekEndDate) {
|
||||
throw createError({ statusCode: 400, message: '주차 정보가 필요합니다.' })
|
||||
}
|
||||
|
||||
// 주차 정보 (기본값: 이번 주)
|
||||
const weekInfo = getWeekInfo()
|
||||
const year = body.reportYear || weekInfo.year
|
||||
const week = body.reportWeek || weekInfo.week
|
||||
if (!body.tasks || body.tasks.length === 0) {
|
||||
throw createError({ statusCode: 400, message: '최소 1개 이상의 Task가 필요합니다.' })
|
||||
}
|
||||
|
||||
// 중복 체크
|
||||
const existing = await query(`
|
||||
SELECT report_id FROM wr_weekly_report
|
||||
const existing = await queryOne<any>(`
|
||||
SELECT report_id FROM wr_weekly_report
|
||||
WHERE author_id = $1 AND report_year = $2 AND report_week = $3
|
||||
`, [parseInt(userId), year, week])
|
||||
`, [userId, body.reportYear, body.reportWeek])
|
||||
|
||||
if (existing.length > 0) {
|
||||
throw createError({ statusCode: 409, message: '이미 해당 주차 보고서가 존재합니다.' })
|
||||
if (existing) {
|
||||
throw createError({ statusCode: 409, message: '해당 주차에 이미 작성된 보고서가 있습니다.' })
|
||||
}
|
||||
|
||||
// 주차 날짜 계산
|
||||
const dates = getWeekInfo(new Date(year, 0, 4 + (week - 1) * 7))
|
||||
|
||||
// 마스터 생성
|
||||
const report = await insertReturning(`
|
||||
// 마스터 등록
|
||||
const result = await queryOne<any>(`
|
||||
INSERT INTO wr_weekly_report (
|
||||
author_id, report_year, report_week,
|
||||
week_start_date, week_end_date,
|
||||
author_id, report_year, report_week, week_start_date, week_end_date,
|
||||
issue_description, vacation_description, remark_description,
|
||||
report_status, created_ip, created_email, updated_ip, updated_email
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'DRAFT', $9, $10, $9, $10)
|
||||
RETURNING *
|
||||
RETURNING report_id
|
||||
`, [
|
||||
parseInt(userId),
|
||||
year,
|
||||
week,
|
||||
dates.startDateStr,
|
||||
dates.endDateStr,
|
||||
body.issueDescription || null,
|
||||
body.vacationDescription || null,
|
||||
body.remarkDescription || null,
|
||||
clientIp,
|
||||
userEmail
|
||||
userId, body.reportYear, body.reportWeek, body.weekStartDate, body.weekEndDate,
|
||||
body.issueDescription || null, body.vacationDescription || null, body.remarkDescription || null,
|
||||
clientIp, userEmail
|
||||
])
|
||||
|
||||
// 프로젝트별 실적 저장
|
||||
for (const proj of body.projects) {
|
||||
const reportId = result.report_id
|
||||
|
||||
// Task 등록
|
||||
for (const task of body.tasks) {
|
||||
await execute(`
|
||||
INSERT INTO wr_weekly_report_project (
|
||||
report_id, project_id, work_description, plan_description,
|
||||
INSERT INTO wr_weekly_report_task (
|
||||
report_id, project_id, task_type, task_description, task_hours, is_completed,
|
||||
created_ip, created_email, updated_ip, updated_email
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $5, $6)
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $7, $8)
|
||||
`, [
|
||||
report.report_id,
|
||||
proj.projectId,
|
||||
proj.workDescription || null,
|
||||
proj.planDescription || null,
|
||||
clientIp,
|
||||
userEmail
|
||||
reportId, task.projectId, task.taskType, task.taskDescription, task.taskHours || 0,
|
||||
task.taskType === 'WORK' ? (task.isCompleted !== false) : null,
|
||||
clientIp, userEmail
|
||||
])
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
reportId: report.report_id
|
||||
reportId,
|
||||
message: '주간보고가 작성되었습니다.'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import { query } from '../../../utils/db'
|
||||
|
||||
const ADMIN_EMAIL = 'coziny@gmail.com'
|
||||
|
||||
/**
|
||||
* 주간보고 목록 조회
|
||||
* 주간보고 목록 조회 (필터링 지원)
|
||||
* GET /api/report/weekly/list
|
||||
*
|
||||
* Query params:
|
||||
* - authorId: 작성자 ID
|
||||
* - projectId: 프로젝트 ID
|
||||
* - year: 연도
|
||||
* - weekFrom: 시작 주차
|
||||
* - weekTo: 종료 주차
|
||||
* - startDate: 시작일 (YYYY-MM-DD)
|
||||
* - endDate: 종료일 (YYYY-MM-DD)
|
||||
* - status: 상태 (DRAFT, SUBMITTED, AGGREGATED)
|
||||
* - viewAll: 전체 조회 (관리자만)
|
||||
* - limit: 조회 개수 (기본 100)
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = getCookie(event, 'user_id')
|
||||
@@ -10,14 +24,89 @@ export default defineEventHandler(async (event) => {
|
||||
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
||||
}
|
||||
|
||||
const queryParams = getQuery(event)
|
||||
const limit = parseInt(queryParams.limit as string) || 20
|
||||
// 현재 사용자 정보 조회 (관리자 여부 확인)
|
||||
const currentUser = await query<any>(`
|
||||
SELECT employee_email FROM wr_employee_info WHERE employee_id = $1
|
||||
`, [userId])
|
||||
const isAdmin = currentUser[0]?.employee_email === ADMIN_EMAIL
|
||||
|
||||
const q = getQuery(event)
|
||||
const limit = parseInt(q.limit as string) || 100
|
||||
const viewAll = q.viewAll === 'true'
|
||||
|
||||
// 필터 조건 구성
|
||||
const conditions: string[] = []
|
||||
const params: any[] = []
|
||||
let paramIndex = 1
|
||||
|
||||
// 관리자가 viewAll이면 전체 조회, 아니면 본인 것만
|
||||
if (!isAdmin || !viewAll) {
|
||||
// 작성자 필터 (본인 또는 지정된 작성자)
|
||||
if (q.authorId) {
|
||||
conditions.push(`r.author_id = $${paramIndex++}`)
|
||||
params.push(q.authorId)
|
||||
} else if (!isAdmin) {
|
||||
// 관리자가 아니면 본인 것만
|
||||
conditions.push(`r.author_id = $${paramIndex++}`)
|
||||
params.push(userId)
|
||||
}
|
||||
} else if (q.authorId) {
|
||||
// 관리자가 viewAll이어도 작성자 필터가 있으면 적용
|
||||
conditions.push(`r.author_id = $${paramIndex++}`)
|
||||
params.push(q.authorId)
|
||||
}
|
||||
|
||||
// 프로젝트 필터
|
||||
if (q.projectId) {
|
||||
conditions.push(`EXISTS (
|
||||
SELECT 1 FROM wr_weekly_report_project wrp
|
||||
WHERE wrp.report_id = r.report_id AND wrp.project_id = $${paramIndex++}
|
||||
)`)
|
||||
params.push(q.projectId)
|
||||
}
|
||||
|
||||
// 연도 필터
|
||||
if (q.year) {
|
||||
conditions.push(`r.report_year = $${paramIndex++}`)
|
||||
params.push(q.year)
|
||||
}
|
||||
|
||||
// 주차 범위 필터
|
||||
if (q.weekFrom) {
|
||||
conditions.push(`r.report_week >= $${paramIndex++}`)
|
||||
params.push(q.weekFrom)
|
||||
}
|
||||
if (q.weekTo) {
|
||||
conditions.push(`r.report_week <= $${paramIndex++}`)
|
||||
params.push(q.weekTo)
|
||||
}
|
||||
|
||||
// 날짜 범위 필터
|
||||
if (q.startDate) {
|
||||
conditions.push(`r.week_start_date >= $${paramIndex++}`)
|
||||
params.push(q.startDate)
|
||||
}
|
||||
if (q.endDate) {
|
||||
conditions.push(`r.week_end_date <= $${paramIndex++}`)
|
||||
params.push(q.endDate)
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (q.status) {
|
||||
conditions.push(`r.report_status = $${paramIndex++}`)
|
||||
params.push(q.status)
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||
|
||||
params.push(limit)
|
||||
|
||||
const reports = await query<any>(`
|
||||
SELECT
|
||||
r.report_id,
|
||||
r.author_id,
|
||||
e.employee_name as author_name,
|
||||
e.employee_email as author_email,
|
||||
r.report_year,
|
||||
r.report_week,
|
||||
r.week_start_date,
|
||||
@@ -27,29 +116,50 @@ export default defineEventHandler(async (event) => {
|
||||
r.report_status,
|
||||
r.submitted_at,
|
||||
r.created_at,
|
||||
(SELECT COUNT(*) FROM wr_weekly_report_project WHERE report_id = r.report_id) as project_count
|
||||
(SELECT COUNT(DISTINCT project_id) FROM wr_weekly_report_task WHERE report_id = r.report_id) as project_count,
|
||||
(SELECT string_agg(DISTINCT p.project_name, ', ')
|
||||
FROM wr_weekly_report_task t
|
||||
JOIN wr_project_info p ON t.project_id = p.project_id
|
||||
WHERE t.report_id = r.report_id) as project_names,
|
||||
(SELECT COALESCE(SUM(task_hours), 0) FROM wr_weekly_report_task WHERE report_id = r.report_id AND task_type = 'WORK') as total_work_hours,
|
||||
(SELECT COALESCE(SUM(task_hours), 0) FROM wr_weekly_report_task WHERE report_id = r.report_id AND task_type = 'PLAN') as total_plan_hours
|
||||
FROM wr_weekly_report r
|
||||
JOIN wr_employee_info e ON r.author_id = e.employee_id
|
||||
WHERE r.author_id = $1
|
||||
ORDER BY r.report_year DESC, r.report_week DESC
|
||||
LIMIT $2
|
||||
`, [userId, limit])
|
||||
${whereClause}
|
||||
ORDER BY r.report_year DESC, r.report_week DESC, e.employee_name
|
||||
LIMIT $${paramIndex}
|
||||
`, params)
|
||||
|
||||
return {
|
||||
isAdmin,
|
||||
reports: reports.map((r: any) => ({
|
||||
reportId: r.report_id,
|
||||
authorId: r.author_id,
|
||||
authorName: r.author_name,
|
||||
authorEmail: r.author_email,
|
||||
reportYear: r.report_year,
|
||||
reportWeek: r.report_week,
|
||||
weekStartDate: r.week_start_date,
|
||||
weekEndDate: r.week_end_date,
|
||||
weekStartDate: formatDateOnly(r.week_start_date),
|
||||
weekEndDate: formatDateOnly(r.week_end_date),
|
||||
issueDescription: r.issue_description,
|
||||
vacationDescription: r.vacation_description,
|
||||
reportStatus: r.report_status,
|
||||
submittedAt: r.submitted_at,
|
||||
createdAt: r.created_at,
|
||||
projectCount: parseInt(r.project_count)
|
||||
projectCount: parseInt(r.project_count),
|
||||
projectNames: r.project_names,
|
||||
totalWorkHours: parseFloat(r.total_work_hours) || 0,
|
||||
totalPlanHours: parseFloat(r.total_plan_hours) || 0
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
// 날짜를 YYYY-MM-DD 형식으로 변환 (타임존 보정)
|
||||
function formatDateOnly(date: Date | string | null): string {
|
||||
if (!date) return ''
|
||||
const d = new Date(date)
|
||||
// 한국 시간 기준으로 날짜만 추출
|
||||
const kstOffset = 9 * 60 * 60 * 1000
|
||||
const kstDate = new Date(d.getTime() + kstOffset)
|
||||
return kstDate.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
@@ -4,9 +4,47 @@
|
||||
|
||||
const OPENAI_API_URL = 'https://api.openai.com/v1/chat/completions'
|
||||
|
||||
// 모델별 파라미터 설정
|
||||
const MODEL_CONFIG: Record<string, { maxTokensParam: string; defaultMaxTokens: number }> = {
|
||||
// 최신 모델 (max_completion_tokens 사용)
|
||||
'gpt-5.1': { maxTokensParam: 'max_completion_tokens', defaultMaxTokens: 4096 },
|
||||
'gpt-5': { maxTokensParam: 'max_completion_tokens', defaultMaxTokens: 4096 },
|
||||
'gpt-4.1': { maxTokensParam: 'max_completion_tokens', defaultMaxTokens: 4096 },
|
||||
'gpt-4.1-mini': { maxTokensParam: 'max_completion_tokens', defaultMaxTokens: 4096 },
|
||||
'gpt-4.1-nano': { maxTokensParam: 'max_completion_tokens', defaultMaxTokens: 4096 },
|
||||
'o1': { maxTokensParam: 'max_completion_tokens', defaultMaxTokens: 4096 },
|
||||
'o1-mini': { maxTokensParam: 'max_completion_tokens', defaultMaxTokens: 4096 },
|
||||
'o1-pro': { maxTokensParam: 'max_completion_tokens', defaultMaxTokens: 4096 },
|
||||
'o3-mini': { maxTokensParam: 'max_completion_tokens', defaultMaxTokens: 4096 },
|
||||
|
||||
// 이전 모델 (max_tokens 사용)
|
||||
'gpt-4o': { maxTokensParam: 'max_tokens', defaultMaxTokens: 4096 },
|
||||
'gpt-4o-mini': { maxTokensParam: 'max_tokens', defaultMaxTokens: 4096 },
|
||||
'gpt-4-turbo': { maxTokensParam: 'max_tokens', defaultMaxTokens: 4096 },
|
||||
'gpt-4': { maxTokensParam: 'max_tokens', defaultMaxTokens: 4096 },
|
||||
'gpt-3.5-turbo': { maxTokensParam: 'max_tokens', defaultMaxTokens: 4096 },
|
||||
}
|
||||
|
||||
// 기본 모델 설정
|
||||
const DEFAULT_MODEL = 'gpt-5.1'
|
||||
|
||||
function getModelConfig(model: string) {
|
||||
if (MODEL_CONFIG[model]) {
|
||||
return MODEL_CONFIG[model]
|
||||
}
|
||||
|
||||
for (const key of Object.keys(MODEL_CONFIG)) {
|
||||
if (model.startsWith(key)) {
|
||||
return MODEL_CONFIG[key]
|
||||
}
|
||||
}
|
||||
|
||||
return { maxTokensParam: 'max_completion_tokens', defaultMaxTokens: 4096 }
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
role: 'system' | 'user' | 'assistant'
|
||||
content: string
|
||||
content: string | Array<{ type: string; text?: string; image_url?: { url: string } }>
|
||||
}
|
||||
|
||||
interface OpenAIResponse {
|
||||
@@ -17,25 +55,37 @@ interface OpenAIResponse {
|
||||
}[]
|
||||
}
|
||||
|
||||
export async function callOpenAI(messages: ChatMessage[], jsonMode = true): Promise<string> {
|
||||
export async function callOpenAI(
|
||||
messages: ChatMessage[],
|
||||
jsonMode = true,
|
||||
model = DEFAULT_MODEL
|
||||
): Promise<string> {
|
||||
const apiKey = process.env.OPENAI_API_KEY
|
||||
|
||||
if (!apiKey || apiKey === 'your-openai-api-key-here') {
|
||||
throw new Error('OPENAI_API_KEY가 설정되지 않았습니다.')
|
||||
}
|
||||
|
||||
const config = getModelConfig(model)
|
||||
|
||||
const requestBody: any = {
|
||||
model,
|
||||
messages,
|
||||
temperature: 0.1,
|
||||
[config.maxTokensParam]: config.defaultMaxTokens,
|
||||
}
|
||||
|
||||
if (jsonMode) {
|
||||
requestBody.response_format = { type: 'json_object' }
|
||||
}
|
||||
|
||||
const response = await fetch(OPENAI_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-4o-mini',
|
||||
messages,
|
||||
temperature: 0.1,
|
||||
...(jsonMode && { response_format: { type: 'json_object' } })
|
||||
})
|
||||
body: JSON.stringify(requestBody)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -48,54 +98,146 @@ export async function callOpenAI(messages: ChatMessage[], jsonMode = true): Prom
|
||||
}
|
||||
|
||||
/**
|
||||
* 주간보고 텍스트 분석 프롬프트
|
||||
* 이미지 분석용 OpenAI 호출 (Vision)
|
||||
*/
|
||||
export function buildParseReportPrompt(rawText: string): ChatMessage[] {
|
||||
return [
|
||||
{
|
||||
role: 'system',
|
||||
content: `당신은 주간업무보고 텍스트를 분석하여 구조화된 JSON으로 변환하는 전문가입니다.
|
||||
export async function callOpenAIVision(
|
||||
systemPrompt: string,
|
||||
imageBase64List: string[],
|
||||
model = DEFAULT_MODEL
|
||||
): Promise<string> {
|
||||
const apiKey = process.env.OPENAI_API_KEY
|
||||
|
||||
if (!apiKey || apiKey === 'your-openai-api-key-here') {
|
||||
throw new Error('OPENAI_API_KEY가 설정되지 않았습니다.')
|
||||
}
|
||||
|
||||
const config = getModelConfig(model)
|
||||
|
||||
const imageContents = imageBase64List.map(base64 => ({
|
||||
type: 'image_url' as const,
|
||||
image_url: {
|
||||
url: base64.startsWith('data:') ? base64 : `data:image/png;base64,${base64}`
|
||||
}
|
||||
}))
|
||||
|
||||
const requestBody: any = {
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: '이 이미지들에서 주간보고 내용을 추출해주세요.' },
|
||||
...imageContents
|
||||
]
|
||||
}
|
||||
],
|
||||
temperature: 0.1,
|
||||
[config.maxTokensParam]: config.defaultMaxTokens,
|
||||
response_format: { type: 'json_object' }
|
||||
}
|
||||
|
||||
const response = await fetch(OPENAI_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
throw new Error(`OpenAI Vision API 오류: ${response.status} - ${error}`)
|
||||
}
|
||||
|
||||
const data = await response.json() as OpenAIResponse
|
||||
return data.choices[0].message.content
|
||||
}
|
||||
|
||||
입력된 텍스트에서 다음 정보를 추출하세요:
|
||||
1. 직원 정보 (이름, 이메일)
|
||||
2. 프로젝트별 실적 (프로젝트명, 금주실적, 차주계획)
|
||||
3. 공통사항 (이슈/리스크, 휴가일정, 기타사항)
|
||||
4. 보고 주차 정보 (텍스트에서 날짜나 주차 정보 추출)
|
||||
/**
|
||||
* 주간보고 분석 시스템 프롬프트 (Task 기반)
|
||||
*/
|
||||
export const REPORT_PARSE_SYSTEM_PROMPT = `당신은 주간업무보고 텍스트를 분석하여 구조화된 JSON으로 변환하는 전문가입니다.
|
||||
|
||||
## 핵심 원칙
|
||||
- **원문의 내용을 그대로 유지하세요!**
|
||||
- **Task는 적당히 묶어서 정리하세요. 너무 세분화하지 마세요!**
|
||||
- 하나의 Task에 여러 줄이 들어갈 수 있습니다.
|
||||
|
||||
## Task 분리 규칙 (중요!)
|
||||
|
||||
❌ 잘못된 예 (너무 세분화):
|
||||
- Task 1: "API 개발"
|
||||
- Task 2: "API 테스트"
|
||||
|
||||
✅ 올바른 예 (적절히 묶기):
|
||||
- Task 1: "API 개발 및 테스트 완료"
|
||||
|
||||
❌ 잘못된 예 (프로젝트명 반복):
|
||||
- "PIMS 고도화 - 사용자 인증 개발"
|
||||
|
||||
✅ 올바른 예 (프로젝트명 제외):
|
||||
- "사용자 인증 개발"
|
||||
|
||||
## 완료여부(isCompleted) 판단 규칙 ★중요★
|
||||
|
||||
금주 실적(workTasks)의 완료여부를 판단합니다:
|
||||
- 기본값: true (완료)
|
||||
- false (진행중): 차주 계획(planTasks)에 비슷한/연관된 작업이 있는 경우
|
||||
|
||||
예시:
|
||||
- 실적: "로그인 API 개발" + 계획: "로그인 API 테스트" → isCompleted: false (연관 작업 있음)
|
||||
- 실적: "DB 백업 완료" + 계획에 관련 없음 → isCompleted: true
|
||||
|
||||
## 수행시간 예측 기준
|
||||
- **0시간**: "없음", "특이사항 없음", "해당없음", "한 게 없다", "작업 없음" 등 실제 작업이 없는 경우
|
||||
- 단순 작업: 2~4시간
|
||||
- 일반 작업: 8시간 (1일)
|
||||
- 복잡한 작업: 16~24시간 (2~3일)
|
||||
|
||||
## JSON 출력 형식
|
||||
|
||||
반드시 아래 JSON 형식으로 응답하세요:
|
||||
{
|
||||
"reportYear": 2025,
|
||||
"reportWeek": 1,
|
||||
"reportWeek": 2,
|
||||
"weekStartDate": "2025-01-06",
|
||||
"weekEndDate": "2025-01-10",
|
||||
"weekEndDate": "2025-01-12",
|
||||
"reports": [
|
||||
{
|
||||
"employeeName": "홍길동",
|
||||
"employeeEmail": "hong@example.com",
|
||||
"projects": [
|
||||
{
|
||||
"projectName": "프로젝트명",
|
||||
"workDescription": "금주 실적 내용",
|
||||
"planDescription": "차주 계획 내용"
|
||||
"projectName": "PIMS 고도화",
|
||||
"workTasks": [
|
||||
{ "description": "사용자 인증 모듈 개발", "hours": 16, "isCompleted": false },
|
||||
{ "description": "DB 백업 스크립트 작성", "hours": 4, "isCompleted": true }
|
||||
],
|
||||
"planTasks": [
|
||||
{ "description": "사용자 인증 테스트 및 배포", "hours": 8 }
|
||||
]
|
||||
}
|
||||
],
|
||||
"issueDescription": "이슈/리스크 내용 또는 null",
|
||||
"vacationDescription": "휴가 일정 또는 null",
|
||||
"remarkDescription": "기타 사항 또는 null"
|
||||
"issueDescription": "개발서버 메모리 부족",
|
||||
"vacationDescription": null,
|
||||
"remarkDescription": null
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
주의사항:
|
||||
- 이메일이 없으면 employeeEmail은 null로
|
||||
- 프로젝트가 여러개면 projects 배열에 모두 포함
|
||||
- 날짜 형식은 YYYY-MM-DD
|
||||
- 주차 정보가 없으면 현재 날짜 기준으로 추정
|
||||
- 실적/계획이 명확히 구분 안되면 workDescription에 통합`
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: rawText
|
||||
}
|
||||
## 주의사항
|
||||
- Task description에 프로젝트명을 포함하지 마세요
|
||||
- 비슷한 작업은 하나의 Task로 묶으세요
|
||||
- 한 Task 내 여러 항목은 \\n으로 줄바꿈
|
||||
- 이메일이 없으면 employeeEmail은 null`
|
||||
|
||||
/**
|
||||
* 주간보고 텍스트 분석 프롬프트
|
||||
*/
|
||||
export function buildParseReportPrompt(rawText: string): ChatMessage[] {
|
||||
return [
|
||||
{ role: 'system', content: REPORT_PARSE_SYSTEM_PROMPT },
|
||||
{ role: 'user', content: rawText }
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user