feat: 페이지 구조 재구성 및 사이드바 네비게이션 구현
- 페이지 폴더 재구성: safety/, attendance/ 폴더 신규 생성 - work/ → safety/: 이슈 신고, 출입 신청 관련 페이지 이동 - common/ → attendance/: 근태/휴가 관련 페이지 이동 - admin/ 정리: safety-* 파일들을 safety/로 이동 - 사이드바 네비게이션 메뉴 구현 - 카테고리별 메뉴: 작업관리, 안전관리, 근태관리, 시스템관리 - 접기/펼치기 기능 및 상태 저장 - 관리자 전용 메뉴 자동 표시/숨김 - 날씨 API 연동 (기상청 단기예보) - TBM 및 navbar에 현재 날씨 표시 - weatherService.js 추가 - 안전 체크리스트 확장 - 기본/날씨별/작업별 체크 유형 추가 - checklist-manage.html 페이지 추가 - 이슈 신고 시스템 구현 - workIssueController, workIssueModel, workIssueRoutes 추가 - DB 마이그레이션 파일 추가 (실행 대기) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
209
api.hyungi.net/services/imageUploadService.js
Normal file
209
api.hyungi.net/services/imageUploadService.js
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* 이미지 업로드 서비스
|
||||
* Base64 인코딩된 이미지를 파일로 저장
|
||||
*
|
||||
* 사용 전 sharp 패키지 설치 필요:
|
||||
* npm install sharp
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const crypto = require('crypto');
|
||||
|
||||
// sharp는 선택적으로 사용 (설치되어 있지 않으면 리사이징 없이 저장)
|
||||
let sharp;
|
||||
try {
|
||||
sharp = require('sharp');
|
||||
} catch (e) {
|
||||
console.warn('sharp 패키지가 설치되어 있지 않습니다. 이미지 리사이징이 비활성화됩니다.');
|
||||
console.warn('이미지 최적화를 위해 npm install sharp 를 실행하세요.');
|
||||
}
|
||||
|
||||
// 업로드 디렉토리 설정
|
||||
const UPLOAD_DIR = path.join(__dirname, '../public/uploads/issues');
|
||||
const MAX_SIZE = { width: 1920, height: 1920 };
|
||||
const QUALITY = 85;
|
||||
|
||||
/**
|
||||
* 업로드 디렉토리 확인 및 생성
|
||||
*/
|
||||
async function ensureUploadDir() {
|
||||
try {
|
||||
await fs.access(UPLOAD_DIR);
|
||||
} catch {
|
||||
await fs.mkdir(UPLOAD_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UUID 생성 (간단한 버전)
|
||||
*/
|
||||
function generateId() {
|
||||
return crypto.randomBytes(4).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 타임스탬프 문자열 생성
|
||||
*/
|
||||
function getTimestamp() {
|
||||
const now = new Date();
|
||||
return now.toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 문자열에서 이미지 형식 추출
|
||||
* @param {string} base64String - Base64 인코딩된 이미지
|
||||
* @returns {string} 이미지 확장자 (jpg, png, etc)
|
||||
*/
|
||||
function getImageExtension(base64String) {
|
||||
const match = base64String.match(/^data:image\/(\w+);base64,/);
|
||||
if (match) {
|
||||
const format = match[1].toLowerCase();
|
||||
// jpeg를 jpg로 변환
|
||||
return format === 'jpeg' ? 'jpg' : format;
|
||||
}
|
||||
return 'jpg'; // 기본값
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 이미지를 파일로 저장
|
||||
* @param {string} base64String - Base64 인코딩된 이미지 (data:image/...;base64,... 형식)
|
||||
* @param {string} prefix - 파일명 접두사 (예: 'issue', 'resolution')
|
||||
* @returns {Promise<string|null>} 저장된 파일의 웹 경로 또는 null
|
||||
*/
|
||||
async function saveBase64Image(base64String, prefix = 'issue') {
|
||||
try {
|
||||
if (!base64String || typeof base64String !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Base64 헤더가 없는 경우 처리
|
||||
let base64Data = base64String;
|
||||
if (base64String.includes('base64,')) {
|
||||
base64Data = base64String.split('base64,')[1];
|
||||
}
|
||||
|
||||
// Base64 디코딩
|
||||
const buffer = Buffer.from(base64Data, 'base64');
|
||||
|
||||
if (buffer.length === 0) {
|
||||
console.error('이미지 데이터가 비어있습니다.');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 디렉토리 확인
|
||||
await ensureUploadDir();
|
||||
|
||||
// 파일명 생성
|
||||
const timestamp = getTimestamp();
|
||||
const uniqueId = generateId();
|
||||
const extension = 'jpg'; // 모든 이미지를 JPEG로 저장
|
||||
const filename = `${prefix}_${timestamp}_${uniqueId}.${extension}`;
|
||||
const filepath = path.join(UPLOAD_DIR, filename);
|
||||
|
||||
// sharp가 설치되어 있으면 리사이징 및 최적화
|
||||
if (sharp) {
|
||||
try {
|
||||
await sharp(buffer)
|
||||
.resize(MAX_SIZE.width, MAX_SIZE.height, {
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true
|
||||
})
|
||||
.jpeg({ quality: QUALITY })
|
||||
.toFile(filepath);
|
||||
} catch (sharpError) {
|
||||
console.error('sharp 처리 실패, 원본 저장:', sharpError.message);
|
||||
// sharp 실패 시 원본 저장
|
||||
await fs.writeFile(filepath, buffer);
|
||||
}
|
||||
} else {
|
||||
// sharp가 없으면 원본 그대로 저장
|
||||
await fs.writeFile(filepath, buffer);
|
||||
}
|
||||
|
||||
// 웹 접근 경로 반환
|
||||
return `/uploads/issues/${filename}`;
|
||||
} catch (error) {
|
||||
console.error('이미지 저장 실패:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 Base64 이미지를 한번에 저장
|
||||
* @param {string[]} base64Images - Base64 이미지 배열
|
||||
* @param {string} prefix - 파일명 접두사
|
||||
* @returns {Promise<string[]>} 저장된 파일 경로 배열
|
||||
*/
|
||||
async function saveMultipleImages(base64Images, prefix = 'issue') {
|
||||
const paths = [];
|
||||
|
||||
for (const base64 of base64Images) {
|
||||
if (base64) {
|
||||
const savedPath = await saveBase64Image(base64, prefix);
|
||||
if (savedPath) {
|
||||
paths.push(savedPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 삭제
|
||||
* @param {string} webPath - 웹 경로 (예: /uploads/issues/filename.jpg)
|
||||
* @returns {Promise<boolean>} 삭제 성공 여부
|
||||
*/
|
||||
async function deleteFile(webPath) {
|
||||
try {
|
||||
if (!webPath || typeof webPath !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 보안: uploads 경로만 삭제 허용
|
||||
if (!webPath.startsWith('/uploads/')) {
|
||||
console.error('삭제 불가: uploads 외부 경로', webPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
const filename = path.basename(webPath);
|
||||
const fullPath = path.join(UPLOAD_DIR, filename);
|
||||
|
||||
try {
|
||||
await fs.access(fullPath);
|
||||
await fs.unlink(fullPath);
|
||||
return true;
|
||||
} catch (accessError) {
|
||||
// 파일이 없으면 성공으로 처리
|
||||
if (accessError.code === 'ENOENT') {
|
||||
return true;
|
||||
}
|
||||
throw accessError;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('파일 삭제 실패:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 파일 삭제
|
||||
* @param {string[]} webPaths - 웹 경로 배열
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function deleteMultipleFiles(webPaths) {
|
||||
for (const webPath of webPaths) {
|
||||
if (webPath) {
|
||||
await deleteFile(webPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
saveBase64Image,
|
||||
saveMultipleImages,
|
||||
deleteFile,
|
||||
deleteMultipleFiles,
|
||||
UPLOAD_DIR
|
||||
};
|
||||
401
api.hyungi.net/services/weatherService.js
Normal file
401
api.hyungi.net/services/weatherService.js
Normal file
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* 날씨 API 서비스
|
||||
*
|
||||
* 기상청 단기예보 API를 사용하여 현재 날씨 정보를 조회
|
||||
* 날씨 조건에 따른 안전 체크리스트 필터링 지원
|
||||
*
|
||||
* @since 2026-02-02
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const logger = require('../utils/logger');
|
||||
const { getDb } = require('../dbPool');
|
||||
|
||||
// 기상청 API 설정
|
||||
const WEATHER_BASE_URL = process.env.WEATHER_API_URL || 'https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0';
|
||||
const WEATHER_API = {
|
||||
baseUrl: WEATHER_BASE_URL,
|
||||
ultraShortUrl: `${WEATHER_BASE_URL}/getUltraSrtNcst`,
|
||||
shortForecastUrl: `${WEATHER_BASE_URL}/getVilageFcst`,
|
||||
apiKey: process.env.WEATHER_API_KEY || '',
|
||||
// 화성시 남양읍 좌표 (격자 좌표)
|
||||
// 위도: 37.2072, 경도: 126.8232
|
||||
defaultLocation: {
|
||||
nx: 57, // 화성시 남양읍 X 좌표
|
||||
ny: 119 // 화성시 남양읍 Y 좌표
|
||||
}
|
||||
};
|
||||
|
||||
// PTY (강수형태) 코드
|
||||
const PTY_CODES = {
|
||||
0: 'none', // 없음
|
||||
1: 'rain', // 비
|
||||
2: 'rain', // 비/눈 (혼합)
|
||||
3: 'snow', // 눈
|
||||
4: 'rain', // 소나기
|
||||
5: 'rain', // 빗방울
|
||||
6: 'rain', // 빗방울/눈날림
|
||||
7: 'snow' // 눈날림
|
||||
};
|
||||
|
||||
// SKY (하늘상태) 코드
|
||||
const SKY_CODES = {
|
||||
1: 'clear', // 맑음
|
||||
3: 'cloudy', // 구름많음
|
||||
4: 'overcast' // 흐림
|
||||
};
|
||||
|
||||
/**
|
||||
* 현재 날씨 정보 조회 (초단기실황)
|
||||
* @param {number} nx - 격자 X 좌표 (optional)
|
||||
* @param {number} ny - 격자 Y 좌표 (optional)
|
||||
* @returns {Promise<Object>} 날씨 데이터
|
||||
*/
|
||||
async function getCurrentWeather(nx = WEATHER_API.defaultLocation.nx, ny = WEATHER_API.defaultLocation.ny) {
|
||||
if (!WEATHER_API.apiKey) {
|
||||
logger.warn('날씨 API 키가 설정되지 않음. 기본값 반환');
|
||||
return getDefaultWeatherData();
|
||||
}
|
||||
|
||||
try {
|
||||
// 현재 시간 기준으로 base_date, base_time 계산
|
||||
const now = new Date();
|
||||
const baseDate = formatDate(now);
|
||||
const baseTime = getBaseTime(now);
|
||||
|
||||
logger.info('날씨 API 호출', { baseDate, baseTime, nx, ny });
|
||||
|
||||
// Encoding 키는 이미 URL 인코딩되어 있으므로 직접 URL에 추가 (이중 인코딩 방지)
|
||||
const url = `${WEATHER_API.ultraShortUrl}?serviceKey=${WEATHER_API.apiKey}` +
|
||||
`&pageNo=1&numOfRows=10&dataType=JSON` +
|
||||
`&base_date=${baseDate}&base_time=${baseTime}` +
|
||||
`&nx=${nx}&ny=${ny}`;
|
||||
|
||||
const response = await axios.get(url, { timeout: 5000 });
|
||||
|
||||
if (response.data?.response?.header?.resultCode !== '00') {
|
||||
throw new Error(`API 오류: ${response.data?.response?.header?.resultMsg}`);
|
||||
}
|
||||
|
||||
const items = response.data.response.body.items.item;
|
||||
const weatherData = parseWeatherItems(items);
|
||||
|
||||
logger.info('날씨 데이터 파싱 완료', weatherData);
|
||||
|
||||
return weatherData;
|
||||
} catch (error) {
|
||||
logger.error('날씨 API 호출 실패', { error: error.message });
|
||||
return getDefaultWeatherData();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 날씨 API 응답 파싱
|
||||
*/
|
||||
function parseWeatherItems(items) {
|
||||
const data = {
|
||||
temperature: null,
|
||||
humidity: null,
|
||||
windSpeed: null,
|
||||
precipitation: null,
|
||||
precipitationType: null,
|
||||
skyCondition: null
|
||||
};
|
||||
|
||||
if (!items || !Array.isArray(items)) {
|
||||
return data;
|
||||
}
|
||||
|
||||
items.forEach(item => {
|
||||
switch (item.category) {
|
||||
case 'T1H': // 기온
|
||||
data.temperature = parseFloat(item.obsrValue);
|
||||
break;
|
||||
case 'REH': // 습도
|
||||
data.humidity = parseInt(item.obsrValue);
|
||||
break;
|
||||
case 'WSD': // 풍속
|
||||
data.windSpeed = parseFloat(item.obsrValue);
|
||||
break;
|
||||
case 'RN1': // 1시간 강수량
|
||||
data.precipitation = parseFloat(item.obsrValue) || 0;
|
||||
break;
|
||||
case 'PTY': // 강수형태
|
||||
data.precipitationType = parseInt(item.obsrValue);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 날씨 데이터를 기반으로 조건 판단
|
||||
* @param {Object} weatherData - 날씨 데이터
|
||||
* @returns {Promise<string[]>} 해당하는 날씨 조건 코드 배열
|
||||
*/
|
||||
async function determineWeatherConditions(weatherData) {
|
||||
const conditions = [];
|
||||
|
||||
// DB에서 날씨 조건 기준 조회
|
||||
const db = await getDb();
|
||||
const [thresholds] = await db.execute(`
|
||||
SELECT condition_code, temp_threshold_min, temp_threshold_max,
|
||||
wind_threshold, precip_threshold
|
||||
FROM weather_conditions
|
||||
WHERE is_active = TRUE
|
||||
`);
|
||||
|
||||
// 조건 판단
|
||||
thresholds.forEach(threshold => {
|
||||
let matches = false;
|
||||
|
||||
switch (threshold.condition_code) {
|
||||
case 'rain':
|
||||
// 강수형태가 비(1,2,4,5,6) 또는 강수량 > 0
|
||||
if (weatherData.precipitationType && PTY_CODES[weatherData.precipitationType] === 'rain') {
|
||||
matches = true;
|
||||
} else if (weatherData.precipitation > 0 && threshold.precip_threshold !== null) {
|
||||
matches = weatherData.precipitation >= threshold.precip_threshold;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'snow':
|
||||
// 강수형태가 눈(3,7)
|
||||
if (weatherData.precipitationType && PTY_CODES[weatherData.precipitationType] === 'snow') {
|
||||
matches = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'heat':
|
||||
// 기온이 폭염 기준 이상
|
||||
if (weatherData.temperature !== null && threshold.temp_threshold_min !== null) {
|
||||
matches = weatherData.temperature >= threshold.temp_threshold_min;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'cold':
|
||||
// 기온이 한파 기준 이하
|
||||
if (weatherData.temperature !== null && threshold.temp_threshold_max !== null) {
|
||||
matches = weatherData.temperature <= threshold.temp_threshold_max;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'wind':
|
||||
// 풍속이 강풍 기준 이상
|
||||
if (weatherData.windSpeed !== null && threshold.wind_threshold !== null) {
|
||||
matches = weatherData.windSpeed >= threshold.wind_threshold;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'clear':
|
||||
// 강수 없고 기온이 정상 범위
|
||||
if (!weatherData.precipitationType || weatherData.precipitationType === 0) {
|
||||
if (weatherData.temperature !== null &&
|
||||
weatherData.temperature > -10 && weatherData.temperature < 35) {
|
||||
matches = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (matches) {
|
||||
conditions.push(threshold.condition_code);
|
||||
}
|
||||
});
|
||||
|
||||
// 조건이 없으면 기본으로 'clear' 추가
|
||||
if (conditions.length === 0) {
|
||||
conditions.push('clear');
|
||||
}
|
||||
|
||||
logger.info('날씨 조건 판단 완료', { weatherData, conditions });
|
||||
|
||||
return conditions;
|
||||
}
|
||||
|
||||
/**
|
||||
* TBM 세션에 날씨 정보 저장
|
||||
* @param {number} sessionId - TBM 세션 ID
|
||||
* @param {Object} weatherData - 날씨 데이터
|
||||
* @param {string[]} conditions - 날씨 조건 배열
|
||||
*/
|
||||
async function saveWeatherRecord(sessionId, weatherData, conditions) {
|
||||
const db = await getDb();
|
||||
|
||||
try {
|
||||
const weatherDate = new Date().toISOString().split('T')[0];
|
||||
|
||||
await db.execute(`
|
||||
INSERT INTO tbm_weather_records
|
||||
(session_id, weather_date, temperature, humidity, wind_speed, precipitation,
|
||||
weather_condition, weather_conditions, data_source, fetched_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'api', NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
temperature = VALUES(temperature),
|
||||
humidity = VALUES(humidity),
|
||||
wind_speed = VALUES(wind_speed),
|
||||
precipitation = VALUES(precipitation),
|
||||
weather_condition = VALUES(weather_condition),
|
||||
weather_conditions = VALUES(weather_conditions),
|
||||
fetched_at = NOW()
|
||||
`, [
|
||||
sessionId,
|
||||
weatherDate,
|
||||
weatherData.temperature,
|
||||
weatherData.humidity,
|
||||
weatherData.windSpeed,
|
||||
weatherData.precipitation,
|
||||
conditions[0] || 'clear', // 주요 조건
|
||||
JSON.stringify(conditions) // 모든 조건
|
||||
]);
|
||||
|
||||
logger.info('날씨 기록 저장 완료', { sessionId, conditions });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('날씨 기록 저장 실패', { sessionId, error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TBM 세션의 날씨 기록 조회
|
||||
* @param {number} sessionId - TBM 세션 ID
|
||||
*/
|
||||
async function getWeatherRecord(sessionId) {
|
||||
const db = await getDb();
|
||||
|
||||
const [rows] = await db.execute(`
|
||||
SELECT wr.*, wc.condition_name, wc.icon
|
||||
FROM tbm_weather_records wr
|
||||
LEFT JOIN weather_conditions wc ON wr.weather_condition = wc.condition_code
|
||||
WHERE wr.session_id = ?
|
||||
`, [sessionId]);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = rows[0];
|
||||
// JSON 문자열 파싱
|
||||
if (record.weather_conditions && typeof record.weather_conditions === 'string') {
|
||||
try {
|
||||
record.weather_conditions = JSON.parse(record.weather_conditions);
|
||||
} catch (e) {
|
||||
record.weather_conditions = [];
|
||||
}
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* 날씨 조건 코드 목록 조회
|
||||
*/
|
||||
async function getWeatherConditionList() {
|
||||
const db = await getDb();
|
||||
|
||||
const [rows] = await db.execute(`
|
||||
SELECT condition_code, condition_name, description, icon,
|
||||
temp_threshold_min, temp_threshold_max, wind_threshold, precip_threshold
|
||||
FROM weather_conditions
|
||||
WHERE is_active = TRUE
|
||||
ORDER BY display_order
|
||||
`);
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 날씨 데이터 반환 (API 실패 시)
|
||||
*/
|
||||
function getDefaultWeatherData() {
|
||||
return {
|
||||
temperature: 20,
|
||||
humidity: 50,
|
||||
windSpeed: 2,
|
||||
precipitation: 0,
|
||||
precipitationType: 0,
|
||||
skyCondition: 'clear',
|
||||
isDefault: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 포맷 (YYYYMMDD)
|
||||
*/
|
||||
function formatDate(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}${month}${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 초단기실황 API용 기준시간 계산
|
||||
* 매시간 정각에 생성되고 10분 후에 제공됨
|
||||
*/
|
||||
function getBaseTime(date) {
|
||||
let hours = date.getHours();
|
||||
let minutes = date.getMinutes();
|
||||
|
||||
// 10분 이전이면 이전 시간 데이터 사용
|
||||
if (minutes < 10) {
|
||||
hours = hours - 1;
|
||||
if (hours < 0) hours = 23;
|
||||
}
|
||||
|
||||
return String(hours).padStart(2, '0') + '00';
|
||||
}
|
||||
|
||||
/**
|
||||
* 위경도를 기상청 격자 좌표로 변환
|
||||
* LCC (Lambert Conformal Conic) 투영법 사용
|
||||
*/
|
||||
function convertToGrid(lat, lon) {
|
||||
const RE = 6371.00877; // 지구 반경(km)
|
||||
const GRID = 5.0; // 격자 간격(km)
|
||||
const SLAT1 = 30.0; // 투영 위도1(degree)
|
||||
const SLAT2 = 60.0; // 투영 위도2(degree)
|
||||
const OLON = 126.0; // 기준점 경도(degree)
|
||||
const OLAT = 38.0; // 기준점 위도(degree)
|
||||
const XO = 43; // 기준점 X좌표(GRID)
|
||||
const YO = 136; // 기준점 Y좌표(GRID)
|
||||
|
||||
const DEGRAD = Math.PI / 180.0;
|
||||
|
||||
const re = RE / GRID;
|
||||
const slat1 = SLAT1 * DEGRAD;
|
||||
const slat2 = SLAT2 * DEGRAD;
|
||||
const olon = OLON * DEGRAD;
|
||||
const olat = OLAT * DEGRAD;
|
||||
|
||||
let sn = Math.tan(Math.PI * 0.25 + slat2 * 0.5) / Math.tan(Math.PI * 0.25 + slat1 * 0.5);
|
||||
sn = Math.log(Math.cos(slat1) / Math.cos(slat2)) / Math.log(sn);
|
||||
let sf = Math.tan(Math.PI * 0.25 + slat1 * 0.5);
|
||||
sf = Math.pow(sf, sn) * Math.cos(slat1) / sn;
|
||||
let ro = Math.tan(Math.PI * 0.25 + olat * 0.5);
|
||||
ro = re * sf / Math.pow(ro, sn);
|
||||
|
||||
let ra = Math.tan(Math.PI * 0.25 + lat * DEGRAD * 0.5);
|
||||
ra = re * sf / Math.pow(ra, sn);
|
||||
let theta = lon * DEGRAD - olon;
|
||||
if (theta > Math.PI) theta -= 2.0 * Math.PI;
|
||||
if (theta < -Math.PI) theta += 2.0 * Math.PI;
|
||||
theta *= sn;
|
||||
|
||||
const x = Math.floor(ra * Math.sin(theta) + XO + 0.5);
|
||||
const y = Math.floor(ro - ra * Math.cos(theta) + YO + 0.5);
|
||||
|
||||
return { nx: x, ny: y };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getCurrentWeather,
|
||||
determineWeatherConditions,
|
||||
saveWeatherRecord,
|
||||
getWeatherRecord,
|
||||
getWeatherConditionList,
|
||||
convertToGrid,
|
||||
getDefaultWeatherData
|
||||
};
|
||||
@@ -10,6 +10,7 @@
|
||||
const workReportModel = require('../models/workReportModel');
|
||||
const { ValidationError, NotFoundError, DatabaseError } = require('../utils/errors');
|
||||
const logger = require('../utils/logger');
|
||||
const { getDb } = require('../dbPool');
|
||||
|
||||
/**
|
||||
* 작업 보고서 생성 (단일 또는 다중)
|
||||
@@ -269,6 +270,170 @@ const getSummaryService = async (year, month) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ========== 부적합 원인 관리 서비스 ==========
|
||||
|
||||
/**
|
||||
* 작업 보고서의 부적합 원인 목록 조회
|
||||
*/
|
||||
const getReportDefectsService = async (reportId) => {
|
||||
const db = await getDb();
|
||||
try {
|
||||
const [rows] = await db.execute(`
|
||||
SELECT
|
||||
d.defect_id,
|
||||
d.report_id,
|
||||
d.error_type_id,
|
||||
d.defect_hours,
|
||||
d.note,
|
||||
d.created_at,
|
||||
et.name as error_type_name,
|
||||
et.severity
|
||||
FROM work_report_defects d
|
||||
JOIN error_types et ON d.error_type_id = et.id
|
||||
WHERE d.report_id = ?
|
||||
ORDER BY d.created_at
|
||||
`, [reportId]);
|
||||
|
||||
return rows;
|
||||
} catch (error) {
|
||||
logger.error('부적합 원인 조회 실패', { reportId, error: error.message });
|
||||
throw new DatabaseError('부적합 원인 조회 중 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 부적합 원인 저장 (전체 교체)
|
||||
*/
|
||||
const saveReportDefectsService = async (reportId, defects) => {
|
||||
const db = await getDb();
|
||||
try {
|
||||
await db.query('START TRANSACTION');
|
||||
|
||||
// 기존 부적합 원인 삭제
|
||||
await db.execute('DELETE FROM work_report_defects WHERE report_id = ?', [reportId]);
|
||||
|
||||
// 새 부적합 원인 추가
|
||||
if (defects && defects.length > 0) {
|
||||
for (const defect of defects) {
|
||||
await db.execute(`
|
||||
INSERT INTO work_report_defects (report_id, error_type_id, defect_hours, note)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, [reportId, defect.error_type_id, defect.defect_hours || 0, defect.note || null]);
|
||||
}
|
||||
}
|
||||
|
||||
// 총 부적합 시간 계산 및 daily_work_reports 업데이트
|
||||
const totalErrorHours = defects
|
||||
? defects.reduce((sum, d) => sum + (parseFloat(d.defect_hours) || 0), 0)
|
||||
: 0;
|
||||
|
||||
await db.execute(`
|
||||
UPDATE daily_work_reports
|
||||
SET error_hours = ?,
|
||||
error_type_id = ?,
|
||||
work_status_id = ?
|
||||
WHERE id = ?
|
||||
`, [
|
||||
totalErrorHours,
|
||||
defects && defects.length > 0 ? defects[0].error_type_id : null,
|
||||
totalErrorHours > 0 ? 2 : 1,
|
||||
reportId
|
||||
]);
|
||||
|
||||
await db.query('COMMIT');
|
||||
|
||||
logger.info('부적합 원인 저장 성공', { reportId, count: defects?.length || 0 });
|
||||
return { success: true, count: defects?.length || 0, totalErrorHours };
|
||||
} catch (error) {
|
||||
await db.query('ROLLBACK');
|
||||
logger.error('부적합 원인 저장 실패', { reportId, error: error.message });
|
||||
throw new DatabaseError('부적합 원인 저장 중 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 부적합 원인 추가 (단일)
|
||||
*/
|
||||
const addReportDefectService = async (reportId, defectData) => {
|
||||
const db = await getDb();
|
||||
try {
|
||||
const [result] = await db.execute(`
|
||||
INSERT INTO work_report_defects (report_id, error_type_id, defect_hours, note)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, [reportId, defectData.error_type_id, defectData.defect_hours || 0, defectData.note || null]);
|
||||
|
||||
// 총 부적합 시간 업데이트
|
||||
await updateTotalErrorHours(reportId);
|
||||
|
||||
logger.info('부적합 원인 추가 성공', { reportId, defectId: result.insertId });
|
||||
return { success: true, defect_id: result.insertId };
|
||||
} catch (error) {
|
||||
logger.error('부적합 원인 추가 실패', { reportId, error: error.message });
|
||||
throw new DatabaseError('부적합 원인 추가 중 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 부적합 원인 삭제
|
||||
*/
|
||||
const removeReportDefectService = async (defectId) => {
|
||||
const db = await getDb();
|
||||
try {
|
||||
// report_id 먼저 조회
|
||||
const [defect] = await db.execute('SELECT report_id FROM work_report_defects WHERE defect_id = ?', [defectId]);
|
||||
if (defect.length === 0) {
|
||||
throw new NotFoundError('부적합 원인을 찾을 수 없습니다');
|
||||
}
|
||||
|
||||
const reportId = defect[0].report_id;
|
||||
|
||||
// 삭제
|
||||
await db.execute('DELETE FROM work_report_defects WHERE defect_id = ?', [defectId]);
|
||||
|
||||
// 총 부적합 시간 업데이트
|
||||
await updateTotalErrorHours(reportId);
|
||||
|
||||
logger.info('부적합 원인 삭제 성공', { defectId, reportId });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) throw error;
|
||||
logger.error('부적합 원인 삭제 실패', { defectId, error: error.message });
|
||||
throw new DatabaseError('부적합 원인 삭제 중 오류가 발생했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 총 부적합 시간 업데이트 헬퍼
|
||||
*/
|
||||
const updateTotalErrorHours = async (reportId) => {
|
||||
const db = await getDb();
|
||||
const [result] = await db.execute(`
|
||||
SELECT COALESCE(SUM(defect_hours), 0) as total
|
||||
FROM work_report_defects
|
||||
WHERE report_id = ?
|
||||
`, [reportId]);
|
||||
|
||||
const totalErrorHours = result[0].total || 0;
|
||||
|
||||
// 첫 번째 부적합 원인의 error_type_id를 대표값으로 사용
|
||||
const [firstDefect] = await db.execute(`
|
||||
SELECT error_type_id FROM work_report_defects WHERE report_id = ? ORDER BY created_at LIMIT 1
|
||||
`, [reportId]);
|
||||
|
||||
await db.execute(`
|
||||
UPDATE daily_work_reports
|
||||
SET error_hours = ?,
|
||||
error_type_id = ?,
|
||||
work_status_id = ?
|
||||
WHERE id = ?
|
||||
`, [
|
||||
totalErrorHours,
|
||||
firstDefect.length > 0 ? firstDefect[0].error_type_id : null,
|
||||
totalErrorHours > 0 ? 2 : 1,
|
||||
reportId
|
||||
]);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createWorkReportService,
|
||||
getWorkReportsByDateService,
|
||||
@@ -276,5 +441,10 @@ module.exports = {
|
||||
getWorkReportByIdService,
|
||||
updateWorkReportService,
|
||||
removeWorkReportService,
|
||||
getSummaryService
|
||||
getSummaryService,
|
||||
// 부적합 원인 관리
|
||||
getReportDefectsService,
|
||||
saveReportDefectsService,
|
||||
addReportDefectService,
|
||||
removeReportDefectService
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user