feat: 페이지 구조 재구성 및 사이드바 네비게이션 구현

- 페이지 폴더 재구성: safety/, attendance/ 폴더 신규 생성
  - work/ → safety/: 이슈 신고, 출입 신청 관련 페이지 이동
  - common/ → attendance/: 근태/휴가 관련 페이지 이동
  - admin/ 정리: safety-* 파일들을 safety/로 이동

- 사이드바 네비게이션 메뉴 구현
  - 카테고리별 메뉴: 작업관리, 안전관리, 근태관리, 시스템관리
  - 접기/펼치기 기능 및 상태 저장
  - 관리자 전용 메뉴 자동 표시/숨김

- 날씨 API 연동 (기상청 단기예보)
  - TBM 및 navbar에 현재 날씨 표시
  - weatherService.js 추가

- 안전 체크리스트 확장
  - 기본/날씨별/작업별 체크 유형 추가
  - checklist-manage.html 페이지 추가

- 이슈 신고 시스템 구현
  - workIssueController, workIssueModel, workIssueRoutes 추가

- DB 마이그레이션 파일 추가 (실행 대기)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-02 14:27:22 +09:00
parent b6485e3140
commit 74d3a78aa3
116 changed files with 23117 additions and 294 deletions

View File

@@ -48,6 +48,7 @@ function setupRoutes(app) {
const vacationTypeRoutes = require('../routes/vacationTypeRoutes');
const vacationBalanceRoutes = require('../routes/vacationBalanceRoutes');
const visitRequestRoutes = require('../routes/visitRequestRoutes');
const workIssueRoutes = require('../routes/workIssueRoutes');
// Rate Limiters 설정
const rateLimit = require('express-rate-limit');
@@ -151,6 +152,7 @@ function setupRoutes(app) {
app.use('/api/workplace-visits', visitRequestRoutes); // 출입 신청 및 안전교육 관리
app.use('/api', pageAccessRoutes); // 페이지 접근 권한 관리
app.use('/api/tbm', tbmRoutes); // TBM 시스템
app.use('/api/work-issues', workIssueRoutes); // 문제 신고 시스템
app.use('/api', uploadBgRoutes);
// Swagger API 문서

View File

@@ -409,6 +409,268 @@ const TbmController = {
});
},
// ==================== 필터링된 안전 체크리스트 (확장) ====================
/**
* 세션에 맞는 필터링된 안전 체크 항목 조회
* 기본 + 날씨 + 작업별 체크항목 통합
*/
getFilteredSafetyChecks: async (req, res) => {
const { sessionId } = req.params;
try {
// 날씨 정보 확인 (이미 저장된 경우 사용, 없으면 새로 조회)
const weatherService = require('../services/weatherService');
let weatherRecord = await weatherService.getWeatherRecord(sessionId);
let weatherConditions = [];
if (weatherRecord && weatherRecord.weather_conditions) {
weatherConditions = weatherRecord.weather_conditions;
} else {
// 날씨 정보가 없으면 현재 날씨 조회
const currentWeather = await weatherService.getCurrentWeather();
weatherConditions = await weatherService.determineWeatherConditions(currentWeather);
// 날씨 기록 저장
await weatherService.saveWeatherRecord(sessionId, currentWeather, weatherConditions);
}
TbmModel.getFilteredSafetyChecks(sessionId, weatherConditions, (err, results) => {
if (err) {
console.error('필터링된 안전 체크 조회 오류:', err);
return res.status(500).json({
success: false,
message: '안전 체크리스트 조회 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('필터링된 안전 체크 조회 오류:', error);
res.status(500).json({
success: false,
message: '안전 체크리스트 조회 중 오류가 발생했습니다.',
error: error.message
});
}
},
/**
* 현재 날씨 조회
*/
getCurrentWeather: async (req, res) => {
try {
const weatherService = require('../services/weatherService');
const { nx, ny } = req.query;
const weatherData = await weatherService.getCurrentWeather(nx, ny);
const conditions = await weatherService.determineWeatherConditions(weatherData);
const conditionList = await weatherService.getWeatherConditionList();
// 현재 조건의 상세 정보 매핑
const activeConditions = conditionList.filter(c => conditions.includes(c.condition_code));
res.json({
success: true,
data: {
...weatherData,
conditions,
conditionDetails: activeConditions
}
});
} catch (error) {
console.error('날씨 조회 오류:', error);
res.status(500).json({
success: false,
message: '날씨 조회 중 오류가 발생했습니다.',
error: error.message
});
}
},
/**
* 세션 날씨 정보 저장
*/
saveSessionWeather: async (req, res) => {
const { sessionId } = req.params;
const { weatherConditions } = req.body;
try {
const weatherService = require('../services/weatherService');
// 현재 날씨 조회
const weatherData = await weatherService.getCurrentWeather();
const conditions = weatherConditions || await weatherService.determineWeatherConditions(weatherData);
// 저장
await weatherService.saveWeatherRecord(sessionId, weatherData, conditions);
res.json({
success: true,
message: '날씨 정보가 저장되었습니다.',
data: { conditions }
});
} catch (error) {
console.error('날씨 저장 오류:', error);
res.status(500).json({
success: false,
message: '날씨 저장 중 오류가 발생했습니다.',
error: error.message
});
}
},
/**
* 세션 날씨 정보 조회
*/
getSessionWeather: async (req, res) => {
const { sessionId } = req.params;
try {
const weatherService = require('../services/weatherService');
const weatherRecord = await weatherService.getWeatherRecord(sessionId);
if (!weatherRecord) {
return res.status(404).json({
success: false,
message: '날씨 기록이 없습니다.'
});
}
res.json({
success: true,
data: weatherRecord
});
} catch (error) {
console.error('날씨 조회 오류:', error);
res.status(500).json({
success: false,
message: '날씨 조회 중 오류가 발생했습니다.',
error: error.message
});
}
},
/**
* 날씨 조건 목록 조회
*/
getWeatherConditions: async (req, res) => {
try {
const weatherService = require('../services/weatherService');
const conditions = await weatherService.getWeatherConditionList();
res.json({
success: true,
data: conditions
});
} catch (error) {
console.error('날씨 조건 조회 오류:', error);
res.status(500).json({
success: false,
message: '날씨 조건 조회 중 오류가 발생했습니다.',
error: error.message
});
}
},
// ==================== 안전 체크항목 관리 (관리자용) ====================
/**
* 안전 체크 항목 생성
*/
createSafetyCheck: (req, res) => {
const checkData = req.body;
if (!checkData.check_category || !checkData.check_item) {
return res.status(400).json({
success: false,
message: '카테고리와 체크 항목은 필수입니다.'
});
}
TbmModel.createSafetyCheck(checkData, (err, result) => {
if (err) {
console.error('안전 체크 항목 생성 오류:', err);
return res.status(500).json({
success: false,
message: '안전 체크 항목 생성 중 오류가 발생했습니다.',
error: err.message
});
}
res.status(201).json({
success: true,
message: '안전 체크 항목이 생성되었습니다.',
data: { check_id: result.insertId }
});
});
},
/**
* 안전 체크 항목 수정
*/
updateSafetyCheck: (req, res) => {
const { checkId } = req.params;
const checkData = req.body;
TbmModel.updateSafetyCheck(checkId, checkData, (err, result) => {
if (err) {
console.error('안전 체크 항목 수정 오류:', err);
return res.status(500).json({
success: false,
message: '안전 체크 항목 수정 중 오류가 발생했습니다.',
error: err.message
});
}
if (result.affectedRows === 0) {
return res.status(404).json({
success: false,
message: '안전 체크 항목을 찾을 수 없습니다.'
});
}
res.json({
success: true,
message: '안전 체크 항목이 수정되었습니다.'
});
});
},
/**
* 안전 체크 항목 삭제 (비활성화)
*/
deleteSafetyCheck: (req, res) => {
const { checkId } = req.params;
TbmModel.deleteSafetyCheck(checkId, (err, result) => {
if (err) {
console.error('안전 체크 항목 삭제 오류:', err);
return res.status(500).json({
success: false,
message: '안전 체크 항목 삭제 중 오류가 발생했습니다.',
error: err.message
});
}
if (result.affectedRows === 0) {
return res.status(404).json({
success: false,
message: '안전 체크 항목을 찾을 수 없습니다.'
});
}
res.json({
success: true,
message: '안전 체크 항목이 삭제되었습니다.'
});
});
},
// ==================== 작업 인계 관련 ====================
/**

View File

@@ -218,7 +218,7 @@ const updateUser = asyncHandler(async (req, res) => {
checkAdminPermission(req.user);
const { id } = req.params;
const { username, name, email, phone, role, role_id, password } = req.body;
const { username, name, email, role, role_id, password } = req.body;
if (!id || isNaN(id)) {
throw new ValidationError('유효하지 않은 사용자 ID입니다');
@@ -227,7 +227,7 @@ const updateUser = asyncHandler(async (req, res) => {
logger.info('사용자 수정 요청', { userId: id, body: req.body });
// 최소 하나의 수정 필드가 필요
if (!username && !name && email === undefined && phone === undefined && !role && !role_id && !password) {
if (!username && !name && email === undefined && !role && !role_id && !password) {
throw new ValidationError('수정할 필드가 없습니다');
}
@@ -278,11 +278,6 @@ const updateUser = asyncHandler(async (req, res) => {
values.push(email || null);
}
if (phone !== undefined) {
updates.push('phone = ?');
values.push(phone || null);
}
// role_id 또는 role 문자열 처리
if (role_id) {
// role_id가 유효한지 확인
@@ -497,6 +492,7 @@ const getUserPageAccess = asyncHandler(async (req, res) => {
const db = await getDb();
try {
// 권한 조회: user_page_access에 명시적 권한이 있으면 사용, 없으면 is_default_accessible 사용
const query = `
SELECT
p.id as page_id,
@@ -504,10 +500,10 @@ const getUserPageAccess = asyncHandler(async (req, res) => {
p.page_name,
p.page_path,
p.category,
COALESCE(upa.can_access, 0) as can_access
p.is_default_accessible,
COALESCE(upa.can_access, p.is_default_accessible) as can_access
FROM pages p
LEFT JOIN user_page_access upa ON p.id = upa.page_id AND upa.user_id = ?
WHERE p.is_active = 1
ORDER BY p.category, p.display_order
`;
@@ -595,6 +591,55 @@ const updateUserPageAccess = asyncHandler(async (req, res) => {
}
});
/**
* 사용자 비밀번호 초기화 (000000)
*/
const resetUserPassword = asyncHandler(async (req, res) => {
checkAdminPermission(req.user);
const { id } = req.params;
if (!id || isNaN(id)) {
throw new ValidationError('유효하지 않은 사용자 ID입니다');
}
const { getDb } = require('../dbPool');
const db = await getDb();
try {
// 사용자 존재 확인
const [existing] = await db.execute('SELECT user_id, username FROM users WHERE user_id = ?', [id]);
if (existing.length === 0) {
throw new NotFoundError('사용자를 찾을 수 없습니다');
}
// 비밀번호를 000000으로 초기화
const hashedPassword = await bcrypt.hash('000000', 10);
await db.execute(
'UPDATE users SET password = ?, password_changed_at = NULL, updated_at = NOW() WHERE user_id = ?',
[hashedPassword, id]
);
logger.info('사용자 비밀번호 초기화 성공', {
userId: id,
username: existing[0].username,
resetBy: req.user.username
});
res.json({
success: true,
message: '비밀번호가 000000으로 초기화되었습니다'
});
} catch (error) {
if (error instanceof NotFoundError || error instanceof ValidationError) {
throw error;
}
logger.error('비밀번호 초기화 실패', { userId: id, error: error.message });
throw new DatabaseError('비밀번호 초기화에 실패했습니다');
}
});
module.exports = {
getAllUsers,
getUserById,
@@ -603,5 +648,6 @@ module.exports = {
updateUserStatus,
deleteUser,
getUserPageAccess,
updateUserPageAccess
updateUserPageAccess,
resetUserPassword
};

View File

@@ -0,0 +1,643 @@
/**
* 작업 중 문제 신고 컨트롤러
*/
const workIssueModel = require('../models/workIssueModel');
const imageUploadService = require('../services/imageUploadService');
// ==================== 신고 카테고리 관리 ====================
/**
* 모든 카테고리 조회
*/
exports.getAllCategories = (req, res) => {
workIssueModel.getAllCategories((err, categories) => {
if (err) {
console.error('카테고리 조회 실패:', err);
return res.status(500).json({ success: false, error: '카테고리 조회 실패' });
}
res.json({ success: true, data: categories });
});
};
/**
* 타입별 카테고리 조회
*/
exports.getCategoriesByType = (req, res) => {
const { type } = req.params;
if (!['nonconformity', 'safety'].includes(type)) {
return res.status(400).json({ success: false, error: '유효하지 않은 카테고리 타입입니다.' });
}
workIssueModel.getCategoriesByType(type, (err, categories) => {
if (err) {
console.error('카테고리 조회 실패:', err);
return res.status(500).json({ success: false, error: '카테고리 조회 실패' });
}
res.json({ success: true, data: categories });
});
};
/**
* 카테고리 생성
*/
exports.createCategory = (req, res) => {
const { category_type, category_name, description, display_order } = req.body;
if (!category_type || !category_name) {
return res.status(400).json({ success: false, error: '카테고리 타입과 이름은 필수입니다.' });
}
workIssueModel.createCategory(
{ category_type, category_name, description, display_order },
(err, categoryId) => {
if (err) {
console.error('카테고리 생성 실패:', err);
return res.status(500).json({ success: false, error: '카테고리 생성 실패' });
}
res.status(201).json({
success: true,
message: '카테고리가 생성되었습니다.',
data: { category_id: categoryId }
});
}
);
};
/**
* 카테고리 수정
*/
exports.updateCategory = (req, res) => {
const { id } = req.params;
const { category_name, description, display_order, is_active } = req.body;
workIssueModel.updateCategory(
id,
{ category_name, description, display_order, is_active },
(err, result) => {
if (err) {
console.error('카테고리 수정 실패:', err);
return res.status(500).json({ success: false, error: '카테고리 수정 실패' });
}
res.json({ success: true, message: '카테고리가 수정되었습니다.' });
}
);
};
/**
* 카테고리 삭제
*/
exports.deleteCategory = (req, res) => {
const { id } = req.params;
workIssueModel.deleteCategory(id, (err, result) => {
if (err) {
console.error('카테고리 삭제 실패:', err);
return res.status(500).json({ success: false, error: '카테고리 삭제 실패' });
}
res.json({ success: true, message: '카테고리가 삭제되었습니다.' });
});
};
// ==================== 사전 정의 항목 관리 ====================
/**
* 카테고리별 항목 조회
*/
exports.getItemsByCategory = (req, res) => {
const { categoryId } = req.params;
workIssueModel.getItemsByCategory(categoryId, (err, items) => {
if (err) {
console.error('항목 조회 실패:', err);
return res.status(500).json({ success: false, error: '항목 조회 실패' });
}
res.json({ success: true, data: items });
});
};
/**
* 모든 항목 조회
*/
exports.getAllItems = (req, res) => {
workIssueModel.getAllItems((err, items) => {
if (err) {
console.error('항목 조회 실패:', err);
return res.status(500).json({ success: false, error: '항목 조회 실패' });
}
res.json({ success: true, data: items });
});
};
/**
* 항목 생성
*/
exports.createItem = (req, res) => {
const { category_id, item_name, description, severity, display_order } = req.body;
if (!category_id || !item_name) {
return res.status(400).json({ success: false, error: '카테고리 ID와 항목명은 필수입니다.' });
}
workIssueModel.createItem(
{ category_id, item_name, description, severity, display_order },
(err, itemId) => {
if (err) {
console.error('항목 생성 실패:', err);
return res.status(500).json({ success: false, error: '항목 생성 실패' });
}
res.status(201).json({
success: true,
message: '항목이 생성되었습니다.',
data: { item_id: itemId }
});
}
);
};
/**
* 항목 수정
*/
exports.updateItem = (req, res) => {
const { id } = req.params;
const { item_name, description, severity, display_order, is_active } = req.body;
workIssueModel.updateItem(
id,
{ item_name, description, severity, display_order, is_active },
(err, result) => {
if (err) {
console.error('항목 수정 실패:', err);
return res.status(500).json({ success: false, error: '항목 수정 실패' });
}
res.json({ success: true, message: '항목이 수정되었습니다.' });
}
);
};
/**
* 항목 삭제
*/
exports.deleteItem = (req, res) => {
const { id } = req.params;
workIssueModel.deleteItem(id, (err, result) => {
if (err) {
console.error('항목 삭제 실패:', err);
return res.status(500).json({ success: false, error: '항목 삭제 실패' });
}
res.json({ success: true, message: '항목이 삭제되었습니다.' });
});
};
// ==================== 문제 신고 관리 ====================
/**
* 신고 생성
*/
exports.createReport = async (req, res) => {
try {
const {
factory_category_id,
workplace_id,
custom_location,
tbm_session_id,
visit_request_id,
issue_category_id,
issue_item_id,
additional_description,
photos = []
} = req.body;
const reporter_id = req.user.user_id;
if (!issue_category_id) {
return res.status(400).json({ success: false, error: '신고 카테고리는 필수입니다.' });
}
// 위치 정보 검증 (지도 선택 또는 기타 위치)
if (!factory_category_id && !custom_location) {
return res.status(400).json({ success: false, error: '위치 정보는 필수입니다.' });
}
// 사진 저장 (최대 5장)
const photoPaths = {
photo_path1: null,
photo_path2: null,
photo_path3: null,
photo_path4: null,
photo_path5: null
};
for (let i = 0; i < Math.min(photos.length, 5); i++) {
if (photos[i]) {
const savedPath = await imageUploadService.saveBase64Image(photos[i], 'issue');
if (savedPath) {
photoPaths[`photo_path${i + 1}`] = savedPath;
}
}
}
const reportData = {
reporter_id,
factory_category_id: factory_category_id || null,
workplace_id: workplace_id || null,
custom_location: custom_location || null,
tbm_session_id: tbm_session_id || null,
visit_request_id: visit_request_id || null,
issue_category_id,
issue_item_id: issue_item_id || null,
additional_description: additional_description || null,
...photoPaths
};
workIssueModel.createReport(reportData, (err, reportId) => {
if (err) {
console.error('신고 생성 실패:', err);
return res.status(500).json({ success: false, error: '신고 생성 실패' });
}
res.status(201).json({
success: true,
message: '문제 신고가 등록되었습니다.',
data: { report_id: reportId }
});
});
} catch (error) {
console.error('신고 생성 에러:', error);
res.status(500).json({ success: false, error: '서버 오류가 발생했습니다.' });
}
};
/**
* 신고 목록 조회
*/
exports.getAllReports = (req, res) => {
const filters = {
status: req.query.status,
category_type: req.query.category_type,
issue_category_id: req.query.issue_category_id,
factory_category_id: req.query.factory_category_id,
workplace_id: req.query.workplace_id,
assigned_user_id: req.query.assigned_user_id,
start_date: req.query.start_date,
end_date: req.query.end_date,
search: req.query.search,
limit: req.query.limit,
offset: req.query.offset
};
// 일반 사용자는 자신의 신고만 조회 (관리자 제외)
const userLevel = req.user.access_level;
if (!['admin', 'system', 'support_team'].includes(userLevel)) {
filters.reporter_id = req.user.user_id;
}
workIssueModel.getAllReports(filters, (err, reports) => {
if (err) {
console.error('신고 목록 조회 실패:', err);
return res.status(500).json({ success: false, error: '신고 목록 조회 실패' });
}
res.json({ success: true, data: reports });
});
};
/**
* 신고 상세 조회
*/
exports.getReportById = (req, res) => {
const { id } = req.params;
workIssueModel.getReportById(id, (err, report) => {
if (err) {
console.error('신고 상세 조회 실패:', err);
return res.status(500).json({ success: false, error: '신고 상세 조회 실패' });
}
if (!report) {
return res.status(404).json({ success: false, error: '신고를 찾을 수 없습니다.' });
}
// 권한 확인: 본인, 담당자, 또는 관리자
const userLevel = req.user.access_level;
const isOwner = report.reporter_id === req.user.user_id;
const isAssignee = report.assigned_user_id === req.user.user_id;
const isManager = ['admin', 'system', 'support_team'].includes(userLevel);
if (!isOwner && !isAssignee && !isManager) {
return res.status(403).json({ success: false, error: '권한이 없습니다.' });
}
res.json({ success: true, data: report });
});
};
/**
* 신고 수정
*/
exports.updateReport = async (req, res) => {
try {
const { id } = req.params;
// 기존 신고 확인
workIssueModel.getReportById(id, async (err, report) => {
if (err) {
console.error('신고 조회 실패:', err);
return res.status(500).json({ success: false, error: '신고 조회 실패' });
}
if (!report) {
return res.status(404).json({ success: false, error: '신고를 찾을 수 없습니다.' });
}
// 권한 확인
const userLevel = req.user.access_level;
const isOwner = report.reporter_id === req.user.user_id;
const isManager = ['admin', 'system'].includes(userLevel);
if (!isOwner && !isManager) {
return res.status(403).json({ success: false, error: '수정 권한이 없습니다.' });
}
// 상태 확인: reported 상태에서만 수정 가능 (관리자 제외)
if (!isManager && report.status !== 'reported') {
return res.status(400).json({ success: false, error: '이미 접수된 신고는 수정할 수 없습니다.' });
}
const {
factory_category_id,
workplace_id,
custom_location,
issue_category_id,
issue_item_id,
additional_description,
photos = []
} = req.body;
// 사진 업데이트 처리
const photoPaths = {};
for (let i = 0; i < Math.min(photos.length, 5); i++) {
if (photos[i]) {
// 기존 사진 삭제
const oldPath = report[`photo_path${i + 1}`];
if (oldPath) {
await imageUploadService.deleteFile(oldPath);
}
// 새 사진 저장
const savedPath = await imageUploadService.saveBase64Image(photos[i], 'issue');
if (savedPath) {
photoPaths[`photo_path${i + 1}`] = savedPath;
}
}
}
const updateData = {
factory_category_id,
workplace_id,
custom_location,
issue_category_id,
issue_item_id,
additional_description,
...photoPaths
};
workIssueModel.updateReport(id, updateData, req.user.user_id, (updateErr, result) => {
if (updateErr) {
console.error('신고 수정 실패:', updateErr);
return res.status(500).json({ success: false, error: '신고 수정 실패' });
}
res.json({ success: true, message: '신고가 수정되었습니다.' });
});
});
} catch (error) {
console.error('신고 수정 에러:', error);
res.status(500).json({ success: false, error: '서버 오류가 발생했습니다.' });
}
};
/**
* 신고 삭제
*/
exports.deleteReport = async (req, res) => {
const { id } = req.params;
workIssueModel.getReportById(id, async (err, report) => {
if (err) {
console.error('신고 조회 실패:', err);
return res.status(500).json({ success: false, error: '신고 조회 실패' });
}
if (!report) {
return res.status(404).json({ success: false, error: '신고를 찾을 수 없습니다.' });
}
// 권한 확인
const userLevel = req.user.access_level;
const isOwner = report.reporter_id === req.user.user_id;
const isManager = ['admin', 'system'].includes(userLevel);
if (!isOwner && !isManager) {
return res.status(403).json({ success: false, error: '삭제 권한이 없습니다.' });
}
workIssueModel.deleteReport(id, async (deleteErr, { result, photos }) => {
if (deleteErr) {
console.error('신고 삭제 실패:', deleteErr);
return res.status(500).json({ success: false, error: '신고 삭제 실패' });
}
// 사진 파일 삭제
if (photos) {
const allPhotos = [
photos.photo_path1, photos.photo_path2, photos.photo_path3,
photos.photo_path4, photos.photo_path5,
photos.resolution_photo_path1, photos.resolution_photo_path2
].filter(Boolean);
await imageUploadService.deleteMultipleFiles(allPhotos);
}
res.json({ success: true, message: '신고가 삭제되었습니다.' });
});
});
};
// ==================== 상태 관리 ====================
/**
* 신고 접수
*/
exports.receiveReport = (req, res) => {
const { id } = req.params;
workIssueModel.receiveReport(id, req.user.user_id, (err, result) => {
if (err) {
console.error('신고 접수 실패:', err);
return res.status(400).json({ success: false, error: err.message || '신고 접수 실패' });
}
res.json({ success: true, message: '신고가 접수되었습니다.' });
});
};
/**
* 담당자 배정
*/
exports.assignReport = (req, res) => {
const { id } = req.params;
const { assigned_department, assigned_user_id } = req.body;
if (!assigned_user_id) {
return res.status(400).json({ success: false, error: '담당자는 필수입니다.' });
}
workIssueModel.assignReport(id, {
assigned_department,
assigned_user_id,
assigned_by: req.user.user_id
}, (err, result) => {
if (err) {
console.error('담당자 배정 실패:', err);
return res.status(400).json({ success: false, error: err.message || '담당자 배정 실패' });
}
res.json({ success: true, message: '담당자가 배정되었습니다.' });
});
};
/**
* 처리 시작
*/
exports.startProcessing = (req, res) => {
const { id } = req.params;
workIssueModel.startProcessing(id, req.user.user_id, (err, result) => {
if (err) {
console.error('처리 시작 실패:', err);
return res.status(400).json({ success: false, error: err.message || '처리 시작 실패' });
}
res.json({ success: true, message: '처리가 시작되었습니다.' });
});
};
/**
* 처리 완료
*/
exports.completeReport = async (req, res) => {
try {
const { id } = req.params;
const { resolution_notes, resolution_photos = [] } = req.body;
// 완료 사진 저장
let resolution_photo_path1 = null;
let resolution_photo_path2 = null;
if (resolution_photos[0]) {
resolution_photo_path1 = await imageUploadService.saveBase64Image(resolution_photos[0], 'resolution');
}
if (resolution_photos[1]) {
resolution_photo_path2 = await imageUploadService.saveBase64Image(resolution_photos[1], 'resolution');
}
workIssueModel.completeReport(id, {
resolution_notes,
resolution_photo_path1,
resolution_photo_path2,
resolved_by: req.user.user_id
}, (err, result) => {
if (err) {
console.error('처리 완료 실패:', err);
return res.status(400).json({ success: false, error: err.message || '처리 완료 실패' });
}
res.json({ success: true, message: '처리가 완료되었습니다.' });
});
} catch (error) {
console.error('처리 완료 에러:', error);
res.status(500).json({ success: false, error: '서버 오류가 발생했습니다.' });
}
};
/**
* 신고 종료
*/
exports.closeReport = (req, res) => {
const { id } = req.params;
workIssueModel.closeReport(id, req.user.user_id, (err, result) => {
if (err) {
console.error('신고 종료 실패:', err);
return res.status(400).json({ success: false, error: err.message || '신고 종료 실패' });
}
res.json({ success: true, message: '신고가 종료되었습니다.' });
});
};
/**
* 상태 변경 이력 조회
*/
exports.getStatusLogs = (req, res) => {
const { id } = req.params;
workIssueModel.getStatusLogs(id, (err, logs) => {
if (err) {
console.error('상태 이력 조회 실패:', err);
return res.status(500).json({ success: false, error: '상태 이력 조회 실패' });
}
res.json({ success: true, data: logs });
});
};
// ==================== 통계 ====================
/**
* 통계 요약
*/
exports.getStatsSummary = (req, res) => {
const filters = {
start_date: req.query.start_date,
end_date: req.query.end_date,
factory_category_id: req.query.factory_category_id
};
workIssueModel.getStatsSummary(filters, (err, stats) => {
if (err) {
console.error('통계 조회 실패:', err);
return res.status(500).json({ success: false, error: '통계 조회 실패' });
}
res.json({ success: true, data: stats });
});
};
/**
* 카테고리별 통계
*/
exports.getStatsByCategory = (req, res) => {
const filters = {
start_date: req.query.start_date,
end_date: req.query.end_date
};
workIssueModel.getStatsByCategory(filters, (err, stats) => {
if (err) {
console.error('카테고리별 통계 조회 실패:', err);
return res.status(500).json({ success: false, error: '통계 조회 실패' });
}
res.json({ success: true, data: stats });
});
};
/**
* 작업장별 통계
*/
exports.getStatsByWorkplace = (req, res) => {
const filters = {
start_date: req.query.start_date,
end_date: req.query.end_date,
factory_category_id: req.query.factory_category_id
};
workIssueModel.getStatsByWorkplace(filters, (err, stats) => {
if (err) {
console.error('작업장별 통계 조회 실패:', err);
return res.status(500).json({ success: false, error: '통계 조회 실패' });
}
res.json({ success: true, data: stats });
});
};

View File

@@ -106,3 +106,70 @@ exports.getSummary = asyncHandler(async (req, res) => {
message: '월간 요약 조회 성공'
});
});
// ========== 부적합 원인 관리 API ==========
/**
* 작업 보고서의 부적합 원인 목록 조회
*/
exports.getReportDefects = asyncHandler(async (req, res) => {
const { reportId } = req.params;
const rows = await workReportService.getReportDefectsService(reportId);
res.json({
success: true,
data: rows,
message: '부적합 원인 조회 성공'
});
});
/**
* 부적합 원인 저장 (전체 교체)
* 기존 부적합 원인을 모두 삭제하고 새로 저장
*/
exports.saveReportDefects = asyncHandler(async (req, res) => {
const { reportId } = req.params;
const { defects } = req.body; // [{ error_type_id, defect_hours, note }]
const result = await workReportService.saveReportDefectsService(reportId, defects);
res.json({
success: true,
data: result,
message: '부적합 원인이 저장되었습니다'
});
});
/**
* 부적합 원인 추가 (단일)
*/
exports.addReportDefect = asyncHandler(async (req, res) => {
const { reportId } = req.params;
const { error_type_id, defect_hours, note } = req.body;
const result = await workReportService.addReportDefectService(reportId, {
error_type_id,
defect_hours,
note
});
res.json({
success: true,
data: result,
message: '부적합 원인이 추가되었습니다'
});
});
/**
* 부적합 원인 삭제
*/
exports.removeReportDefect = asyncHandler(async (req, res) => {
const { defectId } = req.params;
const result = await workReportService.removeReportDefectService(defectId);
res.json({
success: true,
data: result,
message: '부적합 원인이 삭제되었습니다'
});
});

View File

@@ -0,0 +1,266 @@
/**
* 마이그레이션: 작업 중 문제 신고 시스템
* - 신고 카테고리 테이블 (부적합/안전)
* - 사전 정의 신고 항목 테이블
* - 문제 신고 메인 테이블
* - 상태 변경 이력 테이블
*/
exports.up = async function(knex) {
// 1. 신고 카테고리 테이블 생성
await knex.schema.createTable('issue_report_categories', function(table) {
table.increments('category_id').primary().comment('카테고리 ID');
table.enum('category_type', ['nonconformity', 'safety']).notNullable().comment('카테고리 유형 (부적합/안전)');
table.string('category_name', 100).notNullable().comment('카테고리명');
table.text('description').nullable().comment('카테고리 설명');
table.integer('display_order').defaultTo(0).comment('표시 순서');
table.boolean('is_active').defaultTo(true).comment('활성 여부');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.index('category_type', 'idx_irc_category_type');
table.index('is_active', 'idx_irc_is_active');
});
// 카테고리 초기 데이터 삽입
await knex('issue_report_categories').insert([
// 부적합 사항
{ category_type: 'nonconformity', category_name: '자재누락', display_order: 1, is_active: true },
{ category_type: 'nonconformity', category_name: '설계미스', display_order: 2, is_active: true },
{ category_type: 'nonconformity', category_name: '입고불량', display_order: 3, is_active: true },
{ category_type: 'nonconformity', category_name: '검사미스', display_order: 4, is_active: true },
{ category_type: 'nonconformity', category_name: '기타 부적합', display_order: 99, is_active: true },
// 안전 관련
{ category_type: 'safety', category_name: '보호구 미착용', display_order: 1, is_active: true },
{ category_type: 'safety', category_name: '위험구역 출입', display_order: 2, is_active: true },
{ category_type: 'safety', category_name: '안전시설 파손', display_order: 3, is_active: true },
{ category_type: 'safety', category_name: '안전수칙 위반', display_order: 4, is_active: true },
{ category_type: 'safety', category_name: '기타 안전', display_order: 99, is_active: true }
]);
// 2. 사전 정의 신고 항목 테이블 생성
await knex.schema.createTable('issue_report_items', function(table) {
table.increments('item_id').primary().comment('항목 ID');
table.integer('category_id').unsigned().notNullable().comment('소속 카테고리 ID');
table.string('item_name', 200).notNullable().comment('신고 항목명');
table.text('description').nullable().comment('항목 설명');
table.enum('severity', ['low', 'medium', 'high', 'critical']).defaultTo('medium').comment('심각도');
table.integer('display_order').defaultTo(0).comment('표시 순서');
table.boolean('is_active').defaultTo(true).comment('활성 여부');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.foreign('category_id')
.references('category_id')
.inTable('issue_report_categories')
.onDelete('CASCADE')
.onUpdate('CASCADE');
table.index('category_id', 'idx_iri_category_id');
table.index('is_active', 'idx_iri_is_active');
});
// 사전 정의 항목 초기 데이터 삽입
await knex('issue_report_items').insert([
// 자재누락 (category_id: 1)
{ category_id: 1, item_name: '배관 자재 미입고', severity: 'high', display_order: 1 },
{ category_id: 1, item_name: '피팅류 부족', severity: 'medium', display_order: 2 },
{ category_id: 1, item_name: '밸브류 미입고', severity: 'high', display_order: 3 },
{ category_id: 1, item_name: '가스켓/볼트류 부족', severity: 'low', display_order: 4 },
{ category_id: 1, item_name: '서포트 자재 부족', severity: 'medium', display_order: 5 },
// 설계미스 (category_id: 2)
{ category_id: 2, item_name: '도면 치수 오류', severity: 'high', display_order: 1 },
{ category_id: 2, item_name: '스펙 불일치', severity: 'high', display_order: 2 },
{ category_id: 2, item_name: '누락된 상세도', severity: 'medium', display_order: 3 },
{ category_id: 2, item_name: '간섭 발생', severity: 'critical', display_order: 4 },
// 입고불량 (category_id: 3)
{ category_id: 3, item_name: '외관 불량', severity: 'medium', display_order: 1 },
{ category_id: 3, item_name: '치수 불량', severity: 'high', display_order: 2 },
{ category_id: 3, item_name: '수량 부족', severity: 'medium', display_order: 3 },
{ category_id: 3, item_name: '재질 불일치', severity: 'critical', display_order: 4 },
// 검사미스 (category_id: 4)
{ category_id: 4, item_name: '치수 검사 누락', severity: 'high', display_order: 1 },
{ category_id: 4, item_name: '외관 검사 누락', severity: 'medium', display_order: 2 },
{ category_id: 4, item_name: '용접 검사 누락', severity: 'critical', display_order: 3 },
{ category_id: 4, item_name: '도장 검사 누락', severity: 'medium', display_order: 4 },
// 보호구 미착용 (category_id: 6)
{ category_id: 6, item_name: '안전모 미착용', severity: 'high', display_order: 1 },
{ category_id: 6, item_name: '안전화 미착용', severity: 'high', display_order: 2 },
{ category_id: 6, item_name: '보안경 미착용', severity: 'medium', display_order: 3 },
{ category_id: 6, item_name: '안전대 미착용', severity: 'critical', display_order: 4 },
{ category_id: 6, item_name: '귀마개 미착용', severity: 'low', display_order: 5 },
{ category_id: 6, item_name: '안전장갑 미착용', severity: 'medium', display_order: 6 },
// 위험구역 출입 (category_id: 7)
{ category_id: 7, item_name: '통제구역 무단 출입', severity: 'critical', display_order: 1 },
{ category_id: 7, item_name: '고소 작업 구역 무단 출입', severity: 'critical', display_order: 2 },
{ category_id: 7, item_name: '밀폐공간 무단 진입', severity: 'critical', display_order: 3 },
{ category_id: 7, item_name: '장비 가동 구역 무단 접근', severity: 'high', display_order: 4 },
// 안전시설 파손 (category_id: 8)
{ category_id: 8, item_name: '안전난간 파손', severity: 'high', display_order: 1 },
{ category_id: 8, item_name: '경고 표지판 훼손', severity: 'medium', display_order: 2 },
{ category_id: 8, item_name: '안전망 파손', severity: 'high', display_order: 3 },
{ category_id: 8, item_name: '비상조명 고장', severity: 'medium', display_order: 4 },
{ category_id: 8, item_name: '소화설비 파손', severity: 'critical', display_order: 5 },
// 안전수칙 위반 (category_id: 9)
{ category_id: 9, item_name: '지정 통로 미사용', severity: 'medium', display_order: 1 },
{ category_id: 9, item_name: '고소 작업 안전 미준수', severity: 'critical', display_order: 2 },
{ category_id: 9, item_name: '화기 작업 절차 미준수', severity: 'critical', display_order: 3 },
{ category_id: 9, item_name: '정리정돈 미흡', severity: 'low', display_order: 4 },
{ category_id: 9, item_name: '장비 조작 절차 미준수', severity: 'high', display_order: 5 }
]);
// 3. 문제 신고 메인 테이블 생성
await knex.schema.createTable('work_issue_reports', function(table) {
table.increments('report_id').primary().comment('신고 ID');
// 신고자 정보
table.integer('reporter_id').notNullable().comment('신고자 user_id');
table.datetime('report_date').defaultTo(knex.fn.now()).comment('신고 일시');
// 위치 정보
table.integer('factory_category_id').unsigned().nullable().comment('공장 카테고리 ID (지도 외 위치 시 null)');
table.integer('workplace_id').unsigned().nullable().comment('작업장 ID (지도 외 위치 시 null)');
table.string('custom_location', 200).nullable().comment('기타 위치 (지도 외 선택 시)');
// 작업 연결 정보 (선택적)
table.integer('tbm_session_id').unsigned().nullable().comment('연결된 TBM 세션');
table.integer('visit_request_id').unsigned().nullable().comment('연결된 출입 신청');
// 신고 내용
table.integer('issue_category_id').unsigned().notNullable().comment('신고 카테고리 ID');
table.integer('issue_item_id').unsigned().nullable().comment('사전 정의 신고 항목 ID');
table.text('additional_description').nullable().comment('추가 설명');
// 사진 (최대 5장)
table.string('photo_path1', 255).nullable().comment('사진 1');
table.string('photo_path2', 255).nullable().comment('사진 2');
table.string('photo_path3', 255).nullable().comment('사진 3');
table.string('photo_path4', 255).nullable().comment('사진 4');
table.string('photo_path5', 255).nullable().comment('사진 5');
// 상태 관리
table.enum('status', ['reported', 'received', 'in_progress', 'completed', 'closed'])
.defaultTo('reported')
.comment('상태: 신고→접수→처리중→완료→종료');
// 담당자 배정
table.string('assigned_department', 100).nullable().comment('담당 부서');
table.integer('assigned_user_id').nullable().comment('담당자 user_id');
table.datetime('assigned_at').nullable().comment('배정 일시');
table.integer('assigned_by').nullable().comment('배정자 user_id');
// 처리 정보
table.text('resolution_notes').nullable().comment('처리 내용');
table.string('resolution_photo_path1', 255).nullable().comment('처리 완료 사진 1');
table.string('resolution_photo_path2', 255).nullable().comment('처리 완료 사진 2');
table.datetime('resolved_at').nullable().comment('처리 완료 일시');
table.integer('resolved_by').nullable().comment('처리 완료자 user_id');
// 수정 이력 (JSON)
table.json('modification_history').nullable().comment('수정 이력 추적');
// 타임스탬프
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
// 외래키
table.foreign('reporter_id')
.references('user_id')
.inTable('users')
.onDelete('RESTRICT')
.onUpdate('CASCADE');
table.foreign('factory_category_id')
.references('category_id')
.inTable('workplace_categories')
.onDelete('SET NULL')
.onUpdate('CASCADE');
table.foreign('workplace_id')
.references('workplace_id')
.inTable('workplaces')
.onDelete('SET NULL')
.onUpdate('CASCADE');
table.foreign('issue_category_id')
.references('category_id')
.inTable('issue_report_categories')
.onDelete('RESTRICT')
.onUpdate('CASCADE');
table.foreign('issue_item_id')
.references('item_id')
.inTable('issue_report_items')
.onDelete('SET NULL')
.onUpdate('CASCADE');
table.foreign('assigned_user_id')
.references('user_id')
.inTable('users')
.onDelete('SET NULL')
.onUpdate('CASCADE');
table.foreign('assigned_by')
.references('user_id')
.inTable('users')
.onDelete('SET NULL')
.onUpdate('CASCADE');
table.foreign('resolved_by')
.references('user_id')
.inTable('users')
.onDelete('SET NULL')
.onUpdate('CASCADE');
// 인덱스
table.index('reporter_id', 'idx_wir_reporter_id');
table.index('status', 'idx_wir_status');
table.index('report_date', 'idx_wir_report_date');
table.index(['factory_category_id', 'workplace_id'], 'idx_wir_workplace');
table.index('issue_category_id', 'idx_wir_issue_category');
table.index('assigned_user_id', 'idx_wir_assigned_user');
});
// 4. 상태 변경 이력 테이블 생성
await knex.schema.createTable('work_issue_status_logs', function(table) {
table.increments('log_id').primary().comment('로그 ID');
table.integer('report_id').unsigned().notNullable().comment('신고 ID');
table.string('previous_status', 50).nullable().comment('이전 상태');
table.string('new_status', 50).notNullable().comment('새 상태');
table.integer('changed_by').notNullable().comment('변경자 user_id');
table.text('change_reason').nullable().comment('변경 사유');
table.timestamp('changed_at').defaultTo(knex.fn.now());
table.foreign('report_id')
.references('report_id')
.inTable('work_issue_reports')
.onDelete('CASCADE')
.onUpdate('CASCADE');
table.foreign('changed_by')
.references('user_id')
.inTable('users')
.onDelete('RESTRICT')
.onUpdate('CASCADE');
table.index('report_id', 'idx_wisl_report_id');
table.index('changed_at', 'idx_wisl_changed_at');
});
console.log('작업 중 문제 신고 시스템 테이블 생성 완료');
};
exports.down = async function(knex) {
// 역순으로 테이블 삭제
await knex.schema.dropTableIfExists('work_issue_status_logs');
await knex.schema.dropTableIfExists('work_issue_reports');
await knex.schema.dropTableIfExists('issue_report_items');
await knex.schema.dropTableIfExists('issue_report_categories');
console.log('작업 중 문제 신고 시스템 테이블 삭제 완료');
};

View File

@@ -0,0 +1,50 @@
/**
* 마이그레이션: 문제 신고 관련 페이지 등록
*/
exports.up = async function(knex) {
// 문제 신고 등록 페이지
await knex('pages').insert({
page_key: 'issue-report',
page_name: '문제 신고',
page_path: '/pages/work/issue-report.html',
category: 'work',
description: '작업 중 문제(부적합/안전) 신고 등록',
is_admin_only: 0,
display_order: 16
});
// 신고 목록 페이지
await knex('pages').insert({
page_key: 'issue-list',
page_name: '신고 목록',
page_path: '/pages/work/issue-list.html',
category: 'work',
description: '문제 신고 목록 조회 및 관리',
is_admin_only: 0,
display_order: 17
});
// 신고 상세 페이지
await knex('pages').insert({
page_key: 'issue-detail',
page_name: '신고 상세',
page_path: '/pages/work/issue-detail.html',
category: 'work',
description: '문제 신고 상세 조회',
is_admin_only: 0,
display_order: 18
});
console.log('✅ 문제 신고 페이지 등록 완료');
};
exports.down = async function(knex) {
await knex('pages').whereIn('page_key', [
'issue-report',
'issue-list',
'issue-detail'
]).delete();
console.log('✅ 문제 신고 페이지 삭제 완료');
};

View File

@@ -0,0 +1,47 @@
/**
* 작업보고서 부적합 상세 테이블 마이그레이션
*
* 기존: error_hours, error_type_id (단일 값)
* 변경: 여러 부적합 원인 + 각 원인별 시간 저장 가능
*/
exports.up = function(knex) {
return knex.schema
// 1. work_report_defects 테이블 생성
.createTable('work_report_defects', function(table) {
table.increments('defect_id').primary();
table.integer('report_id').notNullable()
.comment('daily_work_reports의 id');
table.integer('error_type_id').notNullable()
.comment('error_types의 id (부적합 원인)');
table.decimal('defect_hours', 4, 1).notNullable().defaultTo(0)
.comment('해당 원인의 부적합 시간');
table.text('note').nullable()
.comment('추가 메모');
table.timestamp('created_at').defaultTo(knex.fn.now());
// 외래키
table.foreign('report_id').references('id').inTable('daily_work_reports').onDelete('CASCADE');
table.foreign('error_type_id').references('id').inTable('error_types');
// 인덱스
table.index('report_id');
table.index('error_type_id');
// 같은 보고서에 같은 원인이 중복되지 않도록
table.unique(['report_id', 'error_type_id']);
})
// 2. 기존 데이터 마이그레이션 (error_hours > 0인 경우)
.then(function() {
return knex.raw(`
INSERT INTO work_report_defects (report_id, error_type_id, defect_hours, created_at)
SELECT id, error_type_id, error_hours, created_at
FROM daily_work_reports
WHERE error_hours > 0 AND error_type_id IS NOT NULL
`);
});
};
exports.down = function(knex) {
return knex.schema.dropTableIfExists('work_report_defects');
};

View File

@@ -0,0 +1,141 @@
/**
* 안전 체크리스트 확장 마이그레이션
*
* 1. tbm_safety_checks 테이블 확장 (check_type, weather_condition, task_id)
* 2. weather_conditions 테이블 생성 (날씨 조건 코드)
* 3. tbm_weather_records 테이블 생성 (세션별 날씨 기록)
* 4. 초기 날씨별 체크항목 데이터
*
* @since 2026-02-02
*/
exports.up = function(knex) {
return knex.schema
// 1. tbm_safety_checks 테이블 확장
.alterTable('tbm_safety_checks', function(table) {
table.enum('check_type', ['basic', 'weather', 'task']).defaultTo('basic').after('check_category');
table.string('weather_condition', 50).nullable().after('check_type');
table.integer('task_id').unsigned().nullable().after('weather_condition');
// 인덱스 추가
table.index('check_type');
table.index('weather_condition');
table.index('task_id');
})
// 2. weather_conditions 테이블 생성
.createTable('weather_conditions', function(table) {
table.string('condition_code', 50).primary();
table.string('condition_name', 100).notNullable();
table.text('description').nullable();
table.string('icon', 50).nullable();
table.decimal('temp_threshold_min', 4, 1).nullable(); // 최소 기온 기준
table.decimal('temp_threshold_max', 4, 1).nullable(); // 최대 기온 기준
table.decimal('wind_threshold', 4, 1).nullable(); // 풍속 기준 (m/s)
table.decimal('precip_threshold', 5, 1).nullable(); // 강수량 기준 (mm)
table.boolean('is_active').defaultTo(true);
table.integer('display_order').defaultTo(0);
table.timestamp('created_at').defaultTo(knex.fn.now());
})
// 3. tbm_weather_records 테이블 생성
.createTable('tbm_weather_records', function(table) {
table.increments('record_id').primary();
table.integer('session_id').unsigned().notNullable();
table.date('weather_date').notNullable();
table.decimal('temperature', 4, 1).nullable(); // 기온 (섭씨)
table.integer('humidity').nullable(); // 습도 (%)
table.decimal('wind_speed', 4, 1).nullable(); // 풍속 (m/s)
table.decimal('precipitation', 5, 1).nullable(); // 강수량 (mm)
table.string('sky_condition', 50).nullable(); // 하늘 상태
table.string('weather_condition', 50).nullable(); // 주요 날씨 상태
table.json('weather_conditions').nullable(); // 복수 조건 ['rain', 'wind']
table.string('data_source', 50).defaultTo('api'); // 데이터 출처
table.timestamp('fetched_at').nullable();
table.timestamp('created_at').defaultTo(knex.fn.now());
// 외래키
table.foreign('session_id').references('session_id').inTable('tbm_sessions').onDelete('CASCADE');
// 인덱스
table.index('weather_date');
table.unique(['session_id']);
})
// 4. 초기 데이터 삽입
.then(function() {
// 기존 체크항목을 'basic' 유형으로 업데이트
return knex('tbm_safety_checks').update({ check_type: 'basic' });
})
.then(function() {
// 날씨 조건 코드 삽입
return knex('weather_conditions').insert([
{ condition_code: 'clear', condition_name: '맑음', description: '맑은 날씨', icon: 'sunny', display_order: 1 },
{ condition_code: 'rain', condition_name: '비', description: '비 오는 날씨', icon: 'rainy', precip_threshold: 0.1, display_order: 2 },
{ condition_code: 'snow', condition_name: '눈', description: '눈 오는 날씨', icon: 'snowy', display_order: 3 },
{ condition_code: 'heat', condition_name: '폭염', description: '기온 35도 이상', icon: 'hot', temp_threshold_min: 35, display_order: 4 },
{ condition_code: 'cold', condition_name: '한파', description: '기온 영하 10도 이하', icon: 'cold', temp_threshold_max: -10, display_order: 5 },
{ condition_code: 'wind', condition_name: '강풍', description: '풍속 10m/s 이상', icon: 'windy', wind_threshold: 10, display_order: 6 },
{ condition_code: 'fog', condition_name: '안개', description: '시정 1km 미만', icon: 'foggy', display_order: 7 },
{ condition_code: 'dust', condition_name: '미세먼지', description: '미세먼지 나쁨 이상', icon: 'dusty', display_order: 8 }
]);
})
.then(function() {
// 날씨별 안전 체크항목 삽입
return knex('tbm_safety_checks').insert([
// 비 (rain)
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'rain', check_item: '우의/우산 준비 확인', description: '비 오는 날 우의 또는 우산 준비 여부', is_required: true, display_order: 1 },
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'rain', check_item: '미끄럼 방지 조치 확인', description: '빗물로 인한 미끄러움 방지 조치', is_required: true, display_order: 2 },
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'rain', check_item: '전기 작업 중단 여부 확인', description: '우천 시 전기 작업 중단 필요성 확인', is_required: true, display_order: 3 },
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'rain', check_item: '배수 상태 확인', description: '작업장 배수 상태 점검', is_required: false, display_order: 4 },
// 눈 (snow)
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'snow', check_item: '제설 작업 완료 확인', description: '작업장 주변 제설 작업 완료 여부', is_required: true, display_order: 1 },
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'snow', check_item: '동파 방지 조치 확인', description: '배관 및 설비 동파 방지 조치', is_required: true, display_order: 2 },
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'snow', check_item: '미끄럼 방지 모래/염화칼슘 비치', description: '미끄럼 방지를 위한 모래 또는 염화칼슘 비치', is_required: true, display_order: 3 },
// 폭염 (heat)
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'heat', check_item: '그늘막/휴게소 확보', description: '무더위 휴식을 위한 그늘막 또는 휴게소 확보', is_required: true, display_order: 1 },
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'heat', check_item: '음료수/식염 포도당 비치', description: '열사병 예방을 위한 음료수 및 염분 보충제 비치', is_required: true, display_order: 2 },
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'heat', check_item: '무더위 휴식 시간 확보', description: '10~15시 사이 충분한 휴식 시간 확보', is_required: true, display_order: 3 },
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'heat', check_item: '작업자 건강 상태 확인', description: '열사병 증상 체크 및 건강 상태 확인', is_required: true, display_order: 4 },
// 한파 (cold)
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'cold', check_item: '방한복/방한장갑 착용 확인', description: '동상 방지를 위한 방한복 및 방한장갑 착용', is_required: true, display_order: 1 },
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'cold', check_item: '난방시설 가동 확인', description: '휴게 공간 난방시설 가동 상태 확인', is_required: true, display_order: 2 },
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'cold', check_item: '온열 음료 비치', description: '체온 유지를 위한 따뜻한 음료 비치', is_required: false, display_order: 3 },
// 강풍 (wind)
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'wind', check_item: '고소 작업 중단 여부 확인', description: '강풍 시 고소 작업 중단 필요성 확인', is_required: true, display_order: 1 },
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'wind', check_item: '자재/장비 결박 확인', description: '바람에 날릴 수 있는 자재 및 장비 고정', is_required: true, display_order: 2 },
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'wind', check_item: '가설물 안전 점검', description: '가설 구조물 및 비계 안전 상태 점검', is_required: true, display_order: 3 },
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'wind', check_item: '크레인 작업 중단 여부 확인', description: '강풍 시 크레인 작업 중단 필요성 확인', is_required: true, display_order: 4 },
// 안개 (fog)
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'fog', check_item: '경광등/조명 확보', description: '시정 확보를 위한 경광등 및 조명 설치', is_required: true, display_order: 1 },
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'fog', check_item: '차량 운행 주의 안내', description: '안개로 인한 차량 운행 주의 안내', is_required: true, display_order: 2 },
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'fog', check_item: '작업 구역 표시 강화', description: '시인성 확보를 위한 작업 구역 표시 강화', is_required: false, display_order: 3 },
// 미세먼지 (dust)
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'dust', check_item: '보호 마스크 착용 확인', description: 'KF94 이상 마스크 착용 여부 확인', is_required: true, display_order: 1 },
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'dust', check_item: '실외 작업 시간 조정', description: '미세먼지 농도에 따른 실외 작업 시간 조정', is_required: true, display_order: 2 },
{ check_category: 'WEATHER', check_type: 'weather', weather_condition: 'dust', check_item: '호흡기 질환자 실내 배치', description: '호흡기 질환 작업자 실내 작업 배치', is_required: false, display_order: 3 }
]);
});
};
exports.down = function(knex) {
return knex.schema
.dropTableIfExists('tbm_weather_records')
.dropTableIfExists('weather_conditions')
.then(function() {
return knex.schema.alterTable('tbm_safety_checks', function(table) {
table.dropIndex('check_type');
table.dropIndex('weather_condition');
table.dropIndex('task_id');
table.dropColumn('check_type');
table.dropColumn('weather_condition');
table.dropColumn('task_id');
});
});
};

