fix: 캘린더 모달 중복 카드 문제 및 삭제 권한 개선

- monthly_worker_status 조회 시 GROUP BY로 중복 데이터 합산
- 작업보고서 삭제 권한을 그룹장 이상으로 제한 (admin, system, group_leader)
- 중복 데이터 정리를 위한 마이그레이션 SQL 추가 (009_fix_duplicate_monthly_status.sql)
- synology_deployment 버전에도 동일 수정 적용
This commit is contained in:
Hyungi Ahn
2025-12-02 13:08:44 +09:00
parent beaffcad49
commit a9bce9d20b
419 changed files with 275129 additions and 394 deletions

View File

@@ -0,0 +1,48 @@
// /services/analysisService.js
const analysisModel = require('../models/analysisModel');
/**
* 기간별 프로젝트 분석 데이터를 조회하는 비즈니스 로직을 처리합니다.
* @param {string} startDate - 시작일 (YYYY-MM-DD)
* @param {string} endDate - 종료일 (YYYY-MM-DD)
* @returns {Promise<object>} - 가공된 분석 데이터
*/
const getAnalysisService = async (startDate, endDate) => {
if (!startDate || !endDate) {
throw new Error('분석을 위해 시작일과 종료일이 모두 필요합니다.');
}
try {
const analysisData = await analysisModel.getAnalysis(startDate, endDate);
// 모델에서 받은 데이터를 그대로 반환하거나, 필요 시 추가 가공을 할 수 있습니다.
// 예를 들어, 비율(percentage) 계산을 여기서 수행할 수 있습니다.
const { summary, byProject, byWorker, byTask, details } = analysisData;
const totalHours = summary.totalHours || 0;
const addPercentage = (item) => ({
...item,
hours: parseFloat(item.hours.toFixed(1)),
percentage: totalHours > 0 ? parseFloat((item.hours / totalHours * 100).toFixed(1)) : 0
});
return {
summary: {
...summary,
totalHours: parseFloat(totalHours.toFixed(1))
},
byProject: byProject.map(addPercentage),
byWorker: byWorker.map(addPercentage),
byTask: byTask.map(addPercentage),
details: details.map(d => ({...d, work_hours: parseFloat(d.work_hours.toFixed(1))})),
};
} catch (error) {
console.error('[Service] 분석 데이터 조회 중 오류 발생:', error);
throw error;
}
};
module.exports = {
getAnalysisService
};

View File

