diff --git a/CODING_GUIDE.md b/CODING_GUIDE.md index 78adb10..4017be6 100644 --- a/CODING_GUIDE.md +++ b/CODING_GUIDE.md @@ -310,6 +310,96 @@ Synology NAS(MySQL 8.0)의 `Strict Mode`로 인해 `db.execute()` 사용 시 `In --- +## 🕐 시간대(Timezone) 처리 가이드 + +### 핵심 원칙 +이 프로젝트는 **한국 시간(KST, UTC+9)** 기준으로 운영됩니다. + +### 문제 상황 +서버가 UTC 시간대로 설정된 경우, `NOW()`, `CURRENT_TIMESTAMP`, `new Date()`를 사용하면 **한국 시간과 9시간 차이**가 발생합니다. + +``` +예시: 한국 시간 2026-02-03 08:00 AM에 신고 등록 +- UTC 시간: 2026-02-02 11:00 PM +- DB 저장: 2026-02-02 (잘못된 날짜!) +``` + +### 해결 방법 + +#### 백엔드 (Node.js) +**공용 유틸리티 사용** (`utils/dateUtils.js`): + +```javascript +const { getKoreaDatetime, getKoreaDateString } = require('../utils/dateUtils'); + +// 비즈니스 날짜 저장 시 +const reportDate = getKoreaDatetime(); // '2026-02-03 08:00:00' +await db.query('INSERT INTO reports (report_date, ...) VALUES (?, ...)', [reportDate, ...]); + +// 날짜만 필요한 경우 +const today = getKoreaDateString(); // '2026-02-03' +``` + +**제공되는 함수들**: +| 함수 | 반환값 | 용도 | +|------|--------|------| +| `getKoreaDatetime()` | `'2026-02-03 08:00:00'` | DB DATETIME 저장 | +| `getKoreaDateString()` | `'2026-02-03'` | DB DATE 저장 | +| `getKoreaTimeString()` | `'08:00:00'` | DB TIME 저장 | +| `getKoreaYear()` | `2026` | 연도 | +| `getKoreaMonth()` | `2` | 월 (1-12) | +| `toKoreaDatetime(date)` | `'2026-02-03 08:00:00'` | Date 객체 변환 | + +#### 프론트엔드 (JavaScript) +```javascript +// 로컬 시간대 기준 날짜 문자열 +function getLocalDateString() { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +// ❌ 잘못된 방법 (UTC 변환됨) +const today = new Date().toISOString().split('T')[0]; + +// ✅ 올바른 방법 (로컬 시간) +const today = getLocalDateString(); +``` + +### 컬럼별 적용 기준 + +| 컬럼 유형 | 처리 방법 | 비고 | +|-----------|-----------|------| +| `created_at`, `updated_at` | `NOW()` 또는 `CURRENT_TIMESTAMP` 사용 가능 | 감사용 메타데이터, UTC로 저장해도 무방 | +| `report_date`, `session_date` | **반드시 `getKoreaDatetime()` 사용** | 비즈니스 날짜, 사용자에게 표시됨 | +| `visit_date`, `attendance_date` | **반드시 한국 시간 기준** | 필터링/조회에 사용됨 | +| API 응답 `timestamp` | `new Date().toISOString()` 사용 가능 | 디버깅용 | + +### 마이그레이션 주의사항 +새 테이블 생성 시 비즈니스 날짜 컬럼은 **default 값을 사용하지 말고** 애플리케이션에서 명시적으로 설정: + +```javascript +// ❌ 잘못된 방법 +table.datetime('report_date').defaultTo(knex.fn.now()); + +// ✅ 올바른 방법 +table.datetime('report_date').notNullable(); // default 없음 +// 애플리케이션에서 getKoreaDatetime()으로 값 설정 +``` + +### 기존 데이터 보정 (필요시) +UTC로 잘못 저장된 데이터를 한국 시간으로 보정: +```sql +-- 주의: 백업 후 실행 +UPDATE work_issue_reports +SET report_date = DATE_ADD(report_date, INTERVAL 9 HOUR) +WHERE report_date < '2026-02-03'; +``` + +--- + ## 🧪 테스트 가이드 (Jest) ### 중요도 diff --git a/api.hyungi.net/controllers/workIssueController.js b/api.hyungi.net/controllers/workIssueController.js index bd831be..0811be6 100644 --- a/api.hyungi.net/controllers/workIssueController.js +++ b/api.hyungi.net/controllers/workIssueController.js @@ -206,6 +206,7 @@ exports.createReport = async (req, res) => { visit_request_id, issue_category_id, issue_item_id, + custom_item_name, // 직접 입력한 항목명 additional_description, photos = [] } = req.body; @@ -221,6 +222,36 @@ exports.createReport = async (req, res) => { return res.status(400).json({ success: false, error: '위치 정보는 필수입니다.' }); } + // 항목 검증 (기존 항목 또는 직접 입력) + if (!issue_item_id && !custom_item_name) { + return res.status(400).json({ success: false, error: '신고 항목은 필수입니다.' }); + } + + // 직접 입력한 항목이 있으면 DB에 저장 + let finalItemId = issue_item_id; + if (custom_item_name && !issue_item_id) { + try { + finalItemId = await new Promise((resolve, reject) => { + workIssueModel.createItem( + { + category_id: issue_category_id, + item_name: custom_item_name, + description: '사용자 직접 입력', + severity: 'medium', + display_order: 999 // 마지막에 표시 + }, + (err, itemId) => { + if (err) reject(err); + else resolve(itemId); + } + ); + }); + } catch (itemErr) { + console.error('커스텀 항목 생성 실패:', itemErr); + return res.status(500).json({ success: false, error: '항목 저장 실패' }); + } + } + // 사진 저장 (최대 5장) const photoPaths = { photo_path1: null, @@ -247,7 +278,7 @@ exports.createReport = async (req, res) => { tbm_session_id: tbm_session_id || null, visit_request_id: visit_request_id || null, issue_category_id, - issue_item_id: issue_item_id || null, + issue_item_id: finalItemId || null, additional_description: additional_description || null, ...photoPaths }; diff --git a/api.hyungi.net/db/migrations/20260203090000_add_category_to_defects.js b/api.hyungi.net/db/migrations/20260203090000_add_category_to_defects.js new file mode 100644 index 0000000..5ff74af --- /dev/null +++ b/api.hyungi.net/db/migrations/20260203090000_add_category_to_defects.js @@ -0,0 +1,43 @@ +/** + * 작업보고서 부적합에 카테고리/아이템 컬럼 추가 + * + * 변경사항: + * 1. work_report_defects 테이블에 category_id, item_id 컬럼 추가 + * 2. issue_report_categories, issue_report_items 테이블 참조 + */ + +exports.up = function(knex) { + return knex.schema + .alterTable('work_report_defects', function(table) { + // 카테고리 ID 추가 + table.integer('category_id').unsigned().nullable() + .comment('issue_report_categories의 category_id (직접 입력 시)') + .after('issue_report_id'); + + // 아이템 ID 추가 + table.integer('item_id').unsigned().nullable() + .comment('issue_report_items의 item_id (직접 입력 시)') + .after('category_id'); + + // 외래키 추가 + table.foreign('category_id') + .references('category_id') + .inTable('issue_report_categories') + .onDelete('SET NULL'); + + table.foreign('item_id') + .references('item_id') + .inTable('issue_report_items') + .onDelete('SET NULL'); + }); +}; + +exports.down = function(knex) { + return knex.schema + .alterTable('work_report_defects', function(table) { + table.dropForeign('category_id'); + table.dropForeign('item_id'); + table.dropColumn('category_id'); + table.dropColumn('item_id'); + }); +}; diff --git a/api.hyungi.net/db/migrations/20260203100000_link_defects_to_issues.js b/api.hyungi.net/db/migrations/20260203100000_link_defects_to_issues.js new file mode 100644 index 0000000..e89e476 --- /dev/null +++ b/api.hyungi.net/db/migrations/20260203100000_link_defects_to_issues.js @@ -0,0 +1,85 @@ +/** + * 작업보고서 부적합을 신고 시스템과 연동 + * + * 변경사항: + * 1. work_report_defects 테이블에 issue_report_id 컬럼 추가 + * 2. error_type_id를 NULL 허용으로 변경 (신고 연동 시 불필요) + * 3. work_issue_reports.report_id (unsigned int)와 타입 일치 필요 + */ + +exports.up = function(knex) { + return knex.schema + .alterTable('work_report_defects', function(table) { + // 1. issue_report_id 컬럼 추가 (unsigned int로 work_issue_reports.report_id와 타입 일치) + table.integer('issue_report_id').unsigned().nullable() + .comment('work_issue_reports의 report_id (신고된 이슈 연결)') + .after('error_type_id'); + + // 2. 외래키 추가 (work_issue_reports.report_id 참조) + table.foreign('issue_report_id') + .references('report_id') + .inTable('work_issue_reports') + .onDelete('SET NULL'); + + // 3. 인덱스 추가 + table.index('issue_report_id'); + }) + // 4. error_type_id를 NULL 허용으로 변경 + .then(function() { + return knex.raw(` + ALTER TABLE work_report_defects + MODIFY COLUMN error_type_id INT NULL + COMMENT 'error_types의 id (부적합 원인) - 레거시, issue_report_id 사용 권장' + `); + }) + // 5. 유니크 제약 수정 (issue_report_id도 고려) + .then(function() { + // 기존 유니크 제약 삭제 + return knex.raw(` + ALTER TABLE work_report_defects + DROP INDEX work_report_defects_report_id_error_type_id_unique + `).catch(() => { + // 인덱스가 없을 수 있음 - 무시 + }); + }) + .then(function() { + // 새 유니크 제약 추가 (report_id + issue_report_id 조합) + return knex.raw(` + ALTER TABLE work_report_defects + ADD UNIQUE INDEX work_report_defects_report_issue_unique (report_id, issue_report_id) + `).catch(() => { + // 이미 존재할 수 있음 - 무시 + }); + }); +}; + +exports.down = function(knex) { + return knex.schema + .alterTable('work_report_defects', function(table) { + // 외래키 및 인덱스 삭제 + table.dropForeign('issue_report_id'); + table.dropIndex('issue_report_id'); + table.dropColumn('issue_report_id'); + }) + // error_type_id를 다시 NOT NULL로 변경 + .then(function() { + return knex.raw(` + ALTER TABLE work_report_defects + MODIFY COLUMN error_type_id INT NOT NULL + COMMENT 'error_types의 id (부적합 원인)' + `); + }) + // 기존 유니크 제약 복원 + .then(function() { + return knex.raw(` + ALTER TABLE work_report_defects + DROP INDEX IF EXISTS work_report_defects_report_issue_unique + `).catch(() => {}); + }) + .then(function() { + return knex.raw(` + ALTER TABLE work_report_defects + ADD UNIQUE INDEX work_report_defects_report_id_error_type_id_unique (report_id, error_type_id) + `).catch(() => {}); + }); +}; diff --git a/api.hyungi.net/models/workIssueModel.js b/api.hyungi.net/models/workIssueModel.js index 90e943e..34ec5ef 100644 --- a/api.hyungi.net/models/workIssueModel.js +++ b/api.hyungi.net/models/workIssueModel.js @@ -200,6 +200,9 @@ const deleteItem = async (itemId, callback) => { // ==================== 문제 신고 관리 ==================== +// 한국 시간 유틸리티 import +const { getKoreaDatetime } = require('../utils/dateUtils'); + /** * 신고 생성 */ @@ -223,13 +226,16 @@ const createReport = async (reportData, callback) => { photo_path5 = null } = reportData; + // 한국 시간 기준으로 신고 일시 설정 + const reportDate = getKoreaDatetime(); + const [result] = await db.query( `INSERT INTO work_issue_reports - (reporter_id, factory_category_id, workplace_id, custom_location, + (reporter_id, report_date, 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, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [reporter_id, reportDate, 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] ); diff --git a/api.hyungi.net/routes/dailyWorkReportRoutes.js b/api.hyungi.net/routes/dailyWorkReportRoutes.js index 17efb14..f805c57 100644 --- a/api.hyungi.net/routes/dailyWorkReportRoutes.js +++ b/api.hyungi.net/routes/dailyWorkReportRoutes.js @@ -2,6 +2,7 @@ const express = require('express'); const router = express.Router(); const dailyWorkReportController = require('../controllers/dailyWorkReportController'); +const workReportController = require('../controllers/workReportController'); // 📋 마스터 데이터 조회 라우트들 (모든 인증된 사용자) router.get('/work-types', dailyWorkReportController.getWorkTypes); @@ -88,4 +89,9 @@ router.delete('/date/:date/worker/:worker_id', dailyWorkReportController.removeD // 🗑️ 특정 작업보고서 삭제 (항상 가장 마지막에 정의) router.delete('/:id', dailyWorkReportController.removeDailyWorkReport); +// 📋 부적합 관리 (workReportController 사용) +router.get('/:reportId/defects', workReportController.getReportDefects); +router.put('/:reportId/defects', workReportController.saveReportDefects); +router.post('/:reportId/defects', workReportController.addReportDefect); + module.exports = router; \ No newline at end of file diff --git a/api.hyungi.net/services/mProjectService.js b/api.hyungi.net/services/mProjectService.js new file mode 100644 index 0000000..6336621 --- /dev/null +++ b/api.hyungi.net/services/mProjectService.js @@ -0,0 +1,383 @@ +/** + * M-Project API 연동 서비스 + * + * TK-FB-Project의 부적합 신고를 M-Project로 전송하는 서비스 + * + * @author TK-FB-Project + * @since 2026-02-03 + */ + +const logger = require('../utils/logger'); + +// M-Project API 설정 +const M_PROJECT_CONFIG = { + baseUrl: process.env.M_PROJECT_API_URL || 'http://localhost:16080', + username: process.env.M_PROJECT_USERNAME || 'api_service', + password: process.env.M_PROJECT_PASSWORD || '', + defaultProjectId: parseInt(process.env.M_PROJECT_DEFAULT_PROJECT_ID) || 1, +}; + +// 캐시된 인증 토큰 +let cachedToken = null; +let tokenExpiry = null; + +/** + * TK-FB 카테고리를 M-Project 카테고리로 매핑 + * @param {string} tkCategory - TK-FB-Project 카테고리 + * @returns {string} M-Project 카테고리 + */ +function mapCategoryToMProject(tkCategory) { + const categoryMap = { + // TK-FB 카테고리 → M-Project 카테고리 + '자재 누락': 'material_missing', + '자재누락': 'material_missing', + '자재 관련': 'material_missing', + '설계 오류': 'design_error', + '설계미스': 'design_error', + '설계 관련': 'design_error', + '입고 불량': 'incoming_defect', + '입고자재 불량': 'incoming_defect', + '입고 관련': 'incoming_defect', + '검사 미스': 'inspection_miss', + '검사미스': 'inspection_miss', + '검사 관련': 'inspection_miss', + '기타': 'etc', + }; + + // 정확히 매칭되는 경우 + if (categoryMap[tkCategory]) { + return categoryMap[tkCategory]; + } + + // 부분 매칭 시도 + const lowerCategory = tkCategory?.toLowerCase() || ''; + if (lowerCategory.includes('자재') || lowerCategory.includes('material')) { + return 'material_missing'; + } + if (lowerCategory.includes('설계') || lowerCategory.includes('design')) { + return 'design_error'; + } + if (lowerCategory.includes('입고') || lowerCategory.includes('incoming')) { + return 'incoming_defect'; + } + if (lowerCategory.includes('검사') || lowerCategory.includes('inspection')) { + return 'inspection_miss'; + } + + // 기본값 + return 'etc'; +} + +/** + * M-Project API 인증 토큰 획득 + * @returns {Promise} JWT 토큰 + */ +async function getAuthToken() { + // 캐시된 토큰이 유효하면 재사용 + if (cachedToken && tokenExpiry && new Date() < tokenExpiry) { + return cachedToken; + } + + try { + const formData = new URLSearchParams(); + formData.append('username', M_PROJECT_CONFIG.username); + formData.append('password', M_PROJECT_CONFIG.password); + + const response = await fetch(`${M_PROJECT_CONFIG.baseUrl}/api/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: formData.toString(), + }); + + if (!response.ok) { + const errorText = await response.text(); + logger.error('M-Project 인증 실패', { + status: response.status, + error: errorText + }); + return null; + } + + const data = await response.json(); + cachedToken = data.access_token; + + // 토큰 만료 시간 설정 (기본 6일, M-Project는 7일 유효) + tokenExpiry = new Date(Date.now() + 6 * 24 * 60 * 60 * 1000); + + logger.info('M-Project 인증 성공'); + return cachedToken; + } catch (error) { + logger.error('M-Project 인증 요청 오류', { error: error.message }); + return null; + } +} + +/** + * 이미지 URL을 Base64로 변환 + * @param {string} imageUrl - 이미지 URL 또는 경로 + * @returns {Promise} Base64 인코딩된 이미지 + */ +async function convertImageToBase64(imageUrl) { + if (!imageUrl) return null; + + try { + // 이미 Base64인 경우 그대로 반환 + if (imageUrl.startsWith('data:image')) { + return imageUrl; + } + + // URL인 경우 fetch + if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) { + const response = await fetch(imageUrl); + if (!response.ok) return null; + + const buffer = await response.arrayBuffer(); + const base64 = Buffer.from(buffer).toString('base64'); + const contentType = response.headers.get('content-type') || 'image/jpeg'; + return `data:${contentType};base64,${base64}`; + } + + // 로컬 파일 경로인 경우 + const fs = require('fs').promises; + const path = require('path'); + const filePath = path.join(process.cwd(), 'uploads', imageUrl); + + try { + const fileBuffer = await fs.readFile(filePath); + const base64 = fileBuffer.toString('base64'); + const ext = path.extname(imageUrl).toLowerCase(); + const mimeTypes = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + }; + const contentType = mimeTypes[ext] || 'image/jpeg'; + return `data:${contentType};base64,${base64}`; + } catch { + return null; + } + } catch (error) { + logger.warn('이미지 Base64 변환 실패', { imageUrl, error: error.message }); + return null; + } +} + +/** + * TK-FB-Project 부적합 신고를 M-Project로 전송 + * + * @param {Object} issueData - 부적합 신고 데이터 + * @param {string} issueData.category - 카테고리 이름 + * @param {string} issueData.description - 신고 내용 + * @param {string} issueData.reporter_name - 신고자 이름 + * @param {string} issueData.project_name - 프로젝트 이름 (옵션) + * @param {number} issueData.tk_issue_id - TK-FB-Project 이슈 ID + * @param {string[]} issueData.photos - 사진 URL 배열 (최대 5개) + * @returns {Promise<{success: boolean, mProjectId?: number, error?: string}>} + */ +async function sendToMProject(issueData) { + const { + category, + description, + reporter_name, + project_name, + tk_issue_id, + photos = [], + } = issueData; + + logger.info('M-Project 연동 시작', { tk_issue_id, category }); + + // 인증 토큰 획득 + const token = await getAuthToken(); + if (!token) { + return { success: false, error: 'M-Project 인증 실패' }; + } + + try { + // 카테고리 매핑 + const mProjectCategory = mapCategoryToMProject(category); + + // 설명에 TK-FB 정보 추가 + const enhancedDescription = [ + description, + '', + '---', + `[TK-FB-Project 연동]`, + `- 원본 이슈 ID: ${tk_issue_id}`, + `- 신고자: ${reporter_name || '미상'}`, + project_name ? `- 프로젝트: ${project_name}` : null, + ].filter(Boolean).join('\n'); + + // 사진 변환 (최대 5개) + const photoPromises = photos.slice(0, 5).map(convertImageToBase64); + const base64Photos = await Promise.all(photoPromises); + + // 요청 본문 구성 + const requestBody = { + category: mProjectCategory, + description: enhancedDescription, + project_id: M_PROJECT_CONFIG.defaultProjectId, + }; + + // 사진 추가 + base64Photos.forEach((photo, index) => { + if (photo) { + const fieldName = index === 0 ? 'photo' : `photo${index + 1}`; + requestBody[fieldName] = photo; + } + }); + + // M-Project API 호출 + const response = await fetch(`${M_PROJECT_CONFIG.baseUrl}/api/issues`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + logger.error('M-Project 이슈 생성 실패', { + status: response.status, + error: errorText, + tk_issue_id + }); + return { + success: false, + error: `M-Project API 오류: ${response.status}` + }; + } + + const createdIssue = await response.json(); + + logger.info('M-Project 이슈 생성 성공', { + tk_issue_id, + m_project_id: createdIssue.id + }); + + return { + success: true, + mProjectId: createdIssue.id + }; + } catch (error) { + logger.error('M-Project 연동 오류', { + tk_issue_id, + error: error.message + }); + return { + success: false, + error: error.message + }; + } +} + +/** + * M-Project에서 이슈 상태 조회 + * + * @param {number} mProjectId - M-Project 이슈 ID + * @returns {Promise<{success: boolean, status?: string, data?: Object, error?: string}>} + */ +async function getIssueStatus(mProjectId) { + const token = await getAuthToken(); + if (!token) { + return { success: false, error: 'M-Project 인증 실패' }; + } + + try { + const response = await fetch( + `${M_PROJECT_CONFIG.baseUrl}/api/issues/${mProjectId}`, + { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + }, + } + ); + + if (!response.ok) { + return { + success: false, + error: `M-Project API 오류: ${response.status}` + }; + } + + const data = await response.json(); + + return { + success: true, + status: data.review_status || data.status, + data + }; + } catch (error) { + return { + success: false, + error: error.message + }; + } +} + +/** + * M-Project 상태를 TK-FB 상태로 매핑 + * @param {string} mProjectStatus - M-Project 상태 + * @returns {string} TK-FB-Project 상태 + */ +function mapStatusFromMProject(mProjectStatus) { + const statusMap = { + 'pending_review': 'received', + 'in_progress': 'in_progress', + 'completed': 'completed', + 'disposed': 'closed', + }; + return statusMap[mProjectStatus] || 'received'; +} + +/** + * M-Project 연결 테스트 + * @returns {Promise<{success: boolean, message: string}>} + */ +async function testConnection() { + try { + const token = await getAuthToken(); + if (!token) { + return { success: false, message: 'M-Project 인증 실패' }; + } + + // /api/auth/me로 연결 테스트 + const response = await fetch(`${M_PROJECT_CONFIG.baseUrl}/api/auth/me`, { + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (response.ok) { + const user = await response.json(); + return { + success: true, + message: `M-Project 연결 성공 (사용자: ${user.username})` + }; + } else { + return { + success: false, + message: `M-Project 연결 실패: ${response.status}` + }; + } + } catch (error) { + return { + success: false, + message: `M-Project 연결 오류: ${error.message}` + }; + } +} + +module.exports = { + sendToMProject, + getIssueStatus, + mapStatusFromMProject, + testConnection, + mapCategoryToMProject, +}; diff --git a/api.hyungi.net/services/weatherService.js b/api.hyungi.net/services/weatherService.js index d56a6f2..7b7d2a7 100644 --- a/api.hyungi.net/services/weatherService.js +++ b/api.hyungi.net/services/weatherService.js @@ -58,10 +58,8 @@ async function getCurrentWeather(nx = WEATHER_API.defaultLocation.nx, ny = WEATH } try { - // 현재 시간 기준으로 base_date, base_time 계산 - const now = new Date(); - const baseDate = formatDate(now); - const baseTime = getBaseTime(now); + // 한국 시간 기준으로 base_date, base_time 계산 + const { baseDate, baseTime } = getBaseDateTime(); logger.info('날씨 API 호출', { baseDate, baseTime, nx, ny }); @@ -322,7 +320,45 @@ function getDefaultWeatherData() { } /** - * 날짜 포맷 (YYYYMMDD) + * 기상청 API용 기준 날짜/시간 계산 + * 한국 시간(KST, UTC+9) 기준으로 계산 + * 매시간 정각에 생성되고 10분 후에 제공됨 + * @returns {{ baseDate: string, baseTime: string }} + */ +function getBaseDateTime() { + // 한국 시간으로 변환 (UTC + 9시간) + const now = new Date(); + const kstOffset = 9 * 60 * 60 * 1000; // 9시간을 밀리초로 + const kstDate = new Date(now.getTime() + kstOffset); + + let year = kstDate.getUTCFullYear(); + let month = kstDate.getUTCMonth() + 1; + let day = kstDate.getUTCDate(); + let hours = kstDate.getUTCHours(); + let minutes = kstDate.getUTCMinutes(); + + // 10분 이전이면 이전 시간 데이터 사용 + if (minutes < 10) { + hours = hours - 1; + if (hours < 0) { + hours = 23; + // 전날로 변경 + const prevDay = new Date(kstDate.getTime() - 24 * 60 * 60 * 1000); + year = prevDay.getUTCFullYear(); + month = prevDay.getUTCMonth() + 1; + day = prevDay.getUTCDate(); + } + } + + const baseDate = `${year}${String(month).padStart(2, '0')}${String(day).padStart(2, '0')}`; + const baseTime = String(hours).padStart(2, '0') + '00'; + + return { baseDate, baseTime }; +} + +/** + * 날짜 포맷 (YYYYMMDD) - 호환성 유지용 + * @deprecated getBaseDateTime() 사용 권장 */ function formatDate(date) { const year = date.getFullYear(); @@ -332,14 +368,12 @@ function formatDate(date) { } /** - * 초단기실황 API용 기준시간 계산 - * 매시간 정각에 생성되고 10분 후에 제공됨 + * @deprecated getBaseDateTime() 사용 권장 */ function getBaseTime(date) { let hours = date.getHours(); let minutes = date.getMinutes(); - // 10분 이전이면 이전 시간 데이터 사용 if (minutes < 10) { hours = hours - 1; if (hours < 0) hours = 23; diff --git a/api.hyungi.net/services/workReportService.js b/api.hyungi.net/services/workReportService.js index 29f4d2c..f1b0daf 100644 --- a/api.hyungi.net/services/workReportService.js +++ b/api.hyungi.net/services/workReportService.js @@ -274,6 +274,7 @@ const getSummaryService = async (year, month) => { /** * 작업 보고서의 부적합 원인 목록 조회 + * - error_type_id 또는 issue_report_id 중 하나로 연결 */ const getReportDefectsService = async (reportId) => { const db = await getDb(); @@ -283,13 +284,22 @@ const getReportDefectsService = async (reportId) => { d.defect_id, d.report_id, d.error_type_id, + d.issue_report_id, d.defect_hours, d.note, d.created_at, et.name as error_type_name, - et.severity + et.severity as error_severity, + ir.content as issue_content, + ir.status as issue_status, + irc.category_name as issue_category_name, + irc.severity as issue_severity, + iri.item_name as issue_item_name FROM work_report_defects d - JOIN error_types et ON d.error_type_id = et.id + LEFT JOIN error_types et ON d.error_type_id = et.id + LEFT JOIN work_issue_reports ir ON d.issue_report_id = ir.report_id + LEFT JOIN issue_report_categories irc ON ir.category_id = irc.category_id + LEFT JOIN issue_report_items iri ON ir.item_id = iri.item_id WHERE d.report_id = ? ORDER BY d.created_at `, [reportId]); @@ -303,6 +313,9 @@ const getReportDefectsService = async (reportId) => { /** * 부적합 원인 저장 (전체 교체) + * - error_type_id 또는 issue_report_id 중 하나 사용 가능 + * - issue_report_id: 신고된 이슈와 연결 + * - error_type_id: 기존 오류 유형 (레거시) */ const saveReportDefectsService = async (reportId, defects) => { const db = await getDb(); @@ -315,10 +328,19 @@ const saveReportDefectsService = async (reportId, defects) => { // 새 부적합 원인 추가 if (defects && defects.length > 0) { for (const defect of defects) { + // issue_report_id > category_id/item_id > error_type_id 순으로 우선 await db.execute(` - INSERT INTO work_report_defects (report_id, error_type_id, defect_hours, note) - VALUES (?, ?, ?, ?) - `, [reportId, defect.error_type_id, defect.defect_hours || 0, defect.note || null]); + INSERT INTO work_report_defects (report_id, error_type_id, issue_report_id, category_id, item_id, defect_hours, note) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, [ + reportId, + (defect.issue_report_id || defect.category_id) ? null : (defect.error_type_id || null), + defect.issue_report_id || null, + defect.category_id || null, + defect.item_id || null, + defect.defect_hours || 0, + defect.note || null + ]); } } @@ -327,6 +349,11 @@ const saveReportDefectsService = async (reportId, defects) => { ? defects.reduce((sum, d) => sum + (parseFloat(d.defect_hours) || 0), 0) : 0; + // 첫 번째 defect의 error_type_id를 대표값으로 (레거시 호환) + const firstErrorTypeId = defects && defects.length > 0 + ? (defects.find(d => d.error_type_id)?.error_type_id || null) + : null; + await db.execute(` UPDATE daily_work_reports SET error_hours = ?, @@ -335,7 +362,7 @@ const saveReportDefectsService = async (reportId, defects) => { WHERE id = ? `, [ totalErrorHours, - defects && defects.length > 0 ? defects[0].error_type_id : null, + firstErrorTypeId, totalErrorHours > 0 ? 2 : 1, reportId ]); @@ -353,14 +380,21 @@ const saveReportDefectsService = async (reportId, defects) => { /** * 부적합 원인 추가 (단일) + * - issue_report_id 또는 error_type_id 중 하나 사용 */ const addReportDefectService = async (reportId, defectData) => { const db = await getDb(); try { const [result] = await db.execute(` - INSERT INTO work_report_defects (report_id, error_type_id, defect_hours, note) - VALUES (?, ?, ?, ?) - `, [reportId, defectData.error_type_id, defectData.defect_hours || 0, defectData.note || null]); + INSERT INTO work_report_defects (report_id, error_type_id, issue_report_id, defect_hours, note) + VALUES (?, ?, ?, ?, ?) + `, [ + reportId, + defectData.issue_report_id ? null : (defectData.error_type_id || null), + defectData.issue_report_id || null, + defectData.defect_hours || 0, + defectData.note || null + ]); // 총 부적합 시간 업데이트 await updateTotalErrorHours(reportId); @@ -404,6 +438,7 @@ const removeReportDefectService = async (defectId) => { /** * 총 부적합 시간 업데이트 헬퍼 + * - issue_report_id가 있는 경우도 고려 */ const updateTotalErrorHours = async (reportId) => { const db = await getDb(); @@ -415,9 +450,12 @@ const updateTotalErrorHours = async (reportId) => { const totalErrorHours = result[0].total || 0; - // 첫 번째 부적합 원인의 error_type_id를 대표값으로 사용 + // 첫 번째 부적합 원인의 error_type_id를 대표값으로 사용 (레거시 호환) + // issue_report_id만 있는 경우 error_type_id는 null const [firstDefect] = await db.execute(` - SELECT error_type_id FROM work_report_defects WHERE report_id = ? ORDER BY created_at LIMIT 1 + SELECT error_type_id FROM work_report_defects + WHERE report_id = ? AND error_type_id IS NOT NULL + ORDER BY created_at LIMIT 1 `, [reportId]); await db.execute(` diff --git a/api.hyungi.net/utils/dateUtils.js b/api.hyungi.net/utils/dateUtils.js new file mode 100644 index 0000000..322462a --- /dev/null +++ b/api.hyungi.net/utils/dateUtils.js @@ -0,0 +1,131 @@ +/** + * 날짜/시간 유틸리티 함수 + * + * 중요: 이 프로젝트는 한국(Asia/Seoul, UTC+9) 시간대 기준으로 운영됩니다. + * DB에 저장되는 비즈니스 날짜(report_date, session_date 등)는 반드시 한국 시간 기준이어야 합니다. + * + * @author TK-FB-Project + * @since 2026-02-03 + */ + +const KOREA_TIMEZONE_OFFSET = 9; // UTC+9 + +/** + * 한국 시간(KST) 기준 현재 Date 객체 반환 + * @returns {Date} 한국 시간 기준 Date 객체 + */ +function getKoreaDate() { + const now = new Date(); + return new Date(now.getTime() + (KOREA_TIMEZONE_OFFSET * 60 * 60 * 1000)); +} + +/** + * 한국 시간(KST) 기준 현재 날짜 문자열 반환 + * @returns {string} 'YYYY-MM-DD' 형식 + */ +function getKoreaDateString() { + const koreaDate = getKoreaDate(); + const year = koreaDate.getUTCFullYear(); + const month = String(koreaDate.getUTCMonth() + 1).padStart(2, '0'); + const day = String(koreaDate.getUTCDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +/** + * 한국 시간(KST) 기준 현재 datetime 문자열 반환 + * @returns {string} 'YYYY-MM-DD HH:mm:ss' 형식 (MySQL DATETIME 호환) + */ +function getKoreaDatetime() { + const koreaDate = getKoreaDate(); + const year = koreaDate.getUTCFullYear(); + const month = String(koreaDate.getUTCMonth() + 1).padStart(2, '0'); + const day = String(koreaDate.getUTCDate()).padStart(2, '0'); + const hours = String(koreaDate.getUTCHours()).padStart(2, '0'); + const minutes = String(koreaDate.getUTCMinutes()).padStart(2, '0'); + const seconds = String(koreaDate.getUTCSeconds()).padStart(2, '0'); + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; +} + +/** + * 한국 시간(KST) 기준 현재 시간 문자열 반환 + * @returns {string} 'HH:mm:ss' 형식 + */ +function getKoreaTimeString() { + const koreaDate = getKoreaDate(); + const hours = String(koreaDate.getUTCHours()).padStart(2, '0'); + const minutes = String(koreaDate.getUTCMinutes()).padStart(2, '0'); + const seconds = String(koreaDate.getUTCSeconds()).padStart(2, '0'); + return `${hours}:${minutes}:${seconds}`; +} + +/** + * UTC Date를 한국 시간 datetime 문자열로 변환 + * @param {Date} date - UTC 기준 Date 객체 + * @returns {string} 'YYYY-MM-DD HH:mm:ss' 형식 + */ +function toKoreaDatetime(date) { + if (!date) return null; + const koreaDate = new Date(date.getTime() + (KOREA_TIMEZONE_OFFSET * 60 * 60 * 1000)); + const year = koreaDate.getUTCFullYear(); + const month = String(koreaDate.getUTCMonth() + 1).padStart(2, '0'); + const day = String(koreaDate.getUTCDate()).padStart(2, '0'); + const hours = String(koreaDate.getUTCHours()).padStart(2, '0'); + const minutes = String(koreaDate.getUTCMinutes()).padStart(2, '0'); + const seconds = String(koreaDate.getUTCSeconds()).padStart(2, '0'); + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; +} + +/** + * UTC Date를 한국 날짜 문자열로 변환 + * @param {Date} date - UTC 기준 Date 객체 + * @returns {string} 'YYYY-MM-DD' 형식 + */ +function toKoreaDateString(date) { + if (!date) return null; + const koreaDate = new Date(date.getTime() + (KOREA_TIMEZONE_OFFSET * 60 * 60 * 1000)); + const year = koreaDate.getUTCFullYear(); + const month = String(koreaDate.getUTCMonth() + 1).padStart(2, '0'); + const day = String(koreaDate.getUTCDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +/** + * 한국 시간 datetime 문자열을 Date 객체로 변환 + * @param {string} koreaDatetimeStr - 'YYYY-MM-DD HH:mm:ss' 형식 + * @returns {Date} UTC 기준 Date 객체 + */ +function fromKoreaDatetime(koreaDatetimeStr) { + if (!koreaDatetimeStr) return null; + // 한국 시간 문자열을 UTC로 변환 + const date = new Date(koreaDatetimeStr + '+09:00'); + return date; +} + +/** + * 연도 반환 (한국 시간 기준) + * @returns {number} 현재 연도 + */ +function getKoreaYear() { + return getKoreaDate().getUTCFullYear(); +} + +/** + * 월 반환 (한국 시간 기준, 1-12) + * @returns {number} 현재 월 (1-12) + */ +function getKoreaMonth() { + return getKoreaDate().getUTCMonth() + 1; +} + +module.exports = { + KOREA_TIMEZONE_OFFSET, + getKoreaDate, + getKoreaDateString, + getKoreaDatetime, + getKoreaTimeString, + toKoreaDatetime, + toKoreaDateString, + fromKoreaDatetime, + getKoreaYear, + getKoreaMonth, +}; diff --git a/web-ui/components/navbar.html b/web-ui/components/navbar.html index 10b1874..f05a45c 100644 --- a/web-ui/components/navbar.html +++ b/web-ui/components/navbar.html @@ -32,6 +32,11 @@ 대시보드 + + + 신고 + + @@ -43,11 +46,8 @@