Compare commits

...

8 Commits

Author SHA1 Message Date
Hyungi Ahn
2b1c7bfb88 feat: 다수 기능 개선 - 순찰, 출근, 작업분석, 모바일 UI 등
- 순찰/점검 기능 개선 (zone-detail 페이지 추가)
- 출근/근태 시스템 개선 (연차 조회, 근무현황)
- 작업분석 대분류 그룹화 및 마이그레이션 스크립트
- 모바일 네비게이션 UI 추가
- NAS 배포 도구 및 문서 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 14:41:01 +09:00
Hyungi Ahn
1548253f56 fix: 작업 분석에서 공정(대분류)으로 올바르게 분류
문제: work_type_id에 task_id가 저장된 경우 공정 분류가 안됨
- work_type_id=10 → 실제로는 task "노즐 용접" (공정: Vessel)

해결:
- API에서 task_id인 경우 해당 task의 work_type_id로 공정 조회
- getRecentWork, getProjectWorkTypeRawData 쿼리 수정
- 프론트엔드는 API 결과의 work_type_name 직접 사용

공정(대분류): Base(구조물), Vessel(용기), Piping Assembly(배관), 작업대기, 휴무, 시설설비

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 10:29:08 +09:00
Hyungi Ahn
0ea253befd fix: 작업자별 현황 테이블도 대분류로 그룹화
- renderWorkReportTable 함수에서 getMajorCategory() 사용
- 작업내용을 대분류(Base제작, 용기제작, 파이핑 등)로 표시

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 10:25:26 +09:00
Hyungi Ahn
bea0fec4f1 fix: 작업 분석 페이지 작업내용을 대분류로 그룹화
- getMajorCategory() 함수 추가
- work_type_id를 대분류(Base제작, 용기제작, 파이핑, 작업대기, 휴무, 시설설비, 기타)로 매핑
- 개별 작업유형 대신 대분류 기준으로 집계
- 세부 분류는 다른 분석에서 처리

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 10:23:29 +09:00
Hyungi Ahn
665a5b1b7d refactor: 작업보고서 조회 페이지 삭제 및 출근체크 버그 수정
- report-view.html 및 관련 파일 삭제 (리소스 최적화)
  - work-report-calendar.js/css
  - modules/calendar/* (CalendarState, CalendarAPI, CalendarView)
  - report-viewer-*.js (미사용)
  - daily-report-viewer.js/css (미사용)
- 사이드바에서 작업보고서 조회 링크 제거
- 출근체크 페이지: 날짜 변경 시 자동 새로고침 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 09:44:19 +09:00
Hyungi Ahn
170adcc149 refactor: 코드 관리 페이지 삭제 및 프론트엔드 모듈화
- codes.html, code-management.js 삭제 (tasks.html에서 동일 기능 제공)
- 사이드바에서 코드 관리 링크 제거
- daily-work-report, tbm, workplace-management JS 모듈 분리
- common/security.js 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 06:42:12 +09:00
Hyungi Ahn
36f110c90a fix: 보안 취약점 수정 및 XSS 방지 적용
## 백엔드 보안 수정
- 하드코딩된 비밀번호 및 JWT 시크릿 폴백 제거
- SQL Injection 방지를 위한 화이트리스트 검증 추가
- 인증 미적용 API 라우트에 requireAuth 미들웨어 적용
- CSRF 보호 미들웨어 구현 (csrf.js)
- 파일 업로드 보안 유틸리티 추가 (fileUploadSecurity.js)
- 비밀번호 정책 검증 유틸리티 추가 (passwordValidator.js)

## 프론트엔드 XSS 방지
- api-base.js에 전역 escapeHtml() 함수 추가
- 17개 주요 JS 파일에 escapeHtml 적용:
  - tbm.js, daily-patrol.js, daily-work-report.js
  - task-management.js, workplace-status.js
  - equipment-detail.js, equipment-management.js
  - issue-detail.js, issue-report.js
  - vacation-common.js, worker-management.js
  - safety-report-list.js, nonconformity-list.js
  - project-management.js, workplace-management.js

## 정리
- 백업 폴더 및 빈 파일 삭제
- SECURITY_GUIDE.md 문서 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 06:33:10 +09:00
Hyungi Ahn
7c38c555f5 fix: 출근체크/근무현황 페이지 버그 수정
- workers API 기본 limit 10 → 100 변경 (작업자 누락 문제 해결)
- 작업자 필터 조건 수정 (status='active' + employment_status 체크)
- 근태 기록 저장 시 컬럼명 불일치 수정 (attendance_type_id)
- 근무현황 페이지에 저장 상태 표시 추가 (✓저장됨)
- 디버그 로그 제거

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 20:58:30 +09:00
712 changed files with 356194 additions and 18150 deletions

View File

@@ -1,2 +0,0 @@
# 삭제 예정 파일 폴더
# 이 폴더의 파일들은 정리 후 삭제해주세요.

View File

@@ -51,13 +51,63 @@ function setupMiddlewares(app) {
app.use(express.static(path.join(__dirname, '../public'))); app.use(express.static(path.join(__dirname, '../public')));
app.use('/uploads', express.static(path.join(__dirname, '../uploads'))); app.use('/uploads', express.static(path.join(__dirname, '../uploads')));
// Rate Limiting (필요시 활성화) // Rate Limiting - API 요청 제한
// const rateLimit = require('express-rate-limit'); const rateLimit = require('express-rate-limit');
// const limiter = rateLimit({
// windowMs: 15 * 60 * 1000, // 15분 // 일반 API 요청 제한
// max: 100 // IP당 최대 100 요청 const apiLimiter = rateLimit({
// }); windowMs: 15 * 60 * 1000, // 15분
// app.use('/api/', limiter); max: 1000, // IP당 최대 1000 요청 (일괄 처리 지원)
message: {
success: false,
error: '너무 많은 요청입니다. 잠시 후 다시 시도해주세요.',
code: 'RATE_LIMIT_EXCEEDED'
},
standardHeaders: true,
legacyHeaders: false,
// 인증된 사용자는 더 많은 요청 허용
skip: (req) => {
// Authorization 헤더가 있으면 Rate Limit 완화
return req.headers.authorization && req.headers.authorization.startsWith('Bearer ');
}
});
// 로그인 시도 제한 (브루트포스 방지)
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 10, // IP당 최대 10회 로그인 시도
message: {
success: false,
error: '로그인 시도 횟수를 초과했습니다. 15분 후 다시 시도해주세요.',
code: 'LOGIN_RATE_LIMIT_EXCEEDED'
},
standardHeaders: true,
legacyHeaders: false
});
// Rate limiter 적용
app.use('/api/', apiLimiter);
app.use('/api/auth/login', loginLimiter);
logger.info('Rate Limiting 설정 완료');
// CSRF Protection (선택적 - 필요 시 주석 해제)
// const { verifyCsrfToken, getCsrfToken } = require('../middlewares/csrf');
//
// CSRF 토큰 발급 엔드포인트
// app.get('/api/csrf-token', getCsrfToken);
//
// CSRF 검증 미들웨어 (로그인 등 일부 경로 제외)
// app.use('/api/', verifyCsrfToken({
// ignorePaths: [
// '/api/auth/login',
// '/api/auth/register',
// '/api/health',
// '/api/csrf-token'
// ]
// }));
//
// logger.info('CSRF Protection 설정 완료');
logger.info('미들웨어 설정 완료'); logger.info('미들웨어 설정 완료');
} }

View File

@@ -107,7 +107,10 @@ function setupRoutes(app) {
'/api/setup/migrate-existing-data', '/api/setup/migrate-existing-data',
'/api/setup/check-data-status', '/api/setup/check-data-status',
'/api/monthly-status/calendar', '/api/monthly-status/calendar',
'/api/monthly-status/daily-details' '/api/monthly-status/daily-details',
'/api/migrate-work-type-id', // 임시 마이그레이션 - 실행 후 삭제!
'/api/diagnose-work-type-id', // 임시 진단 - 실행 후 삭제!
'/api/test-analysis' // 임시 분석 테스트 - 실행 후 삭제!
]; ];
// 인증 미들웨어 - 공개 경로를 제외한 모든 API (rate limiter보다 먼저 실행) // 인증 미들웨어 - 공개 경로를 제외한 모든 API (rate limiter보다 먼저 실행)

View File

@@ -38,6 +38,20 @@ const getDailyAttendanceRecords = asyncHandler(async (req, res) => {
}); });
}); });
/**
* 기간별 근태 기록 조회 (월별 조회용)
*/
const getAttendanceRecordsByRange = asyncHandler(async (req, res) => {
const { start_date, end_date, worker_id } = req.query;
const data = await attendanceService.getAttendanceRecordsByRangeService(start_date, end_date, worker_id);
res.json({
success: true,
data,
message: '근태 기록을 성공적으로 조회했습니다'
});
});
/** /**
* 근태 기록 생성/업데이트 * 근태 기록 생성/업데이트
*/ */
@@ -185,6 +199,7 @@ const saveCheckins = asyncHandler(async (req, res) => {
module.exports = { module.exports = {
getDailyAttendanceStatus, getDailyAttendanceStatus,
getDailyAttendanceRecords, getDailyAttendanceRecords,
getAttendanceRecordsByRange,
upsertAttendanceRecord, upsertAttendanceRecord,
processVacation, processVacation,
approveOvertime, approveOvertime,

View File

@@ -289,6 +289,507 @@ const PatrolController = {
console.error('작업장별 점검 현황 조회 오류:', error); console.error('작업장별 점검 현황 조회 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
} }
},
// ==================== 작업장 상세 정보 (통합) ====================
// 작업장 상세 정보 조회 (시설물, 안전신고, 부적합, 출입, TBM)
getWorkplaceDetail: async (req, res) => {
try {
const { workplaceId } = req.params;
const { date } = req.query; // 기본: 오늘
const targetDate = date || new Date().toISOString().slice(0, 10);
const { getDb } = require('../dbPool');
const db = await getDb();
// 1. 작업장 기본 정보 (카테고리 지도 이미지 포함)
const [workplaceInfo] = await db.query(`
SELECT w.*, wc.category_name, wc.layout_image as category_layout_image
FROM workplaces w
LEFT JOIN workplace_categories wc ON w.category_id = wc.category_id
WHERE w.workplace_id = ?
`, [workplaceId]);
if (!workplaceInfo.length) {
return res.status(404).json({ success: false, message: '작업장을 찾을 수 없습니다.' });
}
// 2. 설비 현황 (해당 작업장 - 원래 위치 또는 현재 위치)
let equipments = [];
try {
const [eqResult] = await db.query(`
SELECT e.equipment_id, e.equipment_name, e.equipment_code, e.equipment_type,
e.status, e.notes, e.workplace_id,
e.map_x_percent, e.map_y_percent, e.map_width_percent, e.map_height_percent,
e.is_temporarily_moved, e.current_workplace_id,
e.current_map_x_percent, e.current_map_y_percent,
e.current_map_width_percent, e.current_map_height_percent,
e.moved_at,
ow.workplace_name as original_workplace_name,
cw.workplace_name as current_workplace_name,
CASE
WHEN e.status IN ('maintenance', 'repair_needed', 'repair_external') THEN 1
WHEN e.is_temporarily_moved = 1 THEN 1
ELSE 0
END as needs_attention
FROM equipments e
LEFT JOIN workplaces ow ON e.workplace_id = ow.workplace_id
LEFT JOIN workplaces cw ON e.current_workplace_id = cw.workplace_id
WHERE (e.workplace_id = ? OR e.current_workplace_id = ?)
AND e.status != 'inactive'
ORDER BY needs_attention DESC, e.equipment_name
`, [workplaceId, workplaceId]);
equipments = eqResult;
} catch (eqError) {
console.log('설비 조회 스킵 (테이블 없음 또는 오류):', eqError.message);
}
// 3. 수리 요청 현황 (미완료) - 테이블 존재 여부 확인 후 조회
let repairRequests = [];
try {
const [repairResult] = await db.query(`
SELECT er.request_id, er.request_date, er.repair_category, er.description,
er.priority, er.status, e.equipment_name, e.equipment_code
FROM equipment_repair_requests er
JOIN equipments e ON er.equipment_id = e.equipment_id
WHERE e.workplace_id = ? AND er.status NOT IN ('completed', 'cancelled')
ORDER BY
CASE er.priority WHEN 'emergency' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END,
er.request_date DESC
LIMIT 10
`, [workplaceId]);
repairRequests = repairResult;
} catch (repairError) {
console.log('수리요청 조회 스킵 (테이블 없음 또는 오류):', repairError.message);
}
// 4. 안전 신고 및 부적합 사항 - 테이블 존재 여부 확인 후 조회
let workIssues = [];
try {
const [issueResult] = await db.query(`
SELECT wi.report_id, wi.issue_type, wi.title, wi.description,
wi.status, wi.severity, wi.created_at, wi.resolved_at,
wic.category_name, wic.issue_type as category_type,
u.name as reporter_name
FROM work_issue_reports wi
LEFT JOIN issue_report_categories wic ON wi.category_id = wic.category_id
LEFT JOIN users u ON wi.reporter_id = u.user_id
WHERE wi.workplace_id = ?
AND wi.created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
ORDER BY wi.created_at DESC
LIMIT 20
`, [workplaceId]);
workIssues = issueResult;
} catch (issueError) {
console.log('신고 조회 스킵 (테이블 없음 또는 오류):', issueError.message);
}
// 5. 오늘의 출입 기록 (해당 공장 카테고리)
const categoryId = workplaceInfo[0].category_id;
let visitRecords = [];
try {
const [visitResult] = await db.query(`
SELECT vr.request_id, vr.visitor_name, vr.visitor_company, vr.visit_purpose,
vr.visit_date, vr.visit_time_from, vr.visit_time_to, vr.status,
vr.vehicle_number, vr.companion_count,
vp.purpose_name, u.name as requester_name
FROM workplace_visit_requests vr
LEFT JOIN visit_purpose_types vp ON vr.purpose_id = vp.purpose_id
LEFT JOIN users u ON vr.requester_id = u.user_id
WHERE vr.category_id = ? AND vr.visit_date = ? AND vr.status = 'approved'
ORDER BY vr.visit_time_from
`, [categoryId, targetDate]);
visitRecords = visitResult;
} catch (visitError) {
console.log('출입기록 조회 스킵 (테이블 없음 또는 오류):', visitError.message);
}
// 6. 오늘의 TBM 세션 (해당 공장 카테고리)
let tbmSessions = [];
try {
const [tbmResult] = await db.query(`
SELECT ts.session_id, ts.session_date, ts.work_location, ts.status,
ts.work_content, ts.safety_measures, ts.team_size,
t.task_name, wt.name as work_type_name,
u.name as leader_name, w.worker_name as leader_worker_name
FROM tbm_sessions ts
LEFT JOIN tasks t ON ts.task_id = t.task_id
LEFT JOIN work_types wt ON t.work_type_id = wt.id
LEFT JOIN users u ON ts.leader_id = u.user_id
LEFT JOIN workers w ON ts.leader_worker_id = w.worker_id
WHERE ts.category_id = ? AND ts.session_date = ?
ORDER BY ts.created_at DESC
`, [categoryId, targetDate]);
tbmSessions = tbmResult;
} catch (tbmError) {
console.log('TBM 조회 스킵 (테이블 없음 또는 오류):', tbmError.message);
}
// 7. TBM 팀원 정보 (세션별)
let tbmWithTeams = [];
try {
tbmWithTeams = await Promise.all(tbmSessions.map(async (session) => {
const [team] = await db.query(`
SELECT tta.assignment_id, w.worker_name, w.occupation,
tta.attendance_status, tta.signature_image
FROM tbm_team_assignments tta
JOIN workers w ON tta.worker_id = w.worker_id
WHERE tta.session_id = ?
ORDER BY w.worker_name
`, [session.session_id]);
return { ...session, team };
}));
} catch (teamError) {
console.log('TBM 팀원 조회 스킵:', teamError.message);
tbmWithTeams = tbmSessions.map(s => ({ ...s, team: [] }));
}
// 8. 최근 순회점검 결과 (해당 작업장)
let recentPatrol = [];
try {
const [patrolResult] = await db.query(`
SELECT ps.session_id, ps.patrol_date, ps.patrol_time, ps.status,
ps.notes, u.name as inspector_name,
(SELECT COUNT(*) FROM patrol_check_records pcr
WHERE pcr.session_id = ps.session_id AND pcr.workplace_id = ?) as checked_count,
(SELECT COUNT(*) FROM patrol_check_records pcr
WHERE pcr.session_id = ps.session_id AND pcr.workplace_id = ?
AND pcr.check_result IN ('warning', 'bad')) as issue_count
FROM daily_patrol_sessions ps
LEFT JOIN users u ON ps.inspector_id = u.user_id
WHERE ps.category_id = ? AND ps.patrol_date >= DATE_SUB(NOW(), INTERVAL 7 DAY)
ORDER BY ps.patrol_date DESC, ps.patrol_time DESC
LIMIT 5
`, [workplaceId, workplaceId, categoryId]);
recentPatrol = patrolResult;
} catch (patrolError) {
console.log('순회점검 조회 스킵 (테이블 없음 또는 오류):', patrolError.message);
}
res.json({
success: true,
data: {
workplace: workplaceInfo[0],
equipments: equipments,
repairRequests: repairRequests,
workIssues: {
safety: workIssues.filter(i => i.category_type === 'safety'),
nonconformity: workIssues.filter(i => i.category_type === 'nonconformity'),
all: workIssues
},
visitRecords: visitRecords,
tbmSessions: tbmWithTeams,
recentPatrol: recentPatrol,
summary: {
equipmentCount: equipments.length,
needsAttention: equipments.filter(e => e.needs_attention).length,
pendingRepairs: repairRequests.length,
openIssues: workIssues.filter(i => i.status !== 'closed').length,
todayVisitors: visitRecords.reduce((sum, v) => sum + 1 + (v.companion_count || 0), 0),
todayTbmSessions: tbmSessions.length
}
}
});
} catch (error) {
console.error('작업장 상세 정보 조회 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// ==================== 구역 내 등록 물품/시설물 ====================
// 구역 내 물품/시설물 목록 조회
getZoneItems: async (req, res) => {
try {
const { workplaceId } = req.params;
const { getDb } = require('../dbPool');
const db = await getDb();
// 테이블이 없으면 생성
await db.query(`
CREATE TABLE IF NOT EXISTS workplace_zone_items (
item_id INT AUTO_INCREMENT PRIMARY KEY,
workplace_id INT NOT NULL,
item_name VARCHAR(200) NOT NULL COMMENT '물품/시설물 명칭',
item_type VARCHAR(50) DEFAULT 'general' COMMENT '유형 (heavy_equipment, hazardous, storage, general 등)',
description TEXT COMMENT '상세 설명',
x_percent DECIMAL(5,2) NOT NULL COMMENT '영역 시작 X 좌표 (%)',
y_percent DECIMAL(5,2) NOT NULL COMMENT '영역 시작 Y 좌표 (%)',
width_percent DECIMAL(5,2) DEFAULT 10 COMMENT '영역 너비 (%)',
height_percent DECIMAL(5,2) DEFAULT 10 COMMENT '영역 높이 (%)',
color VARCHAR(20) DEFAULT '#3b82f6' COMMENT '표시 색상',
warning_level VARCHAR(20) DEFAULT 'normal' COMMENT '주의 수준 (normal, caution, danger)',
quantity INT DEFAULT 1 COMMENT '수량',
unit VARCHAR(20) DEFAULT '개' COMMENT '단위',
weight_kg DECIMAL(10,2) DEFAULT NULL COMMENT '중량 (kg)',
is_active BOOLEAN DEFAULT TRUE,
created_by INT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_workplace (workplace_id),
INDEX idx_type (item_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='구역 내 등록 물품/시설물'
`);
// 새 컬럼 추가 (없으면)
try {
await db.query(`ALTER TABLE workplace_zone_items ADD COLUMN project_type VARCHAR(20) DEFAULT 'non_project'`);
} catch (e) { /* 이미 존재 */ }
try {
await db.query(`ALTER TABLE workplace_zone_items ADD COLUMN project_id INT NULL`);
} catch (e) { /* 이미 존재 */ }
const [items] = await db.query(`
SELECT zi.*, p.project_name
FROM workplace_zone_items zi
LEFT JOIN projects p ON zi.project_id = p.project_id
WHERE zi.workplace_id = ? AND zi.is_active = TRUE
ORDER BY zi.warning_level DESC, zi.item_name
`, [workplaceId]);
// 사진 테이블 존재 확인 및 사진 조회
try {
for (const item of items) {
const [photos] = await db.query(`
SELECT photo_id, photo_url, created_at
FROM zone_item_photos
WHERE item_id = ?
ORDER BY created_at DESC
`, [item.item_id]);
item.photos = photos || [];
}
} catch (e) {
// 사진 테이블이 없으면 무시
items.forEach(item => item.photos = []);
}
res.json({ success: true, data: items });
} catch (error) {
console.error('구역 물품 목록 조회 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 구역 현황 등록
createZoneItem: async (req, res) => {
try {
const { workplaceId } = req.params;
const { item_name, item_type, description, x_percent, y_percent, width_percent, height_percent,
color, warning_level, project_type, project_id } = req.body;
const createdBy = req.user?.user_id;
const { getDb } = require('../dbPool');
const db = await getDb();
if (!item_name || x_percent === undefined || y_percent === undefined) {
return res.status(400).json({ success: false, message: '필수 정보가 누락되었습니다.' });
}
// 테이블에 새 컬럼 추가 (없으면)
try {
await db.query(`ALTER TABLE workplace_zone_items ADD COLUMN project_type VARCHAR(20) DEFAULT 'non_project'`);
} catch (e) { /* 이미 존재 */ }
try {
await db.query(`ALTER TABLE workplace_zone_items ADD COLUMN project_id INT NULL`);
} catch (e) { /* 이미 존재 */ }
const [result] = await db.query(`
INSERT INTO workplace_zone_items
(workplace_id, item_name, item_type, description, x_percent, y_percent, width_percent, height_percent,
color, warning_level, project_type, project_id, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [workplaceId, item_name, item_type || 'working', description, x_percent, y_percent,
width_percent || 5, height_percent || 5, color || '#3b82f6', warning_level || 'good',
project_type || 'non_project', project_id || null, createdBy]);
const newItemId = result.insertId;
// 등록 이력 저장
try {
await db.query(`
INSERT INTO zone_item_history (item_id, action_type, new_values, changed_by)
VALUES (?, 'created', ?, ?)
`, [newItemId, JSON.stringify({ item_name, item_type, warning_level, project_type }), createdBy]);
} catch (e) { /* 테이블 없으면 무시 */ }
res.json({
success: true,
data: { item_id: newItemId },
message: '현황이 등록되었습니다.'
});
} catch (error) {
console.error('구역 현황 등록 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 구역 현황 수정
updateZoneItem: async (req, res) => {
try {
const { itemId } = req.params;
const { item_name, item_type, description, x_percent, y_percent, width_percent, height_percent,
color, warning_level, project_type, project_id } = req.body;
const userId = req.user?.user_id;
const { getDb } = require('../dbPool');
const db = await getDb();
// 이력 테이블 생성 (없으면)
await db.query(`
CREATE TABLE IF NOT EXISTS zone_item_history (
history_id INT AUTO_INCREMENT PRIMARY KEY,
item_id INT NOT NULL,
action_type VARCHAR(20) NOT NULL COMMENT 'created, updated, deleted',
changed_fields TEXT COMMENT '변경된 필드 JSON',
old_values TEXT COMMENT '이전 값 JSON',
new_values TEXT COMMENT '새 값 JSON',
changed_by INT,
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_item (item_id),
INDEX idx_date (changed_at)
)
`);
// 기존 데이터 조회 (이력용)
const [oldData] = await db.query(`SELECT * FROM workplace_zone_items WHERE item_id = ?`, [itemId]);
const oldItem = oldData[0];
// 업데이트
await db.query(`
UPDATE workplace_zone_items SET
item_name = COALESCE(?, item_name),
item_type = COALESCE(?, item_type),
description = ?,
x_percent = COALESCE(?, x_percent),
y_percent = COALESCE(?, y_percent),
width_percent = COALESCE(?, width_percent),
height_percent = COALESCE(?, height_percent),
color = COALESCE(?, color),
warning_level = COALESCE(?, warning_level),
project_type = COALESCE(?, project_type),
project_id = ?
WHERE item_id = ?
`, [item_name, item_type, description, x_percent, y_percent, width_percent, height_percent,
color, warning_level, project_type, project_id, itemId]);
// 변경 이력 저장
if (oldItem) {
const changedFields = [];
const oldValues = {};
const newValues = {};
const fieldMap = { item_name, item_type, description, warning_level, project_type, project_id };
for (const [key, newVal] of Object.entries(fieldMap)) {
if (newVal !== undefined && oldItem[key] !== newVal) {
changedFields.push(key);
oldValues[key] = oldItem[key];
newValues[key] = newVal;
}
}
if (changedFields.length > 0) {
await db.query(`
INSERT INTO zone_item_history (item_id, action_type, changed_fields, old_values, new_values, changed_by)
VALUES (?, 'updated', ?, ?, ?, ?)
`, [itemId, JSON.stringify(changedFields), JSON.stringify(oldValues), JSON.stringify(newValues), userId]);
}
}
res.json({ success: true, message: '현황이 수정되었습니다.' });
} catch (error) {
console.error('구역 현황 수정 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 구역 현황 사진 업로드
uploadZoneItemPhoto: async (req, res) => {
try {
const { item_id } = req.body;
const { getDb } = require('../dbPool');
const db = await getDb();
if (!req.file) {
return res.status(400).json({ success: false, message: '파일이 없습니다.' });
}
// 사진 테이블 생성 (없으면)
await db.query(`
CREATE TABLE IF NOT EXISTS zone_item_photos (
photo_id INT AUTO_INCREMENT PRIMARY KEY,
item_id INT NOT NULL,
photo_url VARCHAR(500) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_item_id (item_id)
)
`);
const photoUrl = `/uploads/${req.file.filename}`;
const [result] = await db.query(
`INSERT INTO zone_item_photos (item_id, photo_url) VALUES (?, ?)`,
[item_id, photoUrl]
);
res.json({
success: true,
data: { photo_id: result.insertId, photo_url: photoUrl },
message: '사진이 업로드되었습니다.'
});
} catch (error) {
console.error('사진 업로드 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 구역 현황 삭제
deleteZoneItem: async (req, res) => {
try {
const { itemId } = req.params;
const userId = req.user?.user_id;
const { getDb } = require('../dbPool');
const db = await getDb();
// 기존 데이터 조회 (이력용)
const [oldData] = await db.query(`SELECT * FROM workplace_zone_items WHERE item_id = ?`, [itemId]);
const oldItem = oldData[0];
// 소프트 삭제
await db.query(`UPDATE workplace_zone_items SET is_active = FALSE WHERE item_id = ?`, [itemId]);
// 삭제 이력 저장
if (oldItem) {
await db.query(`
INSERT INTO zone_item_history (item_id, action_type, old_values, changed_by)
VALUES (?, 'deleted', ?, ?)
`, [itemId, JSON.stringify({ item_name: oldItem.item_name, item_type: oldItem.item_type }), userId]);
}
res.json({ success: true, message: '현황이 삭제되었습니다.' });
} catch (error) {
console.error('구역 현황 삭제 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 구역 현황 이력 조회
getZoneItemHistory: async (req, res) => {
try {
const { itemId } = req.params;
const { getDb } = require('../dbPool');
const db = await getDb();
const [history] = await db.query(`
SELECT h.*, u.full_name as changed_by_name
FROM zone_item_history h
LEFT JOIN users u ON h.changed_by = u.user_id
WHERE h.item_id = ?
ORDER BY h.changed_at DESC
LIMIT 50
`, [itemId]);
res.json({ success: true, data: history });
} catch (error) {
console.error('현황 이력 조회 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
} }
}; };

View File

@@ -21,12 +21,7 @@ exports.createProject = asyncHandler(async (req, res) => {
logger.info('프로젝트 생성 요청', { name: projectData.name }); logger.info('프로젝트 생성 요청', { name: projectData.name });
const id = await new Promise((resolve, reject) => { const id = await projectModel.create(projectData);
projectModel.create(projectData, (err, lastID) => {
if (err) reject(new DatabaseError('프로젝트 생성 중 오류가 발생했습니다'));
else resolve(lastID);
});
});
// 프로젝트 캐시 무효화 // 프로젝트 캐시 무효화
await cache.invalidateCache.project(); await cache.invalidateCache.project();
@@ -44,12 +39,7 @@ exports.createProject = asyncHandler(async (req, res) => {
* 전체 프로젝트 조회 * 전체 프로젝트 조회
*/ */
exports.getAllProjects = asyncHandler(async (req, res) => { exports.getAllProjects = asyncHandler(async (req, res) => {
const rows = await new Promise((resolve, reject) => { const rows = await projectModel.getAll();
projectModel.getAll((err, data) => {
if (err) reject(new DatabaseError('프로젝트 목록 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
res.json({ res.json({
success: true, success: true,
@@ -62,12 +52,7 @@ exports.getAllProjects = asyncHandler(async (req, res) => {
* 활성 프로젝트만 조회 (작업보고서용) * 활성 프로젝트만 조회 (작업보고서용)
*/ */
exports.getActiveProjects = asyncHandler(async (req, res) => { exports.getActiveProjects = asyncHandler(async (req, res) => {
const rows = await new Promise((resolve, reject) => { const rows = await projectModel.getActiveProjects();
projectModel.getActiveProjects((err, data) => {
if (err) reject(new DatabaseError('활성 프로젝트 목록 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
res.json({ res.json({
success: true, success: true,
@@ -86,12 +71,7 @@ exports.getProjectById = asyncHandler(async (req, res) => {
throw new ValidationError('유효하지 않은 프로젝트 ID입니다'); throw new ValidationError('유효하지 않은 프로젝트 ID입니다');
} }
const row = await new Promise((resolve, reject) => { const row = await projectModel.getById(id);
projectModel.getById(id, (err, data) => {
if (err) reject(new DatabaseError('프로젝트 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
if (!row) { if (!row) {
throw new NotFoundError('프로젝트를 찾을 수 없습니다'); throw new NotFoundError('프로젝트를 찾을 수 없습니다');
@@ -116,12 +96,7 @@ exports.updateProject = asyncHandler(async (req, res) => {
const data = { ...req.body, project_id: id }; const data = { ...req.body, project_id: id };
const changes = await new Promise((resolve, reject) => { const changes = await projectModel.update(data);
projectModel.update(data, (err, ch) => {
if (err) reject(new DatabaseError('프로젝트 수정 중 오류가 발생했습니다'));
else resolve(ch);
});
});
if (changes === 0) { if (changes === 0) {
throw new NotFoundError('프로젝트를 찾을 수 없습니다'); throw new NotFoundError('프로젝트를 찾을 수 없습니다');
@@ -149,12 +124,7 @@ exports.removeProject = asyncHandler(async (req, res) => {
throw new ValidationError('유효하지 않은 프로젝트 ID입니다'); throw new ValidationError('유효하지 않은 프로젝트 ID입니다');
} }
const changes = await new Promise((resolve, reject) => { const changes = await projectModel.remove(id);
projectModel.remove(id, (err, ch) => {
if (err) reject(new DatabaseError('프로젝트 삭제 중 오류가 발생했습니다'));
else resolve(ch);
});
});
if (changes === 0) { if (changes === 0) {
throw new NotFoundError('프로젝트를 찾을 수 없습니다'); throw new NotFoundError('프로젝트를 찾을 수 없습니다');

View File

@@ -27,12 +27,7 @@ exports.createTask = asyncHandler(async (req, res) => {
logger.info('작업 생성 요청', { name: taskData.task_name }); logger.info('작업 생성 요청', { name: taskData.task_name });
const id = await new Promise((resolve, reject) => { const id = await taskModel.createTask(taskData);
taskModel.createTask(taskData, (err, lastID) => {
if (err) reject(new DatabaseError('작업 생성 중 오류가 발생했습니다'));
else resolve(lastID);
});
});
logger.info('작업 생성 성공', { task_id: id }); logger.info('작업 생성 성공', { task_id: id });
@@ -44,15 +39,18 @@ exports.createTask = asyncHandler(async (req, res) => {
}); });
/** /**
* 전체 작업 조회 * 전체 작업 조회 (work_type_id 필터 지원)
*/ */
exports.getAllTasks = asyncHandler(async (req, res) => { exports.getAllTasks = asyncHandler(async (req, res) => {
const rows = await new Promise((resolve, reject) => { const { work_type_id } = req.query;
taskModel.getAllTasks((err, data) => {
if (err) reject(new DatabaseError('작업 목록 조회 중 오류가 발생했습니다')); let rows;
else resolve(data); if (work_type_id) {
}); // 특정 공정의 활성 작업만 조회
}); rows = await taskModel.getTasksByWorkType(work_type_id);
} else {
rows = await taskModel.getAllTasks();
}
res.json({ res.json({
success: true, success: true,
@@ -65,12 +63,7 @@ exports.getAllTasks = asyncHandler(async (req, res) => {
* 활성 작업만 조회 * 활성 작업만 조회
*/ */
exports.getActiveTasks = asyncHandler(async (req, res) => { exports.getActiveTasks = asyncHandler(async (req, res) => {
const rows = await new Promise((resolve, reject) => { const rows = await taskModel.getActiveTasks();
taskModel.getActiveTasks((err, data) => {
if (err) reject(new DatabaseError('활성 작업 목록 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
res.json({ res.json({
success: true, success: true,
@@ -89,12 +82,7 @@ exports.getTasksByWorkType = asyncHandler(async (req, res) => {
throw new ValidationError('공정 ID가 필요합니다'); throw new ValidationError('공정 ID가 필요합니다');
} }
const rows = await new Promise((resolve, reject) => { const rows = await taskModel.getTasksByWorkType(workTypeId);
taskModel.getTasksByWorkType(workTypeId, (err, data) => {
if (err) reject(new DatabaseError('공정별 작업 목록 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
res.json({ res.json({
success: true, success: true,
@@ -109,12 +97,7 @@ exports.getTasksByWorkType = asyncHandler(async (req, res) => {
exports.getTaskById = asyncHandler(async (req, res) => { exports.getTaskById = asyncHandler(async (req, res) => {
const taskId = req.params.id; const taskId = req.params.id;
const task = await new Promise((resolve, reject) => { const task = await taskModel.getTaskById(taskId);
taskModel.getTaskById(taskId, (err, data) => {
if (err) reject(new DatabaseError('작업 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
if (!task) { if (!task) {
throw new NotFoundError('작업을 찾을 수 없습니다'); throw new NotFoundError('작업을 찾을 수 없습니다');
@@ -140,12 +123,7 @@ exports.updateTask = asyncHandler(async (req, res) => {
logger.info('작업 수정 요청', { task_id: taskId }); logger.info('작업 수정 요청', { task_id: taskId });
await new Promise((resolve, reject) => { await taskModel.updateTask(taskId, taskData);
taskModel.updateTask(taskId, taskData, (err, result) => {
if (err) reject(new DatabaseError('작업 수정 중 오류가 발생했습니다'));
else resolve(result);
});
});
logger.info('작업 수정 성공', { task_id: taskId }); logger.info('작업 수정 성공', { task_id: taskId });
@@ -163,12 +141,7 @@ exports.deleteTask = asyncHandler(async (req, res) => {
logger.info('작업 삭제 요청', { task_id: taskId }); logger.info('작업 삭제 요청', { task_id: taskId });
await new Promise((resolve, reject) => { await taskModel.deleteTask(taskId);
taskModel.deleteTask(taskId, (err, result) => {
if (err) reject(new DatabaseError('작업 삭제 중 오류가 발생했습니다'));
else resolve(result);
});
});
logger.info('작업 삭제 성공', { task_id: taskId }); logger.info('작업 삭제 성공', { task_id: taskId });

View File

@@ -322,6 +322,70 @@ const vacationBalanceController = {
} }
}, },
/**
* 휴가 잔액 일괄 저장 (upsert)
* POST /api/vacation-balances/bulk-upsert
*/
async bulkUpsert(req, res) {
try {
const { balances } = req.body;
const created_by = req.user.user_id;
if (!balances || !Array.isArray(balances) || balances.length === 0) {
return res.status(400).json({
success: false,
message: '저장할 데이터가 없습니다'
});
}
const { getDb } = require('../dbPool');
const db = await getDb();
let successCount = 0;
let errorCount = 0;
for (const balance of balances) {
const { worker_id, vacation_type_id, year, total_days, notes } = balance;
if (!worker_id || !vacation_type_id || !year || total_days === undefined) {
errorCount++;
continue;
}
try {
// Upsert 쿼리
const query = `
INSERT INTO vacation_balance_details
(worker_id, vacation_type_id, year, total_days, used_days, notes, created_by)
VALUES (?, ?, ?, ?, 0, ?, ?)
ON DUPLICATE KEY UPDATE
total_days = VALUES(total_days),
notes = VALUES(notes),
updated_at = NOW()
`;
await db.query(query, [worker_id, vacation_type_id, year, total_days, notes || null, created_by]);
successCount++;
} catch (err) {
console.error('휴가 잔액 저장 오류:', err);
errorCount++;
}
}
res.json({
success: true,
message: `${successCount}건 저장 완료${errorCount > 0 ? `, ${errorCount}건 실패` : ''}`,
data: { successCount, errorCount }
});
} catch (error) {
console.error('bulkUpsert 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/** /**
* 작업자의 사용 가능한 휴가 일수 조회 * 작업자의 사용 가능한 휴가 일수 조회
* GET /api/vacation-balances/worker/:workerId/year/:year/available * GET /api/vacation-balances/worker/:workerId/year/:year/available

View File

@@ -26,12 +26,7 @@ exports.createWorker = asyncHandler(async (req, res) => {
logger.info('작업자 생성 요청', { name: workerData.worker_name, create_account: createAccount }); logger.info('작업자 생성 요청', { name: workerData.worker_name, create_account: createAccount });
const lastID = await new Promise((resolve, reject) => { const lastID = await workerModel.create(workerData);
workerModel.create(workerData, (err, id) => {
if (err) reject(new DatabaseError('작업자 생성 중 오류가 발생했습니다'));
else resolve(id);
});
});
// 계정 생성 요청이 있으면 users 테이블에 계정 생성 // 계정 생성 요청이 있으면 users 테이블에 계정 생성
if (createAccount && workerData.worker_name) { if (createAccount && workerData.worker_name) {
@@ -73,7 +68,7 @@ exports.createWorker = asyncHandler(async (req, res) => {
* 전체 작업자 조회 (캐싱 및 페이지네이션 적용) * 전체 작업자 조회 (캐싱 및 페이지네이션 적용)
*/ */
exports.getAllWorkers = asyncHandler(async (req, res) => { exports.getAllWorkers = asyncHandler(async (req, res) => {
const { page = 1, limit = 10, search = '', status = '', department_id = null } = req.query; const { page = 1, limit = 100, search = '', status = '', department_id = null } = req.query;
const cacheKey = cache.createKey('workers', 'list', page, limit, search, status, department_id); const cacheKey = cache.createKey('workers', 'list', page, limit, search, status, department_id);
@@ -114,12 +109,7 @@ exports.getWorkerById = asyncHandler(async (req, res) => {
throw new ValidationError('유효하지 않은 작업자 ID입니다'); throw new ValidationError('유효하지 않은 작업자 ID입니다');
} }
const row = await new Promise((resolve, reject) => { const row = await workerModel.getById(id);
workerModel.getById(id, (err, data) => {
if (err) reject(new DatabaseError('작업자 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
if (!row) { if (!row) {
throw new NotFoundError('작업자를 찾을 수 없습니다'); throw new NotFoundError('작업자를 찾을 수 없습니다');
@@ -153,27 +143,14 @@ exports.updateWorker = asyncHandler(async (req, res) => {
}); });
// 먼저 현재 작업자 정보 조회 (계정 여부 확인용) // 먼저 현재 작업자 정보 조회 (계정 여부 확인용)
const currentWorker = await new Promise((resolve, reject) => { const currentWorker = await workerModel.getById(id);
workerModel.getById(id, (err, data) => {
if (err) reject(new DatabaseError('작업자 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
if (!currentWorker) { if (!currentWorker) {
throw new NotFoundError('작업자를 찾을 수 없습니다'); throw new NotFoundError('작업자를 찾을 수 없습니다');
} }
// 작업자 정보 업데이트 // 작업자 정보 업데이트
const changes = await new Promise((resolve, reject) => { const changes = await workerModel.update(workerData);
workerModel.update(workerData, (err, affected) => {
if (err) {
console.error('❌ workerModel.update 에러:', err);
reject(new DatabaseError(`작업자 수정 중 오류가 발생했습니다: ${err.message}`));
}
else resolve(affected);
});
});
// 계정 생성/해제 처리 // 계정 생성/해제 처리
const db = await getDb(); const db = await getDb();
@@ -281,12 +258,7 @@ exports.removeWorker = asyncHandler(async (req, res) => {
throw new ValidationError('유효하지 않은 작업자 ID입니다'); throw new ValidationError('유효하지 않은 작업자 ID입니다');
} }
const changes = await new Promise((resolve, reject) => { const changes = await workerModel.remove(id);
workerModel.remove(id, (err, affected) => {
if (err) reject(new DatabaseError('작업자 삭제 중 오류가 발생했습니다'));
else resolve(affected);
});
});
if (changes === 0) { if (changes === 0) {
throw new NotFoundError('작업자를 찾을 수 없습니다'); throw new NotFoundError('작업자를 찾을 수 없습니다');

View File

@@ -0,0 +1,105 @@
/**
* 마이그레이션: TBM 기반 작업보고서의 work_type_id를 task_id로 수정
*
* 문제: TBM에서 작업보고서 생성 시 work_type_id(공정 ID)가 저장됨
* 해결: tbm_team_assignments 테이블의 task_id로 업데이트
*
* 실행: node db/migrations/20260205_fix_work_type_id_data.js
*/
const { getDb } = require('../../dbPool');
async function migrate() {
const db = await getDb();
console.log('🔄 TBM 기반 작업보고서 work_type_id 수정 시작...\n');
try {
// 1. 수정 대상 확인 (TBM 기반이면서 work_type_id가 task_id와 다른 경우)
const [checkResult] = await db.query(`
SELECT
dwr.id,
dwr.work_type_id as current_work_type_id,
ta.task_id as correct_task_id,
ta.work_type_id as tbm_work_type_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}개 레코드\n`);
if (checkResult.length === 0) {
console.log('✅ 수정할 데이터가 없습니다.');
return;
}
// 수정 대상 샘플 출력
console.log('📋 수정 대상 샘플 (최대 10개):');
console.log('─'.repeat(80));
checkResult.slice(0, 10).forEach(row => {
console.log(` ID: ${row.id} | ${row.worker_name} | ${row.report_date}`);
console.log(` 현재 work_type_id: ${row.current_work_type_id} → 올바른 task_id: ${row.correct_task_id}`);
});
if (checkResult.length > 10) {
console.log(` ... 외 ${checkResult.length - 10}`);
}
console.log('─'.repeat(80));
// 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(`\n✅ 업데이트 완료: ${updateResult.affectedRows}개 레코드 수정됨`);
// 3. 수정 결과 확인
const [verifyResult] = await db.query(`
SELECT
dwr.id,
dwr.work_type_id,
ta.task_id,
t.task_name,
wt.name as work_type_name
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
WHERE dwr.tbm_assignment_id IS NOT NULL
LIMIT 5
`);
console.log('\n📋 수정 후 샘플 확인:');
console.log('─'.repeat(80));
verifyResult.forEach(row => {
console.log(` ID: ${row.id} | work_type_id: ${row.work_type_id} | task: ${row.task_name || 'N/A'} | 공정: ${row.work_type_name || 'N/A'}`);
});
console.log('─'.repeat(80));
} catch (error) {
console.error('❌ 마이그레이션 실패:', error.message);
throw error;
}
}
// 실행
migrate()
.then(() => {
console.log('\n🎉 마이그레이션 완료!');
process.exit(0);
})
.catch(err => {
console.error('\n💥 마이그레이션 실패:', err);
process.exit(1);
});

View File

@@ -0,0 +1,56 @@
-- ============================================
-- error_types → issue_report_items 마이그레이션
-- 실행 전 반드시 백업하세요!
-- ============================================
-- STEP 1: 현재 상태 확인
-- ============================================
SELECT 'Before Migration' as status;
SELECT error_type_id, COUNT(*) as cnt FROM daily_work_reports WHERE error_type_id IS NOT NULL GROUP BY error_type_id;
-- STEP 2: 매핑 업데이트 실행
-- ============================================
-- 주의: 순서가 중요! (충돌 방지를 위해 큰 숫자부터)
-- 6 (검사불량) → 14 (치수 검사 누락)
UPDATE daily_work_reports SET error_type_id = 14 WHERE error_type_id = 6;
-- 5 (설비고장) → 38 (기계 고장)
UPDATE daily_work_reports SET error_type_id = 38 WHERE error_type_id = 5;
-- 4 (작업불량) → 43 (NDE 불합격)
UPDATE daily_work_reports SET error_type_id = 43 WHERE error_type_id = 4;
-- 3 (입고지연) → 1 (배관 자재 미입고) - 이미 1이므로 충돌 가능, 임시값 사용
UPDATE daily_work_reports SET error_type_id = 99991 WHERE error_type_id = 3;
-- 2 (외주작업 불량) → 10 (외관 불량)
UPDATE daily_work_reports SET error_type_id = 10 WHERE error_type_id = 2;
-- 1 (설계미스) → 6 (도면 치수 오류) - 6은 이미 업데이트됨
UPDATE daily_work_reports SET error_type_id = 6 WHERE error_type_id = 1;
-- 임시값 복원: 99991 → 1
UPDATE daily_work_reports SET error_type_id = 1 WHERE error_type_id = 99991;
-- STEP 3: 마이그레이션 결과 확인
-- ============================================
SELECT 'After Migration' as status;
SELECT
dwr.error_type_id,
iri.item_name,
irc.category_name,
COUNT(*) as cnt
FROM daily_work_reports dwr
LEFT JOIN issue_report_items iri ON dwr.error_type_id = iri.item_id
LEFT JOIN issue_report_categories irc ON iri.category_id = irc.category_id
WHERE dwr.error_type_id IS NOT NULL
GROUP BY dwr.error_type_id, iri.item_name, irc.category_name;
-- STEP 4: error_types 테이블 삭제 (선택사항)
-- ============================================
-- 마이그레이션 확인 후 주석 해제하여 실행
-- DROP TABLE IF EXISTS error_types;

View File

@@ -0,0 +1,112 @@
/**
* 마이그레이션: error_types에서 issue_report_items로 전환
*
* 기존 daily_work_reports.error_type_id가 error_types.id를 참조하던 것을
* issue_report_items.item_id를 참조하도록 변경
*
* 기존 error_types 데이터:
* id=1: 설계미스
* id=2: 외주작업 불량
* id=3: 입고지연
*
* 새 issue_report_categories 데이터:
* category_id=1: 자재누락 (nonconformity)
* category_id=2: 설계미스 (nonconformity)
* category_id=3: 입고불량 (nonconformity)
*
* 매핑 전략:
* - error_types.id=1 (설계미스) → issue_report_items에서 '설계미스' 카테고리의 첫 번째 항목
* - error_types.id=2 (외주작업 불량) → issue_report_items에서 '입고불량' 카테고리의 '외관 불량' 항목
* - error_types.id=3 (입고지연) → issue_report_items에서 '자재누락' 카테고리의 첫 번째 항목
*/
exports.up = async function(knex) {
console.log('=== error_type_id 마이그레이션 시작 ===');
// 1. 기존 error_types 데이터와 새 issue_report_items 매핑 테이블 조회
const [categories] = await knex.raw(`
SELECT category_id, category_name
FROM issue_report_categories
WHERE category_type = 'nonconformity'
`);
console.log('부적합 카테고리:', categories);
const [items] = await knex.raw(`
SELECT iri.item_id, iri.item_name, iri.category_id, irc.category_name
FROM issue_report_items iri
JOIN issue_report_categories irc ON iri.category_id = irc.category_id
WHERE irc.category_type = 'nonconformity'
ORDER BY iri.category_id, iri.display_order
`);
console.log('부적합 항목:', items);
// 2. 매핑 정의 (기존 error_type_id → 새 issue_report_items.item_id)
// 설계미스 카테고리 찾기
const designMissCategory = categories.find(c => c.category_name === '설계미스');
const incomingDefectCategory = categories.find(c => c.category_name === '입고불량');
const materialShortageCategory = categories.find(c => c.category_name === '자재누락');
// 각 카테고리의 첫 번째 항목 찾기
const designMissItem = items.find(i => i.category_id === designMissCategory?.category_id);
const incomingDefectItem = items.find(i => i.category_id === incomingDefectCategory?.category_id);
const materialShortageItem = items.find(i => i.category_id === materialShortageCategory?.category_id);
console.log('매핑 결과:');
console.log(' - 설계미스(1) → item_id:', designMissItem?.item_id);
console.log(' - 외주작업불량(2) → item_id:', incomingDefectItem?.item_id);
console.log(' - 입고지연(3) → item_id:', materialShortageItem?.item_id);
// 3. 기존 데이터 업데이트
if (designMissItem) {
const [result1] = await knex.raw(`
UPDATE daily_work_reports
SET error_type_id = ?
WHERE error_type_id = 1
`, [designMissItem.item_id]);
console.log('설계미스(1) 업데이트:', result1.affectedRows, '건');
}
if (incomingDefectItem) {
const [result2] = await knex.raw(`
UPDATE daily_work_reports
SET error_type_id = ?
WHERE error_type_id = 2
`, [incomingDefectItem.item_id]);
console.log('외주작업불량(2) 업데이트:', result2.affectedRows, '건');
}
if (materialShortageItem) {
const [result3] = await knex.raw(`
UPDATE daily_work_reports
SET error_type_id = ?
WHERE error_type_id = 3
`, [materialShortageItem.item_id]);
console.log('입고지연(3) 업데이트:', result3.affectedRows, '건');
}
// 4. 매핑 안된 나머지 데이터 확인 (4 이상의 error_type_id)
const [unmapped] = await knex.raw(`
SELECT DISTINCT error_type_id, COUNT(*) as cnt
FROM daily_work_reports
WHERE error_type_id IS NOT NULL
AND error_type_id NOT IN (?, ?, ?)
GROUP BY error_type_id
`, [
designMissItem?.item_id || 0,
incomingDefectItem?.item_id || 0,
materialShortageItem?.item_id || 0
]);
if (unmapped.length > 0) {
console.log('⚠️ 매핑되지 않은 error_type_id 발견:', unmapped);
console.log(' 이 데이터는 수동으로 확인 필요');
}
console.log('=== error_type_id 마이그레이션 완료 ===');
};
exports.down = async function(knex) {
// 롤백은 복잡하므로 로그만 출력
console.log('⚠️ 이 마이그레이션은 자동 롤백을 지원하지 않습니다.');
console.log(' 데이터 복구가 필요한 경우 백업에서 복원해주세요.');
};

View File

@@ -0,0 +1,73 @@
-- ============================================
-- error_types → issue_report_items 마이그레이션
-- ============================================
-- STEP 1: 현재 데이터 확인
-- ============================================
-- 기존 error_types 확인
SELECT * FROM error_types;
-- 새 issue_report_categories 확인 (부적합만)
SELECT * FROM issue_report_categories WHERE category_type = 'nonconformity';
-- 새 issue_report_items 확인 (부적합만)
SELECT
iri.item_id,
iri.item_name,
iri.category_id,
irc.category_name
FROM issue_report_items iri
JOIN issue_report_categories irc ON iri.category_id = irc.category_id
WHERE irc.category_type = 'nonconformity'
ORDER BY irc.display_order, iri.display_order;
-- 현재 daily_work_reports에서 사용 중인 error_type_id 확인
SELECT
error_type_id,
COUNT(*) as cnt,
et.name as old_error_name
FROM daily_work_reports dwr
LEFT JOIN error_types et ON dwr.error_type_id = et.id
WHERE error_type_id IS NOT NULL
GROUP BY error_type_id
ORDER BY error_type_id;
-- STEP 2: 매핑 업데이트 (실제 item_id 확인 후 수정 필요!)
-- ============================================
-- 먼저 위 쿼리로 실제 item_id 값을 확인하세요!
-- 아래는 예시입니다. 실제 값으로 수정해서 사용하세요.
-- 예시: 설계미스(error_type_id=1) → 설계미스 카테고리의 '도면 치수 오류' 항목
-- UPDATE daily_work_reports SET error_type_id = 6 WHERE error_type_id = 1;
-- 예시: 외주작업 불량(error_type_id=2) → 입고불량 카테고리의 '외관 불량' 항목
-- UPDATE daily_work_reports SET error_type_id = 10 WHERE error_type_id = 2;
-- 예시: 입고지연(error_type_id=3) → 자재누락 카테고리의 '배관 자재 미입고' 항목
-- UPDATE daily_work_reports SET error_type_id = 1 WHERE error_type_id = 3;
-- STEP 3: 매핑 검증
-- ============================================
-- 업데이트 후 확인
SELECT
dwr.error_type_id,
iri.item_name,
irc.category_name,
COUNT(*) as cnt
FROM daily_work_reports dwr
LEFT JOIN issue_report_items iri ON dwr.error_type_id = iri.item_id
LEFT JOIN issue_report_categories irc ON iri.category_id = irc.category_id
WHERE dwr.error_type_id IS NOT NULL
GROUP BY dwr.error_type_id, iri.item_name, irc.category_name;
-- STEP 4: error_types 테이블 삭제 (매핑 완료 후)
-- ============================================
-- 주의: 반드시 STEP 2, 3 완료 후 실행!
-- DROP TABLE IF EXISTS error_types;

View File

@@ -22,6 +22,222 @@ const PORT = process.env.PORT || 20005;
// Trust proxy for accurate IP addresses // Trust proxy for accurate IP addresses
app.set('trust proxy', 1); 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); setupMiddlewares(app);

View File

@@ -0,0 +1,201 @@
/**
* CSRF Protection Middleware
*
* Cross-Site Request Forgery 방지를 위한 토큰 기반 보호
*
* 구현 방식:
* 1. 서버에서 CSRF 토큰 생성 및 응답 헤더로 전송
* 2. 클라이언트는 state-changing 요청 시 토큰을 헤더에 포함
* 3. 서버에서 토큰 검증
*
* @author TK-FB-Project
* @since 2026-02-04
*/
const crypto = require('crypto');
const logger = require('../utils/logger');
// 토큰 저장소 (프로덕션에서는 Redis 사용 권장)
const tokenStore = new Map();
// 토큰 유효 시간 (1시간)
const TOKEN_EXPIRY = 60 * 60 * 1000;
// 토큰 정리 주기 (5분)
const CLEANUP_INTERVAL = 5 * 60 * 1000;
/**
* 만료된 토큰 정리
*/
const cleanupExpiredTokens = () => {
const now = Date.now();
for (const [token, data] of tokenStore.entries()) {
if (now > data.expiresAt) {
tokenStore.delete(token);
}
}
};
// 주기적 정리
setInterval(cleanupExpiredTokens, CLEANUP_INTERVAL);
/**
* CSRF 토큰 생성
*
* @param {string} sessionId - 세션 ID 또는 사용자 식별자
* @returns {string} 생성된 CSRF 토큰
*/
const generateToken = (sessionId) => {
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = Date.now() + TOKEN_EXPIRY;
tokenStore.set(token, {
sessionId,
expiresAt,
createdAt: Date.now()
});
return token;
};
/**
* CSRF 토큰 검증
*
* @param {string} token - 검증할 토큰
* @param {string} sessionId - 세션 ID
* @returns {boolean} 유효 여부
*/
const validateToken = (token, sessionId) => {
if (!token || !tokenStore.has(token)) {
return false;
}
const data = tokenStore.get(token);
// 만료 체크
if (Date.now() > data.expiresAt) {
tokenStore.delete(token);
return false;
}
// 세션 일치 체크
if (data.sessionId !== sessionId) {
return false;
}
return true;
};
/**
* CSRF 토큰을 응답 헤더에 설정하는 미들웨어
*
* @param {Object} req - Express request
* @param {Object} res - Express response
* @param {Function} next - Next middleware
*/
const setCsrfToken = (req, res, next) => {
// 세션 ID 또는 사용자 ID 사용
const sessionId = req.user?.user_id?.toString() || req.sessionID || req.ip;
// 새 토큰 생성
const token = generateToken(sessionId);
// 응답 헤더에 토큰 설정
res.setHeader('X-CSRF-Token', token);
// 요청 객체에 저장 (다른 미들웨어에서 사용 가능)
req.csrfToken = token;
next();
};
/**
* CSRF 토큰 검증 미들웨어
* POST, PUT, DELETE, PATCH 요청에 적용
*
* @param {Object} options - 옵션
* @param {string[]} options.ignoreMethods - 검증 제외 메서드 (기본: GET, HEAD, OPTIONS)
* @param {string[]} options.ignorePaths - 검증 제외 경로 (정규식 패턴 가능)
* @returns {Function} Express 미들웨어
*/
const verifyCsrfToken = (options = {}) => {
const {
ignoreMethods = ['GET', 'HEAD', 'OPTIONS'],
ignorePaths = ['/api/auth/login', '/api/auth/register', '/api/health']
} = options;
return (req, res, next) => {
// 제외 메서드 체크
if (ignoreMethods.includes(req.method)) {
return next();
}
// 제외 경로 체크
for (const pattern of ignorePaths) {
if (typeof pattern === 'string' && req.path === pattern) {
return next();
}
if (pattern instanceof RegExp && pattern.test(req.path)) {
return next();
}
}
// 토큰 추출 (헤더 또는 body에서)
const token = req.headers['x-csrf-token'] ||
req.headers['csrf-token'] ||
req.body?._csrf ||
req.query?._csrf;
// 세션 ID
const sessionId = req.user?.user_id?.toString() || req.sessionID || req.ip;
// 토큰 검증
if (!validateToken(token, sessionId)) {
logger.warn('CSRF 토큰 검증 실패', {
path: req.path,
method: req.method,
ip: req.ip,
hasToken: !!token
});
return res.status(403).json({
success: false,
error: 'CSRF 토큰이 유효하지 않습니다. 페이지를 새로고침 후 다시 시도해주세요.',
code: 'CSRF_TOKEN_INVALID'
});
}
// 사용된 토큰 제거 (일회성 사용)
tokenStore.delete(token);
// 새 토큰 발급
const newToken = generateToken(sessionId);
res.setHeader('X-CSRF-Token', newToken);
req.csrfToken = newToken;
next();
};
};
/**
* CSRF 토큰 발급 엔드포인트 핸들러
* GET /api/csrf-token
*/
const getCsrfToken = (req, res) => {
const sessionId = req.user?.user_id?.toString() || req.sessionID || req.ip;
const token = generateToken(sessionId);
res.json({
success: true,
csrfToken: token,
expiresIn: TOKEN_EXPIRY / 1000 // 초 단위
});
};
module.exports = {
generateToken,
validateToken,
setCsrfToken,
verifyCsrfToken,
getCsrfToken
};

View File

@@ -182,6 +182,10 @@ class WorkAnalysis {
// 최근 작업 현황 // 최근 작업 현황
async getRecentWork(startDate, endDate, limit = 50) { async getRecentWork(startDate, endDate, limit = 50) {
// work_type_id 컬럼에는 task_id가 저장됨 (tasks 테이블 우선 조회)
// task_id로 매칭되면 해당 task의 work_type_id로 공정(대분류) 조회
// 매칭 안 되면 직접 work_types 테이블 조회 (레거시 데이터 호환)
// error_type_id는 issue_report_items 테이블 참조 (issue-categories.html에서 관리)
const query = ` const query = `
SELECT SELECT
dwr.id, dwr.id,
@@ -191,12 +195,21 @@ class WorkAnalysis {
dwr.project_id, dwr.project_id,
p.project_name, p.project_name,
p.job_no, p.job_no,
dwr.work_type_id, dwr.work_type_id as original_work_type_id,
wt.name as work_type_name, COALESCE(
CASE WHEN t.task_id IS NOT NULL THEN t.work_type_id ELSE NULL END,
wt.id
) as 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 as task_name,
dwr.work_status_id, dwr.work_status_id,
wst.name as work_status_name, wst.name as work_status_name,
dwr.error_type_id, dwr.error_type_id,
et.name as error_type_name, iri.item_name as error_type_name,
irc.category_name as error_category_name,
dwr.work_hours, dwr.work_hours,
dwr.created_by, dwr.created_by,
u.name as created_by_name, u.name as created_by_name,
@@ -205,8 +218,11 @@ class WorkAnalysis {
LEFT JOIN workers w ON dwr.worker_id = w.worker_id LEFT JOIN workers w ON dwr.worker_id = w.worker_id
LEFT JOIN projects p ON dwr.project_id = p.project_id LEFT JOIN projects p ON dwr.project_id = p.project_id
LEFT JOIN work_types wt ON dwr.work_type_id = wt.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
LEFT JOIN work_status_types wst ON dwr.work_status_id = wst.id LEFT JOIN work_status_types wst ON dwr.work_status_id = wst.id
LEFT JOIN error_types et ON dwr.error_type_id = et.id LEFT JOIN issue_report_items iri ON dwr.error_type_id = iri.item_id
LEFT JOIN issue_report_categories irc ON iri.category_id = irc.category_id
LEFT JOIN users u ON dwr.created_by = u.user_id LEFT JOIN users u ON dwr.created_by = u.user_id
WHERE dwr.report_date BETWEEN ? AND ? WHERE dwr.report_date BETWEEN ? AND ?
ORDER BY dwr.created_at DESC ORDER BY dwr.created_at DESC
@@ -224,11 +240,13 @@ class WorkAnalysis {
project_name: row.project_name || `프로젝트 ${row.project_id}`, project_name: row.project_name || `프로젝트 ${row.project_id}`,
job_no: row.job_no || 'N/A', job_no: row.job_no || 'N/A',
work_type_id: row.work_type_id, work_type_id: row.work_type_id,
work_type_name: row.work_type_name || `작업유형 ${row.work_type_id}`, work_type_name: row.work_type_name || `작업유형 ${row.original_work_type_id}`,
task_name: row.task_name || null,
work_status_id: row.work_status_id, work_status_id: row.work_status_id,
work_status_name: row.work_status_name || '정상', work_status_name: row.work_status_name || '정상',
error_type_id: row.error_type_id, error_type_id: row.error_type_id,
error_type_name: row.error_type_name || null, error_type_name: row.error_type_name || null,
error_category_name: row.error_category_name || null,
work_hours: parseFloat(row.work_hours) || 0, work_hours: parseFloat(row.work_hours) || 0,
created_by: row.created_by, created_by: row.created_by,
created_by_name: row.created_by_name || '미지정', created_by_name: row.created_by_name || '미지정',
@@ -279,20 +297,23 @@ class WorkAnalysis {
} }
// 에러 분석 // 에러 분석
// error_type_id는 issue_report_items 테이블 참조 (issue-categories.html에서 관리)
async getErrorAnalysis(startDate, endDate) { async getErrorAnalysis(startDate, endDate) {
const query = ` const query = `
SELECT SELECT
dwr.error_type_id, dwr.error_type_id,
et.name as error_type_name, iri.item_name as error_type_name,
irc.category_name as error_category_name,
COUNT(*) as error_count, COUNT(*) as error_count,
SUM(dwr.work_hours) as total_hours, SUM(dwr.work_hours) as total_hours,
COUNT(DISTINCT dwr.worker_id) as affected_workers, COUNT(DISTINCT dwr.worker_id) as affected_workers,
COUNT(DISTINCT dwr.project_id) as affected_projects COUNT(DISTINCT dwr.project_id) as affected_projects
FROM daily_work_reports dwr FROM daily_work_reports dwr
LEFT JOIN error_types et ON dwr.error_type_id = et.id LEFT JOIN issue_report_items iri ON dwr.error_type_id = iri.item_id
LEFT JOIN issue_report_categories irc ON iri.category_id = irc.category_id
WHERE dwr.report_date BETWEEN ? AND ? WHERE dwr.report_date BETWEEN ? AND ?
AND dwr.work_status_id = 2 AND dwr.work_status_id = 2
GROUP BY dwr.error_type_id, et.name GROUP BY dwr.error_type_id, iri.item_name, irc.category_name
ORDER BY error_count DESC ORDER BY error_count DESC
`; `;
@@ -301,6 +322,7 @@ class WorkAnalysis {
return results.map(row => ({ return results.map(row => ({
error_type_id: row.error_type_id, error_type_id: row.error_type_id,
error_type_name: row.error_type_name || `에러유형 ${row.error_type_id}`, error_type_name: row.error_type_name || `에러유형 ${row.error_type_id}`,
error_category_name: row.error_category_name || null,
errorCount: parseInt(row.error_count) || 0, errorCount: parseInt(row.error_count) || 0,
totalHours: parseFloat(row.total_hours) || 0, totalHours: parseFloat(row.total_hours) || 0,
affectedworkers: parseInt(row.affected_workers) || 0, affectedworkers: parseInt(row.affected_workers) || 0,
@@ -427,15 +449,25 @@ class WorkAnalysis {
throw new Error(`대시보드 데이터 조회 실패: ${error.message}`); throw new Error(`대시보드 데이터 조회 실패: ${error.message}`);
} }
} }
// 프로젝트별-작업별 시간 분석용 데이터 조회 // 프로젝트별-작업별 시간 분석용 데이터 조회 (공정/대분류 기준)
async getProjectWorkTypeRawData(startDate, endDate) { async getProjectWorkTypeRawData(startDate, endDate) {
// work_type_id 컬럼에는 task_id가 저장됨 (tasks 테이블 우선 조회)
// task_id로 매칭되면 해당 task의 work_type_id로 공정 조회
// 매칭 안 되면 직접 work_types 조회 (레거시 데이터 호환)
const query = ` const query = `
SELECT SELECT
COALESCE(p.project_id, dwr.project_id) as project_id, COALESCE(p.project_id, dwr.project_id) as project_id,
COALESCE(p.project_name, CONCAT('프로젝트 ', dwr.project_id)) as project_name, COALESCE(p.project_name, CONCAT('프로젝트 ', dwr.project_id)) as project_name,
COALESCE(p.job_no, 'N/A') as job_no, COALESCE(p.job_no, 'N/A') as job_no,
dwr.work_type_id, COALESCE(
COALESCE(wt.name, CONCAT('작업유형 ', dwr.work_type_id)) as work_type_name, CASE WHEN t.task_id IS NOT NULL THEN t.work_type_id ELSE NULL END,
wt.id
) as work_type_id,
COALESCE(
CASE WHEN t.task_id IS NOT NULL THEN wt2.name ELSE NULL END,
wt.name,
CONCAT('작업유형 ', dwr.work_type_id)
) as work_type_name,
-- 총 시간 -- 총 시간
SUM(dwr.work_hours) as total_hours, SUM(dwr.work_hours) as total_hours,
@@ -460,9 +492,19 @@ class WorkAnalysis {
FROM daily_work_reports dwr FROM daily_work_reports dwr
LEFT JOIN projects p ON dwr.project_id = p.project_id LEFT JOIN projects p ON dwr.project_id = p.project_id
LEFT JOIN work_types wt ON dwr.work_type_id = wt.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 dwr.report_date BETWEEN ? AND ? WHERE dwr.report_date BETWEEN ? AND ?
GROUP BY dwr.project_id, p.project_name, p.job_no, dwr.work_type_id, wt.name GROUP BY dwr.project_id, p.project_name, p.job_no,
ORDER BY p.project_name, wt.name COALESCE(
CASE WHEN t.task_id IS NOT NULL THEN t.work_type_id ELSE NULL END,
wt.id
),
COALESCE(
CASE WHEN t.task_id IS NOT NULL THEN wt2.name ELSE NULL END,
wt.name
)
ORDER BY p.project_name, work_type_name
`; `;
try { try {

View File

@@ -201,13 +201,15 @@ class AttendanceModel {
const { const {
record_date, record_date,
worker_id, worker_id,
total_work_hours, total_work_hours = 8,
work_attendance_type_id, work_attendance_type_id = 1,
vacation_type_id, vacation_type_id = null,
is_overtime_approved, is_overtime_approved = false,
created_by created_by = 1
} = recordData; } = recordData;
const attendance_type_id = work_attendance_type_id;
// 기존 기록 확인 // 기존 기록 확인
const [existing] = await db.execute( const [existing] = await db.execute(
'SELECT id FROM daily_attendance_records WHERE worker_id = ? AND record_date = ?', 'SELECT id FROM daily_attendance_records WHERE worker_id = ? AND record_date = ?',
@@ -220,14 +222,14 @@ class AttendanceModel {
UPDATE daily_attendance_records UPDATE daily_attendance_records
SET SET
total_work_hours = ?, total_work_hours = ?,
work_attendance_type_id = ?, attendance_type_id = ?,
vacation_type_id = ?, vacation_type_id = ?,
is_overtime_approved = ?, is_overtime_approved = ?,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = ? WHERE id = ?
`, [ `, [
total_work_hours, total_work_hours,
work_attendance_type_id, attendance_type_id,
vacation_type_id, vacation_type_id,
is_overtime_approved, is_overtime_approved,
existing[0].id existing[0].id
@@ -238,14 +240,14 @@ class AttendanceModel {
// 생성 // 생성
const [result] = await db.execute(` const [result] = await db.execute(`
INSERT INTO daily_attendance_records ( INSERT INTO daily_attendance_records (
record_date, worker_id, total_work_hours, work_attendance_type_id, record_date, worker_id, total_work_hours, attendance_type_id,
vacation_type_id, is_overtime_approved, created_by vacation_type_id, is_overtime_approved, created_by
) VALUES (?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?)
`, [ `, [
record_date, record_date,
worker_id, worker_id,
total_work_hours, total_work_hours,
work_attendance_type_id, attendance_type_id,
vacation_type_id, vacation_type_id,
is_overtime_approved, is_overtime_approved,
created_by created_by
@@ -430,21 +432,23 @@ class AttendanceModel {
static async getMonthlyAttendanceStats(year, month, workerId = null) { static async getMonthlyAttendanceStats(year, month, workerId = null) {
const db = await getDb(); const db = await getDb();
// work_attendance_types: 1=NORMAL, 2=LATE, 3=EARLY_LEAVE, 4=ABSENT, 5=VACATION
// vacation_types: 1=ANNUAL(연차), 2=HALF_ANNUAL(반차), 3=SICK(병가), 4=SPECIAL(경조사)
let query = ` let query = `
SELECT SELECT
w.worker_id, w.worker_id,
w.worker_name, w.worker_name,
COUNT(CASE WHEN dar.status = 'complete' THEN 1 END) as regular_days, COUNT(CASE WHEN dar.attendance_type_id = 1 AND (dar.is_overtime_approved = 0 OR dar.is_overtime_approved IS NULL) THEN 1 END) as regular_days,
COUNT(CASE WHEN dar.status = 'overtime' THEN 1 END) as overtime_days, COUNT(CASE WHEN dar.is_overtime_approved = 1 OR dar.total_work_hours > 8 THEN 1 END) as overtime_days,
COUNT(CASE WHEN dar.status = 'vacation' THEN 1 END) as vacation_days, COUNT(CASE WHEN dar.attendance_type_id = 5 AND dar.vacation_type_id = 1 THEN 1 END) as vacation_days,
COUNT(CASE WHEN dar.status = 'partial' THEN 1 END) as partial_days, COUNT(CASE WHEN dar.vacation_type_id = 2 THEN 1 END) as partial_days,
COUNT(CASE WHEN dar.status = 'incomplete' THEN 1 END) as incomplete_days, COUNT(CASE WHEN dar.attendance_type_id = 4 THEN 1 END) as incomplete_days,
SUM(dar.total_work_hours) as total_work_hours, COALESCE(SUM(dar.total_work_hours), 0) as total_work_hours,
AVG(dar.total_work_hours) as avg_work_hours COALESCE(AVG(dar.total_work_hours), 0) as avg_work_hours
FROM workers w FROM workers w
LEFT JOIN daily_attendance_records dar ON w.worker_id = dar.worker_id LEFT JOIN daily_attendance_records dar ON w.worker_id = dar.worker_id
AND YEAR(dar.record_date) = ? AND MONTH(dar.record_date) = ? AND YEAR(dar.record_date) = ? AND MONTH(dar.record_date) = ?
WHERE w.is_active = TRUE WHERE w.employment_status = 'employed'
`; `;
const params = [year, month]; const params = [year, month];

View File

@@ -29,7 +29,21 @@ const getAllWorkStatusTypes = async (callback) => {
const getAllErrorTypes = async (callback) => { const getAllErrorTypes = async (callback) => {
try { try {
const db = await getDb(); const db = await getDb();
const [rows] = await db.query('SELECT id, name, description, severity, solution_guide, created_at, updated_at FROM error_types ORDER BY name ASC'); // issue_report_items에서 부적합(nonconformity) 타입의 항목만 조회
const [rows] = await db.query(`
SELECT
iri.item_id as id,
iri.item_name as name,
iri.description,
iri.severity,
irc.category_name as category,
iri.display_order,
iri.created_at
FROM issue_report_items iri
INNER JOIN issue_report_categories irc ON iri.category_id = irc.category_id
WHERE irc.category_type = 'nonconformity' AND iri.is_active = TRUE
ORDER BY irc.display_order, iri.display_order, iri.item_name ASC
`);
callback(null, rows); callback(null, rows);
} catch (err) { } catch (err) {
console.error('에러 유형 조회 오류:', err); console.error('에러 유형 조회 오류:', err);
@@ -301,6 +315,7 @@ const removeSpecificEntry = async (entry_id, deleted_by, callback) => {
/** /**
* 공통 SELECT 쿼리 부분 * 공통 SELECT 쿼리 부분
* error_type_id는 issue_report_items 테이블 참조 (issue-categories.html에서 관리)
*/ */
const getSelectQuery = () => ` const getSelectQuery = () => `
SELECT SELECT
@@ -317,7 +332,8 @@ const getSelectQuery = () => `
p.project_name, p.project_name,
wt.name as work_type_name, wt.name as work_type_name,
wst.name as work_status_name, wst.name as work_status_name,
et.name as error_type_name, iri.item_name as error_type_name,
irc.category_name as error_category_name,
u.name as created_by_name, u.name as created_by_name,
dwr.created_at, dwr.created_at,
dwr.updated_at dwr.updated_at
@@ -326,7 +342,8 @@ const getSelectQuery = () => `
LEFT JOIN projects p ON dwr.project_id = p.project_id LEFT JOIN projects p ON dwr.project_id = p.project_id
LEFT JOIN work_types wt ON dwr.work_type_id = wt.id LEFT JOIN work_types wt ON dwr.work_type_id = wt.id
LEFT JOIN work_status_types wst ON dwr.work_status_id = wst.id LEFT JOIN work_status_types wst ON dwr.work_status_id = wst.id
LEFT JOIN error_types et ON dwr.error_type_id = et.id LEFT JOIN issue_report_items iri ON dwr.error_type_id = iri.item_id
LEFT JOIN issue_report_categories irc ON iri.category_id = irc.category_id
LEFT JOIN users u ON dwr.created_by = u.user_id LEFT JOIN users u ON dwr.created_by = u.user_id
`; `;
@@ -873,6 +890,8 @@ const createReportEntries = async ({ report_date, worker_id, entries }) => {
/** /**
* [V2] 공통 SELECT 쿼리 (새로운 스키마 기준) * [V2] 공통 SELECT 쿼리 (새로운 스키마 기준)
* 주의: work_type_id 컬럼에는 실제로 task_id가 저장됨
* error_type_id는 issue_report_items 테이블 참조 (issue-categories.html에서 관리)
*/ */
const getSelectQueryV2 = () => ` const getSelectQueryV2 = () => `
SELECT SELECT
@@ -887,17 +906,21 @@ const getSelectQueryV2 = () => `
dwr.created_by, dwr.created_by,
w.worker_name, w.worker_name,
p.project_name, p.project_name,
t.task_name,
wt.name as work_type_name, wt.name as work_type_name,
wst.name as work_status_name, wst.name as work_status_name,
et.name as error_type_name, iri.item_name as error_type_name,
irc.category_name as error_category_name,
u.name as created_by_name, u.name as created_by_name,
dwr.created_at dwr.created_at
FROM daily_work_reports dwr FROM daily_work_reports dwr
LEFT JOIN workers w ON dwr.worker_id = w.worker_id LEFT JOIN workers w ON dwr.worker_id = w.worker_id
LEFT JOIN projects p ON dwr.project_id = p.project_id LEFT JOIN projects p ON dwr.project_id = p.project_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 wt ON t.work_type_id = wt.id
LEFT JOIN work_status_types wst ON dwr.work_status_id = wst.id LEFT JOIN work_status_types wst ON dwr.work_status_id = wst.id
LEFT JOIN error_types et ON dwr.error_type_id = et.id LEFT JOIN issue_report_items iri ON dwr.error_type_id = iri.item_id
LEFT JOIN issue_report_categories irc ON iri.category_id = irc.category_id
LEFT JOIN users u ON dwr.created_by = u.user_id LEFT JOIN users u ON dwr.created_by = u.user_id
`; `;
@@ -967,9 +990,9 @@ const updateReportById = async (reportId, updateData) => {
} }
} }
// updated_by_user_id는 항상 업데이트 // updated_by는 항상 업데이트
if (updateData.updated_by_user_id) { if (updateData.updated_by_user_id) {
setClauses.push('updated_by_user_id = ?'); setClauses.push('updated_by = ?');
queryParams.push(updateData.updated_by_user_id); queryParams.push(updateData.updated_by_user_id);
} }

View File

@@ -1,53 +1,37 @@
const { getDb } = require('../dbPool'); const { getDb } = require('../dbPool');
// CREATE // CREATE
const create = async (type, callback) => { const create = async (type) => {
try {
const db = await getDb(); const db = await getDb();
const [result] = await db.query( const [result] = await db.query(
`INSERT INTO IssueTypes (category, subcategory) VALUES (?, ?)`, `INSERT INTO IssueTypes (category, subcategory) VALUES (?, ?)`,
[type.category, type.subcategory] [type.category, type.subcategory]
); );
callback(null, result.insertId); return result.insertId;
} catch (err) {
callback(err);
}
}; };
// READ ALL // READ ALL
const getAll = async (callback) => { const getAll = async () => {
try {
const db = await getDb(); const db = await getDb();
const [rows] = await db.query(`SELECT issue_type_id, category, subcategory FROM IssueTypes ORDER BY category, subcategory`); const [rows] = await db.query(`SELECT issue_type_id, category, subcategory FROM IssueTypes ORDER BY category, subcategory`);
callback(null, rows); return rows;
} catch (err) {
callback(err);
}
}; };
// UPDATE // UPDATE
const update = async (id, type, callback) => { const update = async (id, type) => {
try {
const db = await getDb(); const db = await getDb();
const [result] = await db.query( const [result] = await db.query(
`UPDATE IssueTypes SET category = ?, subcategory = ? WHERE id = ?`, `UPDATE IssueTypes SET category = ?, subcategory = ? WHERE id = ?`,
[type.category, type.subcategory, id] [type.category, type.subcategory, id]
); );
callback(null, result.affectedRows); return result.affectedRows;
} catch (err) {
callback(err);
}
}; };
// DELETE // DELETE
const remove = async (id, callback) => { const remove = async (id) => {
try {
const db = await getDb(); const db = await getDb();
const [result] = await db.query(`DELETE FROM IssueTypes WHERE id = ?`, [id]); const [result] = await db.query(`DELETE FROM IssueTypes WHERE id = ?`, [id]);
callback(null, result.affectedRows); return result.affectedRows;
} catch (err) {
callback(err);
}
}; };
module.exports = { module.exports = {

View File

@@ -1,7 +1,6 @@
const { getDb } = require('../dbPool'); const { getDb } = require('../dbPool');
const create = async (project, callback) => { const create = async (project) => {
try {
const db = await getDb(); const db = await getDb();
const { const {
job_no, project_name, job_no, project_name,
@@ -19,54 +18,38 @@ const create = async (project, callback) => {
[job_no, project_name, contract_date, due_date, delivery_method, site, pm, is_active, project_status, completed_date] [job_no, project_name, contract_date, due_date, delivery_method, site, pm, is_active, project_status, completed_date]
); );
callback(null, result.insertId); return result.insertId;
} catch (err) {
callback(err);
}
}; };
const getAll = async (callback) => { const getAll = async () => {
try {
const db = await getDb(); const db = await getDb();
const [rows] = await db.query( const [rows] = await db.query(
`SELECT project_id, job_no, project_name, contract_date, due_date, delivery_method, site, pm, is_active, project_status, completed_date, created_at, updated_at FROM projects ORDER BY project_id DESC` `SELECT project_id, job_no, project_name, contract_date, due_date, delivery_method, site, pm, is_active, project_status, completed_date, created_at, updated_at FROM projects ORDER BY project_id DESC`
); );
callback(null, rows); return rows;
} catch (err) {
callback(err);
}
}; };
// 활성 프로젝트만 조회 (작업보고서용) // 활성 프로젝트만 조회 (작업보고서용)
const getActiveProjects = async (callback) => { const getActiveProjects = async () => {
try {
const db = await getDb(); const db = await getDb();
const [rows] = await db.query( const [rows] = await db.query(
`SELECT project_id, job_no, project_name, contract_date, due_date, delivery_method, site, pm, is_active, project_status, completed_date, created_at, updated_at FROM projects `SELECT project_id, job_no, project_name, contract_date, due_date, delivery_method, site, pm, is_active, project_status, completed_date, created_at, updated_at FROM projects
WHERE is_active = TRUE WHERE is_active = TRUE
ORDER BY project_name ASC` ORDER BY project_name ASC`
); );
callback(null, rows); return rows;
} catch (err) {
callback(err);
}
}; };
const getById = async (project_id, callback) => { const getById = async (project_id) => {
try {
const db = await getDb(); const db = await getDb();
const [rows] = await db.query( const [rows] = await db.query(
`SELECT project_id, job_no, project_name, contract_date, due_date, delivery_method, site, pm, is_active, project_status, completed_date, created_at, updated_at FROM projects WHERE project_id = ?`, `SELECT project_id, job_no, project_name, contract_date, due_date, delivery_method, site, pm, is_active, project_status, completed_date, created_at, updated_at FROM projects WHERE project_id = ?`,
[project_id] [project_id]
); );
callback(null, rows[0]); return rows[0];
} catch (err) {
callback(err);
}
}; };
const update = async (project, callback) => { const update = async (project) => {
try {
const db = await getDb(); const db = await getDb();
const { const {
project_id, job_no, project_name, project_id, job_no, project_name,
@@ -91,23 +74,16 @@ const update = async (project, callback) => {
[job_no, project_name, contract_date, due_date, delivery_method, site, pm, is_active, project_status, completed_date, project_id] [job_no, project_name, contract_date, due_date, delivery_method, site, pm, is_active, project_status, completed_date, project_id]
); );
callback(null, result.affectedRows); return result.affectedRows;
} catch (err) {
callback(new Error(err.message || String(err)));
}
}; };
const remove = async (project_id, callback) => { const remove = async (project_id) => {
try {
const db = await getDb(); const db = await getDb();
const [result] = await db.query( const [result] = await db.query(
`DELETE FROM projects WHERE project_id = ?`, `DELETE FROM projects WHERE project_id = ?`,
[project_id] [project_id]
); );
callback(null, result.affectedRows); return result.affectedRows;
} catch (err) {
callback(err);
}
}; };
module.exports = { module.exports = {

View File

@@ -12,8 +12,7 @@ const { getDb } = require('../dbPool');
/** /**
* 작업 생성 * 작업 생성
*/ */
const createTask = async (taskData, callback) => { const createTask = async (taskData) => {
try {
const db = await getDb(); const db = await getDb();
const { work_type_id, task_name, description } = taskData; const { work_type_id, task_name, description } = taskData;
@@ -23,17 +22,13 @@ const createTask = async (taskData, callback) => {
[work_type_id || null, task_name, description || null] [work_type_id || null, task_name, description || null]
); );
callback(null, result.insertId); return result.insertId;
} catch (err) {
callback(err);
}
}; };
/** /**
* 전체 작업 목록 조회 (공정 정보 포함) * 전체 작업 목록 조회 (공정 정보 포함)
*/ */
const getAllTasks = async (callback) => { const getAllTasks = async () => {
try {
const db = await getDb(); const db = await getDb();
const [rows] = await db.query( const [rows] = await db.query(
`SELECT t.task_id, t.work_type_id, t.task_name, t.description, t.is_active, `SELECT t.task_id, t.work_type_id, t.task_name, t.description, t.is_active,
@@ -43,17 +38,13 @@ const getAllTasks = async (callback) => {
LEFT JOIN work_types wt ON t.work_type_id = wt.id LEFT JOIN work_types wt ON t.work_type_id = wt.id
ORDER BY wt.category ASC, t.task_id DESC` ORDER BY wt.category ASC, t.task_id DESC`
); );
callback(null, rows); return rows;
} catch (err) {
callback(err);
}
}; };
/** /**
* 활성 작업만 조회 * 활성 작업만 조회
*/ */
const getActiveTasks = async (callback) => { const getActiveTasks = async () => {
try {
const db = await getDb(); const db = await getDb();
const [rows] = await db.query( const [rows] = await db.query(
`SELECT t.task_id, t.work_type_id, t.task_name, t.description, `SELECT t.task_id, t.work_type_id, t.task_name, t.description,
@@ -63,17 +54,13 @@ const getActiveTasks = async (callback) => {
WHERE t.is_active = 1 WHERE t.is_active = 1
ORDER BY wt.category ASC, t.task_name ASC` ORDER BY wt.category ASC, t.task_name ASC`
); );
callback(null, rows); return rows;
} catch (err) {
callback(err);
}
}; };
/** /**
* 공정별 작업 목록 조회 * 공정별 작업 목록 조회 (활성 작업만)
*/ */
const getTasksByWorkType = async (workTypeId, callback) => { const getTasksByWorkType = async (workTypeId) => {
try {
const db = await getDb(); const db = await getDb();
const [rows] = await db.query( const [rows] = await db.query(
`SELECT t.task_id, t.work_type_id, t.task_name, t.description, t.is_active, `SELECT t.task_id, t.work_type_id, t.task_name, t.description, t.is_active,
@@ -81,21 +68,17 @@ const getTasksByWorkType = async (workTypeId, callback) => {
wt.name as work_type_name, wt.category wt.name as work_type_name, wt.category
FROM tasks t FROM tasks t
LEFT JOIN work_types wt ON t.work_type_id = wt.id LEFT JOIN work_types wt ON t.work_type_id = wt.id
WHERE t.work_type_id = ? WHERE t.work_type_id = ? AND t.is_active = 1
ORDER BY t.task_id DESC`, ORDER BY t.task_name ASC`,
[workTypeId] [workTypeId]
); );
callback(null, rows); return rows;
} catch (err) {
callback(err);
}
}; };
/** /**
* 단일 작업 조회 * 단일 작업 조회
*/ */
const getTaskById = async (taskId, callback) => { const getTaskById = async (taskId) => {
try {
const db = await getDb(); const db = await getDb();
const [rows] = await db.query( const [rows] = await db.query(
`SELECT t.task_id, t.work_type_id, t.task_name, t.description, t.is_active, `SELECT t.task_id, t.work_type_id, t.task_name, t.description, t.is_active,
@@ -106,17 +89,13 @@ const getTaskById = async (taskId, callback) => {
WHERE t.task_id = ?`, WHERE t.task_id = ?`,
[taskId] [taskId]
); );
callback(null, rows[0] || null); return rows[0] || null;
} catch (err) {
callback(err);
}
}; };
/** /**
* 작업 수정 * 작업 수정
*/ */
const updateTask = async (taskId, taskData, callback) => { const updateTask = async (taskId, taskData) => {
try {
const db = await getDb(); const db = await getDb();
const { work_type_id, task_name, description, is_active } = taskData; const { work_type_id, task_name, description, is_active } = taskData;
@@ -137,26 +116,19 @@ const updateTask = async (taskId, taskData, callback) => {
] ]
); );
callback(null, result); return result;
} catch (err) {
callback(err);
}
}; };
/** /**
* 작업 삭제 * 작업 삭제
*/ */
const deleteTask = async (taskId, callback) => { const deleteTask = async (taskId) => {
try {
const db = await getDb(); const db = await getDb();
const [result] = await db.query( const [result] = await db.query(
`DELETE FROM tasks WHERE task_id = ?`, `DELETE FROM tasks WHERE task_id = ?`,
[taskId] [taskId]
); );
callback(null, result); return result;
} catch (err) {
callback(err);
}
}; };
module.exports = { module.exports = {

View File

@@ -1,30 +1,21 @@
const { getDb } = require('../dbPool'); const { getDb } = require('../dbPool');
// 1. 전체 도구 조회 // 1. 전체 도구 조회
const getAll = async (callback) => { const getAll = async () => {
try {
const db = await getDb(); const db = await getDb();
const [rows] = await db.query('SELECT id, name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note FROM Tools'); const [rows] = await db.query('SELECT id, name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note FROM Tools');
callback(null, rows); return rows;
} catch (err) {
callback(err);
}
}; };
// 2. 단일 도구 조회 // 2. 단일 도구 조회
const getById = async (id, callback) => { const getById = async (id) => {
try {
const db = await getDb(); const db = await getDb();
const [rows] = await db.query('SELECT id, name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note FROM Tools WHERE id = ?', [id]); const [rows] = await db.query('SELECT id, name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note FROM Tools WHERE id = ?', [id]);
callback(null, rows[0]); return rows[0];
} catch (err) {
callback(err);
}
}; };
// 3. 도구 생성 // 3. 도구 생성
const create = async (tool, callback) => { const create = async (tool) => {
try {
const db = await getDb(); const db = await getDb();
const { name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note } = tool; const { name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note } = tool;
@@ -35,15 +26,11 @@ const create = async (tool, callback) => {
[name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note] [name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note]
); );
callback(null, result.insertId); return result.insertId;
} catch (err) {
callback(err);
}
}; };
// 4. 도구 수정 // 4. 도구 수정
const update = async (id, tool, callback) => { const update = async (id, tool) => {
try {
const db = await getDb(); const db = await getDb();
const { name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note } = tool; const { name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note } = tool;
@@ -62,24 +49,16 @@ const update = async (id, tool, callback) => {
[name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note, id] [name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note, id]
); );
callback(null, result.affectedRows); return result.affectedRows;
} catch (err) {
callback(new Error(err.message || String(err)));
}
}; };
// 5. 도구 삭제 // 5. 도구 삭제
const remove = async (id, callback) => { const remove = async (id) => {
try {
const db = await getDb(); const db = await getDb();
const [result] = await db.query('DELETE FROM Tools WHERE id = ?', [id]); const [result] = await db.query('DELETE FROM Tools WHERE id = ?', [id]);
callback(null, result.affectedRows); return result.affectedRows;
} catch (err) {
callback(err);
}
}; };
// ✅ export 정리
module.exports = { module.exports = {
getAll, getAll,
getById, getById,

View File

@@ -1,8 +1,7 @@
const { getDb } = require('../dbPool'); const { getDb } = require('../dbPool');
// 1. 문서 업로드 // 1. 문서 업로드
const create = async (doc, callback) => { const create = async (doc) => {
try {
const db = await getDb(); const db = await getDb();
const sql = ` const sql = `
INSERT INTO uploaded_documents INSERT INTO uploaded_documents
@@ -21,24 +20,16 @@ const create = async (doc, callback) => {
doc.submitted_by doc.submitted_by
]; ];
const [result] = await db.query(sql, values); const [result] = await db.query(sql, values);
callback(null, result.insertId); return result.insertId;
} catch (err) {
callback(new Error(err.message || String(err)));
}
}; };
// 2. 전체 문서 목록 조회 // 2. 전체 문서 목록 조회
const getAll = async (callback) => { const getAll = async () => {
try {
const db = await getDb(); const db = await getDb();
const [rows] = await db.query(`SELECT * FROM uploaded_documents ORDER BY created_at DESC`); const [rows] = await db.query(`SELECT * FROM uploaded_documents ORDER BY created_at DESC`);
callback(null, rows); return rows;
} catch (err) {
callback(err);
}
}; };
// ✅ 내보내기
module.exports = { module.exports = {
create, create,
getAll getAll

View File

@@ -260,6 +260,118 @@ const vacationBalanceModel = {
return Math.min(15 + additionalDays, 25); return Math.min(15 + additionalDays, 25);
}, },
/**
* 휴가 사용 시 우선순위에 따라 잔액에서 차감 (Promise 버전)
* - 일일 근태 기록 저장 시 호출
* @param {number} workerId - 작업자 ID
* @param {number} year - 연도
* @param {number} daysToDeduct - 차감할 일수 (1, 0.5, 0.25)
* @returns {Promise<Object>} - 차감 결과
*/
async deductByPriority(workerId, year, daysToDeduct) {
const db = await getDb();
// 우선순위순으로 잔여 일수가 있는 잔액 조회
const [balances] = await db.query(`
SELECT vbd.id, vbd.vacation_type_id, vbd.total_days, vbd.used_days,
(vbd.total_days - vbd.used_days) as remaining_days,
vt.type_code, vt.type_name, vt.priority
FROM vacation_balance_details vbd
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
WHERE vbd.worker_id = ? AND vbd.year = ?
AND (vbd.total_days - vbd.used_days) > 0
ORDER BY vt.priority ASC
`, [workerId, year]);
if (balances.length === 0) {
// 잔액이 없어도 일단 기록은 저장 (경고만)
console.warn(`[VacationBalance] 작업자 ${workerId}${year}년 휴가 잔액이 없습니다`);
return { success: false, message: '휴가 잔액이 없습니다', deducted: 0 };
}
let remaining = daysToDeduct;
const deductions = [];
for (const balance of balances) {
if (remaining <= 0) break;
const available = parseFloat(balance.remaining_days);
const toDeduct = Math.min(remaining, available);
if (toDeduct > 0) {
await db.query(`
UPDATE vacation_balance_details
SET used_days = used_days + ?, updated_at = NOW()
WHERE id = ?
`, [toDeduct, balance.id]);
deductions.push({
balance_id: balance.id,
type_code: balance.type_code,
type_name: balance.type_name,
deducted: toDeduct
});
remaining -= toDeduct;
}
}
console.log(`[VacationBalance] 작업자 ${workerId}: ${daysToDeduct}일 차감 완료`, deductions);
return { success: true, deductions, totalDeducted: daysToDeduct - remaining };
},
/**
* 휴가 취소 시 우선순위 역순으로 복구 (Promise 버전)
* @param {number} workerId - 작업자 ID
* @param {number} year - 연도
* @param {number} daysToRestore - 복구할 일수
* @returns {Promise<Object>} - 복구 결과
*/
async restoreByPriority(workerId, year, daysToRestore) {
const db = await getDb();
// 우선순위 역순으로 사용 일수가 있는 잔액 조회 (나중에 차감된 것부터 복구)
const [balances] = await db.query(`
SELECT vbd.id, vbd.vacation_type_id, vbd.used_days,
vt.type_code, vt.type_name, vt.priority
FROM vacation_balance_details vbd
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
WHERE vbd.worker_id = ? AND vbd.year = ?
AND vbd.used_days > 0
ORDER BY vt.priority DESC
`, [workerId, year]);
let remaining = daysToRestore;
const restorations = [];
for (const balance of balances) {
if (remaining <= 0) break;
const usedDays = parseFloat(balance.used_days);
const toRestore = Math.min(remaining, usedDays);
if (toRestore > 0) {
await db.query(`
UPDATE vacation_balance_details
SET used_days = used_days - ?, updated_at = NOW()
WHERE id = ?
`, [toRestore, balance.id]);
restorations.push({
balance_id: balance.id,
type_code: balance.type_code,
type_name: balance.type_name,
restored: toRestore
});
remaining -= toRestore;
}
}
console.log(`[VacationBalance] 작업자 ${workerId}: ${daysToRestore}일 복구 완료`, restorations);
return { success: true, restorations, totalRestored: daysToRestore - remaining };
},
/** /**
* 특정 ID로 휴가 잔액 조회 * 특정 ID로 휴가 잔액 조회
*/ */

View File

@@ -10,8 +10,7 @@ const formatDate = (dateStr) => {
}; };
// 1. 작업자 생성 // 1. 작업자 생성
const create = async (worker, callback) => { const create = async (worker) => {
try {
const db = await getDb(); const db = await getDb();
const { const {
worker_name, worker_name,
@@ -31,16 +30,11 @@ const create = async (worker, callback) => {
[worker_name, job_type, formatDate(join_date), salary, annual_leave, status, employment_status, department_id] [worker_name, job_type, formatDate(join_date), salary, annual_leave, status, employment_status, department_id]
); );
callback(null, result.insertId); return result.insertId;
} catch (err) {
console.error('❌ create 함수 에러:', err);
callback(err);
}
}; };
// 2. 전체 조회 // 2. 전체 조회
const getAll = async (callback) => { const getAll = async () => {
try {
const db = await getDb(); const db = await getDb();
const [rows] = await db.query(` const [rows] = await db.query(`
SELECT SELECT
@@ -53,15 +47,11 @@ const getAll = async (callback) => {
LEFT JOIN departments d ON w.department_id = d.department_id LEFT JOIN departments d ON w.department_id = d.department_id
ORDER BY w.worker_id DESC ORDER BY w.worker_id DESC
`); `);
callback(null, rows); return rows;
} catch (err) {
callback(err);
}
}; };
// 3. 단일 조회 // 3. 단일 조회
const getById = async (worker_id, callback) => { const getById = async (worker_id) => {
try {
const db = await getDb(); const db = await getDb();
const [rows] = await db.query(` const [rows] = await db.query(`
SELECT SELECT
@@ -74,15 +64,11 @@ const getById = async (worker_id, callback) => {
LEFT JOIN departments d ON w.department_id = d.department_id LEFT JOIN departments d ON w.department_id = d.department_id
WHERE w.worker_id = ? WHERE w.worker_id = ?
`, [worker_id]); `, [worker_id]);
callback(null, rows[0]); return rows[0];
} catch (err) {
callback(err);
}
}; };
// 4. 작업자 수정 // 4. 작업자 수정
const update = async (worker, callback) => { const update = async (worker) => {
try {
const db = await getDb(); const db = await getDb();
const { const {
worker_id, worker_id,
@@ -134,8 +120,7 @@ const update = async (worker, callback) => {
} }
if (updates.length === 0) { if (updates.length === 0) {
callback(new Error('업데이트할 필드가 없습니다')); throw new Error('업데이트할 필드가 없습니다');
return;
} }
values.push(worker_id); // WHERE 조건용 values.push(worker_id); // WHERE 조건용
@@ -147,15 +132,11 @@ const update = async (worker, callback) => {
const [result] = await db.query(query, values); const [result] = await db.query(query, values);
callback(null, result.affectedRows); return result.affectedRows;
} catch (err) {
console.error('❌ update 함수 에러:', err);
callback(new Error(err.message || String(err)));
}
}; };
// 5. 삭제 (외래키 제약조건 처리) // 5. 삭제 (외래키 제약조건 처리)
const remove = async (worker_id, callback) => { const remove = async (worker_id) => {
const db = await getDb(); const db = await getDb();
const conn = await db.getConnection(); const conn = await db.getConnection();
@@ -196,18 +177,17 @@ const remove = async (worker_id, callback) => {
console.log(`✅ 작업자 삭제 완료: ${result.affectedRows}`); console.log(`✅ 작업자 삭제 완료: ${result.affectedRows}`);
await conn.commit(); await conn.commit();
callback(null, result.affectedRows); return result.affectedRows;
} catch (err) { } catch (err) {
await conn.rollback(); await conn.rollback();
console.error(`❌ 작업자 삭제 오류 (worker_id: ${worker_id}):`, err); console.error(`❌ 작업자 삭제 오류 (worker_id: ${worker_id}):`, err);
callback(new Error(`작업자 삭제 중 오류가 발생했습니다: ${err.message}`)); throw new Error(`작업자 삭제 중 오류가 발생했습니다: ${err.message}`);
} finally { } finally {
conn.release(); conn.release();
} }
}; };
// ✅ 모듈 내보내기 (정상 구조)
module.exports = { module.exports = {
create, create,
getAll, getAll,

View File

@@ -12,6 +12,9 @@ router.get('/daily-status', AttendanceController.getDailyAttendanceStatus);
// 일일 근태 기록 조회 // 일일 근태 기록 조회
router.get('/daily-records', AttendanceController.getDailyAttendanceRecords); router.get('/daily-records', AttendanceController.getDailyAttendanceRecords);
// 기간별 근태 기록 조회 (월별 조회용)
router.get('/records', AttendanceController.getAttendanceRecordsByRange);
// 근태 기록 생성/업데이트 // 근태 기록 생성/업데이트
router.post('/records', AttendanceController.upsertAttendanceRecord); router.post('/records', AttendanceController.upsertAttendanceRecord);
router.put('/records', AttendanceController.upsertAttendanceRecord); router.put('/records', AttendanceController.upsertAttendanceRecord);

View File

@@ -5,12 +5,13 @@ const jwt = require('jsonwebtoken');
const { requireAuth, requireRole } = require('../middlewares/auth'); const { requireAuth, requireRole } = require('../middlewares/auth');
const router = express.Router(); const router = express.Router();
// 임시 사용자 데이터 // 임시 사용자 데이터 (실제 운영 시 DB 사용 필수)
// 비밀번호 해시는 bcrypt.hash('password', 10)으로 생성됨
let users = [ let users = [
{ {
user_id: 1, user_id: 1,
username: 'admin', username: 'admin',
password: '$2b$10$example', password: '$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZRGdjGj/n3.w7VtL.r8yR.nTfC7Hy', // 'password' 해시
name: '관리자', name: '관리자',
access_level: 'admin', access_level: 'admin',
worker_id: null, worker_id: null,
@@ -19,7 +20,7 @@ let users = [
{ {
user_id: 2, user_id: 2,
username: 'group_leader1', username: 'group_leader1',
password: '$2b$10$example', password: '$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZRGdjGj/n3.w7VtL.r8yR.nTfC7Hy', // 'password' 해시
name: '김그룹장', name: '김그룹장',
access_level: 'group_leader', access_level: 'group_leader',
worker_id: 1, worker_id: 1,
@@ -27,6 +28,11 @@ let users = [
} }
]; ];
// 보안 경고: 운영 환경에서는 반드시 .env의 JWT_SECRET을 설정해야 합니다
if (!process.env.JWT_SECRET) {
console.warn('⚠️ WARNING: JWT_SECRET이 설정되지 않았습니다. 운영 환경에서는 반드시 설정하세요!');
}
/** /**
* 로그인 * 로그인
*/ */
@@ -43,8 +49,8 @@ router.post('/login', async (req, res) => {
return res.status(401).json({ error: '사용자를 찾을 수 없습니다.' }); return res.status(401).json({ error: '사용자를 찾을 수 없습니다.' });
} }
// 비밀번호 확인 (실제로는 bcrypt.compare 사용) // 비밀번호 확인 (bcrypt.compare 사용)
const isValid = password === 'password'; // 임시 const isValid = await bcrypt.compare(password, user.password);
if (!isValid) { if (!isValid) {
return res.status(401).json({ error: '비밀번호가 올바르지 않습니다.' }); return res.status(401).json({ error: '비밀번호가 올바르지 않습니다.' });
} }
@@ -57,7 +63,7 @@ router.post('/login', async (req, res) => {
access_level: user.access_level, access_level: user.access_level,
worker_id: user.worker_id worker_id: user.worker_id
}, },
process.env.JWT_SECRET || 'your-secret-key', process.env.JWT_SECRET,
{ expiresIn: '24h' } { expiresIn: '24h' }
); );

View File

@@ -11,6 +11,7 @@ const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const mysql = require('mysql2/promise'); const mysql = require('mysql2/promise');
const { verifyToken } = require('../middlewares/authMiddleware'); const { verifyToken } = require('../middlewares/authMiddleware');
const { validatePassword, getPasswordError } = require('../utils/passwordValidator');
const router = express.Router(); const router = express.Router();
const authController = require('../controllers/authController'); const authController = require('../controllers/authController');
@@ -146,7 +147,7 @@ router.post('/refresh-token', async (req, res) => {
// 사용자 정보 조회 // 사용자 정보 조회
const [users] = await connection.execute( const [users] = await connection.execute(
'SELECT * FROM Users WHERE user_id = ? AND is_active = TRUE', 'SELECT * FROM users WHERE user_id = ? AND is_active = TRUE',
[decoded.user_id] [decoded.user_id]
); );
@@ -213,11 +214,14 @@ router.post('/change-password', verifyToken, async (req, res) => {
}); });
} }
// 비밀번호 강도 검증 // 비밀번호 강도 검증 (12자 이상, 대/소문자, 숫자, 특수문자 필수)
if (newPassword.length < 6) { const passwordValidation = validatePassword(newPassword);
if (!passwordValidation.valid) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
error: '비밀번호는 최소 6자 이상이어야 합니다.' error: '비밀번호가 보안 요구사항을 충족하지 않습니다.',
details: passwordValidation.errors,
code: 'WEAK_PASSWORD'
}); });
} }
@@ -225,7 +229,7 @@ router.post('/change-password', verifyToken, async (req, res) => {
// 현재 사용자의 비밀번호 조회 // 현재 사용자의 비밀번호 조회
const [users] = await connection.execute( const [users] = await connection.execute(
'SELECT password FROM Users WHERE user_id = ?', 'SELECT password FROM users WHERE user_id = ?',
[userId] [userId]
); );
@@ -320,11 +324,14 @@ router.post('/admin/change-password', verifyToken, async (req, res) => {
}); });
} }
// 비밀번호 강도 검증 // 비밀번호 강도 검증 (12자 이상, 대/소문자, 숫자, 특수문자 필수)
if (newPassword.length < 6) { const passwordValidation = validatePassword(newPassword);
if (!passwordValidation.valid) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
error: '비밀번호는 최소 6자 이상이어야 합니다.' error: '비밀번호가 보안 요구사항을 충족하지 않습니다.',
details: passwordValidation.errors,
code: 'WEAK_PASSWORD'
}); });
} }
@@ -332,7 +339,7 @@ router.post('/admin/change-password', verifyToken, async (req, res) => {
// 대상 사용자 확인 // 대상 사용자 확인
const [users] = await connection.execute( const [users] = await connection.execute(
'SELECT username, name FROM Users WHERE user_id = ?', 'SELECT username, name FROM users WHERE user_id = ?',
[userId] [userId]
); );
@@ -449,7 +456,7 @@ router.get('/me', verifyToken, async (req, res) => {
connection = await mysql.createConnection(dbConfig); connection = await mysql.createConnection(dbConfig);
const [rows] = await connection.execute( const [rows] = await connection.execute(
'SELECT user_id, username, name, email, access_level, worker_id, last_login_at, created_at FROM Users WHERE user_id = ?', 'SELECT user_id, username, name, email, access_level, worker_id, last_login_at, created_at FROM users WHERE user_id = ?',
[userId] [userId]
); );
@@ -515,7 +522,7 @@ router.post('/register', verifyToken, async (req, res) => {
// 사용자명 중복 체크 // 사용자명 중복 체크
const [existing] = await connection.execute( const [existing] = await connection.execute(
'SELECT user_id FROM Users WHERE username = ?', 'SELECT user_id FROM users WHERE username = ?',
[username] [username]
); );
@@ -529,7 +536,7 @@ router.post('/register', verifyToken, async (req, res) => {
// 이메일 중복 체크 (이메일이 제공된 경우) // 이메일 중복 체크 (이메일이 제공된 경우)
if (email) { if (email) {
const [existingEmail] = await connection.execute( const [existingEmail] = await connection.execute(
'SELECT user_id FROM Users WHERE email = ?', 'SELECT user_id FROM users WHERE email = ?',
[email] [email]
); );
@@ -693,7 +700,7 @@ router.put('/users/:id', verifyToken, async (req, res) => {
// 사용자 존재 확인 // 사용자 존재 확인
const [existing] = await connection.execute( const [existing] = await connection.execute(
'SELECT user_id, username FROM Users WHERE user_id = ?', 'SELECT user_id, username FROM users WHERE user_id = ?',
[userId] [userId]
); );
@@ -717,7 +724,7 @@ router.put('/users/:id', verifyToken, async (req, res) => {
// 이메일 중복 체크 // 이메일 중복 체크
if (email) { if (email) {
const [emailCheck] = await connection.execute( const [emailCheck] = await connection.execute(
'SELECT user_id FROM Users WHERE email = ? AND user_id != ?', 'SELECT user_id FROM users WHERE email = ? AND user_id != ?',
[email, userId] [email, userId]
); );
@@ -787,7 +794,7 @@ router.put('/users/:id', verifyToken, async (req, res) => {
// 업데이트된 사용자 정보 조회 // 업데이트된 사용자 정보 조회
const [updated] = await connection.execute( const [updated] = await connection.execute(
'SELECT user_id, username, name, email, access_level, worker_id, is_active FROM Users WHERE user_id = ?', 'SELECT user_id, username, name, email, access_level, worker_id, is_active FROM users WHERE user_id = ?',
[userId] [userId]
); );

View File

@@ -25,4 +25,99 @@ router.get('/detail', (req, res) => {
}); });
}); });
// 임시 마이그레이션 엔드포인트 - TBM work_type_id 수정
// 실행 후 이 코드를 삭제하세요!
router.post('/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 }
});
}
// 수정 전 샘플 로깅
console.log('수정 전 샘플:', checkResult.slice(0, 5));
// 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
});
}
});
module.exports = router; module.exports = router;

View File

@@ -2,8 +2,12 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const notificationController = require('../controllers/notificationController'); const notificationController = require('../controllers/notificationController');
const { requireAuth, requireMinLevel } = require('../middlewares/auth');
// 읽지 않은 알림 조회 // 모든 알림 라우트는 인증 필요
router.use(requireAuth);
// 읽지 않은 알림 조회 (본인 알림만)
router.get('/unread', notificationController.getUnread); router.get('/unread', notificationController.getUnread);
// 읽지 않은 알림 개수 // 읽지 않은 알림 개수
@@ -13,15 +17,15 @@ router.get('/unread/count', notificationController.getUnreadCount);
router.get('/', notificationController.getAll); router.get('/', notificationController.getAll);
// 알림 생성 (시스템/관리자용) // 알림 생성 (시스템/관리자용)
router.post('/', notificationController.create); router.post('/', requireMinLevel('support_team'), notificationController.create);
// 모든 알림 읽음 처리 // 모든 알림 읽음 처리 (본인 알림만)
router.post('/read-all', notificationController.markAllAsRead); router.post('/read-all', notificationController.markAllAsRead);
// 특정 알림 읽음 처리 // 특정 알림 읽음 처리 (본인 알림만)
router.post('/:id/read', notificationController.markAsRead); router.post('/:id/read', notificationController.markAsRead);
// 알림 삭제 // 알림 삭제 (본인 알림만)
router.delete('/:id', notificationController.delete); router.delete('/:id', notificationController.delete);
module.exports = router; module.exports = router;

View File

@@ -3,8 +3,35 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const multer = require('multer');
const path = require('path');
const patrolController = require('../controllers/patrolController'); const patrolController = require('../controllers/patrolController');
// Multer 설정 - 구역 현황 사진 업로드
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, path.join(__dirname, '../uploads'));
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const ext = path.extname(file.originalname).toLowerCase();
cb(null, `zone-item-${uniqueSuffix}${ext}`);
}
});
const upload = multer({
storage,
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
fileFilter: (req, file, cb) => {
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('이미지 파일만 업로드 가능합니다.'), false);
}
}
});
// ==================== 순회점검 세션 ==================== // ==================== 순회점검 세션 ====================
// 세션 목록 조회 // 세션 목록 조회
@@ -70,4 +97,30 @@ router.get('/item-types', patrolController.getItemTypes);
// 오늘 순회점검 현황 // 오늘 순회점검 현황
router.get('/today-status', patrolController.getTodayStatus); router.get('/today-status', patrolController.getTodayStatus);
// ==================== 작업장 상세 정보 ====================
// 작업장 상세 정보 조회 (시설물, 안전신고, 부적합, 출입, TBM 통합)
// GET /patrol/workplaces/:workplaceId/detail?date=2026-02-05
router.get('/workplaces/:workplaceId/detail', patrolController.getWorkplaceDetail);
// ==================== 구역 내 등록 물품/시설물 ====================
// 구역 내 등록된 물품/시설물 목록 조회
router.get('/workplaces/:workplaceId/zone-items', patrolController.getZoneItems);
// 구역 내 물품/시설물 등록
router.post('/workplaces/:workplaceId/zone-items', patrolController.createZoneItem);
// 구역 내 물품/시설물 수정
router.put('/zone-items/:itemId', patrolController.updateZoneItem);
// 구역 내 물품/시설물 삭제
router.delete('/zone-items/:itemId', patrolController.deleteZoneItem);
// 구역 현황 사진 업로드
router.post('/zone-items/photos', upload.single('photo'), patrolController.uploadZoneItemPhoto);
// 구역 현황 이력 조회
router.get('/zone-items/:itemId/history', patrolController.getZoneItemHistory);
module.exports = router; module.exports = router;

View File

@@ -2,23 +2,18 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const projectController = require('../controllers/projectController'); const projectController = require('../controllers/projectController');
const { requireAuth, requireMinLevel } = require('../middlewares/auth');
// CREATE // READ - 인증된 사용자
router.post('/', projectController.createProject); router.get('/', requireAuth, projectController.getAllProjects);
router.get('/active/list', requireAuth, projectController.getActiveProjects);
router.get('/:project_id', requireAuth, projectController.getProjectById);
// READ ALL // CREATE/UPDATE - support_team 이상 권한 필요
router.get('/', projectController.getAllProjects); router.post('/', requireAuth, requireMinLevel('support_team'), projectController.createProject);
router.put('/:project_id', requireAuth, requireMinLevel('support_team'), projectController.updateProject);
// READ ACTIVE ONLY (작업보고서용) // DELETE - admin 이상 권한 필요
router.get('/active/list', projectController.getActiveProjects); router.delete('/:project_id', requireAuth, requireMinLevel('admin'), projectController.removeProject);
// READ ONE
router.get('/:project_id', projectController.getProjectById);
// UPDATE
router.put('/:project_id', projectController.updateProject);
// DELETE
router.delete('/:project_id', projectController.removeProject);
module.exports = router; module.exports = router;

View File

@@ -210,6 +210,98 @@ router.get('/logs/password-changes', async (req, res) => {
} }
}); });
/**
* POST /api/system/migrations/fix-work-type-id
* TBM 기반 작업보고서의 work_type_id를 task_id로 수정
*/
router.post('/migrations/fix-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
`);
if (checkResult.length === 0) {
return res.json({
success: true,
message: '수정할 데이터가 없습니다.',
data: { affected_rows: 0, samples: [] }
});
}
// 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
});
}
});
/** /**
* GET /api/system/logs/activity * GET /api/system/logs/activity
* 활동 로그 조회 (activity_logs 테이블이 있는 경우) * 활동 로그 조회 (activity_logs 테이블이 있는 경우)

View File

@@ -2,11 +2,15 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const controller = require('../controllers/toolsController'); const controller = require('../controllers/toolsController');
const { requireAuth, requireMinLevel } = require('../middlewares/auth');
router.get('/', controller.getAll); // 읽기 작업: 인증된 사용자
router.get('/:id', controller.getById); router.get('/', requireAuth, controller.getAll);
router.post('/', controller.create); router.get('/:id', requireAuth, controller.getById);
router.put('/:id', controller.update);
router.delete('/:id', controller.delete); // 쓰기 작업: group_leader 이상 권한 필요
router.post('/', requireAuth, requireMinLevel('group_leader'), controller.create);
router.put('/:id', requireAuth, requireMinLevel('group_leader'), controller.update);
router.delete('/:id', requireAuth, requireMinLevel('admin'), controller.delete);
module.exports = router; module.exports = router;

View File

@@ -1,8 +1,10 @@
// ✅ routes/uploadBgRoutes.js (신규: 배경 이미지 전용 업로드 라우터) // ✅ routes/uploadBgRoutes.js (배경 이미지 전용 업로드 라우터 - 보안 강화)
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const multer = require('multer'); const multer = require('multer');
const path = require('path'); const path = require('path');
const { requireAuth, requireMinLevel } = require('../middlewares/auth');
const { createFileFilter, validateUploadedFile } = require('../utils/fileUploadSecurity');
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: (req, file, cb) => { destination: (req, file, cb) => {
@@ -13,12 +15,37 @@ const storage = multer.diskStorage({
} }
}); });
const upload = multer({ storage }); // 보안 강화된 파일 필터 (이미지만 허용)
const imageFileFilter = createFileFilter({
allowedExtensions: ['.jpg', '.jpeg', '.png', '.gif', '.webp'],
allowedMimes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
});
router.post('/upload-bg', upload.single('image'), (req, res) => { const upload = multer({
storage,
fileFilter: imageFileFilter,
limits: {
fileSize: 10 * 1024 * 1024, // 10MB 제한 (배경 이미지는 크기가 클 수 있음)
files: 1
}
});
// 관리자 권한 필요
router.post('/upload-bg', requireAuth, requireMinLevel('admin'), upload.single('image'), async (req, res) => {
if (!req.file) { if (!req.file) {
return res.status(400).json({ success: false, message: '파일이 없습니다.' }); return res.status(400).json({ success: false, message: '파일이 없습니다.' });
} }
// 업로드된 파일의 실제 내용 검증 (Magic number)
const validation = await validateUploadedFile(req.file.path, req.file.mimetype);
if (!validation.valid) {
return res.status(400).json({
success: false,
message: validation.message,
code: 'INVALID_FILE_TYPE'
});
}
res.json({ success: true, path: '/img/login-bg.jpeg' }); res.json({ success: true, path: '/img/login-bg.jpeg' });
}); });

View File

@@ -22,6 +22,9 @@ router.post('/auto-calculate', vacationBalanceController.autoCalculateAndCreate)
// 휴가 잔액 생성 (관리자만) // 휴가 잔액 생성 (관리자만)
router.post('/', vacationBalanceController.createBalance); router.post('/', vacationBalanceController.createBalance);
// 휴가 잔액 일괄 저장 (upsert)
router.post('/bulk-upsert', vacationBalanceController.bulkUpsert);
// 휴가 잔액 수정 (관리자만) // 휴가 잔액 수정 (관리자만)
router.put('/:id', vacationBalanceController.updateBalance); router.put('/:id', vacationBalanceController.updateBalance);

View File

@@ -4,31 +4,37 @@ const router = express.Router();
const multer = require('multer'); const multer = require('multer');
const path = require('path'); const path = require('path');
const workplaceController = require('../controllers/workplaceController'); const workplaceController = require('../controllers/workplaceController');
const {
generateSafeFilename,
createFileFilter,
ALLOWED_IMAGE_EXTENSIONS
} = require('../utils/fileUploadSecurity');
// Multer 설정 - 작업장 레이아웃 이미지 업로드 // Multer 설정 - 작업장 레이아웃 이미지 업로드 (보안 강화)
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: (req, file, cb) => { destination: (req, file, cb) => {
cb(null, path.join(__dirname, '../uploads')); cb(null, path.join(__dirname, '../uploads'));
}, },
filename: (req, file, cb) => { filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); // 안전한 랜덤 파일명 생성 (원본 파일명 노출 방지)
cb(null, 'workplace-layout-' + uniqueSuffix + path.extname(file.originalname)); const safeName = generateSafeFilename(file.originalname);
cb(null, `workplace-layout-${safeName}`);
} }
}); });
// 보안 강화된 파일 필터
const imageFileFilter = createFileFilter({
allowedExtensions: ALLOWED_IMAGE_EXTENSIONS,
allowedMimes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
});
const upload = multer({ const upload = multer({
storage, storage,
fileFilter: (req, file, cb) => { fileFilter: imageFileFilter,
const allowedTypes = /jpeg|jpg|png|gif/; limits: {
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase()); fileSize: 5 * 1024 * 1024, // 5MB 제한
const mimetype = allowedTypes.test(file.mimetype); files: 1 // 단일 파일만 허용
if (mimetype && extname) {
return cb(null, true);
} else {
cb(new Error('이미지 파일만 업로드 가능합니다 (jpeg, jpg, png, gif)'));
} }
},
limits: { fileSize: 5 * 1024 * 1024 } // 5MB 제한
}); });
// ==================== 카테고리(공장) 관리 ==================== // ==================== 카테고리(공장) 관리 ====================

View File

@@ -8,9 +8,19 @@
*/ */
const AttendanceModel = require('../models/attendanceModel'); const AttendanceModel = require('../models/attendanceModel');
const vacationBalanceModel = require('../models/vacationBalanceModel');
const { ValidationError, NotFoundError, DatabaseError } = require('../utils/errors'); const { ValidationError, NotFoundError, DatabaseError } = require('../utils/errors');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
/**
* 휴가 사용 유형 ID를 차감 일수로 변환
* vacation_type_id: 1=연차(1일), 2=반차(0.5일), 3=반반차(0.25일)
*/
const getVacationDays = (vacationTypeId) => {
const daysMap = { 1: 1, 2: 0.5, 3: 0.25 };
return daysMap[vacationTypeId] || 0;
};
/** /**
* 일일 근태 현황 조회 * 일일 근태 현황 조회
*/ */
@@ -63,8 +73,32 @@ const getDailyAttendanceRecordsService = async (date, workerId = null) => {
} }
}; };
/**
* 기간별 근태 기록 조회 (월별 조회용)
*/
const getAttendanceRecordsByRangeService = async (startDate, endDate, workerId = null) => {
if (!startDate || !endDate) {
throw new ValidationError('시작 날짜와 종료 날짜가 필요합니다', {
required: ['start_date', 'end_date'],
received: { startDate, endDate }
});
}
logger.info('기간별 근태 기록 조회 요청', { startDate, endDate, workerId });
try {
const records = await AttendanceModel.getDailyRecords(startDate, endDate, workerId);
logger.info('기간별 근태 기록 조회 성공', { startDate, endDate, count: records.length });
return records;
} catch (error) {
logger.error('기간별 근태 기록 조회 실패', { startDate, endDate, error: error.message });
throw new DatabaseError('근태 기록 조회 중 데이터베이스 오류가 발생했습니다');
}
};
/** /**
* 근태 기록 생성/업데이트 * 근태 기록 생성/업데이트
* - 휴가 기록 시 vacation_balance_details의 used_days 자동 연동
*/ */
const upsertAttendanceRecordService = async (recordData) => { const upsertAttendanceRecordService = async (recordData) => {
const { const {
@@ -88,22 +122,45 @@ const upsertAttendanceRecordService = async (recordData) => {
}); });
} }
logger.info('근태 기록 저장 요청', { record_date, worker_id }); logger.info('근태 기록 저장 요청', { record_date, worker_id, vacation_type_id });
try { try {
// 1. 기존 기록 조회 (휴가 연동을 위해)
const existingRecords = await AttendanceModel.getDailyAttendanceRecords(record_date, worker_id);
const existingRecord = existingRecords.find(r => r.worker_id === worker_id);
const previousVacationTypeId = existingRecord?.vacation_type_id || null;
// 2. 근태 기록 저장
const result = await AttendanceModel.upsertAttendanceRecord({ const result = await AttendanceModel.upsertAttendanceRecord({
record_date, record_date,
worker_id, worker_id,
total_work_hours, total_work_hours,
attendance_type_id, work_attendance_type_id: attendance_type_id,
vacation_type_id, vacation_type_id,
is_vacation_processed, is_overtime_approved: overtime_approved,
overtime_approved,
status,
notes,
created_by created_by
}); });
// 3. 휴가 잔액 연동 (vacation_balance_details.used_days 업데이트)
const year = new Date(record_date).getFullYear();
const previousDays = getVacationDays(previousVacationTypeId);
const newDays = getVacationDays(vacation_type_id);
// 이전 휴가가 있었고 변경된 경우 → 복구 후 차감
if (previousDays !== newDays) {
// 이전 휴가 복구
if (previousDays > 0) {
await vacationBalanceModel.restoreByPriority(worker_id, year, previousDays);
logger.info('휴가 잔액 복구', { worker_id, year, restored: previousDays });
}
// 새 휴가 차감
if (newDays > 0) {
await vacationBalanceModel.deductByPriority(worker_id, year, newDays);
logger.info('휴가 잔액 차감', { worker_id, year, deducted: newDays });
}
}
logger.info('근태 기록 저장 성공', { record_date, worker_id }); logger.info('근태 기록 저장 성공', { record_date, worker_id });
return result; return result;
} catch (error) { } catch (error) {
@@ -324,6 +381,7 @@ const saveCheckinsService = async (date, checkins) => {
module.exports = { module.exports = {
getDailyAttendanceStatusService, getDailyAttendanceStatusService,
getDailyAttendanceRecordsService, getDailyAttendanceRecordsService,
getAttendanceRecordsByRangeService,
upsertAttendanceRecordService, upsertAttendanceRecordService,
processVacationService, processVacationService,
approveOvertimeService, approveOvertimeService,

View File

@@ -18,12 +18,7 @@ const getAllToolsService = async () => {
logger.info('도구 목록 조회 요청'); logger.info('도구 목록 조회 요청');
try { try {
const rows = await new Promise((resolve, reject) => { const rows = await toolsModel.getAll();
toolsModel.getAll((err, data) => {
if (err) reject(err);
else resolve(data);
});
});
logger.info('도구 목록 조회 성공', { count: rows.length }); logger.info('도구 목록 조회 성공', { count: rows.length });
@@ -46,12 +41,7 @@ const getToolByIdService = async (id) => {
logger.info('도구 조회 요청', { tool_id: id }); logger.info('도구 조회 요청', { tool_id: id });
try { try {
const row = await new Promise((resolve, reject) => { const row = await toolsModel.getById(id);
toolsModel.getById(id, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
if (!row) { if (!row) {
logger.warn('도구를 찾을 수 없음', { tool_id: id }); logger.warn('도구를 찾을 수 없음', { tool_id: id });
@@ -88,12 +78,7 @@ const createToolService = async (toolData) => {
logger.info('도구 생성 요청', { name, location, stock, status }); logger.info('도구 생성 요청', { name, location, stock, status });
try { try {
const insertId = await new Promise((resolve, reject) => { const insertId = await toolsModel.create(toolData);
toolsModel.create(toolData, (err, id) => {
if (err) reject(err);
else resolve(id);
});
});
logger.info('도구 생성 성공', { tool_id: insertId, name }); logger.info('도구 생성 성공', { tool_id: insertId, name });
@@ -119,12 +104,7 @@ const updateToolService = async (id, toolData) => {
logger.info('도구 수정 요청', { tool_id: id, updates: toolData }); logger.info('도구 수정 요청', { tool_id: id, updates: toolData });
try { try {
const affectedRows = await new Promise((resolve, reject) => { const affectedRows = await toolsModel.update(id, toolData);
toolsModel.update(id, toolData, (err, rows) => {
if (err) reject(err);
else resolve(rows);
});
});
if (affectedRows === 0) { if (affectedRows === 0) {
logger.warn('도구를 찾을 수 없거나 변경사항 없음', { tool_id: id }); logger.warn('도구를 찾을 수 없거나 변경사항 없음', { tool_id: id });
@@ -159,12 +139,7 @@ const deleteToolService = async (id) => {
logger.info('도구 삭제 요청', { tool_id: id }); logger.info('도구 삭제 요청', { tool_id: id });
try { try {
const affectedRows = await new Promise((resolve, reject) => { const affectedRows = await toolsModel.remove(id);
toolsModel.remove(id, (err, rows) => {
if (err) reject(err);
else resolve(rows);
});
});
if (affectedRows === 0) { if (affectedRows === 0) {
logger.warn('도구를 찾을 수 없음', { tool_id: id }); logger.warn('도구를 찾을 수 없음', { tool_id: id });

View File

@@ -0,0 +1,315 @@
/**
* File Upload Security - 파일 업로드 보안 유틸리티
*
* - Magic number (파일 시그니처) 검증
* - 파일명 sanitize
* - 확장자 화이트리스트 검증
* - 파일 크기 제한
*
* @author TK-FB-Project
* @since 2026-02-04
*/
const path = require('path');
const crypto = require('crypto');
const fs = require('fs').promises;
/**
* 파일 시그니처 (Magic Numbers)
* 파일의 실제 타입을 확인하기 위한 바이너리 시그니처
*/
const FILE_SIGNATURES = {
// 이미지
'ffd8ff': { mime: 'image/jpeg', ext: ['.jpg', '.jpeg'] },
'89504e47': { mime: 'image/png', ext: ['.png'] },
'47494638': { mime: 'image/gif', ext: ['.gif'] },
'52494646': { mime: 'image/webp', ext: ['.webp'] }, // RIFF (WebP 시작)
// 문서
'25504446': { mime: 'application/pdf', ext: ['.pdf'] },
'504b0304': { mime: 'application/zip', ext: ['.zip', '.xlsx', '.docx', '.pptx'] },
// 주의: BMP, TIFF 등 추가 가능
};
/**
* 허용된 이미지 확장자
*/
const ALLOWED_IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
/**
* 허용된 문서 확장자
*/
const ALLOWED_DOCUMENT_EXTENSIONS = ['.pdf', '.xlsx', '.docx', '.pptx', '.zip'];
/**
* 위험한 확장자 (절대 허용 안 함)
*/
const DANGEROUS_EXTENSIONS = [
'.exe', '.bat', '.cmd', '.sh', '.ps1', '.vbs', '.js', '.jar',
'.php', '.asp', '.aspx', '.jsp', '.cgi', '.pl', '.py', '.rb',
'.htaccess', '.htpasswd', '.ini', '.config', '.env'
];
/**
* 파일 시그니처(Magic Number) 검증
*
* @param {Buffer} buffer - 파일 버퍼 (최소 8바이트)
* @returns {Object|null} 매칭된 파일 정보 또는 null
*/
const checkMagicNumber = (buffer) => {
if (!buffer || buffer.length < 4) {
return null;
}
// 처음 8바이트를 hex로 변환
const hex = buffer.slice(0, 8).toString('hex').toLowerCase();
// 시그니처 매칭
for (const [signature, info] of Object.entries(FILE_SIGNATURES)) {
if (hex.startsWith(signature)) {
return info;
}
}
return null;
};
/**
* 파일 버퍼에서 실제 MIME 타입 검증
*
* @param {Buffer} buffer - 파일 버퍼
* @param {string} declaredMime - 선언된 MIME 타입
* @returns {Object} { valid: boolean, actualType: string|null, message: string }
*/
const validateFileType = (buffer, declaredMime) => {
const detected = checkMagicNumber(buffer);
if (!detected) {
return {
valid: false,
actualType: null,
message: '알 수 없는 파일 형식입니다.'
};
}
// MIME 타입이 일치하는지 확인
if (detected.mime !== declaredMime) {
return {
valid: false,
actualType: detected.mime,
message: `파일 형식이 일치하지 않습니다. (선언: ${declaredMime}, 실제: ${detected.mime})`
};
}
return {
valid: true,
actualType: detected.mime,
message: 'OK'
};
};
/**
* 파일명 sanitize
* 경로 조작 및 특수문자 제거
*
* @param {string} filename - 원본 파일명
* @returns {string} 안전한 파일명
*/
const sanitizeFilename = (filename) => {
if (!filename || typeof filename !== 'string') {
return 'unnamed';
}
// 경로 구분자 제거 (path traversal 방지)
let safe = path.basename(filename);
// 특수문자 제거 (영문, 숫자, -, _, . 만 허용)
safe = safe.replace(/[^a-zA-Z0-9가-힣._-]/g, '_');
// 연속된 점 제거 (이중 확장자 방지)
safe = safe.replace(/\.{2,}/g, '.');
// 앞뒤 점/공백 제거
safe = safe.replace(/^[\s.]+|[\s.]+$/g, '');
// 빈 파일명 처리
if (!safe || safe === '') {
safe = 'unnamed';
}
// 최대 길이 제한 (255자)
if (safe.length > 255) {
const ext = path.extname(safe);
const name = path.basename(safe, ext);
safe = name.slice(0, 255 - ext.length) + ext;
}
return safe;
};
/**
* 확장자 검증
*
* @param {string} filename - 파일명
* @param {string[]} allowedExtensions - 허용된 확장자 배열
* @returns {Object} { valid: boolean, extension: string, message: string }
*/
const validateExtension = (filename, allowedExtensions = ALLOWED_IMAGE_EXTENSIONS) => {
const ext = path.extname(filename).toLowerCase();
// 위험한 확장자 체크
if (DANGEROUS_EXTENSIONS.includes(ext)) {
return {
valid: false,
extension: ext,
message: `보안상 허용되지 않는 파일 형식입니다: ${ext}`
};
}
// 허용된 확장자 체크
if (!allowedExtensions.includes(ext)) {
return {
valid: false,
extension: ext,
message: `허용된 파일 형식: ${allowedExtensions.join(', ')}`
};
}
return {
valid: true,
extension: ext,
message: 'OK'
};
};
/**
* 안전한 랜덤 파일명 생성
*
* @param {string} originalFilename - 원본 파일명 (확장자 추출용)
* @returns {string} 랜덤 파일명
*/
const generateSafeFilename = (originalFilename) => {
const ext = path.extname(originalFilename).toLowerCase();
const randomName = crypto.randomBytes(16).toString('hex');
const timestamp = Date.now();
return `${timestamp}_${randomName}${ext}`;
};
/**
* 안전한 업로드 경로 생성
* 경로 조작(path traversal) 방지
*
* @param {string} baseDir - 기본 업로드 디렉토리
* @param {string} filename - 파일명
* @returns {string} 안전한 전체 경로
*/
const getSafeUploadPath = (baseDir, filename) => {
const safeName = sanitizeFilename(filename);
const fullPath = path.join(baseDir, safeName);
// 결과 경로가 baseDir 안에 있는지 확인
const resolvedBase = path.resolve(baseDir);
const resolvedFull = path.resolve(fullPath);
if (!resolvedFull.startsWith(resolvedBase)) {
throw new Error('경로 조작이 감지되었습니다.');
}
return resolvedFull;
};
/**
* Multer 파일 필터 생성
*
* @param {Object} options - 옵션
* @param {string[]} options.allowedExtensions - 허용된 확장자
* @param {string[]} options.allowedMimes - 허용된 MIME 타입
* @param {boolean} options.checkMagicNumber - Magic number 검증 여부
* @returns {Function} Multer fileFilter 함수
*/
const createFileFilter = (options = {}) => {
const {
allowedExtensions = ALLOWED_IMAGE_EXTENSIONS,
allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
checkMagicNumber = false // Multer에서는 버퍼 접근이 제한적이므로 기본 false
} = options;
return (req, file, cb) => {
// 확장자 검증
const extResult = validateExtension(file.originalname, allowedExtensions);
if (!extResult.valid) {
return cb(new Error(extResult.message), false);
}
// MIME 타입 검증
if (!allowedMimes.includes(file.mimetype)) {
return cb(new Error(`허용된 MIME 타입: ${allowedMimes.join(', ')}`), false);
}
cb(null, true);
};
};
/**
* 업로드된 파일 검증 (후처리용)
* Multer 업로드 후 파일 내용을 검증
*
* @param {string} filePath - 업로드된 파일 경로
* @param {string} declaredMime - 선언된 MIME 타입
* @returns {Promise<Object>} 검증 결과
*/
const validateUploadedFile = async (filePath, declaredMime) => {
try {
// 파일 시작 부분 읽기
const fd = await fs.open(filePath, 'r');
const buffer = Buffer.alloc(8);
await fd.read(buffer, 0, 8, 0);
await fd.close();
// Magic number 검증
const typeResult = validateFileType(buffer, declaredMime);
if (!typeResult.valid) {
// 위험한 파일이면 삭제
await fs.unlink(filePath);
return {
valid: false,
deleted: true,
message: typeResult.message
};
}
return {
valid: true,
deleted: false,
message: 'OK',
actualType: typeResult.actualType
};
} catch (error) {
return {
valid: false,
deleted: false,
message: `파일 검증 중 오류: ${error.message}`
};
}
};
module.exports = {
// 상수
ALLOWED_IMAGE_EXTENSIONS,
ALLOWED_DOCUMENT_EXTENSIONS,
DANGEROUS_EXTENSIONS,
FILE_SIGNATURES,
// 함수
checkMagicNumber,
validateFileType,
sanitizeFilename,
validateExtension,
generateSafeFilename,
getSafeUploadPath,
createFileFilter,
validateUploadedFile
};

View File

@@ -0,0 +1,173 @@
/**
* Password Validator - 비밀번호 정책 검증
*
* 강력한 비밀번호 정책:
* - 최소 12자 이상
* - 대문자 포함
* - 소문자 포함
* - 숫자 포함
* - 특수문자 포함
*
* @author TK-FB-Project
* @since 2026-02-04
*/
/**
* 비밀번호 강도 검증
*
* @param {string} password - 검증할 비밀번호
* @param {Object} options - 옵션 (기본값 사용 권장)
* @returns {Object} { valid: boolean, errors: string[], strength: string }
*/
const validatePassword = (password, options = {}) => {
const config = {
minLength: options.minLength || 12,
requireUppercase: options.requireUppercase !== false,
requireLowercase: options.requireLowercase !== false,
requireNumbers: options.requireNumbers !== false,
requireSpecialChars: options.requireSpecialChars !== false,
maxLength: options.maxLength || 128
};
const errors = [];
let strength = 0;
// 필수 검증
if (!password || typeof password !== 'string') {
return {
valid: false,
errors: ['비밀번호를 입력해주세요.'],
strength: 'invalid'
};
}
// 길이 검증
if (password.length < config.minLength) {
errors.push(`비밀번호는 최소 ${config.minLength}자 이상이어야 합니다.`);
} else {
strength += 1;
}
if (password.length > config.maxLength) {
errors.push(`비밀번호는 ${config.maxLength}자를 초과할 수 없습니다.`);
}
// 대문자 검증
if (config.requireUppercase && !/[A-Z]/.test(password)) {
errors.push('대문자를 1개 이상 포함해야 합니다.');
} else if (/[A-Z]/.test(password)) {
strength += 1;
}
// 소문자 검증
if (config.requireLowercase && !/[a-z]/.test(password)) {
errors.push('소문자를 1개 이상 포함해야 합니다.');
} else if (/[a-z]/.test(password)) {
strength += 1;
}
// 숫자 검증
if (config.requireNumbers && !/\d/.test(password)) {
errors.push('숫자를 1개 이상 포함해야 합니다.');
} else if (/\d/.test(password)) {
strength += 1;
}
// 특수문자 검증
const specialChars = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/;
if (config.requireSpecialChars && !specialChars.test(password)) {
errors.push('특수문자를 1개 이상 포함해야 합니다. (!@#$%^&*()_+-=[]{};\':"|,.<>/?)');
} else if (specialChars.test(password)) {
strength += 1;
}
// 공백 검증
if (/\s/.test(password)) {
errors.push('비밀번호에 공백을 포함할 수 없습니다.');
}
// 연속된 문자 검증 (선택적)
if (/(.)\1{2,}/.test(password)) {
errors.push('동일한 문자를 3회 이상 연속 사용할 수 없습니다.');
}
// 강도 계산
let strengthLabel;
if (strength <= 2) {
strengthLabel = 'weak';
} else if (strength <= 3) {
strengthLabel = 'medium';
} else if (strength <= 4) {
strengthLabel = 'strong';
} else {
strengthLabel = 'very_strong';
}
return {
valid: errors.length === 0,
errors,
strength: strengthLabel,
score: strength
};
};
/**
* 간단한 비밀번호 검증 (기존 호환용)
* 모든 조건을 만족하면 true, 아니면 false
*
* @param {string} password - 검증할 비밀번호
* @returns {boolean} 유효 여부
*/
const isValidPassword = (password) => {
return validatePassword(password).valid;
};
/**
* 비밀번호 검증 결과를 한국어 메시지로 반환
*
* @param {string} password - 검증할 비밀번호
* @returns {string|null} 오류 메시지 (유효하면 null)
*/
const getPasswordError = (password) => {
const result = validatePassword(password);
if (result.valid) {
return null;
}
return result.errors.join(' ');
};
/**
* Express 미들웨어: 요청 body의 password 또는 newPassword 필드 검증
*
* @param {string} fieldName - 검증할 필드명 (기본: 'password')
* @returns {Function} Express 미들웨어
*/
const validatePasswordMiddleware = (fieldName = 'password') => {
return (req, res, next) => {
const password = req.body[fieldName] || req.body.newPassword;
if (!password) {
return next(); // 비밀번호 필드가 없으면 다음 미들웨어로
}
const result = validatePassword(password);
if (!result.valid) {
return res.status(400).json({
success: false,
error: '비밀번호가 보안 요구사항을 충족하지 않습니다.',
details: result.errors,
code: 'WEAK_PASSWORD'
});
}
next();
};
};
module.exports = {
validatePassword,
isValidPassword,
getPasswordError,
validatePasswordMiddleware
};

View File

@@ -2,6 +2,41 @@
const { getDb } = require('../dbPool'); const { getDb } = require('../dbPool');
/**
* SQL Injection 방지를 위한 화이트리스트 검증
*/
const ALLOWED_ORDER_DIRECTIONS = ['ASC', 'DESC'];
const ALLOWED_TABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
const ALLOWED_COLUMN_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_.]*$/;
const validateOrderDirection = (direction) => {
const normalized = (direction || 'DESC').toUpperCase();
if (!ALLOWED_ORDER_DIRECTIONS.includes(normalized)) {
throw new Error(`Invalid order direction: ${direction}`);
}
return normalized;
};
const validateIdentifier = (identifier, type = 'column') => {
if (!identifier || typeof identifier !== 'string') {
throw new Error(`Invalid ${type} name`);
}
if (!ALLOWED_COLUMN_NAME_PATTERN.test(identifier)) {
throw new Error(`Invalid ${type} name: ${identifier}`);
}
return identifier;
};
const validateTableName = (tableName) => {
if (!tableName || typeof tableName !== 'string') {
throw new Error('Invalid table name');
}
if (!ALLOWED_TABLE_NAME_PATTERN.test(tableName)) {
throw new Error(`Invalid table name: ${tableName}`);
}
return tableName;
};
/** /**
* 페이지네이션 헬퍼 * 페이지네이션 헬퍼
*/ */
@@ -24,6 +59,10 @@ const executePagedQuery = async (baseQuery, countQuery, params = [], options = {
const { page = 1, limit = 10, orderBy = 'id', orderDirection = 'DESC' } = options; const { page = 1, limit = 10, orderBy = 'id', orderDirection = 'DESC' } = options;
const { limit: limitNum, offset, page: pageNum } = paginate(page, limit); const { limit: limitNum, offset, page: pageNum } = paginate(page, limit);
// SQL Injection 방지: 컬럼명과 정렬방향 검증
const safeOrderBy = validateIdentifier(orderBy, 'column');
const safeOrderDirection = validateOrderDirection(orderDirection);
try { try {
const db = await getDb(); const db = await getDb();
@@ -31,8 +70,8 @@ const executePagedQuery = async (baseQuery, countQuery, params = [], options = {
const [countResult] = await db.execute(countQuery, params); const [countResult] = await db.execute(countQuery, params);
const totalCount = countResult[0]?.total || 0; const totalCount = countResult[0]?.total || 0;
// 데이터 조회 (ORDER BY와 LIMIT 추가) // 데이터 조회 (ORDER BY와 LIMIT 추가) - 검증된 값만 사용
const pagedQuery = `${baseQuery} ORDER BY ${orderBy} ${orderDirection} LIMIT ${limitNum} OFFSET ${offset}`; const pagedQuery = `${baseQuery} ORDER BY ${safeOrderBy} ${safeOrderDirection} LIMIT ${limitNum} OFFSET ${offset}`;
const [rows] = await db.execute(pagedQuery, params); const [rows] = await db.execute(pagedQuery, params);
// 페이지네이션 메타데이터 계산 // 페이지네이션 메타데이터 계산
@@ -59,14 +98,17 @@ const executePagedQuery = async (baseQuery, countQuery, params = [], options = {
* 인덱스 최적화 제안 * 인덱스 최적화 제안
*/ */
const suggestIndexes = async (tableName) => { const suggestIndexes = async (tableName) => {
// SQL Injection 방지: 테이블명 검증
const safeTableName = validateTableName(tableName);
try { try {
const db = await getDb(); const db = await getDb();
// 현재 인덱스 조회 // 현재 인덱스 조회 - 검증된 테이블명 사용
const [indexes] = await db.execute(`SHOW INDEX FROM ${tableName}`); const [indexes] = await db.execute(`SHOW INDEX FROM \`${safeTableName}\``);
// 테이블 구조 조회 // 테이블 구조 조회 - 검증된 테이블명 사용
const [columns] = await db.execute(`DESCRIBE ${tableName}`); const [columns] = await db.execute(`DESCRIBE \`${safeTableName}\``);
const suggestions = []; const suggestions = [];
@@ -80,7 +122,7 @@ const suggestIndexes = async (tableName) => {
type: 'INDEX', type: 'INDEX',
column: col.Field, column: col.Field,
reason: '외래키 컬럼에 인덱스 추가로 JOIN 성능 향상', reason: '외래키 컬럼에 인덱스 추가로 JOIN 성능 향상',
sql: `CREATE INDEX idx_${tableName}_${col.Field} ON ${tableName}(${col.Field});` sql: `CREATE INDEX idx_${safeTableName}_${col.Field} ON \`${safeTableName}\`(\`${col.Field}\`);`
}); });
}); });
@@ -95,12 +137,12 @@ const suggestIndexes = async (tableName) => {
type: 'INDEX', type: 'INDEX',
column: col.Field, column: col.Field,
reason: '날짜 범위 검색 성능 향상', reason: '날짜 범위 검색 성능 향상',
sql: `CREATE INDEX idx_${tableName}_${col.Field} ON ${tableName}(${col.Field});` sql: `CREATE INDEX idx_${safeTableName}_${col.Field} ON \`${safeTableName}\`(\`${col.Field}\`);`
}); });
}); });
return { return {
tableName, tableName: safeTableName,
currentIndexes: indexes.map(idx => ({ currentIndexes: indexes.map(idx => ({
name: idx.Key_name, name: idx.Key_name,
column: idx.Column_name, column: idx.Column_name,
@@ -179,6 +221,9 @@ const batchInsert = async (tableName, data, batchSize = 100) => {
throw new Error('삽입할 데이터가 없습니다.'); throw new Error('삽입할 데이터가 없습니다.');
} }
// SQL Injection 방지: 테이블명 검증
const safeTableName = validateTableName(tableName);
try { try {
const db = await getDb(); const db = await getDb();
const connection = await db.getConnection(); const connection = await db.getConnection();
@@ -186,8 +231,11 @@ const batchInsert = async (tableName, data, batchSize = 100) => {
await connection.beginTransaction(); await connection.beginTransaction();
const columns = Object.keys(data[0]); const columns = Object.keys(data[0]);
const placeholders = columns.map(() => '?').join(', '); // 컬럼명도 검증
const insertQuery = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders})`; const safeColumns = columns.map(col => validateIdentifier(col, 'column'));
const placeholders = safeColumns.map(() => '?').join(', ');
const columnList = safeColumns.map(col => `\`${col}\``).join(', ');
const insertQuery = `INSERT INTO \`${safeTableName}\` (${columnList}) VALUES (${placeholders})`;
let insertedCount = 0; let insertedCount = 0;

137
deploy/README.md Normal file
View File

@@ -0,0 +1,137 @@
# TK-FB-Project Synology NAS 배포 가이드
## 사전 준비
### 1. Synology NAS 요구사항
- DSM 7.0 이상
- Docker 패키지 설치
- 최소 4GB RAM 권장
- 10GB 이상 저장공간
### 2. Cloudflare Tunnel 설정
1. **Cloudflare 대시보드 접속**
- https://dash.cloudflare.com 로그인
- Zero Trust > Access > Tunnels 이동
2. **터널 생성**
- "Create a tunnel" 클릭
- 이름 입력 (예: tkfb-nas)
- 환경: Docker 선택
- 표시되는 토큰을 `.env` 파일의 `CLOUDFLARE_TUNNEL_TOKEN`에 입력
3. **Public hostname 설정**
- 터널 설정에서 "Public Hostnames" 추가
| Subdomain | Domain | Service |
|-----------|--------|---------|
| tkfb | yourdomain.com | http://web:80 |
| api.tkfb | yourdomain.com | http://api:3005 |
## 배포 순서
### 1. 파일 전송
Synology NAS의 docker 폴더에 다음 파일들을 업로드:
```
/volume1/docker/tkfb/
├── docker-compose.synology.yml (→ docker-compose.yml로 이름 변경)
├── .env (→ .env.synology 복사 후 수정)
├── backup_YYYYMMDD_HHMMSS.sql
├── api.hyungi.net/
├── web-ui/
└── fastapi-bridge/
```
### 2. 환경 변수 설정
```bash
cd /volume1/docker/tkfb
cp .env.synology .env
# .env 파일 편집하여 비밀번호, 토큰 등 수정
```
### 3. Docker Compose 실행
```bash
# SSH로 NAS 접속 후
cd /volume1/docker/tkfb
# 이미지 빌드 및 시작
docker-compose up -d --build
# 로그 확인
docker-compose logs -f
```
### 4. 데이터베이스 복원
```bash
# DB 컨테이너가 시작된 후 (약 30초 대기)
docker exec -i tkfb_db mysql -u root -p'비밀번호' < backup_YYYYMMDD_HHMMSS.sql
```
## 포트 설정
| 서비스 | 내부포트 | 외부포트 | 설명 |
|--------|----------|----------|------|
| web | 80 | 80 | Web UI |
| api | 3005 | 3005 | Node.js API |
| fastapi | 8000 | 8000 | FastAPI Bridge |
| db | 3306 | 3306 | MariaDB |
| phpmyadmin | 80 | 8080 | DB 관리도구 |
## Cloudflare Tunnel 사용 시
Cloudflare Tunnel을 사용하면 포트 포워딩 없이 외부 접속이 가능합니다:
- 방화벽 포트 개방 불필요
- 자동 HTTPS 인증서
- DDoS 보호
### web-ui의 API 주소 변경
`web-ui/js/config.js` 또는 관련 설정 파일에서 API URL을 변경:
```javascript
// 로컬 테스트
const API_URL = 'http://localhost:3005';
// Cloudflare Tunnel 사용 시
const API_URL = 'https://api.tkfb.yourdomain.com';
```
## 문제 해결
### 1. 컨테이너 상태 확인
```bash
docker-compose ps
docker-compose logs api # API 로그
docker-compose logs db # DB 로그
```
### 2. 데이터베이스 연결 오류
```bash
# DB 컨테이너 재시작
docker-compose restart db
# DB 상태 확인
docker exec tkfb_db mysqladmin -u root -p ping
```
### 3. 권한 오류
```bash
# 볼륨 권한 설정
chmod -R 755 /volume1/docker/tkfb
chown -R 1000:1000 /volume1/docker/tkfb/api.hyungi.net/uploads
```
## 업데이트
```bash
cd /volume1/docker/tkfb
# 최신 코드 다운로드 후
docker-compose down
docker-compose up -d --build
# 캐시 포함 전체 재빌드
docker-compose build --no-cache
docker-compose up -d
```

File diff suppressed because it is too large Load Diff

74
deploy/deploy.sh Executable file
View File

@@ -0,0 +1,74 @@
#!/bin/bash
# =============================================================================
# TK-FB-Project Synology NAS 배포 스크립트
# =============================================================================
set -e
echo "=========================================="
echo "TK-FB-Project 배포 시작"
echo "=========================================="
# 1. 환경 변수 파일 확인
if [ ! -f .env ]; then
echo "❌ .env 파일이 없습니다."
echo " .env.synology 파일을 복사하고 값을 수정하세요:"
echo " cp .env.synology .env"
exit 1
fi
# 2. Cloudflare Tunnel 토큰 확인
if grep -q "여기에_터널_토큰_입력" .env; then
echo "⚠️ Cloudflare Tunnel 토큰이 설정되지 않았습니다."
echo " .env 파일에서 CLOUDFLARE_TUNNEL_TOKEN을 설정하세요."
fi
# 3. Docker 이미지 빌드
echo ""
echo "🔨 Docker 이미지 빌드 중..."
docker-compose -f docker-compose.synology.yml build --no-cache
# 4. 기존 컨테이너 중지
echo ""
echo "🛑 기존 컨테이너 중지 중..."
docker-compose -f docker-compose.synology.yml down 2>/dev/null || true
# 5. 컨테이너 시작
echo ""
echo "🚀 컨테이너 시작 중..."
docker-compose -f docker-compose.synology.yml up -d
# 6. DB 초기화 대기
echo ""
echo "⏳ 데이터베이스 초기화 대기 중 (30초)..."
sleep 30
# 7. 데이터베이스 복원 (백업 파일이 있는 경우)
BACKUP_FILE=$(ls -t backup_*.sql 2>/dev/null | head -1)
if [ -n "$BACKUP_FILE" ]; then
echo ""
echo "📦 데이터베이스 복원 중: $BACKUP_FILE"
docker exec -i tkfb_db mysql -u root -p"$MYSQL_ROOT_PASSWORD" < "$BACKUP_FILE"
echo "✅ 데이터베이스 복원 완료"
fi
# 8. 상태 확인
echo ""
echo "=========================================="
echo "📊 컨테이너 상태"
echo "=========================================="
docker-compose -f docker-compose.synology.yml ps
echo ""
echo "=========================================="
echo "✅ 배포 완료!"
echo "=========================================="
echo ""
echo "접속 URL:"
echo " - Web UI: http://localhost:80"
echo " - API: http://localhost:3005"
echo " - phpMyAdmin: http://localhost:8080"
echo ""
echo "Cloudflare Tunnel 설정 시:"
echo " - 외부 접속: https://your-domain.com"
echo ""

View File

@@ -0,0 +1,150 @@
version: "3.8"
services:
# MariaDB 데이터베이스
db:
image: mariadb:10.9
container_name: tkfb_db
restart: unless-stopped
environment:
- MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
- MYSQL_DATABASE=${MYSQL_DATABASE:-hyungi}
- MYSQL_USER=${MYSQL_USER:-hyungi_user}
- MYSQL_PASSWORD=${MYSQL_PASSWORD}
volumes:
- db_data:/var/lib/mysql
- ./init-db:/docker-entrypoint-initdb.d
ports:
- "3306:3306"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
timeout: 20s
retries: 10
networks:
- tkfb_network
# Node.js API 서버
api:
build:
context: ./api.hyungi.net
dockerfile: Dockerfile
container_name: tkfb_api
env_file:
- ./.env
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
restart: unless-stopped
ports:
- "3005:3005"
environment:
- NODE_ENV=production
- PORT=3005
- DB_HOST=db
- DB_PORT=3306
- DB_USER=${MYSQL_USER:-hyungi_user}
- DB_PASSWORD=${MYSQL_PASSWORD}
- DB_NAME=${MYSQL_DATABASE:-hyungi}
- DB_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
- JWT_SECRET=${JWT_SECRET}
- JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-7d}
- JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}
- JWT_REFRESH_EXPIRES_IN=${JWT_REFRESH_EXPIRES_IN:-30d}
- REDIS_HOST=redis
- REDIS_PORT=6379
- WEATHER_API_URL=${WEATHER_API_URL:-}
- WEATHER_API_KEY=${WEATHER_API_KEY:-}
volumes:
- ./api.hyungi.net/uploads:/usr/src/app/uploads
- ./api.hyungi.net/logs:/usr/src/app/logs
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
- tkfb_network
# Web UI (Nginx)
web:
build:
context: ./web-ui
dockerfile: Dockerfile
container_name: tkfb_web
restart: unless-stopped
ports:
- "80:80"
volumes:
- ./web-ui:/usr/share/nginx/html:ro
depends_on:
- api
networks:
- tkfb_network
# FastAPI Bridge
fastapi:
build:
context: ./fastapi-bridge
dockerfile: Dockerfile
container_name: tkfb_fastapi
restart: unless-stopped
ports:
- "8000:8000"
environment:
- API_BASE_URL=http://api:3005
depends_on:
- api
networks:
- tkfb_network
# Redis Cache
redis:
image: redis:6-alpine
container_name: tkfb_redis
restart: unless-stopped
expose:
- "6379"
networks:
- tkfb_network
# Cloudflare Tunnel
cloudflared:
image: cloudflare/cloudflared:latest
container_name: tkfb_cloudflared
restart: unless-stopped
command: tunnel --no-autoupdate run
environment:
- TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN}
depends_on:
- web
- api
networks:
- tkfb_network
# phpMyAdmin (선택사항 - 보안상 제거 권장)
phpmyadmin:
image: phpmyadmin/phpmyadmin:latest
container_name: tkfb_phpmyadmin
depends_on:
- db
restart: unless-stopped
ports:
- "8080:80"
environment:
- PMA_HOST=db
- PMA_USER=root
- PMA_PASSWORD=${MYSQL_ROOT_PASSWORD}
- UPLOAD_LIMIT=50M
networks:
- tkfb_network
volumes:
db_data:
driver: local
networks:
tkfb_network:
driver: bridge
name: tkfb_network

71
deploy/package.sh Executable file
View File

@@ -0,0 +1,71 @@
#!/bin/bash
# =============================================================================
# 배포 패키지 생성 스크립트
# =============================================================================
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
DEPLOY_DIR="$SCRIPT_DIR"
PACKAGE_DIR="$DEPLOY_DIR/tkfb-package"
echo "=========================================="
echo "배포 패키지 생성"
echo "=========================================="
# 기존 패키지 삭제
rm -rf "$PACKAGE_DIR"
mkdir -p "$PACKAGE_DIR"
# 1. Docker 설정 파일
echo "📦 Docker 설정 복사..."
cp "$DEPLOY_DIR/docker-compose.synology.yml" "$PACKAGE_DIR/docker-compose.yml"
cp "$DEPLOY_DIR/.env.synology" "$PACKAGE_DIR/.env.example"
cp "$DEPLOY_DIR/deploy.sh" "$PACKAGE_DIR/"
cp "$DEPLOY_DIR/README.md" "$PACKAGE_DIR/"
# 2. 데이터베이스 백업
echo "📦 DB 백업 복사..."
cp "$DEPLOY_DIR"/backup_*.sql "$PACKAGE_DIR/" 2>/dev/null || echo "⚠️ DB 백업 파일 없음"
# 3. 소스 코드
echo "📦 소스 코드 복사..."
# API
mkdir -p "$PACKAGE_DIR/api.hyungi.net"
rsync -a --exclude='node_modules' --exclude='logs/*' --exclude='.git' \
"$PROJECT_DIR/api.hyungi.net/" "$PACKAGE_DIR/api.hyungi.net/"
# Web UI
mkdir -p "$PACKAGE_DIR/web-ui"
rsync -a --exclude='.git' \
"$PROJECT_DIR/web-ui/" "$PACKAGE_DIR/web-ui/"
# 프로덕션 config 복사
cp "$DEPLOY_DIR/web-ui-config.js" "$PACKAGE_DIR/web-ui/js/config.js"
# FastAPI
mkdir -p "$PACKAGE_DIR/fastapi-bridge"
rsync -a --exclude='__pycache__' --exclude='.git' --exclude='venv' \
"$PROJECT_DIR/fastapi-bridge/" "$PACKAGE_DIR/fastapi-bridge/"
# 4. init-db 폴더 생성 (초기 스키마용)
mkdir -p "$PACKAGE_DIR/init-db"
echo "-- 초기 데이터베이스 생성 완료 시 실행됨" > "$PACKAGE_DIR/init-db/README.txt"
# 5. 압축
echo "📦 압축 중..."
cd "$DEPLOY_DIR"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
tar -czf "tkfb-deploy-$TIMESTAMP.tar.gz" -C "$DEPLOY_DIR" tkfb-package
# 크기 확인
echo ""
echo "=========================================="
echo "✅ 패키지 생성 완료!"
echo "=========================================="
ls -lh "$DEPLOY_DIR/tkfb-deploy-$TIMESTAMP.tar.gz"
echo ""
echo "파일: $DEPLOY_DIR/tkfb-deploy-$TIMESTAMP.tar.gz"
echo ""
echo "Synology NAS로 전송:"
echo " scp $DEPLOY_DIR/tkfb-deploy-$TIMESTAMP.tar.gz admin@nas:/volume1/docker/"

View File

@@ -0,0 +1,34 @@
# =============================================================================
# Synology NAS 배포용 환경 변수
# =============================================================================
# 데이터베이스 설정
MYSQL_ROOT_PASSWORD=변경필수_강력한비밀번호
MYSQL_DATABASE=hyungi
MYSQL_USER=hyungi_user
MYSQL_PASSWORD=변경필수_강력한비밀번호
# API 서버 설정
NODE_ENV=production
PORT=3005
DB_HOST=db
DB_PORT=3306
DB_USER=hyungi_user
DB_PASSWORD=변경필수_강력한비밀번호
DB_NAME=hyungi
# JWT 인증 설정 (새로 생성 권장: openssl rand -base64 32)
JWT_SECRET=변경필수_최소32자이상_랜덤문자열
JWT_EXPIRES_IN=7d
JWT_REFRESH_SECRET=변경필수_최소32자이상_랜덤문자열
JWT_REFRESH_EXPIRES_IN=30d
# FastAPI 설정
API_BASE_URL=http://api:3005
# Cloudflare Tunnel 토큰 (Cloudflare 대시보드에서 발급)
CLOUDFLARE_TUNNEL_TOKEN=여기에_터널_토큰_입력
# 기상청 API (선택사항)
WEATHER_API_URL=https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0
WEATHER_API_KEY=

View File

@@ -0,0 +1,137 @@
# TK-FB-Project Synology NAS 배포 가이드
## 사전 준비
### 1. Synology NAS 요구사항
- DSM 7.0 이상
- Docker 패키지 설치
- 최소 4GB RAM 권장
- 10GB 이상 저장공간
### 2. Cloudflare Tunnel 설정
1. **Cloudflare 대시보드 접속**
- https://dash.cloudflare.com 로그인
- Zero Trust > Access > Tunnels 이동
2. **터널 생성**
- "Create a tunnel" 클릭
- 이름 입력 (예: tkfb-nas)
- 환경: Docker 선택
- 표시되는 토큰을 `.env` 파일의 `CLOUDFLARE_TUNNEL_TOKEN`에 입력
3. **Public hostname 설정**
- 터널 설정에서 "Public Hostnames" 추가
| Subdomain | Domain | Service |
|-----------|--------|---------|
| tkfb | yourdomain.com | http://web:80 |
| api.tkfb | yourdomain.com | http://api:3005 |
## 배포 순서
### 1. 파일 전송
Synology NAS의 docker 폴더에 다음 파일들을 업로드:
```
/volume1/docker/tkfb/
├── docker-compose.synology.yml (→ docker-compose.yml로 이름 변경)
├── .env (→ .env.synology 복사 후 수정)
├── backup_YYYYMMDD_HHMMSS.sql
├── api.hyungi.net/
├── web-ui/
└── fastapi-bridge/
```
### 2. 환경 변수 설정
```bash
cd /volume1/docker/tkfb
cp .env.synology .env
# .env 파일 편집하여 비밀번호, 토큰 등 수정
```
### 3. Docker Compose 실행
```bash
# SSH로 NAS 접속 후
cd /volume1/docker/tkfb
# 이미지 빌드 및 시작
docker-compose up -d --build
# 로그 확인
docker-compose logs -f
```
### 4. 데이터베이스 복원
```bash
# DB 컨테이너가 시작된 후 (약 30초 대기)
docker exec -i tkfb_db mysql -u root -p'비밀번호' < backup_YYYYMMDD_HHMMSS.sql
```
## 포트 설정
| 서비스 | 내부포트 | 외부포트 | 설명 |
|--------|----------|----------|------|
| web | 80 | 80 | Web UI |
| api | 3005 | 3005 | Node.js API |
| fastapi | 8000 | 8000 | FastAPI Bridge |
| db | 3306 | 3306 | MariaDB |
| phpmyadmin | 80 | 8080 | DB 관리도구 |
## Cloudflare Tunnel 사용 시
Cloudflare Tunnel을 사용하면 포트 포워딩 없이 외부 접속이 가능합니다:
- 방화벽 포트 개방 불필요
- 자동 HTTPS 인증서
- DDoS 보호
### web-ui의 API 주소 변경
`web-ui/js/config.js` 또는 관련 설정 파일에서 API URL을 변경:
```javascript
// 로컬 테스트
const API_URL = 'http://localhost:3005';
// Cloudflare Tunnel 사용 시
const API_URL = 'https://api.tkfb.yourdomain.com';
```
## 문제 해결
### 1. 컨테이너 상태 확인
```bash
docker-compose ps
docker-compose logs api # API 로그
docker-compose logs db # DB 로그
```
### 2. 데이터베이스 연결 오류
```bash
# DB 컨테이너 재시작
docker-compose restart db
# DB 상태 확인
docker exec tkfb_db mysqladmin -u root -p ping
```
### 3. 권한 오류
```bash
# 볼륨 권한 설정
chmod -R 755 /volume1/docker/tkfb
chown -R 1000:1000 /volume1/docker/tkfb/api.hyungi.net/uploads
```
## 업데이트
```bash
cd /volume1/docker/tkfb
# 최신 코드 다운로드 후
docker-compose down
docker-compose up -d --build
# 캐시 포함 전체 재빌드
docker-compose build --no-cache
docker-compose up -d
```

View File

@@ -0,0 +1,4 @@
node_modules
npm-debug.log
Dockerfile
.dockerignore

View File

@@ -0,0 +1,199 @@
# API 서버 배포 가이드
## 자동 배포 (권장)
### 1. 배포 스크립트 실행
```bash
cd api.hyungi.net
# 처음 한 번만: 실행 권한 부여
chmod +x deploy.sh
# 배포 실행
./deploy.sh
```
배포 스크립트는 다음을 자동으로 처리합니다:
1. ✅ Git Pull
2. ✅ NPM Install (package.json 변경 시)
3. ✅ 데이터베이스 마이그레이션 (확인 후 실행)
4. ✅ PM2 서버 재시작
5. ✅ 상태 확인
---
## 수동 배포
### 1. Git Pull
```bash
cd api.hyungi.net
git pull
```
### 2. 의존성 설치 (package.json 변경 시)
```bash
npm install
```
### 3. 데이터베이스 마이그레이션
⚠️ **중요**: 마이그레이션 전 데이터베이스 백업을 권장합니다!
```bash
# 마이그레이션 실행
npm run db:migrate
# 마이그레이션 롤백 (문제 발생 시)
npm run db:rollback
```
### 4. PM2 서버 재시작
```bash
# 무중단 재시작 (권장)
pm2 reload ecosystem.config.js --env production
# 또는 일반 재시작
pm2 restart hyungi-api
# 서버 중지 후 시작
pm2 stop hyungi-api
pm2 start ecosystem.config.js --env production
```
---
## 배포 후 확인사항
### 1. 서버 상태 확인
```bash
# PM2 프로세스 목록
pm2 list
# 실시간 로그 확인
pm2 logs hyungi-api
# 에러 로그만 확인
pm2 logs hyungi-api --err
```
### 2. API 응답 확인
```bash
# Health Check
curl http://localhost:20005/health
# 또는
curl http://api.hyungi.net/health
```
### 3. 마이그레이션 상태 확인
```bash
# 현재 마이그레이션 버전 확인
npx knex migrate:currentVersion --knexfile knexfile.js
# 적용된 마이그레이션 목록
npx knex migrate:list --knexfile knexfile.js
```
---
## 문제 해결
### 마이그레이션 실패 시
1. **에러 로그 확인**
```bash
pm2 logs hyungi-api --err
```
2. **마이그레이션 롤백**
```bash
npm run db:rollback
```
3. **특정 마이그레이션만 실행**
```bash
npx knex migrate:up 20260119095549_add_worker_display_fields.js --knexfile knexfile.js
```
### 서버 시작 실패 시
1. **포트 충돌 확인**
```bash
lsof -i :20005
```
2. **PM2 프로세스 완전 삭제 후 재시작**
```bash
pm2 delete hyungi-api
pm2 start ecosystem.config.js --env production
```
3. **환경변수 확인**
```bash
cat .env
```
---
## 환경별 배포
### Development (개발)
```bash
NODE_ENV=development npm run db:migrate
pm2 reload ecosystem.config.js --env development
```
### Production (운영)
```bash
NODE_ENV=production npm run db:migrate
pm2 reload ecosystem.config.js --env production
```
---
## 데이터베이스 백업
### 백업 생성
```bash
# MySQL 백업
mysqldump -h DB_HOST -u DB_USER -p DB_NAME > backup_$(date +%Y%m%d_%H%M%S).sql
```
### 백업 복구
```bash
mysql -h DB_HOST -u DB_USER -p DB_NAME < backup_20260119_120000.sql
```
---
## CI/CD 자동화 (향후 개선안)
GitHub Actions 또는 GitLab CI/CD를 사용한 자동 배포:
```yaml
# .github/workflows/deploy.yml 예시
name: Deploy to Production
on:
push:
branches: [ master ]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: SSH and Deploy
run: |
ssh user@server 'cd /path/to/api.hyungi.net && ./deploy.sh'
```
---
## 참고사항
- **마이그레이션은 한 방향으로만 진행** (forward-only)
- **rollback은 개발 환경에서만 사용 권장**
- **운영 환경에서는 반드시 백업 후 마이그레이션**
- **PM2 reload는 무중단 재시작** (downtime 없음)

View File

@@ -0,0 +1,33 @@
# Node.js 공식 이미지 사용
FROM node:18-alpine
# 작업 디렉토리 설정
WORKDIR /usr/src/app
# 패키지 파일 복사 (캐싱 최적화)
COPY package*.json ./
# 프로덕션 의존성만 설치
RUN npm ci --only=production
# 앱 소스 복사
COPY . .
# 로그 디렉토리 생성
RUN mkdir -p logs uploads
# 실행 권한 설정
RUN chown -R node:node /usr/src/app
# 보안을 위해 non-root 사용자로 실행
USER node
# 포트 노출
EXPOSE 3005
# 헬스체크 추가
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:3005/api/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1); })"
# 앱 시작
CMD ["node", "index.js"]

View File

@@ -0,0 +1,89 @@
/**
* CORS 설정
*
* Cross-Origin Resource Sharing 설정
*
* @author TK-FB-Project
* @since 2025-12-11
*/
const logger = require('../utils/logger');
/**
* 허용된 Origin 목록
*/
const allowedOrigins = [
'http://localhost:20000', // 웹 UI
'http://localhost:3005', // API 서버
'http://localhost:3000', // 개발 포트
'http://127.0.0.1:20000', // 로컬호스트 대체
'http://127.0.0.1:3005',
'http://127.0.0.1:3000'
];
/**
* CORS 설정 옵션
*/
const corsOptions = {
/**
* Origin 검증 함수
*/
origin: function (origin, callback) {
// Origin이 없는 경우 (직접 접근, Postman 등)
if (!origin) {
logger.debug('CORS: Origin 없음 - 허용');
return callback(null, true);
}
// 허용된 Origin 확인
if (allowedOrigins.includes(origin)) {
logger.debug('CORS: 허용된 Origin', { origin });
return callback(null, true);
}
// 개발 환경에서는 모든 localhost 허용
if (process.env.NODE_ENV === 'development') {
if (origin.includes('localhost') || origin.includes('127.0.0.1')) {
logger.debug('CORS: 로컬호스트 허용 (개발 모드)', { origin });
return callback(null, true);
}
}
// 로컬 네트워크 IP 자동 허용 (192.168.x.x)
if (origin.match(/^http:\/\/192\.168\.\d+\.\d+(:\d+)?$/)) {
logger.debug('CORS: 로컬 네트워크 IP 허용', { origin });
return callback(null, true);
}
// 차단
logger.warn('CORS: 차단된 Origin', { origin });
callback(new Error(`CORS 정책에 의해 차단됨: ${origin}`));
},
/**
* 인증 정보 포함 허용
*/
credentials: true,
/**
* 허용된 HTTP 메소드
*/
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
/**
* 허용된 헤더
*/
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
/**
* 노출할 헤더
*/
exposedHeaders: ['Content-Range', 'X-Content-Range'],
/**
* Preflight 요청 캐시 시간 (초)
*/
maxAge: 86400 // 24시간
};
module.exports = corsOptions;

View File

@@ -0,0 +1,79 @@
/**
* 데이터베이스 연결 설정
*
* MySQL/MariaDB 커넥션 풀 관리
* - 환경 변수 기반 설정
* - 자동 재연결 (최대 5회 재시도)
* - UTF-8MB4 문자셋 지원
*
* @author TK-FB-Project
* @since 2025-12-11
*/
require('dotenv').config();
const mysql = require('mysql2/promise');
const retry = require('async-retry');
const logger = require('../utils/logger');
let pool = null;
async function initPool() {
if (pool) return pool;
const {
DB_HOST, DB_PORT, DB_USER,
DB_PASSWORD, DB_NAME,
DB_SOCKET, DB_CONN_LIMIT = '10'
} = process.env;
if (!DB_USER || !DB_PASSWORD || !DB_NAME) {
throw new Error('필수 환경변수(DB_USER, DB_PASSWORD, DB_NAME)가 없습니다.');
}
if (!DB_SOCKET && !DB_HOST) {
throw new Error('DB_SOCKET이 없으면 DB_HOST가 반드시 필요합니다.');
}
await retry(async () => {
const config = {
user: DB_USER,
password: DB_PASSWORD,
database: DB_NAME,
waitForConnections: true,
connectionLimit: parseInt(DB_CONN_LIMIT, 10),
queueLimit: 0,
charset: 'utf8mb4'
};
if (DB_SOCKET) {
config.socketPath = DB_SOCKET;
} else {
config.host = DB_HOST;
config.port = parseInt(DB_PORT, 10);
}
pool = mysql.createPool(config);
// 첫 연결 검증
const conn = await pool.getConnection();
await conn.query('SET NAMES utf8mb4');
conn.release();
const connectionInfo = DB_SOCKET ? `socket=${DB_SOCKET}` : `${DB_HOST}:${DB_PORT}`;
logger.info('MariaDB 연결 성공', {
connection: connectionInfo,
database: DB_NAME,
connectionLimit: parseInt(DB_CONN_LIMIT, 10)
});
}, {
retries: 5,
factor: 2,
minTimeout: 1000
});
return pool;
}
async function getDb() {
return initPool();
}
module.exports = { getDb };

View File

@@ -0,0 +1,115 @@
/**
* 미들웨어 설정
*
* Express 애플리케이션의 모든 미들웨어를 등록하는 중앙화된 설정 파일
*
* @author TK-FB-Project
* @since 2025-12-11
*/
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const compression = require('compression');
const path = require('path');
const helmetOptions = require('./security');
const corsOptions = require('./cors');
const { responseMiddleware } = require('../utils/responseFormatter');
const logger = require('../utils/logger');
/**
* 모든 미들웨어를 Express 앱에 등록
* @param {Express.Application} app - Express 애플리케이션 인스턴스
*/
function setupMiddlewares(app) {
// 보안 헤더 설정 (Helmet)
app.use(helmet(helmetOptions));
// 성능 최적화 - Compression
app.use(compression({
filter: (req, res) => {
if (req.headers['x-no-compression']) {
return false;
}
return compression.filter(req, res);
},
level: 6, // 압축 레벨 (1-9, 6이 기본값)
threshold: 1024 // 1KB 이상만 압축
}));
// 요청 바디 파싱 - 용량 제한 확장
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
app.use(express.json({ limit: '50mb' }));
// 응답 포맷터 미들웨어
app.use(responseMiddleware);
// CORS 설정
app.use(cors(corsOptions));
// 정적 파일 서빙
app.use(express.static(path.join(__dirname, '../public')));
app.use('/uploads', express.static(path.join(__dirname, '../uploads')));
// Rate Limiting - API 요청 제한
const rateLimit = require('express-rate-limit');
// 일반 API 요청 제한
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 1000, // IP당 최대 1000 요청 (일괄 처리 지원)
message: {
success: false,
error: '너무 많은 요청입니다. 잠시 후 다시 시도해주세요.',
code: 'RATE_LIMIT_EXCEEDED'
},
standardHeaders: true,
legacyHeaders: false,
// 인증된 사용자는 더 많은 요청 허용
skip: (req) => {
// Authorization 헤더가 있으면 Rate Limit 완화
return req.headers.authorization && req.headers.authorization.startsWith('Bearer ');
}
});
// 로그인 시도 제한 (브루트포스 방지)
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 10, // IP당 최대 10회 로그인 시도
message: {
success: false,
error: '로그인 시도 횟수를 초과했습니다. 15분 후 다시 시도해주세요.',
code: 'LOGIN_RATE_LIMIT_EXCEEDED'
},
standardHeaders: true,
legacyHeaders: false
});
// Rate limiter 적용
app.use('/api/', apiLimiter);
app.use('/api/auth/login', loginLimiter);
logger.info('Rate Limiting 설정 완료');
// CSRF Protection (선택적 - 필요 시 주석 해제)
// const { verifyCsrfToken, getCsrfToken } = require('../middlewares/csrf');
//
// CSRF 토큰 발급 엔드포인트
// app.get('/api/csrf-token', getCsrfToken);
//
// CSRF 검증 미들웨어 (로그인 등 일부 경로 제외)
// app.use('/api/', verifyCsrfToken({
// ignorePaths: [
// '/api/auth/login',
// '/api/auth/register',
// '/api/health',
// '/api/csrf-token'
// ]
// }));
//
// logger.info('CSRF Protection 설정 완료');
logger.info('미들웨어 설정 완료');
}
module.exports = setupMiddlewares;

View File

@@ -0,0 +1,192 @@
/**
* 라우트 설정
*
* 애플리케이션의 모든 라우트를 등록하는 중앙화된 설정 파일
*
* @author TK-FB-Project
* @since 2025-12-11
*/
const swaggerUi = require('swagger-ui-express');
const swaggerSpec = require('./swagger');
const { verifyToken } = require('../middlewares/authMiddleware');
const { activityLogger } = require('../middlewares/activityLogger');
const logger = require('../utils/logger');
/**
* 모든 라우트를 Express 앱에 등록
* @param {Express.Application} app - Express 애플리케이션 인스턴스
*/
function setupRoutes(app) {
// 라우터 가져오기
const authRoutes = require('../routes/authRoutes');
const projectRoutes = require('../routes/projectRoutes');
const workerRoutes = require('../routes/workerRoutes');
const workReportRoutes = require('../routes/workReportRoutes');
const toolsRoute = require('../routes/toolsRoute');
const uploadRoutes = require('../routes/uploadRoutes');
const uploadBgRoutes = require('../routes/uploadBgRoutes');
const dailyIssueReportRoutes = require('../routes/dailyIssueReportRoutes');
const issueTypeRoutes = require('../routes/issueTypeRoutes');
const healthRoutes = require('../routes/healthRoutes');
const dailyWorkReportRoutes = require('../routes/dailyWorkReportRoutes');
const workAnalysisRoutes = require('../routes/workAnalysisRoutes');
const analysisRoutes = require('../routes/analysisRoutes');
const systemRoutes = require('../routes/systemRoutes');
const performanceRoutes = require('../routes/performanceRoutes');
const userRoutes = require('../routes/userRoutes');
const setupRoutes = require('../routes/setupRoutes');
const workReportAnalysisRoutes = require('../routes/workReportAnalysisRoutes');
const attendanceRoutes = require('../routes/attendanceRoutes');
const monthlyStatusRoutes = require('../routes/monthlyStatusRoutes');
const pageAccessRoutes = require('../routes/pageAccessRoutes');
const workplaceRoutes = require('../routes/workplaceRoutes');
const equipmentRoutes = require('../routes/equipmentRoutes');
const taskRoutes = require('../routes/taskRoutes');
const tbmRoutes = require('../routes/tbmRoutes');
const vacationRequestRoutes = require('../routes/vacationRequestRoutes');
const vacationTypeRoutes = require('../routes/vacationTypeRoutes');
const vacationBalanceRoutes = require('../routes/vacationBalanceRoutes');
const visitRequestRoutes = require('../routes/visitRequestRoutes');
const workIssueRoutes = require('../routes/workIssueRoutes');
const departmentRoutes = require('../routes/departmentRoutes');
const patrolRoutes = require('../routes/patrolRoutes');
const notificationRoutes = require('../routes/notificationRoutes');
const notificationRecipientRoutes = require('../routes/notificationRecipientRoutes');
// Rate Limiters 설정
const rateLimit = require('express-rate-limit');
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 5, // 최대 5회
message: '너무 많은 로그인 시도가 있었습니다. 잠시 후 다시 시도해주세요.',
standardHeaders: true,
legacyHeaders: false
});
const apiLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1분
max: 1000, // 최대 1000회 (기존 100회에서 대폭 증가)
message: 'API 요청 한도를 초과했습니다. 잠시 후 다시 시도해주세요.',
standardHeaders: true,
legacyHeaders: false,
// 관리자 및 시스템 계정은 rate limit 제외
skip: (req) => {
// 인증된 사용자 정보 확인
if (req.user && (req.user.access_level === 'system' || req.user.access_level === 'admin')) {
return true; // rate limit 건너뛰기
}
return false;
}
});
// 모든 API 요청에 활동 로거 적용
app.use('/api/*', activityLogger);
// 인증 불필요 경로 - 로그인
app.use('/api/auth', loginLimiter, authRoutes);
// DB 설정 라우트 (개발용)
app.use('/api/setup', setupRoutes);
// Health check
app.use('/api/health', healthRoutes);
// 인증이 필요 없는 공개 경로 목록
const publicPaths = [
'/api/auth/login',
'/api/auth/refresh-token',
'/api/auth/check-password-strength',
'/api/health',
'/api/ping',
'/api/status',
'/api/setup/setup-attendance-db',
'/api/setup/setup-monthly-status',
'/api/setup/add-overtime-warning',
'/api/setup/migrate-existing-data',
'/api/setup/check-data-status',
'/api/monthly-status/calendar',
'/api/monthly-status/daily-details',
'/api/migrate-work-type-id', // 임시 마이그레이션 - 실행 후 삭제!
'/api/diagnose-work-type-id', // 임시 진단 - 실행 후 삭제!
'/api/test-analysis' // 임시 분석 테스트 - 실행 후 삭제!
];
// 인증 미들웨어 - 공개 경로를 제외한 모든 API (rate limiter보다 먼저 실행)
app.use('/api/*', (req, res, next) => {
const isPublicPath = publicPaths.some(path => {
return req.originalUrl === path ||
req.originalUrl.startsWith(path + '?') ||
req.originalUrl.startsWith(path + '/');
});
if (isPublicPath) {
logger.debug('공개 경로 허용', { url: req.originalUrl });
return next();
}
logger.debug('인증 필요 경로', { url: req.originalUrl });
verifyToken(req, res, next);
});
// 인증 후 일반 API에 속도 제한 적용 (인증된 사용자 정보로 skip 판단)
app.use('/api/', apiLimiter);
// 인증된 사용자만 접근 가능한 라우트들
app.use('/api/issue-reports', dailyIssueReportRoutes);
app.use('/api/issue-types', issueTypeRoutes);
app.use('/api/workers', workerRoutes);
app.use('/api/daily-work-reports', dailyWorkReportRoutes);
app.use('/api/work-analysis', workAnalysisRoutes);
app.use('/api/analysis', analysisRoutes);
app.use('/api/daily-work-reports-analysis', workReportAnalysisRoutes);
app.use('/api/attendance', attendanceRoutes);
app.use('/api/monthly-status', monthlyStatusRoutes);
app.use('/api/workreports', workReportRoutes);
app.use('/api/system', systemRoutes);
app.use('/api/uploads', uploadRoutes);
app.use('/api/performance', performanceRoutes);
app.use('/api/projects', projectRoutes);
app.use('/api/tools', toolsRoute);
app.use('/api/users', userRoutes);
app.use('/api/workplaces', workplaceRoutes);
app.use('/api/equipments', equipmentRoutes);
app.use('/api/tasks', taskRoutes);
app.use('/api/vacation-requests', vacationRequestRoutes); // 휴가 신청 관리
app.use('/api/vacation-types', vacationTypeRoutes); // 휴가 유형 관리
app.use('/api/vacation-balances', vacationBalanceRoutes); // 휴가 잔액 관리
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/departments', departmentRoutes); // 부서 관리
app.use('/api/patrol', patrolRoutes); // 일일순회점검 시스템
app.use('/api/notifications', notificationRoutes); // 알림 시스템
app.use('/api/notification-recipients', notificationRecipientRoutes); // 알림 수신자 설정
app.use('/api', uploadBgRoutes);
// Swagger API 문서
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
explorer: true,
customCss: '.swagger-ui .topbar { display: none }',
customSiteTitle: 'TK Work Management API',
swaggerOptions: {
persistAuthorization: true,
displayRequestDuration: true,
docExpansion: 'none',
filter: true,
showExtensions: true,
showCommonExtensions: true
}
}));
app.get('/api-docs.json', (req, res) => {
res.setHeader('Content-Type', 'application/json');
res.send(swaggerSpec);
});
logger.info('라우트 설정 완료');
}
module.exports = setupRoutes;

View File

@@ -0,0 +1,101 @@
/**
* 보안 설정 (Helmet)
*
* HTTP 헤더 보안 설정
*
* @author TK-FB-Project
* @since 2025-12-11
*/
/**
* Helmet 보안 설정 옵션
*/
const helmetOptions = {
/**
* Content Security Policy
* XSS 공격 방지
*/
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"], // 개발 중 unsafe-eval 허용
imgSrc: ["'self'", "data:", "https:", "blob:"],
fontSrc: ["'self'", "https://fonts.gstatic.com"],
connectSrc: ["'self'", "https://api.technicalkorea.com"],
frameSrc: ["'none'"],
objectSrc: ["'none'"]
}
},
/**
* HTTP Strict Transport Security (HSTS)
* HTTPS 강제 사용
*/
hsts: {
maxAge: 31536000, // 1년
includeSubDomains: true,
preload: true
},
/**
* X-Frame-Options
* 클릭재킹 공격 방지
*/
frameguard: {
action: 'deny'
},
/**
* X-Content-Type-Options
* MIME 타입 스니핑 방지
*/
noSniff: true,
/**
* X-XSS-Protection
* XSS 필터 활성화
*/
xssFilter: true,
/**
* Referrer-Policy
* 리퍼러 정보 제어
*/
referrerPolicy: {
policy: 'strict-origin-when-cross-origin'
},
/**
* X-DNS-Prefetch-Control
* DNS prefetching 제어
*/
dnsPrefetchControl: {
allow: false
},
/**
* X-Download-Options
* IE8+ 다운로드 옵션
*/
ieNoOpen: true,
/**
* X-Permitted-Cross-Domain-Policies
* Adobe 제품의 크로스 도메인 정책
*/
permittedCrossDomainPolicies: {
permittedPolicies: 'none'
},
/**
* Cross-Origin-Resource-Policy
* 크로스 오리진 리소스 공유 설정
* 이미지 등 정적 파일을 다른 포트에서 로드할 수 있도록 허용
*/
crossOriginResourcePolicy: {
policy: 'cross-origin'
}
};
module.exports = helmetOptions;

View File

@@ -0,0 +1,497 @@
// config/swagger.js - Swagger/OpenAPI 설정
const swaggerJSDoc = require('swagger-jsdoc');
const swaggerDefinition = {
openapi: '3.0.0',
info: {
title: 'Technical Korea Work Management API',
version: '2.1.0',
description: '보안이 강화된 생산관리 시스템 API - 작업자, 프로젝트, 일일 작업 보고서 관리',
contact: {
name: 'Technical Korea',
email: 'admin@technicalkorea.com'
},
license: {
name: 'MIT',
url: 'https://opensource.org/licenses/MIT'
}
},
servers: [
{
url: 'http://localhost:20005',
description: '개발 서버 (Docker)'
},
{
url: 'http://localhost:3005',
description: '로컬 개발 서버'
}
],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'JWT 토큰을 사용한 인증. 로그인 후 받은 토큰을 "Bearer {token}" 형식으로 입력하세요.'
}
},
schemas: {
// 공통 응답 스키마
SuccessResponse: {
type: 'object',
properties: {
success: {
type: 'boolean',
example: true
},
message: {
type: 'string',
example: '요청이 성공적으로 처리되었습니다.'
},
data: {
type: 'object',
description: '응답 데이터'
},
timestamp: {
type: 'string',
format: 'date-time',
example: '2024-01-01T00:00:00.000Z'
}
}
},
ErrorResponse: {
type: 'object',
properties: {
success: {
type: 'boolean',
example: false
},
error: {
type: 'string',
example: '오류 메시지'
},
timestamp: {
type: 'string',
format: 'date-time',
example: '2024-01-01T00:00:00.000Z'
}
}
},
PaginatedResponse: {
type: 'object',
properties: {
success: {
type: 'boolean',
example: true
},
message: {
type: 'string',
example: '데이터 조회 성공'
},
data: {
type: 'array',
items: {
type: 'object'
}
},
meta: {
type: 'object',
properties: {
pagination: {
type: 'object',
properties: {
currentPage: { type: 'integer', example: 1 },
totalPages: { type: 'integer', example: 10 },
totalCount: { type: 'integer', example: 100 },
limit: { type: 'integer', example: 10 },
hasNextPage: { type: 'boolean', example: true },
hasPrevPage: { type: 'boolean', example: false }
}
}
}
},
timestamp: {
type: 'string',
format: 'date-time'
}
}
},
// 사용자 관련 스키마
User: {
type: 'object',
properties: {
user_id: {
type: 'integer',
example: 1,
description: '사용자 ID'
},
username: {
type: 'string',
example: 'admin',
description: '사용자명'
},
name: {
type: 'string',
example: '관리자',
description: '실명'
},
email: {
type: 'string',
format: 'email',
example: 'admin@technicalkorea.com',
description: '이메일 주소'
},
role: {
type: 'string',
example: 'admin',
description: '역할'
},
access_level: {
type: 'string',
enum: ['user', 'admin', 'system'],
example: 'admin',
description: '접근 권한 레벨'
},
worker_id: {
type: 'integer',
example: 1,
description: '연결된 작업자 ID'
},
is_active: {
type: 'boolean',
example: true,
description: '활성 상태'
},
last_login_at: {
type: 'string',
format: 'date-time',
description: '마지막 로그인 시간'
},
created_at: {
type: 'string',
format: 'date-time',
description: '생성 시간'
},
updated_at: {
type: 'string',
format: 'date-time',
description: '수정 시간'
}
}
},
// 작업자 관련 스키마
Worker: {
type: 'object',
properties: {
worker_id: {
type: 'integer',
example: 1,
description: '작업자 ID'
},
worker_name: {
type: 'string',
example: '김철수',
description: '작업자 이름'
},
position: {
type: 'string',
example: '용접공',
description: '직책'
},
department: {
type: 'string',
example: '생산부',
description: '부서'
},
phone: {
type: 'string',
example: '010-1234-5678',
description: '전화번호'
},
email: {
type: 'string',
format: 'email',
example: 'worker@technicalkorea.com',
description: '이메일'
},
hire_date: {
type: 'string',
format: 'date',
example: '2024-01-01',
description: '입사일'
},
is_active: {
type: 'boolean',
example: true,
description: '활성 상태'
},
created_at: {
type: 'string',
format: 'date-time',
description: '생성 시간'
},
updated_at: {
type: 'string',
format: 'date-time',
description: '수정 시간'
}
}
},
// 프로젝트 관련 스키마
Project: {
type: 'object',
properties: {
project_id: {
type: 'integer',
example: 1,
description: '프로젝트 ID'
},
project_name: {
type: 'string',
example: '신규 플랜트 건설',
description: '프로젝트 이름'
},
description: {
type: 'string',
example: '대형 화학 플랜트 건설 프로젝트',
description: '프로젝트 설명'
},
start_date: {
type: 'string',
format: 'date',
example: '2024-01-01',
description: '시작일'
},
end_date: {
type: 'string',
format: 'date',
example: '2024-12-31',
description: '종료일'
},
status: {
type: 'string',
example: 'active',
description: '프로젝트 상태'
},
created_at: {
type: 'string',
format: 'date-time',
description: '생성 시간'
},
updated_at: {
type: 'string',
format: 'date-time',
description: '수정 시간'
}
}
},
// 작업 관련 스키마
Task: {
type: 'object',
properties: {
task_id: {
type: 'integer',
example: 1,
description: '작업 ID'
},
task_name: {
type: 'string',
example: '용접 작업',
description: '작업 이름'
},
description: {
type: 'string',
example: '파이프 용접 작업',
description: '작업 설명'
},
category: {
type: 'string',
example: '용접',
description: '작업 카테고리'
},
is_active: {
type: 'boolean',
example: true,
description: '활성 상태'
},
created_at: {
type: 'string',
format: 'date-time',
description: '생성 시간'
},
updated_at: {
type: 'string',
format: 'date-time',
description: '수정 시간'
}
}
},
// 일일 작업 보고서 관련 스키마
DailyWorkReport: {
type: 'object',
properties: {
id: {
type: 'integer',
example: 1,
description: '보고서 ID'
},
report_date: {
type: 'string',
format: 'date',
example: '2024-01-01',
description: '작업 날짜'
},
worker_id: {
type: 'integer',
example: 1,
description: '작업자 ID'
},
project_id: {
type: 'integer',
example: 1,
description: '프로젝트 ID'
},
work_type_id: {
type: 'integer',
example: 1,
description: '작업 유형 ID'
},
work_status_id: {
type: 'integer',
example: 1,
description: '작업 상태 ID (1:정규, 2:에러)'
},
error_type_id: {
type: 'integer',
example: null,
description: '에러 유형 ID (에러일 때만)'
},
work_hours: {
type: 'number',
format: 'decimal',
example: 8.5,
description: '작업 시간'
},
created_by: {
type: 'integer',
example: 1,
description: '작성자 user_id'
},
created_at: {
type: 'string',
format: 'date-time',
description: '생성 시간'
},
updated_at: {
type: 'string',
format: 'date-time',
description: '수정 시간'
},
// 조인된 데이터
worker_name: {
type: 'string',
example: '김철수',
description: '작업자 이름'
},
project_name: {
type: 'string',
example: '신규 플랜트 건설',
description: '프로젝트 이름'
},
work_type_name: {
type: 'string',
example: '용접',
description: '작업 유형 이름'
},
work_status_name: {
type: 'string',
example: '정규',
description: '작업 상태 이름'
},
error_type_name: {
type: 'string',
example: null,
description: '에러 유형 이름'
}
}
},
// 로그인 관련 스키마
LoginRequest: {
type: 'object',
required: ['username', 'password'],
properties: {
username: {
type: 'string',
example: 'admin',
description: '사용자명'
},
password: {
type: 'string',
example: 'password123',
description: '비밀번호'
}
}
},
LoginResponse: {
type: 'object',
properties: {
success: {
type: 'boolean',
example: true
},
message: {
type: 'string',
example: '로그인 성공'
},
data: {
type: 'object',
properties: {
user: {
$ref: '#/components/schemas/User'
},
token: {
type: 'string',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
description: 'JWT 토큰'
},
redirectUrl: {
type: 'string',
example: '/pages/dashboard/group-leader.html',
description: '리다이렉트 URL'
}
}
},
timestamp: {
type: 'string',
format: 'date-time'
}
}
}
}
},
security: [
{
bearerAuth: []
}
]
};
const options = {
definition: swaggerDefinition,
apis: [
'./routes/*.js',
'./controllers/*.js',
'./index.js'
]
};
const swaggerSpec = swaggerJSDoc(options);
module.exports = swaggerSpec;

View File

@@ -0,0 +1,29 @@
/**
* 프로젝트 분석 컨트롤러
*
* 기간별 프로젝트 분석 API 엔드포인트 핸들러
*
* @author TK-FB-Project
* @since 2025-12-11
*/
const analysisService = require('../services/analysisService');
const { asyncHandler } = require('../middlewares/errorHandler');
/**
* 프로젝트 분석 데이터 조회
*/
const getAnalysisData = asyncHandler(async (req, res) => {
const { startDate, endDate } = req.query;
const data = await analysisService.getAnalysisService(startDate, endDate);
res.json({
success: true,
data,
message: '분석 데이터 조회 성공'
});
});
module.exports = {
getAnalysisData
};

View File

@@ -0,0 +1,212 @@
/**
* 근태 관리 컨트롤러
*
* 근태 기록 API 엔드포인트 핸들러
*
* @author TK-FB-Project
* @since 2025-12-11
*/
const attendanceService = require('../services/attendanceService');
const { asyncHandler } = require('../middlewares/errorHandler');
/**
* 일일 근태 현황 조회 (대시보드용)
*/
const getDailyAttendanceStatus = asyncHandler(async (req, res) => {
const { date } = req.query;
const data = await attendanceService.getDailyAttendanceStatusService(date);
res.json({
success: true,
data,
message: '근태 현황을 성공적으로 조회했습니다'
});
});
/**
* 일일 근태 기록 조회
*/
const getDailyAttendanceRecords = asyncHandler(async (req, res) => {
const { date, worker_id } = req.query;
const data = await attendanceService.getDailyAttendanceRecordsService(date, worker_id);
res.json({
success: true,
data,
message: '근태 기록을 성공적으로 조회했습니다'
});
});
/**
* 기간별 근태 기록 조회 (월별 조회용)
*/
const getAttendanceRecordsByRange = asyncHandler(async (req, res) => {
const { start_date, end_date, worker_id } = req.query;
const data = await attendanceService.getAttendanceRecordsByRangeService(start_date, end_date, worker_id);
res.json({
success: true,
data,
message: '근태 기록을 성공적으로 조회했습니다'
});
});
/**
* 근태 기록 생성/업데이트
*/
const upsertAttendanceRecord = asyncHandler(async (req, res) => {
const recordData = {
...req.body,
created_by: req.user?.user_id || req.user?.id
};
const result = await attendanceService.upsertAttendanceRecordService(recordData);
res.json({
success: true,
data: result,
message: '근태 기록이 성공적으로 저장되었습니다'
});
});
/**
* 휴가 처리
*/
const processVacation = asyncHandler(async (req, res) => {
const vacationData = {
record_date: req.body.date,
worker_id: req.body.worker_id,
vacation_type_id: req.body.vacation_type,
created_by: req.user?.user_id || req.user?.id
};
const result = await attendanceService.processVacationService(vacationData);
res.json({
success: true,
data: result,
message: '휴가 처리가 성공적으로 완료되었습니다'
});
});
/**
* 초과근무 승인
*/
const approveOvertime = asyncHandler(async (req, res) => {
const overtimeData = {
record_date: req.body.date,
worker_id: req.body.worker_id,
overtime_approved: true,
approved_by: req.user?.user_id || req.user?.id
};
const result = await attendanceService.approveOvertimeService(overtimeData);
res.json({
success: true,
data: result,
message: '초과근무가 성공적으로 승인되었습니다'
});
});
/**
* 근로 유형 목록 조회
*/
const getAttendanceTypes = asyncHandler(async (req, res) => {
const data = await attendanceService.getAttendanceTypesService();
res.json({
success: true,
data,
message: '근로 유형 목록을 성공적으로 조회했습니다'
});
});
/**
* 휴가 유형 목록 조회
*/
const getVacationTypes = asyncHandler(async (req, res) => {
const data = await attendanceService.getVacationTypesService();
res.json({
success: true,
data,
message: '휴가 유형 목록을 성공적으로 조회했습니다'
});
});
/**
* 작업자 휴가 잔여 조회
*/
const getWorkerVacationBalance = asyncHandler(async (req, res) => {
const { worker_id } = req.params;
const data = await attendanceService.getWorkerVacationBalanceService(parseInt(worker_id));
res.json({
success: true,
data,
message: '휴가 잔여 정보를 성공적으로 조회했습니다'
});
});
/**
* 월별 근태 통계
*/
const getMonthlyAttendanceStats = asyncHandler(async (req, res) => {
const { year, month, worker_id } = req.query;
const data = await attendanceService.getMonthlyAttendanceStatsService(
parseInt(year),
parseInt(month),
worker_id ? parseInt(worker_id) : null
);
res.json({
success: true,
data,
message: '월별 근태 통계를 성공적으로 조회했습니다'
});
});
/**
* 출근 체크 목록 조회 (아침용, 휴가 정보 포함)
*/
const getCheckinList = asyncHandler(async (req, res) => {
const { date } = req.query;
const data = await attendanceService.getCheckinListService(date);
res.json({
success: true,
data,
message: '출근 체크 목록을 성공적으로 조회했습니다'
});
});
/**
* 출근 체크 저장 (일괄 처리)
*/
const saveCheckins = asyncHandler(async (req, res) => {
const { date, checkins } = req.body; // checkins: [{worker_id, is_present}, ...]
const result = await attendanceService.saveCheckinsService(date, checkins);
res.json({
success: true,
data: result,
message: '출근 체크가 성공적으로 저장되었습니다'
});
});
module.exports = {
getDailyAttendanceStatus,
getDailyAttendanceRecords,
getAttendanceRecordsByRange,
upsertAttendanceRecord,
processVacation,
approveOvertime,
getAttendanceTypes,
getVacationTypes,
getWorkerVacationBalance,
getMonthlyAttendanceStats,
getCheckinList,
saveCheckins
};

View File

@@ -0,0 +1,161 @@
const { getDb } = require('../dbPool');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const authService = require('../services/auth.service');
const { asyncHandler } = require('../utils/errorHandler');
const { AuthenticationError, ValidationError } = require('../utils/errors');
const { validateSchema, schemas } = require('../utils/validator');
const login = asyncHandler(async (req, res) => {
const { username, password } = req.body;
const ipAddress = req.ip || req.connection.remoteAddress;
const userAgent = req.headers['user-agent'];
// 유효성 검사
if (!username || !password) {
throw new ValidationError('사용자명과 비밀번호를 입력해주세요.');
}
const result = await authService.loginService(username, password, ipAddress, userAgent);
if (!result.success) {
throw new AuthenticationError(result.error);
}
// 로그인 성공 후, 메인 대시보드로 리다이렉트
const user = result.data.user;
const redirectUrl = '/pages/dashboard.html'; // 메인 대시보드로 리다이렉트
// 새로운 응답 포맷터 사용
res.auth(user, result.data.token, redirectUrl, '로그인 성공');
});
// ✅ 사용자 등록 기능 추가
const register = async (req, res) => {
try {
const { username, password, name, access_level, worker_id } = req.body;
const db = await getDb();
// 필수 필드 검증
if (!username || !password || !name || !access_level) {
return res.status(400).json({
success: false,
error: '필수 정보가 누락되었습니다.'
});
}
// 중복 아이디 확인
const [existing] = await db.query(
'SELECT user_id FROM users WHERE username = ?',
[username]
);
if (existing.length > 0) {
return res.status(409).json({
success: false,
error: '이미 존재하는 아이디입니다.'
});
}
// 비밀번호 해시화
const hashedPassword = await bcrypt.hash(password, 10);
// role 설정 (access_level에 따라)
const roleMap = {
'admin': 'admin',
'system': 'system', // 시스템 계정은 system role로 설정
'group_leader': 'leader',
'support_team': 'support',
'worker': 'user'
};
const role = roleMap[access_level] || 'user';
// 사용자 등록
const [result] = await db.query(
`INSERT INTO users (username, password, name, role, access_level, worker_id)
VALUES (?, ?, ?, ?, ?, ?)`,
[username, hashedPassword, name, role, access_level, worker_id]
);
console.log('[사용자 등록 성공]', username);
return res.status(201).json({
success: true,
message: '사용자 등록이 완료되었습니다.',
user_id: result.insertId
});
} catch (err) {
console.error('[사용자 등록 오류]', err);
return res.status(500).json({
success: false,
error: '서버 오류가 발생했습니다.',
detail: err.message
});
}
};
// ✅ 사용자 삭제 기능 추가
const deleteUser = async (req, res) => {
try {
const { id } = req.params;
const db = await getDb();
// 사용자 존재 확인
const [user] = await db.query(
'SELECT user_id FROM users WHERE user_id = ?',
[id]
);
if (user.length === 0) {
return res.status(404).json({
success: false,
error: '해당 사용자를 찾을 수 없습니다.'
});
}
// 사용자 삭제
await db.query('DELETE FROM users WHERE user_id = ?', [id]);
console.log('[사용자 삭제 성공] ID:', id);
return res.status(200).json({
success: true,
message: '사용자가 삭제되었습니다.'
});
} catch (err) {
console.error('[사용자 삭제 오류]', err);
return res.status(500).json({
success: false,
error: '서버 오류가 발생했습니다.',
detail: err.message
});
}
};
// 모든 사용자 목록 조회
const getAllUsers = async (req, res) => {
try {
const db = await getDb();
// 비밀번호 제외하고 조회
const [rows] = await db.query(
`SELECT user_id, username, name, role, access_level, worker_id, created_at
FROM users
ORDER BY created_at DESC`
);
res.status(200).json(rows);
} catch (err) {
console.error('[사용자 목록 조회 실패]', err);
res.status(500).json({ error: '서버 오류' });
}
};
module.exports = {
login,
register,
deleteUser,
getAllUsers
};

View File

@@ -0,0 +1,64 @@
/**
* 일일 이슈 보고서 관리 컨트롤러
*
* 일일 이슈 보고서 CRUD API 엔드포인트 핸들러
*
* @author TK-FB-Project
* @since 2025-12-11
*/
const dailyIssueReportService = require('../services/dailyIssueReportService');
const { asyncHandler } = require('../middlewares/errorHandler');
/**
* 일일 이슈 보고서 생성
*/
const createDailyIssueReport = asyncHandler(async (req, res) => {
// 프론트엔드에서 worker_ids 또는 worker_id로 보낼 수 있음
const issueData = {
...req.body,
worker_ids: req.body.worker_ids || req.body.worker_id
};
const result = await dailyIssueReportService.createDailyIssueReportService(issueData);
res.status(201).json({
success: true,
data: result,
message: result.message
});
});
/**
* 날짜별 이슈 조회
*/
const getDailyIssuesByDate = asyncHandler(async (req, res) => {
const { date } = req.query;
const issues = await dailyIssueReportService.getDailyIssuesByDateService(date);
res.json({
success: true,
data: issues,
message: '이슈 보고서 조회 성공'
});
});
/**
* 이슈 보고서 삭제
*/
const removeDailyIssue = asyncHandler(async (req, res) => {
const { id } = req.params;
const result = await dailyIssueReportService.removeDailyIssueService(id);
res.json({
success: true,
data: result,
message: result.message
});
});
module.exports = {
createDailyIssueReport,
getDailyIssuesByDate,
removeDailyIssue
};

View File

@@ -0,0 +1,934 @@
/**
* 일일 작업 보고서 컨트롤러
*
* 작업 보고서 API 엔드포인트 핸들러
*
* @author TK-FB-Project
* @since 2025-12-11
*/
const dailyWorkReportModel = require('../models/dailyWorkReportModel');
const dailyWorkReportService = require('../services/dailyWorkReportService');
const { ValidationError, NotFoundError, DatabaseError } = require('../utils/errors');
const { asyncHandler } = require('../middlewares/errorHandler');
const logger = require('../utils/logger');
/**
* 📝 작업보고서 생성 (V2 - Service Layer 사용)
*/
const createDailyWorkReport = asyncHandler(async (req, res) => {
const reportData = {
...req.body,
created_by: req.user?.user_id || req.user?.id,
created_by_name: req.user?.name || req.user?.username || '알 수 없는 사용자'
};
const result = await dailyWorkReportService.createDailyWorkReportService(reportData);
res.status(201).json({
success: true,
data: result,
message: '작업보고서가 성공적으로 생성되었습니다'
});
});
/**
* 📊 기여자별 요약 조회 (새로운 기능)
*/
const getContributorsSummary = asyncHandler(async (req, res) => {
const { date, worker_id } = req.query;
if (!date || !worker_id) {
throw new ApiError('date와 worker_id가 필요합니다.', 400);
}
console.log(`📊 기여자별 요약 조회: date=${date}, worker_id=${worker_id}`);
try {
const data = await new Promise((resolve, reject) => {
dailyWorkReportModel.getContributorsByDate(date, worker_id, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
const totalHours = data.reduce((sum, contributor) => sum + parseFloat(contributor.total_hours || 0), 0);
console.log(`📊 기여자별 요약: ${data.length}명, 총 ${totalHours}시간`);
const result = {
date,
worker_id,
contributors: data,
total_contributors: data.length,
grand_total_hours: totalHours
};
res.success(result, '기여자별 요약 조회 성공');
} catch (err) {
handleDatabaseError(err, '기여자별 요약 조회');
}
});
/**
* 📊 개인 누적 현황 조회 (새로운 기능)
*/
const getMyAccumulatedData = (req, res) => {
const { date, worker_id } = req.query;
const created_by = req.user?.user_id || req.user?.id;
if (!date || !worker_id) {
return res.status(400).json({
error: 'date와 worker_id가 필요합니다.',
example: 'date=2024-06-16&worker_id=1'
});
}
if (!created_by) {
return res.status(401).json({
error: '사용자 인증 정보가 없습니다.'
});
}
console.log(`📊 개인 누적 현황 조회: date=${date}, worker_id=${worker_id}, created_by=${created_by}`);
dailyWorkReportModel.getMyAccumulatedHours(date, worker_id, created_by, (err, data) => {
if (err) {
console.error('개인 누적 현황 조회 오류:', err);
return res.status(500).json({
error: '개인 누적 현황 조회 중 오류가 발생했습니다.',
details: err.message
});
}
console.log(`📊 개인 누적: ${data.my_entry_count}개 항목, ${data.my_total_hours}시간`);
res.json({
date,
worker_id,
created_by,
my_data: data,
timestamp: new Date().toISOString()
});
});
};
/**
* 🗑️ 개별 항목 삭제 (본인 작성분만 - 새로운 기능)
*/
const removeMyEntry = (req, res) => {
const { id } = req.params;
const deleted_by = req.user?.user_id || req.user?.id;
if (!deleted_by) {
return res.status(401).json({
error: '사용자 인증 정보가 없습니다.'
});
}
console.log(`🗑️ 개별 항목 삭제 요청: id=${id}, 삭제자=${deleted_by}`);
dailyWorkReportModel.removeSpecificEntry(id, deleted_by, (err, result) => {
if (err) {
console.error('개별 항목 삭제 오류:', err);
return res.status(500).json({
error: '항목 삭제 중 오류가 발생했습니다.',
details: err.message
});
}
console.log(`✅ 개별 항목 삭제 완료: id=${id}`);
res.json({
message: '항목이 성공적으로 삭제되었습니다.',
id: id,
deleted_by,
timestamp: new Date().toISOString(),
...result
});
});
};
/**
* 📊 작업보고서 조회 (V2 - Service Layer 사용)
*/
const getDailyWorkReports = async (req, res) => {
try {
const userInfo = {
user_id: req.user?.user_id || req.user?.id,
role: req.user?.role || 'user' // 기본값을 'user'로 설정하여 안전하게 처리
};
if (!userInfo.user_id) {
return res.status(401).json({ error: '사용자 인증 정보가 없습니다.' });
}
const reports = await dailyWorkReportService.getDailyWorkReportsService(req.query, userInfo);
res.json(reports);
} catch (error) {
console.error('💥 작업보고서 조회 컨트롤러 오류:', error.message);
res.status(400).json({
success: false,
error: '작업보고서 조회에 실패했습니다.',
details: error.message
});
}
};
/**
* 📊 날짜별 작업보고서 조회 (경로 파라미터 - 권한별 전체 조회 지원)
*/
const getDailyWorkReportsByDate = (req, res) => {
const { date } = req.params;
const current_user_id = req.user?.user_id || req.user?.id;
const user_access_level = req.user?.access_level;
const user_job_type = req.user?.job_type;
if (!current_user_id) {
return res.status(401).json({
error: '사용자 인증 정보가 없습니다.'
});
}
const isAdmin = user_access_level === 'system' || user_access_level === 'admin' || user_access_level === 'leader' || user_job_type === 'leader';
console.log(`📊 날짜별 조회 (경로): date=${date}, user=${current_user_id}, 권한=${user_access_level}, 직책=${user_job_type}, 관리자=${isAdmin}`);
console.log(`🔍 사용자 정보 상세:`, req.user);
dailyWorkReportModel.getByDate(date, (err, data) => {
if (err) {
console.error('날짜별 작업보고서 조회 오류:', err);
return res.status(500).json({
error: '작업보고서 조회 중 오류가 발생했습니다.',
details: err.message
});
}
// 🎯 권한별 필터링 (임시로 비활성화)
let finalData = data;
console.log(`📊 임시로 모든 사용자에게 전체 조회 허용: ${data.length}`);
console.log(`📊 권한 정보: access_level=${user_access_level}, job_type=${user_job_type}, isAdmin=${isAdmin}`);
// if (!isAdmin) {
// finalData = data.filter(report => report.created_by === current_user_id);
// console.log(`📊 권한 필터링: 전체 ${data.length}개 → ${finalData.length}개`);
// } else {
// console.log(`📊 관리자 권한으로 전체 조회: ${data.length}개`);
// }
res.json(finalData);
});
};
/**
* 🔍 작업보고서 검색 (페이지네이션 포함)
*/
const searchWorkReports = (req, res) => {
const { start_date, end_date, worker_id, project_id, work_status_id, page = 1, limit = 20 } = req.query;
const created_by = req.user?.user_id || req.user?.id;
if (!start_date || !end_date) {
return res.status(400).json({
error: 'start_date와 end_date가 필요합니다.',
example: 'start_date=2024-01-01&end_date=2024-01-31',
optional: ['worker_id', 'project_id', 'work_status_id', 'page', 'limit']
});
}
if (!created_by) {
return res.status(401).json({
error: '사용자 인증 정보가 없습니다.'
});
}
const searchParams = {
start_date,
end_date,
worker_id: worker_id ? parseInt(worker_id) : null,
project_id: project_id ? parseInt(project_id) : null,
work_status_id: work_status_id ? parseInt(work_status_id) : null,
created_by, // 작성자 필터링 추가
page: parseInt(page),
limit: parseInt(limit)
};
console.log('🔍 작업보고서 검색 요청:', searchParams);
dailyWorkReportModel.searchWithDetails(searchParams, (err, data) => {
if (err) {
console.error('작업보고서 검색 오류:', err);
return res.status(500).json({
error: '작업보고서 검색 중 오류가 발생했습니다.',
details: err.message
});
}
console.log(`🔍 검색 결과: ${data.reports?.length || 0}개 (전체: ${data.total || 0}개)`);
res.json(data);
});
};
/**
* 📈 통계 조회 (V2 - Service Layer 사용)
*/
const getWorkReportStats = async (req, res) => {
try {
const statsData = await dailyWorkReportService.getStatisticsService(req.query);
res.json(statsData);
} catch (error) {
console.error('💥 통계 조회 컨트롤러 오류:', error.message);
res.status(400).json({
success: false,
error: '통계 조회에 실패했습니다.',
details: error.message
});
}
};
/**
* 📊 일일 근무 요약 조회 (V2 - Service Layer 사용)
*/
const getDailySummary = async (req, res) => {
try {
const summaryData = await dailyWorkReportService.getSummaryService(req.query);
res.json(summaryData);
} catch (error) {
console.error('💥 일일 요약 조회 컨트롤러 오류:', error.message);
res.status(400).json({
success: false,
error: '일일 요약 조회에 실패했습니다.',
details: error.message
});
}
};
/**
* 📅 월간 요약 조회
*/
const getMonthlySummary = (req, res) => {
const { year, month } = req.query;
if (!year || !month) {
return res.status(400).json({
error: 'year와 month가 필요합니다.',
example: 'year=2024&month=01',
note: 'month는 01, 02, ..., 12 형식으로 입력하세요.'
});
}
console.log(`📅 월간 요약 조회: ${year}-${month}`);
dailyWorkReportModel.getMonthlySummary(year, month, (err, data) => {
if (err) {
console.error('월간 요약 조회 오류:', err);
return res.status(500).json({
error: '월간 요약 조회 중 오류가 발생했습니다.',
details: err.message
});
}
res.json({
year: parseInt(year),
month: parseInt(month),
summary: data,
total_entries: data.length,
timestamp: new Date().toISOString()
});
});
};
/**
* ✏️ 작업보고서 수정 (V2 - Service Layer 사용)
*/
const updateWorkReport = async (req, res) => {
try {
const { id: reportId } = req.params;
const updateData = req.body;
const userInfo = {
user_id: req.user?.user_id || req.user?.id,
role: req.user?.role || 'user'
};
if (!userInfo.user_id) {
return res.status(401).json({ error: '사용자 인증 정보가 없습니다.' });
}
const result = await dailyWorkReportService.updateWorkReportService(reportId, updateData, userInfo);
res.json({
success: true,
timestamp: new Date().toISOString(),
...result
});
} catch (error) {
console.error(`💥 작업보고서 수정 컨트롤러 오류 (id: ${req.params.id}):`, error.message);
const statusCode = error.statusCode || 400;
res.status(statusCode).json({
success: false,
error: '작업보고서 수정에 실패했습니다.',
details: error.message
});
}
};
/**
* 🗑️ 특정 작업보고서 삭제 (V2 - Service Layer 사용)
* 권한: 그룹장(group_leader), 시스템(system), 관리자(admin)만 가능
*/
const removeDailyWorkReport = async (req, res) => {
try {
const { id: reportId } = req.params;
const userInfo = {
user_id: req.user?.user_id || req.user?.id,
access_level: req.user?.access_level || req.user?.role,
};
if (!userInfo.user_id) {
return res.status(401).json({ error: '사용자 인증 정보가 없습니다.' });
}
// 권한 체크: 그룹장, 시스템, 관리자만 삭제 가능
const allowedRoles = ['admin', 'system', 'group_leader'];
if (!allowedRoles.includes(userInfo.access_level)) {
return res.status(403).json({
error: '작업보고서 삭제 권한이 없습니다.',
details: '그룹장 이상의 권한이 필요합니다.'
});
}
const result = await dailyWorkReportService.removeDailyWorkReportService(reportId, userInfo);
res.json({
success: true,
timestamp: new Date().toISOString(),
...result
});
} catch (error) {
console.error(`💥 작업보고서 삭제 컨트롤러 오류 (id: ${req.params.id}):`, error.message);
const statusCode = error.statusCode || 400;
res.status(statusCode).json({
success: false,
error: '작업보고서 삭제에 실패했습니다.',
details: error.message
});
}
};
/**
* <20><> 작업자의 특정 날짜 전체 삭제
*/
const removeDailyWorkReportByDateAndWorker = (req, res) => {
const { date, worker_id } = req.params;
const deleted_by = req.user?.user_id || req.user?.id;
const access_level = req.user?.access_level || req.user?.role;
if (!deleted_by) {
return res.status(401).json({
error: '사용자 인증 정보가 없습니다.'
});
}
// 권한 체크: 그룹장, 시스템, 관리자만 삭제 가능
const allowedRoles = ['admin', 'system', 'group_leader'];
if (!allowedRoles.includes(access_level)) {
return res.status(403).json({
error: '작업보고서 삭제 권한이 없습니다.',
details: '그룹장 이상의 권한이 필요합니다.'
});
}
console.log(`🗑️ 날짜+작업자별 전체 삭제 요청: date=${date}, worker_id=${worker_id}, 삭제자=${deleted_by}`);
dailyWorkReportModel.removeByDateAndWorker(date, worker_id, deleted_by, (err, affectedRows) => {
if (err) {
console.error('작업보고서 전체 삭제 오류:', err);
return res.status(500).json({
error: '작업보고서 삭제 중 오류가 발생했습니다.',
details: err.message
});
}
if (affectedRows === 0) {
return res.status(404).json({
error: '삭제할 작업보고서를 찾을 수 없습니다.',
date: date,
worker_id: worker_id
});
}
console.log(`✅ 날짜+작업자별 전체 삭제 완료: ${affectedRows}`);
res.json({
message: `${date} 날짜의 작업자 ${worker_id} 작업보고서 ${affectedRows}개가 삭제되었습니다.`,
date,
worker_id,
affected_rows: affectedRows,
deleted_by,
timestamp: new Date().toISOString()
});
});
};
/**
* 📋 마스터 데이터 조회 함수들
*/
const getWorkTypes = (req, res) => {
console.log('📋 작업 유형 조회 요청');
dailyWorkReportModel.getAllWorkTypes((err, data) => {
if (err) {
console.error('작업 유형 조회 오류:', err);
return res.status(500).json({
success: false,
error: {
message: '작업 유형 조회 중 오류가 발생했습니다.',
code: 'DATABASE_ERROR'
}
});
}
console.log(`📋 작업 유형 조회 결과: ${data.length}`);
res.json({
success: true,
data: data,
message: '작업 유형 조회 성공'
});
});
};
const getWorkStatusTypes = (req, res) => {
console.log('📋 업무 상태 유형 조회 요청');
dailyWorkReportModel.getAllWorkStatusTypes((err, data) => {
if (err) {
console.error('업무 상태 유형 조회 오류:', err);
return res.status(500).json({
error: '업무 상태 유형 조회 중 오류가 발생했습니다.',
details: err.message
});
}
console.log(`📋 업무 상태 유형 조회 결과: ${data.length}`);
res.json(data);
});
};
const getErrorTypes = (req, res) => {
console.log('📋 에러 유형 조회 요청');
dailyWorkReportModel.getAllErrorTypes((err, data) => {
if (err) {
console.error('에러 유형 조회 오류:', err);
return res.status(500).json({
error: '에러 유형 조회 중 오류가 발생했습니다.',
details: err.message
});
}
console.log(`📋 에러 유형 조회 결과: ${data.length}`);
res.json(data);
});
};
// ========== 작업 유형 CRUD ==========
/**
* 📝 작업 유형 생성
*/
const createWorkType = asyncHandler(async (req, res) => {
const { name, description, category } = req.body;
if (!name) {
throw new ApiError('작업 유형 이름이 필요합니다.', 400);
}
console.log('📝 작업 유형 생성:', { name, description, category });
try {
const result = await new Promise((resolve, reject) => {
dailyWorkReportModel.createWorkType({ name, description, category }, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
res.created(result, '작업 유형이 성공적으로 생성되었습니다.');
} catch (err) {
handleDatabaseError(err, '작업 유형 생성');
}
});
/**
* ✏️ 작업 유형 수정
*/
const updateWorkType = asyncHandler(async (req, res) => {
const { id } = req.params;
const { name, description, category } = req.body;
if (!id) {
throw new ApiError('작업 유형 ID가 필요합니다.', 400);
}
console.log('✏️ 작업 유형 수정:', { id, name, description, category });
try {
const result = await new Promise((resolve, reject) => {
dailyWorkReportModel.updateWorkType(id, { name, description, category }, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
if (result.affectedRows === 0) {
throw new ApiError('수정할 작업 유형을 찾을 수 없습니다.', 404);
}
res.success(result, '작업 유형이 성공적으로 수정되었습니다.');
} catch (err) {
handleDatabaseError(err, '작업 유형 수정');
}
});
/**
* 🗑️ 작업 유형 삭제
*/
const deleteWorkType = asyncHandler(async (req, res) => {
const { id } = req.params;
if (!id) {
throw new ApiError('작업 유형 ID가 필요합니다.', 400);
}
console.log('🗑️ 작업 유형 삭제:', id);
try {
const result = await new Promise((resolve, reject) => {
dailyWorkReportModel.deleteWorkType(id, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
if (result.affectedRows === 0) {
throw new ApiError('삭제할 작업 유형을 찾을 수 없습니다.', 404);
}
res.success(result, '작업 유형이 성공적으로 삭제되었습니다.');
} catch (err) {
handleDatabaseError(err, '작업 유형 삭제');
}
});
// ========== 작업 상태 CRUD ==========
/**
* 📝 작업 상태 생성
*/
const createWorkStatus = asyncHandler(async (req, res) => {
const { name, description, is_error } = req.body;
if (!name) {
throw new ApiError('작업 상태 이름이 필요합니다.', 400);
}
console.log('📝 작업 상태 생성:', { name, description, is_error });
try {
const result = await new Promise((resolve, reject) => {
dailyWorkReportModel.createWorkStatus({ name, description, is_error }, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
res.created(result, '작업 상태가 성공적으로 생성되었습니다.');
} catch (err) {
handleDatabaseError(err, '작업 상태 생성');
}
});
/**
* ✏️ 작업 상태 수정
*/
const updateWorkStatus = asyncHandler(async (req, res) => {
const { id } = req.params;
const { name, description, is_error } = req.body;
if (!id) {
throw new ApiError('작업 상태 ID가 필요합니다.', 400);
}
console.log('✏️ 작업 상태 수정:', { id, name, description, is_error });
try {
const result = await new Promise((resolve, reject) => {
dailyWorkReportModel.updateWorkStatus(id, { name, description, is_error }, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
if (result.affectedRows === 0) {
throw new ApiError('수정할 작업 상태를 찾을 수 없습니다.', 404);
}
res.success(result, '작업 상태가 성공적으로 수정되었습니다.');
} catch (err) {
handleDatabaseError(err, '작업 상태 수정');
}
});
/**
* 🗑️ 작업 상태 삭제
*/
const deleteWorkStatus = asyncHandler(async (req, res) => {
const { id } = req.params;
if (!id) {
throw new ApiError('작업 상태 ID가 필요합니다.', 400);
}
console.log('🗑️ 작업 상태 삭제:', id);
try {
const result = await new Promise((resolve, reject) => {
dailyWorkReportModel.deleteWorkStatus(id, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
if (result.affectedRows === 0) {
throw new ApiError('삭제할 작업 상태를 찾을 수 없습니다.', 404);
}
res.success(result, '작업 상태가 성공적으로 삭제되었습니다.');
} catch (err) {
handleDatabaseError(err, '작업 상태 삭제');
}
});
// ========== 오류 유형 CRUD ==========
/**
* 📝 오류 유형 생성
*/
const createErrorType = asyncHandler(async (req, res) => {
const { name, description, severity } = req.body;
if (!name) {
throw new ApiError('오류 유형 이름이 필요합니다.', 400);
}
console.log('📝 오류 유형 생성:', { name, description, severity });
try {
const result = await new Promise((resolve, reject) => {
dailyWorkReportModel.createErrorType({ name, description, severity }, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
res.created(result, '오류 유형이 성공적으로 생성되었습니다.');
} catch (err) {
handleDatabaseError(err, '오류 유형 생성');
}
});
/**
* ✏️ 오류 유형 수정
*/
const updateErrorType = asyncHandler(async (req, res) => {
const { id } = req.params;
const { name, description, severity } = req.body;
if (!id) {
throw new ApiError('오류 유형 ID가 필요합니다.', 400);
}
console.log('✏️ 오류 유형 수정:', { id, name, description, severity });
try {
const result = await new Promise((resolve, reject) => {
dailyWorkReportModel.updateErrorType(id, { name, description, severity }, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
if (result.affectedRows === 0) {
throw new ApiError('수정할 오류 유형을 찾을 수 없습니다.', 404);
}
res.success(result, '오류 유형이 성공적으로 수정되었습니다.');
} catch (err) {
handleDatabaseError(err, '오류 유형 수정');
}
});
/**
* 🗑️ 오류 유형 삭제
*/
const deleteErrorType = asyncHandler(async (req, res) => {
const { id } = req.params;
if (!id) {
throw new ApiError('오류 유형 ID가 필요합니다.', 400);
}
console.log('🗑️ 오류 유형 삭제:', id);
try {
const result = await new Promise((resolve, reject) => {
dailyWorkReportModel.deleteErrorType(id, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
if (result.affectedRows === 0) {
throw new ApiError('삭제할 오류 유형을 찾을 수 없습니다.', 404);
}
res.success(result, '오류 유형이 성공적으로 삭제되었습니다.');
} catch (err) {
handleDatabaseError(err, '오류 유형 삭제');
}
});
/**
* 📊 누적 현황 조회
*/
const getAccumulatedReports = (req, res) => {
const { date, worker_id } = req.query;
if (!date || !worker_id) {
return res.status(400).json({
error: 'date와 worker_id가 필요합니다.',
example: 'date=2024-06-16&worker_id=1'
});
}
console.log(`📊 누적 현황 조회: date=${date}, worker_id=${worker_id}`);
dailyWorkReportModel.getAccumulatedReportsByDate(date, worker_id, (err, data) => {
if (err) {
console.error('누적 현황 조회 오류:', err);
return res.status(500).json({
error: '누적 현황 조회 중 오류가 발생했습니다.',
details: err.message
});
}
console.log(`📊 누적 현황 조회 결과: ${data.length}`);
res.json({
date,
worker_id,
total_entries: data.length,
accumulated_data: data,
timestamp: new Date().toISOString()
});
});
};
/**
* TBM 배정 기반 작업보고서 생성
*/
const createFromTbm = async (req, res) => {
try {
const {
tbm_assignment_id,
tbm_session_id,
worker_id,
project_id,
work_type_id,
report_date,
start_time,
end_time,
total_hours,
error_hours,
error_type_id,
work_status_id
} = req.body;
// 필수 필드 검증
if (!tbm_assignment_id || !tbm_session_id || !worker_id || !report_date || !total_hours) {
return res.status(400).json({
success: false,
message: '필수 필드가 누락되었습니다. (assignment_id, session_id, worker_id, report_date, total_hours)'
});
}
// regular_hours 계산
const regular_hours = total_hours - (error_hours || 0);
const reportData = {
tbm_assignment_id,
tbm_session_id,
worker_id,
project_id,
work_type_id,
report_date,
start_time,
end_time,
total_hours,
error_hours: error_hours || 0,
regular_hours,
work_status_id: work_status_id || (error_hours > 0 ? 2 : 1), // error_hours가 있으면 상태 2 (부적합)
error_type_id,
created_by: req.user.user_id
};
const result = await dailyWorkReportModel.createFromTbmAssignment(reportData);
res.status(201).json({
success: true,
message: '작업보고서가 생성되었습니다.',
data: result
});
} catch (err) {
console.error('TBM 작업보고서 생성 오류:', err);
console.error('Error stack:', err.stack);
res.status(500).json({
success: false,
message: 'TBM 작업보고서 생성 중 오류가 발생했습니다.',
error: err.message,
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined
});
}
};
// 모든 컨트롤러 함수 내보내기 (리팩토링된 함수 위주로 재구성)
module.exports = {
// 📝 V2 핵심 CRUD 함수
createDailyWorkReport,
getDailyWorkReports,
updateWorkReport,
removeDailyWorkReport,
createFromTbm,
// 📊 V2 통계 및 요약 함수
getWorkReportStats,
getDailySummary,
// 🔽 아직 리팩토링되지 않은 레거시 함수들
getAccumulatedReports,
getContributorsSummary,
getMyAccumulatedData,
removeMyEntry,
getDailyWorkReportsByDate,
searchWorkReports,
getMonthlySummary,
removeDailyWorkReportByDateAndWorker,
getWorkTypes,
getWorkStatusTypes,
getErrorTypes,
// 🔽 마스터 데이터 CRUD
createWorkType,
updateWorkType,
deleteWorkType,
createWorkStatus,
updateWorkStatus,
deleteWorkStatus,
createErrorType,
updateErrorType,
deleteErrorType
};

View File

@@ -0,0 +1,241 @@
// controllers/departmentController.js
const departmentModel = require('../models/departmentModel');
const departmentController = {
// 모든 부서 조회
async getAll(req, res) {
try {
const { active_only } = req.query;
const departments = active_only === 'true'
? await departmentModel.getActive()
: await departmentModel.getAll();
res.json({
success: true,
data: departments
});
} catch (error) {
console.error('부서 목록 조회 오류:', error);
res.status(500).json({
success: false,
error: '부서 목록을 불러오는데 실패했습니다.'
});
}
},
// 부서 상세 조회
async getById(req, res) {
try {
const { id } = req.params;
const department = await departmentModel.getById(id);
if (!department) {
return res.status(404).json({
success: false,
error: '부서를 찾을 수 없습니다.'
});
}
res.json({
success: true,
data: department
});
} catch (error) {
console.error('부서 조회 오류:', error);
res.status(500).json({
success: false,
error: '부서 정보를 불러오는데 실패했습니다.'
});
}
},
// 부서 생성
async create(req, res) {
try {
const { department_name, parent_id, description, is_active, display_order } = req.body;
if (!department_name) {
return res.status(400).json({
success: false,
error: '부서명은 필수입니다.'
});
}
const departmentId = await departmentModel.create({
department_name,
parent_id,
description,
is_active,
display_order
});
const newDepartment = await departmentModel.getById(departmentId);
res.status(201).json({
success: true,
message: '부서가 생성되었습니다.',
data: newDepartment
});
} catch (error) {
console.error('부서 생성 오류:', error);
res.status(500).json({
success: false,
error: '부서 생성에 실패했습니다.'
});
}
},
// 부서 수정
async update(req, res) {
try {
const { id } = req.params;
const { department_name, parent_id, description, is_active, display_order } = req.body;
if (!department_name) {
return res.status(400).json({
success: false,
error: '부서명은 필수입니다.'
});
}
// 자기 자신을 상위 부서로 지정하는 것 방지
if (parent_id && parseInt(parent_id) === parseInt(id)) {
return res.status(400).json({
success: false,
error: '자기 자신을 상위 부서로 지정할 수 없습니다.'
});
}
const updated = await departmentModel.update(id, {
department_name,
parent_id,
description,
is_active,
display_order
});
if (!updated) {
return res.status(404).json({
success: false,
error: '부서를 찾을 수 없습니다.'
});
}
const updatedDepartment = await departmentModel.getById(id);
res.json({
success: true,
message: '부서 정보가 수정되었습니다.',
data: updatedDepartment
});
} catch (error) {
console.error('부서 수정 오류:', error);
res.status(500).json({
success: false,
error: '부서 수정에 실패했습니다.'
});
}
},
// 부서 삭제
async delete(req, res) {
try {
const { id } = req.params;
await departmentModel.delete(id);
res.json({
success: true,
message: '부서가 삭제되었습니다.'
});
} catch (error) {
console.error('부서 삭제 오류:', error);
res.status(400).json({
success: false,
error: error.message || '부서 삭제에 실패했습니다.'
});
}
},
// 부서별 작업자 조회
async getWorkers(req, res) {
try {
const { id } = req.params;
const workers = await departmentModel.getWorkersByDepartment(id);
res.json({
success: true,
data: workers
});
} catch (error) {
console.error('부서 작업자 조회 오류:', error);
res.status(500).json({
success: false,
error: '작업자 목록을 불러오는데 실패했습니다.'
});
}
},
// 작업자 부서 이동
async moveWorker(req, res) {
try {
const { workerId, departmentId } = req.body;
if (!workerId || !departmentId) {
return res.status(400).json({
success: false,
error: '작업자 ID와 부서 ID가 필요합니다.'
});
}
await departmentModel.moveWorker(workerId, departmentId);
res.json({
success: true,
message: '작업자 부서가 변경되었습니다.'
});
} catch (error) {
console.error('작업자 부서 이동 오류:', error);
res.status(500).json({
success: false,
error: '작업자 부서 변경에 실패했습니다.'
});
}
},
// 여러 작업자 부서 일괄 이동
async moveWorkers(req, res) {
try {
const { workerIds, departmentId } = req.body;
if (!workerIds || !Array.isArray(workerIds) || workerIds.length === 0) {
return res.status(400).json({
success: false,
error: '이동할 작업자를 선택하세요.'
});
}
if (!departmentId) {
return res.status(400).json({
success: false,
error: '대상 부서를 선택하세요.'
});
}
const count = await departmentModel.moveWorkers(workerIds, departmentId);
res.json({
success: true,
message: `${count}명의 작업자 부서가 변경되었습니다.`
});
} catch (error) {
console.error('작업자 일괄 이동 오류:', error);
res.status(500).json({
success: false,
error: '작업자 부서 변경에 실패했습니다.'
});
}
}
};
module.exports = departmentController;

View File

@@ -0,0 +1,945 @@
// controllers/equipmentController.js
const EquipmentModel = require('../models/equipmentModel');
const imageUploadService = require('../services/imageUploadService');
const EquipmentController = {
// CREATE - 설비 생성
createEquipment: async (req, res) => {
try {
const equipmentData = req.body;
// 필수 필드 검증
if (!equipmentData.equipment_code || !equipmentData.equipment_name) {
return res.status(400).json({
success: false,
message: '설비 코드와 설비명은 필수입니다.'
});
}
// 설비 코드 중복 확인
EquipmentModel.checkDuplicateCode(equipmentData.equipment_code, null, (error, isDuplicate) => {
if (error) {
console.error('설비 코드 중복 확인 오류:', error);
return res.status(500).json({
success: false,
message: '설비 코드 중복 확인 중 오류가 발생했습니다.'
});
}
if (isDuplicate) {
return res.status(409).json({
success: false,
message: '이미 사용 중인 설비 코드입니다.'
});
}
// 설비 생성
EquipmentModel.create(equipmentData, (error, result) => {
if (error) {
console.error('설비 생성 오류:', error);
return res.status(500).json({
success: false,
message: '설비 생성 중 오류가 발생했습니다.'
});
}
res.status(201).json({
success: true,
message: '설비가 성공적으로 생성되었습니다.',
data: result
});
});
});
} catch (error) {
console.error('설비 생성 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// READ ALL - 모든 설비 조회 (필터링 가능)
getAllEquipments: (req, res) => {
try {
const filters = {
workplace_id: req.query.workplace_id,
equipment_type: req.query.equipment_type,
status: req.query.status,
search: req.query.search
};
EquipmentModel.getAll(filters, (error, results) => {
if (error) {
console.error('설비 조회 오류:', error);
return res.status(500).json({
success: false,
message: '설비 조회 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('설비 조회 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// READ ONE - 특정 설비 조회
getEquipmentById: (req, res) => {
try {
const equipmentId = req.params.id;
EquipmentModel.getById(equipmentId, (error, result) => {
if (error) {
console.error('설비 조회 오류:', error);
return res.status(500).json({
success: false,
message: '설비 조회 중 오류가 발생했습니다.'
});
}
if (!result) {
return res.status(404).json({
success: false,
message: '설비를 찾을 수 없습니다.'
});
}
res.json({
success: true,
data: result
});
});
} catch (error) {
console.error('설비 조회 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// READ BY WORKPLACE - 특정 작업장의 설비 조회
getEquipmentsByWorkplace: (req, res) => {
try {
const workplaceId = req.params.workplaceId;
EquipmentModel.getByWorkplace(workplaceId, (error, results) => {
if (error) {
console.error('작업장 설비 조회 오류:', error);
return res.status(500).json({
success: false,
message: '작업장 설비 조회 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('작업장 설비 조회 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// READ ACTIVE - 활성 설비만 조회
getActiveEquipments: (req, res) => {
try {
EquipmentModel.getActive((error, results) => {
if (error) {
console.error('활성 설비 조회 오류:', error);
return res.status(500).json({
success: false,
message: '활성 설비 조회 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('활성 설비 조회 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// UPDATE - 설비 수정
updateEquipment: async (req, res) => {
try {
const equipmentId = req.params.id;
const equipmentData = req.body;
// 필수 필드 검증
if (!equipmentData.equipment_code || !equipmentData.equipment_name) {
return res.status(400).json({
success: false,
message: '설비 코드와 설비명은 필수입니다.'
});
}
// 설비 존재 확인
EquipmentModel.getById(equipmentId, (error, existingEquipment) => {
if (error) {
console.error('설비 조회 오류:', error);
return res.status(500).json({
success: false,
message: '설비 조회 중 오류가 발생했습니다.'
});
}
if (!existingEquipment) {
return res.status(404).json({
success: false,
message: '설비를 찾을 수 없습니다.'
});
}
// 설비 코드 중복 확인 (자신 제외)
EquipmentModel.checkDuplicateCode(equipmentData.equipment_code, equipmentId, (error, isDuplicate) => {
if (error) {
console.error('설비 코드 중복 확인 오류:', error);
return res.status(500).json({
success: false,
message: '설비 코드 중복 확인 중 오류가 발생했습니다.'
});
}
if (isDuplicate) {
return res.status(409).json({
success: false,
message: '이미 사용 중인 설비 코드입니다.'
});
}
// 설비 수정
EquipmentModel.update(equipmentId, equipmentData, (error, result) => {
if (error) {
console.error('설비 수정 오류:', error);
return res.status(500).json({
success: false,
message: '설비 수정 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
message: '설비가 성공적으로 수정되었습니다.',
data: result
});
});
});
});
} catch (error) {
console.error('설비 수정 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// UPDATE MAP POSITION - 지도상 위치 업데이트
updateMapPosition: (req, res) => {
try {
const equipmentId = req.params.id;
const positionData = {
map_x_percent: req.body.map_x_percent,
map_y_percent: req.body.map_y_percent,
map_width_percent: req.body.map_width_percent,
map_height_percent: req.body.map_height_percent
};
// workplace_id가 있으면 포함 (설비를 다른 작업장으로 이동 가능)
if (req.body.workplace_id !== undefined) {
positionData.workplace_id = req.body.workplace_id;
}
EquipmentModel.updateMapPosition(equipmentId, positionData, (error, result) => {
if (error) {
console.error('설비 위치 업데이트 오류:', error);
return res.status(500).json({
success: false,
message: '설비 위치 업데이트 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
message: '설비 위치가 성공적으로 업데이트되었습니다.',
data: result
});
});
} catch (error) {
console.error('설비 위치 업데이트 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// DELETE - 설비 삭제
deleteEquipment: (req, res) => {
try {
const equipmentId = req.params.id;
EquipmentModel.delete(equipmentId, (error, result) => {
if (error) {
console.error('설비 삭제 오류:', error);
return res.status(500).json({
success: false,
message: '설비 삭제 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
message: '설비가 성공적으로 삭제되었습니다.',
data: result
});
});
} catch (error) {
console.error('설비 삭제 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// GET EQUIPMENT TYPES - 사용 중인 설비 유형 목록 조회
getEquipmentTypes: (req, res) => {
try {
EquipmentModel.getEquipmentTypes((error, results) => {
if (error) {
console.error('설비 유형 조회 오류:', error);
return res.status(500).json({
success: false,
message: '설비 유형 조회 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('설비 유형 조회 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// GET NEXT EQUIPMENT CODE - 다음 관리번호 자동 생성
getNextEquipmentCode: (req, res) => {
try {
const prefix = req.query.prefix || 'TKP';
EquipmentModel.getNextEquipmentCode(prefix, (error, nextCode) => {
if (error) {
console.error('다음 관리번호 조회 오류:', error);
return res.status(500).json({
success: false,
message: '다음 관리번호 조회 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
data: {
next_code: nextCode,
prefix: prefix
}
});
});
} catch (error) {
console.error('다음 관리번호 조회 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// ==========================================
// 설비 사진 관리
// ==========================================
// ADD PHOTO - 설비 사진 추가
addPhoto: async (req, res) => {
try {
const equipmentId = req.params.id;
const { photo_base64, description, display_order } = req.body;
if (!photo_base64) {
return res.status(400).json({
success: false,
message: '사진 데이터가 필요합니다.'
});
}
// Base64 이미지를 파일로 저장
const photoPath = await imageUploadService.saveBase64Image(
photo_base64,
'equipment',
'equipments'
);
if (!photoPath) {
return res.status(500).json({
success: false,
message: '사진 저장에 실패했습니다.'
});
}
// DB에 사진 정보 저장
const photoData = {
photo_path: photoPath,
description: description || null,
display_order: display_order || 0,
uploaded_by: req.user?.user_id || null
};
EquipmentModel.addPhoto(equipmentId, photoData, (error, result) => {
if (error) {
console.error('사진 정보 저장 오류:', error);
return res.status(500).json({
success: false,
message: '사진 정보 저장 중 오류가 발생했습니다.'
});
}
res.status(201).json({
success: true,
message: '사진이 성공적으로 추가되었습니다.',
data: result
});
});
} catch (error) {
console.error('사진 추가 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// GET PHOTOS - 설비 사진 조회
getPhotos: (req, res) => {
try {
const equipmentId = req.params.id;
EquipmentModel.getPhotos(equipmentId, (error, results) => {
if (error) {
console.error('사진 조회 오류:', error);
return res.status(500).json({
success: false,
message: '사진 조회 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('사진 조회 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// DELETE PHOTO - 설비 사진 삭제
deletePhoto: async (req, res) => {
try {
const photoId = req.params.photoId;
EquipmentModel.deletePhoto(photoId, async (error, result) => {
if (error) {
if (error.message === 'Photo not found') {
return res.status(404).json({
success: false,
message: '사진을 찾을 수 없습니다.'
});
}
console.error('사진 삭제 오류:', error);
return res.status(500).json({
success: false,
message: '사진 삭제 중 오류가 발생했습니다.'
});
}
// 파일 시스템에서 사진 삭제
if (result.photo_path) {
await imageUploadService.deleteFile(result.photo_path);
}
res.json({
success: true,
message: '사진이 성공적으로 삭제되었습니다.',
data: { photo_id: photoId }
});
});
} catch (error) {
console.error('사진 삭제 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// ==========================================
// 설비 임시 이동
// ==========================================
// MOVE TEMPORARILY - 설비 임시 이동
moveTemporarily: (req, res) => {
try {
const equipmentId = req.params.id;
const moveData = {
target_workplace_id: req.body.target_workplace_id,
target_x_percent: req.body.target_x_percent,
target_y_percent: req.body.target_y_percent,
target_width_percent: req.body.target_width_percent,
target_height_percent: req.body.target_height_percent,
from_workplace_id: req.body.from_workplace_id,
from_x_percent: req.body.from_x_percent,
from_y_percent: req.body.from_y_percent,
reason: req.body.reason,
moved_by: req.user?.user_id || null
};
if (!moveData.target_workplace_id || moveData.target_x_percent === undefined || moveData.target_y_percent === undefined) {
return res.status(400).json({
success: false,
message: '이동할 작업장과 위치가 필요합니다.'
});
}
EquipmentModel.moveTemporarily(equipmentId, moveData, (error, result) => {
if (error) {
console.error('설비 이동 오류:', error);
return res.status(500).json({
success: false,
message: '설비 이동 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
message: '설비가 임시 이동되었습니다.',
data: result
});
});
} catch (error) {
console.error('설비 이동 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// RETURN TO ORIGINAL - 설비 원위치 복귀
returnToOriginal: (req, res) => {
try {
const equipmentId = req.params.id;
const userId = req.user?.user_id || null;
EquipmentModel.returnToOriginal(equipmentId, userId, (error, result) => {
if (error) {
if (error.message === 'Equipment not found') {
return res.status(404).json({
success: false,
message: '설비를 찾을 수 없습니다.'
});
}
console.error('설비 복귀 오류:', error);
return res.status(500).json({
success: false,
message: '설비 복귀 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
message: '설비가 원위치로 복귀되었습니다.',
data: result
});
});
} catch (error) {
console.error('설비 복귀 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// GET TEMPORARILY MOVED - 임시 이동된 설비 목록
getTemporarilyMoved: (req, res) => {
try {
EquipmentModel.getTemporarilyMoved((error, results) => {
if (error) {
console.error('임시 이동 설비 조회 오류:', error);
return res.status(500).json({
success: false,
message: '임시 이동 설비 조회 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('임시 이동 설비 조회 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// GET MOVE LOGS - 설비 이동 이력 조회
getMoveLogs: (req, res) => {
try {
const equipmentId = req.params.id;
EquipmentModel.getMoveLogs(equipmentId, (error, results) => {
if (error) {
console.error('이동 이력 조회 오류:', error);
return res.status(500).json({
success: false,
message: '이동 이력 조회 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('이동 이력 조회 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// ==========================================
// 설비 외부 반출/반입
// ==========================================
// EXPORT EQUIPMENT - 설비 외부 반출
exportEquipment: (req, res) => {
try {
const equipmentId = req.params.id;
const exportData = {
equipment_id: equipmentId,
export_date: req.body.export_date,
expected_return_date: req.body.expected_return_date,
destination: req.body.destination,
reason: req.body.reason,
notes: req.body.notes,
is_repair: req.body.is_repair || false,
exported_by: req.user?.user_id || null
};
EquipmentModel.exportEquipment(exportData, (error, result) => {
if (error) {
console.error('설비 반출 오류:', error);
return res.status(500).json({
success: false,
message: '설비 반출 중 오류가 발생했습니다.'
});
}
res.status(201).json({
success: true,
message: '설비가 외부로 반출되었습니다.',
data: result
});
});
} catch (error) {
console.error('설비 반출 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// RETURN EQUIPMENT - 설비 반입 (외부에서 복귀)
returnEquipment: (req, res) => {
try {
const logId = req.params.logId;
const returnData = {
return_date: req.body.return_date,
new_status: req.body.new_status || 'active',
notes: req.body.notes,
returned_by: req.user?.user_id || null
};
EquipmentModel.returnEquipment(logId, returnData, (error, result) => {
if (error) {
if (error.message === 'Export log not found') {
return res.status(404).json({
success: false,
message: '반출 기록을 찾을 수 없습니다.'
});
}
console.error('설비 반입 오류:', error);
return res.status(500).json({
success: false,
message: '설비 반입 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
message: '설비가 반입되었습니다.',
data: result
});
});
} catch (error) {
console.error('설비 반입 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// GET EXTERNAL LOGS - 설비 외부 반출 이력 조회
getExternalLogs: (req, res) => {
try {
const equipmentId = req.params.id;
EquipmentModel.getExternalLogs(equipmentId, (error, results) => {
if (error) {
console.error('반출 이력 조회 오류:', error);
return res.status(500).json({
success: false,
message: '반출 이력 조회 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('반출 이력 조회 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// GET EXPORTED EQUIPMENTS - 현재 외부 반출 중인 설비 목록
getExportedEquipments: (req, res) => {
try {
EquipmentModel.getExportedEquipments((error, results) => {
if (error) {
console.error('반출 중 설비 조회 오류:', error);
return res.status(500).json({
success: false,
message: '반출 중 설비 조회 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('반출 중 설비 조회 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// ==========================================
// 설비 수리 신청
// ==========================================
// CREATE REPAIR REQUEST - 수리 신청
createRepairRequest: async (req, res) => {
try {
const equipmentId = req.params.id;
const { photo_base64_list, description, item_id, workplace_id } = req.body;
// 사진 저장 (있는 경우)
let photoPaths = [];
if (photo_base64_list && photo_base64_list.length > 0) {
for (const base64 of photo_base64_list) {
const path = await imageUploadService.saveBase64Image(base64, 'repair', 'issues');
if (path) photoPaths.push(path);
}
}
const requestData = {
equipment_id: equipmentId,
item_id: item_id || null,
workplace_id: workplace_id || null,
description: description || null,
photo_paths: photoPaths.length > 0 ? photoPaths : null,
reported_by: req.user?.user_id || null
};
EquipmentModel.createRepairRequest(requestData, (error, result) => {
if (error) {
if (error.message === '설비 수리 카테고리가 없습니다') {
return res.status(400).json({
success: false,
message: error.message
});
}
console.error('수리 신청 오류:', error);
return res.status(500).json({
success: false,
message: '수리 신청 중 오류가 발생했습니다.'
});
}
res.status(201).json({
success: true,
message: '수리 신청이 접수되었습니다.',
data: result
});
});
} catch (error) {
console.error('수리 신청 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// GET REPAIR HISTORY - 설비 수리 이력 조회
getRepairHistory: (req, res) => {
try {
const equipmentId = req.params.id;
EquipmentModel.getRepairHistory(equipmentId, (error, results) => {
if (error) {
console.error('수리 이력 조회 오류:', error);
return res.status(500).json({
success: false,
message: '수리 이력 조회 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('수리 이력 조회 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// GET REPAIR CATEGORIES - 설비 수리 항목 목록 조회
getRepairCategories: (req, res) => {
try {
EquipmentModel.getRepairCategories((error, results) => {
if (error) {
console.error('수리 항목 조회 오류:', error);
return res.status(500).json({
success: false,
message: '수리 항목 조회 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('수리 항목 조회 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// ADD REPAIR CATEGORY - 새 수리 항목 추가
addRepairCategory: (req, res) => {
try {
const { item_name } = req.body;
if (!item_name || !item_name.trim()) {
return res.status(400).json({
success: false,
message: '수리 유형 이름을 입력하세요.'
});
}
EquipmentModel.addRepairCategory(item_name.trim(), (error, result) => {
if (error) {
console.error('수리 항목 추가 오류:', error);
return res.status(500).json({
success: false,
message: '수리 항목 추가 중 오류가 발생했습니다.'
});
}
res.status(201).json({
success: true,
message: result.isNew ? '새 수리 유형이 추가되었습니다.' : '기존 수리 유형을 사용합니다.',
data: result
});
});
} catch (error) {
console.error('수리 항목 추가 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
}
};
module.exports = EquipmentController;

View File

@@ -0,0 +1,65 @@
/**
* 이슈 유형 관리 컨트롤러
*
* 이슈 유형(카테고리/서브카테고리) CRUD API 엔드포인트 핸들러
*
* @author TK-FB-Project
* @since 2025-12-11
*/
const issueTypeService = require('../services/issueTypeService');
const { asyncHandler } = require('../middlewares/errorHandler');
/**
* 이슈 유형 생성
*/
exports.createIssueType = asyncHandler(async (req, res) => {
const result = await issueTypeService.createIssueTypeService(req.body);
res.status(201).json({
success: true,
data: result,
message: '이슈 유형이 성공적으로 생성되었습니다'
});
});
/**
* 전체 이슈 유형 조회
*/
exports.getAllIssueTypes = asyncHandler(async (req, res) => {
const rows = await issueTypeService.getAllIssueTypesService();
res.json({
success: true,
data: rows,
message: '이슈 유형 목록 조회 성공'
});
});
/**
* 이슈 유형 수정
*/
exports.updateIssueType = asyncHandler(async (req, res) => {
const id = parseInt(req.params.id, 10);
const result = await issueTypeService.updateIssueTypeService(id, req.body);
res.json({
success: true,
data: result,
message: '이슈 유형이 성공적으로 수정되었습니다'
});
});
/**
* 이슈 유형 삭제
*/
exports.removeIssueType = asyncHandler(async (req, res) => {
const id = parseInt(req.params.id, 10);
const result = await issueTypeService.removeIssueTypeService(id);
res.json({
success: true,
data: result,
message: '이슈 유형이 성공적으로 삭제되었습니다'
});
});

View File

@@ -0,0 +1,231 @@
/**
* 월별 작업자 상태 집계 컨트롤러
*
* 월별 캘린더 및 작업자 상태 집계 API 엔드포인트 핸들러
*
* @author TK-FB-Project
* @since 2025-12-11
*/
const MonthlyStatusModel = require('../models/monthlyStatusModel');
const { ValidationError, ForbiddenError, DatabaseError } = require('../utils/errors');
const { asyncHandler } = require('../middlewares/errorHandler');
const logger = require('../utils/logger');
/**
* 월별 캘린더 데이터 조회
*/
const getMonthlyCalendarData = asyncHandler(async (req, res) => {
const { year, month } = req.query;
if (!year || !month) {
throw new ValidationError('연도(year)와 월(month)이 필요합니다', {
required: ['year', 'month'],
received: { year, month }
});
}
const yearNum = parseInt(year);
const monthNum = parseInt(month);
if (yearNum < 2020 || yearNum > 2030 || monthNum < 1 || monthNum > 12) {
throw new ValidationError('유효하지 않은 연도 또는 월입니다', {
received: { year: yearNum, month: monthNum }
});
}
logger.info('월별 캘린더 데이터 조회 요청', { year: yearNum, month: monthNum });
try {
const summaryData = await MonthlyStatusModel.getMonthlySummary(yearNum, monthNum);
// 날짜별 객체로 변환
const calendarData = {};
summaryData.forEach(day => {
const dateKey = day.date.toISOString().split('T')[0];
calendarData[dateKey] = {
totalWorkers: day.total_workers,
workingWorkers: day.working_workers,
hasIssues: day.has_issues,
hasErrors: day.has_errors,
hasOvertimeWarning: day.has_overtime_warning,
incompleteWorkers: day.incomplete_workers,
partialWorkers: day.partial_workers,
errorWorkers: day.error_workers,
overtimeWarningWorkers: day.overtime_warning_workers,
totalHours: parseFloat(day.total_work_hours || 0),
totalTasks: day.total_work_count,
errorCount: day.total_error_count,
lastUpdated: day.last_updated
};
});
logger.info('월별 캘린더 데이터 조회 성공', {
year: yearNum,
month: monthNum,
dayCount: Object.keys(calendarData).length
});
res.json({
success: true,
data: calendarData,
message: `${year}${month}월 캘린더 데이터를 성공적으로 조회했습니다`
});
} catch (error) {
logger.error('월별 캘린더 데이터 조회 실패', {
year: yearNum,
month: monthNum,
error: error.message
});
throw new DatabaseError('월별 캘린더 데이터 조회 중 오류가 발생했습니다');
}
});
/**
* 특정 날짜의 작업자별 상세 상태 조회
*/
const getDailyWorkerDetails = asyncHandler(async (req, res) => {
const { date } = req.query;
if (!date) {
throw new ValidationError('날짜(date)가 필요합니다', {
required: ['date'],
received: { date }
});
}
logger.info('일별 작업자 상세 조회 요청', { date });
try {
const workerDetails = await MonthlyStatusModel.getDailyWorkerStatus(date);
// 데이터 변환
const formattedData = workerDetails.map(worker => ({
workerId: worker.worker_id,
workerName: worker.worker_name,
jobType: worker.job_type,
totalHours: parseFloat(worker.total_work_hours || 0),
actualWorkHours: parseFloat(worker.actual_work_hours || 0),
vacationHours: parseFloat(worker.vacation_hours || 0),
totalWorkCount: worker.total_work_count,
regularWorkCount: worker.regular_work_count,
errorWorkCount: worker.error_work_count,
status: worker.work_status,
hasVacation: worker.has_vacation,
hasError: worker.has_error,
hasIssues: worker.has_issues,
lastUpdated: worker.last_updated
}));
// 요약 정보 계산
const summary = {
totalWorkers: formattedData.length,
totalHours: formattedData.reduce((sum, w) => sum + w.totalHours, 0),
totalTasks: formattedData.reduce((sum, w) => sum + w.totalWorkCount, 0),
errorCount: formattedData.reduce((sum, w) => sum + w.errorWorkCount, 0)
};
logger.info('일별 작업자 상세 조회 성공', {
date,
workerCount: formattedData.length,
totalHours: summary.totalHours
});
res.json({
success: true,
data: {
workers: formattedData,
summary
},
message: `${date} 작업자 상세 정보를 성공적으로 조회했습니다`
});
} catch (error) {
logger.error('일별 작업자 상세 조회 실패', {
date,
error: error.message
});
throw new DatabaseError('일별 작업자 상세 조회 중 오류가 발생했습니다');
}
});
/**
* 월별 집계 재계산 (관리자용)
*/
const recalculateMonth = asyncHandler(async (req, res) => {
const { year, month } = req.body;
if (!year || !month) {
throw new ValidationError('연도(year)와 월(month)이 필요합니다', {
required: ['year', 'month'],
received: { year, month }
});
}
// 관리자 권한 확인
if (req.user.role !== 'admin' && req.user.role !== 'system') {
throw new ForbiddenError('관리자 권한이 필요합니다');
}
logger.info('월별 집계 재계산 시작', {
year,
month,
requestedBy: req.user.username
});
try {
const result = await MonthlyStatusModel.recalculateMonth(parseInt(year), parseInt(month));
logger.info('월별 집계 재계산 성공', { year, month, result });
res.json({
success: true,
data: result,
message: `${year}${month}월 집계 재계산이 완료되었습니다`
});
} catch (error) {
logger.error('월별 집계 재계산 실패', {
year,
month,
error: error.message
});
throw new DatabaseError('월별 집계 재계산 중 오류가 발생했습니다');
}
});
/**
* 집계 테이블 상태 확인 (관리자용)
*/
const getStatusInfo = asyncHandler(async (req, res) => {
// 관리자 권한 확인
if (req.user.role !== 'admin' && req.user.role !== 'system') {
throw new ForbiddenError('관리자 권한이 필요합니다');
}
logger.info('집계 테이블 상태 확인 요청', {
requestedBy: req.user.username
});
try {
const statusInfo = await MonthlyStatusModel.getStatusInfo();
logger.info('집계 테이블 상태 확인 성공');
res.json({
success: true,
data: statusInfo,
message: '집계 테이블 상태 정보를 성공적으로 조회했습니다'
});
} catch (error) {
logger.error('집계 테이블 상태 확인 실패', {
error: error.message
});
throw new DatabaseError('집계 테이블 상태 확인 중 오류가 발생했습니다');
}
});
module.exports = {
getMonthlyCalendarData,
getDailyWorkerDetails,
recalculateMonth,
getStatusInfo
};

View File

@@ -0,0 +1,165 @@
// controllers/notificationController.js
const notificationModel = require('../models/notificationModel');
const notificationController = {
// 읽지 않은 알림 조회
async getUnread(req, res) {
try {
const userId = req.user?.id || null;
const notifications = await notificationModel.getUnread(userId);
res.json({
success: true,
data: notifications
});
} catch (error) {
console.error('읽지 않은 알림 조회 오류:', error);
res.status(500).json({
success: false,
message: '알림 조회 중 오류가 발생했습니다.'
});
}
},
// 전체 알림 조회
async getAll(req, res) {
try {
const userId = req.user?.id || null;
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const result = await notificationModel.getAll(userId, page, limit);
res.json({
success: true,
data: result.notifications,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: Math.ceil(result.total / result.limit)
}
});
} catch (error) {
console.error('알림 목록 조회 오류:', error);
res.status(500).json({
success: false,
message: '알림 조회 중 오류가 발생했습니다.'
});
}
},
// 읽지 않은 알림 개수
async getUnreadCount(req, res) {
try {
const userId = req.user?.id || null;
const count = await notificationModel.getUnreadCount(userId);
res.json({
success: true,
data: { count }
});
} catch (error) {
console.error('알림 개수 조회 오류:', error);
res.status(500).json({
success: false,
message: '알림 개수 조회 중 오류가 발생했습니다.'
});
}
},
// 알림 읽음 처리
async markAsRead(req, res) {
try {
const { id } = req.params;
const success = await notificationModel.markAsRead(id);
res.json({
success,
message: success ? '알림을 읽음 처리했습니다.' : '알림을 찾을 수 없습니다.'
});
} catch (error) {
console.error('알림 읽음 처리 오류:', error);
res.status(500).json({
success: false,
message: '알림 처리 중 오류가 발생했습니다.'
});
}
},
// 모든 알림 읽음 처리
async markAllAsRead(req, res) {
try {
const userId = req.user?.id || null;
const count = await notificationModel.markAllAsRead(userId);
res.json({
success: true,
message: `${count}개의 알림을 읽음 처리했습니다.`,
data: { count }
});
} catch (error) {
console.error('전체 읽음 처리 오류:', error);
res.status(500).json({
success: false,
message: '알림 처리 중 오류가 발생했습니다.'
});
}
},
// 알림 삭제
async delete(req, res) {
try {
const { id } = req.params;
const success = await notificationModel.delete(id);
res.json({
success,
message: success ? '알림을 삭제했습니다.' : '알림을 찾을 수 없습니다.'
});
} catch (error) {
console.error('알림 삭제 오류:', error);
res.status(500).json({
success: false,
message: '알림 삭제 중 오류가 발생했습니다.'
});
}
},
// 알림 생성 (시스템용)
async create(req, res) {
try {
const { type, title, message, link_url, user_id } = req.body;
if (!title) {
return res.status(400).json({
success: false,
message: '알림 제목은 필수입니다.'
});
}
const notificationId = await notificationModel.create({
user_id,
type,
title,
message,
link_url,
created_by: req.user?.id
});
res.json({
success: true,
message: '알림이 생성되었습니다.',
data: { notification_id: notificationId }
});
} catch (error) {
console.error('알림 생성 오류:', error);
res.status(500).json({
success: false,
message: '알림 생성 중 오류가 발생했습니다.'
});
}
}
};
module.exports = notificationController;

View File

@@ -0,0 +1,91 @@
// controllers/notificationRecipientController.js
const notificationRecipientModel = require('../models/notificationRecipientModel');
const notificationRecipientController = {
// 알림 유형 목록
getTypes: async (req, res) => {
try {
const types = notificationRecipientModel.getTypes();
res.json({ success: true, data: types });
} catch (error) {
console.error('알림 유형 조회 오류:', error);
res.status(500).json({ success: false, error: '알림 유형 조회 실패' });
}
},
// 전체 수신자 목록 (유형별 그룹화)
getAll: async (req, res) => {
try {
console.log('🔔 알림 수신자 목록 조회 시작');
const recipients = await notificationRecipientModel.getAll();
console.log('✅ 알림 수신자 목록 조회 완료:', recipients);
res.json({ success: true, data: recipients });
} catch (error) {
console.error('❌ 수신자 목록 조회 오류:', error.message);
console.error('❌ 스택:', error.stack);
res.status(500).json({ success: false, error: '수신자 목록 조회 실패', detail: error.message });
}
},
// 유형별 수신자 조회
getByType: async (req, res) => {
try {
const { type } = req.params;
const recipients = await notificationRecipientModel.getByType(type);
res.json({ success: true, data: recipients });
} catch (error) {
console.error('수신자 조회 오류:', error);
res.status(500).json({ success: false, error: '수신자 조회 실패' });
}
},
// 수신자 추가
add: async (req, res) => {
try {
const { notification_type, user_id } = req.body;
if (!notification_type || !user_id) {
return res.status(400).json({ success: false, error: '알림 유형과 사용자 ID가 필요합니다.' });
}
await notificationRecipientModel.add(notification_type, user_id, req.user?.user_id);
res.json({ success: true, message: '수신자가 추가되었습니다.' });
} catch (error) {
console.error('수신자 추가 오류:', error);
res.status(500).json({ success: false, error: '수신자 추가 실패' });
}
},
// 수신자 제거
remove: async (req, res) => {
try {
const { type, userId } = req.params;
await notificationRecipientModel.remove(type, userId);
res.json({ success: true, message: '수신자가 제거되었습니다.' });
} catch (error) {
console.error('수신자 제거 오류:', error);
res.status(500).json({ success: false, error: '수신자 제거 실패' });
}
},
// 유형별 수신자 일괄 설정
setRecipients: async (req, res) => {
try {
const { type } = req.params;
const { user_ids } = req.body;
if (!Array.isArray(user_ids)) {
return res.status(400).json({ success: false, error: 'user_ids 배열이 필요합니다.' });
}
await notificationRecipientModel.setRecipients(type, user_ids, req.user?.user_id);
res.json({ success: true, message: '수신자가 설정되었습니다.' });
} catch (error) {
console.error('수신자 설정 오류:', error);
res.status(500).json({ success: false, error: '수신자 설정 실패' });
}
}
};
module.exports = notificationRecipientController;

View File

@@ -0,0 +1,200 @@
// controllers/pageAccessController.js
const PageAccessModel = require('../models/pageAccessModel');
const PageAccessController = {
// 사용자의 페이지 권한 조회
getUserPageAccess: (req, res) => {
const userId = parseInt(req.params.userId);
if (isNaN(userId)) {
return res.status(400).json({
success: false,
message: '유효하지 않은 사용자 ID입니다.'
});
}
PageAccessModel.getUserPageAccess(userId, (err, results) => {
if (err) {
console.error('페이지 권한 조회 오류:', err);
return res.status(500).json({
success: false,
message: '페이지 권한 조회 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
data: results
});
});
},
// 모든 페이지 목록 조회
getAllPages: (req, res) => {
PageAccessModel.getAllPages((err, results) => {
if (err) {
console.error('페이지 목록 조회 오류:', err);
return res.status(500).json({
success: false,
message: '페이지 목록 조회 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
data: results
});
});
},
// 페이지 권한 부여
grantPageAccess: (req, res) => {
const userId = parseInt(req.params.userId);
const { pageId } = req.body;
const grantedBy = req.user.user_id;
if (isNaN(userId) || !pageId) {
return res.status(400).json({
success: false,
message: '필수 파라미터가 누락되었습니다.'
});
}
PageAccessModel.grantPageAccess(userId, pageId, grantedBy, (err, result) => {
if (err) {
console.error('페이지 권한 부여 오류:', err);
return res.status(500).json({
success: false,
message: '페이지 권한 부여 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
message: '페이지 권한이 부여되었습니다.',
data: result
});
});
},
// 페이지 권한 회수
revokePageAccess: (req, res) => {
const userId = parseInt(req.params.userId);
const pageId = parseInt(req.params.pageId);
if (isNaN(userId) || isNaN(pageId)) {
return res.status(400).json({
success: false,
message: '유효하지 않은 파라미터입니다.'
});
}
PageAccessModel.revokePageAccess(userId, pageId, (err, result) => {
if (err) {
console.error('페이지 권한 회수 오류:', err);
return res.status(500).json({
success: false,
message: '페이지 권한 회수 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
message: '페이지 권한이 회수되었습니다.',
data: result
});
});
},
// 사용자 페이지 권한 일괄 설정
setUserPageAccess: (req, res) => {
const userId = parseInt(req.params.userId);
const { pageIds } = req.body;
const grantedBy = req.user.user_id;
if (isNaN(userId)) {
return res.status(400).json({
success: false,
message: '유효하지 않은 사용자 ID입니다.'
});
}
if (!Array.isArray(pageIds)) {
return res.status(400).json({
success: false,
message: 'pageIds는 배열이어야 합니다.'
});
}
PageAccessModel.setUserPageAccess(userId, pageIds, grantedBy, (err, result) => {
if (err) {
console.error('페이지 권한 설정 오류:', err);
return res.status(500).json({
success: false,
message: '페이지 권한 설정 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
message: '페이지 권한이 설정되었습니다.',
data: result
});
});
},
// 특정 페이지 접근 권한 확인
checkPageAccess: (req, res) => {
const userId = req.user.user_id;
const { pageKey } = req.params;
if (!pageKey) {
return res.status(400).json({
success: false,
message: '페이지 키가 필요합니다.'
});
}
PageAccessModel.checkPageAccess(userId, pageKey, (err, result) => {
if (err) {
console.error('페이지 접근 권한 확인 오류:', err);
return res.status(500).json({
success: false,
message: '페이지 접근 권한 확인 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
data: result
});
});
},
// 계정이 있는 사용자 목록 조회 (권한 관리용)
getUsersWithAccounts: (req, res) => {
PageAccessModel.getUsersWithAccounts((err, results) => {
if (err) {
console.error('사용자 목록 조회 오류:', err);
return res.status(500).json({
success: false,
message: '사용자 목록 조회 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
data: results
});
});
}
};
module.exports = PageAccessController;

View File

@@ -0,0 +1,796 @@
// patrolController.js
// 일일순회점검 시스템 컨트롤러
const PatrolModel = require('../models/patrolModel');
const PatrolController = {
// ==================== 순회점검 세션 ====================
// 세션 시작/조회
getOrCreateSession: async (req, res) => {
try {
const { patrol_date, patrol_time, category_id } = req.body;
const inspectorId = req.user.user_id;
if (!patrol_date || !patrol_time || !category_id) {
return res.status(400).json({ success: false, message: '필수 정보가 누락되었습니다.' });
}
const session = await PatrolModel.getOrCreateSession(patrol_date, patrol_time, category_id, inspectorId);
res.json({ success: true, data: session });
} catch (error) {
console.error('세션 생성/조회 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 세션 상세 조회
getSession: async (req, res) => {
try {
const { sessionId } = req.params;
const session = await PatrolModel.getSession(sessionId);
if (!session) {
return res.status(404).json({ success: false, message: '세션을 찾을 수 없습니다.' });
}
res.json({ success: true, data: session });
} catch (error) {
console.error('세션 조회 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 세션 목록 조회
getSessions: async (req, res) => {
try {
const { patrol_date, patrol_time, category_id, status, limit } = req.query;
const sessions = await PatrolModel.getSessions({
patrol_date,
patrol_time,
category_id,
status,
limit
});
res.json({ success: true, data: sessions });
} catch (error) {
console.error('세션 목록 조회 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 세션 완료
completeSession: async (req, res) => {
try {
const { sessionId } = req.params;
await PatrolModel.completeSession(sessionId);
res.json({ success: true, message: '순회점검이 완료되었습니다.' });
} catch (error) {
console.error('세션 완료 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 세션 메모 업데이트
updateSessionNotes: async (req, res) => {
try {
const { sessionId } = req.params;
const { notes } = req.body;
await PatrolModel.updateSessionNotes(sessionId, notes);
res.json({ success: true, message: '메모가 저장되었습니다.' });
} catch (error) {
console.error('메모 저장 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// ==================== 체크리스트 항목 ====================
// 체크리스트 항목 조회
getChecklistItems: async (req, res) => {
try {
const { category_id, workplace_id } = req.query;
const items = await PatrolModel.getChecklistItems(category_id, workplace_id);
// 카테고리별로 그룹화
const grouped = {};
items.forEach(item => {
if (!grouped[item.check_category]) {
grouped[item.check_category] = [];
}
grouped[item.check_category].push(item);
});
res.json({ success: true, data: { items, grouped } });
} catch (error) {
console.error('체크리스트 항목 조회 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 체크리스트 항목 추가
createChecklistItem: async (req, res) => {
try {
const itemId = await PatrolModel.createChecklistItem(req.body);
res.json({ success: true, data: { item_id: itemId }, message: '항목이 추가되었습니다.' });
} catch (error) {
console.error('항목 추가 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 체크리스트 항목 수정
updateChecklistItem: async (req, res) => {
try {
const { itemId } = req.params;
await PatrolModel.updateChecklistItem(itemId, req.body);
res.json({ success: true, message: '항목이 수정되었습니다.' });
} catch (error) {
console.error('항목 수정 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 체크리스트 항목 삭제
deleteChecklistItem: async (req, res) => {
try {
const { itemId } = req.params;
await PatrolModel.deleteChecklistItem(itemId);
res.json({ success: true, message: '항목이 삭제되었습니다.' });
} catch (error) {
console.error('항목 삭제 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// ==================== 체크 기록 ====================
// 작업장별 체크 기록 조회
getCheckRecords: async (req, res) => {
try {
const { sessionId } = req.params;
const { workplace_id } = req.query;
const records = await PatrolModel.getCheckRecords(sessionId, workplace_id);
res.json({ success: true, data: records });
} catch (error) {
console.error('체크 기록 조회 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 체크 기록 저장
saveCheckRecord: async (req, res) => {
try {
const { sessionId } = req.params;
const { workplace_id, check_item_id, is_checked, check_result, note } = req.body;
if (!workplace_id || !check_item_id) {
return res.status(400).json({ success: false, message: '필수 정보가 누락되었습니다.' });
}
await PatrolModel.saveCheckRecord(sessionId, workplace_id, check_item_id, is_checked, check_result, note);
res.json({ success: true, message: '저장되었습니다.' });
} catch (error) {
console.error('체크 기록 저장 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 체크 기록 일괄 저장
saveCheckRecords: async (req, res) => {
try {
const { sessionId } = req.params;
const { workplace_id, records } = req.body;
if (!workplace_id || !records || !Array.isArray(records)) {
return res.status(400).json({ success: false, message: '필수 정보가 누락되었습니다.' });
}
await PatrolModel.saveCheckRecords(sessionId, workplace_id, records);
res.json({ success: true, message: '저장되었습니다.' });
} catch (error) {
console.error('체크 기록 일괄 저장 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// ==================== 작업장 물품 현황 ====================
// 작업장 물품 조회
getWorkplaceItems: async (req, res) => {
try {
const { workplaceId } = req.params;
const { include_inactive } = req.query;
const items = await PatrolModel.getWorkplaceItems(workplaceId, include_inactive !== 'true');
res.json({ success: true, data: items });
} catch (error) {
console.error('물품 조회 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 물품 추가
createWorkplaceItem: async (req, res) => {
try {
const { workplaceId } = req.params;
const data = { ...req.body, workplace_id: workplaceId, created_by: req.user.user_id };
const itemId = await PatrolModel.createWorkplaceItem(data);
res.json({ success: true, data: { item_id: itemId }, message: '물품이 추가되었습니다.' });
} catch (error) {
console.error('물품 추가 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 물품 수정
updateWorkplaceItem: async (req, res) => {
try {
const { itemId } = req.params;
await PatrolModel.updateWorkplaceItem(itemId, req.body, req.user.user_id);
res.json({ success: true, message: '물품이 수정되었습니다.' });
} catch (error) {
console.error('물품 수정 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 물품 삭제
deleteWorkplaceItem: async (req, res) => {
try {
const { itemId } = req.params;
const { permanent } = req.query;
if (permanent === 'true') {
await PatrolModel.hardDeleteWorkplaceItem(itemId);
} else {
await PatrolModel.deleteWorkplaceItem(itemId, req.user.user_id);
}
res.json({ success: true, message: '물품이 삭제되었습니다.' });
} catch (error) {
console.error('물품 삭제 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// ==================== 물품 유형 ====================
// 물품 유형 목록
getItemTypes: async (req, res) => {
try {
const types = await PatrolModel.getItemTypes();
res.json({ success: true, data: types });
} catch (error) {
console.error('물품 유형 조회 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// ==================== 대시보드/통계 ====================
// 오늘 순회점검 현황
getTodayStatus: async (req, res) => {
try {
const { category_id } = req.query;
const status = await PatrolModel.getTodayPatrolStatus(category_id);
res.json({ success: true, data: status });
} catch (error) {
console.error('오늘 현황 조회 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 작업장별 점검 현황
getWorkplaceCheckStatus: async (req, res) => {
try {
const { sessionId } = req.params;
const status = await PatrolModel.getWorkplaceCheckStatus(sessionId);
res.json({ success: true, data: status });
} catch (error) {
console.error('작업장별 점검 현황 조회 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// ==================== 작업장 상세 정보 (통합) ====================
// 작업장 상세 정보 조회 (시설물, 안전신고, 부적합, 출입, TBM)
getWorkplaceDetail: async (req, res) => {
try {
const { workplaceId } = req.params;
const { date } = req.query; // 기본: 오늘
const targetDate = date || new Date().toISOString().slice(0, 10);
const { getDb } = require('../dbPool');
const db = await getDb();
// 1. 작업장 기본 정보 (카테고리 지도 이미지 포함)
const [workplaceInfo] = await db.query(`
SELECT w.*, wc.category_name, wc.layout_image as category_layout_image
FROM workplaces w
LEFT JOIN workplace_categories wc ON w.category_id = wc.category_id
WHERE w.workplace_id = ?
`, [workplaceId]);
if (!workplaceInfo.length) {
return res.status(404).json({ success: false, message: '작업장을 찾을 수 없습니다.' });
}
// 2. 설비 현황 (해당 작업장 - 원래 위치 또는 현재 위치)
let equipments = [];
try {
const [eqResult] = await db.query(`
SELECT e.equipment_id, e.equipment_name, e.equipment_code, e.equipment_type,
e.status, e.notes, e.workplace_id,
e.map_x_percent, e.map_y_percent, e.map_width_percent, e.map_height_percent,
e.is_temporarily_moved, e.current_workplace_id,
e.current_map_x_percent, e.current_map_y_percent,
e.current_map_width_percent, e.current_map_height_percent,
e.moved_at,
ow.workplace_name as original_workplace_name,
cw.workplace_name as current_workplace_name,
CASE
WHEN e.status IN ('maintenance', 'repair_needed', 'repair_external') THEN 1
WHEN e.is_temporarily_moved = 1 THEN 1
ELSE 0
END as needs_attention
FROM equipments e
LEFT JOIN workplaces ow ON e.workplace_id = ow.workplace_id
LEFT JOIN workplaces cw ON e.current_workplace_id = cw.workplace_id
WHERE (e.workplace_id = ? OR e.current_workplace_id = ?)
AND e.status != 'inactive'
ORDER BY needs_attention DESC, e.equipment_name
`, [workplaceId, workplaceId]);
equipments = eqResult;
} catch (eqError) {
console.log('설비 조회 스킵 (테이블 없음 또는 오류):', eqError.message);
}
// 3. 수리 요청 현황 (미완료) - 테이블 존재 여부 확인 후 조회
let repairRequests = [];
try {
const [repairResult] = await db.query(`
SELECT er.request_id, er.request_date, er.repair_category, er.description,
er.priority, er.status, e.equipment_name, e.equipment_code
FROM equipment_repair_requests er
JOIN equipments e ON er.equipment_id = e.equipment_id
WHERE e.workplace_id = ? AND er.status NOT IN ('completed', 'cancelled')
ORDER BY
CASE er.priority WHEN 'emergency' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END,
er.request_date DESC
LIMIT 10
`, [workplaceId]);
repairRequests = repairResult;
} catch (repairError) {
console.log('수리요청 조회 스킵 (테이블 없음 또는 오류):', repairError.message);
}
// 4. 안전 신고 및 부적합 사항 - 테이블 존재 여부 확인 후 조회
let workIssues = [];
try {
const [issueResult] = await db.query(`
SELECT wi.report_id, wi.issue_type, wi.title, wi.description,
wi.status, wi.severity, wi.created_at, wi.resolved_at,
wic.category_name, wic.issue_type as category_type,
u.name as reporter_name
FROM work_issue_reports wi
LEFT JOIN work_issue_categories wic ON wi.category_id = wic.category_id
LEFT JOIN Users u ON wi.reporter_id = u.user_id
WHERE wi.workplace_id = ?
AND wi.created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
ORDER BY wi.created_at DESC
LIMIT 20
`, [workplaceId]);
workIssues = issueResult;
} catch (issueError) {
console.log('신고 조회 스킵 (테이블 없음 또는 오류):', issueError.message);
}
// 5. 오늘의 출입 기록 (해당 공장 카테고리)
const categoryId = workplaceInfo[0].category_id;
let visitRecords = [];
try {
const [visitResult] = await db.query(`
SELECT vr.request_id, vr.visitor_name, vr.visitor_company, vr.visit_purpose,
vr.visit_date, vr.visit_time_from, vr.visit_time_to, vr.status,
vr.vehicle_number, vr.companion_count,
vp.purpose_name, u.name as requester_name
FROM visit_requests vr
LEFT JOIN visit_purposes vp ON vr.purpose_id = vp.purpose_id
LEFT JOIN Users u ON vr.requester_id = u.user_id
WHERE vr.category_id = ? AND vr.visit_date = ? AND vr.status = 'approved'
ORDER BY vr.visit_time_from
`, [categoryId, targetDate]);
visitRecords = visitResult;
} catch (visitError) {
console.log('출입기록 조회 스킵 (테이블 없음 또는 오류):', visitError.message);
}
// 6. 오늘의 TBM 세션 (해당 공장 카테고리)
let tbmSessions = [];
try {
const [tbmResult] = await db.query(`
SELECT ts.session_id, ts.session_date, ts.work_location, ts.status,
ts.work_content, ts.safety_measures, ts.team_size,
t.task_name, wt.name as work_type_name,
u.name as leader_name, w.worker_name as leader_worker_name
FROM tbm_sessions ts
LEFT JOIN tasks t ON ts.task_id = t.task_id
LEFT JOIN work_types wt ON t.work_type_id = wt.id
LEFT JOIN Users u ON ts.leader_id = u.user_id
LEFT JOIN workers w ON ts.leader_worker_id = w.worker_id
WHERE ts.category_id = ? AND ts.session_date = ?
ORDER BY ts.created_at DESC
`, [categoryId, targetDate]);
tbmSessions = tbmResult;
} catch (tbmError) {
console.log('TBM 조회 스킵 (테이블 없음 또는 오류):', tbmError.message);
}
// 7. TBM 팀원 정보 (세션별)
let tbmWithTeams = [];
try {
tbmWithTeams = await Promise.all(tbmSessions.map(async (session) => {
const [team] = await db.query(`
SELECT tta.assignment_id, w.worker_name, w.occupation,
tta.attendance_status, tta.signature_image
FROM tbm_team_assignments tta
JOIN workers w ON tta.worker_id = w.worker_id
WHERE tta.session_id = ?
ORDER BY w.worker_name
`, [session.session_id]);
return { ...session, team };
}));
} catch (teamError) {
console.log('TBM 팀원 조회 스킵:', teamError.message);
tbmWithTeams = tbmSessions.map(s => ({ ...s, team: [] }));
}
// 8. 최근 순회점검 결과 (해당 작업장)
let recentPatrol = [];
try {
const [patrolResult] = await db.query(`
SELECT ps.session_id, ps.patrol_date, ps.patrol_time, ps.status,
ps.notes, u.name as inspector_name,
(SELECT COUNT(*) FROM patrol_check_records pcr
WHERE pcr.session_id = ps.session_id AND pcr.workplace_id = ?) as checked_count,
(SELECT COUNT(*) FROM patrol_check_records pcr
WHERE pcr.session_id = ps.session_id AND pcr.workplace_id = ?
AND pcr.check_result IN ('warning', 'bad')) as issue_count
FROM patrol_sessions ps
LEFT JOIN Users u ON ps.inspector_id = u.user_id
WHERE ps.category_id = ? AND ps.patrol_date >= DATE_SUB(NOW(), INTERVAL 7 DAY)
ORDER BY ps.patrol_date DESC, ps.patrol_time DESC
LIMIT 5
`, [workplaceId, workplaceId, categoryId]);
recentPatrol = patrolResult;
} catch (patrolError) {
console.log('순회점검 조회 스킵 (테이블 없음 또는 오류):', patrolError.message);
}
res.json({
success: true,
data: {
workplace: workplaceInfo[0],
equipments: equipments,
repairRequests: repairRequests,
workIssues: {
safety: workIssues.filter(i => i.category_type === 'safety'),
nonconformity: workIssues.filter(i => i.category_type === 'nonconformity'),
all: workIssues
},
visitRecords: visitRecords,
tbmSessions: tbmWithTeams,
recentPatrol: recentPatrol,
summary: {
equipmentCount: equipments.length,
needsAttention: equipments.filter(e => e.needs_attention).length,
pendingRepairs: repairRequests.length,
openIssues: workIssues.filter(i => i.status !== 'closed').length,
todayVisitors: visitRecords.reduce((sum, v) => sum + 1 + (v.companion_count || 0), 0),
todayTbmSessions: tbmSessions.length
}
}
});
} catch (error) {
console.error('작업장 상세 정보 조회 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// ==================== 구역 내 등록 물품/시설물 ====================
// 구역 내 물품/시설물 목록 조회
getZoneItems: async (req, res) => {
try {
const { workplaceId } = req.params;
const { getDb } = require('../dbPool');
const db = await getDb();
// 테이블이 없으면 생성
await db.query(`
CREATE TABLE IF NOT EXISTS workplace_zone_items (
item_id INT AUTO_INCREMENT PRIMARY KEY,
workplace_id INT NOT NULL,
item_name VARCHAR(200) NOT NULL COMMENT '물품/시설물 명칭',
item_type VARCHAR(50) DEFAULT 'general' COMMENT '유형 (heavy_equipment, hazardous, storage, general 등)',
description TEXT COMMENT '상세 설명',
x_percent DECIMAL(5,2) NOT NULL COMMENT '영역 시작 X 좌표 (%)',
y_percent DECIMAL(5,2) NOT NULL COMMENT '영역 시작 Y 좌표 (%)',
width_percent DECIMAL(5,2) DEFAULT 10 COMMENT '영역 너비 (%)',
height_percent DECIMAL(5,2) DEFAULT 10 COMMENT '영역 높이 (%)',
color VARCHAR(20) DEFAULT '#3b82f6' COMMENT '표시 색상',
warning_level VARCHAR(20) DEFAULT 'normal' COMMENT '주의 수준 (normal, caution, danger)',
quantity INT DEFAULT 1 COMMENT '수량',
unit VARCHAR(20) DEFAULT '개' COMMENT '단위',
weight_kg DECIMAL(10,2) DEFAULT NULL COMMENT '중량 (kg)',
is_active BOOLEAN DEFAULT TRUE,
created_by INT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_workplace (workplace_id),
INDEX idx_type (item_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='구역 내 등록 물품/시설물'
`);
// 새 컬럼 추가 (없으면)
try {
await db.query(`ALTER TABLE workplace_zone_items ADD COLUMN project_type VARCHAR(20) DEFAULT 'non_project'`);
} catch (e) { /* 이미 존재 */ }
try {
await db.query(`ALTER TABLE workplace_zone_items ADD COLUMN project_id INT NULL`);
} catch (e) { /* 이미 존재 */ }
const [items] = await db.query(`
SELECT zi.*, p.project_name
FROM workplace_zone_items zi
LEFT JOIN projects p ON zi.project_id = p.project_id
WHERE zi.workplace_id = ? AND zi.is_active = TRUE
ORDER BY zi.warning_level DESC, zi.item_name
`, [workplaceId]);
// 사진 테이블 존재 확인 및 사진 조회
try {
for (const item of items) {
const [photos] = await db.query(`
SELECT photo_id, photo_url, created_at
FROM zone_item_photos
WHERE item_id = ?
ORDER BY created_at DESC
`, [item.item_id]);
item.photos = photos || [];
}
} catch (e) {
// 사진 테이블이 없으면 무시
items.forEach(item => item.photos = []);
}
res.json({ success: true, data: items });
} catch (error) {
console.error('구역 물품 목록 조회 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 구역 현황 등록
createZoneItem: async (req, res) => {
try {
const { workplaceId } = req.params;
const { item_name, item_type, description, x_percent, y_percent, width_percent, height_percent,
color, warning_level, project_type, project_id } = req.body;
const createdBy = req.user?.user_id;
const { getDb } = require('../dbPool');
const db = await getDb();
if (!item_name || x_percent === undefined || y_percent === undefined) {
return res.status(400).json({ success: false, message: '필수 정보가 누락되었습니다.' });
}
// 테이블에 새 컬럼 추가 (없으면)
try {
await db.query(`ALTER TABLE workplace_zone_items ADD COLUMN project_type VARCHAR(20) DEFAULT 'non_project'`);
} catch (e) { /* 이미 존재 */ }
try {
await db.query(`ALTER TABLE workplace_zone_items ADD COLUMN project_id INT NULL`);
} catch (e) { /* 이미 존재 */ }
const [result] = await db.query(`
INSERT INTO workplace_zone_items
(workplace_id, item_name, item_type, description, x_percent, y_percent, width_percent, height_percent,
color, warning_level, project_type, project_id, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [workplaceId, item_name, item_type || 'working', description, x_percent, y_percent,
width_percent || 5, height_percent || 5, color || '#3b82f6', warning_level || 'good',
project_type || 'non_project', project_id || null, createdBy]);
const newItemId = result.insertId;
// 등록 이력 저장
try {
await db.query(`
INSERT INTO zone_item_history (item_id, action_type, new_values, changed_by)
VALUES (?, 'created', ?, ?)
`, [newItemId, JSON.stringify({ item_name, item_type, warning_level, project_type }), createdBy]);
} catch (e) { /* 테이블 없으면 무시 */ }
res.json({
success: true,
data: { item_id: newItemId },
message: '현황이 등록되었습니다.'
});
} catch (error) {
console.error('구역 현황 등록 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 구역 현황 수정
updateZoneItem: async (req, res) => {
try {
const { itemId } = req.params;
const { item_name, item_type, description, x_percent, y_percent, width_percent, height_percent,
color, warning_level, project_type, project_id } = req.body;
const userId = req.user?.user_id;
const { getDb } = require('../dbPool');
const db = await getDb();
// 이력 테이블 생성 (없으면)
await db.query(`
CREATE TABLE IF NOT EXISTS zone_item_history (
history_id INT AUTO_INCREMENT PRIMARY KEY,
item_id INT NOT NULL,
action_type VARCHAR(20) NOT NULL COMMENT 'created, updated, deleted',
changed_fields TEXT COMMENT '변경된 필드 JSON',
old_values TEXT COMMENT '이전 값 JSON',
new_values TEXT COMMENT '새 값 JSON',
changed_by INT,
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_item (item_id),
INDEX idx_date (changed_at)
)
`);
// 기존 데이터 조회 (이력용)
const [oldData] = await db.query(`SELECT * FROM workplace_zone_items WHERE item_id = ?`, [itemId]);
const oldItem = oldData[0];
// 업데이트
await db.query(`
UPDATE workplace_zone_items SET
item_name = COALESCE(?, item_name),
item_type = COALESCE(?, item_type),
description = ?,
x_percent = COALESCE(?, x_percent),
y_percent = COALESCE(?, y_percent),
width_percent = COALESCE(?, width_percent),
height_percent = COALESCE(?, height_percent),
color = COALESCE(?, color),
warning_level = COALESCE(?, warning_level),
project_type = COALESCE(?, project_type),
project_id = ?
WHERE item_id = ?
`, [item_name, item_type, description, x_percent, y_percent, width_percent, height_percent,
color, warning_level, project_type, project_id, itemId]);
// 변경 이력 저장
if (oldItem) {
const changedFields = [];
const oldValues = {};
const newValues = {};
const fieldMap = { item_name, item_type, description, warning_level, project_type, project_id };
for (const [key, newVal] of Object.entries(fieldMap)) {
if (newVal !== undefined && oldItem[key] !== newVal) {
changedFields.push(key);
oldValues[key] = oldItem[key];
newValues[key] = newVal;
}
}
if (changedFields.length > 0) {
await db.query(`
INSERT INTO zone_item_history (item_id, action_type, changed_fields, old_values, new_values, changed_by)
VALUES (?, 'updated', ?, ?, ?, ?)
`, [itemId, JSON.stringify(changedFields), JSON.stringify(oldValues), JSON.stringify(newValues), userId]);
}
}
res.json({ success: true, message: '현황이 수정되었습니다.' });
} catch (error) {
console.error('구역 현황 수정 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 구역 현황 사진 업로드
uploadZoneItemPhoto: async (req, res) => {
try {
const { item_id } = req.body;
const { getDb } = require('../dbPool');
const db = await getDb();
if (!req.file) {
return res.status(400).json({ success: false, message: '파일이 없습니다.' });
}
// 사진 테이블 생성 (없으면)
await db.query(`
CREATE TABLE IF NOT EXISTS zone_item_photos (
photo_id INT AUTO_INCREMENT PRIMARY KEY,
item_id INT NOT NULL,
photo_url VARCHAR(500) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_item_id (item_id)
)
`);
const photoUrl = `/uploads/${req.file.filename}`;
const [result] = await db.query(
`INSERT INTO zone_item_photos (item_id, photo_url) VALUES (?, ?)`,
[item_id, photoUrl]
);
res.json({
success: true,
data: { photo_id: result.insertId, photo_url: photoUrl },
message: '사진이 업로드되었습니다.'
});
} catch (error) {
console.error('사진 업로드 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 구역 현황 삭제
deleteZoneItem: async (req, res) => {
try {
const { itemId } = req.params;
const userId = req.user?.user_id;
const { getDb } = require('../dbPool');
const db = await getDb();
// 기존 데이터 조회 (이력용)
const [oldData] = await db.query(`SELECT * FROM workplace_zone_items WHERE item_id = ?`, [itemId]);
const oldItem = oldData[0];
// 소프트 삭제
await db.query(`UPDATE workplace_zone_items SET is_active = FALSE WHERE item_id = ?`, [itemId]);
// 삭제 이력 저장
if (oldItem) {
await db.query(`
INSERT INTO zone_item_history (item_id, action_type, old_values, changed_by)
VALUES (?, 'deleted', ?, ?)
`, [itemId, JSON.stringify({ item_name: oldItem.item_name, item_type: oldItem.item_type }), userId]);
}
res.json({ success: true, message: '현황이 삭제되었습니다.' });
} catch (error) {
console.error('구역 현황 삭제 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 구역 현황 이력 조회
getZoneItemHistory: async (req, res) => {
try {
const { itemId } = req.params;
const { getDb } = require('../dbPool');
const db = await getDb();
const [history] = await db.query(`
SELECT h.*, u.full_name as changed_by_name
FROM zone_item_history h
LEFT JOIN users u ON h.changed_by = u.user_id
WHERE h.item_id = ?
ORDER BY h.changed_at DESC
LIMIT 50
`, [itemId]);
res.json({ success: true, data: history });
} catch (error) {
console.error('현황 이력 조회 오류:', error);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
}
};
module.exports = PatrolController;

View File

@@ -0,0 +1,142 @@
/**
* 프로젝트 관리 컨트롤러
*
* 프로젝트 CRUD API 엔드포인트 핸들러
*
* @author TK-FB-Project
* @since 2025-12-11
*/
const projectModel = require('../models/projectModel');
const { ValidationError, NotFoundError, DatabaseError } = require('../utils/errors');
const { asyncHandler } = require('../middlewares/errorHandler');
const logger = require('../utils/logger');
const cache = require('../utils/cache');
/**
* 프로젝트 생성
*/
exports.createProject = asyncHandler(async (req, res) => {
const projectData = req.body;
logger.info('프로젝트 생성 요청', { name: projectData.name });
const id = await projectModel.create(projectData);
// 프로젝트 캐시 무효화
await cache.invalidateCache.project();
logger.info('프로젝트 생성 성공', { project_id: id });
res.status(201).json({
success: true,
data: { project_id: id },
message: '프로젝트가 성공적으로 생성되었습니다'
});
});
/**
* 전체 프로젝트 조회
*/
exports.getAllProjects = asyncHandler(async (req, res) => {
const rows = await projectModel.getAll();
res.json({
success: true,
data: rows,
message: '프로젝트 목록 조회 성공'
});
});
/**
* 활성 프로젝트만 조회 (작업보고서용)
*/
exports.getActiveProjects = asyncHandler(async (req, res) => {
const rows = await projectModel.getActiveProjects();
res.json({
success: true,
data: rows,
message: '활성 프로젝트 목록 조회 성공'
});
});
/**
* 단일 프로젝트 조회
*/
exports.getProjectById = asyncHandler(async (req, res) => {
const id = parseInt(req.params.project_id, 10);
if (isNaN(id)) {
throw new ValidationError('유효하지 않은 프로젝트 ID입니다');
}
const row = await projectModel.getById(id);
if (!row) {
throw new NotFoundError('프로젝트를 찾을 수 없습니다');
}
res.json({
success: true,
data: row,
message: '프로젝트 조회 성공'
});
});
/**
* 프로젝트 수정
*/
exports.updateProject = asyncHandler(async (req, res) => {
const id = parseInt(req.params.project_id, 10);
if (isNaN(id)) {
throw new ValidationError('유효하지 않은 프로젝트 ID입니다');
}
const data = { ...req.body, project_id: id };
const changes = await projectModel.update(data);
if (changes === 0) {
throw new NotFoundError('프로젝트를 찾을 수 없습니다');
}
// 프로젝트 캐시 무효화
await cache.invalidateCache.project();
logger.info('프로젝트 수정 성공', { project_id: id });
res.json({
success: true,
data: { changes },
message: '프로젝트 정보가 성공적으로 수정되었습니다'
});
});
/**
* 프로젝트 삭제
*/
exports.removeProject = asyncHandler(async (req, res) => {
const id = parseInt(req.params.project_id, 10);
if (isNaN(id)) {
throw new ValidationError('유효하지 않은 프로젝트 ID입니다');
}
const changes = await projectModel.remove(id);
if (changes === 0) {
throw new NotFoundError('프로젝트를 찾을 수 없습니다');
}
// 프로젝트 캐시 무효화
await cache.invalidateCache.project();
logger.info('프로젝트 삭제 성공', { project_id: id });
res.json({
success: true,
message: '프로젝트가 성공적으로 삭제되었습니다'
});
});

View File

@@ -0,0 +1,467 @@
// 시스템 관리 컨트롤러
const { getDb } = require('../dbPool');
const bcrypt = require('bcryptjs');
const { ApiError, asyncHandler, handleDatabaseError } = require('../utils/errorHandler');
const { validateSchema, schemas } = require('../utils/validator');
/**
* 시스템 상태 확인
*/
exports.getSystemStatus = asyncHandler(async (req, res) => {
try {
const db = await getDb();
// 데이터베이스 연결 상태 확인
const [dbStatus] = await db.query('SELECT 1 as status');
// 시스템 상태 정보
const systemStatus = {
server: 'online',
database: dbStatus.length > 0 ? 'online' : 'offline'
};
res.health('healthy', systemStatus);
} catch (error) {
handleDatabaseError(error, '시스템 상태 확인');
}
});
/**
* 데이터베이스 상태 확인
*/
exports.getDatabaseStatus = asyncHandler(async (req, res) => {
try {
const db = await getDb();
// 데이터베이스 연결 수 확인
const [connections] = await db.query('SHOW STATUS LIKE "Threads_connected"');
const [maxConnections] = await db.query('SHOW VARIABLES LIKE "max_connections"');
// 데이터베이스 크기 확인
const [dbSize] = await db.query(`
SELECT
ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS size_mb
FROM information_schema.tables
WHERE table_schema = DATABASE()
`);
const dbStatus = {
status: 'online',
connections: parseInt(connections[0]?.Value || 0),
max_connections: parseInt(maxConnections[0]?.Value || 0),
size_mb: dbSize[0]?.size_mb || 0
};
res.success(dbStatus, '데이터베이스 상태 조회 성공');
} catch (error) {
handleDatabaseError(error, '데이터베이스 상태 확인');
}
});
/**
* 시스템 알림 조회
*/
exports.getSystemAlerts = async (req, res) => {
try {
const db = await getDb();
// 최근 실패한 로그인 시도
const [failedLogins] = await db.query(`
SELECT COUNT(*) as count
FROM login_logs
WHERE login_status = 'failed'
AND login_time > DATE_SUB(NOW(), INTERVAL 1 HOUR)
`);
// 비활성 사용자 수
const [inactiveusers] = await db.query(`
SELECT COUNT(*) as count
FROM users
WHERE is_active = 0
`);
const alerts = [];
if (failedLogins[0]?.count > 5) {
alerts.push({
type: 'security',
level: 'warning',
message: `최근 1시간 동안 ${failedLogins[0].count}회의 로그인 실패가 발생했습니다.`,
timestamp: new Date().toISOString()
});
}
if (inactiveusers[0]?.count > 0) {
alerts.push({
type: 'user',
level: 'info',
message: `${inactiveusers[0].count}명의 비활성 사용자가 있습니다.`,
timestamp: new Date().toISOString()
});
}
res.json({
success: true,
alerts: alerts
});
} catch (error) {
console.error('시스템 알림 조회 오류:', error);
res.status(500).json({
success: false,
error: '시스템 알림을 조회할 수 없습니다.'
});
}
};
/**
* 최근 시스템 활동 조회
*/
exports.getRecentActivities = async (req, res) => {
try {
const db = await getDb();
// 최근 로그인 활동
const [loginActivities] = await db.query(`
SELECT
ll.login_time as created_at,
u.name as user_name,
ll.login_status,
ll.ip_address,
'login' as activity_type
FROM login_logs ll
LEFT JOIN users u ON ll.user_id = u.user_id
ORDER BY ll.login_time DESC
LIMIT 10
`);
// 비밀번호 변경 활동
const [passwordActivities] = await db.query(`
SELECT
pcl.changed_at as created_at,
u.name as user_name,
pcl.change_type,
'password_change' as activity_type
FROM password_change_logs pcl
LEFT JOIN users u ON pcl.user_id = u.user_id
ORDER BY pcl.changed_at DESC
LIMIT 5
`);
// 활동 통합 및 정렬
const activities = [
...loginActivities.map(activity => ({
type: activity.login_status === 'success' ? 'login' : 'login_failed',
title: activity.login_status === 'success'
? `${activity.user_name || '알 수 없는 사용자'} 로그인`
: `로그인 실패 (${activity.ip_address})`,
description: activity.login_status === 'success'
? `IP: ${activity.ip_address}`
: `사용자: ${activity.user_name || '알 수 없음'}`,
created_at: activity.created_at
})),
...passwordActivities.map(activity => ({
type: 'password_change',
title: `${activity.user_name || '알 수 없는 사용자'} 비밀번호 변경`,
description: `변경 유형: ${activity.change_type}`,
created_at: activity.created_at
}))
];
// 시간순 정렬
activities.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
res.json({
success: true,
data: activities.slice(0, 15)
});
} catch (error) {
console.error('최근 활동 조회 오류:', error);
res.status(500).json({
success: false,
error: '최근 활동을 조회할 수 없습니다.'
});
}
};
/**
* 사용자 통계 조회
*/
exports.getUserStats = async (req, res) => {
try {
const db = await getDb();
// 전체 사용자 수
const [totalusers] = await db.query('SELECT COUNT(*) as count FROM users');
// 활성 사용자 수
const [activeusers] = await db.query('SELECT COUNT(*) as count FROM users WHERE is_active = 1');
// 최근 24시간 로그인 사용자 수
const [recentLogins] = await db.query(`
SELECT COUNT(DISTINCT user_id) as count
FROM login_logs
WHERE login_status = 'success'
AND login_time > DATE_SUB(NOW(), INTERVAL 24 HOUR)
`);
// 권한별 사용자 수
const [roleStats] = await db.query(`
SELECT role, COUNT(*) as count
FROM users
WHERE is_active = 1
GROUP BY role
`);
res.json({
success: true,
data: {
total: totalusers[0]?.count || 0,
active: activeusers[0]?.count || 0,
recent_logins: recentLogins[0]?.count || 0,
by_role: roleStats
}
});
} catch (error) {
console.error('사용자 통계 조회 오류:', error);
res.status(500).json({
success: false,
error: '사용자 통계를 조회할 수 없습니다.'
});
}
};
/**
* 모든 사용자 목록 조회 (시스템 관리자용)
*/
exports.getAllUsers = asyncHandler(async (req, res) => {
try {
const db = await getDb();
const [users] = await db.query(`
SELECT
user_id,
username,
name,
email,
role,
access_level,
worker_id,
is_active,
last_login_at,
failed_login_attempts,
locked_until,
created_at,
updated_at
FROM users
ORDER BY created_at DESC
`);
res.list(users, '사용자 목록 조회 성공');
} catch (error) {
handleDatabaseError(error, '사용자 목록 조회');
}
});
/**
* 사용자 생성
*/
exports.createUser = asyncHandler(async (req, res) => {
const { username, password, name, email, role, access_level, worker_id } = req.body;
// 스키마 기반 유효성 검사
validateSchema(req.body, schemas.createUser);
try {
const db = await getDb();
// 사용자명 중복 확인
const [existing] = await db.query('SELECT user_id FROM users WHERE username = ?', [username]);
if (existing.length > 0) {
throw new ApiError('이미 존재하는 사용자명입니다.', 409);
}
// 이메일 중복 확인 (이메일이 제공된 경우)
if (email) {
const [existingEmail] = await db.query('SELECT user_id FROM users WHERE email = ?', [email]);
if (existingEmail.length > 0) {
throw new ApiError('이미 사용 중인 이메일입니다.', 409);
}
}
// 비밀번호 해시화
const hashedPassword = await bcrypt.hash(password, 10);
// 사용자 생성
const [result] = await db.query(`
INSERT INTO users (username, password, name, email, role, access_level, worker_id, is_active, created_at, password_changed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 1, NOW(), NOW())
`, [username, hashedPassword, name, email || null, role, access_level || role, worker_id || null]);
// 비밀번호 변경 로그 기록
await db.query(`
INSERT INTO password_change_logs (user_id, changed_by_user_id, changed_at, change_type)
VALUES (?, ?, NOW(), 'initial')
`, [result.insertId, req.user.user_id]);
res.created({ user_id: result.insertId }, '사용자가 성공적으로 생성되었습니다.');
} catch (error) {
handleDatabaseError(error, '사용자 생성');
}
});
/**
* 사용자 수정
*/
exports.updateUser = async (req, res) => {
try {
const { id } = req.params;
const { name, email, role, access_level, is_active, worker_id } = req.body;
const db = await getDb();
// 사용자 존재 확인
const [user] = await db.query('SELECT user_id FROM users WHERE user_id = ?', [id]);
if (user.length === 0) {
return res.status(404).json({
success: false,
error: '해당 사용자를 찾을 수 없습니다.'
});
}
// 이메일 중복 확인 (다른 사용자가 사용 중인지)
if (email) {
const [existingEmail] = await db.query(
'SELECT user_id FROM users WHERE email = ? AND user_id != ?',
[email, id]
);
if (existingEmail.length > 0) {
return res.status(409).json({
success: false,
error: '이미 사용 중인 이메일입니다.'
});
}
}
// 사용자 정보 업데이트
await db.query(`
UPDATE users
SET name = ?, email = ?, role = ?, access_level = ?, is_active = ?, worker_id = ?, updated_at = NOW()
WHERE user_id = ?
`, [name, email || null, role, access_level || role, is_active ? 1 : 0, worker_id || null, id]);
res.json({
success: true,
message: '사용자 정보가 성공적으로 업데이트되었습니다.'
});
} catch (error) {
console.error('사용자 수정 오류:', error);
res.status(500).json({
success: false,
error: '사용자 수정 중 오류가 발생했습니다.'
});
}
};
/**
* 사용자 삭제
*/
exports.deleteUser = async (req, res) => {
try {
const { id } = req.params;
const db = await getDb();
// 자기 자신 삭제 방지
if (parseInt(id) === req.user.user_id) {
return res.status(400).json({
success: false,
error: '자기 자신은 삭제할 수 없습니다.'
});
}
// 사용자 존재 확인
const [user] = await db.query('SELECT user_id, username FROM users WHERE user_id = ?', [id]);
if (user.length === 0) {
return res.status(404).json({
success: false,
error: '해당 사용자를 찾을 수 없습니다.'
});
}
// 사용자 삭제 (관련 로그는 유지)
await db.query('DELETE FROM users WHERE user_id = ?', [id]);
res.json({
success: true,
message: `사용자 '${user[0].username}'가 성공적으로 삭제되었습니다.`
});
} catch (error) {
console.error('사용자 삭제 오류:', error);
res.status(500).json({
success: false,
error: '사용자 삭제 중 오류가 발생했습니다.'
});
}
};
/**
* 사용자 비밀번호 재설정
*/
exports.resetUserPassword = async (req, res) => {
try {
const { id } = req.params;
const { new_password } = req.body;
const db = await getDb();
if (!new_password || new_password.length < 6) {
return res.status(400).json({
success: false,
error: '비밀번호는 최소 6자 이상이어야 합니다.'
});
}
// 사용자 존재 확인
const [user] = await db.query('SELECT user_id, username FROM users WHERE user_id = ?', [id]);
if (user.length === 0) {
return res.status(404).json({
success: false,
error: '해당 사용자를 찾을 수 없습니다.'
});
}
// 비밀번호 해시화
const hashedPassword = await bcrypt.hash(new_password, 10);
// 비밀번호 업데이트
await db.query(`
UPDATE users
SET password = ?, password_changed_at = NOW(), failed_login_attempts = 0, locked_until = NULL
WHERE user_id = ?
`, [hashedPassword, id]);
// 비밀번호 변경 로그 기록
await db.query(`
INSERT INTO password_change_logs (user_id, changed_by_user_id, changed_at, change_type)
VALUES (?, ?, NOW(), 'admin')
`, [id, req.user.user_id]);
res.json({
success: true,
message: `사용자 '${user[0].username}'의 비밀번호가 재설정되었습니다.`
});
} catch (error) {
console.error('비밀번호 재설정 오류:', error);
res.status(500).json({
success: false,
error: '비밀번호 재설정 중 오류가 발생했습니다.'
});
}
};

View File

@@ -0,0 +1,152 @@
/**
* 작업 관리 컨트롤러
*
* 작업 CRUD API 엔드포인트 핸들러
* (공정=work_types에 속하는 세부 작업)
*
* @author TK-FB-Project
* @since 2026-01-26
*/
const taskModel = require('../models/taskModel');
const { ValidationError, NotFoundError, DatabaseError } = require('../utils/errors');
const { asyncHandler } = require('../middlewares/errorHandler');
const logger = require('../utils/logger');
// ==================== 작업 CRUD ====================
/**
* 작업 생성
*/
exports.createTask = asyncHandler(async (req, res) => {
const taskData = req.body;
if (!taskData.task_name) {
throw new ValidationError('작업명은 필수 입력 항목입니다');
}
logger.info('작업 생성 요청', { name: taskData.task_name });
const id = await taskModel.createTask(taskData);
logger.info('작업 생성 성공', { task_id: id });
res.status(201).json({
success: true,
data: { task_id: id },
message: '작업이 성공적으로 생성되었습니다'
});
});
/**
* 전체 작업 조회 (work_type_id 필터 지원)
*/
exports.getAllTasks = asyncHandler(async (req, res) => {
const { work_type_id } = req.query;
let rows;
if (work_type_id) {
// 특정 공정의 활성 작업만 조회
rows = await taskModel.getTasksByWorkType(work_type_id);
} else {
rows = await taskModel.getAllTasks();
}
res.json({
success: true,
data: rows,
message: '작업 목록 조회 성공'
});
});
/**
* 활성 작업만 조회
*/
exports.getActiveTasks = asyncHandler(async (req, res) => {
const rows = await taskModel.getActiveTasks();
res.json({
success: true,
data: rows,
message: '활성 작업 목록 조회 성공'
});
});
/**
* 공정별 작업 조회
*/
exports.getTasksByWorkType = asyncHandler(async (req, res) => {
const workTypeId = req.params.work_type_id || req.query.work_type_id;
if (!workTypeId) {
throw new ValidationError('공정 ID가 필요합니다');
}
const rows = await taskModel.getTasksByWorkType(workTypeId);
res.json({
success: true,
data: rows,
message: '공정별 작업 목록 조회 성공'
});
});
/**
* 단일 작업 조회
*/
exports.getTaskById = asyncHandler(async (req, res) => {
const taskId = req.params.id;
const task = await taskModel.getTaskById(taskId);
if (!task) {
throw new NotFoundError('작업을 찾을 수 없습니다');
}
res.json({
success: true,
data: task,
message: '작업 조회 성공'
});
});
/**
* 작업 수정
*/
exports.updateTask = asyncHandler(async (req, res) => {
const taskId = req.params.id;
const taskData = req.body;
if (!taskData.task_name) {
throw new ValidationError('작업명은 필수 입력 항목입니다');
}
logger.info('작업 수정 요청', { task_id: taskId });
await taskModel.updateTask(taskId, taskData);
logger.info('작업 수정 성공', { task_id: taskId });
res.json({
success: true,
message: '작업이 성공적으로 수정되었습니다'
});
});
/**
* 작업 삭제
*/
exports.deleteTask = asyncHandler(async (req, res) => {
const taskId = req.params.id;
logger.info('작업 삭제 요청', { task_id: taskId });
await taskModel.deleteTask(taskId);
logger.info('작업 삭제 성공', { task_id: taskId });
res.json({
success: true,
message: '작업이 성공적으로 삭제되었습니다'
});
});

View File

@@ -0,0 +1,893 @@
// controllers/tbmController.js - TBM 시스템 컨트롤러
const TbmModel = require('../models/tbmModel');
const TbmController = {
// ==================== TBM 세션 관련 ====================
/**
* TBM 세션 생성
*/
createSession: (req, res) => {
const sessionData = {
session_date: req.body.session_date,
leader_id: req.body.leader_id || null,
project_id: req.body.project_id || null,
work_location: req.body.work_location || null,
work_description: req.body.work_description || null,
safety_notes: req.body.safety_notes || null,
start_time: req.body.start_time || null,
created_by: req.user.user_id
};
// 필수 필드 검증 (날짜만 필수, leader_id는 관리자의 경우 null 허용)
if (!sessionData.session_date) {
return res.status(400).json({
success: false,
message: 'TBM 날짜는 필수입니다.'
});
}
TbmModel.createSession(sessionData, (err, result) => {
if (err) {
console.error('TBM 세션 생성 오류:', err);
return res.status(500).json({
success: false,
message: 'TBM 세션 생성 중 오류가 발생했습니다.',
error: err.message
});
}
res.status(201).json({
success: true,
message: 'TBM 세션이 생성되었습니다.',
data: {
session_id: result.insertId,
...sessionData
}
});
});
},
/**
* 특정 날짜의 TBM 세션 목록 조회
*/
getSessionsByDate: (req, res) => {
const { date } = req.params;
if (!date) {
return res.status(400).json({
success: false,
message: '날짜 정보가 필요합니다.'
});
}
TbmModel.getSessionsByDate(date, (err, results) => {
if (err) {
console.error('TBM 세션 조회 오류:', err);
return res.status(500).json({
success: false,
message: 'TBM 세션 조회 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
data: results
});
});
},
/**
* TBM 세션 상세 조회
*/
getSessionById: (req, res) => {
const { sessionId } = req.params;
TbmModel.getSessionById(sessionId, (err, results) => {
if (err) {
console.error('TBM 세션 상세 조회 오류:', err);
return res.status(500).json({
success: false,
message: 'TBM 세션 상세 조회 중 오류가 발생했습니다.',
error: err.message
});
}
if (results.length === 0) {
return res.status(404).json({
success: false,
message: 'TBM 세션을 찾을 수 없습니다.'
});
}
res.json({
success: true,
data: results[0]
});
});
},
/**
* TBM 세션 수정
*/
updateSession: (req, res) => {
const { sessionId } = req.params;
const sessionData = {
project_id: req.body.project_id,
work_location: req.body.work_location,
work_description: req.body.work_description,
safety_notes: req.body.safety_notes,
status: req.body.status || 'draft'
};
TbmModel.updateSession(sessionId, sessionData, (err, result) => {
if (err) {
console.error('TBM 세션 수정 오류:', err);
return res.status(500).json({
success: false,
message: 'TBM 세션 수정 중 오류가 발생했습니다.',
error: err.message
});
}
if (result.affectedRows === 0) {
return res.status(404).json({
success: false,
message: 'TBM 세션을 찾을 수 없습니다.'
});
}
res.json({
success: true,
message: 'TBM 세션이 수정되었습니다.'
});
});
},
/**
* TBM 세션 완료 처리
*/
completeSession: (req, res) => {
const { sessionId } = req.params;
const endTime = req.body.end_time || new Date().toTimeString().slice(0, 8);
TbmModel.completeSession(sessionId, endTime, (err, result) => {
if (err) {
console.error('TBM 세션 완료 처리 오류:', err);
return res.status(500).json({
success: false,
message: 'TBM 세션 완료 처리 중 오류가 발생했습니다.',
error: err.message
});
}
if (result.affectedRows === 0) {
return res.status(404).json({
success: false,
message: 'TBM 세션을 찾을 수 없습니다.'
});
}
res.json({
success: true,
message: 'TBM 세션이 완료되었습니다.'
});
});
},
// ==================== 팀 구성 관련 ====================
/**
* 팀원 추가 (작업자별 상세 정보 포함)
*/
addTeamMember: (req, res) => {
const assignmentData = {
session_id: req.params.sessionId,
worker_id: req.body.worker_id,
assigned_role: req.body.assigned_role || null,
work_detail: req.body.work_detail || null,
is_present: req.body.is_present,
absence_reason: req.body.absence_reason || null,
project_id: req.body.project_id || null,
work_type_id: req.body.work_type_id || null,
task_id: req.body.task_id || null,
workplace_category_id: req.body.workplace_category_id || null,
workplace_id: req.body.workplace_id || null
};
if (!assignmentData.worker_id) {
return res.status(400).json({
success: false,
message: '작업자 ID가 필요합니다.'
});
}
TbmModel.addTeamMember(assignmentData, (err, result) => {
if (err) {
console.error('팀원 추가 오류:', err);
return res.status(500).json({
success: false,
message: '팀원 추가 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
message: '팀원이 추가되었습니다.'
});
});
},
/**
* 팀 구성 일괄 추가
*/
addTeamMembers: (req, res) => {
const { sessionId } = req.params;
const { members } = req.body;
if (!Array.isArray(members) || members.length === 0) {
return res.status(400).json({
success: false,
message: '팀원 목록이 필요합니다.'
});
}
TbmModel.addTeamMembers(sessionId, members, (err, result) => {
if (err) {
console.error('팀 구성 일괄 추가 오류:', err);
return res.status(500).json({
success: false,
message: '팀 구성 추가 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
message: `${members.length}명의 팀원이 추가되었습니다.`,
data: { count: members.length }
});
});
},
/**
* TBM 세션의 팀 구성 조회
*/
getTeamMembers: (req, res) => {
const { sessionId } = req.params;
TbmModel.getTeamMembers(sessionId, (err, results) => {
if (err) {
console.error('팀 구성 조회 오류:', err);
return res.status(500).json({
success: false,
message: '팀 구성 조회 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
data: results
});
});
},
/**
* 팀원 제거
*/
removeTeamMember: (req, res) => {
const { sessionId, workerId } = req.params;
TbmModel.removeTeamMember(sessionId, workerId, (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: '팀원이 제거되었습니다.'
});
});
},
/**
* 세션의 모든 팀원 삭제 (수정 시 사용)
*/
clearAllTeamMembers: (req, res) => {
const { sessionId } = req.params;
TbmModel.clearAllTeamMembers(sessionId, (err, result) => {
if (err) {
console.error('팀원 전체 삭제 오류:', err);
return res.status(500).json({
success: false,
message: '팀원 전체 삭제 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
message: '모든 팀원이 삭제되었습니다.',
data: { deletedCount: result.affectedRows }
});
});
},
// ==================== 안전 체크리스트 관련 ====================
/**
* 모든 안전 체크 항목 조회
*/
getAllSafetyChecks: (req, res) => {
TbmModel.getAllSafetyChecks((err, results) => {
if (err) {
console.error('안전 체크 항목 조회 오류:', err);
return res.status(500).json({
success: false,
message: '안전 체크 항목 조회 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
data: results
});
});
},
/**
* TBM 세션의 안전 체크 기록 조회
*/
getSafetyRecords: (req, res) => {
const { sessionId } = req.params;
TbmModel.getSafetyRecords(sessionId, (err, results) => {
if (err) {
console.error('안전 체크 기록 조회 오류:', err);
return res.status(500).json({
success: false,
message: '안전 체크 기록 조회 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
data: results
});
});
},
/**
* 안전 체크 일괄 저장
*/
saveSafetyRecords: (req, res) => {
const { sessionId } = req.params;
const { records } = req.body;
if (!Array.isArray(records) || records.length === 0) {
return res.status(400).json({
success: false,
message: '안전 체크 기록이 필요합니다.'
});
}
const checkedBy = req.user.user_id;
TbmModel.saveSafetyRecords(sessionId, records, checkedBy, (err, result) => {
if (err) {
console.error('안전 체크 저장 오류:', err);
return res.status(500).json({
success: false,
message: '안전 체크 저장 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
message: '안전 체크가 저장되었습니다.',
data: { count: records.length }
});
});
},
// ==================== 필터링된 안전 체크리스트 (확장) ====================
/**
* 세션에 맞는 필터링된 안전 체크 항목 조회
* 기본 + 날씨 + 작업별 체크항목 통합
*/
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: '안전 체크 항목이 삭제되었습니다.'
});
});
},
// ==================== 작업 인계 관련 ====================
/**
* 작업 인계 생성
*/
createHandover: (req, res) => {
const handoverData = {
session_id: req.body.session_id,
from_leader_id: req.body.from_leader_id,
to_leader_id: req.body.to_leader_id,
handover_date: req.body.handover_date,
handover_time: req.body.handover_time || null,
reason: req.body.reason,
handover_notes: req.body.handover_notes || null,
worker_ids: req.body.worker_ids || []
};
// 필수 필드 검증
if (!handoverData.session_id || !handoverData.from_leader_id ||
!handoverData.to_leader_id || !handoverData.handover_date || !handoverData.reason) {
return res.status(400).json({
success: false,
message: '필수 정보가 누락되었습니다.'
});
}
TbmModel.createHandover(handoverData, (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: { handover_id: result.insertId }
});
});
},
/**
* 작업 인계 확인
*/
confirmHandover: (req, res) => {
const { handoverId } = req.params;
const confirmedBy = req.user.user_id;
TbmModel.confirmHandover(handoverId, confirmedBy, (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: '작업 인계가 확인되었습니다.'
});
});
},
/**
* 특정 날짜의 작업 인계 목록 조회
*/
getHandoversByDate: (req, res) => {
const { date } = req.params;
TbmModel.getHandoversByDate(date, (err, results) => {
if (err) {
console.error('작업 인계 목록 조회 오류:', err);
return res.status(500).json({
success: false,
message: '작업 인계 목록 조회 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
data: results
});
});
},
/**
* 나에게 온 미확인 인계 건 조회
*/
getMyPendingHandovers: (req, res) => {
// worker_id는 req.user에서 가져옴
const toLeaderId = req.user.worker_id;
if (!toLeaderId) {
return res.status(400).json({
success: false,
message: '작업자 정보를 찾을 수 없습니다.'
});
}
TbmModel.getPendingHandovers(toLeaderId, (err, results) => {
if (err) {
console.error('미확인 인계 건 조회 오류:', err);
return res.status(500).json({
success: false,
message: '미확인 인계 건 조회 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
data: results
});
});
},
// ==================== 통계 및 리포트 ====================
/**
* TBM 통계 조회
*/
getTbmStatistics: (req, res) => {
const { startDate, endDate } = req.query;
if (!startDate || !endDate) {
return res.status(400).json({
success: false,
message: '시작일과 종료일이 필요합니다.'
});
}
TbmModel.getTbmStatistics(startDate, endDate, (err, results) => {
if (err) {
console.error('TBM 통계 조회 오류:', err);
return res.status(500).json({
success: false,
message: 'TBM 통계 조회 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
data: results
});
});
},
/**
* 리더별 TBM 진행 현황 조회
*/
getLeaderStatistics: (req, res) => {
const { startDate, endDate } = req.query;
if (!startDate || !endDate) {
return res.status(400).json({
success: false,
message: '시작일과 종료일이 필요합니다.'
});
}
TbmModel.getLeaderStatistics(startDate, endDate, (err, results) => {
if (err) {
console.error('리더 통계 조회 오류:', err);
return res.status(500).json({
success: false,
message: '리더 통계 조회 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
data: results
});
});
},
/**
* 작업보고서가 작성되지 않은 TBM 팀 배정 조회
*/
getIncompleteWorkReports: (req, res) => {
const userId = req.user.user_id;
const accessLevel = req.user.access_level;
// 관리자는 모든 TBM 조회, 일반 사용자는 본인이 작성한 것만 조회
const filterUserId = (accessLevel === 'system' || accessLevel === 'admin') ? null : userId;
TbmModel.getIncompleteWorkReports(filterUserId, (err, results) => {
if (err) {
console.error('미완료 작업보고서 조회 오류:', err);
return res.status(500).json({
success: false,
message: '미완료 작업보고서 조회 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
data: results
});
});
}
};
module.exports = TbmController;

View File

@@ -0,0 +1,75 @@
/**
* 도구 관리 컨트롤러
*
* 도구(공구) 재고 및 위치 관리 API 엔드포인트 핸들러
*
* @author TK-FB-Project
* @since 2025-12-11
*/
const toolsService = require('../services/toolsService');
const { asyncHandler } = require('../middlewares/errorHandler');
/**
* 전체 도구 조회
*/
exports.getAll = asyncHandler(async (req, res) => {
const rows = await toolsService.getAllToolsService();
res.json({
success: true,
data: rows,
message: '도구 목록 조회 성공'
});
});
/**
* 단일 도구 조회
*/
exports.getById = asyncHandler(async (req, res) => {
const id = parseInt(req.params.id, 10);
const row = await toolsService.getToolByIdService(id);
res.json({
success: true,
data: row,
message: '도구 조회 성공'
});
});
/**
* 도구 생성
*/
exports.create = asyncHandler(async (req, res) => {
const result = await toolsService.createToolService(req.body);
res.status(201).json({
success: true,
data: result,
message: '도구가 성공적으로 생성되었습니다'
});
});
/**
* 도구 수정
*/
exports.update = asyncHandler(async (req, res) => {
const id = parseInt(req.params.id, 10);
const result = await toolsService.updateToolService(id, req.body);
res.json({
success: true,
data: result,
message: '도구 정보가 성공적으로 수정되었습니다'
});
});
/**
* 도구 삭제
*/
exports.delete = asyncHandler(async (req, res) => {
const id = parseInt(req.params.id, 10);
await toolsService.deleteToolService(id);
res.status(204).send();
});

View File

@@ -0,0 +1,38 @@
/**
* 문서 업로드 관리 컨트롤러
*
* 파일 업로드 및 문서 메타데이터 CRUD API 엔드포인트 핸들러
*
* @author TK-FB-Project
* @since 2025-12-11
*/
const uploadService = require('../services/uploadService');
const { asyncHandler } = require('../middlewares/errorHandler');
/**
* 문서 업로드
*/
exports.createUpload = asyncHandler(async (req, res) => {
const doc = req.body;
const result = await uploadService.createUploadService(doc);
res.status(201).json({
success: true,
data: result,
message: '문서가 성공적으로 업로드되었습니다'
});
});
/**
* 전체 업로드 문서 조회
*/
exports.getUploads = asyncHandler(async (req, res) => {
const rows = await uploadService.getAllUploadsService();
res.json({
success: true,
data: rows,
message: '업로드 문서 목록 조회 성공'
});
});

View File

@@ -0,0 +1,739 @@
/**
* 사용자 관리 컨트롤러
*
* 사용자 CRUD 및 상태 관리 기능을 제공하는 컨트롤러
*
* @author TK-FB-Project
* @since 2025-12-11
*/
const bcrypt = require('bcrypt');
const { ValidationError, ForbiddenError, NotFoundError, ConflictError, DatabaseError } = require('../utils/errors');
const { asyncHandler } = require('../middlewares/errorHandler');
const logger = require('../utils/logger');
/**
* 관리자 권한 확인 헬퍼 함수
*/
const checkAdminPermission = (user) => {
if (!user || !['admin', 'system'].includes(user.access_level)) {
throw new ForbiddenError('관리자 권한이 필요합니다');
}
};
/**
* 모든 사용자 조회
*/
const getAllUsers = asyncHandler(async (req, res) => {
checkAdminPermission(req.user);
logger.info('사용자 목록 조회 요청', { requestedBy: req.user?.username });
const { getDb } = require('../dbPool');
const db = await getDb();
try {
const query = `
SELECT
u.user_id,
u.username,
u.name,
u.email,
u.role_id,
r.name as role,
u._access_level_old as access_level,
u.is_active,
u.worker_id,
w.worker_name,
w.department_id,
d.department_name,
u.created_at,
u.updated_at,
u.last_login_at as last_login
FROM users u
LEFT JOIN roles r ON u.role_id = r.id
LEFT JOIN workers w ON u.worker_id = w.worker_id
LEFT JOIN departments d ON w.department_id = d.department_id
ORDER BY u.created_at DESC
`;
const [users] = await db.execute(query);
logger.info('사용자 목록 조회 성공', { count: users.length });
res.json({
success: true,
data: users,
message: '사용자 목록 조회 성공'
});
} catch (error) {
logger.error('사용자 목록 조회 실패', { error: error.message });
throw new DatabaseError('사용자 목록을 조회하는데 실패했습니다');
}
});
/**
* 특정 사용자 조회
*/
const getUserById = asyncHandler(async (req, res) => {
const { id } = req.params;
if (!id || isNaN(id)) {
throw new ValidationError('유효하지 않은 사용자 ID입니다');
}
logger.info('사용자 조회 요청', { userId: id });
const { getDb } = require('../dbPool');
const db = await getDb();
try {
const query = `
SELECT
user_id,
username,
name,
email,
phone,
role,
access_level,
is_active,
created_at,
updated_at,
last_login
FROM users
WHERE user_id = ?
`;
const [users] = await db.execute(query, [id]);
if (users.length === 0) {
throw new NotFoundError('사용자를 찾을 수 없습니다');
}
logger.info('사용자 조회 성공', { userId: id, username: users[0].username });
res.json({
success: true,
data: users[0],
message: '사용자 조회 성공'
});
} catch (error) {
if (error instanceof NotFoundError) {
throw error;
}
logger.error('사용자 조회 실패', { userId: id, error: error.message });
throw new DatabaseError('사용자를 조회하는데 실패했습니다');
}
});
/**
* 새 사용자 생성
*/
const createUser = asyncHandler(async (req, res) => {
checkAdminPermission(req.user);
const { username, name, email, phone, role, password } = req.body;
logger.info('사용자 생성 요청', { username, name, role });
// 필수 필드 검증
if (!username || !name || !role || !password) {
throw new ValidationError('필수 필드가 누락되었습니다', {
required: ['username', 'name', 'role', 'password'],
received: { username, name, role, password: '***' }
});
}
// 사용자명 유효성 검증
if (username.length < 3 || username.length > 20) {
throw new ValidationError('사용자명은 3-20자 사이여야 합니다');
}
// 비밀번호 유효성 검증
if (password.length < 6) {
throw new ValidationError('비밀번호는 최소 6자 이상이어야 합니다');
}
// 권한 레벨 검증
const validRoles = ['admin', 'group_leader', 'worker'];
if (!validRoles.includes(role)) {
throw new ValidationError('유효하지 않은 권한입니다', {
valid: validRoles,
received: role
});
}
const { getDb } = require('../dbPool');
const db = await getDb();
try {
// 사용자명 중복 확인
const checkQuery = 'SELECT user_id FROM users WHERE username = ?';
const [existing] = await db.execute(checkQuery, [username]);
if (existing.length > 0) {
throw new ConflictError('이미 존재하는 사용자명입니다');
}
// 비밀번호 해시화
const hashedPassword = await bcrypt.hash(password, 10);
// 사용자 생성
const insertQuery = `
INSERT INTO users (username, name, email, phone, role, access_level, password_hash, is_active, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 1, NOW())
`;
const [result] = await db.execute(insertQuery, [
username,
name,
email || null,
phone || null,
role,
role, // access_level을 role과 동일하게 설정
hashedPassword
]);
logger.info('사용자 생성 성공', {
userId: result.insertId,
username,
name,
role,
createdBy: req.user.username
});
res.status(201).json({
success: true,
data: { user_id: result.insertId },
message: '사용자가 성공적으로 생성되었습니다'
});
} catch (error) {
if (error instanceof ConflictError) {
throw error;
}
logger.error('사용자 생성 실패', { username, error: error.message });
throw new DatabaseError('사용자를 생성하는데 실패했습니다');
}
});
/**
* 사용자 정보 수정
*/
const updateUser = asyncHandler(async (req, res) => {
checkAdminPermission(req.user);
const { id } = req.params;
const { username, name, email, role, role_id, password, worker_id } = req.body;
if (!id || isNaN(id)) {
throw new ValidationError('유효하지 않은 사용자 ID입니다');
}
logger.info('사용자 수정 요청', { userId: id, body: req.body });
// 최소 하나의 수정 필드가 필요
if (!username && !name && email === undefined && !role && !role_id && !password && worker_id === undefined) {
throw new ValidationError('수정할 필드가 없습니다');
}
const { getDb } = require('../dbPool');
const db = await getDb();
try {
// 사용자 존재 확인
const checkQuery = 'SELECT user_id, username, is_active FROM users WHERE user_id = ?';
const [existing] = await db.execute(checkQuery, [id]);
if (existing.length === 0) {
throw new NotFoundError('사용자를 찾을 수 없습니다');
}
if (existing[0].is_active === 0) {
throw new ValidationError('비활성화된 사용자는 수정할 수 없습니다');
}
// 업데이트할 필드들
const updates = [];
const values = [];
if (username) {
if (username.length < 3 || username.length > 20) {
throw new ValidationError('사용자명은 3-20자 사이여야 합니다');
}
// 사용자명 중복 확인 (자신 제외)
const dupQuery = 'SELECT user_id FROM users WHERE username = ? AND user_id != ?';
const [duplicate] = await db.execute(dupQuery, [username, id]);
if (duplicate.length > 0) {
throw new ConflictError('이미 존재하는 사용자명입니다');
}
updates.push('username = ?');
values.push(username);
}
if (name) {
updates.push('name = ?');
values.push(name);
}
if (email !== undefined) {
updates.push('email = ?');
values.push(email || null);
}
// role_id 또는 role 문자열 처리
if (role_id) {
// role_id가 유효한지 확인
const [roleCheck] = await db.execute('SELECT id, name FROM roles WHERE id = ?', [role_id]);
if (roleCheck.length === 0) {
throw new ValidationError('유효하지 않은 역할 ID입니다');
}
updates.push('role_id = ?');
values.push(role_id);
logger.info('role_id로 역할 변경', { userId: id, role_id, role_name: roleCheck[0].name });
} else if (role) {
// role 문자열을 role_id로 변환 (하위 호환성)
const roleNameMap = {
'admin': 'Admin',
'system': 'System Admin',
'user': 'User',
'guest': 'Guest',
'group_leader': 'User', // 임시 매핑
'worker': 'User' // 임시 매핑
};
const roleName = roleNameMap[role.toLowerCase()] || role;
const [roleCheck] = await db.execute('SELECT id FROM roles WHERE name = ?', [roleName]);
if (roleCheck.length === 0) {
throw new ValidationError(`유효하지 않은 권한입니다: ${role}`);
}
updates.push('role_id = ?');
values.push(roleCheck[0].id);
logger.info('role 문자열로 역할 변경', { userId: id, role, role_id: roleCheck[0].id });
}
if (password) {
if (password.length < 6) {
throw new ValidationError('비밀번호는 최소 6자 이상이어야 합니다');
}
const hashedPassword = await bcrypt.hash(password, 10);
updates.push('password = ?');
values.push(hashedPassword);
}
// worker_id 업데이트 (null도 허용 - 연결 해제)
if (worker_id !== undefined) {
if (worker_id !== null) {
// worker_id가 유효한지 확인
const [workerCheck] = await db.execute('SELECT worker_id, worker_name FROM workers WHERE worker_id = ?', [worker_id]);
if (workerCheck.length === 0) {
throw new ValidationError('유효하지 않은 작업자 ID입니다');
}
logger.info('작업자 연결', { userId: id, worker_id, worker_name: workerCheck[0].worker_name });
} else {
logger.info('작업자 연결 해제', { userId: id });
}
updates.push('worker_id = ?');
values.push(worker_id);
}
updates.push('updated_at = NOW()');
values.push(id);
const updateQuery = `UPDATE users SET ${updates.join(', ')} WHERE user_id = ?`;
logger.info('실행할 UPDATE 쿼리', { query: updateQuery, values });
await db.execute(updateQuery, values);
logger.info('사용자 수정 성공', {
userId: id,
username: existing[0].username,
updatedFields: Object.keys(req.body),
updatedBy: req.user.username
});
res.json({
success: true,
data: { user_id: id },
message: '사용자 정보가 성공적으로 수정되었습니다'
});
} catch (error) {
if (error instanceof NotFoundError || error instanceof ValidationError || error instanceof ConflictError) {
throw error;
}
logger.error('사용자 수정 실패', { userId: id, error: error.message, stack: error.stack });
throw new DatabaseError('사용자 정보를 수정하는데 실패했습니다');
}
});
/**
* 사용자 상태 변경 (활성화/비활성화)
*/
const updateUserStatus = asyncHandler(async (req, res) => {
checkAdminPermission(req.user);
const { id } = req.params;
const { is_active } = req.body;
if (!id || isNaN(id)) {
throw new ValidationError('유효하지 않은 사용자 ID입니다');
}
if (is_active === undefined || ![0, 1, true, false].includes(is_active)) {
throw new ValidationError('유효하지 않은 활성 상태 값입니다');
}
const activeValue = is_active === true || is_active === 1 ? 1 : 0;
// 자기 자신 비활성화 방지
if (parseInt(id) === req.user.user_id && activeValue === 0) {
throw new ValidationError('자기 자신을 비활성화할 수 없습니다');
}
logger.info('사용자 상태 변경 요청', { userId: id, is_active: activeValue });
const { getDb } = require('../dbPool');
const db = await getDb();
try {
// 사용자 존재 확인
const checkQuery = 'SELECT user_id, username, is_active FROM users WHERE user_id = ?';
const [users] = await db.execute(checkQuery, [id]);
if (users.length === 0) {
throw new NotFoundError('사용자를 찾을 수 없습니다');
}
// 상태 변경이 필요한지 확인
if (users[0].is_active === activeValue) {
const status = activeValue === 1 ? '활성' : '비활성';
throw new ValidationError(`사용자가 이미 ${status} 상태입니다`);
}
const query = 'UPDATE users SET is_active = ?, updated_at = NOW() WHERE user_id = ?';
await db.execute(query, [activeValue, id]);
const statusText = activeValue === 1 ? '활성화' : '비활성화';
logger.info(`사용자 ${statusText} 성공`, {
userId: id,
username: users[0].username,
newStatus: activeValue,
updatedBy: req.user.username
});
res.json({
success: true,
data: { user_id: id, is_active: activeValue },
message: `사용자가 성공적으로 ${statusText}되었습니다`
});
} catch (error) {
if (error instanceof NotFoundError || error instanceof ValidationError) {
throw error;
}
logger.error('사용자 상태 변경 실패', { userId: id, error: error.message });
throw new DatabaseError('사용자 상태를 변경하는데 실패했습니다');
}
});
/**
* 사용자 삭제 (Soft Delete)
*/
const deleteUser = asyncHandler(async (req, res) => {
checkAdminPermission(req.user);
const { id } = req.params;
if (!id || isNaN(id)) {
throw new ValidationError('유효하지 않은 사용자 ID입니다');
}
// 자기 자신 삭제 방지
if (req.user && req.user.user_id == id) {
throw new ValidationError('자기 자신은 삭제할 수 없습니다');
}
logger.info('사용자 삭제 요청', { userId: id });
const { getDb } = require('../dbPool');
const db = await getDb();
try {
// 사용자 존재 확인
const checkQuery = 'SELECT user_id, username, is_active FROM users WHERE user_id = ?';
const [users] = await db.execute(checkQuery, [id]);
if (users.length === 0) {
throw new NotFoundError('사용자를 찾을 수 없습니다');
}
if (users[0].is_active === 0) {
throw new ValidationError('이미 비활성화된 사용자입니다');
}
// Soft Delete (is_active = 0)
const query = 'UPDATE users SET is_active = 0, updated_at = NOW() WHERE user_id = ?';
await db.execute(query, [id]);
logger.info('사용자 비활성화 성공', {
userId: id,
username: users[0].username,
deletedBy: req.user.username
});
res.json({
success: true,
data: { user_id: id },
message: '사용자가 성공적으로 비활성화되었습니다'
});
} catch (error) {
if (error instanceof NotFoundError || error instanceof ValidationError) {
throw error;
}
logger.error('사용자 비활성화 실패', { userId: id, error: error.message });
throw new DatabaseError('사용자를 비활성화하는데 실패했습니다');
}
});
/**
* 사용자 영구 삭제 (Hard Delete)
*/
const permanentDeleteUser = asyncHandler(async (req, res) => {
checkAdminPermission(req.user);
const { id } = req.params;
if (!id || isNaN(id)) {
throw new ValidationError('유효하지 않은 사용자 ID입니다');
}
// 자기 자신 삭제 방지
if (req.user && req.user.user_id == id) {
throw new ValidationError('자기 자신은 삭제할 수 없습니다');
}
logger.info('사용자 영구 삭제 요청', { userId: id });
const { getDb } = require('../dbPool');
const db = await getDb();
try {
// 사용자 존재 확인
const checkQuery = 'SELECT user_id, username FROM users WHERE user_id = ?';
const [users] = await db.execute(checkQuery, [id]);
if (users.length === 0) {
throw new NotFoundError('사용자를 찾을 수 없습니다');
}
const username = users[0].username;
// 관련 데이터 삭제 (외래 키 제약 조건 때문에 순서 중요)
// 1. 로그인 로그 삭제
await db.execute('DELETE FROM login_logs WHERE user_id = ?', [id]);
// 2. 페이지 접근 권한 삭제
await db.execute('DELETE FROM user_page_access WHERE user_id = ?', [id]);
// 3. 사용자 삭제
await db.execute('DELETE FROM users WHERE user_id = ?', [id]);
logger.info('사용자 영구 삭제 성공', {
userId: id,
username: username,
deletedBy: req.user.username
});
res.json({
success: true,
data: { user_id: id },
message: `사용자 "${username}"이(가) 영구적으로 삭제되었습니다`
});
} catch (error) {
if (error instanceof NotFoundError || error instanceof ValidationError) {
throw error;
}
logger.error('사용자 영구 삭제 실패', { userId: id, error: error.message });
throw new DatabaseError('사용자를 영구 삭제하는데 실패했습니다');
}
});
/**
* 사용자의 페이지 접근 권한 조회
*/
const getUserPageAccess = asyncHandler(async (req, res) => {
const { id } = req.params;
if (!id || isNaN(id)) {
throw new ValidationError('유효하지 않은 사용자 ID입니다');
}
logger.info('사용자 페이지 권한 조회 요청', { userId: id });
const { getDb } = require('../dbPool');
const db = await getDb();
try {
// 권한 조회: user_page_access에 명시적 권한이 있으면 사용, 없으면 is_default_accessible 사용
const query = `
SELECT
p.id as page_id,
p.page_key,
p.page_name,
p.page_path,
p.category,
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 = ?
ORDER BY p.category, p.display_order
`;
const [pageAccess] = await db.execute(query, [id]);
logger.info('사용자 페이지 권한 조회 성공', { userId: id, pageCount: pageAccess.length });
res.json({
success: true,
data: {
pageAccess
},
message: '페이지 권한 조회 성공'
});
} catch (error) {
logger.error('사용자 페이지 권한 조회 실패', { userId: id, error: error.message });
throw new DatabaseError('페이지 권한을 조회하는데 실패했습니다');
}
});
/**
* 사용자의 페이지 접근 권한 업데이트
*/
const updateUserPageAccess = asyncHandler(async (req, res) => {
checkAdminPermission(req.user);
const { id } = req.params;
const { pageAccess } = req.body;
if (!id || isNaN(id)) {
throw new ValidationError('유효하지 않은 사용자 ID입니다');
}
if (!Array.isArray(pageAccess)) {
throw new ValidationError('pageAccess는 배열이어야 합니다');
}
logger.info('사용자 페이지 권한 업데이트 요청', {
userId: id,
pageCount: pageAccess.length,
updatedBy: req.user.username
});
const { getDb } = require('../dbPool');
const db = await getDb();
try {
// 트랜잭션 시작
await db.query('START TRANSACTION');
// 기존 권한 삭제
await db.execute('DELETE FROM user_page_access WHERE user_id = ?', [id]);
// 새 권한 삽입
if (pageAccess.length > 0) {
const values = pageAccess.map(p => [id, p.page_id, p.can_access]);
const placeholders = values.map(() => '(?, ?, ?)').join(', ');
const flatValues = values.flat();
await db.execute(
`INSERT INTO user_page_access (user_id, page_id, can_access) VALUES ${placeholders}`,
flatValues
);
}
// 커밋
await db.query('COMMIT');
logger.info('사용자 페이지 권한 업데이트 성공', {
userId: id,
pageCount: pageAccess.length,
updatedBy: req.user.username
});
res.json({
success: true,
data: { user_id: id },
message: '페이지 권한이 성공적으로 업데이트되었습니다'
});
} catch (error) {
// 롤백
await db.query('ROLLBACK');
logger.error('사용자 페이지 권한 업데이트 실패', { userId: id, error: error.message });
throw new DatabaseError('페이지 권한을 업데이트하는데 실패했습니다');
}
});
/**
* 사용자 비밀번호 초기화 (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,
createUser,
updateUser,
updateUserStatus,
deleteUser,
permanentDeleteUser,
getUserPageAccess,
updateUserPageAccess,
resetUserPassword
};

View File

@@ -0,0 +1,421 @@
/**
* vacationBalanceController.js
* 휴가 잔액 관련 컨트롤러
*/
const vacationBalanceModel = require('../models/vacationBalanceModel');
const vacationTypeModel = require('../models/vacationTypeModel');
const vacationBalanceController = {
/**
* 특정 작업자의 휴가 잔액 조회 (특정 연도)
* GET /api/vacation-balances/worker/:workerId/year/:year
*/
async getByWorkerAndYear(req, res) {
try {
const { workerId, year } = req.params;
vacationBalanceModel.getByWorkerAndYear(workerId, year, (err, results) => {
if (err) {
console.error('휴가 잔액 조회 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 잔액을 조회하는 중 오류가 발생했습니다'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('getByWorkerAndYear 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 모든 작업자의 휴가 잔액 조회 (특정 연도)
* GET /api/vacation-balances/year/:year
*/
async getAllByYear(req, res) {
try {
const { year } = req.params;
vacationBalanceModel.getAllByYear(year, (err, results) => {
if (err) {
console.error('전체 휴가 잔액 조회 오류:', err);
return res.status(500).json({
success: false,
message: '전체 휴가 잔액을 조회하는 중 오류가 발생했습니다'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('getAllByYear 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 휴가 잔액 생성
* POST /api/vacation-balances
*/
async createBalance(req, res) {
try {
const {
worker_id,
vacation_type_id,
year,
total_days,
used_days,
notes
} = req.body;
const created_by = req.user.user_id;
// 필수 필드 검증
if (!worker_id || !vacation_type_id || !year || total_days === undefined) {
return res.status(400).json({
success: false,
message: '필수 필드가 누락되었습니다 (worker_id, vacation_type_id, year, total_days)'
});
}
// 중복 체크
vacationBalanceModel.getByWorkerTypeYear(worker_id, vacation_type_id, year, (err, existing) => {
if (err) {
console.error('중복 체크 오류:', err);
return res.status(500).json({
success: false,
message: '중복 체크 중 오류가 발생했습니다'
});
}
if (existing && existing.length > 0) {
return res.status(400).json({
success: false,
message: '이미 해당 작업자의 해당 연도 휴가 잔액이 존재합니다'
});
}
const balanceData = {
worker_id,
vacation_type_id,
year,
total_days,
used_days: used_days || 0,
notes: notes || null,
created_by
};
vacationBalanceModel.create(balanceData, (err, result) => {
if (err) {
console.error('휴가 잔액 생성 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 잔액을 생성하는 중 오류가 발생했습니다'
});
}
res.status(201).json({
success: true,
message: '휴가 잔액이 생성되었습니다',
data: { id: result.insertId }
});
});
});
} catch (error) {
console.error('createBalance 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 휴가 잔액 수정
* PUT /api/vacation-balances/:id
*/
async updateBalance(req, res) {
try {
const { id } = req.params;
const { total_days, used_days, notes } = req.body;
const updateData = {};
if (total_days !== undefined) updateData.total_days = total_days;
if (used_days !== undefined) updateData.used_days = used_days;
if (notes !== undefined) updateData.notes = notes;
updateData.updated_at = new Date();
if (Object.keys(updateData).length === 1) {
return res.status(400).json({
success: false,
message: '수정할 데이터가 없습니다'
});
}
vacationBalanceModel.update(id, updateData, (err, result) => {
if (err) {
console.error('휴가 잔액 수정 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 잔액을 수정하는 중 오류가 발생했습니다'
});
}
if (result.affectedRows === 0) {
return res.status(404).json({
success: false,
message: '휴가 잔액을 찾을 수 없습니다'
});
}
res.json({
success: true,
message: '휴가 잔액이 수정되었습니다'
});
});
} catch (error) {
console.error('updateBalance 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 휴가 잔액 삭제
* DELETE /api/vacation-balances/:id
*/
async deleteBalance(req, res) {
try {
const { id } = req.params;
vacationBalanceModel.delete(id, (err, result) => {
if (err) {
console.error('휴가 잔액 삭제 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 잔액을 삭제하는 중 오류가 발생했습니다'
});
}
if (result.affectedRows === 0) {
return res.status(404).json({
success: false,
message: '휴가 잔액을 찾을 수 없습니다'
});
}
res.json({
success: true,
message: '휴가 잔액이 삭제되었습니다'
});
});
} catch (error) {
console.error('deleteBalance 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 근속년수 기반 연차 자동 계산 및 생성
* POST /api/vacation-balances/auto-calculate
*/
async autoCalculateAndCreate(req, res) {
try {
const { worker_id, hire_date, year } = req.body;
const created_by = req.user.user_id;
if (!worker_id || !hire_date || !year) {
return res.status(400).json({
success: false,
message: '필수 필드가 누락되었습니다 (worker_id, hire_date, year)'
});
}
// 연차 일수 계산
const annualDays = vacationBalanceModel.calculateAnnualLeaveDays(hire_date, year);
// ANNUAL 휴가 유형 ID 조회
vacationTypeModel.getByCode('ANNUAL', (err, types) => {
if (err || !types || types.length === 0) {
console.error('ANNUAL 휴가 유형 조회 오류:', err);
return res.status(500).json({
success: false,
message: 'ANNUAL 휴가 유형을 찾을 수 없습니다'
});
}
const annualTypeId = types[0].id;
// 중복 체크
vacationBalanceModel.getByWorkerTypeYear(worker_id, annualTypeId, year, (err, existing) => {
if (err) {
console.error('중복 체크 오류:', err);
return res.status(500).json({
success: false,
message: '중복 체크 중 오류가 발생했습니다'
});
}
if (existing && existing.length > 0) {
return res.status(400).json({
success: false,
message: '이미 해당 작업자의 해당 연도 연차가 존재합니다'
});
}
const balanceData = {
worker_id,
vacation_type_id: annualTypeId,
year,
total_days: annualDays,
used_days: 0,
notes: `근속년수 기반 자동 계산 (입사일: ${hire_date})`,
created_by
};
vacationBalanceModel.create(balanceData, (err, result) => {
if (err) {
console.error('휴가 잔액 생성 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 잔액을 생성하는 중 오류가 발생했습니다'
});
}
res.status(201).json({
success: true,
message: `${annualDays}일의 연차가 자동으로 생성되었습니다`,
data: {
id: result.insertId,
calculated_days: annualDays
}
});
});
});
});
} catch (error) {
console.error('autoCalculateAndCreate 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 휴가 잔액 일괄 저장 (upsert)
* POST /api/vacation-balances/bulk-upsert
*/
async bulkUpsert(req, res) {
try {
const { balances } = req.body;
const created_by = req.user.user_id;
if (!balances || !Array.isArray(balances) || balances.length === 0) {
return res.status(400).json({
success: false,
message: '저장할 데이터가 없습니다'
});
}
const { getDb } = require('../dbPool');
const db = await getDb();
let successCount = 0;
let errorCount = 0;
for (const balance of balances) {
const { worker_id, vacation_type_id, year, total_days, notes } = balance;
if (!worker_id || !vacation_type_id || !year || total_days === undefined) {
errorCount++;
continue;
}
try {
// Upsert 쿼리
const query = `
INSERT INTO vacation_balance_details
(worker_id, vacation_type_id, year, total_days, used_days, notes, created_by)
VALUES (?, ?, ?, ?, 0, ?, ?)
ON DUPLICATE KEY UPDATE
total_days = VALUES(total_days),
notes = VALUES(notes),
updated_at = NOW()
`;
await db.query(query, [worker_id, vacation_type_id, year, total_days, notes || null, created_by]);
successCount++;
} catch (err) {
console.error('휴가 잔액 저장 오류:', err);
errorCount++;
}
}
res.json({
success: true,
message: `${successCount}건 저장 완료${errorCount > 0 ? `, ${errorCount}건 실패` : ''}`,
data: { successCount, errorCount }
});
} catch (error) {
console.error('bulkUpsert 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 작업자의 사용 가능한 휴가 일수 조회
* GET /api/vacation-balances/worker/:workerId/year/:year/available
*/
async getAvailableDays(req, res) {
try {
const { workerId, year } = req.params;
vacationBalanceModel.getAvailableVacationDays(workerId, year, (err, results) => {
if (err) {
console.error('사용 가능 휴가 조회 오류:', err);
return res.status(500).json({
success: false,
message: '사용 가능 휴가를 조회하는 중 오류가 발생했습니다'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('getAvailableDays 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
}
};
module.exports = vacationBalanceController;

View File

@@ -0,0 +1,565 @@
/**
* vacationRequestController.js
* 휴가 신청 관련 컨트롤러
*/
const vacationRequestModel = require('../models/vacationRequestModel');
// TODO: workerVacationBalanceModel 구현 필요
// const workerVacationBalanceModel = require('../models/workerVacationBalanceModel');
const vacationRequestController = {
/**
* 휴가 신청 생성
*/
async createRequest(req, res) {
try {
const { worker_id, vacation_type_id, start_date, end_date, days_used, reason } = req.body;
const requested_by = req.user.user_id;
// 필수 필드 검증
if (!worker_id || !vacation_type_id || !start_date || !end_date || !days_used) {
return res.status(400).json({
success: false,
message: '필수 필드가 누락되었습니다'
});
}
// 날짜 유효성 검증
const startDate = new Date(start_date);
const endDate = new Date(end_date);
if (endDate < startDate) {
return res.status(400).json({
success: false,
message: '종료일은 시작일보다 이후여야 합니다'
});
}
// 기간 중복 체크
vacationRequestModel.checkOverlap(worker_id, start_date, end_date, null, (err, results) => {
if (err) {
console.error('기간 중복 체크 오류:', err);
return res.status(500).json({
success: false,
message: '기간 중복 체크 중 오류가 발생했습니다'
});
}
if (results[0].count > 0) {
return res.status(400).json({
success: false,
message: '해당 기간에 이미 신청된 휴가가 있습니다'
});
}
// TODO: 잔여 연차 확인 로직 구현 필요
// 현재는 잔여 연차 확인 없이 신청 가능
// 휴가 신청 생성
const requestData = {
worker_id,
vacation_type_id,
start_date,
end_date,
days_used,
reason: reason || null,
status: 'pending',
requested_by
};
vacationRequestModel.create(requestData, (err, result) => {
if (err) {
console.error('휴가 신청 생성 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 신청 생성 중 오류가 발생했습니다'
});
}
res.status(201).json({
success: true,
message: '휴가 신청이 완료되었습니다',
data: {
request_id: result.insertId
}
});
});
});
} catch (error) {
console.error('휴가 신청 생성 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 휴가 신청 목록 조회
*/
async getAllRequests(req, res) {
try {
const filters = {
worker_id: req.query.worker_id,
status: req.query.status,
start_date: req.query.start_date,
end_date: req.query.end_date,
vacation_type_id: req.query.vacation_type_id
};
// 일반 사용자는 자신의 신청만 조회 가능
if (req.user.access_level !== 'system') {
if (req.user.worker_id) {
filters.worker_id = req.user.worker_id;
} else {
return res.status(403).json({
success: false,
message: '권한이 없습니다'
});
}
}
vacationRequestModel.getAll(filters, (err, results) => {
if (err) {
console.error('휴가 신청 목록 조회 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 신청 목록 조회 중 오류가 발생했습니다'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('휴가 신청 목록 조회 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 특정 휴가 신청 조회
*/
async getRequestById(req, res) {
try {
const { id } = req.params;
vacationRequestModel.getById(id, (err, results) => {
if (err) {
console.error('휴가 신청 조회 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 신청 조회 중 오류가 발생했습니다'
});
}
if (results.length === 0) {
return res.status(404).json({
success: false,
message: '해당 휴가 신청을 찾을 수 없습니다'
});
}
const request = results[0];
// 권한 검증: 관리자 또는 본인만 조회 가능
if (req.user.access_level !== 'system' && req.user.worker_id !== request.worker_id) {
return res.status(403).json({
success: false,
message: '권한이 없습니다'
});
}
res.json({
success: true,
data: request
});
});
} catch (error) {
console.error('휴가 신청 조회 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 휴가 신청 수정 (대기 중인 신청만)
*/
async updateRequest(req, res) {
try {
const { id } = req.params;
const { start_date, end_date, days_used, reason } = req.body;
// 기존 신청 조회
vacationRequestModel.getById(id, (err, results) => {
if (err) {
console.error('휴가 신청 조회 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 신청 조회 중 오류가 발생했습니다'
});
}
if (results.length === 0) {
return res.status(404).json({
success: false,
message: '해당 휴가 신청을 찾을 수 없습니다'
});
}
const existingRequest = results[0];
// 권한 검증
if (req.user.access_level !== 'system' && req.user.worker_id !== existingRequest.worker_id) {
return res.status(403).json({
success: false,
message: '권한이 없습니다'
});
}
// 대기 중인 신청만 수정 가능
if (existingRequest.status !== 'pending') {
return res.status(400).json({
success: false,
message: '승인/거부된 신청은 수정할 수 없습니다'
});
}
const updateData = {};
if (start_date) updateData.start_date = start_date;
if (end_date) updateData.end_date = end_date;
if (days_used) updateData.days_used = days_used;
if (reason !== undefined) updateData.reason = reason;
// 날짜가 변경된 경우 중복 체크
if (start_date || end_date) {
const newStartDate = start_date || existingRequest.start_date;
const newEndDate = end_date || existingRequest.end_date;
vacationRequestModel.checkOverlap(
existingRequest.worker_id,
newStartDate,
newEndDate,
id,
(err, overlapResults) => {
if (err) {
console.error('기간 중복 체크 오류:', err);
return res.status(500).json({
success: false,
message: '기간 중복 체크 중 오류가 발생했습니다'
});
}
if (overlapResults[0].count > 0) {
return res.status(400).json({
success: false,
message: '해당 기간에 이미 신청된 휴가가 있습니다'
});
}
// 수정 실행
vacationRequestModel.update(id, updateData, (err, result) => {
if (err) {
console.error('휴가 신청 수정 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 신청 수정 중 오류가 발생했습니다'
});
}
res.json({
success: true,
message: '휴가 신청이 수정되었습니다'
});
});
}
);
} else {
// 날짜 변경 없이 바로 수정
vacationRequestModel.update(id, updateData, (err, result) => {
if (err) {
console.error('휴가 신청 수정 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 신청 수정 중 오류가 발생했습니다'
});
}
res.json({
success: true,
message: '휴가 신청이 수정되었습니다'
});
});
}
});
} catch (error) {
console.error('휴가 신청 수정 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 휴가 신청 삭제 (대기 중인 신청만)
*/
async deleteRequest(req, res) {
try {
const { id } = req.params;
// 기존 신청 조회
vacationRequestModel.getById(id, (err, results) => {
if (err) {
console.error('휴가 신청 조회 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 신청 조회 중 오류가 발생했습니다'
});
}
if (results.length === 0) {
return res.status(404).json({
success: false,
message: '해당 휴가 신청을 찾을 수 없습니다'
});
}
const existingRequest = results[0];
// 권한 검증
if (req.user.access_level !== 'system' && req.user.worker_id !== existingRequest.worker_id) {
return res.status(403).json({
success: false,
message: '권한이 없습니다'
});
}
// 대기 중인 신청만 삭제 가능
if (existingRequest.status !== 'pending') {
return res.status(400).json({
success: false,
message: '승인/거부된 신청은 삭제할 수 없습니다'
});
}
vacationRequestModel.delete(id, (err, result) => {
if (err) {
console.error('휴가 신청 삭제 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 신청 삭제 중 오류가 발생했습니다'
});
}
res.json({
success: true,
message: '휴가 신청이 삭제되었습니다'
});
});
});
} catch (error) {
console.error('휴가 신청 삭제 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 휴가 신청 승인 (관리자만)
*/
async approveRequest(req, res) {
try {
const { id } = req.params;
const { review_note } = req.body;
const reviewed_by = req.user.user_id;
// 관리자 권한 확인
if (req.user.access_level !== 'system') {
return res.status(403).json({
success: false,
message: '관리자만 승인할 수 있습니다'
});
}
// 기존 신청 조회
vacationRequestModel.getById(id, (err, results) => {
if (err) {
console.error('휴가 신청 조회 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 신청 조회 중 오류가 발생했습니다'
});
}
if (results.length === 0) {
return res.status(404).json({
success: false,
message: '해당 휴가 신청을 찾을 수 없습니다'
});
}
const request = results[0];
if (request.status !== 'pending') {
return res.status(400).json({
success: false,
message: '이미 처리된 신청입니다'
});
}
// 상태 업데이트
const statusData = {
status: 'approved',
reviewed_by,
review_note
};
vacationRequestModel.updateStatus(id, statusData, (err, result) => {
if (err) {
console.error('휴가 승인 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 승인 중 오류가 발생했습니다'
});
}
// TODO: 잔여 연차에서 차감 로직 구현 필요
// 현재는 연차 차감 없이 승인만 처리
res.json({
success: true,
message: '휴가 신청이 승인되었습니다'
});
});
});
} catch (error) {
console.error('휴가 승인 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 휴가 신청 거부 (관리자만)
*/
async rejectRequest(req, res) {
try {
const { id } = req.params;
const { review_note } = req.body;
const reviewed_by = req.user.user_id;
// 관리자 권한 확인
if (req.user.access_level !== 'system') {
return res.status(403).json({
success: false,
message: '관리자만 거부할 수 있습니다'
});
}
// 기존 신청 조회
vacationRequestModel.getById(id, (err, results) => {
if (err) {
console.error('휴가 신청 조회 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 신청 조회 중 오류가 발생했습니다'
});
}
if (results.length === 0) {
return res.status(404).json({
success: false,
message: '해당 휴가 신청을 찾을 수 없습니다'
});
}
const request = results[0];
if (request.status !== 'pending') {
return res.status(400).json({
success: false,
message: '이미 처리된 신청입니다'
});
}
// 상태 업데이트
const statusData = {
status: 'rejected',
reviewed_by,
review_note
};
vacationRequestModel.updateStatus(id, statusData, (err, result) => {
if (err) {
console.error('휴가 거부 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 거부 중 오류가 발생했습니다'
});
}
res.json({
success: true,
message: '휴가 신청이 거부되었습니다'
});
});
});
} catch (error) {
console.error('휴가 거부 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 대기 중인 휴가 신청 목록 (관리자용)
*/
async getPendingRequests(req, res) {
try {
// 관리자 권한 확인
if (req.user.access_level !== 'system') {
return res.status(403).json({
success: false,
message: '관리자만 조회할 수 있습니다'
});
}
vacationRequestModel.getAllPending((err, results) => {
if (err) {
console.error('대기 중인 휴가 신청 조회 오류:', err);
return res.status(500).json({
success: false,
message: '대기 중인 휴가 신청 조회 중 오류가 발생했습니다'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('대기 중인 휴가 신청 조회 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
}
};
module.exports = vacationRequestController;

View File

@@ -0,0 +1,333 @@
/**
* vacationTypeController.js
* 휴가 유형 관련 컨트롤러
*/
const vacationTypeModel = require('../models/vacationTypeModel');
const vacationTypeController = {
/**
* 모든 활성 휴가 유형 조회
* GET /api/vacation-types
*/
async getAllTypes(req, res) {
try {
vacationTypeModel.getAll((err, results) => {
if (err) {
console.error('휴가 유형 조회 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 유형을 조회하는 중 오류가 발생했습니다'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('getAllTypes 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 시스템 기본 휴가 유형 조회
* GET /api/vacation-types/system
*/
async getSystemTypes(req, res) {
try {
vacationTypeModel.getSystemTypes((err, results) => {
if (err) {
console.error('시스템 휴가 유형 조회 오류:', err);
return res.status(500).json({
success: false,
message: '시스템 휴가 유형을 조회하는 중 오류가 발생했습니다'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('getSystemTypes 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 특별 휴가 유형 조회
* GET /api/vacation-types/special
*/
async getSpecialTypes(req, res) {
try {
vacationTypeModel.getSpecialTypes((err, results) => {
if (err) {
console.error('특별 휴가 유형 조회 오류:', err);
return res.status(500).json({
success: false,
message: '특별 휴가 유형을 조회하는 중 오류가 발생했습니다'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('getSpecialTypes 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 특별 휴가 유형 생성 (관리자만)
* POST /api/vacation-types
*/
async createType(req, res) {
try {
const {
type_code,
type_name,
deduct_days,
priority,
description
} = req.body;
// 필수 필드 검증
if (!type_code || !type_name || !deduct_days) {
return res.status(400).json({
success: false,
message: '필수 필드가 누락되었습니다 (type_code, type_name, deduct_days)'
});
}
// type_code 중복 체크
vacationTypeModel.getByCode(type_code, (err, existingTypes) => {
if (err) {
console.error('type_code 중복 체크 오류:', err);
return res.status(500).json({
success: false,
message: 'type_code 중복 체크 중 오류가 발생했습니다'
});
}
if (existingTypes && existingTypes.length > 0) {
return res.status(400).json({
success: false,
message: '이미 존재하는 type_code입니다'
});
}
// 특별 휴가 유형으로 생성
const typeData = {
type_code,
type_name,
deduct_days,
priority: priority || 50,
description: description || null,
is_special: true,
is_system: false,
is_active: true
};
vacationTypeModel.create(typeData, (err, result) => {
if (err) {
console.error('휴가 유형 생성 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 유형을 생성하는 중 오류가 발생했습니다'
});
}
res.status(201).json({
success: true,
message: '특별 휴가 유형이 생성되었습니다',
data: { id: result.insertId }
});
});
});
} catch (error) {
console.error('createType 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 휴가 유형 수정 (관리자만)
* PUT /api/vacation-types/:id
*/
async updateType(req, res) {
try {
const { id } = req.params;
const {
type_name,
deduct_days,
priority,
description,
is_active
} = req.body;
// 먼저 해당 유형 조회
vacationTypeModel.getById(id, (err, types) => {
if (err) {
console.error('휴가 유형 조회 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 유형을 조회하는 중 오류가 발생했습니다'
});
}
if (!types || types.length === 0) {
return res.status(404).json({
success: false,
message: '휴가 유형을 찾을 수 없습니다'
});
}
const type = types[0];
// 시스템 기본 휴가의 경우 제한적으로만 수정 가능
const updateData = {};
if (type.is_system) {
// 시스템 휴가는 priority와 description만 수정 가능
if (priority !== undefined) updateData.priority = priority;
if (description !== undefined) updateData.description = description;
} else {
// 특별 휴가는 모든 필드 수정 가능
if (type_name) updateData.type_name = type_name;
if (deduct_days !== undefined) updateData.deduct_days = deduct_days;
if (priority !== undefined) updateData.priority = priority;
if (description !== undefined) updateData.description = description;
if (is_active !== undefined) updateData.is_active = is_active;
}
if (Object.keys(updateData).length === 0) {
return res.status(400).json({
success: false,
message: '수정할 데이터가 없습니다'
});
}
updateData.updated_at = new Date();
vacationTypeModel.update(id, updateData, (err, result) => {
if (err) {
console.error('휴가 유형 수정 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 유형을 수정하는 중 오류가 발생했습니다'
});
}
res.json({
success: true,
message: '휴가 유형이 수정되었습니다'
});
});
});
} catch (error) {
console.error('updateType 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 특별 휴가 유형 삭제 (관리자만, 시스템 기본 휴가는 삭제 불가)
* DELETE /api/vacation-types/:id
*/
async deleteType(req, res) {
try {
const { id } = req.params;
vacationTypeModel.delete(id, (err, result) => {
if (err) {
console.error('휴가 유형 삭제 오류:', err);
return res.status(500).json({
success: false,
message: '휴가 유형을 삭제하는 중 오류가 발생했습니다'
});
}
if (result.affectedRows === 0) {
return res.status(400).json({
success: false,
message: '삭제할 수 없습니다. 시스템 기본 휴가이거나 존재하지 않는 휴가 유형입니다'
});
}
res.json({
success: true,
message: '휴가 유형이 삭제되었습니다'
});
});
} catch (error) {
console.error('deleteType 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
},
/**
* 휴가 유형 우선순위 일괄 업데이트 (관리자만)
* PUT /api/vacation-types/priorities
*/
async updatePriorities(req, res) {
try {
const { priorities } = req.body;
// priorities = [{ id: 1, priority: 10 }, { id: 2, priority: 20 }, ...]
if (!priorities || !Array.isArray(priorities)) {
return res.status(400).json({
success: false,
message: 'priorities 배열이 필요합니다'
});
}
vacationTypeModel.updatePriorities(priorities, (err, result) => {
if (err) {
console.error('우선순위 업데이트 오류:', err);
return res.status(500).json({
success: false,
message: '우선순위를 업데이트하는 중 오류가 발생했습니다'
});
}
res.json({
success: true,
message: '우선순위가 업데이트되었습니다',
data: { updated: result.affectedRows }
});
});
} catch (error) {
console.error('updatePriorities 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다'
});
}
}
};
module.exports = vacationTypeController;

View File

@@ -0,0 +1,555 @@
const visitRequestModel = require('../models/visitRequestModel');
// ==================== 출입 신청 관리 ====================
/**
* 출입 신청 생성
*/
exports.createVisitRequest = (req, res) => {
const requester_id = req.user.user_id;
const requestData = {
requester_id,
...req.body
};
// 필수 필드 검증
const requiredFields = ['visitor_company', 'category_id', 'workplace_id', 'visit_date', 'visit_time', 'purpose_id'];
for (const field of requiredFields) {
if (!requestData[field]) {
return res.status(400).json({
success: false,
message: `${field}는 필수 입력 항목입니다.`
});
}
}
visitRequestModel.createVisitRequest(requestData, (err, requestId) => {
if (err) {
console.error('출입 신청 생성 오류:', err);
return res.status(500).json({
success: false,
message: '출입 신청 생성 중 오류가 발생했습니다.',
error: err.message
});
}
res.status(201).json({
success: true,
message: '출입 신청이 성공적으로 생성되었습니다.',
data: { request_id: requestId }
});
});
};
/**
* 출입 신청 목록 조회
*/
exports.getAllVisitRequests = (req, res) => {
const filters = {
status: req.query.status,
visit_date: req.query.visit_date,
start_date: req.query.start_date,
end_date: req.query.end_date,
requester_id: req.query.requester_id,
category_id: req.query.category_id
};
visitRequestModel.getAllVisitRequests(filters, (err, requests) => {
if (err) {
console.error('출입 신청 목록 조회 오류:', err);
return res.status(500).json({
success: false,
message: '출입 신청 목록 조회 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
data: requests
});
});
};
/**
* 출입 신청 상세 조회
*/
exports.getVisitRequestById = (req, res) => {
const requestId = req.params.id;
visitRequestModel.getVisitRequestById(requestId, (err, request) => {
if (err) {
console.error('출입 신청 조회 오류:', err);
return res.status(500).json({
success: false,
message: '출입 신청 조회 중 오류가 발생했습니다.',
error: err.message
});
}
if (!request) {
return res.status(404).json({
success: false,
message: '출입 신청을 찾을 수 없습니다.'
});
}
res.json({
success: true,
data: request
});
});
};
/**
* 출입 신청 수정
*/
exports.updateVisitRequest = (req, res) => {
const requestId = req.params.id;
const requestData = req.body;
visitRequestModel.updateVisitRequest(requestId, requestData, (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: '출입 신청이 수정되었습니다.'
});
});
};
/**
* 출입 신청 삭제
*/
exports.deleteVisitRequest = (req, res) => {
const requestId = req.params.id;
visitRequestModel.deleteVisitRequest(requestId, (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: '출입 신청이 삭제되었습니다.'
});
});
};
/**
* 출입 신청 승인
*/
exports.approveVisitRequest = (req, res) => {
const requestId = req.params.id;
const approvedBy = req.user.user_id;
visitRequestModel.approveVisitRequest(requestId, approvedBy, (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: '출입 신청이 승인되었습니다.'
});
});
};
/**
* 출입 신청 반려
*/
exports.rejectVisitRequest = (req, res) => {
const requestId = req.params.id;
const approvedBy = req.user.user_id;
const rejectionReason = req.body.rejection_reason || '사유 없음';
const rejectionData = {
approved_by: approvedBy,
rejection_reason: rejectionReason
};
visitRequestModel.rejectVisitRequest(requestId, rejectionData, (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: '출입 신청이 반려되었습니다.'
});
});
};
// ==================== 방문 목적 관리 ====================
/**
* 모든 방문 목적 조회
*/
exports.getAllVisitPurposes = (req, res) => {
visitRequestModel.getAllVisitPurposes((err, purposes) => {
if (err) {
console.error('방문 목적 조회 오류:', err);
return res.status(500).json({
success: false,
message: '방문 목적 조회 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
data: purposes
});
});
};
/**
* 활성 방문 목적만 조회
*/
exports.getActiveVisitPurposes = (req, res) => {
visitRequestModel.getActiveVisitPurposes((err, purposes) => {
if (err) {
console.error('활성 방문 목적 조회 오류:', err);
return res.status(500).json({
success: false,
message: '활성 방문 목적 조회 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
data: purposes
});
});
};
/**
* 방문 목적 추가
*/
exports.createVisitPurpose = (req, res) => {
const purposeData = req.body;
if (!purposeData.purpose_name) {
return res.status(400).json({
success: false,
message: 'purpose_name은 필수 입력 항목입니다.'
});
}
visitRequestModel.createVisitPurpose(purposeData, (err, purposeId) => {
if (err) {
console.error('방문 목적 추가 오류:', err);
return res.status(500).json({
success: false,
message: '방문 목적 추가 중 오류가 발생했습니다.',
error: err.message
});
}
res.status(201).json({
success: true,
message: '방문 목적이 추가되었습니다.',
data: { purpose_id: purposeId }
});
});
};
/**
* 방문 목적 수정
*/
exports.updateVisitPurpose = (req, res) => {
const purposeId = req.params.id;
const purposeData = req.body;
visitRequestModel.updateVisitPurpose(purposeId, purposeData, (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: '방문 목적이 수정되었습니다.'
});
});
};
/**
* 방문 목적 삭제
*/
exports.deleteVisitPurpose = (req, res) => {
const purposeId = req.params.id;
visitRequestModel.deleteVisitPurpose(purposeId, (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: '방문 목적이 삭제되었습니다.'
});
});
};
// ==================== 안전교육 기록 관리 ====================
/**
* 안전교육 기록 생성
*/
exports.createTrainingRecord = (req, res) => {
const trainerId = req.user.user_id;
const trainingData = {
trainer_id: trainerId,
...req.body
};
// 필수 필드 검증
const requiredFields = ['request_id', 'training_date', 'training_start_time'];
for (const field of requiredFields) {
if (!trainingData[field]) {
return res.status(400).json({
success: false,
message: `${field}는 필수 입력 항목입니다.`
});
}
}
visitRequestModel.createTrainingRecord(trainingData, (err, trainingId) => {
if (err) {
console.error('안전교육 기록 생성 오류:', err);
return res.status(500).json({
success: false,
message: '안전교육 기록 생성 중 오류가 발생했습니다.',
error: err.message
});
}
// 안전교육 기록이 생성되면 출입 신청 상태를 training_completed로 변경
console.log(`[교육 완료] request_id=${trainingData.request_id} 상태를 training_completed로 변경 중...`);
visitRequestModel.updateVisitRequestStatus(trainingData.request_id, 'training_completed', (statusErr) => {
if (statusErr) {
console.error('출입 신청 상태 업데이트 오류:', statusErr);
// 에러가 발생해도 교육 기록은 생성되었으므로 성공 응답
} else {
console.log(`[교육 완료] request_id=${trainingData.request_id} 상태 변경 성공`);
}
res.status(201).json({
success: true,
message: '안전교육 기록이 생성되었습니다.',
data: { training_id: trainingId }
});
});
});
};
/**
* 특정 출입 신청의 안전교육 기록 조회
*/
exports.getTrainingRecordByRequestId = (req, res) => {
const requestId = req.params.requestId;
visitRequestModel.getTrainingRecordByRequestId(requestId, (err, record) => {
if (err) {
console.error('안전교육 기록 조회 오류:', err);
return res.status(500).json({
success: false,
message: '안전교육 기록 조회 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
data: record || null
});
});
};
/**
* 안전교육 기록 수정
*/
exports.updateTrainingRecord = (req, res) => {
const trainingId = req.params.id;
const trainingData = req.body;
visitRequestModel.updateTrainingRecord(trainingId, trainingData, (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: '안전교육 기록이 수정되었습니다.'
});
});
};
/**
* 안전교육 완료 (서명 포함)
*/
exports.completeTraining = (req, res) => {
const trainingId = req.params.id;
const signatureData = req.body.signature_data;
if (!signatureData) {
return res.status(400).json({
success: false,
message: '서명 데이터가 필요합니다.'
});
}
visitRequestModel.completeTraining(trainingId, signatureData, (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: '안전교육 기록을 찾을 수 없습니다.'
});
}
// 교육 완료 후 출입 신청 상태를 'training_completed'로 변경
visitRequestModel.getTrainingRecordByRequestId(trainingId, (err, record) => {
if (err || !record) {
return res.json({
success: true,
message: '안전교육이 완료되었습니다.'
});
}
visitRequestModel.updateVisitRequestStatus(record.request_id, 'training_completed', (err) => {
if (err) {
console.error('출입 신청 상태 업데이트 오류:', err);
}
res.json({
success: true,
message: '안전교육이 완료되었습니다.'
});
});
});
});
};
/**
* 안전교육 기록 목록 조회
*/
exports.getTrainingRecords = (req, res) => {
const filters = {
training_date: req.query.training_date,
start_date: req.query.start_date,
end_date: req.query.end_date,
trainer_id: req.query.trainer_id
};
visitRequestModel.getTrainingRecords(filters, (err, records) => {
if (err) {
console.error('안전교육 기록 목록 조회 오류:', err);
return res.status(500).json({
success: false,
message: '안전교육 기록 목록 조회 중 오류가 발생했습니다.',
error: err.message
});
}
res.json({
success: true,
data: records
});
});
};

View File

@@ -0,0 +1,490 @@
/**
* 작업 분석 컨트롤러
*
* 작업 보고서 다차원 분석 API 엔드포인트 핸들러
*
* @author TK-FB-Project
* @since 2025-12-11
*/
const WorkAnalysis = require('../models/WorkAnalysis');
const { getDb } = require('../dbPool');
const { ValidationError, DatabaseError } = require('../utils/errors');
const { asyncHandler } = require('../middlewares/errorHandler');
const logger = require('../utils/logger');
/**
* 날짜 유효성 검사 헬퍼 함수
*/
const validateDateRange = (startDate, endDate) => {
if (!startDate || !endDate) {
throw new ValidationError('시작일과 종료일을 입력해주세요', {
required: ['start', 'end'],
received: { start: startDate, end: endDate }
});
}
const start = new Date(startDate);
const end = new Date(endDate);
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
throw new ValidationError('올바른 날짜 형식을 입력해주세요', {
format: 'YYYY-MM-DD',
received: { start: startDate, end: endDate }
});
}
if (start > end) {
throw new ValidationError('시작일이 종료일보다 늦을 수 없습니다', {
start: startDate,
end: endDate
});
}
// 너무 긴 기간 방지 (1년 제한)
const diffTime = Math.abs(end - start);
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays > 365) {
throw new ValidationError('조회 기간은 1년을 초과할 수 없습니다', {
days: diffDays,
max: 365
});
}
return { start, end };
};
/**
* 기본 통계 조회
*/
const getStats = asyncHandler(async (req, res) => {
const { start, end } = req.query;
validateDateRange(start, end);
logger.info('기본 통계 조회 요청', { start, end });
try {
const db = await getDb();
const workAnalysis = new WorkAnalysis(db);
const stats = await workAnalysis.getBasicStats(start, end);
logger.info('기본 통계 조회 성공', { start, end });
res.json({
success: true,
data: stats,
message: '기본 통계 조회 완료'
});
} catch (error) {
logger.error('기본 통계 조회 실패', { start, end, error: error.message });
throw new DatabaseError('기본 통계 조회 중 오류가 발생했습니다');
}
});
/**
* 일별 작업시간 추이 조회
*/
const getDailyTrend = asyncHandler(async (req, res) => {
const { start, end } = req.query;
validateDateRange(start, end);
logger.info('일별 추이 조회 요청', { start, end });
try {
const db = await getDb();
const workAnalysis = new WorkAnalysis(db);
const trendData = await workAnalysis.getDailyTrend(start, end);
logger.info('일별 추이 조회 성공', { start, end, dataPoints: trendData.length });
res.json({
success: true,
data: trendData,
message: '일별 추이 조회 완료'
});
} catch (error) {
logger.error('일별 추이 조회 실패', { start, end, error: error.message });
throw new DatabaseError('일별 추이 조회 중 오류가 발생했습니다');
}
});
/**
* 작업자별 통계 조회
*/
const getWorkerStats = asyncHandler(async (req, res) => {
const { start, end } = req.query;
validateDateRange(start, end);
logger.info('작업자별 통계 조회 요청', { start, end });
try {
const db = await getDb();
const workAnalysis = new WorkAnalysis(db);
const workerStats = await workAnalysis.getWorkerStats(start, end);
logger.info('작업자별 통계 조회 성공', {
start,
end,
workerCount: workerStats.length
});
res.json({
success: true,
data: workerStats,
message: '작업자별 통계 조회 완료'
});
} catch (error) {
logger.error('작업자별 통계 조회 실패', { start, end, error: error.message });
throw new DatabaseError('작업자별 통계 조회 중 오류가 발생했습니다');
}
});
/**
* 프로젝트별 통계 조회
*/
const getProjectStats = asyncHandler(async (req, res) => {
const { start, end } = req.query;
validateDateRange(start, end);
logger.info('프로젝트별 통계 조회 요청', { start, end });
try {
const db = await getDb();
const workAnalysis = new WorkAnalysis(db);
const projectStats = await workAnalysis.getProjectStats(start, end);
logger.info('프로젝트별 통계 조회 성공', {
start,
end,
projectCount: projectStats.length
});
res.json({
success: true,
data: projectStats,
message: '프로젝트별 통계 조회 완료'
});
} catch (error) {
logger.error('프로젝트별 통계 조회 실패', { start, end, error: error.message });
throw new DatabaseError('프로젝트별 통계 조회 중 오류가 발생했습니다');
}
});
/**
* 작업유형별 통계 조회
*/
const getWorkTypeStats = asyncHandler(async (req, res) => {
const { start, end } = req.query;
validateDateRange(start, end);
logger.info('작업유형별 통계 조회 요청', { start, end });
try {
const db = await getDb();
const workAnalysis = new WorkAnalysis(db);
const workTypeStats = await workAnalysis.getWorkTypeStats(start, end);
logger.info('작업유형별 통계 조회 성공', {
start,
end,
workTypeCount: workTypeStats.length
});
res.json({
success: true,
data: workTypeStats,
message: '작업유형별 통계 조회 완료'
});
} catch (error) {
logger.error('작업유형별 통계 조회 실패', { start, end, error: error.message });
throw new DatabaseError('작업유형별 통계 조회 중 오류가 발생했습니다');
}
});
/**
* 최근 작업 현황 조회
*/
const getRecentWork = asyncHandler(async (req, res) => {
const { start, end, limit = 10 } = req.query;
validateDateRange(start, end);
// limit 유효성 검사 (최대 5000까지 허용)
const limitNum = parseInt(limit);
if (isNaN(limitNum) || limitNum < 1 || limitNum > 5000) {
throw new ValidationError('limit은 1~5000 사이의 숫자여야 합니다', {
received: limit,
min: 1,
max: 5000
});
}
logger.info('최근 작업 현황 조회 요청', { start, end, limit: limitNum });
try {
const db = await getDb();
const workAnalysis = new WorkAnalysis(db);
const recentWork = await workAnalysis.getRecentWork(start, end, limitNum);
logger.info('최근 작업 현황 조회 성공', {
start,
end,
limit: limitNum,
resultCount: recentWork.length
});
res.json({
success: true,
data: recentWork,
message: '최근 작업 현황 조회 완료'
});
} catch (error) {
logger.error('최근 작업 현황 조회 실패', {
start,
end,
limit: limitNum,
error: error.message
});
throw new DatabaseError('최근 작업 현황 조회 중 오류가 발생했습니다');
}
});
/**
* 요일별 패턴 분석 조회
*/
const getWeekdayPattern = asyncHandler(async (req, res) => {
const { start, end } = req.query;
validateDateRange(start, end);
logger.info('요일별 패턴 분석 요청', { start, end });
try {
const db = await getDb();
const workAnalysis = new WorkAnalysis(db);
const weekdayPattern = await workAnalysis.getWeekdayPattern(start, end);
logger.info('요일별 패턴 분석 성공', { start, end });
res.json({
success: true,
data: weekdayPattern,
message: '요일별 패턴 분석 완료'
});
} catch (error) {
logger.error('요일별 패턴 분석 실패', { start, end, error: error.message });
throw new DatabaseError('요일별 패턴 분석 중 오류가 발생했습니다');
}
});
/**
* 에러 분석 조회
*/
const getErrorAnalysis = asyncHandler(async (req, res) => {
const { start, end } = req.query;
validateDateRange(start, end);
logger.info('에러 분석 요청', { start, end });
try {
const db = await getDb();
const workAnalysis = new WorkAnalysis(db);
const errorAnalysis = await workAnalysis.getErrorAnalysis(start, end);
logger.info('에러 분석 성공', { start, end });
res.json({
success: true,
data: errorAnalysis,
message: '에러 분석 완료'
});
} catch (error) {
logger.error('에러 분석 실패', { start, end, error: error.message });
throw new DatabaseError('에러 분석 중 오류가 발생했습니다');
}
});
/**
* 월별 비교 분석 조회
*/
const getMonthlyComparison = asyncHandler(async (req, res) => {
const { year = new Date().getFullYear() } = req.query;
const yearNum = parseInt(year);
if (isNaN(yearNum) || yearNum < 2000 || yearNum > 2050) {
throw new ValidationError('올바른 연도를 입력해주세요', {
received: year,
min: 2000,
max: 2050
});
}
logger.info('월별 비교 분석 요청', { year: yearNum });
try {
const db = await getDb();
const workAnalysis = new WorkAnalysis(db);
const monthlyData = await workAnalysis.getMonthlyComparison(yearNum);
logger.info('월별 비교 분석 성공', { year: yearNum });
res.json({
success: true,
data: monthlyData,
message: '월별 비교 분석 완료'
});
} catch (error) {
logger.error('월별 비교 분석 실패', { year: yearNum, error: error.message });
throw new DatabaseError('월별 비교 분석 중 오류가 발생했습니다');
}
});
/**
* 작업자별 전문분야 분석 조회
*/
const getWorkerSpecialization = asyncHandler(async (req, res) => {
const { start, end } = req.query;
validateDateRange(start, end);
logger.info('작업자별 전문분야 분석 요청', { start, end });
try {
const db = await getDb();
const workAnalysis = new WorkAnalysis(db);
const specializationData = await workAnalysis.getWorkerSpecialization(start, end);
// 작업자별로 그룹화하여 정리
const groupedData = specializationData.reduce((acc, item) => {
if (!acc[item.worker_id]) {
acc[item.worker_id] = [];
}
acc[item.worker_id].push({
work_type_id: item.work_type_id,
project_id: item.project_id,
totalHours: item.totalHours,
totalReports: item.totalReports,
percentage: item.percentage
});
return acc;
}, {});
logger.info('작업자별 전문분야 분석 성공', {
start,
end,
workerCount: Object.keys(groupedData).length
});
res.json({
success: true,
data: groupedData,
message: '작업자별 전문분야 분석 완료'
});
} catch (error) {
logger.error('작업자별 전문분야 분석 실패', { start, end, error: error.message });
throw new DatabaseError('작업자별 전문분야 분석 중 오류가 발생했습니다');
}
});
/**
* 대시보드용 종합 데이터 조회
*/
const getDashboardData = asyncHandler(async (req, res) => {
const { start, end } = req.query;
validateDateRange(start, end);
logger.info('대시보드 데이터 조회 요청', { start, end });
try {
const db = await getDb();
const workAnalysis = new WorkAnalysis(db);
// 병렬로 여러 데이터 조회
const [
stats,
dailyTrend,
workerStats,
projectStats,
workTypeStats,
recentWork
] = await Promise.all([
workAnalysis.getBasicStats(start, end),
workAnalysis.getDailyTrend(start, end),
workAnalysis.getWorkerStats(start, end),
workAnalysis.getProjectStats(start, end),
workAnalysis.getWorkTypeStats(start, end),
workAnalysis.getRecentWork(start, end, 10)
]);
logger.info('대시보드 데이터 조회 성공', { start, end });
res.json({
success: true,
data: {
stats,
dailyTrend,
workerStats,
projectStats,
workTypeStats,
recentWork
},
message: '대시보드 데이터 조회 완료'
});
} catch (error) {
logger.error('대시보드 데이터 조회 실패', { start, end, error: error.message });
throw new DatabaseError('대시보드 데이터 조회 중 오류가 발생했습니다');
}
});
const workAnalysisService = require('../services/workAnalysisService');
/**
* 프로젝트별-작업별 시간 분석 (총시간, 정규시간, 에러시간)
*/
const getProjectWorkTypeAnalysis = asyncHandler(async (req, res) => {
const { start, end } = req.query;
validateDateRange(start, end);
logger.info('프로젝트별-작업별 시간 분석 요청', { start, end });
try {
const result = await workAnalysisService.getProjectWorkTypeAnalysis(start, end);
logger.info('프로젝트별-작업별 시간 분석 성공', {
start,
end,
projectCount: result.summary.total_projects,
workTypeCount: result.summary.total_work_types,
totalHours: result.summary.grand_total_hours
});
res.json({
success: true,
data: result,
message: '프로젝트별-작업별 시간 분석 완료'
});
} catch (error) {
logger.error('프로젝트별-작업별 시간 분석 실패', {
start,
end,
error: error.message
});
// Service throws DatabaseError wrapper or Error
if (error.name === 'DatabaseError') {
throw error;
}
throw new DatabaseError('프로젝트별-작업별 시간 분석 중 오류가 발생했습니다');
}
});
module.exports = {
getStats,
getDailyTrend,
getWorkerStats,
getProjectStats,
getWorkTypeStats,
getRecentWork,
getWeekdayPattern,
getErrorAnalysis,
getMonthlyComparison,
getWorkerSpecialization,
getDashboardData,
getProjectWorkTypeAnalysis
};

View File

@@ -0,0 +1,674 @@
/**
* 작업 중 문제 신고 컨트롤러
*/
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,
custom_item_name, // 직접 입력한 항목명
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: '위치 정보는 필수입니다.' });
}
// 항목 검증 (기존 항목 또는 직접 입력)
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,
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: finalItemId || 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 });
});
};

View File

@@ -0,0 +1,429 @@
/**
* 데일리 워크 레포트 분석 컨트롤러
*
* 작업 보고서 종합 분석 API 엔드포인트 핸들러
*
* @author TK-FB-Project
* @since 2025-12-11
*/
const { getDb } = require('../dbPool');
const { ValidationError, DatabaseError } = require('../utils/errors');
const { asyncHandler } = require('../middlewares/errorHandler');
const logger = require('../utils/logger');
/**
* 분석용 필터 데이터 조회 (프로젝트, 작업자, 작업유형 목록)
*/
const getAnalysisFilters = asyncHandler(async (req, res) => {
logger.info('분석 필터 데이터 조회 요청');
const db = await getDb();
try {
// 프로젝트 목록
const [projects] = await db.query(`
SELECT DISTINCT p.project_id, p.project_name
FROM projects p
INNER JOIN daily_work_reports dwr ON p.project_id = dwr.project_id
ORDER BY p.project_name
`);
// 작업자 목록
const [workers] = await db.query(`
SELECT DISTINCT w.worker_id, w.worker_name
FROM workers w
INNER JOIN daily_work_reports dwr ON w.worker_id = dwr.worker_id
ORDER BY w.worker_name
`);
// 작업 유형 목록
const [workTypes] = await db.query(`
SELECT DISTINCT wt.id as work_type_id, wt.name as work_type_name
FROM work_types wt
INNER JOIN daily_work_reports dwr ON wt.id = dwr.work_type_id
ORDER BY wt.name
`);
// 날짜 범위
const [dateRange] = await db.query(`
SELECT
MIN(report_date) as min_date,
MAX(report_date) as max_date
FROM daily_work_reports
`);
logger.info('분석 필터 데이터 조회 성공', {
projects: projects.length,
workers: workers.length,
workTypes: workTypes.length
});
res.json({
success: true,
data: {
projects,
workers,
workTypes,
dateRange: dateRange[0]
},
message: '분석 필터 데이터 조회 성공'
});
} catch (error) {
logger.error('분석 필터 데이터 조회 실패', { error: error.message });
throw new DatabaseError('필터 데이터 조회 중 오류가 발생했습니다');
}
});
/**
* 기간별 작업 분석 데이터 조회
*/
const getAnalyticsByPeriod = asyncHandler(async (req, res) => {
const { start_date, end_date, project_id, worker_id } = req.query;
if (!start_date || !end_date) {
throw new ValidationError('start_date와 end_date가 필요합니다', {
required: ['start_date', 'end_date'],
received: { start_date, end_date },
example: 'start_date=2025-08-01&end_date=2025-08-31'
});
}
logger.info('기간별 분석 데이터 조회 요청', {
start_date,
end_date,
project_id,
worker_id
});
const db = await getDb();
try {
// 기본 조건
let whereConditions = ['dwr.report_date BETWEEN ? AND ?'];
let queryParams = [start_date, end_date];
if (project_id) {
whereConditions.push('dwr.project_id = ?');
queryParams.push(project_id);
}
if (worker_id) {
whereConditions.push('dwr.worker_id = ?');
queryParams.push(worker_id);
}
const whereClause = whereConditions.join(' AND ');
// 1. 전체 요약 통계
const overallSql = `
SELECT
COUNT(*) as total_entries,
SUM(dwr.work_hours) as total_hours,
COUNT(DISTINCT dwr.worker_id) as unique_workers,
COUNT(DISTINCT dwr.project_id) as unique_projects,
COUNT(DISTINCT dwr.report_date) as working_days,
AVG(dwr.work_hours) as avg_hours_per_entry,
COUNT(DISTINCT dwr.created_by) as contributors,
COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) as error_entries,
ROUND((COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) / COUNT(*)) * 100, 2) as error_rate
FROM daily_work_reports dwr
WHERE ${whereClause}
`;
const [overallStats] = await db.query(overallSql, queryParams);
// 2. 일별 통계
const dailyStatsSql = `
SELECT
dwr.report_date,
SUM(dwr.work_hours) as daily_hours,
COUNT(*) as daily_entries,
COUNT(DISTINCT dwr.worker_id) as daily_workers
FROM daily_work_reports dwr
WHERE ${whereClause}
GROUP BY dwr.report_date
ORDER BY dwr.report_date ASC
`;
const [dailyStats] = await db.query(dailyStatsSql, queryParams);
// 3. 일별 에러 통계
const dailyErrorStatsSql = `
SELECT
dwr.report_date,
COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) as daily_errors,
COUNT(*) as daily_total,
ROUND((COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) / COUNT(*)) * 100, 2) as daily_error_rate
FROM daily_work_reports dwr
WHERE ${whereClause}
GROUP BY dwr.report_date
ORDER BY dwr.report_date ASC
`;
const [dailyErrorStats] = await db.query(dailyErrorStatsSql, queryParams);
// 4. 에러 유형별 분석
const errorAnalysisSql = `
SELECT
et.id as error_type_id,
et.name as error_type_name,
COUNT(*) as error_count,
SUM(dwr.work_hours) as error_hours,
ROUND((COUNT(*) / (SELECT COUNT(*) FROM daily_work_reports WHERE error_type_id IS NOT NULL)) * 100, 2) as error_percentage
FROM daily_work_reports dwr
LEFT JOIN error_types et ON dwr.error_type_id = et.id
WHERE ${whereClause} AND dwr.error_type_id IS NOT NULL
GROUP BY et.id, et.name
ORDER BY error_count DESC
`;
const [errorAnalysis] = await db.query(errorAnalysisSql, queryParams);
// 5. 작업 유형별 분석
const workTypeAnalysisSql = `
SELECT
wt.id as work_type_id,
wt.name as work_type_name,
COUNT(*) as work_count,
SUM(dwr.work_hours) as total_hours,
AVG(dwr.work_hours) as avg_hours,
COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) as error_count,
ROUND((COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) / COUNT(*)) * 100, 2) as error_rate
FROM daily_work_reports dwr
LEFT JOIN work_types wt ON dwr.work_type_id = wt.id
WHERE ${whereClause}
GROUP BY wt.id, wt.name
ORDER BY total_hours DESC
`;
const [workTypeAnalysis] = await db.query(workTypeAnalysisSql, queryParams);
// 6. 작업자별 성과 분석
const workerAnalysisSql = `
SELECT
w.worker_id,
w.worker_name,
COUNT(*) as total_entries,
SUM(dwr.work_hours) as total_hours,
AVG(dwr.work_hours) as avg_hours_per_entry,
COUNT(DISTINCT dwr.project_id) as projects_worked,
COUNT(DISTINCT dwr.report_date) as working_days,
COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) as error_count,
ROUND((COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) / COUNT(*)) * 100, 2) as error_rate
FROM daily_work_reports dwr
LEFT JOIN workers w ON dwr.worker_id = w.worker_id
WHERE ${whereClause}
GROUP BY w.worker_id, w.worker_name
ORDER BY total_hours DESC
`;
const [workerAnalysis] = await db.query(workerAnalysisSql, queryParams);
// 7. 프로젝트별 분석
const projectAnalysisSql = `
SELECT
p.project_id,
p.project_name,
COUNT(*) as total_entries,
SUM(dwr.work_hours) as total_hours,
COUNT(DISTINCT dwr.worker_id) as workers_count,
COUNT(DISTINCT dwr.report_date) as working_days,
AVG(dwr.work_hours) as avg_hours_per_entry,
COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) as error_count,
ROUND((COUNT(CASE WHEN dwr.error_type_id IS NOT NULL THEN 1 END) / COUNT(*)) * 100, 2) as error_rate
FROM daily_work_reports dwr
LEFT JOIN projects p ON dwr.project_id = p.project_id
WHERE ${whereClause}
GROUP BY p.project_id, p.project_name
ORDER BY total_hours DESC
`;
const [projectAnalysis] = await db.query(projectAnalysisSql, queryParams);
logger.info('기간별 분석 데이터 조회 성공', {
start_date,
end_date,
total_entries: overallStats[0].total_entries,
total_hours: overallStats[0].total_hours
});
res.json({
success: true,
data: {
summary: overallStats[0],
dailyStats,
dailyErrorStats,
errorAnalysis,
workTypeAnalysis,
workerAnalysis,
projectAnalysis,
period: { start_date, end_date },
filters: { project_id, worker_id }
},
message: '기간별 분석 데이터 조회 성공'
});
} catch (error) {
logger.error('기간별 분석 데이터 조회 실패', {
start_date,
end_date,
error: error.message
});
throw new DatabaseError('기간별 분석 데이터 조회 중 오류가 발생했습니다');
}
});
/**
* 프로젝트별 상세 분석
*/
const getProjectAnalysis = asyncHandler(async (req, res) => {
const { start_date, end_date, project_id } = req.query;
if (!start_date || !end_date) {
throw new ValidationError('start_date와 end_date가 필요합니다', {
required: ['start_date', 'end_date'],
received: { start_date, end_date }
});
}
logger.info('프로젝트별 분석 조회 요청', {
start_date,
end_date,
project_id
});
const db = await getDb();
try {
let whereConditions = ['dwr.report_date BETWEEN ? AND ?'];
let queryParams = [start_date, end_date];
if (project_id) {
whereConditions.push('dwr.project_id = ?');
queryParams.push(project_id);
}
const whereClause = whereConditions.join(' AND ');
const projectStatsSql = `
SELECT
dwr.project_id,
p.project_name,
SUM(dwr.work_hours) as total_hours,
COUNT(*) as total_entries,
COUNT(DISTINCT dwr.worker_id) as workers_count,
COUNT(DISTINCT dwr.report_date) as working_days,
AVG(dwr.work_hours) as avg_hours_per_entry
FROM daily_work_reports dwr
LEFT JOIN projects p ON dwr.project_id = p.project_id
WHERE ${whereClause}
GROUP BY dwr.project_id
ORDER BY total_hours DESC
`;
const [projectStats] = await db.query(projectStatsSql, queryParams);
logger.info('프로젝트별 분석 조회 성공', {
start_date,
end_date,
projectCount: projectStats.length
});
res.json({
success: true,
data: {
projectStats,
period: { start_date, end_date }
},
message: '프로젝트별 분석 조회 성공'
});
} catch (error) {
logger.error('프로젝트별 분석 조회 실패', {
start_date,
end_date,
error: error.message
});
throw new DatabaseError('프로젝트별 분석 데이터 조회 중 오류가 발생했습니다');
}
});
/**
* 작업자별 상세 분석
*/
const getWorkerAnalysis = asyncHandler(async (req, res) => {
const { start_date, end_date, worker_id } = req.query;
if (!start_date || !end_date) {
throw new ValidationError('start_date와 end_date가 필요합니다', {
required: ['start_date', 'end_date'],
received: { start_date, end_date }
});
}
logger.info('작업자별 분석 조회 요청', {
start_date,
end_date,
worker_id
});
const db = await getDb();
try {
let whereConditions = ['dwr.report_date BETWEEN ? AND ?'];
let queryParams = [start_date, end_date];
if (worker_id) {
whereConditions.push('dwr.worker_id = ?');
queryParams.push(worker_id);
}
const whereClause = whereConditions.join(' AND ');
const workerStatsSql = `
SELECT
dwr.worker_id,
w.worker_name,
SUM(dwr.work_hours) as total_hours,
COUNT(*) as total_entries,
COUNT(DISTINCT dwr.project_id) as projects_worked,
COUNT(DISTINCT dwr.report_date) as working_days,
AVG(dwr.work_hours) as avg_hours_per_entry
FROM daily_work_reports dwr
LEFT JOIN workers w ON dwr.worker_id = w.worker_id
WHERE ${whereClause}
GROUP BY dwr.worker_id
ORDER BY total_hours DESC
`;
const [workerStats] = await db.query(workerStatsSql, queryParams);
logger.info('작업자별 분석 조회 성공', {
start_date,
end_date,
workerCount: workerStats.length
});
res.json({
success: true,
data: {
workerStats,
period: { start_date, end_date }
},
message: '작업자별 분석 조회 성공'
});
} catch (error) {
logger.error('작업자별 분석 조회 실패', {
start_date,
end_date,
error: error.message
});
throw new DatabaseError('작업자별 분석 데이터 조회 중 오류가 발생했습니다');
}
});
module.exports = {
getAnalysisFilters,
getAnalyticsByPeriod,
getProjectAnalysis,
getWorkerAnalysis
};

View File

@@ -0,0 +1,175 @@
/**
* 작업 보고서 관리 컨트롤러
*
* 작업 보고서 CRUD API 엔드포인트 핸들러
*
* @author TK-FB-Project
* @since 2025-12-11
*/
const workReportService = require('../services/workReportService');
const { asyncHandler } = require('../middlewares/errorHandler');
/**
* 작업 보고서 생성 (단일 또는 다중)
*/
exports.createWorkReport = asyncHandler(async (req, res) => {
const result = await workReportService.createWorkReportService(req.body);
res.json({
success: true,
data: result,
message: '작업 보고서가 성공적으로 생성되었습니다'
});
});
/**
* 날짜별 작업 보고서 조회
*/
exports.getWorkReportsByDate = asyncHandler(async (req, res) => {
const { date } = req.params;
const rows = await workReportService.getWorkReportsByDateService(date);
res.json({
success: true,
data: rows,
message: '작업 보고서 조회 성공'
});
});
/**
* 기간별 작업 보고서 조회
*/
exports.getWorkReportsInRange = asyncHandler(async (req, res) => {
const { start, end } = req.query;
const rows = await workReportService.getWorkReportsInRangeService(start, end);
res.json({
success: true,
data: rows,
message: '작업 보고서 조회 성공'
});
});
/**
* 단일 작업 보고서 조회
*/
exports.getWorkReportById = asyncHandler(async (req, res) => {
const { id } = req.params;
const row = await workReportService.getWorkReportByIdService(id);
res.json({
success: true,
data: row,
message: '작업 보고서 조회 성공'
});
});
/**
* 작업 보고서 수정
*/
exports.updateWorkReport = asyncHandler(async (req, res) => {
const { id } = req.params;
const result = await workReportService.updateWorkReportService(id, req.body);
res.json({
success: true,
data: result,
message: '작업 보고서가 성공적으로 수정되었습니다'
});
});
/**
* 작업 보고서 삭제
*/
exports.removeWorkReport = asyncHandler(async (req, res) => {
const { id } = req.params;
const result = await workReportService.removeWorkReportService(id);
res.json({
success: true,
data: result,
message: '작업 보고서가 성공적으로 삭제되었습니다'
});
});
/**
* 월간 요약 조회
*/
exports.getSummary = asyncHandler(async (req, res) => {
const { year, month } = req.query;
const rows = await workReportService.getSummaryService(year, month);
res.json({
success: true,
data: rows,
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: '부적합 원인이 삭제되었습니다'
});
});

View File

@@ -0,0 +1,278 @@
/**
* 작업자 관리 컨트롤러
*
* 작업자 CRUD API 엔드포인트 핸들러
*
* @author TK-FB-Project
* @since 2025-12-11
*/
const workerModel = require('../models/workerModel');
const { ValidationError, NotFoundError, DatabaseError } = require('../utils/errors');
const { asyncHandler } = require('../middlewares/errorHandler');
const logger = require('../utils/logger');
const cache = require('../utils/cache');
const { optimizedQueries } = require('../utils/queryOptimizer');
const { hangulToRoman, generateUniqueUsername } = require('../utils/hangulToRoman');
const bcrypt = require('bcrypt');
const { getDb } = require('../dbPool');
/**
* 작업자 생성
*/
exports.createWorker = asyncHandler(async (req, res) => {
const workerData = req.body;
const createAccount = req.body.create_account;
logger.info('작업자 생성 요청', { name: workerData.worker_name, create_account: createAccount });
const lastID = await workerModel.create(workerData);
// 계정 생성 요청이 있으면 users 테이블에 계정 생성
if (createAccount && workerData.worker_name) {
try {
const db = await getDb();
const username = await generateUniqueUsername(workerData.worker_name, db);
const hashedPassword = await bcrypt.hash('1234', 10);
// User 역할 조회
const [userRole] = await db.query('SELECT id FROM roles WHERE name = ?', ['User']);
if (userRole && userRole.length > 0) {
await db.query(
`INSERT INTO users (username, password, name, worker_id, role_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, NOW(), NOW())`,
[username, hashedPassword, workerData.worker_name, lastID, userRole[0].id]
);
logger.info('작업자 계정 자동 생성 성공', { worker_id: lastID, username });
}
} catch (accountError) {
logger.error('계정 생성 실패 (작업자는 생성됨)', { worker_id: lastID, error: accountError.message });
}
}
// 작업자 관련 캐시 무효화
await cache.invalidateCache.worker();
logger.info('작업자 생성 성공', { worker_id: lastID });
res.status(201).json({
success: true,
data: { worker_id: lastID },
message: '작업자가 성공적으로 생성되었습니다'
});
});
/**
* 전체 작업자 조회 (캐싱 및 페이지네이션 적용)
*/
exports.getAllWorkers = asyncHandler(async (req, res) => {
const { page = 1, limit = 100, search = '', status = '', department_id = null } = req.query;
const cacheKey = cache.createKey('workers', 'list', page, limit, search, status, department_id);
// 캐시에서 조회
const cachedData = await cache.get(cacheKey);
if (cachedData) {
logger.debug('캐시 히트', { cacheKey });
return res.json({
success: true,
data: cachedData.data,
pagination: cachedData.pagination,
message: '작업자 목록 조회 성공 (캐시)'
});
}
// 최적화된 쿼리 사용
const result = await optimizedQueries.getWorkersPaged(page, limit, search, status, department_id);
// 캐시에 저장 (5분)
await cache.set(cacheKey, result, cache.TTL.MEDIUM);
logger.debug('캐시 저장', { cacheKey });
res.json({
success: true,
data: result.data,
pagination: result.pagination,
message: '작업자 목록 조회 성공'
});
});
/**
* 단일 작업자 조회
*/
exports.getWorkerById = asyncHandler(async (req, res) => {
const id = parseInt(req.params.worker_id, 10);
if (isNaN(id)) {
throw new ValidationError('유효하지 않은 작업자 ID입니다');
}
const row = await workerModel.getById(id);
if (!row) {
throw new NotFoundError('작업자를 찾을 수 없습니다');
}
res.json({
success: true,
data: row,
message: '작업자 조회 성공'
});
});
/**
* 작업자 수정
*/
exports.updateWorker = asyncHandler(async (req, res) => {
const id = parseInt(req.params.worker_id, 10);
if (isNaN(id)) {
throw new ValidationError('유효하지 않은 작업자 ID입니다');
}
const workerData = { ...req.body, worker_id: id };
const createAccount = req.body.create_account;
console.log('🔧 작업자 수정 요청:', {
worker_id: id,
받은데이터: req.body,
처리할데이터: workerData,
create_account: createAccount
});
// 먼저 현재 작업자 정보 조회 (계정 여부 확인용)
const currentWorker = await workerModel.getById(id);
if (!currentWorker) {
throw new NotFoundError('작업자를 찾을 수 없습니다');
}
// 작업자 정보 업데이트
const changes = await workerModel.update(workerData);
// 계정 생성/해제 처리
const db = await getDb();
const hasAccount = currentWorker.user_id !== null && currentWorker.user_id !== undefined;
let accountAction = null;
let accountUsername = null;
console.log('🔍 계정 생성 체크:', {
createAccount,
hasAccount,
currentWorker_user_id: currentWorker.user_id,
worker_name: workerData.worker_name
});
if (createAccount && !hasAccount && workerData.worker_name) {
// 계정 생성
console.log('✅ 계정 생성 로직 시작');
try {
console.log('🔑 사용자명 생성 중...');
const username = await generateUniqueUsername(workerData.worker_name, db);
console.log('🔑 생성된 사용자명:', username);
const hashedPassword = await bcrypt.hash('1234', 10);
console.log('🔒 비밀번호 해싱 완료');
// User 역할 조회
console.log('👤 User 역할 조회 중...');
const [userRole] = await db.query('SELECT id FROM roles WHERE name = ?', ['User']);
console.log('👤 User 역할 조회 결과:', userRole);
if (userRole && userRole.length > 0) {
console.log('💾 계정 DB 삽입 시작...');
await db.query(
`INSERT INTO users (username, password, name, worker_id, role_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, NOW(), NOW())`,
[username, hashedPassword, workerData.worker_name, id, userRole[0].id]
);
console.log('✅ 계정 DB 삽입 완료');
accountAction = 'created';
accountUsername = username;
logger.info('작업자 계정 생성 성공', { worker_id: id, username });
} else {
console.log('❌ User 역할을 찾을 수 없음');
}
} catch (accountError) {
console.error('❌ 계정 생성 오류:', accountError);
logger.error('계정 생성 실패', { worker_id: id, error: accountError.message });
accountAction = 'failed';
}
} else {
console.log('⏭️ 계정 생성 조건 불만족:', { createAccount, hasAccount, hasWorkerName: !!workerData.worker_name });
}
if (!createAccount && hasAccount) {
// 계정 연동 해제 (users.worker_id = NULL)
try {
await db.query('UPDATE users SET worker_id = NULL WHERE worker_id = ?', [id]);
accountAction = 'unlinked';
logger.info('작업자 계정 연동 해제 성공', { worker_id: id });
} catch (unlinkError) {
logger.error('계정 연동 해제 실패', { worker_id: id, error: unlinkError.message });
accountAction = 'unlink_failed';
}
} else if (createAccount && hasAccount) {
accountAction = 'already_exists';
}
// 작업자 관련 캐시 무효화
logger.info('작업자 수정 후 캐시 무효화', { worker_id: id });
await cache.invalidateCache.worker();
logger.info('작업자 수정 성공', { worker_id: id });
// 응답 메시지 구성
let message = '작업자 정보가 성공적으로 수정되었습니다';
if (accountAction === 'created') {
message += ` (계정 생성 완료: ${accountUsername}, 초기 비밀번호: 1234)`;
} else if (accountAction === 'unlinked') {
message += ' (계정 연동 해제 완료)';
} else if (accountAction === 'already_exists') {
message += ' (이미 계정이 존재합니다)';
} else if (accountAction === 'failed') {
message += ' (계정 생성 실패)';
}
res.json({
success: true,
data: {
changes,
account_action: accountAction,
account_username: accountUsername
},
message
});
});
/**
* 작업자 삭제
*/
exports.removeWorker = asyncHandler(async (req, res) => {
const id = parseInt(req.params.worker_id, 10);
if (isNaN(id)) {
throw new ValidationError('유효하지 않은 작업자 ID입니다');
}
const changes = await workerModel.remove(id);
if (changes === 0) {
throw new NotFoundError('작업자를 찾을 수 없습니다');
}
// 작업자 관련 캐시 무효화
logger.info('작업자 삭제 후 캐시 무효화 시작', { worker_id: id });
await cache.invalidateCache.worker();
await cache.delPattern('workers:*');
await cache.flush();
logger.info('작업자 삭제 후 캐시 무효화 완료', { worker_id: id });
res.json({
success: true,
message: '작업자가 성공적으로 삭제되었습니다'
});
});

View File

@@ -0,0 +1,575 @@
/**
* 작업장 관리 컨트롤러
*
* 작업장 카테고리(공장) 및 작업장 CRUD API 엔드포인트 핸들러
*
* @author TK-FB-Project
* @since 2026-01-26
*/
const workplaceModel = require('../models/workplaceModel');
const { ValidationError, NotFoundError, DatabaseError } = require('../utils/errors');
const { asyncHandler } = require('../middlewares/errorHandler');
const logger = require('../utils/logger');
// ==================== 카테고리(공장) 관련 ====================
/**
* 카테고리 생성
*/
exports.createCategory = asyncHandler(async (req, res) => {
const categoryData = req.body;
if (!categoryData.category_name) {
throw new ValidationError('카테고리명은 필수 입력 항목입니다');
}
logger.info('카테고리 생성 요청', { name: categoryData.category_name });
const id = await new Promise((resolve, reject) => {
workplaceModel.createCategory(categoryData, (err, lastID) => {
if (err) reject(new DatabaseError('카테고리 생성 중 오류가 발생했습니다'));
else resolve(lastID);
});
});
logger.info('카테고리 생성 성공', { category_id: id });
res.status(201).json({
success: true,
data: { category_id: id },
message: '카테고리가 성공적으로 생성되었습니다'
});
});
/**
* 전체 카테고리 조회
*/
exports.getAllCategories = asyncHandler(async (req, res) => {
const rows = await new Promise((resolve, reject) => {
workplaceModel.getAllCategories((err, data) => {
if (err) reject(new DatabaseError('카테고리 목록 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
res.json({
success: true,
data: rows,
message: '카테고리 목록 조회 성공'
});
});
/**
* 활성 카테고리만 조회
*/
exports.getActiveCategories = asyncHandler(async (req, res) => {
const rows = await new Promise((resolve, reject) => {
workplaceModel.getActiveCategories((err, data) => {
if (err) reject(new DatabaseError('활성 카테고리 목록 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
res.json({
success: true,
data: rows,
message: '활성 카테고리 목록 조회 성공'
});
});
/**
* 단일 카테고리 조회
*/
exports.getCategoryById = asyncHandler(async (req, res) => {
const categoryId = req.params.id;
const category = await new Promise((resolve, reject) => {
workplaceModel.getCategoryById(categoryId, (err, data) => {
if (err) reject(new DatabaseError('카테고리 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
if (!category) {
throw new NotFoundError('카테고리를 찾을 수 없습니다');
}
res.json({
success: true,
data: category,
message: '카테고리 조회 성공'
});
});
/**
* 카테고리 수정
*/
exports.updateCategory = asyncHandler(async (req, res) => {
const categoryId = req.params.id;
const categoryData = req.body;
if (!categoryData.category_name) {
throw new ValidationError('카테고리명은 필수 입력 항목입니다');
}
logger.info('카테고리 수정 요청', { category_id: categoryId });
// 기존 카테고리 정보 가져오기
const existingCategory = await new Promise((resolve, reject) => {
workplaceModel.getCategoryById(categoryId, (err, data) => {
if (err) reject(new DatabaseError('카테고리 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
if (!existingCategory) {
throw new NotFoundError('카테고리를 찾을 수 없습니다');
}
// layout_image가 요청에 없거나 null이면 기존 값 보존
const updateData = {
...categoryData,
layout_image: (categoryData.layout_image !== undefined && categoryData.layout_image !== null)
? categoryData.layout_image
: existingCategory.layout_image
};
await new Promise((resolve, reject) => {
workplaceModel.updateCategory(categoryId, updateData, (err, result) => {
if (err) reject(new DatabaseError('카테고리 수정 중 오류가 발생했습니다'));
else resolve(result);
});
});
logger.info('카테고리 수정 성공', { category_id: categoryId });
res.json({
success: true,
message: '카테고리가 성공적으로 수정되었습니다'
});
});
/**
* 카테고리 삭제
*/
exports.deleteCategory = asyncHandler(async (req, res) => {
const categoryId = req.params.id;
logger.info('카테고리 삭제 요청', { category_id: categoryId });
await new Promise((resolve, reject) => {
workplaceModel.deleteCategory(categoryId, (err, result) => {
if (err) reject(new DatabaseError('카테고리 삭제 중 오류가 발생했습니다'));
else resolve(result);
});
});
logger.info('카테고리 삭제 성공', { category_id: categoryId });
res.json({
success: true,
message: '카테고리가 성공적으로 삭제되었습니다'
});
});
// ==================== 작업장 관련 ====================
/**
* 작업장 생성
*/
exports.createWorkplace = asyncHandler(async (req, res) => {
const workplaceData = req.body;
if (!workplaceData.workplace_name) {
throw new ValidationError('작업장명은 필수 입력 항목입니다');
}
logger.info('작업장 생성 요청', { name: workplaceData.workplace_name });
const id = await new Promise((resolve, reject) => {
workplaceModel.createWorkplace(workplaceData, (err, lastID) => {
if (err) reject(new DatabaseError('작업장 생성 중 오류가 발생했습니다'));
else resolve(lastID);
});
});
logger.info('작업장 생성 성공', { workplace_id: id });
res.status(201).json({
success: true,
data: { workplace_id: id },
message: '작업장이 성공적으로 생성되었습니다'
});
});
/**
* 전체 작업장 조회
*/
exports.getAllWorkplaces = asyncHandler(async (req, res) => {
const categoryId = req.query.category_id;
// 카테고리별 필터링
if (categoryId) {
const rows = await new Promise((resolve, reject) => {
workplaceModel.getWorkplacesByCategory(categoryId, (err, data) => {
if (err) reject(new DatabaseError('작업장 목록 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
return res.json({
success: true,
data: rows,
message: '작업장 목록 조회 성공'
});
}
// 전체 조회
const rows = await new Promise((resolve, reject) => {
workplaceModel.getAllWorkplaces((err, data) => {
if (err) reject(new DatabaseError('작업장 목록 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
res.json({
success: true,
data: rows,
message: '작업장 목록 조회 성공'
});
});
/**
* 활성 작업장만 조회
*/
exports.getActiveWorkplaces = asyncHandler(async (req, res) => {
const rows = await new Promise((resolve, reject) => {
workplaceModel.getActiveWorkplaces((err, data) => {
if (err) reject(new DatabaseError('활성 작업장 목록 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
res.json({
success: true,
data: rows,
message: '활성 작업장 목록 조회 성공'
});
});
/**
* 단일 작업장 조회
*/
exports.getWorkplaceById = asyncHandler(async (req, res) => {
const workplaceId = req.params.id;
const workplace = await new Promise((resolve, reject) => {
workplaceModel.getWorkplaceById(workplaceId, (err, data) => {
if (err) reject(new DatabaseError('작업장 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
if (!workplace) {
throw new NotFoundError('작업장을 찾을 수 없습니다');
}
res.json({
success: true,
data: workplace,
message: '작업장 조회 성공'
});
});
/**
* 작업장 수정
*/
exports.updateWorkplace = asyncHandler(async (req, res) => {
const workplaceId = req.params.id;
const workplaceData = req.body;
if (!workplaceData.workplace_name) {
throw new ValidationError('작업장명은 필수 입력 항목입니다');
}
logger.info('작업장 수정 요청', { workplace_id: workplaceId });
// 기존 작업장 정보 가져오기
const existingWorkplace = await new Promise((resolve, reject) => {
workplaceModel.getWorkplaceById(workplaceId, (err, data) => {
if (err) reject(new DatabaseError('작업장 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
if (!existingWorkplace) {
throw new NotFoundError('작업장을 찾을 수 없습니다');
}
// layout_image가 요청에 없거나 null이면 기존 값 보존
const updateData = {
...workplaceData,
layout_image: (workplaceData.layout_image !== undefined && workplaceData.layout_image !== null)
? workplaceData.layout_image
: existingWorkplace.layout_image
};
await new Promise((resolve, reject) => {
workplaceModel.updateWorkplace(workplaceId, updateData, (err, result) => {
if (err) reject(new DatabaseError('작업장 수정 중 오류가 발생했습니다'));
else resolve(result);
});
});
logger.info('작업장 수정 성공', { workplace_id: workplaceId });
res.json({
success: true,
message: '작업장이 성공적으로 수정되었습니다'
});
});
/**
* 작업장 삭제
*/
exports.deleteWorkplace = asyncHandler(async (req, res) => {
const workplaceId = req.params.id;
logger.info('작업장 삭제 요청', { workplace_id: workplaceId });
await new Promise((resolve, reject) => {
workplaceModel.deleteWorkplace(workplaceId, (err, result) => {
if (err) reject(new DatabaseError('작업장 삭제 중 오류가 발생했습니다'));
else resolve(result);
});
});
logger.info('작업장 삭제 성공', { workplace_id: workplaceId });
res.json({
success: true,
message: '작업장이 성공적으로 삭제되었습니다'
});
});
// ==================== 작업장 지도 영역 관련 ====================
/**
* 카테고리 레이아웃 이미지 업로드
*/
exports.uploadCategoryLayoutImage = asyncHandler(async (req, res) => {
const categoryId = req.params.id;
if (!req.file) {
throw new ValidationError('이미지 파일이 필요합니다');
}
const imagePath = `/uploads/${req.file.filename}`;
logger.info('카테고리 레이아웃 이미지 업로드 요청', { category_id: categoryId, path: imagePath });
// 현재 카테고리 정보 가져오기
const category = await new Promise((resolve, reject) => {
workplaceModel.getCategoryById(categoryId, (err, data) => {
if (err) reject(new DatabaseError('카테고리 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
if (!category) {
throw new NotFoundError('카테고리를 찾을 수 없습니다');
}
// 카테고리 정보 업데이트 (이미지 경로만 변경)
const updatedData = {
category_name: category.category_name,
description: category.description,
display_order: category.display_order,
is_active: category.is_active,
layout_image: imagePath
};
await new Promise((resolve, reject) => {
workplaceModel.updateCategory(categoryId, updatedData, (err, result) => {
if (err) reject(new DatabaseError('이미지 경로 저장 중 오류가 발생했습니다'));
else resolve(result);
});
});
logger.info('레이아웃 이미지 업로드 성공', { category_id: categoryId });
res.json({
success: true,
data: { image_path: imagePath },
message: '레이아웃 이미지가 성공적으로 업로드되었습니다'
});
});
/**
* 작업장 레이아웃 이미지 업로드
*/
exports.uploadWorkplaceLayoutImage = asyncHandler(async (req, res) => {
const workplaceId = req.params.id;
if (!req.file) {
throw new ValidationError('이미지 파일이 필요합니다');
}
const imagePath = `/uploads/${req.file.filename}`;
logger.info('작업장 레이아웃 이미지 업로드 요청', { workplace_id: workplaceId, path: imagePath });
// 현재 작업장 정보 가져오기
const workplace = await new Promise((resolve, reject) => {
workplaceModel.getWorkplaceById(workplaceId, (err, data) => {
if (err) reject(new DatabaseError('작업장 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
if (!workplace) {
throw new NotFoundError('작업장을 찾을 수 없습니다');
}
// 작업장 정보 업데이트 (이미지 경로만 변경)
const updatedData = {
workplace_name: workplace.workplace_name,
category_id: workplace.category_id,
description: workplace.description,
workplace_purpose: workplace.workplace_purpose,
display_priority: workplace.display_priority,
is_active: workplace.is_active,
layout_image: imagePath
};
await new Promise((resolve, reject) => {
workplaceModel.updateWorkplace(workplaceId, updatedData, (err, result) => {
if (err) reject(new DatabaseError('이미지 경로 저장 중 오류가 발생했습니다'));
else resolve(result);
});
});
logger.info('작업장 레이아웃 이미지 업로드 성공', { workplace_id: workplaceId });
res.json({
success: true,
data: { image_path: imagePath },
message: '작업장 레이아웃 이미지가 성공적으로 업로드되었습니다'
});
});
/**
* 지도 영역 생성
*/
exports.createMapRegion = asyncHandler(async (req, res) => {
const regionData = req.body;
if (!regionData.workplace_id || !regionData.category_id) {
throw new ValidationError('작업장 ID와 카테고리 ID는 필수 입력 항목입니다');
}
logger.info('지도 영역 생성 요청', { workplace_id: regionData.workplace_id });
const id = await new Promise((resolve, reject) => {
workplaceModel.createMapRegion(regionData, (err, lastID) => {
if (err) reject(new DatabaseError('지도 영역 생성 중 오류가 발생했습니다'));
else resolve(lastID);
});
});
logger.info('지도 영역 생성 성공', { region_id: id });
res.status(201).json({
success: true,
data: { region_id: id },
message: '지도 영역이 성공적으로 생성되었습니다'
});
});
/**
* 카테고리별 지도 영역 조회 (작업장 정보 포함)
*/
exports.getMapRegionsByCategory = asyncHandler(async (req, res) => {
const categoryId = req.params.categoryId;
const rows = await new Promise((resolve, reject) => {
workplaceModel.getMapRegionsByCategory(categoryId, (err, data) => {
if (err) reject(new DatabaseError('지도 영역 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
res.json({
success: true,
data: rows,
message: '지도 영역 조회 성공'
});
});
/**
* 작업장별 지도 영역 조회
*/
exports.getMapRegionByWorkplace = asyncHandler(async (req, res) => {
const workplaceId = req.params.workplaceId;
const region = await new Promise((resolve, reject) => {
workplaceModel.getMapRegionByWorkplace(workplaceId, (err, data) => {
if (err) reject(new DatabaseError('지도 영역 조회 중 오류가 발생했습니다'));
else resolve(data);
});
});
res.json({
success: true,
data: region,
message: '지도 영역 조회 성공'
});
});
/**
* 지도 영역 수정
*/
exports.updateMapRegion = asyncHandler(async (req, res) => {
const regionId = req.params.id;
const regionData = req.body;
logger.info('지도 영역 수정 요청', { region_id: regionId });
await new Promise((resolve, reject) => {
workplaceModel.updateMapRegion(regionId, regionData, (err, result) => {
if (err) reject(new DatabaseError('지도 영역 수정 중 오류가 발생했습니다'));
else resolve(result);
});
});
logger.info('지도 영역 수정 성공', { region_id: regionId });
res.json({
success: true,
message: '지도 영역이 성공적으로 수정되었습니다'
});
});
/**
* 지도 영역 삭제
*/
exports.deleteMapRegion = asyncHandler(async (req, res) => {
const regionId = req.params.id;
logger.info('지도 영역 삭제 요청', { region_id: regionId });
await new Promise((resolve, reject) => {
workplaceModel.deleteMapRegion(regionId, (err, result) => {
if (err) reject(new DatabaseError('지도 영역 삭제 중 오류가 발생했습니다'));
else resolve(result);
});
});
logger.info('지도 영역 삭제 성공', { region_id: regionId });
res.json({
success: true,
message: '지도 영역이 성공적으로 삭제되었습니다'
});
});

View File

@@ -0,0 +1,193 @@
// 근태 관리 테이블 생성 스크립트
const mysql = require('mysql2/promise');
async function createAttendanceTables() {
let connection;
try {
// 로컬 MySQL 연결 (기본 설정)
connection = await mysql.createConnection({
host: 'localhost',
user: 'root',
password: '', // 비밀번호가 있다면 여기에 입력
database: 'hyungi'
});
console.log('✅ MySQL 연결 성공');
// 1. 근로 유형 테이블 생성
console.log('📋 근로 유형 테이블 생성 중...');
await connection.execute(`
CREATE TABLE IF NOT EXISTS work_attendance_types (
id INT PRIMARY KEY AUTO_INCREMENT,
type_code VARCHAR(20) NOT NULL UNIQUE COMMENT '근로 유형 코드',
type_name VARCHAR(50) NOT NULL COMMENT '근로 유형명',
description TEXT COMMENT '설명',
is_active BOOLEAN DEFAULT TRUE COMMENT '활성 상태',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) COMMENT='근로 유형 관리 테이블'
`);
// 2. 휴가 유형 테이블 생성
console.log('🏖️ 휴가 유형 테이블 생성 중...');
await connection.execute(`
CREATE TABLE IF NOT EXISTS vacation_types (
id INT PRIMARY KEY AUTO_INCREMENT,
type_code VARCHAR(20) NOT NULL UNIQUE COMMENT '휴가 유형 코드',
type_name VARCHAR(50) NOT NULL COMMENT '휴가 유형명',
hours_deduction DECIMAL(4,2) NOT NULL COMMENT '차감 시간',
description TEXT COMMENT '설명',
is_active BOOLEAN DEFAULT TRUE COMMENT '활성 상태',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) COMMENT='휴가 유형 관리 테이블'
`);
// 3. 일일 근태 기록 테이블 생성
console.log('📊 일일 근태 기록 테이블 생성 중...');
await connection.execute(`
CREATE TABLE IF NOT EXISTS daily_attendance_records (
id INT PRIMARY KEY AUTO_INCREMENT,
record_date DATE NOT NULL COMMENT '기록 날짜',
worker_id INT NOT NULL COMMENT '작업자 ID',
total_work_hours DECIMAL(4,2) DEFAULT 0 COMMENT '총 작업 시간',
attendance_type_id INT COMMENT '근로 유형 ID',
vacation_type_id INT NULL COMMENT '휴가 유형 ID',
is_vacation_processed BOOLEAN DEFAULT FALSE COMMENT '휴가 처리 여부',
overtime_approved BOOLEAN DEFAULT FALSE COMMENT '초과근무 승인 여부',
overtime_approved_by INT NULL COMMENT '초과근무 승인자 ID',
overtime_approved_at TIMESTAMP NULL COMMENT '초과근무 승인 시간',
status ENUM('incomplete', 'partial', 'complete', 'overtime', 'vacation', 'error') DEFAULT 'incomplete' COMMENT '상태',
notes TEXT COMMENT '비고',
created_by INT NOT NULL DEFAULT 1 COMMENT '생성자 ID',
updated_by INT NULL COMMENT '수정자 ID',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY unique_worker_date (worker_id, record_date),
INDEX idx_record_date (record_date),
INDEX idx_worker_date (worker_id, record_date),
INDEX idx_status (status)
) COMMENT='일일 근태 기록 테이블'
`);
// 4. 작업자 휴가 잔여 관리 테이블 생성
console.log('📅 휴가 잔여 관리 테이블 생성 중...');
await connection.execute(`
CREATE TABLE IF NOT EXISTS worker_vacation_balance (
id INT PRIMARY KEY AUTO_INCREMENT,
worker_id INT NOT NULL COMMENT '작업자 ID',
year YEAR NOT NULL COMMENT '연도',
total_annual_leave DECIMAL(4,2) DEFAULT 15.0 COMMENT '연간 총 연차 (일)',
used_annual_leave DECIMAL(4,2) DEFAULT 0 COMMENT '사용한 연차 (일)',
remaining_annual_leave DECIMAL(4,2) GENERATED ALWAYS AS (total_annual_leave - used_annual_leave) STORED COMMENT '잔여 연차 (일)',
notes TEXT COMMENT '비고',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY unique_worker_year (worker_id, year),
INDEX idx_worker_year (worker_id, year)
) COMMENT='작업자별 휴가 잔여 관리 테이블'
`);
// 5. 기본 데이터 삽입
console.log('📝 기본 데이터 삽입 중...');
// 근로 유형 기본 데이터
await connection.execute(`
INSERT IGNORE INTO work_attendance_types (type_code, type_name, description) VALUES
('REGULAR', '정시근로', '8시간 정규 근무'),
('OVERTIME', '연장근로', '8시간 초과 근무'),
('PARTIAL', '부분근로', '8시간 미만 근무'),
('VACATION', '휴가근로', '휴가와 함께하는 부분 근무')
`);
// 휴가 유형 기본 데이터
await connection.execute(`
INSERT IGNORE INTO vacation_types (type_code, type_name, hours_deduction, description) VALUES
('ANNUAL_FULL', '연차', 8.0, '하루 전체 연차'),
('ANNUAL_HALF', '반차', 4.0, '반일 연차'),
('ANNUAL_QUARTER', '반반차', 2.0, '1/4일 연차'),
('SICK_FULL', '병가', 8.0, '하루 전체 병가'),
('SICK_HALF', '반일병가', 4.0, '반일 병가'),
('PERSONAL_FULL', '개인사유', 8.0, '개인사유로 인한 휴가'),
('PERSONAL_HALF', '반일개인사유', 4.0, '반일 개인사유 휴가')
`);
// 6. 휴가 전용 작업 유형 추가
console.log('🏖️ 휴가 전용 작업 유형 추가 중...');
await connection.execute(`
INSERT IGNORE INTO work_types (name, description, is_active) VALUES
('휴가', '연차, 반차, 병가 등 휴가 처리용', TRUE)
`);
// 7. daily_work_reports 테이블에 근태 기록 연결 컬럼 추가 (이미 있으면 무시)
try {
await connection.execute(`
ALTER TABLE daily_work_reports
ADD COLUMN attendance_record_id INT NULL COMMENT '근태 기록 ID' AFTER updated_by
`);
console.log('✅ daily_work_reports 테이블에 attendance_record_id 컬럼 추가됨');
} catch (error) {
if (error.code !== 'ER_DUP_FIELDNAME') {
console.log('⚠️ attendance_record_id 컬럼 추가 실패:', error.message);
} else {
console.log('✅ attendance_record_id 컬럼이 이미 존재함');
}
}
// 8. 인덱스 추가
try {
await connection.execute(`CREATE INDEX idx_attendance_record ON daily_work_reports(attendance_record_id)`);
console.log('✅ attendance_record_id 인덱스 추가됨');
} catch (error) {
console.log('⚠️ 인덱스 추가 실패 (이미 존재할 수 있음):', error.message);
}
console.log('🎉 근태 관리 DB 설정 완료!');
console.log('');
console.log('📋 생성된 테이블:');
console.log(' - work_attendance_types (근로 유형)');
console.log(' - vacation_types (휴가 유형)');
console.log(' - daily_attendance_records (일일 근태 기록)');
console.log(' - worker_vacation_balance (휴가 잔여 관리)');
console.log('');
console.log('✅ 기본 데이터도 모두 삽입되었습니다.');
} catch (error) {
console.error('❌ DB 설정 중 오류 발생:', error);
// 다른 연결 정보로 시도
if (error.code === 'ECONNREFUSED' || error.code === 'ER_ACCESS_DENIED_ERROR') {
console.log('');
console.log('💡 다른 DB 연결 정보를 시도해보세요:');
console.log(' - host: localhost 또는 127.0.0.1');
console.log(' - port: 3306 (기본값)');
console.log(' - user: root 또는 다른 사용자');
console.log(' - password: 설정된 비밀번호');
console.log(' - database: hyungi');
}
throw error;
} finally {
if (connection) {
await connection.end();
}
}
}
// 직접 실행
if (require.main === module) {
createAttendanceTables()
.then(() => {
console.log('✅ 설정 완료');
process.exit(0);
})
.catch((error) => {
console.error('❌ 설정 실패:', error);
process.exit(1);
});
}
module.exports = { createAttendanceTables };

View File

@@ -0,0 +1,35 @@
require('dotenv').config();
const mysql = require('mysql2/promise');
const retry = require('async-retry');
// 초기화된 pool을 export 하기 위한 변수
let pool = null;
const initPool = async () => {
if (pool) return pool; // 이미 초기화된 경우 재사용
await retry(async () => {
pool = mysql.createPool({
host: process.env.DB_HOST,
port: process.env.DB_PORT || 3306,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
});
const conn = await pool.getConnection();
await conn.query('SET FOREIGN_KEY_CHECKS = 1');
console.log(`✅ MariaDB 연결 성공: ${process.env.DB_HOST}:${process.env.DB_PORT || 3306}/${process.env.DB_NAME}`);
conn.release();
}, {
retries: 10,
minTimeout: 3000
});
return pool;
};
module.exports = initPool;

View File

@@ -0,0 +1,17 @@
// db/connection.js - 레거시 콜백 방식 DB 래퍼
const { getDb } = require('../dbPool');
// 콜백 방식 쿼리 래퍼
const query = async (sql, params, callback) => {
try {
const db = await getDb();
const [results] = await db.query(sql, params);
callback(null, results);
} catch (error) {
callback(error);
}
};
module.exports = {
query
};

View File

@@ -0,0 +1,49 @@
const fs = require('fs');
const path = require('path');
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function(knex) {
const schemaSql = fs.readFileSync(path.join(__dirname, '../../hyungi_schema_v2.sql'), 'utf8');
return knex.raw(schemaSql);
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function(knex) {
// down 마이그레이션은 모든 테이블을 역순으로 삭제하도록 구현합니다.
const tables = [
'cutting_plans',
'daily_issue_reports',
'daily_work_reports',
'codes',
'code_types',
'factory_info',
'equipment_list',
'pipe_specs',
'tasks',
'worker_groups',
'workers',
'projects',
'password_change_logs',
'login_logs',
'users'
];
// 외래 키 제약 조건을 먼저 비활성화합니다.
return knex.raw('SET FOREIGN_KEY_CHECKS = 0;')
.then(() => {
// 각 테이블을 순회하며 drop table if exists를 실행합니다.
return tables.reduce((promise, tableName) => {
return promise.then(() => knex.schema.dropTableIfExists(tableName));
}, Promise.resolve());
})
.finally(() => {
// 외래 키 제약 조건을 다시 활성화합니다.
return knex.raw('SET FOREIGN_KEY_CHECKS = 1;');
});
};

View File

@@ -0,0 +1,23 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function(knex) {
return knex.schema.table('projects', function (table) {
table.boolean('is_active').defaultTo(true).after('pm');
table.string('project_status').defaultTo('active').after('is_active');
table.date('completed_date').nullable().after('project_status');
});
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function(knex) {
return knex.schema.table('projects', function (table) {
table.dropColumn('is_active');
table.dropColumn('project_status');
table.dropColumn('completed_date');
});
};

View File

@@ -0,0 +1,57 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function(knex) {
return knex.schema
// 1. roles 테이블 생성
.createTable('roles', function(table) {
table.increments('id').primary();
table.string('name', 50).notNullable().unique();
table.string('description', 255);
table.timestamps(true, true);
})
// 2. permissions 테이블 생성
.createTable('permissions', function(table) {
table.increments('id').primary();
table.string('name', 100).notNullable().unique(); // 예: 'user:create'
table.string('description', 255);
table.timestamps(true, true);
})
// 3. role_permissions (역할-권한) 조인 테이블 생성
.createTable('role_permissions', function(table) {
table.integer('role_id').unsigned().notNullable().references('id').inTable('roles').onDelete('CASCADE');
table.integer('permission_id').unsigned().notNullable().references('id').inTable('permissions').onDelete('CASCADE');
table.primary(['role_id', 'permission_id']);
})
// 4. users 테이블에 role_id 추가 및 기존 컬럼 삭제
.table('users', function(table) {
table.integer('role_id').unsigned().references('id').inTable('roles').onDelete('SET NULL').after('email');
// 기존 컬럼들은 삭제 또는 비활성화 (데이터 보존을 위해 일단 이름 변경)
table.renameColumn('role', '_role_old');
table.renameColumn('access_level', '_access_level_old');
})
// 5. user_permissions (사용자-개별 권한) 조인 테이블 생성
.createTable('user_permissions', function(table) {
table.integer('user_id').notNullable().references('user_id').inTable('users').onDelete('CASCADE');
table.integer('permission_id').unsigned().notNullable().references('id').inTable('permissions').onDelete('CASCADE');
table.primary(['user_id', 'permission_id']);
});
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function(knex) {
return knex.schema
.dropTableIfExists('user_permissions')
.dropTableIfExists('role_permissions')
.dropTableIfExists('permissions')
.dropTableIfExists('roles')
.table('users', function(table) {
table.dropColumn('role_id');
table.renameColumn('_role_old', 'role');
table.renameColumn('_access_level_old', 'access_level');
});
};

View File

@@ -0,0 +1,103 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = async function(knex) {
// 1. Roles 생성
await knex('roles').insert([
{ id: 1, name: 'System Admin', description: '시스템 전체 관리자. 모든 권한을 가짐.' },
{ id: 2, name: 'Admin', description: '관리자. 사용자 및 프로젝트 관리 등 대부분의 권한을 가짐.' },
{ id: 3, name: 'Leader', description: '그룹장. 팀원 작업 현황 조회 등 중간 관리자 권한.' },
{ id: 4, name: 'Worker', description: '일반 작업자. 자신의 작업 보고서 작성 및 조회 권한.' },
]);
// 2. Permissions 생성 (예시)
const permissions = [
// User
{ name: 'user:create', description: '사용자 생성' },
{ name: 'user:read', description: '사용자 정보 조회' },
{ name: 'user:update', description: '사용자 정보 수정' },
{ name: 'user:delete', description: '사용자 삭제' },
// Project
{ name: 'project:create', description: '프로젝트 생성' },
{ name: 'project:read', description: '프로젝트 조회' },
{ name: 'project:update', description: '프로젝트 수정' },
{ name: 'project:delete', description: '프로젝트 삭제' },
// Work Report
{ name: 'work-report:create', description: '작업 보고서 생성' },
{ name: 'work-report:read-own', description: '자신의 작업 보고서 조회' },
{ name: 'work-report:read-team', description: '팀의 작업 보고서 조회' },
{ name: 'work-report:read-all', description: '모든 작업 보고서 조회' },
{ name: 'work-report:update', description: '작업 보고서 수정' },
{ name: 'work-report:delete', description: '작업 보고서 삭제' },
// System
{ name: 'system:read-logs', description: '시스템 로그 조회' },
{ name: 'system:manage-settings', description: '시스템 설정 관리' },
];
await knex('permissions').insert(permissions);
// 3. Role-Permissions 매핑
const allPermissions = await knex('permissions').select('id', 'name');
const permissionMap = allPermissions.reduce((acc, p) => {
acc[p.name] = p.id;
return acc;
}, {});
const rolePermissions = {
// System Admin (모든 권한)
'System Admin': allPermissions.map(p => p.id),
// Admin
'Admin': [
permissionMap['user:create'], permissionMap['user:read'], permissionMap['user:update'], permissionMap['user:delete'],
permissionMap['project:create'], permissionMap['project:read'], permissionMap['project:update'], permissionMap['project:delete'],
permissionMap['work-report:read-all'], permissionMap['work-report:update'], permissionMap['work-report:delete'],
],
// Leader
'Leader': [
permissionMap['user:read'],
permissionMap['project:read'],
permissionMap['work-report:read-team'],
permissionMap['work-report:read-own'],
permissionMap['work-report:create'],
],
// Worker
'Worker': [
permissionMap['work-report:create'],
permissionMap['work-report:read-own'],
],
};
const rolePermissionInserts = [];
for (const roleName in rolePermissions) {
const roleId = (await knex('roles').where('name', roleName).first()).id;
rolePermissions[roleName].forEach(permissionId => {
rolePermissionInserts.push({ role_id: roleId, permission_id: permissionId });
});
}
await knex('role_permissions').insert(rolePermissionInserts);
// 4. 기존 사용자에게 역할 부여 (예: 기존 admin -> Admin, leader -> Leader, user -> Worker)
await knex.raw(`
UPDATE users SET role_id =
CASE
WHEN _role_old = 'system' THEN (SELECT id FROM roles WHERE name = 'System Admin')
WHEN _role_old = 'admin' THEN (SELECT id FROM roles WHERE name = 'Admin')
WHEN _role_old = 'leader' THEN (SELECT id FROM roles WHERE name = 'Leader')
ELSE (SELECT id FROM roles WHERE name = 'Worker')
END
`);
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = async function(knex) {
await knex('role_permissions').del();
await knex('user_permissions').del();
await knex('roles').del();
await knex('permissions').del();
// 역할 롤백 (단순화된 버전)
await knex.raw("UPDATE users SET _role_old = 'user' WHERE role_id IS NOT NULL");
};

View File

@@ -0,0 +1,62 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = async function (knex) {
const hasHireDate = await knex.schema.hasColumn('workers', 'hire_date');
if (!hasHireDate) {
await knex.schema.alterTable('workers', function (table) {
// Modify status to ENUM
// Note: Knex might not support modifying to ENUM easily across DBs, but valid for MySQL
// We use raw SQL for status modification to be safe with existing data
// Add new columns
table.string('phone_number', 20).nullable().comment('전화번호');
table.string('email', 100).nullable().comment('이메일');
table.date('hire_date').nullable().comment('입사일');
table.string('department', 100).nullable().comment('부서');
table.text('notes').nullable().comment('비고');
});
// Update status column using raw query
await knex.raw(`
ALTER TABLE workers
MODIFY COLUMN status ENUM('active', 'inactive') DEFAULT 'active' COMMENT '작업자 상태 (active: 활성, inactive: 비활성)'
`);
// Add indexes
await knex.raw(`CREATE INDEX IF NOT EXISTS idx_workers_status ON workers(status)`);
await knex.raw(`CREATE INDEX IF NOT EXISTS idx_workers_hire_date ON workers(hire_date)`);
// Set NULL status to active
await knex('workers').whereNull('status').update({ status: 'active' });
}
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = async function (knex) {
// We generally don't want to lose data on rollback of this critical schema fix,
// but technically we should revert changes.
// For safety, we might skip dropping columns or implement it carefully.
const hasHireDate = await knex.schema.hasColumn('workers', 'hire_date');
if (hasHireDate) {
await knex.schema.alterTable('workers', function (table) {
table.dropColumn('phone_number');
table.dropColumn('email');
table.dropColumn('hire_date');
table.dropColumn('department');
table.dropColumn('notes');
});
await knex.raw(`
ALTER TABLE workers
MODIFY COLUMN status VARCHAR(20) DEFAULT 'active' COMMENT '상태 (active, inactive)'
`);
}
};

View File

@@ -0,0 +1,151 @@
/**
* 권한 시스템 단순화 및 페이지 접근 권한 추가
* - Leader와 Worker를 User로 통합
* - 페이지 접근 권한 테이블 생성
* - Admin이 사용자별 페이지 접근 권한을 설정할 수 있도록 함
*
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = async function(knex) {
// 1. 페이지 목록 테이블 생성
await knex.schema.createTable('pages', function(table) {
table.increments('id').primary();
table.string('page_key', 100).notNullable().unique(); // 예: 'worker-management', 'project-management'
table.string('page_name', 100).notNullable(); // 예: '작업자 관리', '프로젝트 관리'
table.string('page_path', 255).notNullable(); // 예: '/pages/management/worker-management.html'
table.string('category', 50); // 예: 'management', 'dashboard', 'admin'
table.string('description', 255);
table.boolean('is_admin_only').defaultTo(false); // Admin 전용 페이지 여부
table.integer('display_order').defaultTo(0); // 표시 순서
table.timestamps(true, true);
});
// 2. 사용자별 페이지 접근 권한 테이블 생성
await knex.schema.createTable('user_page_access', function(table) {
table.integer('user_id').notNullable()
.references('user_id').inTable('users').onDelete('CASCADE');
table.integer('page_id').unsigned().notNullable()
.references('id').inTable('pages').onDelete('CASCADE');
table.boolean('can_access').defaultTo(true); // 접근 가능 여부
table.timestamp('granted_at').defaultTo(knex.fn.now());
table.integer('granted_by') // 권한을 부여한 Admin의 user_id
.references('user_id').inTable('users').onDelete('SET NULL');
table.primary(['user_id', 'page_id']);
});
// 3. 기본 페이지 목록 삽입
await knex('pages').insert([
// Dashboard
{ page_key: 'dashboard-user', page_name: '사용자 대시보드', page_path: '/pages/dashboard/user.html', category: 'dashboard', is_admin_only: false, display_order: 1 },
{ page_key: 'dashboard-leader', page_name: '그룹장 대시보드', page_path: '/pages/dashboard/group-leader.html', category: 'dashboard', is_admin_only: false, display_order: 2 },
// Management
{ page_key: 'worker-management', page_name: '작업자 관리', page_path: '/pages/management/worker-management.html', category: 'management', is_admin_only: false, display_order: 10 },
{ page_key: 'project-management', page_name: '프로젝트 관리', page_path: '/pages/management/project-management.html', category: 'management', is_admin_only: false, display_order: 11 },
{ page_key: 'work-management', page_name: '작업 관리', page_path: '/pages/management/work-management.html', category: 'management', is_admin_only: false, display_order: 12 },
{ page_key: 'code-management', page_name: '코드 관리', page_path: '/pages/management/code-management.html', category: 'management', is_admin_only: false, display_order: 13 },
// Common
{ page_key: 'daily-work-report', page_name: '작업 현황 확인', page_path: '/pages/common/daily-work-report-viewer.html', category: 'common', is_admin_only: false, display_order: 20 },
// Admin
{ page_key: 'user-management', page_name: '사용자 관리', page_path: '/pages/admin/manage-user.html', category: 'admin', is_admin_only: true, display_order: 100 },
]);
// 4. roles 테이블 업데이트: Leader와 Worker를 User로 통합
// Leader와 Worker 역할을 가진 사용자를 모두 User로 변경
const userRoleId = await knex('roles').where('name', 'Worker').first().then(r => r.id);
const leaderRoleId = await knex('roles').where('name', 'Leader').first().then(r => r ? r.id : null);
if (leaderRoleId) {
// Leader를 User로 변경
await knex('users').where('role_id', leaderRoleId).update({ role_id: userRoleId });
}
// 5. role_permissions 업데이트: Worker 권한을 확장하여 모든 일반 기능 사용 가능하게
const allPermissions = await knex('permissions').select('id', 'name');
const permissionMap = allPermissions.reduce((acc, p) => {
acc[p.name] = p.id;
return acc;
}, {});
// Worker 역할의 기존 권한 삭제
await knex('role_permissions').where('role_id', userRoleId).del();
// Worker(이제 User) 역할에 모든 일반 권한 부여 (Admin/System 권한 제외)
const userPermissions = [
permissionMap['user:read'],
permissionMap['project:read'],
permissionMap['project:create'],
permissionMap['project:update'],
permissionMap['work-report:create'],
permissionMap['work-report:read-own'],
permissionMap['work-report:read-team'],
permissionMap['work-report:read-all'],
permissionMap['work-report:update'],
permissionMap['work-report:delete'],
].filter(Boolean); // undefined 제거
const rolePermissionInserts = userPermissions.map(permissionId => ({
role_id: userRoleId,
permission_id: permissionId
}));
await knex('role_permissions').insert(rolePermissionInserts);
// 6. Leader 역할 삭제 (더 이상 사용하지 않음)
if (leaderRoleId) {
await knex('role_permissions').where('role_id', leaderRoleId).del();
await knex('roles').where('id', leaderRoleId).del();
}
// 7. Worker 역할 이름을 'User'로 변경
await knex('roles').where('id', userRoleId).update({
name: 'User',
description: '일반 사용자. 작업 보고서 및 프로젝트 관리 등 모든 일반 기능을 사용할 수 있음.'
});
// 8. 모든 일반 사용자에게 모든 페이지 접근 권한 부여 (Admin 페이지 제외)
const normalPages = await knex('pages').where('is_admin_only', false).select('id');
const normalUsers = await knex('users').where('role_id', userRoleId).select('user_id');
const userPageAccessInserts = [];
normalUsers.forEach(user => {
normalPages.forEach(page => {
userPageAccessInserts.push({
user_id: user.user_id,
page_id: page.id,
can_access: true
});
});
});
if (userPageAccessInserts.length > 0) {
await knex('user_page_access').insert(userPageAccessInserts);
}
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = async function(knex) {
// 테이블 삭제 (역순)
await knex.schema.dropTableIfExists('user_page_access');
await knex.schema.dropTableIfExists('pages');
// User 역할을 다시 Worker로 변경
const userRoleId = await knex('roles').where('name', 'User').first().then(r => r ? r.id : null);
if (userRoleId) {
await knex('roles').where('id', userRoleId).update({
name: 'Worker',
description: '일반 작업자. 자신의 작업 보고서 작성 및 조회 권한.'
});
}
// Leader 역할 재생성
await knex('roles').insert([
{ id: 3, name: 'Leader', description: '그룹장. 팀원 작업 현황 조회 등 중간 관리자 권한.' }
]);
};

View File

@@ -0,0 +1,27 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = async function(knex) {
await knex.schema.alterTable('workers', (table) => {
// 재직 상태 (employed: 재직, resigned: 퇴사)
table.enum('employment_status', ['employed', 'resigned'])
.defaultTo('employed')
.notNullable()
.comment('재직 상태 (employed: 재직, resigned: 퇴사)');
});
console.log('✅ workers 테이블에 employment_status 컬럼 추가 완료');
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = async function(knex) {
await knex.schema.alterTable('workers', (table) => {
table.dropColumn('employment_status');
});
console.log('✅ workers 테이블에서 employment_status 컬럼 삭제 완료');
};

View File

@@ -0,0 +1,33 @@
/**
* 마이그레이션: Workers 테이블에 급여 및 기본 연차 컬럼 추가
* 작성일: 2026-01-19
*
* 변경사항:
* - salary 컬럼 추가 (NULL 허용, 선택 사항)
* - base_annual_leave 컬럼 추가 (기본값: 15일)
*/
exports.up = async function(knex) {
console.log('⏳ Workers 테이블에 salary, base_annual_leave 컬럼 추가 중...');
await knex.schema.alterTable('workers', (table) => {
// 급여 정보 (선택 사항, NULL 허용)
table.decimal('salary', 12, 2).nullable().comment('급여 (선택)');
// 기본 연차 일수 (기본값: 15일)
table.integer('base_annual_leave').defaultTo(15).notNullable().comment('기본 연차 일수');
});
console.log('✅ Workers 테이블 컬럼 추가 완료');
};
exports.down = async function(knex) {
console.log('⏳ Workers 테이블에서 salary, base_annual_leave 컬럼 제거 중...');
await knex.schema.alterTable('workers', (table) => {
table.dropColumn('salary');
table.dropColumn('base_annual_leave');
});
console.log('✅ Workers 테이블 컬럼 제거 완료');
};

View File

@@ -0,0 +1,112 @@
/**
* 마이그레이션: 출근/근태 관련 테이블 생성
* 작성일: 2026-01-19
*
* 생성 테이블:
* - work_attendance_types: 출근 유형 (정상, 지각, 조퇴, 결근, 휴가)
* - vacation_types: 휴가 유형 (연차, 반차, 병가, 경조사)
* - daily_attendance_records: 일일 출근 기록
* - worker_vacation_balance: 작업자 연차 잔액 (연도별)
*/
exports.up = async function(knex) {
console.log('⏳ 출근/근태 관련 테이블 생성 중...');
// 1. 출근 유형 테이블
await knex.schema.createTable('work_attendance_types', (table) => {
table.increments('id').primary();
table.string('type_code', 20).unique().notNullable().comment('유형 코드');
table.string('type_name', 50).notNullable().comment('유형 이름');
table.text('description').nullable().comment('설명');
table.boolean('is_active').defaultTo(true).comment('활성 여부');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
});
console.log('✅ work_attendance_types 테이블 생성 완료');
// 초기 데이터 입력
await knex('work_attendance_types').insert([
{ type_code: 'NORMAL', type_name: '정상 출근', description: '정상 출근' },
{ type_code: 'LATE', type_name: '지각', description: '지각' },
{ type_code: 'EARLY_LEAVE', type_name: '조퇴', description: '조퇴' },
{ type_code: 'ABSENT', type_name: '결근', description: '무단 결근' },
{ type_code: 'VACATION', type_name: '휴가', description: '승인된 휴가' }
]);
console.log('✅ work_attendance_types 초기 데이터 입력 완료');
// 2. 휴가 유형 테이블
await knex.schema.createTable('vacation_types', (table) => {
table.increments('id').primary();
table.string('type_code', 20).unique().notNullable().comment('휴가 코드');
table.string('type_name', 50).notNullable().comment('휴가 이름');
table.decimal('deduct_days', 3, 1).defaultTo(1.0).comment('차감 일수');
table.boolean('is_active').defaultTo(true).comment('활성 여부');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
});
console.log('✅ vacation_types 테이블 생성 완료');
// 초기 데이터 입력
await knex('vacation_types').insert([
{ type_code: 'ANNUAL', type_name: '연차', deduct_days: 1.0 },
{ type_code: 'HALF_ANNUAL', type_name: '반차', deduct_days: 0.5 },
{ type_code: 'SICK', type_name: '병가', deduct_days: 1.0 },
{ type_code: 'SPECIAL', type_name: '경조사', deduct_days: 0 }
]);
console.log('✅ vacation_types 초기 데이터 입력 완료');
// 3. 일일 출근 기록 테이블
await knex.schema.createTable('daily_attendance_records', (table) => {
table.increments('id').primary();
table.integer('worker_id').unsigned().notNullable().comment('작업자 ID');
table.date('record_date').notNullable().comment('기록 날짜');
table.integer('attendance_type_id').unsigned().notNullable().comment('출근 유형 ID');
table.integer('vacation_type_id').unsigned().nullable().comment('휴가 유형 ID');
table.time('check_in_time').nullable().comment('출근 시간');
table.time('check_out_time').nullable().comment('퇴근 시간');
table.decimal('total_work_hours', 4, 2).defaultTo(0).comment('총 근무 시간');
table.boolean('is_overtime_approved').defaultTo(false).comment('초과근무 승인 여부');
table.text('notes').nullable().comment('비고');
table.integer('created_by').unsigned().notNullable().comment('등록자 user_id');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
// 인덱스 및 제약조건
table.unique(['worker_id', 'record_date']);
table.foreign('worker_id').references('workers.worker_id').onDelete('CASCADE');
table.foreign('attendance_type_id').references('work_attendance_types.id');
table.foreign('vacation_type_id').references('vacation_types.id');
table.foreign('created_by').references('users.user_id');
});
console.log('✅ daily_attendance_records 테이블 생성 완료');
// 4. 작업자 연차 잔액 테이블
await knex.schema.createTable('worker_vacation_balance', (table) => {
table.increments('id').primary();
table.integer('worker_id').unsigned().notNullable().comment('작업자 ID');
table.integer('year').notNullable().comment('연도');
table.decimal('total_annual_leave', 4, 1).defaultTo(15.0).comment('총 연차');
table.decimal('used_annual_leave', 4, 1).defaultTo(0).comment('사용 연차');
// remaining_annual_leave는 애플리케이션 레벨에서 계산
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
// 인덱스 및 제약조건
table.unique(['worker_id', 'year']);
table.foreign('worker_id').references('workers.worker_id').onDelete('CASCADE');
});
console.log('✅ worker_vacation_balance 테이블 생성 완료');
console.log('✅ 모든 출근/근태 관련 테이블 생성 완료');
};
exports.down = async function(knex) {
console.log('⏳ 출근/근태 관련 테이블 제거 중...');
await knex.schema.dropTableIfExists('worker_vacation_balance');
await knex.schema.dropTableIfExists('daily_attendance_records');
await knex.schema.dropTableIfExists('vacation_types');
await knex.schema.dropTableIfExists('work_attendance_types');
console.log('✅ 모든 출근/근태 관련 테이블 제거 완료');
};

Some files were not shown because too many files have changed in this diff Show More