Files
TK-FB-Project/api.hyungi.net/controllers/workAnalysisController.js
Hyungi Ahn de427c457b feat: 작업 분석 시스템 및 관리 기능 대폭 개선
 새로운 기능:
- 작업 분석 페이지 구현 (기간별, 프로젝트별, 작업자별, 오류별)
- 개별 분석 실행 버튼으로 API 부하 최적화
- 연차/휴무 집계 방식 개선 (주말 제외, 작업내용 통합)
- 프로젝트 관리 시스템 (활성화/비활성화)
- 작업자 관리 시스템 (CRUD 기능)
- 코드 관리 시스템 (작업유형, 작업상태, 오류유형)

🎨 UI/UX 개선:
- 기간별 작업 현황을 테이블 형태로 변경
- 작업자별 rowspan 그룹화로 가독성 향상
- 연차/휴무 프로젝트 하단 배치 및 시각적 구분
- 기간 확정 시스템으로 사용자 경험 개선
- 반응형 디자인 적용

🔧 기술적 개선:
- Rate Limiting 제거 (내부 시스템 최적화)
- 주말 연차/휴무 자동 제외 로직
- 작업공수 계산 정확도 향상
- 데이터베이스 마이그레이션 추가
- API 엔드포인트 확장 및 최적화

🐛 버그 수정:
- projectSelect 요소 참조 오류 해결
- 차트 높이 무한 증가 문제 해결
- 날짜 표시 형식 단순화
- 작업보고서 저장 validation 오류 수정
2025-11-04 16:56:47 +09:00

509 lines
18 KiB
JavaScript

