From 9206672b6319229a752941a87393eca0a768156e Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Thu, 11 Dec 2025 13:30:42 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=203.8=20-=20=EB=B3=B5=EC=9E=A1?= =?UTF-8?q?=ED=95=9C=20=EB=B6=84=EC=84=9D=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 두 개의 복잡한 분석 컨트롤러를 현대적인 패턴으로 전면 개선: ## workReportAnalysisController.js (381 → 430 lines) - 7개 SQL 쿼리 기반 복합 분석 엔드포인트 개선 - console.error → logger.info/error/warn 전환 - try-catch → asyncHandler 미들웨어 적용 - Error → ValidationError, DatabaseError 전환 - JSDoc 문서화 및 구조화된 로깅 추가 - 4개 함수: getAnalysisFilters, getAnalyticsByPeriod, getProjectAnalysis, getWorkerAnalysis ## workAnalysisController.js (523 → 622 lines) - 클래스 기반 → 함수 기반 컨트롤러 전환 - console.error → logger.info/error/debug 전환 - try-catch → asyncHandler 미들웨어 적용 - Error → ValidationError, DatabaseError 전환 - validateDateRange 헬퍼 함수 개선 (상세한 에러 컨텍스트) - JSDoc 문서화 및 구조화된 로깅 추가 - 12개 함수: getStats, getDailyTrend, getWorkerStats, getProjectStats, getWorkTypeStats, getRecentWork, getWeekdayPattern, getErrorAnalysis, getMonthlyComparison, getWorkerSpecialization, getDashboardData, getProjectWorkTypeAnalysis ## 기술적 개선사항 - 통합 에러 처리: 커스텀 에러 클래스로 일관된 에러 핸들링 - 구조화된 로깅: 모든 API 호출에 컨텍스트 정보 포함 - 자동 에러 전파: asyncHandler로 보일러플레이트 코드 제거 - 향상된 유효성 검사: 상세한 에러 메시지와 컨텍스트 - 프로덕션 준비: 표준화된 응답 형식 및 에러 처리 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../controllers/workAnalysisController.js | 1014 +++++++++-------- .../workReportAnalysisController.js | 308 ++--- 2 files changed, 734 insertions(+), 588 deletions(-) diff --git a/api.hyungi.net/controllers/workAnalysisController.js b/api.hyungi.net/controllers/workAnalysisController.js index ced400a..c15fad3 100644 --- a/api.hyungi.net/controllers/workAnalysisController.js +++ b/api.hyungi.net/controllers/workAnalysisController.js @@ -1,523 +1,621 @@ -// controllers/workAnalysisController.js +/** + * 작업 분석 컨트롤러 + * + * 작업 보고서 다차원 분석 API 엔드포인트 핸들러 + * + * @author TK-FB-Project + * @since 2025-12-11 + */ const WorkAnalysis = require('../models/WorkAnalysis'); -const { getDb } = require('../dbPool'); // 기존 프로젝트의 DB 연결 방식 사용 +const { getDb } = require('../dbPool'); +const { ValidationError, DatabaseError } = require('../utils/errors'); +const { asyncHandler } = require('../middlewares/errorHandler'); +const logger = require('../utils/logger'); -class WorkAnalysisController { - constructor() { - // 메서드 바인딩 - this.getStats = this.getStats.bind(this); - this.getDailyTrend = this.getDailyTrend.bind(this); - this.getWorkerStats = this.getWorkerStats.bind(this); - this.getProjectStats = this.getProjectStats.bind(this); - this.getWorkTypeStats = this.getWorkTypeStats.bind(this); - this.getRecentWork = this.getRecentWork.bind(this); - this.getWeekdayPattern = this.getWeekdayPattern.bind(this); - this.getErrorAnalysis = this.getErrorAnalysis.bind(this); - this.getMonthlyComparison = this.getMonthlyComparison.bind(this); - this.getWorkerSpecialization = this.getWorkerSpecialization.bind(this); - this.getProjectWorkTypeAnalysis = this.getProjectWorkTypeAnalysis.bind(this); - } +/** + * 날짜 유효성 검사 헬퍼 함수 + */ +const validateDateRange = (startDate, endDate) => { + if (!startDate || !endDate) { + throw new ValidationError('시작일과 종료일을 입력해주세요', { + required: ['start', 'end'], + received: { start: startDate, end: endDate } + }); + } - // 날짜 유효성 검사 - validateDateRange(startDate, endDate) { - if (!startDate || !endDate) { - throw new Error('시작일과 종료일을 입력해주세요.'); - } + const start = new Date(startDate); + const end = new Date(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 (isNaN(start.getTime()) || isNaN(end.getTime())) { - throw new Error('올바른 날짜 형식을 입력해주세요. (YYYY-MM-DD)'); - } + if (start > end) { + throw new ValidationError('시작일이 종료일보다 늦을 수 없습니다', { + start: startDate, + end: endDate + }); + } - if (start > end) { - throw new Error('시작일이 종료일보다 늦을 수 없습니다.'); - } + // 너무 긴 기간 방지 (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 + }); + } - // 너무 긴 기간 방지 (1년 제한) - const diffTime = Math.abs(end - start); - const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - if (diffDays > 365) { - throw new Error('조회 기간은 1년을 초과할 수 없습니다.'); - } + return { start, end }; +}; - return { start, end }; - } +/** + * 기본 통계 조회 + */ +const getStats = asyncHandler(async (req, res) => { + const { start, end } = req.query; + validateDateRange(start, end); - // 기본 통계 조회 - async getStats(req, res) { - try { - const { start, end } = req.query; - this.validateDateRange(start, end); + logger.info('기본 통계 조회 요청', { start, end }); - const db = await getDb(); - const workAnalysis = new WorkAnalysis(db); - const stats = await workAnalysis.getBasicStats(start, end); + try { + const db = await getDb(); + const workAnalysis = new WorkAnalysis(db); + const stats = await workAnalysis.getBasicStats(start, end); - res.status(200).json({ - success: true, - data: stats, - message: '기본 통계 조회 완료' - }); + logger.info('기본 통계 조회 성공', { start, end }); - } catch (error) { - console.error('기본 통계 조회 오류:', error); - res.status(400).json({ - success: false, - error: error.message - }); - } - } + res.json({ + success: true, + data: stats, + message: '기본 통계 조회 완료' + }); + } catch (error) { + logger.error('기본 통계 조회 실패', { start, end, error: error.message }); + throw new DatabaseError('기본 통계 조회 중 오류가 발생했습니다'); + } +}); - // 일별 작업시간 추이 - async getDailyTrend(req, res) { - try { - const { start, end } = req.query; - this.validateDateRange(start, end); +/** + * 일별 작업시간 추이 조회 + */ +const getDailyTrend = asyncHandler(async (req, res) => { + const { start, end } = req.query; + validateDateRange(start, end); - const db = await getDb(); - const workAnalysis = new WorkAnalysis(db); - const trendData = await workAnalysis.getDailyTrend(start, end); + logger.info('일별 추이 조회 요청', { start, end }); - res.status(200).json({ - success: true, - data: trendData, - message: '일별 추이 조회 완료' - }); + try { + const db = await getDb(); + const workAnalysis = new WorkAnalysis(db); + const trendData = await workAnalysis.getDailyTrend(start, end); - } catch (error) { - console.error('일별 추이 조회 오류:', error); - res.status(400).json({ - success: false, - error: error.message - }); - } - } + logger.info('일별 추이 조회 성공', { start, end, dataPoints: trendData.length }); - // 작업자별 통계 - async getWorkerStats(req, res) { - try { - const { start, end } = req.query; - this.validateDateRange(start, end); + res.json({ + success: true, + data: trendData, + message: '일별 추이 조회 완료' + }); + } catch (error) { + logger.error('일별 추이 조회 실패', { start, end, error: error.message }); + throw new DatabaseError('일별 추이 조회 중 오류가 발생했습니다'); + } +}); - const db = await getDb(); - const workAnalysis = new WorkAnalysis(db); - const workerStats = await workAnalysis.getWorkerStats(start, end); +/** + * 작업자별 통계 조회 + */ +const getWorkerStats = asyncHandler(async (req, res) => { + const { start, end } = req.query; + validateDateRange(start, end); - res.status(200).json({ - success: true, - data: workerStats, - message: '작업자별 통계 조회 완료' - }); + logger.info('작업자별 통계 조회 요청', { start, end }); - } catch (error) { - console.error('작업자별 통계 조회 오류:', error); - res.status(400).json({ - success: false, - error: error.message - }); - } - } + try { + const db = await getDb(); + const workAnalysis = new WorkAnalysis(db); + const workerStats = await workAnalysis.getWorkerStats(start, end); - // 프로젝트별 통계 - async getProjectStats(req, res) { - try { - const { start, end } = req.query; - this.validateDateRange(start, end); + logger.info('작업자별 통계 조회 성공', { + start, + end, + workerCount: workerStats.length + }); - const db = await getDb(); - const workAnalysis = new WorkAnalysis(db); - const projectStats = await workAnalysis.getProjectStats(start, end); + res.json({ + success: true, + data: workerStats, + message: '작업자별 통계 조회 완료' + }); + } catch (error) { + logger.error('작업자별 통계 조회 실패', { start, end, error: error.message }); + throw new DatabaseError('작업자별 통계 조회 중 오류가 발생했습니다'); + } +}); - res.status(200).json({ - success: true, - data: projectStats, - message: '프로젝트별 통계 조회 완료' - }); +/** + * 프로젝트별 통계 조회 + */ +const getProjectStats = asyncHandler(async (req, res) => { + const { start, end } = req.query; + validateDateRange(start, end); - } catch (error) { - console.error('프로젝트별 통계 조회 오류:', error); - res.status(400).json({ - success: false, - error: error.message - }); - } - } + logger.info('프로젝트별 통계 조회 요청', { start, end }); - // 작업유형별 통계 - async getWorkTypeStats(req, res) { - try { - const { start, end } = req.query; - this.validateDateRange(start, end); + try { + const db = await getDb(); + const workAnalysis = new WorkAnalysis(db); + const projectStats = await workAnalysis.getProjectStats(start, end); - const db = await getDb(); - const workAnalysis = new WorkAnalysis(db); - const workTypeStats = await workAnalysis.getWorkTypeStats(start, end); + logger.info('프로젝트별 통계 조회 성공', { + start, + end, + projectCount: projectStats.length + }); - res.status(200).json({ - success: true, - data: workTypeStats, - message: '작업유형별 통계 조회 완료' - }); + res.json({ + success: true, + data: projectStats, + message: '프로젝트별 통계 조회 완료' + }); + } catch (error) { + logger.error('프로젝트별 통계 조회 실패', { start, end, error: error.message }); + throw new DatabaseError('프로젝트별 통계 조회 중 오류가 발생했습니다'); + } +}); - } catch (error) { - console.error('작업유형별 통계 조회 오류:', error); - res.status(400).json({ - success: false, - error: error.message - }); - } - } +/** + * 작업유형별 통계 조회 + */ +const getWorkTypeStats = asyncHandler(async (req, res) => { + const { start, end } = req.query; + validateDateRange(start, end); - // 최근 작업 현황 - async getRecentWork(req, res) { - try { - const { start, end, limit = 10 } = req.query; - this.validateDateRange(start, end); + logger.info('작업유형별 통계 조회 요청', { start, end }); - // limit 유효성 검사 (최대 5000까지 허용) - const limitNum = parseInt(limit); - if (isNaN(limitNum) || limitNum < 1 || limitNum > 5000) { - throw new Error('limit은 1~5000 사이의 숫자여야 합니다.'); - } + try { + const db = await getDb(); + const workAnalysis = new WorkAnalysis(db); + const workTypeStats = await workAnalysis.getWorkTypeStats(start, end); - const db = await getDb(); - const workAnalysis = new WorkAnalysis(db); - const recentWork = await workAnalysis.getRecentWork(start, end, limitNum); + logger.info('작업유형별 통계 조회 성공', { + start, + end, + workTypeCount: workTypeStats.length + }); - res.status(200).json({ - success: true, - data: recentWork, - message: '최근 작업 현황 조회 완료' - }); + res.json({ + success: true, + data: workTypeStats, + message: '작업유형별 통계 조회 완료' + }); + } catch (error) { + logger.error('작업유형별 통계 조회 실패', { start, end, error: error.message }); + throw new DatabaseError('작업유형별 통계 조회 중 오류가 발생했습니다'); + } +}); - } catch (error) { - console.error('최근 작업 현황 조회 오류:', error); - res.status(400).json({ - success: false, - error: error.message - }); - } - } +/** + * 최근 작업 현황 조회 + */ +const getRecentWork = asyncHandler(async (req, res) => { + const { start, end, limit = 10 } = req.query; + validateDateRange(start, end); - // 요일별 패턴 분석 - async getWeekdayPattern(req, res) { - try { - const { start, end } = req.query; - this.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 + }); + } - const db = await getDb(); - const workAnalysis = new WorkAnalysis(db); - const weekdayPattern = await workAnalysis.getWeekdayPattern(start, end); + logger.info('최근 작업 현황 조회 요청', { start, end, limit: limitNum }); - res.status(200).json({ - success: true, - data: weekdayPattern, - message: '요일별 패턴 분석 완료' - }); + try { + const db = await getDb(); + const workAnalysis = new WorkAnalysis(db); + const recentWork = await workAnalysis.getRecentWork(start, end, limitNum); - } catch (error) { - console.error('요일별 패턴 분석 오류:', error); - res.status(400).json({ - success: false, - error: error.message - }); - } - } + logger.info('최근 작업 현황 조회 성공', { + start, + end, + limit: limitNum, + resultCount: recentWork.length + }); - // 에러 분석 - async getErrorAnalysis(req, res) { - try { - const { start, end } = req.query; - this.validateDateRange(start, end); + res.json({ + success: true, + data: recentWork, + message: '최근 작업 현황 조회 완료' + }); + } catch (error) { + logger.error('최근 작업 현황 조회 실패', { + start, + end, + limit: limitNum, + error: error.message + }); + throw new DatabaseError('최근 작업 현황 조회 중 오류가 발생했습니다'); + } +}); - const db = await getDb(); - const workAnalysis = new WorkAnalysis(db); - const errorAnalysis = await workAnalysis.getErrorAnalysis(start, end); +/** + * 요일별 패턴 분석 조회 + */ +const getWeekdayPattern = asyncHandler(async (req, res) => { + const { start, end } = req.query; + validateDateRange(start, end); - res.status(200).json({ - success: true, - data: errorAnalysis, - message: '에러 분석 완료' - }); + logger.info('요일별 패턴 분석 요청', { start, end }); - } catch (error) { - console.error('에러 분석 오류:', error); - res.status(400).json({ - success: false, - error: error.message - }); - } - } + try { + const db = await getDb(); + const workAnalysis = new WorkAnalysis(db); + const weekdayPattern = await workAnalysis.getWeekdayPattern(start, end); - // 월별 비교 분석 - async getMonthlyComparison(req, res) { - try { - const { year = new Date().getFullYear() } = req.query; - - const yearNum = parseInt(year); - if (isNaN(yearNum) || yearNum < 2000 || yearNum > 2050) { - throw new Error('올바른 연도를 입력해주세요. (2000-2050)'); - } + logger.info('요일별 패턴 분석 성공', { start, end }); - const db = await getDb(); - const workAnalysis = new WorkAnalysis(db); - const monthlyData = await workAnalysis.getMonthlyComparison(yearNum); + res.json({ + success: true, + data: weekdayPattern, + message: '요일별 패턴 분석 완료' + }); + } catch (error) { + logger.error('요일별 패턴 분석 실패', { start, end, error: error.message }); + throw new DatabaseError('요일별 패턴 분석 중 오류가 발생했습니다'); + } +}); - res.status(200).json({ - success: true, - data: monthlyData, - message: '월별 비교 분석 완료' - }); +/** + * 에러 분석 조회 + */ +const getErrorAnalysis = asyncHandler(async (req, res) => { + const { start, end } = req.query; + validateDateRange(start, end); - } catch (error) { - console.error('월별 비교 분석 오류:', error); - res.status(400).json({ - success: false, - error: error.message - }); - } - } + logger.info('에러 분석 요청', { start, end }); - // 작업자별 전문분야 분석 - async getWorkerSpecialization(req, res) { - try { - const { start, end } = req.query; - this.validateDateRange(start, end); + try { + const db = await getDb(); + const workAnalysis = new WorkAnalysis(db); + const errorAnalysis = await workAnalysis.getErrorAnalysis(start, end); - const db = await getDb(); - const workAnalysis = new WorkAnalysis(db); - const specializationData = await workAnalysis.getWorkerSpecialization(start, end); + logger.info('에러 분석 성공', { 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; - }, {}); + res.json({ + success: true, + data: errorAnalysis, + message: '에러 분석 완료' + }); + } catch (error) { + logger.error('에러 분석 실패', { start, end, error: error.message }); + throw new DatabaseError('에러 분석 중 오류가 발생했습니다'); + } +}); - res.status(200).json({ - success: true, - data: groupedData, - message: '작업자별 전문분야 분석 완료' - }); +/** + * 월별 비교 분석 조회 + */ +const getMonthlyComparison = asyncHandler(async (req, res) => { + const { year = new Date().getFullYear() } = req.query; - } catch (error) { - console.error('작업자별 전문분야 분석 오류:', error); - res.status(400).json({ - success: false, - error: error.message - }); - } - } + const yearNum = parseInt(year); + if (isNaN(yearNum) || yearNum < 2000 || yearNum > 2050) { + throw new ValidationError('올바른 연도를 입력해주세요', { + received: year, + min: 2000, + max: 2050 + }); + } - // 대시보드용 종합 데이터 - async getDashboardData(req, res) { - try { - const { start, end } = req.query; - this.validateDateRange(start, end); + logger.info('월별 비교 분석 요청', { year: yearNum }); - const db = await getDb(); - const workAnalysis = new WorkAnalysis(db); + try { + const db = await getDb(); + const workAnalysis = new WorkAnalysis(db); + const monthlyData = await workAnalysis.getMonthlyComparison(yearNum); - // 병렬로 여러 데이터 조회 - 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('월별 비교 분석 성공', { year: yearNum }); - res.status(200).json({ - success: true, - data: { - stats, - dailyTrend, - workerStats, - projectStats, - workTypeStats, - recentWork - }, - message: '대시보드 데이터 조회 완료' - }); + res.json({ + success: true, + data: monthlyData, + message: '월별 비교 분석 완료' + }); + } catch (error) { + logger.error('월별 비교 분석 실패', { year: yearNum, error: error.message }); + throw new DatabaseError('월별 비교 분석 중 오류가 발생했습니다'); + } +}); - } catch (error) { - console.error('대시보드 데이터 조회 오류:', error); - res.status(400).json({ - success: false, - error: error.message - }); - } - } +/** + * 작업자별 전문분야 분석 조회 + */ +const getWorkerSpecialization = asyncHandler(async (req, res) => { + const { start, end } = req.query; + validateDateRange(start, end); - // 프로젝트별-작업별 시간 분석 (총시간, 정규시간, 에러시간) - async getProjectWorkTypeAnalysis(req, res) { - try { - const { start, end } = req.query; - this.validateDateRange(start, end); + logger.info('작업자별 전문분야 분석 요청', { start, end }); - 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]); - console.log('📊 데이터 확인:', testResults[0]); - - // 먼저 간단한 테스트 쿼리로 데이터 확인 - 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]); - console.log('📊 기간 내 데이터 확인:', 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 - `; + try { + const db = await getDb(); + const workAnalysis = new WorkAnalysis(db); + const specializationData = await workAnalysis.getWorkerSpecialization(start, end); - const results = await db.query(query, [start, end]); - console.log('📊 쿼리 결과 개수:', results[0].length); - console.log('📊 첫 번째 결과:', results[0][0]); - console.log('📊 모든 결과:', JSON.stringify(results[0], null, 2)); + // 작업자별로 그룹화하여 정리 + 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; + }, {}); - // 데이터를 프로젝트별로 그룹화 - 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 - }); - }); + logger.info('작업자별 전문분야 분석 성공', { + start, + end, + workerCount: Object.keys(groupedData).length + }); - // 프로젝트별 에러율 계산 - 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; - }); + res.json({ + success: true, + data: groupedData, + message: '작업자별 전문분야 분석 완료' + }); + } catch (error) { + logger.error('작업자별 전문분야 분석 실패', { start, end, error: error.message }); + throw new DatabaseError('작업자별 전문분야 분석 중 오류가 발생했습니다'); + } +}); - // 전체 요약 통계 - 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; +/** + * 대시보드용 종합 데이터 조회 + */ +const getDashboardData = asyncHandler(async (req, res) => { + const { start, end } = req.query; + validateDateRange(start, end); - res.status(200).json({ - success: true, - data: { - summary: totalStats, - projects: Object.values(groupedData), - period: { start, end } - }, - message: '프로젝트별-작업별 시간 분석 완료' - }); + logger.info('대시보드 데이터 조회 요청', { start, end }); - } catch (error) { - console.error('프로젝트별-작업별 시간 분석 오류:', error); - res.status(400).json({ - success: false, - error: error.message - }); - } - } -} + try { + const db = await getDb(); + const workAnalysis = new WorkAnalysis(db); -module.exports = new WorkAnalysisController(); \ No newline at end of file + // 병렬로 여러 데이터 조회 + 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 +}; diff --git a/api.hyungi.net/controllers/workReportAnalysisController.js b/api.hyungi.net/controllers/workReportAnalysisController.js index b4ac4de..2e376ac 100644 --- a/api.hyungi.net/controllers/workReportAnalysisController.js +++ b/api.hyungi.net/controllers/workReportAnalysisController.js @@ -1,46 +1,64 @@ -// controllers/workReportAnalysisController.js - 데일리 워크 레포트 분석 전용 컨트롤러 -const dailyWorkReportModel = require('../models/dailyWorkReportModel'); +/** + * 데일리 워크 레포트 분석 컨트롤러 + * + * 작업 보고서 종합 분석 API 엔드포인트 핸들러 + * + * @author TK-FB-Project + * @since 2025-12-11 + */ + const { getDb } = require('../dbPool'); +const { ValidationError, DatabaseError } = require('../utils/errors'); +const { asyncHandler } = require('../middlewares/errorHandler'); +const logger = require('../utils/logger'); /** - * 📋 분석용 필터 데이터 조회 (프로젝트, 작업자, 작업유형 목록) + * 분석용 필터 데이터 조회 (프로젝트, 작업자, 작업유형 목록) */ -const getAnalysisFilters = async (req, res) => { +const getAnalysisFilters = asyncHandler(async (req, res) => { + logger.info('분석 필터 데이터 조회 요청'); + + const db = await getDb(); + try { - const db = await getDb(); - // 프로젝트 목록 const [projects] = await db.query(` - SELECT DISTINCT p.project_id, p.project_name - FROM projects p - INNER JOIN daily_work_reports dwr ON p.project_id = dwr.project_id + SELECT DISTINCT p.project_id, p.project_name + FROM projects p + INNER JOIN daily_work_reports dwr ON p.project_id = dwr.project_id ORDER BY p.project_name `); - + // 작업자 목록 const [workers] = await db.query(` - SELECT DISTINCT w.worker_id, w.worker_name - FROM workers w - INNER JOIN daily_work_reports dwr ON w.worker_id = dwr.worker_id + SELECT DISTINCT w.worker_id, w.worker_name + FROM workers w + INNER JOIN daily_work_reports dwr ON w.worker_id = dwr.worker_id ORDER BY w.worker_name `); - + // 작업 유형 목록 const [workTypes] = await db.query(` - SELECT DISTINCT wt.id as work_type_id, wt.name as work_type_name - FROM work_types wt - INNER JOIN daily_work_reports dwr ON wt.id = dwr.work_type_id + SELECT DISTINCT wt.id as work_type_id, wt.name as work_type_name + FROM work_types wt + INNER JOIN daily_work_reports dwr ON wt.id = dwr.work_type_id ORDER BY wt.name `); - - // 날짜 범위 (최초/최신 데이터) + + // 날짜 범위 const [dateRange] = await db.query(` - SELECT + SELECT MIN(report_date) as min_date, MAX(report_date) as max_date FROM daily_work_reports `); - + + logger.info('분석 필터 데이터 조회 성공', { + projects: projects.length, + workers: workers.length, + workTypes: workTypes.length + }); + res.json({ success: true, data: { @@ -48,57 +66,58 @@ const getAnalysisFilters = async (req, res) => { workers, workTypes, dateRange: dateRange[0] - } + }, + message: '분석 필터 데이터 조회 성공' }); - } catch (error) { - console.error('필터 데이터 조회 오류:', error); - res.status(500).json({ - success: false, - error: '필터 데이터 조회 중 오류가 발생했습니다.', - detail: error.message - }); + logger.error('분석 필터 데이터 조회 실패', { error: error.message }); + throw new DatabaseError('필터 데이터 조회 중 오류가 발생했습니다'); } -}; +}); /** - * 📊 기간별 작업 분석 데이터 조회 + * 기간별 작업 분석 데이터 조회 */ -const getAnalyticsByPeriod = async (req, res) => { - try { - const { start_date, end_date, project_id, worker_id } = req.query; - - if (!start_date || !end_date) { - return res.status(400).json({ - success: false, - error: 'start_date와 end_date가 필요합니다.', - example: 'start_date=2025-08-01&end_date=2025-08-31' - }); - } +const getAnalyticsByPeriod = asyncHandler(async (req, res) => { + const { start_date, end_date, project_id, worker_id } = req.query; - const db = await getDb(); - + if (!start_date || !end_date) { + throw new ValidationError('start_date와 end_date가 필요합니다', { + required: ['start_date', 'end_date'], + received: { start_date, end_date }, + example: 'start_date=2025-08-01&end_date=2025-08-31' + }); + } + + logger.info('기간별 분석 데이터 조회 요청', { + start_date, + end_date, + project_id, + worker_id + }); + + const db = await getDb(); + + try { // 기본 조건 let whereConditions = ['dwr.report_date BETWEEN ? AND ?']; let queryParams = [start_date, end_date]; - - // 프로젝트 필터 + if (project_id) { whereConditions.push('dwr.project_id = ?'); queryParams.push(project_id); } - - // 작업자 필터 + if (worker_id) { whereConditions.push('dwr.worker_id = ?'); queryParams.push(worker_id); } - + const whereClause = whereConditions.join(' AND '); - // 1. 전체 요약 통계 (에러 분석 포함) + // 1. 전체 요약 통계 const overallSql = ` - SELECT + SELECT COUNT(*) as total_entries, SUM(dwr.work_hours) as total_hours, COUNT(DISTINCT dwr.worker_id) as unique_workers, @@ -111,12 +130,12 @@ const getAnalyticsByPeriod = async (req, res) => { FROM daily_work_reports dwr WHERE ${whereClause} `; - + const [overallStats] = await db.query(overallSql, queryParams); // 2. 일별 통계 const dailyStatsSql = ` - SELECT + SELECT dwr.report_date, SUM(dwr.work_hours) as daily_hours, COUNT(*) as daily_entries, @@ -126,12 +145,12 @@ const getAnalyticsByPeriod = async (req, res) => { GROUP BY dwr.report_date ORDER BY dwr.report_date ASC `; - + const [dailyStats] = await db.query(dailyStatsSql, queryParams); - // 2.5. 일별 에러 발생 통계 + // 3. 일별 에러 통계 const dailyErrorStatsSql = ` - SELECT + SELECT dwr.report_date, COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) as daily_errors, COUNT(*) as daily_total, @@ -141,12 +160,12 @@ const getAnalyticsByPeriod = async (req, res) => { GROUP BY dwr.report_date ORDER BY dwr.report_date ASC `; - + const [dailyErrorStats] = await db.query(dailyErrorStatsSql, queryParams); - // 3. 에러 유형별 분석 (간단한 방식으로 수정) + // 4. 에러 유형별 분석 const errorAnalysisSql = ` - SELECT + SELECT et.id as error_type_id, et.name as error_type_name, COUNT(*) as error_count, @@ -158,12 +177,12 @@ const getAnalyticsByPeriod = async (req, res) => { GROUP BY et.id, et.name ORDER BY error_count DESC `; - + const [errorAnalysis] = await db.query(errorAnalysisSql, queryParams); - // 4. 작업 유형별 분석 + // 5. 작업 유형별 분석 const workTypeAnalysisSql = ` - SELECT + SELECT wt.id as work_type_id, wt.name as work_type_name, COUNT(*) as work_count, @@ -177,12 +196,12 @@ const getAnalyticsByPeriod = async (req, res) => { GROUP BY wt.id, wt.name ORDER BY total_hours DESC `; - + const [workTypeAnalysis] = await db.query(workTypeAnalysisSql, queryParams); - // 5. 작업자별 성과 분석 + // 6. 작업자별 성과 분석 const workerAnalysisSql = ` - SELECT + SELECT w.worker_id, w.worker_name, COUNT(*) as total_entries, @@ -198,12 +217,12 @@ const getAnalyticsByPeriod = async (req, res) => { GROUP BY w.worker_id, w.worker_name ORDER BY total_hours DESC `; - + const [workerAnalysis] = await db.query(workerAnalysisSql, queryParams); - // 6. 프로젝트별 분석 + // 7. 프로젝트별 분석 const projectAnalysisSql = ` - SELECT + SELECT p.project_id, p.project_name, COUNT(*) as total_entries, @@ -219,9 +238,16 @@ const getAnalyticsByPeriod = async (req, res) => { GROUP BY p.project_id, p.project_name ORDER BY total_hours DESC `; - + const [projectAnalysis] = await db.query(projectAnalysisSql, queryParams); + logger.info('기간별 분석 데이터 조회 성공', { + start_date, + end_date, + total_entries: overallStats[0].total_entries, + total_hours: overallStats[0].total_hours + }); + res.json({ success: true, data: { @@ -234,48 +260,53 @@ const getAnalyticsByPeriod = async (req, res) => { projectAnalysis, period: { start_date, end_date }, filters: { project_id, worker_id } - } + }, + message: '기간별 분석 데이터 조회 성공' }); - } catch (error) { - console.error('기간별 분석 데이터 조회 오류:', error); - res.status(500).json({ - success: false, - error: '기간별 분석 데이터 조회 중 오류가 발생했습니다.', - detail: error.message + logger.error('기간별 분석 데이터 조회 실패', { + start_date, + end_date, + error: error.message }); + throw new DatabaseError('기간별 분석 데이터 조회 중 오류가 발생했습니다'); } -}; +}); /** - * 📈 프로젝트별 상세 분석 + * 프로젝트별 상세 분석 */ -const getProjectAnalysis = async (req, res) => { - try { - const { start_date, end_date, project_id } = req.query; - - if (!start_date || !end_date) { - return res.status(400).json({ - success: false, - error: 'start_date와 end_date가 필요합니다.' - }); - } +const getProjectAnalysis = asyncHandler(async (req, res) => { + const { start_date, end_date, project_id } = req.query; - const db = await getDb(); - + if (!start_date || !end_date) { + throw new ValidationError('start_date와 end_date가 필요합니다', { + required: ['start_date', 'end_date'], + received: { start_date, end_date } + }); + } + + logger.info('프로젝트별 분석 조회 요청', { + start_date, + end_date, + project_id + }); + + const db = await getDb(); + + try { let whereConditions = ['dwr.report_date BETWEEN ? AND ?']; let queryParams = [start_date, end_date]; - + if (project_id) { whereConditions.push('dwr.project_id = ?'); queryParams.push(project_id); } - + const whereClause = whereConditions.join(' AND '); - // 프로젝트별 통계 const projectStatsSql = ` - SELECT + SELECT dwr.project_id, p.project_name, SUM(dwr.work_hours) as total_hours, @@ -289,56 +320,67 @@ const getProjectAnalysis = async (req, res) => { GROUP BY dwr.project_id ORDER BY total_hours DESC `; - + const [projectStats] = await db.query(projectStatsSql, queryParams); + logger.info('프로젝트별 분석 조회 성공', { + start_date, + end_date, + projectCount: projectStats.length + }); + res.json({ success: true, data: { projectStats, period: { start_date, end_date } - } + }, + message: '프로젝트별 분석 조회 성공' }); - } catch (error) { - console.error('프로젝트별 분석 데이터 조회 오류:', error); - res.status(500).json({ - success: false, - error: '프로젝트별 분석 데이터 조회 중 오류가 발생했습니다.', - detail: error.message + logger.error('프로젝트별 분석 조회 실패', { + start_date, + end_date, + error: error.message }); + throw new DatabaseError('프로젝트별 분석 데이터 조회 중 오류가 발생했습니다'); } -}; +}); /** - * 👤 작업자별 상세 분석 + * 작업자별 상세 분석 */ -const getWorkerAnalysis = async (req, res) => { - try { - const { start_date, end_date, worker_id } = req.query; - - if (!start_date || !end_date) { - return res.status(400).json({ - success: false, - error: 'start_date와 end_date가 필요합니다.' - }); - } +const getWorkerAnalysis = asyncHandler(async (req, res) => { + const { start_date, end_date, worker_id } = req.query; - const db = await getDb(); - + if (!start_date || !end_date) { + throw new ValidationError('start_date와 end_date가 필요합니다', { + required: ['start_date', 'end_date'], + received: { start_date, end_date } + }); + } + + logger.info('작업자별 분석 조회 요청', { + start_date, + end_date, + worker_id + }); + + const db = await getDb(); + + try { let whereConditions = ['dwr.report_date BETWEEN ? AND ?']; let queryParams = [start_date, end_date]; - + if (worker_id) { whereConditions.push('dwr.worker_id = ?'); queryParams.push(worker_id); } - + const whereClause = whereConditions.join(' AND '); - // 작업자별 통계 const workerStatsSql = ` - SELECT + SELECT dwr.worker_id, w.worker_name, SUM(dwr.work_hours) as total_hours, @@ -352,30 +394,36 @@ const getWorkerAnalysis = async (req, res) => { GROUP BY dwr.worker_id ORDER BY total_hours DESC `; - + const [workerStats] = await db.query(workerStatsSql, queryParams); + logger.info('작업자별 분석 조회 성공', { + start_date, + end_date, + workerCount: workerStats.length + }); + res.json({ success: true, data: { workerStats, period: { start_date, end_date } - } + }, + message: '작업자별 분석 조회 성공' }); - } catch (error) { - console.error('작업자별 분석 데이터 조회 오류:', error); - res.status(500).json({ - success: false, - error: '작업자별 분석 데이터 조회 중 오류가 발생했습니다.', - detail: error.message + logger.error('작업자별 분석 조회 실패', { + start_date, + end_date, + error: error.message }); + throw new DatabaseError('작업자별 분석 데이터 조회 중 오류가 발생했습니다'); } -}; +}); module.exports = { getAnalysisFilters, getAnalyticsByPeriod, getProjectAnalysis, getWorkerAnalysis -}; \ No newline at end of file +};