/** * 작업 분석 컨트롤러 * * 작업 보고서 다차원 분석 API 엔드포인트 핸들러 * * @author TK-FB-Project * @since 2025-12-11 */ const WorkAnalysis = require('../models/WorkAnalysis'); const { getDb } = require('../dbPool'); const { ValidationError, DatabaseError } = require('../utils/errors'); const { asyncHandler } = require('../middlewares/errorHandler'); const logger = require('../utils/logger'); /** * 날짜 유효성 검사 헬퍼 함수 */ const validateDateRange = (startDate, endDate) => { if (!startDate || !endDate) { throw new ValidationError('시작일과 종료일을 입력해주세요', { required: ['start', 'end'], received: { start: startDate, end: endDate } }); } const start = new Date(startDate); const end = new Date(endDate); if (isNaN(start.getTime()) || isNaN(end.getTime())) { throw new ValidationError('올바른 날짜 형식을 입력해주세요', { format: 'YYYY-MM-DD', received: { start: startDate, end: endDate } }); } if (start > end) { throw new ValidationError('시작일이 종료일보다 늦을 수 없습니다', { start: startDate, end: endDate }); } // 너무 긴 기간 방지 (1년 제한) const diffTime = Math.abs(end - start); const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); if (diffDays > 365) { throw new ValidationError('조회 기간은 1년을 초과할 수 없습니다', { days: diffDays, max: 365 }); } return { start, end }; }; /** * 기본 통계 조회 */ const getStats = asyncHandler(async (req, res) => { const { start, end } = req.query; validateDateRange(start, end); logger.info('기본 통계 조회 요청', { start, end }); try { const db = await getDb(); const workAnalysis = new WorkAnalysis(db); const stats = await workAnalysis.getBasicStats(start, end); logger.info('기본 통계 조회 성공', { start, end }); res.json({ success: true, data: stats, message: '기본 통계 조회 완료' }); } catch (error) { logger.error('기본 통계 조회 실패', { start, end, error: error.message }); throw new DatabaseError('기본 통계 조회 중 오류가 발생했습니다'); } }); /** * 일별 작업시간 추이 조회 */ const getDailyTrend = asyncHandler(async (req, res) => { const { start, end } = req.query; validateDateRange(start, end); logger.info('일별 추이 조회 요청', { start, end }); try { const db = await getDb(); const workAnalysis = new WorkAnalysis(db); const trendData = await workAnalysis.getDailyTrend(start, end); logger.info('일별 추이 조회 성공', { start, end, dataPoints: trendData.length }); res.json({ success: true, data: trendData, message: '일별 추이 조회 완료' }); } catch (error) { logger.error('일별 추이 조회 실패', { start, end, error: error.message }); throw new DatabaseError('일별 추이 조회 중 오류가 발생했습니다'); } }); /** * 작업자별 통계 조회 */ const getWorkerStats = asyncHandler(async (req, res) => { const { start, end } = req.query; validateDateRange(start, end); logger.info('작업자별 통계 조회 요청', { start, end }); try { const db = await getDb(); const workAnalysis = new WorkAnalysis(db); const workerStats = await workAnalysis.getWorkerStats(start, end); logger.info('작업자별 통계 조회 성공', { start, end, workerCount: workerStats.length }); res.json({ success: true, data: workerStats, message: '작업자별 통계 조회 완료' }); } catch (error) { logger.error('작업자별 통계 조회 실패', { start, end, error: error.message }); throw new DatabaseError('작업자별 통계 조회 중 오류가 발생했습니다'); } }); /** * 프로젝트별 통계 조회 */ const getProjectStats = asyncHandler(async (req, res) => { const { start, end } = req.query; validateDateRange(start, end); logger.info('프로젝트별 통계 조회 요청', { start, end }); try { const db = await getDb(); const workAnalysis = new WorkAnalysis(db); const projectStats = await workAnalysis.getProjectStats(start, end); logger.info('프로젝트별 통계 조회 성공', { start, end, projectCount: projectStats.length }); res.json({ success: true, data: projectStats, message: '프로젝트별 통계 조회 완료' }); } catch (error) { logger.error('프로젝트별 통계 조회 실패', { start, end, error: error.message }); throw new DatabaseError('프로젝트별 통계 조회 중 오류가 발생했습니다'); } }); /** * 작업유형별 통계 조회 */ const getWorkTypeStats = asyncHandler(async (req, res) => { const { start, end } = req.query; validateDateRange(start, end); logger.info('작업유형별 통계 조회 요청', { start, end }); try { const db = await getDb(); const workAnalysis = new WorkAnalysis(db); const workTypeStats = await workAnalysis.getWorkTypeStats(start, end); logger.info('작업유형별 통계 조회 성공', { start, end, workTypeCount: workTypeStats.length }); res.json({ success: true, data: workTypeStats, message: '작업유형별 통계 조회 완료' }); } catch (error) { logger.error('작업유형별 통계 조회 실패', { start, end, error: error.message }); throw new DatabaseError('작업유형별 통계 조회 중 오류가 발생했습니다'); } }); /** * 최근 작업 현황 조회 */ const getRecentWork = asyncHandler(async (req, res) => { const { start, end, limit = 10 } = req.query; validateDateRange(start, end); // limit 유효성 검사 (최대 5000까지 허용) const limitNum = parseInt(limit); if (isNaN(limitNum) || limitNum < 1 || limitNum > 5000) { throw new ValidationError('limit은 1~5000 사이의 숫자여야 합니다', { received: limit, min: 1, max: 5000 }); } logger.info('최근 작업 현황 조회 요청', { start, end, limit: limitNum }); try { const db = await getDb(); const workAnalysis = new WorkAnalysis(db); const recentWork = await workAnalysis.getRecentWork(start, end, limitNum); logger.info('최근 작업 현황 조회 성공', { start, end, limit: limitNum, resultCount: recentWork.length }); res.json({ success: true, data: recentWork, message: '최근 작업 현황 조회 완료' }); } catch (error) { logger.error('최근 작업 현황 조회 실패', { start, end, limit: limitNum, error: error.message }); throw new DatabaseError('최근 작업 현황 조회 중 오류가 발생했습니다'); } }); /** * 요일별 패턴 분석 조회 */ const getWeekdayPattern = asyncHandler(async (req, res) => { const { start, end } = req.query; validateDateRange(start, end); logger.info('요일별 패턴 분석 요청', { start, end }); try { const db = await getDb(); const workAnalysis = new WorkAnalysis(db); const weekdayPattern = await workAnalysis.getWeekdayPattern(start, end); logger.info('요일별 패턴 분석 성공', { start, end }); res.json({ success: true, data: weekdayPattern, message: '요일별 패턴 분석 완료' }); } catch (error) { logger.error('요일별 패턴 분석 실패', { start, end, error: error.message }); throw new DatabaseError('요일별 패턴 분석 중 오류가 발생했습니다'); } }); /** * 에러 분석 조회 */ const getErrorAnalysis = asyncHandler(async (req, res) => { const { start, end } = req.query; validateDateRange(start, end); logger.info('에러 분석 요청', { start, end }); try { const db = await getDb(); const workAnalysis = new WorkAnalysis(db); const errorAnalysis = await workAnalysis.getErrorAnalysis(start, end); logger.info('에러 분석 성공', { start, end }); res.json({ success: true, data: errorAnalysis, message: '에러 분석 완료' }); } catch (error) { logger.error('에러 분석 실패', { start, end, error: error.message }); throw new DatabaseError('에러 분석 중 오류가 발생했습니다'); } }); /** * 월별 비교 분석 조회 */ const getMonthlyComparison = asyncHandler(async (req, res) => { const { year = new Date().getFullYear() } = req.query; const yearNum = parseInt(year); if (isNaN(yearNum) || yearNum < 2000 || yearNum > 2050) { throw new ValidationError('올바른 연도를 입력해주세요', { received: year, min: 2000, max: 2050 }); } logger.info('월별 비교 분석 요청', { year: yearNum }); try { const db = await getDb(); const workAnalysis = new WorkAnalysis(db); const monthlyData = await workAnalysis.getMonthlyComparison(yearNum); logger.info('월별 비교 분석 성공', { year: yearNum }); res.json({ success: true, data: monthlyData, message: '월별 비교 분석 완료' }); } catch (error) { logger.error('월별 비교 분석 실패', { year: yearNum, error: error.message }); throw new DatabaseError('월별 비교 분석 중 오류가 발생했습니다'); } }); /** * 작업자별 전문분야 분석 조회 */ const getWorkerSpecialization = asyncHandler(async (req, res) => { const { start, end } = req.query; validateDateRange(start, end); logger.info('작업자별 전문분야 분석 요청', { start, end }); try { const db = await getDb(); const workAnalysis = new WorkAnalysis(db); const specializationData = await workAnalysis.getWorkerSpecialization(start, end); // 작업자별로 그룹화하여 정리 const groupedData = specializationData.reduce((acc, item) => { if (!acc[item.worker_id]) { acc[item.worker_id] = []; } acc[item.worker_id].push({ work_type_id: item.work_type_id, project_id: item.project_id, totalHours: item.totalHours, totalReports: item.totalReports, percentage: item.percentage }); return acc; }, {}); logger.info('작업자별 전문분야 분석 성공', { start, end, workerCount: Object.keys(groupedData).length }); res.json({ success: true, data: groupedData, message: '작업자별 전문분야 분석 완료' }); } catch (error) { logger.error('작업자별 전문분야 분석 실패', { start, end, error: error.message }); throw new DatabaseError('작업자별 전문분야 분석 중 오류가 발생했습니다'); } }); /** * 대시보드용 종합 데이터 조회 */ const getDashboardData = asyncHandler(async (req, res) => { const { start, end } = req.query; validateDateRange(start, end); logger.info('대시보드 데이터 조회 요청', { start, end }); try { const db = await getDb(); const workAnalysis = new WorkAnalysis(db); // 병렬로 여러 데이터 조회 const [ stats, dailyTrend, workerStats, projectStats, workTypeStats, recentWork ] = await Promise.all([ workAnalysis.getBasicStats(start, end), workAnalysis.getDailyTrend(start, end), workAnalysis.getWorkerStats(start, end), workAnalysis.getProjectStats(start, end), workAnalysis.getWorkTypeStats(start, end), workAnalysis.getRecentWork(start, end, 10) ]); logger.info('대시보드 데이터 조회 성공', { start, end }); res.json({ success: true, data: { stats, dailyTrend, workerStats, projectStats, workTypeStats, recentWork }, message: '대시보드 데이터 조회 완료' }); } catch (error) { logger.error('대시보드 데이터 조회 실패', { start, end, error: error.message }); throw new DatabaseError('대시보드 데이터 조회 중 오류가 발생했습니다'); } }); /** * 프로젝트별-작업별 시간 분석 (총시간, 정규시간, 에러시간) */ const getProjectWorkTypeAnalysis = asyncHandler(async (req, res) => { const { start, end } = req.query; validateDateRange(start, end); logger.info('프로젝트별-작업별 시간 분석 요청', { start, end }); try { const db = await getDb(); // 먼저 데이터 존재 여부 확인 const testQuery = ` SELECT COUNT(*) as total_count, MIN(report_date) as min_date, MAX(report_date) as max_date, SUM(work_hours) as total_hours FROM daily_work_reports WHERE report_date BETWEEN ? AND ? `; const testResults = await db.query(testQuery, [start, end]); logger.debug('데이터 확인', { start, end, count: testResults[0][0]?.total_count }); // 먼저 간단한 테스트 쿼리로 데이터 확인 const simpleQuery = ` SELECT COUNT(*) as count, MIN(report_date) as min_date, MAX(report_date) as max_date FROM daily_work_reports WHERE report_date BETWEEN ? AND ? `; const simpleResult = await db.query(simpleQuery, [start, end]); logger.debug('기간 내 데이터 확인', { start, end, result: simpleResult[0][0] }); // 프로젝트별-작업별 시간 분석 쿼리 (work_types 테이블과 조인) const query = ` SELECT COALESCE(p.project_id, dwr.project_id) as project_id, COALESCE(p.project_name, CONCAT('프로젝트 ', dwr.project_id)) as project_name, COALESCE(p.job_no, 'N/A') as job_no, dwr.work_type_id, COALESCE(wt.name, CONCAT('작업유형 ', dwr.work_type_id)) as work_type_name, -- 총 시간 SUM(dwr.work_hours) as total_hours, -- 정규 시간 (work_status_id = 1) SUM(CASE WHEN dwr.work_status_id = 1 THEN dwr.work_hours ELSE 0 END) as regular_hours, -- 에러 시간 (work_status_id = 2) SUM(CASE WHEN dwr.work_status_id = 2 THEN dwr.work_hours ELSE 0 END) as error_hours, -- 작업 건수 COUNT(*) as total_reports, COUNT(CASE WHEN dwr.work_status_id = 1 THEN 1 END) as regular_reports, COUNT(CASE WHEN dwr.work_status_id = 2 THEN 1 END) as error_reports, -- 에러율 계산 ROUND( (SUM(CASE WHEN dwr.work_status_id = 2 THEN dwr.work_hours ELSE 0 END) / SUM(dwr.work_hours)) * 100, 2 ) as error_rate_percent FROM daily_work_reports dwr LEFT JOIN projects p ON dwr.project_id = p.project_id LEFT JOIN work_types wt ON dwr.work_type_id = wt.id WHERE dwr.report_date BETWEEN ? AND ? GROUP BY dwr.project_id, p.project_name, p.job_no, dwr.work_type_id, wt.name ORDER BY p.project_name, wt.name `; const results = await db.query(query, [start, end]); logger.debug('쿼리 결과', { start, end, resultCount: results[0].length }); // 데이터를 프로젝트별로 그룹화 const groupedData = {}; results[0].forEach(row => { const projectKey = `${row.project_id}_${row.project_name}`; if (!groupedData[projectKey]) { groupedData[projectKey] = { project_id: row.project_id, project_name: row.project_name, job_no: row.job_no, total_project_hours: 0, total_regular_hours: 0, total_error_hours: 0, work_types: [] }; } // 프로젝트 총계 누적 groupedData[projectKey].total_project_hours += parseFloat(row.total_hours); groupedData[projectKey].total_regular_hours += parseFloat(row.regular_hours); groupedData[projectKey].total_error_hours += parseFloat(row.error_hours); // 작업 유형별 데이터 추가 groupedData[projectKey].work_types.push({ work_type_id: row.work_type_id, work_type_name: row.work_type_name, total_hours: parseFloat(row.total_hours), regular_hours: parseFloat(row.regular_hours), error_hours: parseFloat(row.error_hours), total_reports: row.total_reports, regular_reports: row.regular_reports, error_reports: row.error_reports, error_rate_percent: parseFloat(row.error_rate_percent) || 0 }); }); // 프로젝트별 에러율 계산 Object.values(groupedData).forEach(project => { project.project_error_rate = project.total_project_hours > 0 ? Math.round((project.total_error_hours / project.total_project_hours) * 100 * 100) / 100 : 0; }); // 전체 요약 통계 const totalStats = { total_projects: Object.keys(groupedData).length, total_work_types: new Set(results[0].map(r => r.work_type_id)).size, grand_total_hours: Object.values(groupedData).reduce((sum, p) => sum + p.total_project_hours, 0), grand_regular_hours: Object.values(groupedData).reduce((sum, p) => sum + p.total_regular_hours, 0), grand_error_hours: Object.values(groupedData).reduce((sum, p) => sum + p.total_error_hours, 0) }; totalStats.grand_error_rate = totalStats.grand_total_hours > 0 ? Math.round((totalStats.grand_error_hours / totalStats.grand_total_hours) * 100 * 100) / 100 : 0; logger.info('프로젝트별-작업별 시간 분석 성공', { start, end, projectCount: totalStats.total_projects, workTypeCount: totalStats.total_work_types, totalHours: totalStats.grand_total_hours }); res.json({ success: true, data: { summary: totalStats, projects: Object.values(groupedData), period: { start, end } }, message: '프로젝트별-작업별 시간 분석 완료' }); } catch (error) { logger.error('프로젝트별-작업별 시간 분석 실패', { start, end, error: error.message }); throw new DatabaseError('프로젝트별-작업별 시간 분석 중 오류가 발생했습니다'); } }); module.exports = { getStats, getDailyTrend, getWorkerStats, getProjectStats, getWorkTypeStats, getRecentWork, getWeekdayPattern, getErrorAnalysis, getMonthlyComparison, getWorkerSpecialization, getDashboardData, getProjectWorkTypeAnalysis };