// controllers/workAnalysisController.js
const WorkAnalysis = require('../models/WorkAnalysis');
const { getDb } = require('../dbPool'); // 기존 프로젝트의 DB 연결 방식 사용
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);
}
// 날짜 유효성 검사
validateDateRange(startDate, endDate) {
if (!startDate || !endDate) {
throw new Error('시작일과 종료일을 입력해주세요.');
}
const start = new Date(startDate);
const end = new Date(endDate);
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
throw new Error('올바른 날짜 형식을 입력해주세요. (YYYY-MM-DD)');
}
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 Error('조회 기간은 1년을 초과할 수 없습니다.');
}
return { start, end };
}
// 기본 통계 조회
async getStats(req, res) {
try {
const { start, end } = req.query;
this.validateDateRange(start, end);
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: '기본 통계 조회 완료'
});
} catch (error) {
console.error('기본 통계 조회 오류:', error);
res.status(400).json({
success: false,
error: error.message
});
}
}
// 일별 작업시간 추이
async getDailyTrend(req, res) {
try {
const { start, end } = req.query;
this.validateDateRange(start, end);
const db = await getDb();
const workAnalysis = new WorkAnalysis(db);
const trendData = await workAnalysis.getDailyTrend(start, end);
res.status(200).json({
success: true,
data: trendData,
message: '일별 추이 조회 완료'
});
} catch (error) {
console.error('일별 추이 조회 오류:', error);
res.status(400).json({
success: false,
error: error.message
});
}
}
// 작업자별 통계
async getWorkerStats(req, res) {
try {
const { start, end } = req.query;
this.validateDateRange(start, end);
const db = await getDb();
const workAnalysis = new WorkAnalysis(db);
const workerStats = await workAnalysis.getWorkerStats(start, end);
res.status(200).json({
success: true,
data: workerStats,
message: '작업자별 통계 조회 완료'
});
} catch (error) {
console.error('작업자별 통계 조회 오류:', error);
res.status(400).json({
success: false,
error: error.message
});
}
}
// 프로젝트별 통계
async getProjectStats(req, res) {
try {
const { start, end } = req.query;
this.validateDateRange(start, end);
const db = await getDb();
const workAnalysis = new WorkAnalysis(db);
const projectStats = await workAnalysis.getProjectStats(start, end);
res.status(200).json({
success: true,
data: projectStats,
message: '프로젝트별 통계 조회 완료'
});
} catch (error) {
console.error('프로젝트별 통계 조회 오류:', error);
res.status(400).json({
success: false,
error: error.message
});
}
}
// 작업유형별 통계
async getWorkTypeStats(req, res) {
try {
const { start, end } = req.query;
this.validateDateRange(start, end);
const db = await getDb();
const workAnalysis = new WorkAnalysis(db);
const workTypeStats = await workAnalysis.getWorkTypeStats(start, end);
res.status(200).json({
success: true,
data: workTypeStats,
message: '작업유형별 통계 조회 완료'
});
} catch (error) {
console.error('작업유형별 통계 조회 오류:', error);
res.status(400).json({
success: false,
error: error.message
});
}
}
// 최근 작업 현황
async getRecentWork(req, res) {
try {
const { start, end, limit = 10 } = req.query;
this.validateDateRange(start, end);
// limit 유효성 검사 (최대 5000까지 허용)
const limitNum = parseInt(limit);
if (isNaN(limitNum) || limitNum < 1 || limitNum > 5000) {
throw new Error('limit은 1~5000 사이의 숫자여야 합니다.');
}
const db = await getDb();
const workAnalysis = new WorkAnalysis(db);
const recentWork = await workAnalysis.getRecentWork(start, end, limitNum);
res.status(200).json({
success: true,
data: recentWork,
message: '최근 작업 현황 조회 완료'
});
} catch (error) {
console.error('최근 작업 현황 조회 오류:', error);
res.status(400).json({
success: false,
error: error.message
});
}
}
// 요일별 패턴 분석
async getWeekdayPattern(req, res) {
try {
const { start, end } = req.query;
this.validateDateRange(start, end);
const db = await getDb();
const workAnalysis = new WorkAnalysis(db);
const weekdayPattern = await workAnalysis.getWeekdayPattern(start, end);
res.status(200).json({
success: true,
data: weekdayPattern,
message: '요일별 패턴 분석 완료'
});
} catch (error) {
console.error('요일별 패턴 분석 오류:', error);
res.status(400).json({
success: false,
error: error.message
});
}
}
// 에러 분석
async getErrorAnalysis(req, res) {
try {
const { start, end } = req.query;
this.validateDateRange(start, end);
const db = await getDb();
const workAnalysis = new WorkAnalysis(db);
const errorAnalysis = await workAnalysis.getErrorAnalysis(start, end);
res.status(200).json({
success: true,
data: errorAnalysis,
message: '에러 분석 완료'
});
} catch (error) {
console.error('에러 분석 오류:', error);
res.status(400).json({
success: false,
error: error.message
});
}
}
// 월별 비교 분석
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)');
}
const db = await getDb();
const workAnalysis = new WorkAnalysis(db);
const monthlyData = await workAnalysis.getMonthlyComparison(yearNum);
res.status(200).json({
success: true,
data: monthlyData,
message: '월별 비교 분석 완료'
});
} catch (error) {
console.error('월별 비교 분석 오류:', error);
res.status(400).json({
success: false,
error: error.message
});
}
}
// 작업자별 전문분야 분석
async getWorkerSpecialization(req, res) {
try {
const { start, end } = req.query;
this.validateDateRange(start, end);
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;
}, {});
res.status(200).json({
success: true,
data: groupedData,
message: '작업자별 전문분야 분석 완료'
});
} catch (error) {
console.error('작업자별 전문분야 분석 오류:', error);
res.status(400).json({
success: false,
error: error.message
});
}
}
// 대시보드용 종합 데이터
async getDashboardData(req, res) {
try {
const { start, end } = req.query;
this.validateDateRange(start, end);
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)
]);
res.status(200).json({
success: true,
data: {
stats,
dailyTrend,
workerStats,
projectStats,
workTypeStats,
recentWork
},
message: '대시보드 데이터 조회 완료'
});
} catch (error) {
console.error('대시보드 데이터 조회 오류:', error);
res.status(400).json({
success: false,
error: error.message
});
}
}
// 프로젝트별-작업별 시간 분석 (총시간, 정규시간, 에러시간)
async getProjectWorkTypeAnalysis(req, res) {
try {
const { start, end } = req.query;
this.validateDateRange(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 query = `
SELECT
COALESCE(p.project_id, 0) as project_id,
COALESCE(p.project_name, 'Unknown Project') as project_name,
COALESCE(p.job_no, 'N/A') as job_no,
dwr.work_type_id,
CONCAT('Work Type ', 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
WHERE dwr.report_date BETWEEN ? AND ?
GROUP BY p.project_id, p.project_name, p.job_no, dwr.work_type_id
ORDER BY p.project_name, dwr.work_type_id
`;
const results = await db.query(query, [start, end]);
// 데이터를 프로젝트별로 그룹화
const groupedData = {};
results.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.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;
res.status(200).json({
success: true,
data: {
summary: totalStats,
projects: Object.values(groupedData),
period: { start, end }
},
message: '프로젝트별-작업별 시간 분석 완료'
});
} catch (error) {
console.error('프로젝트별-작업별 시간 분석 오류:', error);
res.status(400).json({
success: false,
error: error.message
});
}
}
}
module.exports = new WorkAnalysisController();