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:
@@ -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 문서
|
||||
|
||||
@@ -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: '안전 체크 항목이 삭제되었습니다.'
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
// ==================== 작업 인계 관련 ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
643
api.hyungi.net/controllers/workIssueController.js
Normal file
643
api.hyungi.net/controllers/workIssueController.js
Normal 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 });
|
||||
});
|
||||
};
|
||||
@@ -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: '부적합 원인이 삭제되었습니다'
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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('작업 중 문제 신고 시스템 테이블 삭제 완료');
|
||||
};
|
||||
@@ -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('✅ 문제 신고 페이지 삭제 완료');
|
||||
};
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
};
|
||||
319
api.hyungi.net/db/migrations/20260202200000_reorganize_pages.js
Normal file
319
api.hyungi.net/db/migrations/20260202200000_reorganize_pages.js
Normal 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('페이지 구조 재구성 롤백 완료');
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
881
api.hyungi.net/models/workIssueModel.js
Normal file
881
api.hyungi.net/models/workIssueModel.js
Normal 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
|
||||
};
|
||||
18
api.hyungi.net/package-lock.json
generated
18
api.hyungi.net/package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
// ==================== 작업 인계 관련 ====================
|
||||
|
||||
// 작업 인계 생성
|
||||
|
||||
@@ -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;
|
||||
|
||||
92
api.hyungi.net/routes/workIssueRoutes.js
Normal file
92
api.hyungi.net/routes/workIssueRoutes.js
Normal 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;
|
||||
@@ -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;
|
||||
209
api.hyungi.net/services/imageUploadService.js
Normal file
209
api.hyungi.net/services/imageUploadService.js
Normal 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
|
||||
};
|
||||
401
api.hyungi.net/services/weatherService.js
Normal file
401
api.hyungi.net/services/weatherService.js
Normal 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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user