feat: 다수 기능 개선 - 순찰, 출근, 작업분석, 모바일 UI 등
- 순찰/점검 기능 개선 (zone-detail 페이지 추가) - 출근/근태 시스템 개선 (연차 조회, 근무현황) - 작업분석 대분류 그룹화 및 마이그레이션 스크립트 - 모바일 네비게이션 UI 추가 - NAS 배포 도구 및 문서 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 프로젝트 분석 서비스
|
||||
*
|
||||
* 기간별 프로젝트, 작업자, 작업 유형 분석 데이터 처리
|
||||
*
|
||||
* @author TK-FB-Project
|
||||
* @since 2025-12-11
|
||||
*/
|
||||
|
||||
const analysisModel = require('../models/analysisModel');
|
||||
const { ValidationError, DatabaseError } = require('../utils/errors');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* 기간별 프로젝트 분석 데이터 조회
|
||||
*
|
||||
* @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 ValidationError('시작일과 종료일이 필요합니다', {
|
||||
required: ['startDate', 'endDate'],
|
||||
received: { startDate, endDate }
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('분석 데이터 조회 요청', { startDate, endDate });
|
||||
|
||||
try {
|
||||
const analysisData = await analysisModel.getAnalysis(startDate, endDate);
|
||||
|
||||
const { summary, byProject, byWorker, byTask, details } = analysisData;
|
||||
const totalHours = summary.totalHours || 0;
|
||||
|
||||
// 비율(percentage) 계산 헬퍼 함수
|
||||
const addPercentage = (item) => ({
|
||||
...item,
|
||||
hours: parseFloat(item.hours.toFixed(1)),
|
||||
percentage: totalHours > 0 ? parseFloat((item.hours / totalHours * 100).toFixed(1)) : 0
|
||||
});
|
||||
|
||||
const result = {
|
||||
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))
|
||||
}))
|
||||
};
|
||||
|
||||
logger.info('분석 데이터 조회 성공', {
|
||||
startDate,
|
||||
endDate,
|
||||
totalHours: result.summary.totalHours,
|
||||
projectCount: result.byProject.length,
|
||||
workerCount: result.byWorker.length,
|
||||
detailCount: result.details.length
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('분석 데이터 조회 실패', {
|
||||
startDate,
|
||||
endDate,
|
||||
error: error.message
|
||||
});
|
||||
throw new DatabaseError('분석 데이터 조회 중 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getAnalysisService
|
||||
};
|
||||
394
deploy/tkfb-package/api.hyungi.net/services/attendanceService.js
Normal file
394
deploy/tkfb-package/api.hyungi.net/services/attendanceService.js
Normal file
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* 근태 관리 서비스
|
||||
*
|
||||
* 근태 기록 관련 비즈니스 로직 처리
|
||||
*
|
||||
* @author TK-FB-Project
|
||||
* @since 2025-12-11
|
||||
*/
|
||||
|
||||
const AttendanceModel = require('../models/attendanceModel');
|
||||
const vacationBalanceModel = require('../models/vacationBalanceModel');
|
||||
const { ValidationError, NotFoundError, DatabaseError } = require('../utils/errors');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* 휴가 사용 유형 ID를 차감 일수로 변환
|
||||
* vacation_type_id: 1=연차(1일), 2=반차(0.5일), 3=반반차(0.25일)
|
||||
*/
|
||||
const getVacationDays = (vacationTypeId) => {
|
||||
const daysMap = { 1: 1, 2: 0.5, 3: 0.25 };
|
||||
return daysMap[vacationTypeId] || 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* 일일 근태 현황 조회
|
||||
*/
|
||||
const getDailyAttendanceStatusService = async (date) => {
|
||||
if (!date) {
|
||||
throw new ValidationError('날짜가 필요합니다', {
|
||||
required: ['date'],
|
||||
received: { date }
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('일일 근태 현황 조회 요청', { date });
|
||||
|
||||
try {
|
||||
// 조회 전 초기화 수행 (Lazy Initialization)
|
||||
// 생성자는 시스템(1) 또는 요청자가 될 수 있으나, 여기서는 안전하게 1(System/Admin) 사용
|
||||
// 혹은 req.user가 없으므로 서비스 레벨에서는 1로 가정하거나 파라미터로 받아야 함.
|
||||
// 서비스 인터페이스 변경 최소화를 위해 하드코딩 또는 안전장치.
|
||||
await AttendanceModel.initializeDailyRecords(date, 1);
|
||||
|
||||
const attendanceStatus = await AttendanceModel.getWorkerAttendanceStatus(date);
|
||||
logger.info('일일 근태 현황 조회 성공', { date, count: attendanceStatus.length });
|
||||
return attendanceStatus;
|
||||
} catch (error) {
|
||||
logger.error('일일 근태 현황 조회 실패', { date, error: error.message });
|
||||
throw new DatabaseError('근태 현황 조회 중 데이터베이스 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 일일 근태 기록 조회
|
||||
*/
|
||||
const getDailyAttendanceRecordsService = async (date, workerId = null) => {
|
||||
if (!date) {
|
||||
throw new ValidationError('날짜가 필요합니다', {
|
||||
required: ['date'],
|
||||
received: { date }
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('일일 근태 기록 조회 요청', { date, workerId });
|
||||
|
||||
try {
|
||||
const records = await AttendanceModel.getDailyAttendanceRecords(date, workerId);
|
||||
logger.info('일일 근태 기록 조회 성공', { date, count: records.length });
|
||||
return records;
|
||||
} catch (error) {
|
||||
logger.error('일일 근태 기록 조회 실패', { date, error: error.message });
|
||||
throw new DatabaseError('근태 기록 조회 중 데이터베이스 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 기간별 근태 기록 조회 (월별 조회용)
|
||||
*/
|
||||
const getAttendanceRecordsByRangeService = async (startDate, endDate, workerId = null) => {
|
||||
if (!startDate || !endDate) {
|
||||
throw new ValidationError('시작 날짜와 종료 날짜가 필요합니다', {
|
||||
required: ['start_date', 'end_date'],
|
||||
received: { startDate, endDate }
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('기간별 근태 기록 조회 요청', { startDate, endDate, workerId });
|
||||
|
||||
try {
|
||||
const records = await AttendanceModel.getDailyRecords(startDate, endDate, workerId);
|
||||
logger.info('기간별 근태 기록 조회 성공', { startDate, endDate, count: records.length });
|
||||
return records;
|
||||
} catch (error) {
|
||||
logger.error('기간별 근태 기록 조회 실패', { startDate, endDate, error: error.message });
|
||||
throw new DatabaseError('근태 기록 조회 중 데이터베이스 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 근태 기록 생성/업데이트
|
||||
* - 휴가 기록 시 vacation_balance_details의 used_days 자동 연동
|
||||
*/
|
||||
const upsertAttendanceRecordService = async (recordData) => {
|
||||
const {
|
||||
record_date,
|
||||
worker_id,
|
||||
total_work_hours,
|
||||
attendance_type_id,
|
||||
vacation_type_id,
|
||||
is_vacation_processed,
|
||||
overtime_approved,
|
||||
status,
|
||||
notes,
|
||||
created_by
|
||||
} = recordData;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!record_date || !worker_id) {
|
||||
throw new ValidationError('필수 필드가 누락되었습니다', {
|
||||
required: ['record_date', 'worker_id'],
|
||||
received: { record_date, worker_id }
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('근태 기록 저장 요청', { record_date, worker_id, vacation_type_id });
|
||||
|
||||
try {
|
||||
// 1. 기존 기록 조회 (휴가 연동을 위해)
|
||||
const existingRecords = await AttendanceModel.getDailyAttendanceRecords(record_date, worker_id);
|
||||
const existingRecord = existingRecords.find(r => r.worker_id === worker_id);
|
||||
const previousVacationTypeId = existingRecord?.vacation_type_id || null;
|
||||
|
||||
// 2. 근태 기록 저장
|
||||
const result = await AttendanceModel.upsertAttendanceRecord({
|
||||
record_date,
|
||||
worker_id,
|
||||
total_work_hours,
|
||||
work_attendance_type_id: attendance_type_id,
|
||||
vacation_type_id,
|
||||
is_overtime_approved: overtime_approved,
|
||||
created_by
|
||||
});
|
||||
|
||||
// 3. 휴가 잔액 연동 (vacation_balance_details.used_days 업데이트)
|
||||
const year = new Date(record_date).getFullYear();
|
||||
const previousDays = getVacationDays(previousVacationTypeId);
|
||||
const newDays = getVacationDays(vacation_type_id);
|
||||
|
||||
// 이전 휴가가 있었고 변경된 경우 → 복구 후 차감
|
||||
if (previousDays !== newDays) {
|
||||
// 이전 휴가 복구
|
||||
if (previousDays > 0) {
|
||||
await vacationBalanceModel.restoreByPriority(worker_id, year, previousDays);
|
||||
logger.info('휴가 잔액 복구', { worker_id, year, restored: previousDays });
|
||||
}
|
||||
|
||||
// 새 휴가 차감
|
||||
if (newDays > 0) {
|
||||
await vacationBalanceModel.deductByPriority(worker_id, year, newDays);
|
||||
logger.info('휴가 잔액 차감', { worker_id, year, deducted: newDays });
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('근태 기록 저장 성공', { record_date, worker_id });
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('근태 기록 저장 실패', { record_date, worker_id, error: error.message });
|
||||
throw new DatabaseError('근태 기록 저장 중 데이터베이스 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 휴가 처리
|
||||
*/
|
||||
const processVacationService = async (vacationData) => {
|
||||
const { record_date, worker_id, vacation_type_id } = vacationData;
|
||||
|
||||
if (!record_date || !worker_id || !vacation_type_id) {
|
||||
throw new ValidationError('필수 필드가 누락되었습니다', {
|
||||
required: ['record_date', 'worker_id', 'vacation_type_id'],
|
||||
received: { record_date, worker_id, vacation_type_id }
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('휴가 처리 요청', { record_date, worker_id, vacation_type_id });
|
||||
|
||||
try {
|
||||
const result = await AttendanceModel.processVacation({
|
||||
record_date,
|
||||
worker_id,
|
||||
vacation_type_id
|
||||
});
|
||||
|
||||
logger.info('휴가 처리 성공', { record_date, worker_id });
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('휴가 처리 실패', { record_date, worker_id, error: error.message });
|
||||
throw new DatabaseError('휴가 처리 중 데이터베이스 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 초과 근무 승인
|
||||
*/
|
||||
const approveOvertimeService = async (overtimeData) => {
|
||||
const { record_date, worker_id, overtime_approved } = overtimeData;
|
||||
|
||||
if (!record_date || !worker_id || overtime_approved === undefined) {
|
||||
throw new ValidationError('필수 필드가 누락되었습니다', {
|
||||
required: ['record_date', 'worker_id', 'overtime_approved'],
|
||||
received: { record_date, worker_id, overtime_approved }
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('초과 근무 승인 요청', { record_date, worker_id, overtime_approved });
|
||||
|
||||
try {
|
||||
const result = await AttendanceModel.approveOvertime({
|
||||
record_date,
|
||||
worker_id,
|
||||
overtime_approved
|
||||
});
|
||||
|
||||
logger.info('초과 근무 승인 처리 성공', { record_date, worker_id });
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('초과 근무 승인 실패', { record_date, worker_id, error: error.message });
|
||||
throw new DatabaseError('초과 근무 승인 중 데이터베이스 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 근태 유형 목록 조회
|
||||
*/
|
||||
const getAttendanceTypesService = async () => {
|
||||
logger.info('근태 유형 목록 조회 요청');
|
||||
|
||||
try {
|
||||
const types = await AttendanceModel.getAttendanceTypes();
|
||||
logger.info('근태 유형 목록 조회 성공', { count: types.length });
|
||||
return types;
|
||||
} catch (error) {
|
||||
logger.error('근태 유형 목록 조회 실패', { error: error.message });
|
||||
throw new DatabaseError('근태 유형 목록 조회 중 데이터베이스 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 휴가 유형 목록 조회
|
||||
*/
|
||||
const getVacationTypesService = async () => {
|
||||
logger.info('휴가 유형 목록 조회 요청');
|
||||
|
||||
try {
|
||||
const types = await AttendanceModel.getVacationTypes();
|
||||
logger.info('휴가 유형 목록 조회 성공', { count: types.length });
|
||||
return types;
|
||||
} catch (error) {
|
||||
logger.error('휴가 유형 목록 조회 실패', { error: error.message });
|
||||
throw new DatabaseError('휴가 유형 목록 조회 중 데이터베이스 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 작업자 휴가 잔여 일수 조회
|
||||
*/
|
||||
const getWorkerVacationBalanceService = async (workerId) => {
|
||||
if (!workerId) {
|
||||
throw new ValidationError('작업자 ID가 필요합니다', {
|
||||
required: ['worker_id'],
|
||||
received: { workerId }
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('휴가 잔여 일수 조회 요청', { workerId });
|
||||
|
||||
try {
|
||||
const balance = await AttendanceModel.getWorkerVacationBalance(workerId);
|
||||
logger.info('휴가 잔여 일수 조회 성공', { workerId });
|
||||
return balance;
|
||||
} catch (error) {
|
||||
logger.error('휴가 잔여 일수 조회 실패', { workerId, error: error.message });
|
||||
throw new DatabaseError('휴가 잔여 일수 조회 중 데이터베이스 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 월별 근태 통계 조회
|
||||
*/
|
||||
const getMonthlyAttendanceStatsService = async (year, month, workerId = null) => {
|
||||
if (!year || !month) {
|
||||
throw new ValidationError('연도와 월이 필요합니다', {
|
||||
required: ['year', 'month'],
|
||||
received: { year, month }
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('월별 근태 통계 조회 요청', { year, month, workerId });
|
||||
|
||||
try {
|
||||
const stats = await AttendanceModel.getMonthlyAttendanceStats(year, month, workerId);
|
||||
logger.info('월별 근태 통계 조회 성공', { year, month });
|
||||
return stats;
|
||||
} catch (error) {
|
||||
logger.error('월별 근태 통계 조회 실패', { year, month, error: error.message });
|
||||
throw new DatabaseError('월별 근태 통계 조회 중 데이터베이스 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 출근 체크 목록 조회 (휴가 정보 포함)
|
||||
*/
|
||||
const getCheckinListService = async (date) => {
|
||||
if (!date) {
|
||||
throw new ValidationError('날짜가 필요합니다', {
|
||||
required: ['date'],
|
||||
received: { date }
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('출근 체크 목록 조회 요청', { date });
|
||||
|
||||
try {
|
||||
const checkinList = await AttendanceModel.getCheckinList(date);
|
||||
logger.info('출근 체크 목록 조회 성공', { date, count: checkinList.length });
|
||||
return checkinList;
|
||||
} catch (error) {
|
||||
logger.error('출근 체크 목록 조회 실패', { date, error: error.message });
|
||||
throw new DatabaseError('출근 체크 목록 조회 중 데이터베이스 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 출근 체크 일괄 저장
|
||||
*/
|
||||
const saveCheckinsService = async (date, checkins) => {
|
||||
if (!date || !checkins || !Array.isArray(checkins)) {
|
||||
throw new ValidationError('날짜와 출근 체크 목록이 필요합니다', {
|
||||
required: ['date', 'checkins'],
|
||||
received: { date, checkins: checkins ? `Array[${checkins.length}]` : null }
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('출근 체크 일괄 저장 요청', { date, count: checkins.length });
|
||||
|
||||
try {
|
||||
const results = [];
|
||||
|
||||
for (const checkin of checkins) {
|
||||
const { worker_id, is_present } = checkin;
|
||||
|
||||
if (!worker_id || is_present === undefined) {
|
||||
logger.warn('출근 체크 데이터 누락', { checkin });
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await AttendanceModel.upsertCheckin({
|
||||
worker_id,
|
||||
record_date: date,
|
||||
is_present
|
||||
});
|
||||
|
||||
results.push({
|
||||
worker_id,
|
||||
record_id: result,
|
||||
is_present
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('출근 체크 일괄 저장 성공', { date, saved: results.length });
|
||||
return {
|
||||
saved_count: results.length,
|
||||
results
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('출근 체크 일괄 저장 실패', { date, error: error.message });
|
||||
throw new DatabaseError('출근 체크 저장 중 데이터베이스 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getDailyAttendanceStatusService,
|
||||
getDailyAttendanceRecordsService,
|
||||
getAttendanceRecordsByRangeService,
|
||||
upsertAttendanceRecordService,
|
||||
processVacationService,
|
||||
approveOvertimeService,
|
||||
getAttendanceTypesService,
|
||||
getVacationTypesService,
|
||||
getWorkerVacationBalanceService,
|
||||
getMonthlyAttendanceStatsService,
|
||||
getCheckinListService,
|
||||
saveCheckinsService
|
||||
};
|
||||
98
deploy/tkfb-package/api.hyungi.net/services/auth.service.js
Normal file
98
deploy/tkfb-package/api.hyungi.net/services/auth.service.js
Normal 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.query(
|
||||
`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_name, role_id: user.role_id, 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_name,
|
||||
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,
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 일일 이슈 보고서 관리 서비스
|
||||
*
|
||||
* 일일 이슈 보고서 생성, 조회, 삭제 관련 비즈니스 로직 처리
|
||||
*
|
||||
* @author TK-FB-Project
|
||||
* @since 2025-12-11
|
||||
*/
|
||||
|
||||
const dailyIssueReportModel = require('../models/dailyIssueReportModel');
|
||||
const { ValidationError, NotFoundError, DatabaseError } = require('../utils/errors');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* 일일 이슈 보고서 생성
|
||||
*
|
||||
* 한 번에 여러 작업자에 대해 동일한 이슈를 등록할 수 있습니다.
|
||||
*
|
||||
* @param {object} issueData - 컨트롤러에서 전달된 이슈 데이터
|
||||
* @param {string} issueData.date - 이슈 발생 날짜 (YYYY-MM-DD)
|
||||
* @param {number} issueData.project_id - 프로젝트 ID
|
||||
* @param {string} issueData.start_time - 이슈 시작 시간
|
||||
* @param {string} issueData.end_time - 이슈 종료 시간
|
||||
* @param {number} issueData.issue_type_id - 이슈 유형 ID
|
||||
* @param {number[]} issueData.worker_ids - 작업자 ID 배열
|
||||
* @returns {Promise<object>} 생성 결과
|
||||
*/
|
||||
const createDailyIssueReportService = async (issueData) => {
|
||||
const { date, project_id, start_time, end_time, issue_type_id, worker_ids } = issueData;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!date || !project_id || !start_time || !end_time || !issue_type_id || !worker_ids) {
|
||||
throw new ValidationError('필수 필드가 누락되었습니다', {
|
||||
required: ['date', 'project_id', 'start_time', 'end_time', 'issue_type_id', 'worker_ids'],
|
||||
received: { date, project_id, start_time, end_time, issue_type_id, worker_ids: !!worker_ids }
|
||||
});
|
||||
}
|
||||
|
||||
if (!Array.isArray(worker_ids) || worker_ids.length === 0) {
|
||||
throw new ValidationError('worker_ids는 최소 한 명 이상의 작업자를 포함하는 배열이어야 합니다', {
|
||||
received: { worker_ids, isArray: Array.isArray(worker_ids), length: worker_ids?.length }
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('이슈 보고서 생성 요청', {
|
||||
date,
|
||||
project_id,
|
||||
issue_type_id,
|
||||
worker_count: worker_ids.length
|
||||
});
|
||||
|
||||
// 모델에 전달할 데이터 준비
|
||||
const reportsToCreate = worker_ids.map(worker_id => ({
|
||||
date,
|
||||
project_id,
|
||||
start_time,
|
||||
end_time,
|
||||
issue_type_id,
|
||||
worker_id
|
||||
}));
|
||||
|
||||
try {
|
||||
const insertedIds = await dailyIssueReportModel.createMany(reportsToCreate);
|
||||
|
||||
logger.info('이슈 보고서 생성 성공', {
|
||||
count: insertedIds.length,
|
||||
issue_report_ids: insertedIds
|
||||
});
|
||||
|
||||
return {
|
||||
message: `${insertedIds.length}개의 이슈 보고서가 성공적으로 생성되었습니다`,
|
||||
issue_report_ids: insertedIds
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('이슈 보고서 생성 실패', {
|
||||
date,
|
||||
project_id,
|
||||
worker_ids,
|
||||
error: error.message
|
||||
});
|
||||
throw new DatabaseError('이슈 보고서 생성 중 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 특정 날짜의 모든 이슈 보고서 조회
|
||||
*
|
||||
* @param {string} date - 조회할 날짜 (YYYY-MM-DD)
|
||||
* @returns {Promise<Array>} 조회된 이슈 보고서 배열
|
||||
*/
|
||||
const getDailyIssuesByDateService = async (date) => {
|
||||
if (!date) {
|
||||
throw new ValidationError('날짜가 필요합니다', {
|
||||
required: ['date'],
|
||||
received: { date }
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('이슈 보고서 날짜별 조회 요청', { date });
|
||||
|
||||
try {
|
||||
const issues = await dailyIssueReportModel.getAllByDate(date);
|
||||
|
||||
logger.info('이슈 보고서 조회 성공', {
|
||||
date,
|
||||
count: issues.length
|
||||
});
|
||||
|
||||
return issues;
|
||||
} catch (error) {
|
||||
logger.error('이슈 보고서 조회 실패', {
|
||||
date,
|
||||
error: error.message
|
||||
});
|
||||
throw new DatabaseError('이슈 보고서 조회 중 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 특정 ID의 이슈 보고서 삭제
|
||||
*
|
||||
* @param {string|number} issueId - 삭제할 이슈 보고서의 ID
|
||||
* @returns {Promise<object>} 삭제 결과
|
||||
*/
|
||||
const removeDailyIssueService = async (issueId) => {
|
||||
if (!issueId) {
|
||||
throw new ValidationError('이슈 보고서 ID가 필요합니다', {
|
||||
required: ['issue_id'],
|
||||
received: { issueId }
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('이슈 보고서 삭제 요청', { issue_id: issueId });
|
||||
|
||||
try {
|
||||
const affectedRows = await dailyIssueReportModel.remove(issueId);
|
||||
|
||||
if (affectedRows === 0) {
|
||||
logger.warn('삭제할 이슈 보고서를 찾을 수 없음', { issue_id: issueId });
|
||||
throw new NotFoundError('삭제할 이슈 보고서를 찾을 수 없습니다');
|
||||
}
|
||||
|
||||
logger.info('이슈 보고서 삭제 성공', {
|
||||
issue_id: issueId,
|
||||
affected_rows: affectedRows
|
||||
});
|
||||
|
||||
return {
|
||||
message: '이슈 보고서가 성공적으로 삭제되었습니다',
|
||||
deleted_id: issueId,
|
||||
affected_rows: affectedRows
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.error('이슈 보고서 삭제 실패', {
|
||||
issue_id: issueId,
|
||||
error: error.message
|
||||
});
|
||||
throw new DatabaseError('이슈 보고서 삭제 중 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createDailyIssueReportService,
|
||||
getDailyIssuesByDateService,
|
||||
removeDailyIssueService
|
||||
};
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* 일일 작업 보고서 서비스
|
||||
*
|
||||
* 작업 보고서 관련 비즈니스 로직 처리
|
||||
*
|
||||
* @author TK-FB-Project
|
||||
* @since 2025-12-11
|
||||
*/
|
||||
|
||||
const dailyWorkReportModel = require('../models/dailyWorkReportModel');
|
||||
const { ValidationError, NotFoundError, ForbiddenError, DatabaseError } = require('../utils/errors');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* 일일 작업 보고서를 생성하는 비즈니스 로직을 처리합니다.
|
||||
* @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 ValidationError('필수 필드가 누락되었습니다', {
|
||||
required: ['report_date', 'worker_id', 'work_entries'],
|
||||
received: { report_date, worker_id, work_entries: !!work_entries }
|
||||
});
|
||||
}
|
||||
|
||||
if (!Array.isArray(work_entries) || work_entries.length === 0) {
|
||||
throw new ValidationError('최소 하나의 작업 항목이 필요합니다');
|
||||
}
|
||||
|
||||
if (!created_by) {
|
||||
throw new ValidationError('사용자 인증 정보가 없습니다');
|
||||
}
|
||||
|
||||
// 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 ValidationError(`작업 항목 ${i + 1}의 필수 필드가 누락되었습니다`, {
|
||||
entry: i + 1,
|
||||
missingField: field
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// is_error가 true일 때 error_type_code_id 필수 검사
|
||||
if (entry.is_error === true && !entry.error_type_code_id) {
|
||||
throw new ValidationError(`에러 상태인 경우 에러 타입이 필요합니다`, {
|
||||
entry: i + 1,
|
||||
is_error: true
|
||||
});
|
||||
}
|
||||
|
||||
const hours = parseFloat(entry.work_hours);
|
||||
if (isNaN(hours) || hours <= 0 || hours > 24) {
|
||||
throw new ValidationError(`작업 시간이 유효하지 않습니다 (0 초과 24 이하)`, {
|
||||
entry: i + 1,
|
||||
work_hours: entry.work_hours,
|
||||
valid_range: '0 < hours <= 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
|
||||
}))
|
||||
};
|
||||
|
||||
logger.info('작업보고서 생성 요청', {
|
||||
date: report_date,
|
||||
worker: worker_id,
|
||||
creator: created_by_name,
|
||||
entries_count: modelData.entries.length
|
||||
});
|
||||
|
||||
// 4. 모델 함수 호출
|
||||
try {
|
||||
const result = await dailyWorkReportModel.createReportEntries(modelData);
|
||||
|
||||
logger.info('작업보고서 생성 성공', {
|
||||
report_id: result.report_id,
|
||||
entries_count: modelData.entries.length
|
||||
});
|
||||
|
||||
return {
|
||||
message: '작업보고서가 성공적으로 생성되었습니다.',
|
||||
...result
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('작업보고서 생성 실패', {
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
throw new DatabaseError('작업보고서 생성 중 데이터베이스 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 사용자 권한과 요청 파라미터에 따라 일일 작업 보고서를 조회하는 비즈니스 로직을 처리합니다.
|
||||
* @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 ValidationError('날짜 또는 날짜 범위가 필요합니다', {
|
||||
required: 'date OR (start_date AND end_date)',
|
||||
received: { 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가 명시되지 않으면 모든 작성자의 데이터를 조회
|
||||
|
||||
logger.info('작업보고서 조회 요청', { ...options, requester: current_user_id, isAdmin });
|
||||
|
||||
try {
|
||||
const reports = await dailyWorkReportModel.getReportsWithOptions(options);
|
||||
logger.info('작업보고서 조회 성공', { count: reports.length });
|
||||
return reports;
|
||||
} catch (error) {
|
||||
logger.error('작업보고서 조회 실패', { error: error.message });
|
||||
throw new DatabaseError('작업보고서 조회 중 데이터베이스 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 특정 작업 보고서 항목을 수정하는 비즈니스 로직을 처리합니다.
|
||||
* @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 ValidationError('보고서 ID와 수정할 데이터가 필요합니다', {
|
||||
reportId,
|
||||
hasUpdateData: !!updateData,
|
||||
updateFieldsCount: updateData ? Object.keys(updateData).length : 0
|
||||
});
|
||||
}
|
||||
|
||||
const modelUpdateData = { ...updateData, updated_by_user_id: updated_by };
|
||||
|
||||
logger.info('작업보고서 수정 요청', { reportId, updatedBy: updated_by });
|
||||
|
||||
try {
|
||||
const affectedRows = await dailyWorkReportModel.updateReportById(reportId, modelUpdateData);
|
||||
|
||||
if (affectedRows === 0) {
|
||||
throw new NotFoundError('수정할 작업보고서를 찾을 수 없습니다');
|
||||
}
|
||||
|
||||
logger.info('작업보고서 수정 성공', { reportId });
|
||||
return {
|
||||
message: '작업보고서가 성공적으로 수정되었습니다.',
|
||||
report_id: reportId,
|
||||
affected_rows: affectedRows
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
logger.error('작업보고서 수정 실패', { reportId, error: error.message });
|
||||
throw new DatabaseError('작업보고서 수정 중 데이터베이스 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 특정 작업 보고서 항목을 삭제하는 비즈니스 로직을 처리합니다.
|
||||
* @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 ValidationError('삭제할 보고서 ID가 필요합니다');
|
||||
}
|
||||
|
||||
logger.info('작업보고서 삭제 요청', { reportId, deletedBy: deleted_by });
|
||||
|
||||
try {
|
||||
const affectedRows = await dailyWorkReportModel.removeReportById(reportId, deleted_by);
|
||||
|
||||
if (affectedRows === 0) {
|
||||
throw new NotFoundError('삭제할 작업보고서를 찾을 수 없습니다');
|
||||
}
|
||||
|
||||
logger.info('작업보고서 삭제 성공', { reportId });
|
||||
return {
|
||||
message: '작업보고서가 성공적으로 삭제되었습니다.',
|
||||
report_id: reportId,
|
||||
affected_rows: affectedRows
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
logger.error('작업보고서 삭제 실패', { reportId, error: error.message });
|
||||
throw new DatabaseError('작업보고서 삭제 중 데이터베이스 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 기간별 작업 보고서 통계를 조회하는 비즈니스 로직을 처리합니다.
|
||||
* @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 ValidationError('시작일과 종료일이 모두 필요합니다', {
|
||||
required: ['start_date', 'end_date'],
|
||||
received: { start_date, end_date }
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('작업보고서 통계 조회 요청', { start_date, end_date });
|
||||
|
||||
try {
|
||||
const statsData = await dailyWorkReportModel.getStatistics(start_date, end_date);
|
||||
|
||||
logger.info('작업보고서 통계 조회 성공', { period: `${start_date} ~ ${end_date}` });
|
||||
return {
|
||||
...statsData,
|
||||
metadata: {
|
||||
period: `${start_date} ~ ${end_date}`,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('통계 조회 실패', { error: error.message });
|
||||
throw new DatabaseError('통계 조회 중 데이터베이스 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 일일 또는 작업자별 작업 요약 정보를 조회하는 비즈니스 로직을 처리합니다.
|
||||
* @param {object} queryParams - 컨트롤러에서 전달된 쿼리 파라미터 (date 또는 worker_id)
|
||||
* @returns {Promise<object>} 요약 데이터
|
||||
*/
|
||||
const getSummaryService = async (queryParams) => {
|
||||
const { date, worker_id } = queryParams;
|
||||
|
||||
if (!date && !worker_id) {
|
||||
throw new ValidationError('날짜 또는 작업자 ID가 필요합니다', {
|
||||
required: 'date OR worker_id',
|
||||
received: { date, worker_id }
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
if (date) {
|
||||
logger.info('일일 요약 조회 요청', { date });
|
||||
const result = await dailyWorkReportModel.getSummaryByDate(date);
|
||||
logger.info('일일 요약 조회 성공', { date });
|
||||
return result;
|
||||
} else { // worker_id
|
||||
logger.info('작업자별 요약 조회 요청', { worker_id });
|
||||
const result = await dailyWorkReportModel.getSummaryByWorker(worker_id);
|
||||
logger.info('작업자별 요약 조회 성공', { worker_id });
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('요약 정보 조회 실패', { error: error.message });
|
||||
throw new DatabaseError('요약 정보 조회 중 데이터베이스 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createDailyWorkReportService,
|
||||
getDailyWorkReportsService,
|
||||
updateWorkReportService,
|
||||
removeDailyWorkReportService,
|
||||
getStatisticsService,
|
||||
getSummaryService,
|
||||
};
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* 이미지 업로드 서비스
|
||||
* Base64 인코딩된 이미지를 파일로 저장
|
||||
*
|
||||
* 사용 전 sharp 패키지 설치 필요:
|
||||
* npm install sharp
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const crypto = require('crypto');
|
||||
|
||||
// sharp는 선택적으로 사용 (설치되어 있지 않으면 리사이징 없이 저장)
|
||||
let sharp;
|
||||
try {
|
||||
sharp = require('sharp');
|
||||
} catch (e) {
|
||||
console.warn('sharp 패키지가 설치되어 있지 않습니다. 이미지 리사이징이 비활성화됩니다.');
|
||||
console.warn('이미지 최적화를 위해 npm install sharp 를 실행하세요.');
|
||||
}
|
||||
|
||||
// 업로드 디렉토리 설정
|
||||
const UPLOAD_DIRS = {
|
||||
issues: path.join(__dirname, '../public/uploads/issues'),
|
||||
equipments: path.join(__dirname, '../public/uploads/equipments')
|
||||
};
|
||||
const UPLOAD_DIR = UPLOAD_DIRS.issues; // 기존 호환성 유지
|
||||
const MAX_SIZE = { width: 1920, height: 1920 };
|
||||
const QUALITY = 85;
|
||||
|
||||
/**
|
||||
* 업로드 디렉토리 확인 및 생성
|
||||
* @param {string} category - 카테고리 ('issues' 또는 'equipments')
|
||||
*/
|
||||
async function ensureUploadDir(category = 'issues') {
|
||||
const uploadDir = UPLOAD_DIRS[category] || UPLOAD_DIRS.issues;
|
||||
try {
|
||||
await fs.access(uploadDir);
|
||||
} catch {
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
}
|
||||
return uploadDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* UUID 생성 (간단한 버전)
|
||||
*/
|
||||
function generateId() {
|
||||
return crypto.randomBytes(4).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 타임스탬프 문자열 생성
|
||||
*/
|
||||
function getTimestamp() {
|
||||
const now = new Date();
|
||||
return now.toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 문자열에서 이미지 형식 추출
|
||||
* @param {string} base64String - Base64 인코딩된 이미지
|
||||
* @returns {string} 이미지 확장자 (jpg, png, etc)
|
||||
*/
|
||||
function getImageExtension(base64String) {
|
||||
const match = base64String.match(/^data:image\/(\w+);base64,/);
|
||||
if (match) {
|
||||
const format = match[1].toLowerCase();
|
||||
// jpeg를 jpg로 변환
|
||||
return format === 'jpeg' ? 'jpg' : format;
|
||||
}
|
||||
return 'jpg'; // 기본값
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 이미지를 파일로 저장
|
||||
* @param {string} base64String - Base64 인코딩된 이미지 (data:image/...;base64,... 형식)
|
||||
* @param {string} prefix - 파일명 접두사 (예: 'issue', 'resolution', 'equipment')
|
||||
* @param {string} category - 저장 카테고리 ('issues' 또는 'equipments')
|
||||
* @returns {Promise<string|null>} 저장된 파일의 웹 경로 또는 null
|
||||
*/
|
||||
async function saveBase64Image(base64String, prefix = 'issue', category = 'issues') {
|
||||
try {
|
||||
if (!base64String || typeof base64String !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Base64 헤더가 없는 경우 처리
|
||||
let base64Data = base64String;
|
||||
if (base64String.includes('base64,')) {
|
||||
base64Data = base64String.split('base64,')[1];
|
||||
}
|
||||
|
||||
// Base64 디코딩
|
||||
const buffer = Buffer.from(base64Data, 'base64');
|
||||
|
||||
if (buffer.length === 0) {
|
||||
console.error('이미지 데이터가 비어있습니다.');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 디렉토리 확인
|
||||
const uploadDir = await ensureUploadDir(category);
|
||||
|
||||
// 파일명 생성
|
||||
const timestamp = getTimestamp();
|
||||
const uniqueId = generateId();
|
||||
const extension = 'jpg'; // 모든 이미지를 JPEG로 저장
|
||||
const filename = `${prefix}_${timestamp}_${uniqueId}.${extension}`;
|
||||
const filepath = path.join(uploadDir, filename);
|
||||
|
||||
// sharp가 설치되어 있으면 리사이징 및 최적화
|
||||
if (sharp) {
|
||||
try {
|
||||
await sharp(buffer)
|
||||
.resize(MAX_SIZE.width, MAX_SIZE.height, {
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true
|
||||
})
|
||||
.jpeg({ quality: QUALITY })
|
||||
.toFile(filepath);
|
||||
} catch (sharpError) {
|
||||
console.error('sharp 처리 실패, 원본 저장:', sharpError.message);
|
||||
// sharp 실패 시 원본 저장
|
||||
await fs.writeFile(filepath, buffer);
|
||||
}
|
||||
} else {
|
||||
// sharp가 없으면 원본 그대로 저장
|
||||
await fs.writeFile(filepath, buffer);
|
||||
}
|
||||
|
||||
// 웹 접근 경로 반환
|
||||
return `/uploads/${category}/${filename}`;
|
||||
} catch (error) {
|
||||
console.error('이미지 저장 실패:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 Base64 이미지를 한번에 저장
|
||||
* @param {string[]} base64Images - Base64 이미지 배열
|
||||
* @param {string} prefix - 파일명 접두사
|
||||
* @returns {Promise<string[]>} 저장된 파일 경로 배열
|
||||
*/
|
||||
async function saveMultipleImages(base64Images, prefix = 'issue') {
|
||||
const paths = [];
|
||||
|
||||
for (const base64 of base64Images) {
|
||||
if (base64) {
|
||||
const savedPath = await saveBase64Image(base64, prefix);
|
||||
if (savedPath) {
|
||||
paths.push(savedPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 삭제
|
||||
* @param {string} webPath - 웹 경로 (예: /uploads/issues/filename.jpg)
|
||||
* @returns {Promise<boolean>} 삭제 성공 여부
|
||||
*/
|
||||
async function deleteFile(webPath) {
|
||||
try {
|
||||
if (!webPath || typeof webPath !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 보안: uploads 경로만 삭제 허용
|
||||
if (!webPath.startsWith('/uploads/')) {
|
||||
console.error('삭제 불가: uploads 외부 경로', webPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
const filename = path.basename(webPath);
|
||||
const fullPath = path.join(UPLOAD_DIR, filename);
|
||||
|
||||
try {
|
||||
await fs.access(fullPath);
|
||||
await fs.unlink(fullPath);
|
||||
return true;
|
||||
} catch (accessError) {
|
||||
// 파일이 없으면 성공으로 처리
|
||||
if (accessError.code === 'ENOENT') {
|
||||
return true;
|
||||
}
|
||||
throw accessError;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('파일 삭제 실패:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 파일 삭제
|
||||
* @param {string[]} webPaths - 웹 경로 배열
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function deleteMultipleFiles(webPaths) {
|
||||
for (const webPath of webPaths) {
|
||||
if (webPath) {
|
||||
await deleteFile(webPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
saveBase64Image,
|
||||
saveMultipleImages,
|
||||
deleteFile,
|
||||
deleteMultipleFiles,
|
||||
UPLOAD_DIR
|
||||
};
|
||||
169
deploy/tkfb-package/api.hyungi.net/services/issueTypeService.js
Normal file
169
deploy/tkfb-package/api.hyungi.net/services/issueTypeService.js
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* 이슈 유형 관리 서비스
|
||||
*
|
||||
* 이슈 유형(카테고리/서브카테고리) 관련 비즈니스 로직 처리
|
||||
*
|
||||
* @author TK-FB-Project
|
||||
* @since 2025-12-11
|
||||
*/
|
||||
|
||||
const issueTypeModel = require('../models/issueTypeModel');
|
||||
const { ValidationError, NotFoundError, DatabaseError } = require('../utils/errors');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* 이슈 유형 생성
|
||||
*/
|
||||
const createIssueTypeService = async (issueTypeData) => {
|
||||
const { category, subcategory } = issueTypeData;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!category || !subcategory) {
|
||||
throw new ValidationError('카테고리와 서브카테고리가 필요합니다', {
|
||||
required: ['category', 'subcategory'],
|
||||
received: { category, subcategory }
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('이슈 유형 생성 요청', { category, subcategory });
|
||||
|
||||
try {
|
||||
const insertId = await new Promise((resolve, reject) => {
|
||||
issueTypeModel.create({ category, subcategory }, (err, id) => {
|
||||
if (err) reject(err);
|
||||
else resolve(id);
|
||||
});
|
||||
});
|
||||
|
||||
logger.info('이슈 유형 생성 성공', { issue_type_id: insertId });
|
||||
|
||||
return { issue_type_id: insertId };
|
||||
} catch (error) {
|
||||
logger.error('이슈 유형 생성 실패', {
|
||||
category,
|
||||
subcategory,
|
||||
error: error.message
|
||||
});
|
||||
throw new DatabaseError('이슈 유형 생성 중 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 전체 이슈 유형 조회
|
||||
*/
|
||||
const getAllIssueTypesService = async () => {
|
||||
logger.info('이슈 유형 목록 조회 요청');
|
||||
|
||||
try {
|
||||
const rows = await new Promise((resolve, reject) => {
|
||||
issueTypeModel.getAll((err, data) => {
|
||||
if (err) reject(err);
|
||||
else resolve(data);
|
||||
});
|
||||
});
|
||||
|
||||
logger.info('이슈 유형 목록 조회 성공', { count: rows.length });
|
||||
|
||||
return rows;
|
||||
} catch (error) {
|
||||
logger.error('이슈 유형 목록 조회 실패', { error: error.message });
|
||||
throw new DatabaseError('이슈 유형 목록 조회 중 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 이슈 유형 수정
|
||||
*/
|
||||
const updateIssueTypeService = async (id, issueTypeData) => {
|
||||
const { category, subcategory } = issueTypeData;
|
||||
|
||||
// ID 검증
|
||||
if (!id || isNaN(id)) {
|
||||
throw new ValidationError('유효하지 않은 이슈 유형 ID입니다');
|
||||
}
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!category || !subcategory) {
|
||||
throw new ValidationError('카테고리와 서브카테고리가 필요합니다', {
|
||||
required: ['category', 'subcategory'],
|
||||
received: { category, subcategory }
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('이슈 유형 수정 요청', { issue_type_id: id, category, subcategory });
|
||||
|
||||
try {
|
||||
const affectedRows = await new Promise((resolve, reject) => {
|
||||
issueTypeModel.update(id, { category, subcategory }, (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows);
|
||||
});
|
||||
});
|
||||
|
||||
if (affectedRows === 0) {
|
||||
logger.warn('이슈 유형을 찾을 수 없음', { issue_type_id: id });
|
||||
throw new NotFoundError('이슈 유형을 찾을 수 없습니다');
|
||||
}
|
||||
|
||||
logger.info('이슈 유형 수정 성공', { issue_type_id: id, affectedRows });
|
||||
|
||||
return { changes: affectedRows };
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.error('이슈 유형 수정 실패', {
|
||||
issue_type_id: id,
|
||||
error: error.message
|
||||
});
|
||||
throw new DatabaseError('이슈 유형 수정 중 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 이슈 유형 삭제
|
||||
*/
|
||||
const removeIssueTypeService = async (id) => {
|
||||
// ID 검증
|
||||
if (!id || isNaN(id)) {
|
||||
throw new ValidationError('유효하지 않은 이슈 유형 ID입니다');
|
||||
}
|
||||
|
||||
logger.info('이슈 유형 삭제 요청', { issue_type_id: id });
|
||||
|
||||
try {
|
||||
const affectedRows = await new Promise((resolve, reject) => {
|
||||
issueTypeModel.remove(id, (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows);
|
||||
});
|
||||
});
|
||||
|
||||
if (affectedRows === 0) {
|
||||
logger.warn('이슈 유형을 찾을 수 없음', { issue_type_id: id });
|
||||
throw new NotFoundError('이슈 유형을 찾을 수 없습니다');
|
||||
}
|
||||
|
||||
logger.info('이슈 유형 삭제 성공', { issue_type_id: id, affectedRows });
|
||||
|
||||
return { changes: affectedRows };
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.error('이슈 유형 삭제 실패', {
|
||||
issue_type_id: id,
|
||||
error: error.message
|
||||
});
|
||||
throw new DatabaseError('이슈 유형 삭제 중 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createIssueTypeService,
|
||||
getAllIssueTypesService,
|
||||
updateIssueTypeService,
|
||||
removeIssueTypeService
|
||||
};
|
||||
383
deploy/tkfb-package/api.hyungi.net/services/mProjectService.js
Normal file
383
deploy/tkfb-package/api.hyungi.net/services/mProjectService.js
Normal file
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* M-Project API 연동 서비스
|
||||
*
|
||||
* TK-FB-Project의 부적합 신고를 M-Project로 전송하는 서비스
|
||||
*
|
||||
* @author TK-FB-Project
|
||||
* @since 2026-02-03
|
||||
*/
|
||||
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// M-Project API 설정
|
||||
const M_PROJECT_CONFIG = {
|
||||
baseUrl: process.env.M_PROJECT_API_URL || 'http://localhost:16080',
|
||||
username: process.env.M_PROJECT_USERNAME || 'api_service',
|
||||
password: process.env.M_PROJECT_PASSWORD || '',
|
||||
defaultProjectId: parseInt(process.env.M_PROJECT_DEFAULT_PROJECT_ID) || 1,
|
||||
};
|
||||
|
||||
// 캐시된 인증 토큰
|
||||
let cachedToken = null;
|
||||
let tokenExpiry = null;
|
||||
|
||||
/**
|
||||
* TK-FB 카테고리를 M-Project 카테고리로 매핑
|
||||
* @param {string} tkCategory - TK-FB-Project 카테고리
|
||||
* @returns {string} M-Project 카테고리
|
||||
*/
|
||||
function mapCategoryToMProject(tkCategory) {
|
||||
const categoryMap = {
|
||||
// TK-FB 카테고리 → M-Project 카테고리
|
||||
'자재 누락': 'material_missing',
|
||||
'자재누락': 'material_missing',
|
||||
'자재 관련': 'material_missing',
|
||||
'설계 오류': 'design_error',
|
||||
'설계미스': 'design_error',
|
||||
'설계 관련': 'design_error',
|
||||
'입고 불량': 'incoming_defect',
|
||||
'입고자재 불량': 'incoming_defect',
|
||||
'입고 관련': 'incoming_defect',
|
||||
'검사 미스': 'inspection_miss',
|
||||
'검사미스': 'inspection_miss',
|
||||
'검사 관련': 'inspection_miss',
|
||||
'기타': 'etc',
|
||||
};
|
||||
|
||||
// 정확히 매칭되는 경우
|
||||
if (categoryMap[tkCategory]) {
|
||||
return categoryMap[tkCategory];
|
||||
}
|
||||
|
||||
// 부분 매칭 시도
|
||||
const lowerCategory = tkCategory?.toLowerCase() || '';
|
||||
if (lowerCategory.includes('자재') || lowerCategory.includes('material')) {
|
||||
return 'material_missing';
|
||||
}
|
||||
if (lowerCategory.includes('설계') || lowerCategory.includes('design')) {
|
||||
return 'design_error';
|
||||
}
|
||||
if (lowerCategory.includes('입고') || lowerCategory.includes('incoming')) {
|
||||
return 'incoming_defect';
|
||||
}
|
||||
if (lowerCategory.includes('검사') || lowerCategory.includes('inspection')) {
|
||||
return 'inspection_miss';
|
||||
}
|
||||
|
||||
// 기본값
|
||||
return 'etc';
|
||||
}
|
||||
|
||||
/**
|
||||
* M-Project API 인증 토큰 획득
|
||||
* @returns {Promise<string|null>} JWT 토큰
|
||||
*/
|
||||
async function getAuthToken() {
|
||||
// 캐시된 토큰이 유효하면 재사용
|
||||
if (cachedToken && tokenExpiry && new Date() < tokenExpiry) {
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new URLSearchParams();
|
||||
formData.append('username', M_PROJECT_CONFIG.username);
|
||||
formData.append('password', M_PROJECT_CONFIG.password);
|
||||
|
||||
const response = await fetch(`${M_PROJECT_CONFIG.baseUrl}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: formData.toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error('M-Project 인증 실패', {
|
||||
status: response.status,
|
||||
error: errorText
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
cachedToken = data.access_token;
|
||||
|
||||
// 토큰 만료 시간 설정 (기본 6일, M-Project는 7일 유효)
|
||||
tokenExpiry = new Date(Date.now() + 6 * 24 * 60 * 60 * 1000);
|
||||
|
||||
logger.info('M-Project 인증 성공');
|
||||
return cachedToken;
|
||||
} catch (error) {
|
||||
logger.error('M-Project 인증 요청 오류', { error: error.message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 URL을 Base64로 변환
|
||||
* @param {string} imageUrl - 이미지 URL 또는 경로
|
||||
* @returns {Promise<string|null>} Base64 인코딩된 이미지
|
||||
*/
|
||||
async function convertImageToBase64(imageUrl) {
|
||||
if (!imageUrl) return null;
|
||||
|
||||
try {
|
||||
// 이미 Base64인 경우 그대로 반환
|
||||
if (imageUrl.startsWith('data:image')) {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
// URL인 경우 fetch
|
||||
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
|
||||
const response = await fetch(imageUrl);
|
||||
if (!response.ok) return null;
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
const base64 = Buffer.from(buffer).toString('base64');
|
||||
const contentType = response.headers.get('content-type') || 'image/jpeg';
|
||||
return `data:${contentType};base64,${base64}`;
|
||||
}
|
||||
|
||||
// 로컬 파일 경로인 경우
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const filePath = path.join(process.cwd(), 'uploads', imageUrl);
|
||||
|
||||
try {
|
||||
const fileBuffer = await fs.readFile(filePath);
|
||||
const base64 = fileBuffer.toString('base64');
|
||||
const ext = path.extname(imageUrl).toLowerCase();
|
||||
const mimeTypes = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
};
|
||||
const contentType = mimeTypes[ext] || 'image/jpeg';
|
||||
return `data:${contentType};base64,${base64}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('이미지 Base64 변환 실패', { imageUrl, error: error.message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TK-FB-Project 부적합 신고를 M-Project로 전송
|
||||
*
|
||||
* @param {Object} issueData - 부적합 신고 데이터
|
||||
* @param {string} issueData.category - 카테고리 이름
|
||||
* @param {string} issueData.description - 신고 내용
|
||||
* @param {string} issueData.reporter_name - 신고자 이름
|
||||
* @param {string} issueData.project_name - 프로젝트 이름 (옵션)
|
||||
* @param {number} issueData.tk_issue_id - TK-FB-Project 이슈 ID
|
||||
* @param {string[]} issueData.photos - 사진 URL 배열 (최대 5개)
|
||||
* @returns {Promise<{success: boolean, mProjectId?: number, error?: string}>}
|
||||
*/
|
||||
async function sendToMProject(issueData) {
|
||||
const {
|
||||
category,
|
||||
description,
|
||||
reporter_name,
|
||||
project_name,
|
||||
tk_issue_id,
|
||||
photos = [],
|
||||
} = issueData;
|
||||
|
||||
logger.info('M-Project 연동 시작', { tk_issue_id, category });
|
||||
|
||||
// 인증 토큰 획득
|
||||
const token = await getAuthToken();
|
||||
if (!token) {
|
||||
return { success: false, error: 'M-Project 인증 실패' };
|
||||
}
|
||||
|
||||
try {
|
||||
// 카테고리 매핑
|
||||
const mProjectCategory = mapCategoryToMProject(category);
|
||||
|
||||
// 설명에 TK-FB 정보 추가
|
||||
const enhancedDescription = [
|
||||
description,
|
||||
'',
|
||||
'---',
|
||||
`[TK-FB-Project 연동]`,
|
||||
`- 원본 이슈 ID: ${tk_issue_id}`,
|
||||
`- 신고자: ${reporter_name || '미상'}`,
|
||||
project_name ? `- 프로젝트: ${project_name}` : null,
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
// 사진 변환 (최대 5개)
|
||||
const photoPromises = photos.slice(0, 5).map(convertImageToBase64);
|
||||
const base64Photos = await Promise.all(photoPromises);
|
||||
|
||||
// 요청 본문 구성
|
||||
const requestBody = {
|
||||
category: mProjectCategory,
|
||||
description: enhancedDescription,
|
||||
project_id: M_PROJECT_CONFIG.defaultProjectId,
|
||||
};
|
||||
|
||||
// 사진 추가
|
||||
base64Photos.forEach((photo, index) => {
|
||||
if (photo) {
|
||||
const fieldName = index === 0 ? 'photo' : `photo${index + 1}`;
|
||||
requestBody[fieldName] = photo;
|
||||
}
|
||||
});
|
||||
|
||||
// M-Project API 호출
|
||||
const response = await fetch(`${M_PROJECT_CONFIG.baseUrl}/api/issues`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error('M-Project 이슈 생성 실패', {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
tk_issue_id
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
error: `M-Project API 오류: ${response.status}`
|
||||
};
|
||||
}
|
||||
|
||||
const createdIssue = await response.json();
|
||||
|
||||
logger.info('M-Project 이슈 생성 성공', {
|
||||
tk_issue_id,
|
||||
m_project_id: createdIssue.id
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
mProjectId: createdIssue.id
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('M-Project 연동 오류', {
|
||||
tk_issue_id,
|
||||
error: error.message
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* M-Project에서 이슈 상태 조회
|
||||
*
|
||||
* @param {number} mProjectId - M-Project 이슈 ID
|
||||
* @returns {Promise<{success: boolean, status?: string, data?: Object, error?: string}>}
|
||||
*/
|
||||
async function getIssueStatus(mProjectId) {
|
||||
const token = await getAuthToken();
|
||||
if (!token) {
|
||||
return { success: false, error: 'M-Project 인증 실패' };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${M_PROJECT_CONFIG.baseUrl}/api/issues/${mProjectId}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
error: `M-Project API 오류: ${response.status}`
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status: data.review_status || data.status,
|
||||
data
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* M-Project 상태를 TK-FB 상태로 매핑
|
||||
* @param {string} mProjectStatus - M-Project 상태
|
||||
* @returns {string} TK-FB-Project 상태
|
||||
*/
|
||||
function mapStatusFromMProject(mProjectStatus) {
|
||||
const statusMap = {
|
||||
'pending_review': 'received',
|
||||
'in_progress': 'in_progress',
|
||||
'completed': 'completed',
|
||||
'disposed': 'closed',
|
||||
};
|
||||
return statusMap[mProjectStatus] || 'received';
|
||||
}
|
||||
|
||||
/**
|
||||
* M-Project 연결 테스트
|
||||
* @returns {Promise<{success: boolean, message: string}>}
|
||||
*/
|
||||
async function testConnection() {
|
||||
try {
|
||||
const token = await getAuthToken();
|
||||
if (!token) {
|
||||
return { success: false, message: 'M-Project 인증 실패' };
|
||||
}
|
||||
|
||||
// /api/auth/me로 연결 테스트
|
||||
const response = await fetch(`${M_PROJECT_CONFIG.baseUrl}/api/auth/me`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const user = await response.json();
|
||||
return {
|
||||
success: true,
|
||||
message: `M-Project 연결 성공 (사용자: ${user.username})`
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: `M-Project 연결 실패: ${response.status}`
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `M-Project 연결 오류: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sendToMProject,
|
||||
getIssueStatus,
|
||||
mapStatusFromMProject,
|
||||
testConnection,
|
||||
mapCategoryToMProject,
|
||||
};
|
||||
171
deploy/tkfb-package/api.hyungi.net/services/toolsService.js
Normal file
171
deploy/tkfb-package/api.hyungi.net/services/toolsService.js
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* 도구 관리 서비스
|
||||
*
|
||||
* 도구(공구) 재고 및 위치 관리 관련 비즈니스 로직 처리
|
||||
*
|
||||
* @author TK-FB-Project
|
||||
* @since 2025-12-11
|
||||
*/
|
||||
|
||||
const toolsModel = require('../models/toolsModel');
|
||||
const { ValidationError, NotFoundError, DatabaseError } = require('../utils/errors');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* 전체 도구 조회
|
||||
*/
|
||||
const getAllToolsService = async () => {
|
||||
logger.info('도구 목록 조회 요청');
|
||||
|
||||
try {
|
||||
const rows = await toolsModel.getAll();
|
||||
|
||||
logger.info('도구 목록 조회 성공', { count: rows.length });
|
||||
|
||||
return rows;
|
||||
} catch (error) {
|
||||
logger.error('도구 목록 조회 실패', { error: error.message });
|
||||
throw new DatabaseError('도구 목록 조회 중 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 단일 도구 조회
|
||||
*/
|
||||
const getToolByIdService = async (id) => {
|
||||
// ID 검증
|
||||
if (!id || isNaN(id)) {
|
||||
throw new ValidationError('유효하지 않은 도구 ID입니다');
|
||||
}
|
||||
|
||||
logger.info('도구 조회 요청', { tool_id: id });
|
||||
|
||||
try {
|
||||
const row = await toolsModel.getById(id);
|
||||
|
||||
if (!row) {
|
||||
logger.warn('도구를 찾을 수 없음', { tool_id: id });
|
||||
throw new NotFoundError('도구를 찾을 수 없습니다');
|
||||
}
|
||||
|
||||
logger.info('도구 조회 성공', { tool_id: id });
|
||||
|
||||
return row;
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.error('도구 조회 실패', { tool_id: id, error: error.message });
|
||||
throw new DatabaseError('도구 조회 중 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 도구 생성
|
||||
*/
|
||||
const createToolService = async (toolData) => {
|
||||
const { name, location, stock, status, factory_id } = toolData;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!name) {
|
||||
throw new ValidationError('도구 이름이 필요합니다', {
|
||||
required: ['name'],
|
||||
received: { name }
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('도구 생성 요청', { name, location, stock, status });
|
||||
|
||||
try {
|
||||
const insertId = await toolsModel.create(toolData);
|
||||
|
||||
logger.info('도구 생성 성공', { tool_id: insertId, name });
|
||||
|
||||
return { tool_id: insertId };
|
||||
} catch (error) {
|
||||
logger.error('도구 생성 실패', {
|
||||
name,
|
||||
error: error.message
|
||||
});
|
||||
throw new DatabaseError('도구 생성 중 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 도구 수정
|
||||
*/
|
||||
const updateToolService = async (id, toolData) => {
|
||||
// ID 검증
|
||||
if (!id || isNaN(id)) {
|
||||
throw new ValidationError('유효하지 않은 도구 ID입니다');
|
||||
}
|
||||
|
||||
logger.info('도구 수정 요청', { tool_id: id, updates: toolData });
|
||||
|
||||
try {
|
||||
const affectedRows = await toolsModel.update(id, toolData);
|
||||
|
||||
if (affectedRows === 0) {
|
||||
logger.warn('도구를 찾을 수 없거나 변경사항 없음', { tool_id: id });
|
||||
throw new NotFoundError('도구를 찾을 수 없습니다');
|
||||
}
|
||||
|
||||
logger.info('도구 수정 성공', { tool_id: id, affectedRows });
|
||||
|
||||
return { changes: affectedRows };
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.error('도구 수정 실패', {
|
||||
tool_id: id,
|
||||
error: error.message
|
||||
});
|
||||
throw new DatabaseError('도구 수정 중 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 도구 삭제
|
||||
*/
|
||||
const deleteToolService = async (id) => {
|
||||
// ID 검증
|
||||
if (!id || isNaN(id)) {
|
||||
throw new ValidationError('유효하지 않은 도구 ID입니다');
|
||||
}
|
||||
|
||||
logger.info('도구 삭제 요청', { tool_id: id });
|
||||
|
||||
try {
|
||||
const affectedRows = await toolsModel.remove(id);
|
||||
|
||||
if (affectedRows === 0) {
|
||||
logger.warn('도구를 찾을 수 없음', { tool_id: id });
|
||||
throw new NotFoundError('도구를 찾을 수 없습니다');
|
||||
}
|
||||
|
||||
logger.info('도구 삭제 성공', { tool_id: id, affectedRows });
|
||||
|
||||
return { changes: affectedRows };
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.error('도구 삭제 실패', {
|
||||
tool_id: id,
|
||||
error: error.message
|
||||
});
|
||||
throw new DatabaseError('도구 삭제 중 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getAllToolsService,
|
||||
getToolByIdService,
|
||||
createToolService,
|
||||
updateToolService,
|
||||
deleteToolService
|
||||
};
|
||||
96
deploy/tkfb-package/api.hyungi.net/services/uploadService.js
Normal file
96
deploy/tkfb-package/api.hyungi.net/services/uploadService.js
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 문서 업로드 관리 서비스
|
||||
*
|
||||
* 파일 업로드 및 문서 메타데이터 관리 관련 비즈니스 로직 처리
|
||||
*
|
||||
* @author TK-FB-Project
|
||||
* @since 2025-12-11
|
||||
*/
|
||||
|
||||
const uploadModel = require('../models/uploadModel');
|
||||
const { ValidationError, DatabaseError } = require('../utils/errors');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* 문서 업로드 생성
|
||||
*/
|
||||
const createUploadService = async (uploadData) => {
|
||||
const {
|
||||
title,
|
||||
tags,
|
||||
description,
|
||||
original_name,
|
||||
stored_name,
|
||||
file_path,
|
||||
file_type,
|
||||
file_size,
|
||||
submitted_by
|
||||
} = uploadData;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!original_name || !stored_name || !file_path) {
|
||||
throw new ValidationError('필수 필드가 누락되었습니다', {
|
||||
required: ['original_name', 'stored_name', 'file_path'],
|
||||
received: { original_name, stored_name, file_path }
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('문서 업로드 생성 요청', {
|
||||
title,
|
||||
original_name,
|
||||
file_type,
|
||||
file_size,
|
||||
submitted_by
|
||||
});
|
||||
|
||||
try {
|
||||
const insertId = await new Promise((resolve, reject) => {
|
||||
uploadModel.create(uploadData, (err, id) => {
|
||||
if (err) reject(err);
|
||||
else resolve(id);
|
||||
});
|
||||
});
|
||||
|
||||
logger.info('문서 업로드 생성 성공', {
|
||||
upload_id: insertId,
|
||||
original_name,
|
||||
file_size
|
||||
});
|
||||
|
||||
return { upload_id: insertId };
|
||||
} catch (error) {
|
||||
logger.error('문서 업로드 생성 실패', {
|
||||
original_name,
|
||||
error: error.message
|
||||
});
|
||||
throw new DatabaseError('문서 업로드 생성 중 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 전체 업로드 문서 조회
|
||||
*/
|
||||
const getAllUploadsService = async () => {
|
||||
logger.info('업로드 문서 목록 조회 요청');
|
||||
|
||||
try {
|
||||
const rows = await new Promise((resolve, reject) => {
|
||||
uploadModel.getAll((err, data) => {
|
||||
if (err) reject(err);
|
||||
else resolve(data);
|
||||
});
|
||||
});
|
||||
|
||||
logger.info('업로드 문서 목록 조회 성공', { count: rows.length });
|
||||
|
||||
return rows;
|
||||
} catch (error) {
|
||||
logger.error('업로드 문서 목록 조회 실패', { error: error.message });
|
||||
throw new DatabaseError('업로드 문서 목록 조회 중 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createUploadService,
|
||||
getAllUploadsService
|
||||
};
|
||||
435
deploy/tkfb-package/api.hyungi.net/services/weatherService.js
Normal file
435
deploy/tkfb-package/api.hyungi.net/services/weatherService.js
Normal file
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* 날씨 API 서비스
|
||||
*
|
||||
* 기상청 단기예보 API를 사용하여 현재 날씨 정보를 조회
|
||||
* 날씨 조건에 따른 안전 체크리스트 필터링 지원
|
||||
*
|
||||
* @since 2026-02-02
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const logger = require('../utils/logger');
|
||||
const { getDb } = require('../dbPool');
|
||||
|
||||
// 기상청 API 설정
|
||||
const WEATHER_BASE_URL = process.env.WEATHER_API_URL || 'https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0';
|
||||
const WEATHER_API = {
|
||||
baseUrl: WEATHER_BASE_URL,
|
||||
ultraShortUrl: `${WEATHER_BASE_URL}/getUltraSrtNcst`,
|
||||
shortForecastUrl: `${WEATHER_BASE_URL}/getVilageFcst`,
|
||||
apiKey: process.env.WEATHER_API_KEY || '',
|
||||
// 화성시 남양읍 좌표 (격자 좌표)
|
||||
// 위도: 37.2072, 경도: 126.8232
|
||||
defaultLocation: {
|
||||
nx: 57, // 화성시 남양읍 X 좌표
|
||||
ny: 119 // 화성시 남양읍 Y 좌표
|
||||
}
|
||||
};
|
||||
|
||||
// PTY (강수형태) 코드
|
||||
const PTY_CODES = {
|
||||
0: 'none', // 없음
|
||||
1: 'rain', // 비
|
||||
2: 'rain', // 비/눈 (혼합)
|
||||
3: 'snow', // 눈
|
||||
4: 'rain', // 소나기
|
||||
5: 'rain', // 빗방울
|
||||
6: 'rain', // 빗방울/눈날림
|
||||
7: 'snow' // 눈날림
|
||||
};
|
||||
|
||||
// SKY (하늘상태) 코드
|
||||
const SKY_CODES = {
|
||||
1: 'clear', // 맑음
|
||||
3: 'cloudy', // 구름많음
|
||||
4: 'overcast' // 흐림
|
||||
};
|
||||
|
||||
/**
|
||||
* 현재 날씨 정보 조회 (초단기실황)
|
||||
* @param {number} nx - 격자 X 좌표 (optional)
|
||||
* @param {number} ny - 격자 Y 좌표 (optional)
|
||||
* @returns {Promise<Object>} 날씨 데이터
|
||||
*/
|
||||
async function getCurrentWeather(nx = WEATHER_API.defaultLocation.nx, ny = WEATHER_API.defaultLocation.ny) {
|
||||
if (!WEATHER_API.apiKey) {
|
||||
logger.warn('날씨 API 키가 설정되지 않음. 기본값 반환');
|
||||
return getDefaultWeatherData();
|
||||
}
|
||||
|
||||
try {
|
||||
// 한국 시간 기준으로 base_date, base_time 계산
|
||||
const { baseDate, baseTime } = getBaseDateTime();
|
||||
|
||||
logger.info('날씨 API 호출', { baseDate, baseTime, nx, ny });
|
||||
|
||||
// Encoding 키는 이미 URL 인코딩되어 있으므로 직접 URL에 추가 (이중 인코딩 방지)
|
||||
const url = `${WEATHER_API.ultraShortUrl}?serviceKey=${WEATHER_API.apiKey}` +
|
||||
`&pageNo=1&numOfRows=10&dataType=JSON` +
|
||||
`&base_date=${baseDate}&base_time=${baseTime}` +
|
||||
`&nx=${nx}&ny=${ny}`;
|
||||
|
||||
const response = await axios.get(url, { timeout: 5000 });
|
||||
|
||||
if (response.data?.response?.header?.resultCode !== '00') {
|
||||
throw new Error(`API 오류: ${response.data?.response?.header?.resultMsg}`);
|
||||
}
|
||||
|
||||
const items = response.data.response.body.items.item;
|
||||
const weatherData = parseWeatherItems(items);
|
||||
|
||||
logger.info('날씨 데이터 파싱 완료', weatherData);
|
||||
|
||||
return weatherData;
|
||||
} catch (error) {
|
||||
logger.error('날씨 API 호출 실패', { error: error.message });
|
||||
return getDefaultWeatherData();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 날씨 API 응답 파싱
|
||||
*/
|
||||
function parseWeatherItems(items) {
|
||||
const data = {
|
||||
temperature: null,
|
||||
humidity: null,
|
||||
windSpeed: null,
|
||||
precipitation: null,
|
||||
precipitationType: null,
|
||||
skyCondition: null
|
||||
};
|
||||
|
||||
if (!items || !Array.isArray(items)) {
|
||||
return data;
|
||||
}
|
||||
|
||||
items.forEach(item => {
|
||||
switch (item.category) {
|
||||
case 'T1H': // 기온
|
||||
data.temperature = parseFloat(item.obsrValue);
|
||||
break;
|
||||
case 'REH': // 습도
|
||||
data.humidity = parseInt(item.obsrValue);
|
||||
break;
|
||||
case 'WSD': // 풍속
|
||||
data.windSpeed = parseFloat(item.obsrValue);
|
||||
break;
|
||||
case 'RN1': // 1시간 강수량
|
||||
data.precipitation = parseFloat(item.obsrValue) || 0;
|
||||
break;
|
||||
case 'PTY': // 강수형태
|
||||
data.precipitationType = parseInt(item.obsrValue);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 날씨 데이터를 기반으로 조건 판단
|
||||
* @param {Object} weatherData - 날씨 데이터
|
||||
* @returns {Promise<string[]>} 해당하는 날씨 조건 코드 배열
|
||||
*/
|
||||
async function determineWeatherConditions(weatherData) {
|
||||
const conditions = [];
|
||||
|
||||
// DB에서 날씨 조건 기준 조회
|
||||
const db = await getDb();
|
||||
const [thresholds] = await db.execute(`
|
||||
SELECT condition_code, temp_threshold_min, temp_threshold_max,
|
||||
wind_threshold, precip_threshold
|
||||
FROM weather_conditions
|
||||
WHERE is_active = TRUE
|
||||
`);
|
||||
|
||||
// 조건 판단
|
||||
thresholds.forEach(threshold => {
|
||||
let matches = false;
|
||||
|
||||
switch (threshold.condition_code) {
|
||||
case 'rain':
|
||||
// 강수형태가 비(1,2,4,5,6) 또는 강수량 > 0
|
||||
if (weatherData.precipitationType && PTY_CODES[weatherData.precipitationType] === 'rain') {
|
||||
matches = true;
|
||||
} else if (weatherData.precipitation > 0 && threshold.precip_threshold !== null) {
|
||||
matches = weatherData.precipitation >= threshold.precip_threshold;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'snow':
|
||||
// 강수형태가 눈(3,7)
|
||||
if (weatherData.precipitationType && PTY_CODES[weatherData.precipitationType] === 'snow') {
|
||||
matches = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'heat':
|
||||
// 기온이 폭염 기준 이상
|
||||
if (weatherData.temperature !== null && threshold.temp_threshold_min !== null) {
|
||||
matches = weatherData.temperature >= threshold.temp_threshold_min;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'cold':
|
||||
// 기온이 한파 기준 이하
|
||||
if (weatherData.temperature !== null && threshold.temp_threshold_max !== null) {
|
||||
matches = weatherData.temperature <= threshold.temp_threshold_max;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'wind':
|
||||
// 풍속이 강풍 기준 이상
|
||||
if (weatherData.windSpeed !== null && threshold.wind_threshold !== null) {
|
||||
matches = weatherData.windSpeed >= threshold.wind_threshold;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'clear':
|
||||
// 강수 없고 기온이 정상 범위
|
||||
if (!weatherData.precipitationType || weatherData.precipitationType === 0) {
|
||||
if (weatherData.temperature !== null &&
|
||||
weatherData.temperature > -10 && weatherData.temperature < 35) {
|
||||
matches = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (matches) {
|
||||
conditions.push(threshold.condition_code);
|
||||
}
|
||||
});
|
||||
|
||||
// 조건이 없으면 기본으로 'clear' 추가
|
||||
if (conditions.length === 0) {
|
||||
conditions.push('clear');
|
||||
}
|
||||
|
||||
logger.info('날씨 조건 판단 완료', { weatherData, conditions });
|
||||
|
||||
return conditions;
|
||||
}
|
||||
|
||||
/**
|
||||
* TBM 세션에 날씨 정보 저장
|
||||
* @param {number} sessionId - TBM 세션 ID
|
||||
* @param {Object} weatherData - 날씨 데이터
|
||||
* @param {string[]} conditions - 날씨 조건 배열
|
||||
*/
|
||||
async function saveWeatherRecord(sessionId, weatherData, conditions) {
|
||||
const db = await getDb();
|
||||
|
||||
try {
|
||||
const weatherDate = new Date().toISOString().split('T')[0];
|
||||
|
||||
await db.execute(`
|
||||
INSERT INTO tbm_weather_records
|
||||
(session_id, weather_date, temperature, humidity, wind_speed, precipitation,
|
||||
weather_condition, weather_conditions, data_source, fetched_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'api', NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
temperature = VALUES(temperature),
|
||||
humidity = VALUES(humidity),
|
||||
wind_speed = VALUES(wind_speed),
|
||||
precipitation = VALUES(precipitation),
|
||||
weather_condition = VALUES(weather_condition),
|
||||
weather_conditions = VALUES(weather_conditions),
|
||||
fetched_at = NOW()
|
||||
`, [
|
||||
sessionId,
|
||||
weatherDate,
|
||||
weatherData.temperature,
|
||||
weatherData.humidity,
|
||||
weatherData.windSpeed,
|
||||
weatherData.precipitation,
|
||||
conditions[0] || 'clear', // 주요 조건
|
||||
JSON.stringify(conditions) // 모든 조건
|
||||
]);
|
||||
|
||||
logger.info('날씨 기록 저장 완료', { sessionId, conditions });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('날씨 기록 저장 실패', { sessionId, error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TBM 세션의 날씨 기록 조회
|
||||
* @param {number} sessionId - TBM 세션 ID
|
||||
*/
|
||||
async function getWeatherRecord(sessionId) {
|
||||
const db = await getDb();
|
||||
|
||||
const [rows] = await db.execute(`
|
||||
SELECT wr.*, wc.condition_name, wc.icon
|
||||
FROM tbm_weather_records wr
|
||||
LEFT JOIN weather_conditions wc ON wr.weather_condition = wc.condition_code
|
||||
WHERE wr.session_id = ?
|
||||
`, [sessionId]);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = rows[0];
|
||||
// JSON 문자열 파싱
|
||||
if (record.weather_conditions && typeof record.weather_conditions === 'string') {
|
||||
try {
|
||||
record.weather_conditions = JSON.parse(record.weather_conditions);
|
||||
} catch (e) {
|
||||
record.weather_conditions = [];
|
||||
}
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* 날씨 조건 코드 목록 조회
|
||||
*/
|
||||
async function getWeatherConditionList() {
|
||||
const db = await getDb();
|
||||
|
||||
const [rows] = await db.execute(`
|
||||
SELECT condition_code, condition_name, description, icon,
|
||||
temp_threshold_min, temp_threshold_max, wind_threshold, precip_threshold
|
||||
FROM weather_conditions
|
||||
WHERE is_active = TRUE
|
||||
ORDER BY display_order
|
||||
`);
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 날씨 데이터 반환 (API 실패 시)
|
||||
*/
|
||||
function getDefaultWeatherData() {
|
||||
return {
|
||||
temperature: 20,
|
||||
humidity: 50,
|
||||
windSpeed: 2,
|
||||
precipitation: 0,
|
||||
precipitationType: 0,
|
||||
skyCondition: 'clear',
|
||||
isDefault: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 기상청 API용 기준 날짜/시간 계산
|
||||
* 한국 시간(KST, UTC+9) 기준으로 계산
|
||||
* 매시간 정각에 생성되고 10분 후에 제공됨
|
||||
* @returns {{ baseDate: string, baseTime: string }}
|
||||
*/
|
||||
function getBaseDateTime() {
|
||||
// 한국 시간으로 변환 (UTC + 9시간)
|
||||
const now = new Date();
|
||||
const kstOffset = 9 * 60 * 60 * 1000; // 9시간을 밀리초로
|
||||
const kstDate = new Date(now.getTime() + kstOffset);
|
||||
|
||||
let year = kstDate.getUTCFullYear();
|
||||
let month = kstDate.getUTCMonth() + 1;
|
||||
let day = kstDate.getUTCDate();
|
||||
let hours = kstDate.getUTCHours();
|
||||
let minutes = kstDate.getUTCMinutes();
|
||||
|
||||
// 10분 이전이면 이전 시간 데이터 사용
|
||||
if (minutes < 10) {
|
||||
hours = hours - 1;
|
||||
if (hours < 0) {
|
||||
hours = 23;
|
||||
// 전날로 변경
|
||||
const prevDay = new Date(kstDate.getTime() - 24 * 60 * 60 * 1000);
|
||||
year = prevDay.getUTCFullYear();
|
||||
month = prevDay.getUTCMonth() + 1;
|
||||
day = prevDay.getUTCDate();
|
||||
}
|
||||
}
|
||||
|
||||
const baseDate = `${year}${String(month).padStart(2, '0')}${String(day).padStart(2, '0')}`;
|
||||
const baseTime = String(hours).padStart(2, '0') + '00';
|
||||
|
||||
return { baseDate, baseTime };
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 포맷 (YYYYMMDD) - 호환성 유지용
|
||||
* @deprecated getBaseDateTime() 사용 권장
|
||||
*/
|
||||
function formatDate(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}${month}${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated getBaseDateTime() 사용 권장
|
||||
*/
|
||||
function getBaseTime(date) {
|
||||
let hours = date.getHours();
|
||||
let minutes = date.getMinutes();
|
||||
|
||||
if (minutes < 10) {
|
||||
hours = hours - 1;
|
||||
if (hours < 0) hours = 23;
|
||||
}
|
||||
|
||||
return String(hours).padStart(2, '0') + '00';
|
||||
}
|
||||
|
||||
/**
|
||||
* 위경도를 기상청 격자 좌표로 변환
|
||||
* LCC (Lambert Conformal Conic) 투영법 사용
|
||||
*/
|
||||
function convertToGrid(lat, lon) {
|
||||
const RE = 6371.00877; // 지구 반경(km)
|
||||
const GRID = 5.0; // 격자 간격(km)
|
||||
const SLAT1 = 30.0; // 투영 위도1(degree)
|
||||
const SLAT2 = 60.0; // 투영 위도2(degree)
|
||||
const OLON = 126.0; // 기준점 경도(degree)
|
||||
const OLAT = 38.0; // 기준점 위도(degree)
|
||||
const XO = 43; // 기준점 X좌표(GRID)
|
||||
const YO = 136; // 기준점 Y좌표(GRID)
|
||||
|
||||
const DEGRAD = Math.PI / 180.0;
|
||||
|
||||
const re = RE / GRID;
|
||||
const slat1 = SLAT1 * DEGRAD;
|
||||
const slat2 = SLAT2 * DEGRAD;
|
||||
const olon = OLON * DEGRAD;
|
||||
const olat = OLAT * DEGRAD;
|
||||
|
||||
let sn = Math.tan(Math.PI * 0.25 + slat2 * 0.5) / Math.tan(Math.PI * 0.25 + slat1 * 0.5);
|
||||
sn = Math.log(Math.cos(slat1) / Math.cos(slat2)) / Math.log(sn);
|
||||
let sf = Math.tan(Math.PI * 0.25 + slat1 * 0.5);
|
||||
sf = Math.pow(sf, sn) * Math.cos(slat1) / sn;
|
||||
let ro = Math.tan(Math.PI * 0.25 + olat * 0.5);
|
||||
ro = re * sf / Math.pow(ro, sn);
|
||||
|
||||
let ra = Math.tan(Math.PI * 0.25 + lat * DEGRAD * 0.5);
|
||||
ra = re * sf / Math.pow(ra, sn);
|
||||
let theta = lon * DEGRAD - olon;
|
||||
if (theta > Math.PI) theta -= 2.0 * Math.PI;
|
||||
if (theta < -Math.PI) theta += 2.0 * Math.PI;
|
||||
theta *= sn;
|
||||
|
||||
const x = Math.floor(ra * Math.sin(theta) + XO + 0.5);
|
||||
const y = Math.floor(ro - ra * Math.cos(theta) + YO + 0.5);
|
||||
|
||||
return { nx: x, ny: y };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getCurrentWeather,
|
||||
determineWeatherConditions,
|
||||
saveWeatherRecord,
|
||||
getWeatherRecord,
|
||||
getWeatherConditionList,
|
||||
convertToGrid,
|
||||
getDefaultWeatherData
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Work Analysis Service
|
||||
*
|
||||
* Handles complex business logic and data aggregation for detailed work analysis reports
|
||||
*
|
||||
* @author TK-FB-Project
|
||||
* @since 2025-12-19
|
||||
*/
|
||||
|
||||
const { getDb } = require('../dbPool');
|
||||
const WorkAnalysis = require('../models/WorkAnalysis');
|
||||
const logger = require('../utils/logger');
|
||||
const { DatabaseError } = require('../utils/errors');
|
||||
|
||||
class WorkAnalysisService {
|
||||
constructor() {
|
||||
this.db = null;
|
||||
this.model = null;
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (!this.db) {
|
||||
this.db = await getDb();
|
||||
this.model = new WorkAnalysis(this.db);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로젝트별-작업별 시간 분석 및 집계 (Service Layer logic)
|
||||
*/
|
||||
async getProjectWorkTypeAnalysis(startDate, endDate) {
|
||||
await this.init();
|
||||
|
||||
try {
|
||||
// 1. Get Raw Data from Model
|
||||
const rawData = await this.model.getProjectWorkTypeRawData(startDate, endDate);
|
||||
|
||||
// 2. Process and Group Data
|
||||
const groupedData = {};
|
||||
|
||||
rawData.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: []
|
||||
};
|
||||
}
|
||||
|
||||
// Project Totals Accumulation
|
||||
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);
|
||||
|
||||
// Add WorkType Entry
|
||||
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
|
||||
});
|
||||
});
|
||||
|
||||
// 3. Calculate Project Error Rates
|
||||
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;
|
||||
});
|
||||
|
||||
// 4. Calculate Grand Totals
|
||||
const totalStats = {
|
||||
total_projects: Object.keys(groupedData).length,
|
||||
total_work_types: new Set(rawData.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;
|
||||
|
||||
return {
|
||||
summary: totalStats,
|
||||
projects: Object.values(groupedData),
|
||||
period: { start: startDate, end: endDate }
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Service: 프로젝트별-작업별 시간 분석 처리 실패', { error: error.message });
|
||||
// Re-throw as DatabaseError to maintain consistency with controller expectation, or custom ServiceError
|
||||
throw new DatabaseError(`분석 데이터 처리 중 오류 발생: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new WorkAnalysisService();
|
||||
488
deploy/tkfb-package/api.hyungi.net/services/workReportService.js
Normal file
488
deploy/tkfb-package/api.hyungi.net/services/workReportService.js
Normal file
@@ -0,0 +1,488 @@
|
||||
/**
|
||||
* 작업 보고서 관리 서비스
|
||||
*
|
||||
* 작업 보고서 CRUD 및 조회 관련 비즈니스 로직 처리
|
||||
*
|
||||
* @author TK-FB-Project
|
||||
* @since 2025-12-11
|
||||
*/
|
||||
|
||||
const workReportModel = require('../models/workReportModel');
|
||||
const { ValidationError, NotFoundError, DatabaseError } = require('../utils/errors');
|
||||
const logger = require('../utils/logger');
|
||||
const { getDb } = require('../dbPool');
|
||||
|
||||
/**
|
||||
* 작업 보고서 생성 (단일 또는 다중)
|
||||
*/
|
||||
const createWorkReportService = async (reportData) => {
|
||||
const reports = Array.isArray(reportData) ? reportData : [reportData];
|
||||
|
||||
if (reports.length === 0) {
|
||||
throw new ValidationError('보고서 데이터가 필요합니다');
|
||||
}
|
||||
|
||||
logger.info('작업 보고서 생성 요청', { count: reports.length });
|
||||
|
||||
const workReport_ids = [];
|
||||
|
||||
try {
|
||||
for (const report of reports) {
|
||||
const id = await new Promise((resolve, reject) => {
|
||||
workReportModel.create(report, (err, insertId) => {
|
||||
if (err) reject(err);
|
||||
else resolve(insertId);
|
||||
});
|
||||
});
|
||||
workReport_ids.push(id);
|
||||
}
|
||||
|
||||
logger.info('작업 보고서 생성 성공', {
|
||||
count: workReport_ids.length,
|
||||
ids: workReport_ids
|
||||
});
|
||||
|
||||
return { workReport_ids };
|
||||
} catch (error) {
|
||||
logger.error('작업 보고서 생성 실패', {
|
||||
count: reports.length,
|
||||
error: error.message
|
||||
});
|
||||
throw new DatabaseError('작업 보고서 생성 중 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 날짜별 작업 보고서 조회
|
||||
*/
|
||||
const getWorkReportsByDateService = async (date) => {
|
||||
if (!date) {
|
||||
throw new ValidationError('날짜가 필요합니다', {
|
||||
required: ['date'],
|
||||
received: { date }
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('작업 보고서 날짜별 조회 요청', { date });
|
||||
|
||||
try {
|
||||
const rows = await new Promise((resolve, reject) => {
|
||||
workReportModel.getAllByDate(date, (err, data) => {
|
||||
if (err) reject(err);
|
||||
else resolve(data);
|
||||
});
|
||||
});
|
||||
|
||||
logger.info('작업 보고서 조회 성공', { date, count: rows.length });
|
||||
|
||||
return rows;
|
||||
} catch (error) {
|
||||
logger.error('작업 보고서 조회 실패', { date, error: error.message });
|
||||
throw new DatabaseError('작업 보고서 조회 중 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 기간별 작업 보고서 조회
|
||||
*/
|
||||
const getWorkReportsInRangeService = async (start, end) => {
|
||||
if (!start || !end) {
|
||||
throw new ValidationError('시작일과 종료일이 필요합니다', {
|
||||
required: ['start', 'end'],
|
||||
received: { start, end }
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('작업 보고서 기간별 조회 요청', { start, end });
|
||||
|
||||
try {
|
||||
const rows = await new Promise((resolve, reject) => {
|
||||
workReportModel.getByRange(start, end, (err, data) => {
|
||||
if (err) reject(err);
|
||||
else resolve(data);
|
||||
});
|
||||
});
|
||||
|
||||
logger.info('작업 보고서 조회 성공', { start, end, count: rows.length });
|
||||
|
||||
return rows;
|
||||
} catch (error) {
|
||||
logger.error('작업 보고서 조회 실패', { start, end, error: error.message });
|
||||
throw new DatabaseError('작업 보고서 조회 중 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 단일 작업 보고서 조회
|
||||
*/
|
||||
const getWorkReportByIdService = async (id) => {
|
||||
if (!id) {
|
||||
throw new ValidationError('보고서 ID가 필요합니다');
|
||||
}
|
||||
|
||||
logger.info('작업 보고서 조회 요청', { report_id: id });
|
||||
|
||||
try {
|
||||
const row = await new Promise((resolve, reject) => {
|
||||
workReportModel.getById(id, (err, data) => {
|
||||
if (err) reject(err);
|
||||
else resolve(data);
|
||||
});
|
||||
});
|
||||
|
||||
if (!row) {
|
||||
logger.warn('작업 보고서를 찾을 수 없음', { report_id: id });
|
||||
throw new NotFoundError('작업 보고서를 찾을 수 없습니다');
|
||||
}
|
||||
|
||||
logger.info('작업 보고서 조회 성공', { report_id: id });
|
||||
|
||||
return row;
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.error('작업 보고서 조회 실패', { report_id: id, error: error.message });
|
||||
throw new DatabaseError('작업 보고서 조회 중 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 작업 보고서 수정
|
||||
*/
|
||||
const updateWorkReportService = async (id, updateData) => {
|
||||
if (!id) {
|
||||
throw new ValidationError('보고서 ID가 필요합니다');
|
||||
}
|
||||
|
||||
logger.info('작업 보고서 수정 요청', { report_id: id });
|
||||
|
||||
try {
|
||||
const changes = await new Promise((resolve, reject) => {
|
||||
workReportModel.update(id, updateData, (err, affectedRows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(affectedRows);
|
||||
});
|
||||
});
|
||||
|
||||
if (changes === 0) {
|
||||
logger.warn('작업 보고서를 찾을 수 없거나 변경사항 없음', { report_id: id });
|
||||
throw new NotFoundError('작업 보고서를 찾을 수 없습니다');
|
||||
}
|
||||
|
||||
logger.info('작업 보고서 수정 성공', { report_id: id, changes });
|
||||
|
||||
return { changes };
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.error('작업 보고서 수정 실패', { report_id: id, error: error.message });
|
||||
throw new DatabaseError('작업 보고서 수정 중 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 작업 보고서 삭제
|
||||
*/
|
||||
const removeWorkReportService = async (id) => {
|
||||
if (!id) {
|
||||
throw new ValidationError('보고서 ID가 필요합니다');
|
||||
}
|
||||
|
||||
logger.info('작업 보고서 삭제 요청', { report_id: id });
|
||||
|
||||
try {
|
||||
const changes = await new Promise((resolve, reject) => {
|
||||
workReportModel.remove(id, (err, affectedRows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(affectedRows);
|
||||
});
|
||||
});
|
||||
|
||||
if (changes === 0) {
|
||||
logger.warn('작업 보고서를 찾을 수 없음', { report_id: id });
|
||||
throw new NotFoundError('작업 보고서를 찾을 수 없습니다');
|
||||
}
|
||||
|
||||
logger.info('작업 보고서 삭제 성공', { report_id: id, changes });
|
||||
|
||||
return { changes };
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.error('작업 보고서 삭제 실패', { report_id: id, error: error.message });
|
||||
throw new DatabaseError('작업 보고서 삭제 중 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 월간 요약 조회
|
||||
*/
|
||||
const getSummaryService = async (year, month) => {
|
||||
if (!year || !month) {
|
||||
throw new ValidationError('연도와 월이 필요합니다', {
|
||||
required: ['year', 'month'],
|
||||
received: { year, month }
|
||||
});
|
||||
}
|
||||
|
||||
const start = `${year.padStart(4, '0')}-${month.padStart(2, '0')}-01`;
|
||||
const end = `${year.padStart(4, '0')}-${month.padStart(2, '0')}-31`;
|
||||
|
||||
logger.info('작업 보고서 월간 요약 조회 요청', { year, month, start, end });
|
||||
|
||||
try {
|
||||
const rows = await new Promise((resolve, reject) => {
|
||||
workReportModel.getByRange(start, end, (err, data) => {
|
||||
if (err) reject(err);
|
||||
else resolve(data);
|
||||
});
|
||||
});
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
logger.warn('월간 요약 데이터 없음', { year, month });
|
||||
throw new NotFoundError('해당 기간의 작업 보고서가 없습니다');
|
||||
}
|
||||
|
||||
logger.info('작업 보고서 월간 요약 조회 성공', {
|
||||
year,
|
||||
month,
|
||||
count: rows.length
|
||||
});
|
||||
|
||||
return rows;
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.error('작업 보고서 월간 요약 조회 실패', {
|
||||
year,
|
||||
month,
|
||||
error: error.message
|
||||
});
|
||||
throw new DatabaseError('월간 요약 조회 중 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
// ========== 부적합 원인 관리 서비스 ==========
|
||||
|
||||
/**
|
||||
* 작업 보고서의 부적합 원인 목록 조회
|
||||
* - error_type_id 또는 issue_report_id 중 하나로 연결
|
||||
*/
|
||||
const getReportDefectsService = async (reportId) => {
|
||||
const db = await getDb();
|
||||
try {
|
||||
const [rows] = await db.execute(`
|
||||
SELECT
|
||||
d.defect_id,
|
||||
d.report_id,
|
||||
d.error_type_id,
|
||||
d.issue_report_id,
|
||||
d.defect_hours,
|
||||
d.note,
|
||||
d.created_at,
|
||||
et.name as error_type_name,
|
||||
et.severity as error_severity,
|
||||
ir.content as issue_content,
|
||||
ir.status as issue_status,
|
||||
irc.category_name as issue_category_name,
|
||||
irc.severity as issue_severity,
|
||||
iri.item_name as issue_item_name
|
||||
FROM work_report_defects d
|
||||
LEFT JOIN error_types et ON d.error_type_id = et.id
|
||||
LEFT JOIN work_issue_reports ir ON d.issue_report_id = ir.report_id
|
||||
LEFT JOIN issue_report_categories irc ON ir.category_id = irc.category_id
|
||||
LEFT JOIN issue_report_items iri ON ir.item_id = iri.item_id
|
||||
WHERE d.report_id = ?
|
||||
ORDER BY d.created_at
|
||||
`, [reportId]);
|
||||
|
||||
return rows;
|
||||
} catch (error) {
|
||||
logger.error('부적합 원인 조회 실패', { reportId, error: error.message });
|
||||
throw new DatabaseError('부적합 원인 조회 중 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 부적합 원인 저장 (전체 교체)
|
||||
* - error_type_id 또는 issue_report_id 중 하나 사용 가능
|
||||
* - issue_report_id: 신고된 이슈와 연결
|
||||
* - error_type_id: 기존 오류 유형 (레거시)
|
||||
*/
|
||||
const saveReportDefectsService = async (reportId, defects) => {
|
||||
const db = await getDb();
|
||||
try {
|
||||
await db.query('START TRANSACTION');
|
||||
|
||||
// 기존 부적합 원인 삭제
|
||||
await db.execute('DELETE FROM work_report_defects WHERE report_id = ?', [reportId]);
|
||||
|
||||
// 새 부적합 원인 추가
|
||||
if (defects && defects.length > 0) {
|
||||
for (const defect of defects) {
|
||||
// issue_report_id > category_id/item_id > error_type_id 순으로 우선
|
||||
await db.execute(`
|
||||
INSERT INTO work_report_defects (report_id, error_type_id, issue_report_id, category_id, item_id, defect_hours, note)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
reportId,
|
||||
(defect.issue_report_id || defect.category_id) ? null : (defect.error_type_id || null),
|
||||
defect.issue_report_id || null,
|
||||
defect.category_id || null,
|
||||
defect.item_id || null,
|
||||
defect.defect_hours || 0,
|
||||
defect.note || null
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 총 부적합 시간 계산 및 daily_work_reports 업데이트
|
||||
const totalErrorHours = defects
|
||||
? defects.reduce((sum, d) => sum + (parseFloat(d.defect_hours) || 0), 0)
|
||||
: 0;
|
||||
|
||||
// 첫 번째 defect의 error_type_id를 대표값으로 (레거시 호환)
|
||||
const firstErrorTypeId = defects && defects.length > 0
|
||||
? (defects.find(d => d.error_type_id)?.error_type_id || null)
|
||||
: null;
|
||||
|
||||
await db.execute(`
|
||||
UPDATE daily_work_reports
|
||||
SET error_hours = ?,
|
||||
error_type_id = ?,
|
||||
work_status_id = ?
|
||||
WHERE id = ?
|
||||
`, [
|
||||
totalErrorHours,
|
||||
firstErrorTypeId,
|
||||
totalErrorHours > 0 ? 2 : 1,
|
||||
reportId
|
||||
]);
|
||||
|
||||
await db.query('COMMIT');
|
||||
|
||||
logger.info('부적합 원인 저장 성공', { reportId, count: defects?.length || 0 });
|
||||
return { success: true, count: defects?.length || 0, totalErrorHours };
|
||||
} catch (error) {
|
||||
await db.query('ROLLBACK');
|
||||
logger.error('부적합 원인 저장 실패', { reportId, error: error.message });
|
||||
throw new DatabaseError('부적합 원인 저장 중 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 부적합 원인 추가 (단일)
|
||||
* - issue_report_id 또는 error_type_id 중 하나 사용
|
||||
*/
|
||||
const addReportDefectService = async (reportId, defectData) => {
|
||||
const db = await getDb();
|
||||
try {
|
||||
const [result] = await db.execute(`
|
||||
INSERT INTO work_report_defects (report_id, error_type_id, issue_report_id, defect_hours, note)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, [
|
||||
reportId,
|
||||
defectData.issue_report_id ? null : (defectData.error_type_id || null),
|
||||
defectData.issue_report_id || null,
|
||||
defectData.defect_hours || 0,
|
||||
defectData.note || null
|
||||
]);
|
||||
|
||||
// 총 부적합 시간 업데이트
|
||||
await updateTotalErrorHours(reportId);
|
||||
|
||||
logger.info('부적합 원인 추가 성공', { reportId, defectId: result.insertId });
|
||||
return { success: true, defect_id: result.insertId };
|
||||
} catch (error) {
|
||||
logger.error('부적합 원인 추가 실패', { reportId, error: error.message });
|
||||
throw new DatabaseError('부적합 원인 추가 중 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 부적합 원인 삭제
|
||||
*/
|
||||
const removeReportDefectService = async (defectId) => {
|
||||
const db = await getDb();
|
||||
try {
|
||||
// report_id 먼저 조회
|
||||
const [defect] = await db.execute('SELECT report_id FROM work_report_defects WHERE defect_id = ?', [defectId]);
|
||||
if (defect.length === 0) {
|
||||
throw new NotFoundError('부적합 원인을 찾을 수 없습니다');
|
||||
}
|
||||
|
||||
const reportId = defect[0].report_id;
|
||||
|
||||
// 삭제
|
||||
await db.execute('DELETE FROM work_report_defects WHERE defect_id = ?', [defectId]);
|
||||
|
||||
// 총 부적합 시간 업데이트
|
||||
await updateTotalErrorHours(reportId);
|
||||
|
||||
logger.info('부적합 원인 삭제 성공', { defectId, reportId });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) throw error;
|
||||
logger.error('부적합 원인 삭제 실패', { defectId, error: error.message });
|
||||
throw new DatabaseError('부적합 원인 삭제 중 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 총 부적합 시간 업데이트 헬퍼
|
||||
* - issue_report_id가 있는 경우도 고려
|
||||
*/
|
||||
const updateTotalErrorHours = async (reportId) => {
|
||||
const db = await getDb();
|
||||
const [result] = await db.execute(`
|
||||
SELECT COALESCE(SUM(defect_hours), 0) as total
|
||||
FROM work_report_defects
|
||||
WHERE report_id = ?
|
||||
`, [reportId]);
|
||||
|
||||
const totalErrorHours = result[0].total || 0;
|
||||
|
||||
// 첫 번째 부적합 원인의 error_type_id를 대표값으로 사용 (레거시 호환)
|
||||
// issue_report_id만 있는 경우 error_type_id는 null
|
||||
const [firstDefect] = await db.execute(`
|
||||
SELECT error_type_id FROM work_report_defects
|
||||
WHERE report_id = ? AND error_type_id IS NOT NULL
|
||||
ORDER BY created_at LIMIT 1
|
||||
`, [reportId]);
|
||||
|
||||
await db.execute(`
|
||||
UPDATE daily_work_reports
|
||||
SET error_hours = ?,
|
||||
error_type_id = ?,
|
||||
work_status_id = ?
|
||||
WHERE id = ?
|
||||
`, [
|
||||
totalErrorHours,
|
||||
firstDefect.length > 0 ? firstDefect[0].error_type_id : null,
|
||||
totalErrorHours > 0 ? 2 : 1,
|
||||
reportId
|
||||
]);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createWorkReportService,
|
||||
getWorkReportsByDateService,
|
||||
getWorkReportsInRangeService,
|
||||
getWorkReportByIdService,
|
||||
updateWorkReportService,
|
||||
removeWorkReportService,
|
||||
getSummaryService,
|
||||
// 부적합 원인 관리
|
||||
getReportDefectsService,
|
||||
saveReportDefectsService,
|
||||
addReportDefectService,
|
||||
removeReportDefectService
|
||||
};
|
||||
Reference in New Issue
Block a user