View File

@@ -0,0 +1,319 @@
/**
* 페이지 구조 재구성 마이그레이션
* - 페이지 경로 업데이트 (safety/, attendance/ 폴더로 이동)
* - 카테고리 재분류
* - 역할별 기본 페이지 권한 테이블 생성
*/
exports.up = async function(knex) {
// 1. 페이지 경로 업데이트 - safety 폴더로 이동된 페이지들
const safetyPageUpdates = [
{
old_key: 'issue-report',
new_key: 'safety.issue_report',
new_path: '/pages/safety/issue-report.html',
new_category: 'safety',
new_name: '이슈 신고'
},
{
old_key: 'issue-list',
new_key: 'safety.issue_list',
new_path: '/pages/safety/issue-list.html',
new_category: 'safety',
new_name: '이슈 목록'
},
{
old_key: 'issue-detail',
new_key: 'safety.issue_detail',
new_path: '/pages/safety/issue-detail.html',
new_category: 'safety',
new_name: '이슈 상세'
},
{
old_key: 'visit-request',
new_key: 'safety.visit_request',
new_path: '/pages/safety/visit-request.html',
new_category: 'safety',
new_name: '방문 요청'
},
{
old_key: 'safety-management',
new_key: 'safety.management',
new_path: '/pages/safety/management.html',
new_category: 'safety',
new_name: '안전 관리'
},
{
old_key: 'safety-training-conduct',
new_key: 'safety.training_conduct',
new_path: '/pages/safety/training-conduct.html',
new_category: 'safety',
new_name: '안전교육 진행'
}
];
// 2. 페이지 경로 업데이트 - attendance 폴더로 이동된 페이지들
const attendancePageUpdates = [
{
old_key: 'daily-attendance',
new_key: 'attendance.daily',
new_path: '/pages/attendance/daily.html',
new_category: 'attendance',
new_name: '일일 출퇴근'
},
{
old_key: 'monthly-attendance',
new_key: 'attendance.monthly',
new_path: '/pages/attendance/monthly.html',
new_category: 'attendance',
new_name: '월간 근태'
},
{
old_key: 'annual-vacation-overview',
new_key: 'attendance.annual_overview',
new_path: '/pages/attendance/annual-overview.html',
new_category: 'attendance',
new_name: '연간 휴가 현황'
},
{
old_key: 'vacation-request',
new_key: 'attendance.vacation_request',
new_path: '/pages/attendance/vacation-request.html',
new_category: 'attendance',
new_name: '휴가 신청'
},
{
old_key: 'vacation-management',
new_key: 'attendance.vacation_management',
new_path: '/pages/attendance/vacation-management.html',
new_category: 'attendance',
new_name: '휴가 관리'
},
{
old_key: 'vacation-allocation',
new_key: 'attendance.vacation_allocation',
new_path: '/pages/attendance/vacation-allocation.html',
new_category: 'attendance',
new_name: '휴가 발생 입력'
}
];
// 3. admin 폴더 내 파일명 변경
const adminPageUpdates = [
{
old_key: 'attendance-report-comparison',
new_key: 'admin.attendance_report',
new_path: '/pages/admin/attendance-report.html',
new_category: 'admin',
new_name: '출퇴근-보고서 대조'
}
];
// 모든 업데이트 실행
const allUpdates = [...safetyPageUpdates, ...attendancePageUpdates, ...adminPageUpdates];
for (const update of allUpdates) {
await knex('pages')
.where('page_key', update.old_key)
.update({
page_key: update.new_key,
page_path: update.new_path,
category: update.new_category,
page_name: update.new_name
});
}
// 4. 안전 체크리스트 관리 페이지 추가 (새로 생성된 페이지)
const existingChecklistPage = await knex('pages')
.where('page_key', 'safety.checklist_manage')
.orWhere('page_key', 'safety-checklist-manage')
.first();
if (!existingChecklistPage) {
await knex('pages').insert({
page_key: 'safety.checklist_manage',
page_name: '안전 체크리스트 관리',
page_path: '/pages/safety/checklist-manage.html',
category: 'safety',
description: '안전 체크리스트 항목 관리',
is_admin_only: 1,
display_order: 50
});
}
// 5. 휴가 승인/직접입력 페이지 추가 (새로 생성된 페이지인 경우)
const vacationPages = [
{
page_key: 'attendance.vacation_approval',
page_name: '휴가 승인 관리',
page_path: '/pages/attendance/vacation-approval.html',
category: 'attendance',
description: '휴가 신청 승인/거부',
is_admin_only: 1,
display_order: 65
},
{
page_key: 'attendance.vacation_input',
page_name: '휴가 직접 입력',
page_path: '/pages/attendance/vacation-input.html',
category: 'attendance',
description: '관리자 휴가 직접 입력',
is_admin_only: 1,
display_order: 66
}
];
for (const page of vacationPages) {
const existing = await knex('pages').where('page_key', page.page_key).first();
if (!existing) {
await knex('pages').insert(page);
}
}
// 6. role_default_pages 테이블 생성 (역할별 기본 페이지 권한)
const tableExists = await knex.schema.hasTable('role_default_pages');
if (!tableExists) {
await knex.schema.createTable('role_default_pages', (table) => {
table.integer('role_id').unsigned().notNullable()
.references('id').inTable('roles').onDelete('CASCADE');
table.integer('page_id').unsigned().notNullable()
.references('id').inTable('pages').onDelete('CASCADE');
table.primary(['role_id', 'page_id']);
table.timestamps(true, true);
});
}
// 7. 기본 역할-페이지 매핑 데이터 삽입
// 역할 조회
const roles = await knex('roles').select('id', 'name');
const pages = await knex('pages').select('id', 'page_key', 'category');
const roleMap = {};
roles.forEach(r => { roleMap[r.name] = r.id; });
const pageMap = {};
pages.forEach(p => { pageMap[p.page_key] = p.id; });
// Worker 역할 기본 페이지 (대시보드, 작업보고서, 휴가신청)
const workerPages = [
'dashboard',
'work.report_create',
'work.report_view',
'attendance.vacation_request'
];
// Leader 역할 기본 페이지 (Worker + TBM, 안전, 근태 일부)
const leaderPages = [
...workerPages,
'work.tbm',
'work.analysis',
'safety.issue_report',
'safety.issue_list',
'attendance.daily',
'attendance.monthly'
];
// SafetyManager 역할 기본 페이지 (Leader + 안전 전체)
const safetyManagerPages = [
...leaderPages,
'safety.issue_detail',
'safety.visit_request',
'safety.management',
'safety.training_conduct',
'safety.checklist_manage'
];
// 역할별 페이지 매핑 삽입
const rolePageMappings = [];
if (roleMap['Worker']) {
workerPages.forEach(pageKey => {
if (pageMap[pageKey]) {
rolePageMappings.push({ role_id: roleMap['Worker'], page_id: pageMap[pageKey] });
}
});
}
if (roleMap['Leader']) {
leaderPages.forEach(pageKey => {
if (pageMap[pageKey]) {
rolePageMappings.push({ role_id: roleMap['Leader'], page_id: pageMap[pageKey] });
}
});
}
if (roleMap['SafetyManager']) {
safetyManagerPages.forEach(pageKey => {
if (pageMap[pageKey]) {
rolePageMappings.push({ role_id: roleMap['SafetyManager'], page_id: pageMap[pageKey] });
}
});
}
// 중복 제거 후 삽입
for (const mapping of rolePageMappings) {
const existing = await knex('role_default_pages')
.where('role_id', mapping.role_id)
.where('page_id', mapping.page_id)
.first();
if (!existing) {
await knex('role_default_pages').insert(mapping);
}
}
console.log('페이지 구조 재구성 완료');
console.log(`- 업데이트된 페이지: ${allUpdates.length}`);
console.log(`- 역할별 기본 페이지 매핑: ${rolePageMappings.length}`);
};
exports.down = async function(knex) {
// 1. role_default_pages 테이블 삭제
await knex.schema.dropTableIfExists('role_default_pages');
// 2. 페이지 경로 원복 - safety → work/admin
const safetyRevert = [
{ new_key: 'safety.issue_report', old_key: 'issue-report', old_path: '/pages/work/issue-report.html', old_category: 'work' },
{ new_key: 'safety.issue_list', old_key: 'issue-list', old_path: '/pages/work/issue-list.html', old_category: 'work' },
{ new_key: 'safety.issue_detail', old_key: 'issue-detail', old_path: '/pages/work/issue-detail.html', old_category: 'work' },
{ new_key: 'safety.visit_request', old_key: 'visit-request', old_path: '/pages/work/visit-request.html', old_category: 'work' },
{ new_key: 'safety.management', old_key: 'safety-management', old_path: '/pages/admin/safety-management.html', old_category: 'admin' },
{ new_key: 'safety.training_conduct', old_key: 'safety-training-conduct', old_path: '/pages/admin/safety-training-conduct.html', old_category: 'admin' },
];
// 3. 페이지 경로 원복 - attendance → common
const attendanceRevert = [
{ new_key: 'attendance.daily', old_key: 'daily-attendance', old_path: '/pages/common/daily-attendance.html', old_category: 'common' },
{ new_key: 'attendance.monthly', old_key: 'monthly-attendance', old_path: '/pages/common/monthly-attendance.html', old_category: 'common' },
{ new_key: 'attendance.annual_overview', old_key: 'annual-vacation-overview', old_path: '/pages/common/annual-vacation-overview.html', old_category: 'common' },
{ new_key: 'attendance.vacation_request', old_key: 'vacation-request', old_path: '/pages/common/vacation-request.html', old_category: 'common' },
{ new_key: 'attendance.vacation_management', old_key: 'vacation-management', old_path: '/pages/common/vacation-management.html', old_category: 'common' },
{ new_key: 'attendance.vacation_allocation', old_key: 'vacation-allocation', old_path: '/pages/common/vacation-allocation.html', old_category: 'common' },
];
// 4. admin 파일명 원복
const adminRevert = [
{ new_key: 'admin.attendance_report', old_key: 'attendance-report-comparison', old_path: '/pages/admin/attendance-report-comparison.html', old_category: 'admin' }
];
const allReverts = [...safetyRevert, ...attendanceRevert, ...adminRevert];
for (const revert of allReverts) {
await knex('pages')
.where('page_key', revert.new_key)
.update({
page_key: revert.old_key,
page_path: revert.old_path,
category: revert.old_category
});
}
// 5. 새로 추가된 페이지 삭제
await knex('pages').whereIn('page_key', [
'safety.checklist_manage',
'attendance.vacation_approval',
'attendance.vacation_input'
]).del();
console.log('페이지 구조 재구성 롤백 완료');
};

