feat: 다수 기능 개선 - 순찰, 출근, 작업분석, 모바일 UI 등
- 순찰/점검 기능 개선 (zone-detail 페이지 추가) - 출근/근태 시스템 개선 (연차 조회, 근무현황) - 작업분석 대분류 그룹화 및 마이그레이션 스크립트 - 모바일 네비게이션 UI 추가 - NAS 배포 도구 및 문서 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,490 @@
|
||||
/**
|
||||
* 작업 분석 컨트롤러
|
||||
*
|
||||
* 작업 보고서 다차원 분석 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 workAnalysisService = require('../services/workAnalysisService');
|
||||
|
||||
/**
|
||||
* 프로젝트별-작업별 시간 분석 (총시간, 정규시간, 에러시간)
|
||||
*/
|
||||
const getProjectWorkTypeAnalysis = asyncHandler(async (req, res) => {
|
||||
const { start, end } = req.query;
|
||||
validateDateRange(start, end);
|
||||
|
||||
logger.info('프로젝트별-작업별 시간 분석 요청', { start, end });
|
||||
|
||||
try {
|
||||
const result = await workAnalysisService.getProjectWorkTypeAnalysis(start, end);
|
||||
|
||||
logger.info('프로젝트별-작업별 시간 분석 성공', {
|
||||
start,
|
||||
end,
|
||||
projectCount: result.summary.total_projects,
|
||||
workTypeCount: result.summary.total_work_types,
|
||||
totalHours: result.summary.grand_total_hours
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: '프로젝트별-작업별 시간 분석 완료'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('프로젝트별-작업별 시간 분석 실패', {
|
||||
start,
|
||||
end,
|
||||
error: error.message
|
||||
});
|
||||
// Service throws DatabaseError wrapper or Error
|
||||
if (error.name === 'DatabaseError') {
|
||||
throw error;
|
||||
}
|
||||
throw new DatabaseError('프로젝트별-작업별 시간 분석 중 오류가 발생했습니다');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
getStats,
|
||||
getDailyTrend,
|
||||
getWorkerStats,
|
||||
getProjectStats,
|
||||
getWorkTypeStats,
|
||||
getRecentWork,
|
||||
getWeekdayPattern,
|
||||
getErrorAnalysis,
|
||||
getMonthlyComparison,
|
||||
getWorkerSpecialization,
|
||||
getDashboardData,
|
||||
getProjectWorkTypeAnalysis
|
||||
};
|
||||
Reference in New Issue
Block a user