- 순찰/점검 기능 개선 (zone-detail 페이지 추가) - 출근/근태 시스템 개선 (연차 조회, 근무현황) - 작업분석 대분류 그룹화 및 마이그레이션 스크립트 - 모바일 네비게이션 UI 추가 - NAS 배포 도구 및 문서 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
345 lines
10 KiB
JavaScript
345 lines
10 KiB
JavaScript
/**
|
|
* TK-FB-Project API Server
|
|
*
|
|
* 작업 관리 시스템의 메인 서버 애플리케이션
|
|
*
|
|
* @author TK-FB-Project
|
|
* @since 2025-12-11
|
|
* @version 2.2.0
|
|
*/
|
|
|
|
require('dotenv').config();
|
|
const express = require('express');
|
|
const setupMiddlewares = require('./config/middleware');
|
|
const setupRoutes = require('./config/routes');
|
|
const { errorHandler } = require('./middlewares/errorHandler');
|
|
const logger = require('./utils/logger');
|
|
const cache = require('./utils/cache');
|
|
|
|
const app = express();
|
|
const PORT = process.env.PORT || 20005;
|
|
|
|
// Trust proxy for accurate IP addresses
|
|
app.set('trust proxy', 1);
|
|
|
|
// JSON body parser 미리 적용 (마이그레이션용)
|
|
app.use(express.json());
|
|
|
|
// 임시 분석 테스트 엔드포인트 - 실행 후 삭제!
|
|
app.get('/api/test-analysis', async (req, res) => {
|
|
try {
|
|
const { getDb } = require('./dbPool');
|
|
const db = await getDb();
|
|
|
|
// 수정된 COALESCE 로직 테스트 (tasks 우선)
|
|
const [results] = await db.query(`
|
|
SELECT
|
|
dwr.id,
|
|
w.worker_name,
|
|
dwr.report_date,
|
|
dwr.work_type_id as original_work_type_id,
|
|
COALESCE(
|
|
CASE WHEN t.task_id IS NOT NULL THEN t.work_type_id ELSE NULL END,
|
|
wt.id
|
|
) as resolved_work_type_id,
|
|
COALESCE(
|
|
CASE WHEN t.task_id IS NOT NULL THEN wt2.name ELSE NULL END,
|
|
wt.name
|
|
) as work_type_name,
|
|
t.task_name,
|
|
wt.name as direct_match_work_type,
|
|
wt2.name as task_work_type
|
|
FROM daily_work_reports dwr
|
|
LEFT JOIN workers w ON dwr.worker_id = w.worker_id
|
|
LEFT JOIN work_types wt ON dwr.work_type_id = wt.id
|
|
LEFT JOIN tasks t ON dwr.work_type_id = t.task_id
|
|
LEFT JOIN work_types wt2 ON t.work_type_id = wt2.id
|
|
WHERE w.worker_name LIKE '%조승민%' OR w.worker_name LIKE '%최광욱%'
|
|
ORDER BY dwr.report_date DESC
|
|
LIMIT 20
|
|
`);
|
|
|
|
res.json({
|
|
success: true,
|
|
message: 'tasks 테이블 우선 조회 결과',
|
|
data: results.map(r => ({
|
|
id: r.id,
|
|
worker: r.worker_name,
|
|
date: r.report_date,
|
|
original_id: r.original_work_type_id,
|
|
resolved_work_type: r.work_type_name,
|
|
task: r.task_name,
|
|
note: `원래 ID ${r.original_work_type_id} → ${r.work_type_name}`
|
|
}))
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('테스트 실패:', error);
|
|
res.status(500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
// 임시 진단 엔드포인트 - 실행 후 삭제!
|
|
app.get('/api/diagnose-work-type-id', async (req, res) => {
|
|
try {
|
|
const { getDb } = require('./dbPool');
|
|
const db = await getDb();
|
|
|
|
// 1. 전체 작업보고서 현황
|
|
const [totalStats] = await db.query(`
|
|
SELECT
|
|
COUNT(*) as total_reports,
|
|
COUNT(tbm_assignment_id) as tbm_reports,
|
|
COUNT(CASE WHEN tbm_assignment_id IS NULL THEN 1 END) as non_tbm_reports
|
|
FROM daily_work_reports
|
|
`);
|
|
|
|
// 2. work_type_id 값 분포 (상위 20개)
|
|
const [workTypeDistribution] = await db.query(`
|
|
SELECT
|
|
dwr.work_type_id,
|
|
COUNT(*) as count,
|
|
wt.name as if_work_type,
|
|
t.task_name as if_task,
|
|
wt2.name as task_work_type
|
|
FROM daily_work_reports dwr
|
|
LEFT JOIN work_types wt ON dwr.work_type_id = wt.id
|
|
LEFT JOIN tasks t ON dwr.work_type_id = t.task_id
|
|
LEFT JOIN work_types wt2 ON t.work_type_id = wt2.id
|
|
GROUP BY dwr.work_type_id
|
|
ORDER BY count DESC
|
|
LIMIT 20
|
|
`);
|
|
|
|
// 3. 특정 작업자 데이터 확인 (조승민, 최광욱)
|
|
const [workerSamples] = await db.query(`
|
|
SELECT
|
|
dwr.id,
|
|
w.worker_name,
|
|
dwr.work_type_id,
|
|
dwr.tbm_assignment_id,
|
|
wt.name as direct_work_type,
|
|
t.task_name,
|
|
wt2.name as task_work_type,
|
|
dwr.report_date
|
|
FROM daily_work_reports dwr
|
|
LEFT JOIN workers w ON dwr.worker_id = w.worker_id
|
|
LEFT JOIN work_types wt ON dwr.work_type_id = wt.id
|
|
LEFT JOIN tasks t ON dwr.work_type_id = t.task_id
|
|
LEFT JOIN work_types wt2 ON t.work_type_id = wt2.id
|
|
WHERE w.worker_name LIKE '%조승민%' OR w.worker_name LIKE '%최광욱%'
|
|
ORDER BY dwr.report_date DESC
|
|
LIMIT 20
|
|
`);
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
total_stats: totalStats[0],
|
|
work_type_distribution: workTypeDistribution,
|
|
worker_samples: workerSamples
|
|
}
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('진단 실패:', error);
|
|
res.status(500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
// 임시 마이그레이션 엔드포인트 (인증 없이 실행) - 실행 후 삭제!
|
|
app.post('/api/migrate-work-type-id', async (req, res) => {
|
|
try {
|
|
const { getDb } = require('./dbPool');
|
|
const db = await getDb();
|
|
|
|
console.log('🔄 TBM 기반 작업보고서 work_type_id 수정 시작...');
|
|
|
|
// 1. 수정 대상 확인
|
|
const [checkResult] = await db.query(`
|
|
SELECT
|
|
dwr.id,
|
|
dwr.work_type_id as current_work_type_id,
|
|
ta.task_id as correct_task_id,
|
|
w.worker_name,
|
|
dwr.report_date
|
|
FROM daily_work_reports dwr
|
|
INNER JOIN tbm_team_assignments ta ON dwr.tbm_assignment_id = ta.assignment_id
|
|
INNER JOIN workers w ON dwr.worker_id = w.worker_id
|
|
WHERE dwr.tbm_assignment_id IS NOT NULL
|
|
AND ta.task_id IS NOT NULL
|
|
AND dwr.work_type_id != ta.task_id
|
|
ORDER BY dwr.report_date DESC
|
|
`);
|
|
|
|
console.log('📊 수정 대상:', checkResult.length, '개 레코드');
|
|
|
|
if (checkResult.length === 0) {
|
|
return res.json({
|
|
success: true,
|
|
message: '수정할 데이터가 없습니다.',
|
|
data: { affected_rows: 0 }
|
|
});
|
|
}
|
|
|
|
// 2. 업데이트 실행
|
|
const [updateResult] = await db.query(`
|
|
UPDATE daily_work_reports dwr
|
|
INNER JOIN tbm_team_assignments ta ON dwr.tbm_assignment_id = ta.assignment_id
|
|
SET dwr.work_type_id = ta.task_id
|
|
WHERE dwr.tbm_assignment_id IS NOT NULL
|
|
AND ta.task_id IS NOT NULL
|
|
AND dwr.work_type_id != ta.task_id
|
|
`);
|
|
|
|
console.log('✅ 업데이트 완료:', updateResult.affectedRows, '개 레코드 수정됨');
|
|
|
|
// 3. 수정 후 확인
|
|
const [samples] = await db.query(`
|
|
SELECT
|
|
dwr.id,
|
|
dwr.work_type_id,
|
|
t.task_name,
|
|
wt.name as work_type_name,
|
|
w.worker_name,
|
|
dwr.report_date
|
|
FROM daily_work_reports dwr
|
|
INNER JOIN tbm_team_assignments ta ON dwr.tbm_assignment_id = ta.assignment_id
|
|
LEFT JOIN tasks t ON dwr.work_type_id = t.task_id
|
|
LEFT JOIN work_types wt ON t.work_type_id = wt.id
|
|
LEFT JOIN workers w ON dwr.worker_id = w.worker_id
|
|
WHERE dwr.tbm_assignment_id IS NOT NULL
|
|
ORDER BY dwr.report_date DESC
|
|
LIMIT 10
|
|
`);
|
|
|
|
res.json({
|
|
success: true,
|
|
message: updateResult.affectedRows + '개 레코드가 수정되었습니다.',
|
|
data: {
|
|
affected_rows: updateResult.affectedRows,
|
|
before_count: checkResult.length,
|
|
samples: samples.map(s => ({
|
|
id: s.id,
|
|
worker: s.worker_name,
|
|
date: s.report_date,
|
|
task: s.task_name,
|
|
work_type: s.work_type_name
|
|
}))
|
|
}
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('마이그레이션 실패:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: '마이그레이션 실패: ' + error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// 미들웨어 설정
|
|
setupMiddlewares(app);
|
|
|
|
// 라우트 설정
|
|
setupRoutes(app);
|
|
|
|
// 에러 핸들러 (모든 라우트 이후에 위치)
|
|
app.use(errorHandler);
|
|
|
|
// 404 핸들러
|
|
app.use((req, res) => {
|
|
logger.warn('404 Not Found', { url: req.originalUrl, method: req.method });
|
|
res.status(404).json({
|
|
success: false,
|
|
error: '요청하신 경로를 찾을 수 없습니다',
|
|
path: req.originalUrl
|
|
});
|
|
});
|
|
|
|
// 서버 시작
|
|
const server = app.listen(PORT, () => {
|
|
logger.info(`서버 시작 완료`, {
|
|
port: PORT,
|
|
env: process.env.NODE_ENV || 'development',
|
|
nodeVersion: process.version
|
|
});
|
|
console.log(`\n🚀 서버가 포트 ${PORT}에서 실행 중입니다.`);
|
|
console.log(`📚 API 문서: http://localhost:${PORT}/api-docs\n`);
|
|
});
|
|
|
|
// Graceful Shutdown
|
|
const gracefulShutdown = (signal) => {
|
|
logger.info(`${signal} 신호 수신 - 서버 종료 시작`);
|
|
console.log(`\n🛑 ${signal} 신호를 받았습니다. 서버를 종료합니다...`);
|
|
|
|
server.close(async () => {
|
|
logger.info('HTTP 서버 종료 완료');
|
|
console.log('✅ HTTP 서버가 정상적으로 종료되었습니다.');
|
|
|
|
// 리소스 정리
|
|
try {
|
|
// DB 연결 종료는 각 요청에서 pool을 사용하므로 불필요
|
|
// Redis 종료 (사용 중인 경우)
|
|
if (cache.redis) {
|
|
await cache.redis.quit();
|
|
logger.info('캐시 시스템 종료 완료');
|
|
}
|
|
} catch (error) {
|
|
logger.error('리소스 정리 중 오류 발생', { error: error.message });
|
|
}
|
|
|
|
process.exit(0);
|
|
});
|
|
|
|
// 30초 후 강제 종료
|
|
setTimeout(() => {
|
|
logger.error('강제 종료 - 정상 종료 시간 초과');
|
|
console.error('❌ 정상 종료 실패, 강제 종료합니다.');
|
|
process.exit(1);
|
|
}, 30000);
|
|
};
|
|
|
|
// 시그널 핸들러 등록
|
|
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
|
|
// 처리되지 않은 Promise 거부
|
|
process.on('unhandledRejection', (reason, promise) => {
|
|
logger.error('처리되지 않은 Promise 거부', {
|
|
reason: reason,
|
|
promise: promise
|
|
});
|
|
console.error('⚠️ 처리되지 않은 Promise 거부:', reason);
|
|
|
|
if (process.env.NODE_ENV === 'development') {
|
|
process.exit(1);
|
|
}
|
|
});
|
|
|
|
// 처리되지 않은 예외
|
|
process.on('uncaughtException', (error) => {
|
|
logger.error('처리되지 않은 예외', {
|
|
error: error.message,
|
|
stack: error.stack
|
|
});
|
|
console.error('💥 처리되지 않은 예외:', error);
|
|
gracefulShutdown('UNCAUGHT_EXCEPTION');
|
|
});
|
|
|
|
// 캐시 시스템 초기화 (선택적)
|
|
(async () => {
|
|
try {
|
|
if (cache.initRedis) {
|
|
await cache.initRedis();
|
|
logger.info('캐시 시스템 초기화 완료');
|
|
console.log('✅ 캐시 시스템 초기화 완료');
|
|
}
|
|
} catch (error) {
|
|
logger.warn('캐시 시스템 초기화 실패 - 계속 진행', { error: error.message });
|
|
console.warn('⚠️ 캐시 시스템 초기화 실패:', error.message);
|
|
}
|
|
})();
|
|
|
|
module.exports = app;
|