View File

@@ -698,6 +698,296 @@ const TbmModel = {
} catch (err) {
callback(err);
}
},
// ========== 안전 체크리스트 확장 메서드 ==========
/**
* 유형별 안전 체크 항목 조회
* @param {string} checkType - 체크 유형 (basic, weather, task)
* @param {Object} options - 추가 옵션 (weatherCondition, taskId)
*/
getSafetyChecksByType: async (checkType, options = {}, callback) => {
try {
const db = await getDb();
let sql = `
SELECT sc.*,
wc.condition_name as weather_condition_name,
wc.icon as weather_icon,
t.task_name
FROM tbm_safety_checks sc
LEFT JOIN weather_conditions wc ON sc.weather_condition = wc.condition_code
LEFT JOIN tasks t ON sc.task_id = t.task_id
WHERE sc.is_active = 1 AND sc.check_type = ?
`;
const params = [checkType];
if (checkType === 'weather' && options.weatherCondition) {
sql += ' AND sc.weather_condition = ?';
params.push(options.weatherCondition);
}
if (checkType === 'task' && options.taskId) {
sql += ' AND sc.task_id = ?';
params.push(options.taskId);
}
sql += ' ORDER BY sc.check_category, sc.display_order';
const [rows] = await db.query(sql, params);
callback(null, rows);
} catch (err) {
callback(err);
}
},
/**
* 날씨 조건별 안전 체크 항목 조회 (복수 조건)
* @param {string[]} conditions - 날씨 조건 배열 ['rain', 'wind']
*/
getSafetyChecksByWeather: async (conditions, callback) => {
try {
const db = await getDb();
if (!conditions || conditions.length === 0) {
return callback(null, []);
}
const placeholders = conditions.map(() => '?').join(',');
const sql = `
SELECT sc.*,
wc.condition_name as weather_condition_name,
wc.icon as weather_icon
FROM tbm_safety_checks sc
LEFT JOIN weather_conditions wc ON sc.weather_condition = wc.condition_code
WHERE sc.is_active = 1
AND sc.check_type = 'weather'
AND sc.weather_condition IN (${placeholders})
ORDER BY sc.weather_condition, sc.display_order
`;
const [rows] = await db.query(sql, conditions);
callback(null, rows);
} catch (err) {
callback(err);
}
},
/**
* 작업별 안전 체크 항목 조회 (복수 작업)
* @param {number[]} taskIds - 작업 ID 배열
*/
getSafetyChecksByTasks: async (taskIds, callback) => {
try {
const db = await getDb();
if (!taskIds || taskIds.length === 0) {
return callback(null, []);
}
const placeholders = taskIds.map(() => '?').join(',');
const sql = `
SELECT sc.*,
t.task_name,
wt.name as work_type_name
FROM tbm_safety_checks sc
LEFT JOIN tasks t ON sc.task_id = t.task_id
LEFT JOIN work_types wt ON t.work_type_id = wt.id
WHERE sc.is_active = 1
AND sc.check_type = 'task'
AND sc.task_id IN (${placeholders})
ORDER BY sc.task_id, sc.display_order
`;
const [rows] = await db.query(sql, taskIds);
callback(null, rows);
} catch (err) {
callback(err);
}
},
/**
* TBM 세션에 맞는 필터링된 안전 체크 항목 조회
* 기본 + 날씨 + 작업별 체크항목 통합 조회
* @param {number} sessionId - TBM 세션 ID
* @param {string[]} weatherConditions - 날씨 조건 배열 (optional)
*/
getFilteredSafetyChecks: async (sessionId, weatherConditions = [], callback) => {
try {
const db = await getDb();
// 1. 세션 정보에서 작업 ID 목록 조회
const [assignments] = await db.query(`
SELECT DISTINCT task_id
FROM tbm_team_assignments
WHERE session_id = ? AND task_id IS NOT NULL
`, [sessionId]);
const taskIds = assignments.map(a => a.task_id);
// 2. 기본 체크항목 조회
const [basicChecks] = await db.query(`
SELECT sc.*, 'basic' as section_type
FROM tbm_safety_checks sc
WHERE sc.is_active = 1 AND sc.check_type = 'basic'
ORDER BY sc.check_category, sc.display_order
`);
// 3. 날씨별 체크항목 조회
let weatherChecks = [];
if (weatherConditions && weatherConditions.length > 0) {
const wcPlaceholders = weatherConditions.map(() => '?').join(',');
const [rows] = await db.query(`
SELECT sc.*, wc.condition_name as weather_condition_name, wc.icon as weather_icon,
'weather' as section_type
FROM tbm_safety_checks sc
LEFT JOIN weather_conditions wc ON sc.weather_condition = wc.condition_code
WHERE sc.is_active = 1
AND sc.check_type = 'weather'
AND sc.weather_condition IN (${wcPlaceholders})
ORDER BY sc.weather_condition, sc.display_order
`, weatherConditions);
weatherChecks = rows;
}
// 4. 작업별 체크항목 조회
let taskChecks = [];
if (taskIds.length > 0) {
const taskPlaceholders = taskIds.map(() => '?').join(',');
const [rows] = await db.query(`
SELECT sc.*, t.task_name, wt.name as work_type_name,
'task' as section_type
FROM tbm_safety_checks sc
LEFT JOIN tasks t ON sc.task_id = t.task_id
LEFT JOIN work_types wt ON t.work_type_id = wt.id
WHERE sc.is_active = 1
AND sc.check_type = 'task'
AND sc.task_id IN (${taskPlaceholders})
ORDER BY sc.task_id, sc.display_order
`, taskIds);
taskChecks = rows;
}
// 5. 기존 체크 기록 조회
const [existingRecords] = await db.query(`
SELECT check_id, is_checked, notes
FROM tbm_safety_records
WHERE session_id = ?
`, [sessionId]);
const recordMap = {};
existingRecords.forEach(r => {
recordMap[r.check_id] = { is_checked: r.is_checked, notes: r.notes };
});
// 6. 기록과 병합
const mergeWithRecords = (checks) => {
return checks.map(check => ({
...check,
is_checked: recordMap[check.check_id]?.is_checked || false,
notes: recordMap[check.check_id]?.notes || null
}));
};
const result = {
basic: mergeWithRecords(basicChecks),
weather: mergeWithRecords(weatherChecks),
task: mergeWithRecords(taskChecks),
totalCount: basicChecks.length + weatherChecks.length + taskChecks.length,
weatherConditions: weatherConditions
};
callback(null, result);
} catch (err) {
callback(err);
}
},
/**
* 안전 체크 항목 생성 (관리자용)
*/
createSafetyCheck: async (checkData, callback) => {
try {
const db = await getDb();
const sql = `
INSERT INTO tbm_safety_checks
(check_category, check_type, weather_condition, task_id, check_item, description, is_required, display_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`;
const values = [
checkData.check_category,
checkData.check_type || 'basic',
checkData.weather_condition || null,
checkData.task_id || null,
checkData.check_item,
checkData.description || null,
checkData.is_required !== false,
checkData.display_order || 0
];
const [result] = await db.query(sql, values);
callback(null, { insertId: result.insertId });
} catch (err) {
callback(err);
}
},
/**
* 안전 체크 항목 수정 (관리자용)
*/
updateSafetyCheck: async (checkId, checkData, callback) => {
try {
const db = await getDb();
const sql = `
UPDATE tbm_safety_checks
SET check_category = ?,
check_type = ?,
weather_condition = ?,
task_id = ?,
check_item = ?,
description = ?,
is_required = ?,
display_order = ?,
is_active = ?,
updated_at = NOW()
WHERE check_id = ?
`;
const values = [
checkData.check_category,
checkData.check_type || 'basic',
checkData.weather_condition || null,
checkData.task_id || null,
checkData.check_item,
checkData.description || null,
checkData.is_required !== false,
checkData.display_order || 0,
checkData.is_active !== false,
checkId
];
const [result] = await db.query(sql, values);
callback(null, { affectedRows: result.affectedRows });
} catch (err) {
callback(err);
}
},
/**
* 안전 체크 항목 삭제 (비활성화)
*/
deleteSafetyCheck: async (checkId, callback) => {
try {
const db = await getDb();
// 실제 삭제 대신 비활성화
const sql = `UPDATE tbm_safety_checks SET is_active = 0 WHERE check_id = ?`;
const [result] = await db.query(sql, [checkId]);
callback(null, { affectedRows: result.affectedRows });
} catch (err) {
callback(err);
}
}
};