@@ -0,0 +1,98 @@
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const userModel = require('../models/userModel');
const { getDb } = require('../dbPool');
// 로그인 이력 기록 (서비스 내부 헬퍼 함수)
const recordLoginHistory = async (userId, success, ipAddress, userAgent, failureReason = null) => {
try {
const db = await getDb();
await db.execute(
`INSERT INTO login_logs (user_id, login_time, ip_address, user_agent, login_status, failure_reason)
VALUES (?, NOW(), ?, ?, ?, ?)`,
[userId, ipAddress || 'unknown', userAgent || 'unknown', success ? 'success' : 'failed', failureReason]
);
} catch (error) {
console.error('로그인 이력 기록 실패:', error);
}
};
const loginService = async (username, password, ipAddress, userAgent) => {
// 서비스 레이어에서는 더 이상 DB 커넥션을 직접 다루지 않음
try {
const user = await userModel.findByUsername(username);
if (!user) {
console.log(`[로그인 실패] 사용자를 찾을 수 없음: ${username}`);
return { success: false, status: 401, error: '아이디 또는 비밀번호가 올바르지 않습니다.' };
}
if (user.is_active === false) {
await recordLoginHistory(user.user_id, false, ipAddress, userAgent, 'account_disabled');
return { success: false, status: 403, error: '비활성화된 계정입니다. 관리자에게 문의하세요.' };
}
if (user.locked_until && new Date(user.locked_until) > new Date()) {
const remainingTime = Math.ceil((new Date(user.locked_until) - new Date()) / 1000 / 60);
return { success: false, status: 429, error: `계정이 잠겨있습니다. ${remainingTime}분 후에 다시 시도하세요.` };
}
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
console.log(`[로그인 실패] 비밀번호 불일치: ${username}`);
// 모델 함수를 사용하여 로그인 실패 처리
await userModel.incrementFailedLoginAttempts(user.user_id);
if (user.failed_login_attempts >= 4) {
await userModel.lockUserAccount(user.user_id);
}
await recordLoginHistory(user.user_id, false, ipAddress, userAgent, 'invalid_password');
return { success: false, status: 401, error: '아이디 또는 비밀번호가 올바르지 않습니다.' };
}
// 성공 시 모델 함수를 사용하여 상태 초기화
await userModel.resetLoginAttempts(user.user_id);
const token = jwt.sign(
{ user_id: user.user_id, username: user.username, role: user.role, access_level: user.access_level, worker_id: user.worker_id, name: user.name || user.username },
process.env.JWT_SECRET || 'your-secret-key',
{ expiresIn: process.env.JWT_EXPIRES_IN || '24h' }
);
const refreshToken = jwt.sign(
{ user_id: user.user_id, type: 'refresh' },
process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET || 'your-refresh-secret',
{ expiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d' }
);
await recordLoginHistory(user.user_id, true, ipAddress, userAgent);
console.log(`[로그인 성공] 사용자: ${user.username} (${user.access_level})`);
return {
success: true,
data: {
token,
refreshToken,
user: {
user_id: user.user_id,
username: user.username,
name: user.name || user.username,
role: user.role,
access_level: user.access_level,
worker_id: user.worker_id
}
}
};
} catch (error) {
console.error('Login service error:', error);
throw new Error('서버 오류가 발생했습니다.');
}
// 서비스 레이어에서는 더 이상 DB 커넥션을 직접 다루지 않음
};
module.exports = {
loginService,
};

View File

@@ -0,0 +1,93 @@
// /services/dailyIssueReportService.js
const dailyIssueReportModel = require('../models/dailyIssueReportModel');
/**
* 일일 이슈 보고서를 생성하는 비즈니스 로직을 처리합니다.
* 한 번에 여러 작업자에 대해 동일한 이슈를 등록할 수 있습니다.
* @param {object} issueData - 컨트롤러에서 전달된 이슈 데이터
* @returns {Promise<object>} 생성 결과
*/
const createDailyIssueReportService = async (issueData) => {
const { date, project_id, start_time, end_time, issue_type_id, worker_ids } = issueData;
// 1. 유효성 검사
if (!date || !project_id || !start_time || !end_time || !issue_type_id || !worker_ids) {
throw new Error('필수 필드가 누락되었습니다.');
}
if (!Array.isArray(worker_ids) || worker_ids.length === 0) {
throw new Error('worker_ids는 최소 한 명 이상의 작업자를 포함하는 배열이어야 합니다.');
}
// 2. 모델에 전달할 데이터 준비
const reportsToCreate = worker_ids.map(worker_id => ({
date,
project_id,
start_time,
end_time,
issue_type_id,
worker_id
}));
// 3. 모델 함수 호출 (모델에 createMany와 같은 함수가 필요)
try {
const insertedIds = await dailyIssueReportModel.createMany(reportsToCreate);
return {
message: `${insertedIds.length}개의 이슈 보고서가 성공적으로 생성되었습니다.`,
issue_report_ids: insertedIds
};
} catch (error) {
console.error('[Service] 이슈 보고서 생성 중 오류 발생:', error);
throw error;
}
};
/**
* 특정 날짜의 모든 이슈 보고서를 조회합니다.
* @param {string} date - 조회할 날짜 (YYYY-MM-DD)
* @returns {Promise<Array>} 조회된 이슈 보고서 배열
*/
const getDailyIssuesByDateService = async (date) => {
if (!date) {
throw new Error('조회를 위해 날짜(date)는 필수입니다.');
}
try {
const issues = await dailyIssueReportModel.getAllByDate(date);
return issues;
} catch (error) {
console.error(`[Service] ${date}의 이슈 보고서 조회 중 오류 발생:`, error);
throw error;
}
};
/**
* 특정 ID의 이슈 보고서를 삭제합니다.
* @param {string} issueId - 삭제할 이슈 보고서의 ID
* @returns {Promise<object>} 삭제 결과
*/
const removeDailyIssueService = async (issueId) => {
if (!issueId) {
throw new Error('삭제를 위해 이슈 보고서 ID가 필요합니다.');
}
try {
const affectedRows = await dailyIssueReportModel.remove(issueId);
if (affectedRows === 0) {
const notFoundError = new Error('삭제할 이슈 보고서를 찾을 수 없습니다.');
notFoundError.statusCode = 404;
throw notFoundError;
}
return {
message: '이슈 보고서가 성공적으로 삭제되었습니다.',
deleted_id: issueId,
affected_rows: affectedRows
};
} catch (error) {
console.error(`[Service] 이슈 보고서(id: ${issueId}) 삭제 중 오류 발생:`, error);
throw error;
}
};
module.exports = {
createDailyIssueReportService,
getDailyIssuesByDateService,
removeDailyIssueService,
};

