diff --git a/CODING_GUIDE.md b/CODING_GUIDE.md index acfbb34..78adb10 100644 --- a/CODING_GUIDE.md +++ b/CODING_GUIDE.md @@ -92,36 +92,74 @@ docker-compose up -d # 수동 실행 - **일관된 헤더**: 모든 페이지에서 `
` 사용 - **CSS 로딩 순서**: `design-system.css` → 페이지별 CSS -### 페이지 구조 (2026-01-20 개편) +### 페이지 구조 (2026-02-02 현행) ``` web-ui/pages/ -├── dashboard.html # 메인 대시보드 -├── work/ # 작업 관련 페이지 +├── dashboard.html # 메인 대시보드 (작업장 현황 지도 포함) +├── work/ # 작업 관련 페이지 (현장 입력/생산) +│ ├── tbm.html # TBM(Tool Box Meeting) 관리 │ ├── report-create.html # 작업보고서 작성 │ ├── report-view.html # 작업보고서 조회 │ └── analysis.html # 작업 분석 -├── admin/ # 관리자 기능 -│ ├── index.html # 관리 메뉴 허브 -│ ├── projects.html # 프로젝트 관리 +├── safety/ # 안전 관리 페이지 (신규) +│ ├── issue-report.html # 이슈 신고 +│ ├── issue-list.html # 이슈 목록 +│ ├── issue-detail.html # 이슈 상세 +│ ├── visit-request.html # 출입 신청 +│ ├── management.html # 안전 관리 (출입 승인) +│ ├── training-conduct.html # 안전교육 진행 +│ └── checklist-manage.html # 안전 체크리스트 관리 +├── attendance/ # 근태 관리 페이지 (common → attendance 변경) +│ ├── daily.html # 일일 출퇴근 입력 +│ ├── monthly.html # 월별 출퇴근 현황 +│ ├── vacation-request.html # 휴가 신청 +│ ├── vacation-management.html # 휴가 관리 (통합) +│ ├── vacation-approval.html # 휴가 승인 관리 +│ ├── vacation-input.html # 휴가 직접 입력 +│ ├── vacation-allocation.html # 휴가 발생 입력 +│ └── annual-overview.html # 연간 연차 현황 +├── admin/ # 시스템 관리 페이지 +│ ├── accounts.html # 계정 관리 +│ ├── page-access.html # 페이지 접근 권한 관리 │ ├── workers.html # 작업자 관리 +│ ├── projects.html # 프로젝트 관리 +│ ├── tasks.html # 작업 관리 +│ ├── workplaces.html # 작업장 관리 (지도 구역 설정) +│ ├── equipments.html # 설비 관리 │ ├── codes.html # 코드 관리 -│ └── accounts.html # 계정 관리 +│ └── attendance-report.html # 출퇴근-작업보고서 대조 ├── profile/ # 사용자 프로필 │ ├── info.html # 내 정보 │ └── password.html # 비밀번호 변경 └── .archived-*/ # 미사용 페이지 보관 ``` +**폴더 분류 기준** (2026-02-02 변경): +- `work/`: 현장 입력/생산 활동 (TBM, 작업보고서) +- `safety/`: 안전 관리/분석 (이슈, 출입, 안전교육) +- `attendance/`: 근태/휴가 관리 +- `admin/`: 시스템 관리 (관리자 전용) +- `profile/`: 개인 설정 페이지 + **네이밍 규칙**: - 메인 페이지: 단일 명사 (`dashboard.html`) - 관리 페이지: 복수형 명사 (`projects.html`, `workers.html`) -- 기능 페이지: 동사-명사 (`report-create.html`, `report-view.html`) -- 폴더명: 단수형, 소문자 (`work/`, `admin/`, `profile/`) +- 기능 페이지: 동사-명사 또는 명사 (`report-create.html`, `daily.html`) +- 폴더명: 단수형, 소문자 (`work/`, `safety/`, `attendance/`, `admin/`, `profile/`) **네비게이션 구조**: -- 1차: `dashboard.html` (메인 진입점) -- 2차: `admin/index.html` (관리 허브) -- 모든 페이지: navbar를 통해 profile, 작업 페이지로 이동 가능 +- 1차: `dashboard.html` (메인 진입점, 작업장 현황 지도) +- 2차: 사이드 메뉴 또는 빠른 작업 카드를 통한 각 기능 페이지 이동 +- 모든 페이지: navbar를 통해 profile, 로그아웃 가능 + +### 대기 중인 DB 마이그레이션 +페이지 구조 변경에 따른 DB 마이그레이션이 필요합니다: +```bash +cd /Users/hyungiahn/Documents/code/TK-FB-Project/api.hyungi.net +npx knex migrate:latest +``` +- 마이그레이션 파일: `db/migrations/20260202200000_reorganize_pages.js` +- 내용: pages 테이블 경로 업데이트, role_default_pages 테이블 생성 ### 표준 컴포넌트 (2026-01-20 업데이트) diff --git a/api.hyungi.net/config/routes.js b/api.hyungi.net/config/routes.js index 6bd43f8..31a4702 100644 --- a/api.hyungi.net/config/routes.js +++ b/api.hyungi.net/config/routes.js @@ -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 문서 diff --git a/api.hyungi.net/controllers/tbmController.js b/api.hyungi.net/controllers/tbmController.js index 422b6c3..dd6fd39 100644 --- a/api.hyungi.net/controllers/tbmController.js +++ b/api.hyungi.net/controllers/tbmController.js @@ -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: '안전 체크 항목이 삭제되었습니다.' + }); + }); + }, + // ==================== 작업 인계 관련 ==================== /** diff --git a/api.hyungi.net/controllers/userController.js b/api.hyungi.net/controllers/userController.js index 25eca27..3252b2aa 100644 --- a/api.hyungi.net/controllers/userController.js +++ b/api.hyungi.net/controllers/userController.js @@ -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 }; diff --git a/api.hyungi.net/controllers/workIssueController.js b/api.hyungi.net/controllers/workIssueController.js new file mode 100644 index 0000000..bd831be --- /dev/null +++ b/api.hyungi.net/controllers/workIssueController.js @@ -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 }); + }); +}; diff --git a/api.hyungi.net/controllers/workReportController.js b/api.hyungi.net/controllers/workReportController.js index 59e85cc..2b3d555 100644 --- a/api.hyungi.net/controllers/workReportController.js +++ b/api.hyungi.net/controllers/workReportController.js @@ -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: '부적합 원인이 삭제되었습니다' + }); +}); diff --git a/api.hyungi.net/db/migrations/20260130000001_create_work_issue_system.js b/api.hyungi.net/db/migrations/20260130000001_create_work_issue_system.js new file mode 100644 index 0000000..73af1b1 --- /dev/null +++ b/api.hyungi.net/db/migrations/20260130000001_create_work_issue_system.js @@ -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('작업 중 문제 신고 시스템 테이블 삭제 완료'); +}; diff --git a/api.hyungi.net/db/migrations/20260130000002_register_issue_pages.js b/api.hyungi.net/db/migrations/20260130000002_register_issue_pages.js new file mode 100644 index 0000000..602e1be --- /dev/null +++ b/api.hyungi.net/db/migrations/20260130000002_register_issue_pages.js @@ -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('✅ 문제 신고 페이지 삭제 완료'); +}; diff --git a/api.hyungi.net/db/migrations/20260202000001_create_work_report_defects.js b/api.hyungi.net/db/migrations/20260202000001_create_work_report_defects.js new file mode 100644 index 0000000..15d4d84 --- /dev/null +++ b/api.hyungi.net/db/migrations/20260202000001_create_work_report_defects.js @@ -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'); +}; diff --git a/api.hyungi.net/db/migrations/20260202100000_expand_safety_checklist.js b/api.hyungi.net/db/migrations/20260202100000_expand_safety_checklist.js new file mode 100644 index 0000000..62d303d --- /dev/null +++ b/api.hyungi.net/db/migrations/20260202100000_expand_safety_checklist.js @@ -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'); + }); + }); +}; diff --git a/api.hyungi.net/db/migrations/20260202200000_reorganize_pages.js b/api.hyungi.net/db/migrations/20260202200000_reorganize_pages.js new file mode 100644 index 0000000..df21ca4 --- /dev/null +++ b/api.hyungi.net/db/migrations/20260202200000_reorganize_pages.js @@ -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('페이지 구조 재구성 롤백 완료'); +}; diff --git a/api.hyungi.net/models/tbmModel.js b/api.hyungi.net/models/tbmModel.js index df5ec0c..a191243 100644 --- a/api.hyungi.net/models/tbmModel.js +++ b/api.hyungi.net/models/tbmModel.js @@ -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); + } } }; diff --git a/api.hyungi.net/models/workIssueModel.js b/api.hyungi.net/models/workIssueModel.js new file mode 100644 index 0000000..90e943e --- /dev/null +++ b/api.hyungi.net/models/workIssueModel.js @@ -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 +}; diff --git a/api.hyungi.net/package-lock.json b/api.hyungi.net/package-lock.json index 153deba..40600a7 100644 --- a/api.hyungi.net/package-lock.json +++ b/api.hyungi.net/package-lock.json @@ -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" diff --git a/api.hyungi.net/package.json b/api.hyungi.net/package.json index 3be2186..ff0bf1d 100644 --- a/api.hyungi.net/package.json +++ b/api.hyungi.net/package.json @@ -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", diff --git a/api.hyungi.net/routes/tbmRoutes.js b/api.hyungi.net/routes/tbmRoutes.js index 8e433c0..5e22a7a 100644 --- a/api.hyungi.net/routes/tbmRoutes.js +++ b/api.hyungi.net/routes/tbmRoutes.js @@ -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); + // ==================== 작업 인계 관련 ==================== // 작업 인계 생성 diff --git a/api.hyungi.net/routes/userRoutes.js b/api.hyungi.net/routes/userRoutes.js index daf9c8d..da1bcb5 100644 --- a/api.hyungi.net/routes/userRoutes.js +++ b/api.hyungi.net/routes/userRoutes.js @@ -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; diff --git a/api.hyungi.net/routes/workIssueRoutes.js b/api.hyungi.net/routes/workIssueRoutes.js new file mode 100644 index 0000000..31f5fda --- /dev/null +++ b/api.hyungi.net/routes/workIssueRoutes.js @@ -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; diff --git a/api.hyungi.net/routes/workReportRoutes.js b/api.hyungi.net/routes/workReportRoutes.js index 5d608c5..a320ae9 100644 --- a/api.hyungi.net/routes/workReportRoutes.js +++ b/api.hyungi.net/routes/workReportRoutes.js @@ -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; \ No newline at end of file diff --git a/api.hyungi.net/services/imageUploadService.js b/api.hyungi.net/services/imageUploadService.js new file mode 100644 index 0000000..00136ee --- /dev/null +++ b/api.hyungi.net/services/imageUploadService.js @@ -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