View File

@@ -0,0 +1,881 @@
/**
* 작업 중 문제 신고 모델
* 부적합/안전 신고 관련 DB 쿼리
*/
const { getDb } = require('../dbPool');
// ==================== 신고 카테고리 관리 ====================
/**
* 모든 신고 카테고리 조회
*/
const getAllCategories = async (callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT category_id, category_type, category_name, description, display_order, is_active, created_at
FROM issue_report_categories
ORDER BY category_type, display_order, category_id`
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 타입별 활성 카테고리 조회 (nonconformity/safety)
*/
const getCategoriesByType = async (categoryType, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT category_id, category_type, category_name, description, display_order
FROM issue_report_categories
WHERE category_type = ? AND is_active = TRUE
ORDER BY display_order, category_id`,
[categoryType]
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 카테고리 생성
*/
const createCategory = async (categoryData, callback) => {
try {
const db = await getDb();
const { category_type, category_name, description = null, display_order = 0 } = categoryData;
const [result] = await db.query(
`INSERT INTO issue_report_categories (category_type, category_name, description, display_order)
VALUES (?, ?, ?, ?)`,
[category_type, category_name, description, display_order]
);
callback(null, result.insertId);
} catch (err) {
callback(err);
}
};
/**
* 카테고리 수정
*/
const updateCategory = async (categoryId, categoryData, callback) => {
try {
const db = await getDb();
const { category_name, description, display_order, is_active } = categoryData;
const [result] = await db.query(
`UPDATE issue_report_categories
SET category_name = ?, description = ?, display_order = ?, is_active = ?
WHERE category_id = ?`,
[category_name, description, display_order, is_active, categoryId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 카테고리 삭제
*/
const deleteCategory = async (categoryId, callback) => {
try {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM issue_report_categories WHERE category_id = ?`,
[categoryId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
// ==================== 사전 정의 신고 항목 관리 ====================
/**
* 카테고리별 활성 항목 조회
*/
const getItemsByCategory = async (categoryId, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT item_id, category_id, item_name, description, severity, display_order
FROM issue_report_items
WHERE category_id = ? AND is_active = TRUE
ORDER BY display_order, item_id`,
[categoryId]
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 모든 항목 조회 (관리용)
*/
const getAllItems = async (callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT iri.item_id, iri.category_id, iri.item_name, iri.description,
iri.severity, iri.display_order, iri.is_active, iri.created_at,
irc.category_name, irc.category_type
FROM issue_report_items iri
INNER JOIN issue_report_categories irc ON iri.category_id = irc.category_id
ORDER BY irc.category_type, irc.display_order, iri.display_order, iri.item_id`
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 항목 생성
*/
const createItem = async (itemData, callback) => {
try {
const db = await getDb();
const { category_id, item_name, description = null, severity = 'medium', display_order = 0 } = itemData;
const [result] = await db.query(
`INSERT INTO issue_report_items (category_id, item_name, description, severity, display_order)
VALUES (?, ?, ?, ?, ?)`,
[category_id, item_name, description, severity, display_order]
);
callback(null, result.insertId);
} catch (err) {
callback(err);
}
};
/**
* 항목 수정
*/
const updateItem = async (itemId, itemData, callback) => {
try {
const db = await getDb();
const { item_name, description, severity, display_order, is_active } = itemData;
const [result] = await db.query(
`UPDATE issue_report_items
SET item_name = ?, description = ?, severity = ?, display_order = ?, is_active = ?
WHERE item_id = ?`,
[item_name, description, severity, display_order, is_active, itemId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 항목 삭제
*/
const deleteItem = async (itemId, callback) => {
try {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM issue_report_items WHERE item_id = ?`,
[itemId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
// ==================== 문제 신고 관리 ====================
/**
* 신고 생성
*/
const createReport = async (reportData, callback) => {
try {
const db = await getDb();
const {
reporter_id,
factory_category_id = null,
workplace_id = null,
custom_location = null,
tbm_session_id = null,
visit_request_id = null,
issue_category_id,
issue_item_id = null,
additional_description = null,
photo_path1 = null,
photo_path2 = null,
photo_path3 = null,
photo_path4 = null,
photo_path5 = null
} = reportData;
const [result] = await db.query(
`INSERT INTO work_issue_reports
(reporter_id, factory_category_id, workplace_id, custom_location,
tbm_session_id, visit_request_id, issue_category_id, issue_item_id,
additional_description, photo_path1, photo_path2, photo_path3, photo_path4, photo_path5)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[reporter_id, factory_category_id, workplace_id, custom_location,
tbm_session_id, visit_request_id, issue_category_id, issue_item_id,
additional_description, photo_path1, photo_path2, photo_path3, photo_path4, photo_path5]
);
// 상태 변경 로그 기록
await db.query(
`INSERT INTO work_issue_status_logs (report_id, previous_status, new_status, changed_by)
VALUES (?, NULL, 'reported', ?)`,
[result.insertId, reporter_id]
);
callback(null, result.insertId);
} catch (err) {
callback(err);
}
};
/**
* 신고 목록 조회 (필터 옵션 포함)
*/
const getAllReports = async (filters = {}, callback) => {
try {
const db = await getDb();
let query = `
SELECT
wir.report_id, wir.reporter_id, wir.report_date,
wir.factory_category_id, wir.workplace_id, wir.custom_location,
wir.tbm_session_id, wir.visit_request_id,
wir.issue_category_id, wir.issue_item_id, wir.additional_description,
wir.photo_path1, wir.photo_path2, wir.photo_path3, wir.photo_path4, wir.photo_path5,
wir.status, wir.assigned_department, wir.assigned_user_id, wir.assigned_at,
wir.resolution_notes, wir.resolved_at,
wir.created_at, wir.updated_at,
u.username as reporter_name, u.name as reporter_full_name,
wc.category_name as factory_name,
w.workplace_name,
irc.category_type, irc.category_name as issue_category_name,
iri.item_name as issue_item_name, iri.severity,
assignee.username as assigned_user_name, assignee.name as assigned_full_name
FROM work_issue_reports wir
INNER JOIN users u ON wir.reporter_id = u.user_id
LEFT JOIN workplace_categories wc ON wir.factory_category_id = wc.category_id
LEFT JOIN workplaces w ON wir.workplace_id = w.workplace_id
INNER JOIN issue_report_categories irc ON wir.issue_category_id = irc.category_id
LEFT JOIN issue_report_items iri ON wir.issue_item_id = iri.item_id
LEFT JOIN users assignee ON wir.assigned_user_id = assignee.user_id
WHERE 1=1
`;
const params = [];
// 필터 적용
if (filters.status) {
query += ` AND wir.status = ?`;
params.push(filters.status);
}
if (filters.category_type) {
query += ` AND irc.category_type = ?`;
params.push(filters.category_type);
}
if (filters.issue_category_id) {
query += ` AND wir.issue_category_id = ?`;
params.push(filters.issue_category_id);
}
if (filters.factory_category_id) {
query += ` AND wir.factory_category_id = ?`;
params.push(filters.factory_category_id);
}
if (filters.workplace_id) {
query += ` AND wir.workplace_id = ?`;
params.push(filters.workplace_id);
}
if (filters.reporter_id) {
query += ` AND wir.reporter_id = ?`;
params.push(filters.reporter_id);
}
if (filters.assigned_user_id) {
query += ` AND wir.assigned_user_id = ?`;
params.push(filters.assigned_user_id);
}
if (filters.start_date && filters.end_date) {
query += ` AND DATE(wir.report_date) BETWEEN ? AND ?`;
params.push(filters.start_date, filters.end_date);
}
if (filters.search) {
query += ` AND (wir.additional_description LIKE ? OR iri.item_name LIKE ? OR wir.custom_location LIKE ?)`;
const searchTerm = `%${filters.search}%`;
params.push(searchTerm, searchTerm, searchTerm);
}
query += ` ORDER BY wir.report_date DESC, wir.report_id DESC`;
// 페이지네이션
if (filters.limit) {
query += ` LIMIT ?`;
params.push(parseInt(filters.limit));
if (filters.offset) {
query += ` OFFSET ?`;
params.push(parseInt(filters.offset));
}
}
const [rows] = await db.query(query, params);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 신고 상세 조회
*/
const getReportById = async (reportId, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT
wir.report_id, wir.reporter_id, wir.report_date,
wir.factory_category_id, wir.workplace_id, wir.custom_location,
wir.tbm_session_id, wir.visit_request_id,
wir.issue_category_id, wir.issue_item_id, wir.additional_description,
wir.photo_path1, wir.photo_path2, wir.photo_path3, wir.photo_path4, wir.photo_path5,
wir.status, wir.assigned_department, wir.assigned_user_id, wir.assigned_at, wir.assigned_by,
wir.resolution_notes, wir.resolution_photo_path1, wir.resolution_photo_path2,
wir.resolved_at, wir.resolved_by,
wir.modification_history,
wir.created_at, wir.updated_at,
u.username as reporter_name, u.name as reporter_full_name,
wc.category_name as factory_name,
w.workplace_name,
irc.category_type, irc.category_name as issue_category_name,
iri.item_name as issue_item_name, iri.severity,
assignee.username as assigned_user_name, assignee.name as assigned_full_name,
assigner.username as assigned_by_name,
resolver.username as resolved_by_name
FROM work_issue_reports wir
INNER JOIN users u ON wir.reporter_id = u.user_id
LEFT JOIN workplace_categories wc ON wir.factory_category_id = wc.category_id
LEFT JOIN workplaces w ON wir.workplace_id = w.workplace_id
INNER JOIN issue_report_categories irc ON wir.issue_category_id = irc.category_id
LEFT JOIN issue_report_items iri ON wir.issue_item_id = iri.item_id
LEFT JOIN users assignee ON wir.assigned_user_id = assignee.user_id
LEFT JOIN users assigner ON wir.assigned_by = assigner.user_id
LEFT JOIN users resolver ON wir.resolved_by = resolver.user_id
WHERE wir.report_id = ?`,
[reportId]
);
callback(null, rows[0]);
} catch (err) {
callback(err);
}
};
/**
* 신고 수정
*/
const updateReport = async (reportId, reportData, userId, callback) => {
try {
const db = await getDb();
// 기존 데이터 조회
const [existing] = await db.query(
`SELECT * FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
if (existing.length === 0) {
return callback(new Error('신고를 찾을 수 없습니다.'));
}
const current = existing[0];
// 수정 이력 생성
const modifications = [];
const now = new Date().toISOString();
for (const key of Object.keys(reportData)) {
if (current[key] !== reportData[key] && reportData[key] !== undefined) {
modifications.push({
field: key,
old_value: current[key],
new_value: reportData[key],
modified_at: now,
modified_by: userId
});
}
}
// 기존 이력과 병합
const existingHistory = current.modification_history ? JSON.parse(current.modification_history) : [];
const newHistory = [...existingHistory, ...modifications];
const {
factory_category_id,
workplace_id,
custom_location,
issue_category_id,
issue_item_id,
additional_description,
photo_path1,
photo_path2,
photo_path3,
photo_path4,
photo_path5
} = reportData;
const [result] = await db.query(
`UPDATE work_issue_reports
SET factory_category_id = COALESCE(?, factory_category_id),
workplace_id = COALESCE(?, workplace_id),
custom_location = COALESCE(?, custom_location),
issue_category_id = COALESCE(?, issue_category_id),
issue_item_id = COALESCE(?, issue_item_id),
additional_description = COALESCE(?, additional_description),
photo_path1 = COALESCE(?, photo_path1),
photo_path2 = COALESCE(?, photo_path2),
photo_path3 = COALESCE(?, photo_path3),
photo_path4 = COALESCE(?, photo_path4),
photo_path5 = COALESCE(?, photo_path5),
modification_history = ?,
updated_at = NOW()
WHERE report_id = ?`,
[factory_category_id, workplace_id, custom_location,
issue_category_id, issue_item_id, additional_description,
photo_path1, photo_path2, photo_path3, photo_path4, photo_path5,
JSON.stringify(newHistory), reportId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 신고 삭제
*/
const deleteReport = async (reportId, callback) => {
try {
const db = await getDb();
// 먼저 사진 경로 조회 (삭제용)
const [photos] = await db.query(
`SELECT photo_path1, photo_path2, photo_path3, photo_path4, photo_path5,
resolution_photo_path1, resolution_photo_path2
FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
const [result] = await db.query(
`DELETE FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
// 삭제할 사진 경로 반환
callback(null, { result, photos: photos[0] });
} catch (err) {
callback(err);
}
};
// ==================== 상태 관리 ====================
/**
* 신고 접수 (reported → received)
*/
const receiveReport = async (reportId, userId, callback) => {
try {
const db = await getDb();
// 현재 상태 확인
const [current] = await db.query(
`SELECT status FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
if (current.length === 0) {
return callback(new Error('신고를 찾을 수 없습니다.'));
}
if (current[0].status !== 'reported') {
return callback(new Error('접수 대기 상태가 아닙니다.'));
}
const [result] = await db.query(
`UPDATE work_issue_reports
SET status = 'received', updated_at = NOW()
WHERE report_id = ?`,
[reportId]
);
// 상태 변경 로그
await db.query(
`INSERT INTO work_issue_status_logs (report_id, previous_status, new_status, changed_by)
VALUES (?, 'reported', 'received', ?)`,
[reportId, userId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 담당자 배정
*/
const assignReport = async (reportId, assignData, callback) => {
try {
const db = await getDb();
const { assigned_department, assigned_user_id, assigned_by } = assignData;
// 현재 상태 확인
const [current] = await db.query(
`SELECT status FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
if (current.length === 0) {
return callback(new Error('신고를 찾을 수 없습니다.'));
}
// 접수 상태 이상이어야 배정 가능
const validStatuses = ['received', 'in_progress'];
if (!validStatuses.includes(current[0].status)) {
return callback(new Error('접수된 상태에서만 담당자 배정이 가능합니다.'));
}
const [result] = await db.query(
`UPDATE work_issue_reports
SET assigned_department = ?, assigned_user_id = ?,
assigned_at = NOW(), assigned_by = ?, updated_at = NOW()
WHERE report_id = ?`,
[assigned_department, assigned_user_id, assigned_by, reportId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 처리 시작 (received → in_progress)
*/
const startProcessing = async (reportId, userId, callback) => {
try {
const db = await getDb();
// 현재 상태 확인
const [current] = await db.query(
`SELECT status FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
if (current.length === 0) {
return callback(new Error('신고를 찾을 수 없습니다.'));
}
if (current[0].status !== 'received') {
return callback(new Error('접수된 상태에서만 처리를 시작할 수 있습니다.'));
}
const [result] = await db.query(
`UPDATE work_issue_reports
SET status = 'in_progress', updated_at = NOW()
WHERE report_id = ?`,
[reportId]
);
// 상태 변경 로그
await db.query(
`INSERT INTO work_issue_status_logs (report_id, previous_status, new_status, changed_by)
VALUES (?, 'received', 'in_progress', ?)`,
[reportId, userId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 처리 완료 (in_progress → completed)
*/
const completeReport = async (reportId, completionData, callback) => {
try {
const db = await getDb();
const { resolution_notes, resolution_photo_path1, resolution_photo_path2, resolved_by } = completionData;
// 현재 상태 확인
const [current] = await db.query(
`SELECT status FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
if (current.length === 0) {
return callback(new Error('신고를 찾을 수 없습니다.'));
}
if (current[0].status !== 'in_progress') {
return callback(new Error('처리 중 상태에서만 완료할 수 있습니다.'));
}
const [result] = await db.query(
`UPDATE work_issue_reports
SET status = 'completed', resolution_notes = ?,
resolution_photo_path1 = ?, resolution_photo_path2 = ?,
resolved_at = NOW(), resolved_by = ?, updated_at = NOW()
WHERE report_id = ?`,
[resolution_notes, resolution_photo_path1, resolution_photo_path2, resolved_by, reportId]
);
// 상태 변경 로그
await db.query(
`INSERT INTO work_issue_status_logs (report_id, previous_status, new_status, changed_by, change_reason)
VALUES (?, 'in_progress', 'completed', ?, ?)`,
[reportId, resolved_by, resolution_notes]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 신고 종료 (completed → closed)
*/
const closeReport = async (reportId, userId, callback) => {
try {
const db = await getDb();
// 현재 상태 확인
const [current] = await db.query(
`SELECT status FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
if (current.length === 0) {
return callback(new Error('신고를 찾을 수 없습니다.'));
}
if (current[0].status !== 'completed') {
return callback(new Error('완료된 상태에서만 종료할 수 있습니다.'));
}
const [result] = await db.query(
`UPDATE work_issue_reports
SET status = 'closed', updated_at = NOW()
WHERE report_id = ?`,
[reportId]
);
// 상태 변경 로그
await db.query(
`INSERT INTO work_issue_status_logs (report_id, previous_status, new_status, changed_by)
VALUES (?, 'completed', 'closed', ?)`,
[reportId, userId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 상태 변경 이력 조회
*/
const getStatusLogs = async (reportId, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT wisl.log_id, wisl.report_id, wisl.previous_status, wisl.new_status,
wisl.changed_by, wisl.change_reason, wisl.changed_at,
u.username as changed_by_name, u.name as changed_by_full_name
FROM work_issue_status_logs wisl
INNER JOIN users u ON wisl.changed_by = u.user_id
WHERE wisl.report_id = ?
ORDER BY wisl.changed_at ASC`,
[reportId]
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
// ==================== 통계 ====================
/**
* 신고 통계 요약
*/
const getStatsSummary = async (filters = {}, callback) => {
try {
const db = await getDb();
let whereClause = '1=1';
const params = [];
if (filters.start_date && filters.end_date) {
whereClause += ` AND DATE(report_date) BETWEEN ? AND ?`;
params.push(filters.start_date, filters.end_date);
}
if (filters.factory_category_id) {
whereClause += ` AND factory_category_id = ?`;
params.push(filters.factory_category_id);
}
const [rows] = await db.query(
`SELECT
COUNT(*) as total,
SUM(CASE WHEN status = 'reported' THEN 1 ELSE 0 END) as reported,
SUM(CASE WHEN status = 'received' THEN 1 ELSE 0 END) as received,
SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END) as closed
FROM work_issue_reports
WHERE ${whereClause}`,
params
);
callback(null, rows[0]);
} catch (err) {
callback(err);
}
};
/**
* 카테고리별 통계
*/
const getStatsByCategory = async (filters = {}, callback) => {
try {
const db = await getDb();
let whereClause = '1=1';
const params = [];
if (filters.start_date && filters.end_date) {
whereClause += ` AND DATE(wir.report_date) BETWEEN ? AND ?`;
params.push(filters.start_date, filters.end_date);
}
const [rows] = await db.query(
`SELECT
irc.category_type, irc.category_name,
COUNT(*) as count
FROM work_issue_reports wir
INNER JOIN issue_report_categories irc ON wir.issue_category_id = irc.category_id
WHERE ${whereClause}
GROUP BY irc.category_id
ORDER BY irc.category_type, count DESC`,
params
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 작업장별 통계
*/
const getStatsByWorkplace = async (filters = {}, callback) => {
try {
const db = await getDb();
let whereClause = 'wir.workplace_id IS NOT NULL';
const params = [];
if (filters.start_date && filters.end_date) {
whereClause += ` AND DATE(wir.report_date) BETWEEN ? AND ?`;
params.push(filters.start_date, filters.end_date);
}
if (filters.factory_category_id) {
whereClause += ` AND wir.factory_category_id = ?`;
params.push(filters.factory_category_id);
}
const [rows] = await db.query(
`SELECT
wir.factory_category_id, wc.category_name as factory_name,
wir.workplace_id, w.workplace_name,
COUNT(*) as count
FROM work_issue_reports wir
INNER JOIN workplace_categories wc ON wir.factory_category_id = wc.category_id
INNER JOIN workplaces w ON wir.workplace_id = w.workplace_id
WHERE ${whereClause}
GROUP BY wir.factory_category_id, wir.workplace_id
ORDER BY count DESC`,
params
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
module.exports = {
// 카테고리
getAllCategories,
getCategoriesByType,
createCategory,
updateCategory,
deleteCategory,
// 항목
getItemsByCategory,
getAllItems,
createItem,
updateItem,
deleteItem,
// 신고
createReport,
getAllReports,
getReportById,
updateReport,
deleteReport,
// 상태 관리
receiveReport,
assignReport,
startProcessing,
completeReport,
closeReport,
getStatusLogs,
// 통계
getStatsSummary,
getStatsByCategory,
getStatsByWorkplace
};

View File

@@ -10,6 +10,7 @@
"dependencies": {
"@simplewebauthn/server": "^13.1.1",
"async-retry": "^1.3.3",
"axios": "^1.6.7",
"bcrypt": "^6.0.0",
"bcryptjs": "^2.4.3",
"compression": "^1.8.1",
@@ -1956,7 +1957,6 @@
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/aws-ssl-profiles": {
@@ -1968,6 +1968,17 @@
"node": ">= 6.0.0"
}
},
"node_modules/axios": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz",
"integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/babel-jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@@ -2700,7 +2711,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
@@ -3043,7 +3053,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
@@ -3320,7 +3329,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -3691,7 +3699,6 @@
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"dev": true,
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
@@ -4004,7 +4011,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"

View File

@@ -21,6 +21,7 @@
"dependencies": {
"@simplewebauthn/server": "^13.1.1",
"async-retry": "^1.3.3",
"axios": "^1.6.7",
"bcrypt": "^6.0.0",
"bcryptjs": "^2.4.3",
"compression": "^1.8.1",

View File

@@ -46,12 +46,38 @@ router.delete('/sessions/:sessionId/team/:workerId', requireAuth, TbmController.
// 모든 안전 체크 항목 조회
router.get('/safety-checks', requireAuth, TbmController.getAllSafetyChecks);
// 안전 체크 항목 생성 (관리자용)
router.post('/safety-checks', requireAuth, TbmController.createSafetyCheck);
// 안전 체크 항목 수정 (관리자용)
router.put('/safety-checks/:checkId', requireAuth, TbmController.updateSafetyCheck);
// 안전 체크 항목 삭제 (관리자용)
router.delete('/safety-checks/:checkId', requireAuth, TbmController.deleteSafetyCheck);
// TBM 세션의 안전 체크 기록 조회
router.get('/sessions/:sessionId/safety', requireAuth, TbmController.getSafetyRecords);
// 안전 체크 일괄 저장
router.post('/sessions/:sessionId/safety', requireAuth, TbmController.saveSafetyRecords);
// 필터링된 안전 체크리스트 조회 (기본 + 날씨 + 작업별)
router.get('/sessions/:sessionId/safety-checks/filtered', requireAuth, TbmController.getFilteredSafetyChecks);
// ==================== 날씨 관련 ====================
// 현재 날씨 조회
router.get('/weather/current', requireAuth, TbmController.getCurrentWeather);
// 날씨 조건 목록 조회
router.get('/weather/conditions', requireAuth, TbmController.getWeatherConditions);
// 세션 날씨 정보 조회
router.get('/sessions/:sessionId/weather', requireAuth, TbmController.getSessionWeather);
// 세션 날씨 정보 저장
router.post('/sessions/:sessionId/weather', requireAuth, TbmController.saveSessionWeather);
// ==================== 작업 인계 관련 ====================
// 작업 인계 생성

View File

@@ -104,6 +104,24 @@ router.get('/me/monthly-stats', async (req, res) => {
}
});
// ========== 자신의 페이지 권한 조회 (Admin 불필요) ==========
// 📄 사용자 페이지 접근 권한 조회 (자신 또는 Admin)
router.get('/:id/page-access', (req, res, next) => {
const requestedId = parseInt(req.params.id);
const currentUserId = req.user?.user_id;
const userRole = req.user?.role?.toLowerCase();
// 자신의 권한 조회이거나 Admin인 경우 허용
if (requestedId === currentUserId || userRole === 'admin' || userRole === 'system admin') {
return userController.getUserPageAccess(req, res, next);
}
return res.status(403).json({
success: false,
message: '자신의 페이지 권한만 조회할 수 있습니다'
});
});
// ========== 관리자 전용 API ==========
/**
* 모든 라우트에 관리자 권한 적용
@@ -125,13 +143,13 @@ router.put('/:id', userController.updateUser);
// 🔄 사용자 상태 변경
router.put('/:id/status', userController.updateUserStatus);
// 🔑 사용자 비밀번호 초기화 (000000)
router.post('/:id/reset-password', userController.resetUserPassword);
// 🗑️ 사용자 삭제
router.delete('/:id', userController.deleteUser);
// 📄 사용자 페이지 접근 권한 조회
router.get('/:id/page-access', userController.getUserPageAccess);
// 🔐 사용자 페이지 접근 권한 업데이트
// 🔐 사용자 페이지 접근 권한 업데이트 (Admin만)
router.put('/:id/page-access', userController.updateUserPageAccess);
module.exports = router;

View File

@@ -0,0 +1,92 @@
/**
* 작업 중 문제 신고 라우터
*/
const express = require('express');
const router = express.Router();
const workIssueController = require('../controllers/workIssueController');
const { requireMinLevel } = require('../middlewares/auth');
// ==================== 카테고리 관리 ====================
// 모든 카테고리 조회
router.get('/categories', workIssueController.getAllCategories);
// 타입별 카테고리 조회 (nonconformity/safety)
router.get('/categories/type/:type', workIssueController.getCategoriesByType);
// 카테고리 생성 (admin 이상)
router.post('/categories', requireMinLevel('admin'), workIssueController.createCategory);
// 카테고리 수정 (admin 이상)
router.put('/categories/:id', requireMinLevel('admin'), workIssueController.updateCategory);
// 카테고리 삭제 (admin 이상)
router.delete('/categories/:id', requireMinLevel('admin'), workIssueController.deleteCategory);
// ==================== 사전 정의 항목 관리 ====================
// 모든 항목 조회
router.get('/items', workIssueController.getAllItems);
// 카테고리별 항목 조회
router.get('/items/category/:categoryId', workIssueController.getItemsByCategory);
// 항목 생성 (admin 이상)
router.post('/items', requireMinLevel('admin'), workIssueController.createItem);
// 항목 수정 (admin 이상)
router.put('/items/:id', requireMinLevel('admin'), workIssueController.updateItem);
// 항목 삭제 (admin 이상)
router.delete('/items/:id', requireMinLevel('admin'), workIssueController.deleteItem);
// ==================== 통계 ====================
// 통계 요약 (support_team 이상)
router.get('/stats/summary', requireMinLevel('support_team'), workIssueController.getStatsSummary);
// 카테고리별 통계 (support_team 이상)
router.get('/stats/by-category', requireMinLevel('support_team'), workIssueController.getStatsByCategory);
// 작업장별 통계 (support_team 이상)
router.get('/stats/by-workplace', requireMinLevel('support_team'), workIssueController.getStatsByWorkplace);
// ==================== 문제 신고 관리 ====================
// 신고 목록 조회
router.get('/', workIssueController.getAllReports);
// 신고 생성
router.post('/', workIssueController.createReport);
// 신고 상세 조회
router.get('/:id', workIssueController.getReportById);
// 신고 수정
router.put('/:id', workIssueController.updateReport);
// 신고 삭제
router.delete('/:id', workIssueController.deleteReport);
// ==================== 상태 관리 ====================
// 신고 접수 (support_team 이상)
router.put('/:id/receive', requireMinLevel('support_team'), workIssueController.receiveReport);
// 담당자 배정 (support_team 이상)
router.put('/:id/assign', requireMinLevel('support_team'), workIssueController.assignReport);
// 처리 시작
router.put('/:id/start', workIssueController.startProcessing);
// 처리 완료
router.put('/:id/complete', workIssueController.completeReport);
// 신고 종료 (admin 이상)
router.put('/:id/close', requireMinLevel('admin'), workIssueController.closeReport);
// 상태 변경 이력 조회
router.get('/:id/logs', workIssueController.getStatusLogs);
module.exports = router;

View File

@@ -23,4 +23,17 @@ router.put('/:id', workReportController.updateWorkReport);
// DELETE
router.delete('/:id', workReportController.removeWorkReport);
// ========== 부적합 원인 관리 ==========
// 작업 보고서의 부적합 원인 목록 조회
router.get('/:reportId/defects', workReportController.getReportDefects);
// 부적합 원인 저장 (전체 교체)
router.put('/:reportId/defects', workReportController.saveReportDefects);
// 부적합 원인 추가 (단일)
router.post('/:reportId/defects', workReportController.addReportDefect);
// 부적합 원인 삭제
router.delete('/defects/:defectId', workReportController.removeReportDefect);
module.exports = router;

View File

@@ -0,0 +1,209 @@
/**
* 이미지 업로드 서비스
* 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_DIR = path.join(__dirname, '../public/uploads/issues');
const MAX_SIZE = { width: 1920, height: 1920 };
const QUALITY = 85;
/**
* 업로드 디렉토리 확인 및 생성
*/
async function ensureUploadDir() {
try {
await fs.access(UPLOAD_DIR);
} catch {
await fs.mkdir(UPLOAD_DIR, { recursive: true });
}
}
/**
* 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')
* @returns {Promise<string|null>} 저장된 파일의 웹 경로 또는 null
*/
async function saveBase64Image(base64String, prefix = 'issue') {
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;
}
// 디렉토리 확인
await ensureUploadDir();
// 파일명 생성
const timestamp = getTimestamp();
const uniqueId = generateId();
const extension = 'jpg'; // 모든 이미지를 JPEG로 저장
const filename = `${prefix}_${timestamp}_${uniqueId}.${extension}`;
const filepath = path.join(UPLOAD_DIR, 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/issues/${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
};

View File

@@ -0,0 +1,401 @@
/**
* 날씨 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 now = new Date();
const baseDate = formatDate(now);
const baseTime = getBaseTime(now);
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
};
}
/**
* 날짜 포맷 (YYYYMMDD)
*/
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}`;
}
/**
* 초단기실황 API용 기준시간 계산
* 매시간 정각에 생성되고 10분 후에 제공됨
*/
function getBaseTime(date) {
let hours = date.getHours();
let minutes = date.getMinutes();
// 10분 이전이면 이전 시간 데이터 사용
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
};

View File

@@ -10,6 +10,7 @@
const workReportModel = require('../models/workReportModel');
const { ValidationError, NotFoundError, DatabaseError } = require('../utils/errors');
const logger = require('../utils/logger');
const { getDb } = require('../dbPool');
/**
* 작업 보고서 생성 (단일 또는 다중)
@@ -269,6 +270,170 @@ const getSummaryService = async (year, month) => {
}
};
// ========== 부적합 원인 관리 서비스 ==========
/**
* 작업 보고서의 부적합 원인 목록 조회
*/
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.defect_hours,
d.note,
d.created_at,
et.name as error_type_name,
et.severity
FROM work_report_defects d
JOIN error_types et ON d.error_type_id = et.id
WHERE d.report_id = ?
ORDER BY d.created_at
`, [reportId]);
return rows;
} catch (error) {
logger.error('부적합 원인 조회 실패', { reportId, error: error.message });
throw new DatabaseError('부적합 원인 조회 중 오류가 발생했습니다');
}
};
/**
* 부적합 원인 저장 (전체 교체)
*/
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) {
await db.execute(`
INSERT INTO work_report_defects (report_id, error_type_id, defect_hours, note)
VALUES (?, ?, ?, ?)
`, [reportId, defect.error_type_id, 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;
await db.execute(`
UPDATE daily_work_reports
SET error_hours = ?,
error_type_id = ?,
work_status_id = ?
WHERE id = ?
`, [
totalErrorHours,
defects && defects.length > 0 ? defects[0].error_type_id : null,
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('부적합 원인 저장 중 오류가 발생했습니다');
}
};
/**
* 부적합 원인 추가 (단일)
*/
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, defect_hours, note)
VALUES (?, ?, ?, ?)
`, [reportId, defectData.error_type_id, 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('부적합 원인 삭제 중 오류가 발생했습니다');
}
};
/**
* 총 부적합 시간 업데이트 헬퍼
*/
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를 대표값으로 사용
const [firstDefect] = await db.execute(`
SELECT error_type_id FROM work_report_defects WHERE report_id = ? 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,
@@ -276,5 +441,10 @@ module.exports = {
getWorkReportByIdService,
updateWorkReportService,
removeWorkReportService,
getSummaryService
getSummaryService,
// 부적합 원인 관리
getReportDefectsService,
saveReportDefectsService,
addReportDefectService,
removeReportDefectService
};