View File

@@ -0,0 +1,282 @@
const dailyWorkReportModel = require('../models/dailyWorkReportModel');
/**
* 일일 작업 보고서를 생성하는 비즈니스 로직을 처리합니다.
* @param {object} reportData - 컨트롤러에서 전달된 보고서 데이터
* @returns {Promise<object>} 생성 결과 또는 에러
*/
const createDailyWorkReportService = async (reportData) => {
const { report_date, worker_id, work_entries, created_by, created_by_name } = reportData;
// 1. 기본 유효성 검사
if (!report_date || !worker_id || !work_entries) {
throw new Error('필수 필드(report_date, worker_id, work_entries)가 누락되었습니다.');
}
if (!Array.isArray(work_entries) || work_entries.length === 0) {
throw new Error('최소 하나의 작업 항목(work_entries)이 필요합니다.');
}
if (!created_by) {
throw new Error('사용자 인증 정보(created_by)가 없습니다.');
}
// 2. 작업 항목 유효성 검사
for (let i = 0; i < work_entries.length; i++) {
const entry = work_entries[i];
const requiredFields = ['project_id', 'task_id', 'work_hours'];
for (const field of requiredFields) {
if (entry[field] === undefined || entry[field] === null || entry[field] === '') {
throw new Error(`작업 항목 ${i + 1}의 '${field}' 필드가 누락되었습니다.`);
}
}
// is_error가 true일 때 error_type_code_id 필수 검사
if (entry.is_error === true && !entry.error_type_code_id) {
throw new Error(`작업 항목 ${i + 1}이 에러 상태인 경우 'error_type_code_id'가 필요합니다.`);
}
const hours = parseFloat(entry.work_hours);
if (isNaN(hours) || hours <= 0 || hours > 24) {
throw new Error(`작업 항목 ${i + 1}의 작업 시간이 유효하지 않습니다 (0 초과 24 이하).`);
}
}
// 3. 모델에 전달할 데이터 준비
const modelData = {
report_date,
worker_id: parseInt(worker_id),
entries: work_entries.map(entry => ({
project_id: entry.project_id,
work_type_id: entry.task_id, // task_id를 work_type_id로 매핑
work_hours: parseFloat(entry.work_hours),
work_status_id: entry.work_status_id,
error_type_id: entry.error_type_id,
created_by: created_by
}))
};
console.log('📝 [Service] 작업보고서 생성 요청:', {
date: report_date,
worker: worker_id,
creator: created_by_name,
entries_count: modelData.entries.length
});
// 4. 모델 함수 호출
try {
const result = await dailyWorkReportModel.createReportEntries(modelData);
console.log('✅ [Service] 작업보고서 생성 성공:', result);
return {
message: '작업보고서가 성공적으로 생성되었습니다.',
...result
};
} catch (error) {
console.error('[Service] 작업보고서 생성 중 오류 발생:', error);
// 모델에서 발생한 오류를 그대로 상위로 전달
throw error;
}
};
/**
* 사용자 권한과 요청 파라미터에 따라 일일 작업 보고서를 조회하는 비즈니스 로직을 처리합니다.
* @param {object} queryParams - 컨트롤러에서 전달된 쿼리 파라미터
* @param {object} userInfo - 요청을 보낸 사용자의 정보 (id, role 등)
* @returns {Promise<Array>} 조회된 작업 보고서 배열
*/
const getDailyWorkReportsService = async (queryParams, userInfo) => {
const { date, start_date, end_date, worker_id, created_by: requested_created_by, view_all } = queryParams;
const { user_id: current_user_id, role } = userInfo;
// 날짜 또는 날짜 범위 중 하나는 필수
if (!date && (!start_date || !end_date)) {
throw new Error('조회를 위해 날짜(date) 또는 날짜 범위(start_date, end_date)가 필요합니다.');
}
// 관리자 여부 확인
const isAdmin = role === 'system' || role === 'admin';
const canViewAll = isAdmin || view_all === 'true';
// 모델에 전달할 조회 옵션 객체 생성
const options = {};
if (date) {
options.date = date;
} else {
options.start_date = start_date;
options.end_date = end_date;
}
if (worker_id) {
options.worker_id = parseInt(worker_id);
}
// 최종적으로 필터링할 작성자 ID 결정
if (!canViewAll) {
// 관리자가 아니면 자신의 데이터만 보거나, 명시적으로 요청된 자신의 ID만 허용
options.created_by_user_id = requested_created_by ? Math.min(requested_created_by, current_user_id) : current_user_id;
} else if (requested_created_by) {
// 관리자는 다른 사람의 데이터도 조회 가능
options.created_by_user_id = parseInt(requested_created_by);
}
// created_by_user_id가 명시되지 않으면 모든 작성자의 데이터를 조회
console.log('📊 [Service] 작업보고서 조회 요청:', { ...options, requester: current_user_id, isAdmin });
try {
// 모델 함수 호출
const reports = await dailyWorkReportModel.getReportsWithOptions(options);
console.log(`✅ [Service] 작업보고서 ${reports.length}개 조회 성공`);
return reports;
} catch (error) {
console.error('[Service] 작업보고서 조회 중 오류 발생:', error);
throw error;
}
};
/**
* 특정 작업 보고서 항목을 수정하는 비즈니스 로직을 처리합니다.
* @param {string} reportId - 수정할 보고서의 ID
* @param {object} updateData - 수정할 데이터
* @param {object} userInfo - 요청을 보낸 사용자의 정보
* @returns {Promise<object>} 수정 결과
*/
const updateWorkReportService = async (reportId, updateData, userInfo) => {
const { user_id: updated_by } = userInfo;
if (!reportId || !updateData || Object.keys(updateData).length === 0) {
throw new Error('수정을 위해 보고서 ID와 수정할 데이터가 필요합니다.');
}
const modelUpdateData = { ...updateData, updated_by_user_id: updated_by };
console.log(`✏️ [Service] 작업보고서 수정 요청: id=${reportId}`);
try {
const affectedRows = await dailyWorkReportModel.updateReportById(reportId, modelUpdateData);
if (affectedRows === 0) {
// 에러를 발생시켜 컨트롤러에서 404 처리를 할 수 있도록 함
const notFoundError = new Error('수정할 작업보고서를 찾을 수 없습니다.');
notFoundError.statusCode = 404;
throw notFoundError;
}
console.log(`✅ [Service] 작업보고서 수정 성공: id=${reportId}`);
return {
message: '작업보고서가 성공적으로 수정되었습니다.',
report_id: reportId,
affected_rows: affectedRows
};
} catch (error) {
console.error(`[Service] 작업보고서 수정 중 오류 발생 (id: ${reportId}):`, error);
throw error;
}
};
/**
* 특정 작업 보고서 항목을 삭제하는 비즈니스 로직을 처리합니다.
* @param {string} reportId - 삭제할 보고서의 ID
* @param {object} userInfo - 요청을 보낸 사용자의 정보
* @returns {Promise<object>} 삭제 결과
*/
const removeDailyWorkReportService = async (reportId, userInfo) => {
const { user_id: deleted_by } = userInfo;
if (!reportId) {
throw new Error('삭제를 위해 보고서 ID가 필요합니다.');
}
console.log(`🗑️ [Service] 작업보고서 삭제 요청: id=${reportId}`);
try {
// 모델 함수는 삭제 전 권한 검사를 위해 deleted_by 정보를 받을 수 있습니다 (현재 모델에서는 미사용).
const affectedRows = await dailyWorkReportModel.removeReportById(reportId, deleted_by);
if (affectedRows === 0) {
const notFoundError = new Error('삭제할 작업보고서를 찾을 수 없습니다.');
notFoundError.statusCode = 404;
throw notFoundError;
}
console.log(`✅ [Service] 작업보고서 삭제 성공: id=${reportId}`);
return {
message: '작업보고서가 성공적으로 삭제되었습니다.',
report_id: reportId,
affected_rows: affectedRows
};
} catch (error) {
console.error(`[Service] 작업보고서 삭제 중 오류 발생 (id: ${reportId}):`, error);
throw error;
}
};
/**
* 기간별 작업 보고서 통계를 조회하는 비즈니스 로직을 처리합니다.
* @param {object} queryParams - 컨트롤러에서 전달된 쿼리 파라미터 (start_date, end_date)
* @returns {Promise<object>} 통계 데이터
*/
const getStatisticsService = async (queryParams) => {
const { start_date, end_date } = queryParams;
if (!start_date || !end_date) {
throw new Error('통계 조회를 위해 시작일(start_date)과 종료일(end_date)이 모두 필요합니다.');
}
console.log(`📈 [Service] 통계 조회 요청: ${start_date} ~ ${end_date}`);
try {
// 모델의 getStatistics 함수가 Promise를 반환하도록 수정 필요
const statsData = await dailyWorkReportModel.getStatistics(start_date, end_date);
console.log('✅ [Service] 통계 조회 성공');
return {
...statsData,
metadata: {
period: `${start_date} ~ ${end_date}`,
timestamp: new Date().toISOString()
}
};
} catch (error) {
console.error('[Service] 통계 조회 중 오류 발생:', error);
throw error;
}
};
/**
* 일일 또는 작업자별 작업 요약 정보를 조회하는 비즈니스 로직을 처리합니다.
* @param {object} queryParams - 컨트롤러에서 전달된 쿼리 파라미터 (date 또는 worker_id)
* @returns {Promise<object>} 요약 데이터
*/
const getSummaryService = async (queryParams) => {
const { date, worker_id } = queryParams;
if (!date && !worker_id) {
throw new Error('일일 또는 작업자별 요약 조회를 위해 날짜(date) 또는 작업자 ID(worker_id)가 필요합니다.');
}
try {
if (date) {
console.log(`📊 [Service] 일일 요약 조회 요청: date=${date}`);
// 모델의 getSummaryByDate 함수가 Promise를 반환하도록 수정 필요
return await dailyWorkReportModel.getSummaryByDate(date);
} else { // worker_id
console.log(`📊 [Service] 작업자별 요약 조회 요청: worker_id=${worker_id}`);
// 모델의 getSummaryByWorker 함수가 Promise를 반환하도록 수정 필요
return await dailyWorkReportModel.getSummaryByWorker(worker_id);
}
} catch (error) {
console.error('[Service] 요약 정보 조회 중 오류 발생:', error);
throw error;
}
};
module.exports = {
createDailyWorkReportService,
getDailyWorkReportsService,
updateWorkReportService,
removeDailyWorkReportService,
getStatisticsService,
getSummaryService,
};