feat: 다수 기능 개선 - 순찰, 출근, 작업분석, 모바일 UI 등

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-09 14:41:01 +09:00
parent 1548253f56
commit 2b1c7bfb88
633 changed files with 361224 additions and 1090 deletions

View File

@@ -0,0 +1,519 @@
// models/WorkAnalysis.js - 향상된 버전
class WorkAnalysis {
constructor(db) {
this.db = db;
}
// 기본 통계 조회
async getBasicStats(startDate, endDate) {
const query = `
SELECT
COALESCE(SUM(work_hours), 0) as total_hours,
COUNT(*) as total_reports,
COUNT(DISTINCT project_id) as active_projects,
COUNT(DISTINCT worker_id) as active_workers,
SUM(CASE WHEN work_status_id = 2 THEN 1 ELSE 0 END) as error_reports,
ROUND(AVG(work_hours), 2) as avg_hours_per_report
FROM daily_work_reports
WHERE report_date BETWEEN ? AND ?
`;
try {
const [results] = await this.db.execute(query, [startDate, endDate]);
const stats = results[0];
const errorRate = stats.total_reports > 0
? (stats.error_reports / stats.total_reports) * 100
: 0;
return {
totalHours: parseFloat(stats.total_hours) || 0,
totalReports: parseInt(stats.total_reports) || 0,
activeProjects: parseInt(stats.active_projects) || 0,
activeworkers: parseInt(stats.active_workers) || 0,
errorRate: parseFloat(errorRate.toFixed(2)) || 0,
avgHoursPerReport: parseFloat(stats.avg_hours_per_report) || 0
};
} catch (error) {
throw new Error(`기본 통계 조회 실패: ${error.message}`);
}
}
// 일별 작업시간 추이
async getDailyTrend(startDate, endDate) {
const query = `
SELECT
report_date as date,
SUM(work_hours) as hours,
COUNT(*) as reports,
COUNT(DISTINCT worker_id) as workers,
SUM(CASE WHEN work_status_id = 2 THEN 1 ELSE 0 END) as errors
FROM daily_work_reports
WHERE report_date BETWEEN ? AND ?
GROUP BY report_date
ORDER BY report_date
`;
try {
const [results] = await this.db.execute(query, [startDate, endDate]);
return results.map(row => ({
date: row.date,
hours: parseFloat(row.hours) || 0,
reports: parseInt(row.reports) || 0,
workers: parseInt(row.workers) || 0,
errors: parseInt(row.errors) || 0
}));
} catch (error) {
throw new Error(`일별 추이 조회 실패: ${error.message}`);
}
}
// 작업자별 통계
async getWorkerStats(startDate, endDate) {
const query = `
SELECT
dwr.worker_id,
w.worker_name,
SUM(dwr.work_hours) as totalHours,
COUNT(*) as totalReports,
ROUND(AVG(dwr.work_hours), 2) as avgHours,
COUNT(DISTINCT dwr.project_id) as projectCount,
SUM(CASE WHEN dwr.work_status_id = 2 THEN 1 ELSE 0 END) as errorCount,
COUNT(DISTINCT dwr.report_date) as workingDays
FROM daily_work_reports dwr
LEFT JOIN workers w ON dwr.worker_id = w.worker_id
WHERE dwr.report_date BETWEEN ? AND ?
GROUP BY dwr.worker_id, w.worker_name
ORDER BY totalHours DESC
`;
try {
const [results] = await this.db.execute(query, [startDate, endDate]);
return results.map(row => ({
worker_id: row.worker_id,
worker_name: row.worker_name || `작업자 ${row.worker_id}`,
totalHours: parseFloat(row.totalHours) || 0,
totalReports: parseInt(row.totalReports) || 0,
avgHours: parseFloat(row.avgHours) || 0,
projectCount: parseInt(row.projectCount) || 0,
errorCount: parseInt(row.errorCount) || 0,
workingDays: parseInt(row.workingDays) || 0,
errorRate: row.totalReports > 0 ? parseFloat(((row.errorCount / row.totalReports) * 100).toFixed(2)) : 0
}));
} catch (error) {
throw new Error(`작업자별 통계 조회 실패: ${error.message}`);
}
}
// 프로젝트별 통계
async getProjectStats(startDate, endDate) {
const query = `
SELECT
dwr.project_id,
p.project_name,
SUM(dwr.work_hours) as totalHours,
COUNT(*) as totalReports,
COUNT(DISTINCT dwr.worker_id) as workerCount,
ROUND(AVG(dwr.work_hours), 2) as avgHours,
SUM(CASE WHEN dwr.work_status_id = 2 THEN 1 ELSE 0 END) as errorCount,
COUNT(DISTINCT dwr.report_date) as activeDays
FROM daily_work_reports dwr
LEFT JOIN projects p ON dwr.project_id = p.project_id
WHERE dwr.report_date BETWEEN ? AND ?
GROUP BY dwr.project_id, p.project_name
ORDER BY totalHours DESC
`;
try {
const [results] = await this.db.execute(query, [startDate, endDate]);
return results.map(row => ({
project_id: row.project_id,
project_name: row.project_name || `프로젝트 ${row.project_id}`,
totalHours: parseFloat(row.totalHours) || 0,
totalReports: parseInt(row.totalReports) || 0,
workerCount: parseInt(row.workerCount) || 0,
avgHours: parseFloat(row.avgHours) || 0,
errorCount: parseInt(row.errorCount) || 0,
activeDays: parseInt(row.activeDays) || 0,
errorRate: row.totalReports > 0 ? parseFloat(((row.errorCount / row.totalReports) * 100).toFixed(2)) : 0
}));
} catch (error) {
throw new Error(`프로젝트별 통계 조회 실패: ${error.message}`);
}
}
// 작업유형별 통계
async getWorkTypeStats(startDate, endDate) {
const query = `
SELECT
dwr.work_type_id,
wt.name as work_type_name,
SUM(dwr.work_hours) as totalHours,
COUNT(*) as totalReports,
ROUND(AVG(dwr.work_hours), 2) as avgHours,
COUNT(DISTINCT dwr.worker_id) as workerCount,
SUM(CASE WHEN dwr.work_status_id = 2 THEN 1 ELSE 0 END) as errorCount,
COUNT(DISTINCT dwr.project_id) as projectCount
FROM daily_work_reports dwr
LEFT JOIN work_types wt ON dwr.work_type_id = wt.id
WHERE dwr.report_date BETWEEN ? AND ?
GROUP BY dwr.work_type_id, wt.name
ORDER BY totalHours DESC
`;
try {
const [results] = await this.db.execute(query, [startDate, endDate]);
return results.map(row => ({
work_type_id: row.work_type_id,
work_type_name: row.work_type_name || `작업유형 ${row.work_type_id}`,
totalHours: parseFloat(row.totalHours) || 0,
totalReports: parseInt(row.totalReports) || 0,
avgHours: parseFloat(row.avgHours) || 0,
workerCount: parseInt(row.workerCount) || 0,
errorCount: parseInt(row.errorCount) || 0,
projectCount: parseInt(row.projectCount) || 0,
errorRate: row.totalReports > 0 ? parseFloat(((row.errorCount / row.totalReports) * 100).toFixed(2)) : 0
}));
} catch (error) {
throw new Error(`작업유형별 통계 조회 실패: ${error.message}`);
}
}
// 최근 작업 현황
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 = `
SELECT
dwr.id,
dwr.report_date,
dwr.worker_id,
w.worker_name,
dwr.project_id,
p.project_name,
p.job_no,
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 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,
wst.name as work_status_name,
dwr.error_type_id,
iri.item_name as error_type_name,
irc.category_name as error_category_name,
dwr.work_hours,
dwr.created_by,
u.name as created_by_name,
dwr.created_at
FROM daily_work_reports dwr
LEFT JOIN workers w ON dwr.worker_id = w.worker_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 wt2 ON t.work_type_id = wt2.id
LEFT JOIN work_status_types wst ON dwr.work_status_id = wst.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
WHERE dwr.report_date BETWEEN ? AND ?
ORDER BY dwr.created_at DESC
LIMIT ?
`;
try {
const [results] = await this.db.execute(query, [startDate, endDate, parseInt(limit)]);
return results.map(row => ({
id: row.id,
report_date: row.report_date,
worker_id: row.worker_id,
worker_name: row.worker_name || `작업자 ${row.worker_id}`,
project_id: row.project_id,
project_name: row.project_name || `프로젝트 ${row.project_id}`,
job_no: row.job_no || 'N/A',
work_type_id: 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_name: row.work_status_name || '정상',
error_type_id: row.error_type_id,
error_type_name: row.error_type_name || null,
error_category_name: row.error_category_name || null,
work_hours: parseFloat(row.work_hours) || 0,
created_by: row.created_by,
created_by_name: row.created_by_name || '미지정',
created_at: row.created_at
}));
} catch (error) {
throw new Error(`최근 작업 현황 조회 실패: ${error.message}`);
}
}
// 요일별 패턴 분석
async getWeekdayPattern(startDate, endDate) {
const query = `
SELECT
DAYOFWEEK(report_date) as day_of_week,
CASE DAYOFWEEK(report_date)
WHEN 1 THEN '일요일'
WHEN 2 THEN '월요일'
WHEN 3 THEN '화요일'
WHEN 4 THEN '수요일'
WHEN 5 THEN '목요일'
WHEN 6 THEN '금요일'
WHEN 7 THEN '토요일'
END as day_name,
SUM(work_hours) as total_hours,
COUNT(*) as total_reports,
ROUND(AVG(work_hours), 2) as avg_hours,
COUNT(DISTINCT worker_id) as active_workers
FROM daily_work_reports
WHERE report_date BETWEEN ? AND ?
GROUP BY DAYOFWEEK(report_date)
ORDER BY day_of_week
`;
try {
const [results] = await this.db.execute(query, [startDate, endDate]);
return results.map(row => ({
dayOfWeek: row.day_of_week,
dayName: row.day_name,
totalHours: parseFloat(row.total_hours) || 0,
totalReports: parseInt(row.total_reports) || 0,
avgHours: parseFloat(row.avg_hours) || 0,
activeworkers: parseInt(row.active_workers) || 0
}));
} catch (error) {
throw new Error(`요일별 패턴 분석 실패: ${error.message}`);
}
}
// 에러 분석
// error_type_id는 issue_report_items 테이블 참조 (issue-categories.html에서 관리)
async getErrorAnalysis(startDate, endDate) {
const query = `
SELECT
dwr.error_type_id,
iri.item_name as error_type_name,
irc.category_name as error_category_name,
COUNT(*) as error_count,
SUM(dwr.work_hours) as total_hours,
COUNT(DISTINCT dwr.worker_id) as affected_workers,
COUNT(DISTINCT dwr.project_id) as affected_projects
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.report_date BETWEEN ? AND ?
AND dwr.work_status_id = 2
GROUP BY dwr.error_type_id, iri.item_name, irc.category_name
ORDER BY error_count DESC
`;
try {
const [results] = await this.db.execute(query, [startDate, endDate]);
return results.map(row => ({
error_type_id: 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,
totalHours: parseFloat(row.total_hours) || 0,
affectedworkers: parseInt(row.affected_workers) || 0,
affectedProjects: parseInt(row.affected_projects) || 0
}));
} catch (error) {
throw new Error(`에러 분석 실패: ${error.message}`);
}
}
// 월별 비교 분석
async getMonthlyComparison(year) {
const query = `
SELECT
MONTH(report_date) as month,
MONTHNAME(report_date) as month_name,
SUM(work_hours) as total_hours,
COUNT(*) as total_reports,
COUNT(DISTINCT worker_id) as active_workers,
COUNT(DISTINCT project_id) as active_projects,
SUM(CASE WHEN work_status_id = 2 THEN 1 ELSE 0 END) as error_count
FROM daily_work_reports
WHERE YEAR(report_date) = ?
GROUP BY MONTH(report_date), MONTHNAME(report_date)
ORDER BY month
`;
try {
const [results] = await this.db.execute(query, [year]);
return results.map(row => ({
month: row.month,
monthName: row.month_name,
totalHours: parseFloat(row.total_hours) || 0,
totalReports: parseInt(row.total_reports) || 0,
activeworkers: parseInt(row.active_workers) || 0,
activeProjects: parseInt(row.active_projects) || 0,
errorCount: parseInt(row.error_count) || 0,
errorRate: row.total_reports > 0 ? parseFloat(((row.error_count / row.total_reports) * 100).toFixed(2)) : 0
}));
} catch (error) {
throw new Error(`월별 비교 분석 실패: ${error.message}`);
}
}
// 작업자별 전문분야 분석
async getWorkerSpecialization(startDate, endDate) {
const query = `
SELECT
dwr.worker_id,
w.worker_name,
dwr.work_type_id,
wt.name as work_type_name,
dwr.project_id,
p.project_name,
SUM(dwr.work_hours) as totalHours,
COUNT(*) as totalReports,
ROUND((SUM(dwr.work_hours) / (
SELECT SUM(work_hours)
FROM daily_work_reports
WHERE worker_id = dwr.worker_id
AND report_date BETWEEN ? AND ?
)) * 100, 2) as percentage
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 projects p ON dwr.project_id = p.project_id
WHERE dwr.report_date BETWEEN ? AND ?
GROUP BY dwr.worker_id, w.worker_name, dwr.work_type_id, wt.name, dwr.project_id, p.project_name
HAVING totalHours > 0
ORDER BY dwr.worker_id, totalHours DESC
`;
try {
const [results] = await this.db.execute(query, [startDate, endDate, startDate, endDate]);
return results.map(row => ({
worker_id: row.worker_id,
worker_name: row.worker_name || `작업자 ${row.worker_id}`,
work_type_id: row.work_type_id,
work_type_name: row.work_type_name || `작업유형 ${row.work_type_id}`,
project_id: row.project_id,
project_name: row.project_name || `프로젝트 ${row.project_id}`,
totalHours: parseFloat(row.totalHours) || 0,
totalReports: parseInt(row.totalReports) || 0,
percentage: parseFloat(row.percentage) || 0
}));
} catch (error) {
throw new Error(`작업자별 전문분야 분석 실패: ${error.message}`);
}
}
// 대시보드용 종합 데이터
async getDashboardData(startDate, endDate) {
try {
// 병렬로 모든 데이터 조회
const [
stats,
dailyTrend,
workerStats,
projectStats,
workTypeStats,
recentWork
] = await Promise.all([
this.getBasicStats(startDate, endDate),
this.getDailyTrend(startDate, endDate),
this.getWorkerStats(startDate, endDate),
this.getProjectStats(startDate, endDate),
this.getWorkTypeStats(startDate, endDate),
this.getRecentWork(startDate, endDate, 20)
]);
return {
stats,
dailyTrend,
workerStats,
projectStats,
workTypeStats,
recentWork,
metadata: {
period: `${startDate} ~ ${endDate}`,
timestamp: new Date().toISOString()
}
};
} catch (error) {
throw new Error(`대시보드 데이터 조회 실패: ${error.message}`);
}
}
// 프로젝트별-작업별 시간 분석용 데이터 조회 (공정/대분류 기준)
async getProjectWorkTypeRawData(startDate, endDate) {
// work_type_id 컬럼에는 task_id가 저장됨 (tasks 테이블 우선 조회)
// task_id로 매칭되면 해당 task의 work_type_id로 공정 조회
// 매칭 안 되면 직접 work_types 조회 (레거시 데이터 호환)
const query = `
SELECT
COALESCE(p.project_id, dwr.project_id) as project_id,
COALESCE(p.project_name, CONCAT('프로젝트 ', dwr.project_id)) as project_name,
COALESCE(p.job_no, 'N/A') as job_no,
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,
CONCAT('작업유형 ', dwr.work_type_id)
) as work_type_name,
-- 총 시간
SUM(dwr.work_hours) as total_hours,
-- 정규 시간 (work_status_id = 1)
SUM(CASE WHEN dwr.work_status_id = 1 THEN dwr.work_hours ELSE 0 END) as regular_hours,
-- 에러 시간 (work_status_id = 2)
SUM(CASE WHEN dwr.work_status_id = 2 THEN dwr.work_hours ELSE 0 END) as error_hours,
-- 작업 건수
COUNT(*) as total_reports,
COUNT(CASE WHEN dwr.work_status_id = 1 THEN 1 END) as regular_reports,
COUNT(CASE WHEN dwr.work_status_id = 2 THEN 1 END) as error_reports,
-- 에러율 계산
ROUND(
(SUM(CASE WHEN dwr.work_status_id = 2 THEN dwr.work_hours ELSE 0 END) /
SUM(dwr.work_hours)) * 100, 2
) as error_rate_percent
FROM daily_work_reports dwr
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 wt2 ON t.work_type_id = wt2.id
WHERE dwr.report_date BETWEEN ? AND ?
GROUP BY dwr.project_id, p.project_name, p.job_no,
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 {
const [results] = await this.db.execute(query, [startDate, endDate]);
return results;
} catch (error) {
throw new Error(`프로젝트-작업유형별 원본 데이터 조회 실패: ${error.message}`);
}
}
}
module.exports = WorkAnalysis;

View File

@@ -0,0 +1,115 @@
// /models/analysisModel.js
const { getDb } = require('../dbPool');
/**
* 지정된 기간 동안의 작업 보고서 데이터를 집계하여 분석합니다.
* 이 함수는 여러 개의 SQL 쿼리를 병렬로 실행하여 효율성을 높입니다.
* @param {string} startDate - 시작일 (YYYY-MM-DD)
* @param {string} endDate - 종료일 (YYYY-MM-DD)
* @returns {Promise<object>} - 요약, 프로젝트별, 작업자별, 작업별 집계 데이터 및 상세 내역
*/
const getAnalysis = async (startDate, endDate) => {
try {
const db = await getDb();
// SQL 쿼리에서 반복적으로 사용될 WHERE 조건과 실제 투입 시간 계산 로직
const whereClause = `WHERE dwr.report_date BETWEEN ? AND ?`;
const workHoursCalc = `
CASE dwr.work_details
WHEN '연차' THEN 0 WHEN '반차' THEN 4 WHEN '반반차' THEN 6
WHEN '조퇴' THEN 2 WHEN '휴무' THEN 0 WHEN '유급' THEN 0
ELSE 8
END + (COALESCE(dwr.overtime_hours, 0) * 1.5)
`;
// 1. 요약 정보 쿼리
const summarySql = `
SELECT
COUNT(DISTINCT dwr.project_id) as totalProjects,
COUNT(DISTINCT dwr.worker_id) as totalworkers,
COUNT(DISTINCT dwr.task_id) as totalTasks,
SUM(${workHoursCalc}) as totalHours
FROM DailyWorkReports dwr
${whereClause} AND (${workHoursCalc}) > 0
`;
// 2. 프로젝트별 집계 쿼리
const byProjectSql = `
SELECT p.project_name as name, SUM(${workHoursCalc}) as hours, COUNT(DISTINCT dwr.worker_id) as participants
FROM DailyWorkReports dwr
JOIN projects p ON dwr.project_id = p.project_id
${whereClause}
GROUP BY p.project_name
HAVING hours > 0
ORDER BY hours DESC;
`;
// 3. 작업자별 집계 쿼리
const byWorkerSql = `
SELECT w.worker_name as name, SUM(${workHoursCalc}) as hours, COUNT(DISTINCT dwr.project_id) as participants
FROM DailyWorkReports dwr
JOIN workers w ON dwr.worker_id = w.worker_id
${whereClause}
GROUP BY w.worker_name
HAVING hours > 0
ORDER BY hours DESC;
`;
// 4. 작업별 집계 쿼리
const byTaskSql = `
SELECT t.category as name, SUM(${workHoursCalc}) as hours, COUNT(DISTINCT dwr.worker_id) as participants
FROM DailyWorkReports dwr
JOIN Tasks t ON dwr.task_id = t.task_id
${whereClause}
GROUP BY t.category
HAVING hours > 0
ORDER BY hours DESC;
`;
// 5. 상세 내역 쿼리
const detailsSql = `
SELECT
dwr.report_date as date, p.project_name, w.worker_name,
t.category as task_category, dwr.work_details,
(${workHoursCalc}) as work_hours, dwr.memo
FROM DailyWorkReports dwr
JOIN projects p ON dwr.project_id = p.project_id
JOIN workers w ON dwr.worker_id = w.worker_id
JOIN Tasks t ON dwr.task_id = t.task_id
${whereClause}
HAVING work_hours > 0
ORDER BY dwr.report_date DESC;
`;
// 모든 쿼리를 병렬로 실행
const [
[summaryResult],
[byProject],
[byWorker],
[byTask],
[details]
] = await Promise.all([
db.query(summarySql, [startDate, endDate]),
db.query(byProjectSql, [startDate, endDate]),
db.query(byWorkerSql, [startDate, endDate]),
db.query(byTaskSql, [startDate, endDate]),
db.query(detailsSql, [startDate, endDate])
]);
return {
summary: summaryResult[0],
byProject,
byWorker,
byTask,
details
};
} catch (err) {
console.error('[Model] 분석 데이터 조회 오류:', err);
throw new Error('데이터베이스에서 분석 데이터를 조회하는 중 오류가 발생했습니다.');
}
};
module.exports = {
getAnalysis
};

View File

@@ -0,0 +1,530 @@
const { getDb } = require('../dbPool');
class AttendanceModel {
// 일일 근태 기록 조회
static async getDailyAttendanceRecords(date, workerId = null) {
const db = await getDb();
let query = `
SELECT
dar.*,
w.worker_name,
w.job_type,
wat.type_name as attendance_type_name,
wat.type_code as attendance_type_code,
vt.type_name as vacation_type_name,
vt.type_code as vacation_type_code,
vt.deduct_days as vacation_days
FROM daily_attendance_records dar
LEFT JOIN workers w ON dar.worker_id = w.worker_id
LEFT JOIN work_attendance_types wat ON dar.attendance_type_id = wat.id
LEFT JOIN vacation_types vt ON dar.vacation_type_id = vt.id
WHERE dar.record_date = ?
`;
const params = [date];
if (workerId) {
query += ' AND dar.worker_id = ?';
params.push(workerId);
}
query += ' ORDER BY w.worker_name';
const [rows] = await db.execute(query, params);
return rows;
}
// 기간별 근태 기록 조회 (월별 조회용)
static async getDailyRecords(startDate, endDate, workerId = null) {
const db = await getDb();
let query = `
SELECT
dar.*,
w.worker_name,
w.job_type,
wat.type_name as attendance_type_name,
wat.type_code as attendance_type_code,
vt.type_name as vacation_type_name,
vt.type_code as vacation_type_code,
vt.deduct_days as vacation_days
FROM daily_attendance_records dar
LEFT JOIN workers w ON dar.worker_id = w.worker_id
LEFT JOIN work_attendance_types wat ON dar.attendance_type_id = wat.id
LEFT JOIN vacation_types vt ON dar.vacation_type_id = vt.id
WHERE dar.record_date BETWEEN ? AND ?
`;
const params = [startDate, endDate];
if (workerId) {
query += ' AND dar.worker_id = ?';
params.push(workerId);
}
query += ' ORDER BY dar.record_date ASC';
const [rows] = await db.execute(query, params);
return rows;
}
// 작업 보고서와 근태 기록 동기화 (시간 합산 및 상태 업데이트)
static async syncWithWorkReports(workerId, date) {
const db = await getDb();
// 1. 해당 날짜의 총 작업 시간 계산
const [reportStats] = await db.execute(`
SELECT
COALESCE(SUM(work_hours), 0) as total_hours,
COUNT(*) as report_count
FROM daily_work_reports
WHERE worker_id = ? AND report_date = ?
`, [workerId, date]);
const totalHours = parseFloat(reportStats[0].total_hours || 0);
const reportCount = reportStats[0].report_count;
// 2. 근태 유형 및 상태 결정
// 기본 규칙: 0시간 -> incomplete, <8시간 -> partial, 8시간 -> complete, >8시간 -> overtime
// (휴가는 별도 로직이지만 여기서 덮어쓰지 않도록 주의해야 함. 하지만 작업보고서가 추가되면 실 근무로 간주)
let status = 'incomplete';
let typeCode = 'REGULAR'; // 기본값
if (totalHours === 0) {
status = 'incomplete';
} else if (totalHours < 8) {
status = 'partial';
typeCode = 'PARTIAL';
} else if (totalHours === 8) {
status = 'complete';
typeCode = 'REGULAR';
} else {
status = 'overtime';
typeCode = 'OVERTIME';
}
// 근태 유형 ID 조회
const [types] = await db.execute('SELECT id FROM work_attendance_types WHERE type_code = ?', [typeCode]);
const typeId = types[0]?.id;
// 3. 기록 업데이트 (휴가 정보는 유지)
// 기존 기록 조회
const [existing] = await db.execute(
'SELECT id, vacation_type_id FROM daily_attendance_records WHERE worker_id = ? AND record_date = ?',
[workerId, date]
);
if (existing.length > 0) {
// 휴가가 설정되어 있고 시간이 0이면 휴가 상태 유지, 시간이 있으면 근무+휴가 복합 상태일 수 있음
// 여기서는 단순화하여 근무 시간이 있으면 근무 상태로 업데이트 (단, vacation_type_id는 유지)
const recordId = existing[0].id;
// 만약 기존 상태가 'vacation'이고 근무시간이 0이면 업데이트 건너뛸 수도 있지만,
// 작업보고서가 삭제되어 0이 된 경우도 있으므로 업데이트는 수행해야 함.
await db.execute(`
UPDATE daily_attendance_records
SET
total_work_hours = ?,
attendance_type_id = ?,
status = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`, [totalHours, typeId, status, recordId]);
return { synced: true, totalHours, status };
} else {
// 기록이 없으면 생성 (일반적으로는 initializeDailyRecords로 생성되어 있어야 함)
// 생성자가 명확하지 않으므로 시스템(1) 또는 알 수 없음 처리
await db.execute(`
INSERT INTO daily_attendance_records
(record_date, worker_id, total_work_hours, attendance_type_id, status, created_by)
VALUES (?, ?, ?, ?, ?, 1)
`, [date, workerId, totalHours, typeId, status]);
return { synced: true, totalHours, status, created: true };
}
}
// 일일 근태 기록 초기화 (모든 활성 작업자에 대한 기본 레코드 생성)
static async initializeDailyRecords(date, createdBy) {
const db = await getDb();
// 1. 활성 작업자 조회
const [workers] = await db.execute(
'SELECT worker_id FROM workers WHERE status = "active"' // is_active check not needed as status covers it based on previous fix? Wait, previous fix used status='active'.
);
if (workers.length === 0) return { inserted: 0 };
// 2. 일일 근태 레코드 일괄 생성 (이미 존재하면 무시)
// VALUES (...), (...), ...
const values = workers.map(w => [date, w.worker_id, 'incomplete', createdBy]);
// Bulk INSERT IGNORE
// Note: mysql2 execute doesn't support nested arrays for bulk insert easily with placeholder ?
// We should build the query or use query method for pool?
// Using simple loop for safety and compatibility or building string.
let insertedCount = 0;
// 트랜잭션 사용 권장
const conn = await db.getConnection();
try {
await conn.beginTransaction();
for (const w of workers) {
const [result] = await conn.execute(`
INSERT IGNORE INTO daily_attendance_records
(record_date, worker_id, status, created_by)
VALUES (?, ?, 'incomplete', ?)
`, [date, w.worker_id, createdBy]);
insertedCount += result.affectedRows;
}
await conn.commit();
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
return { inserted: insertedCount, total_active_workers: workers.length };
}
// 근태 기록 생성 또는 업데이트
static async upsertAttendanceRecord(recordData) {
const db = await getDb();
const {
record_date,
worker_id,
total_work_hours = 8,
work_attendance_type_id = 1,
vacation_type_id = null,
is_overtime_approved = false,
created_by = 1
} = recordData;
const attendance_type_id = work_attendance_type_id;
// 기존 기록 확인
const [existing] = await db.execute(
'SELECT id FROM daily_attendance_records WHERE worker_id = ? AND record_date = ?',
[worker_id, record_date]
);
if (existing.length > 0) {
// 업데이트
const [result] = await db.execute(`
UPDATE daily_attendance_records
SET
total_work_hours = ?,
attendance_type_id = ?,
vacation_type_id = ?,
is_overtime_approved = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`, [
total_work_hours,
attendance_type_id,
vacation_type_id,
is_overtime_approved,
existing[0].id
]);
return { id: existing[0].id, affected: result.affectedRows };
} else {
// 생성
const [result] = await db.execute(`
INSERT INTO daily_attendance_records (
record_date, worker_id, total_work_hours, attendance_type_id,
vacation_type_id, is_overtime_approved, created_by
) VALUES (?, ?, ?, ?, ?, ?, ?)
`, [
record_date,
worker_id,
total_work_hours,
attendance_type_id,
vacation_type_id,
is_overtime_approved,
created_by
]);
return { id: result.insertId, affected: result.affectedRows };
}
}
// 작업자별 근태 현황 조회 (대시보드용)
static async getWorkerAttendanceStatus(date) {
const db = await getDb();
// 모든 작업자와 해당 날짜의 근태 기록을 조회
const [rows] = await db.execute(`
SELECT
w.worker_id,
w.worker_name,
w.job_type,
COALESCE(dar.total_work_hours, 0) as total_work_hours,
COALESCE(dar.status, 'incomplete') as status,
dar.is_vacation_processed,
dar.overtime_approved,
wat.type_name as attendance_type_name,
wat.type_code as attendance_type_code,
vt.type_name as vacation_type_name,
vt.type_code as vacation_type_code,
dar.notes,
-- 작업 건수 계산
(SELECT COUNT(*) FROM daily_work_reports dwr
WHERE dwr.worker_id = w.worker_id AND dwr.report_date = ?) as work_count,
-- 오류 건수 계산
(SELECT COUNT(*) FROM daily_work_reports dwr
WHERE dwr.worker_id = w.worker_id AND dwr.report_date = ? AND dwr.work_status_id = 2) as error_count
FROM workers w
LEFT JOIN daily_attendance_records dar ON w.worker_id = dar.worker_id AND dar.record_date = ?
LEFT JOIN work_attendance_types wat ON dar.attendance_type_id = wat.id
LEFT JOIN vacation_types vt ON dar.vacation_type_id = vt.id
WHERE w.is_active = TRUE
ORDER BY w.worker_name
`, [date, date, date]);
return rows;
}
// 휴가 처리
static async processVacation(workerId, date, vacationType, createdBy) {
const db = await getDb();
// 휴가 유형 정보 조회
const [vacationTypes] = await db.execute(
'SELECT id, type_code, type_name, deduct_days, is_active, created_at, updated_at FROM vacation_types WHERE type_code = ?',
[vacationType]
);
if (vacationTypes.length === 0) {
throw new Error('유효하지 않은 휴가 유형입니다.');
}
const vacationTypeInfo = vacationTypes[0];
// 현재 작업 시간 조회
const [workHours] = await db.execute(`
SELECT COALESCE(SUM(work_hours), 0) as total_hours
FROM daily_work_reports
WHERE worker_id = ? AND report_date = ?
`, [workerId, date]);
const currentHours = parseFloat(workHours[0].total_hours);
// deduct_days를 시간으로 변환 (1일 = 8시간)
const vacationHours = parseFloat(vacationTypeInfo.deduct_days) * 8;
const totalHours = currentHours + vacationHours;
// 근로 유형 결정
let attendanceTypeCode = 'VACATION';
let status = 'vacation';
if (totalHours >= 8) {
attendanceTypeCode = totalHours > 8 ? 'OVERTIME' : 'REGULAR';
status = totalHours > 8 ? 'overtime' : 'complete';
}
const [attendanceTypes] = await db.execute(
'SELECT id FROM work_attendance_types WHERE type_code = ?',
[attendanceTypeCode]
);
// 휴가 작업 기록 생성 (프로젝트 ID 13 = "연차/휴무", work_type_id 1 = 기본)
await db.execute(`
INSERT INTO daily_work_reports (
report_date, worker_id, project_id, work_type_id, work_status_id,
work_hours, description, created_by
) VALUES (?, ?, 13, 1, 1, ?, ?, ?)
`, [
date,
workerId,
vacationHours,
`${vacationTypeInfo.type_name} 처리`,
createdBy
]);
// 근태 기록 업데이트
const attendanceData = {
record_date: date,
worker_id: workerId,
total_work_hours: totalHours,
work_attendance_type_id: attendanceTypes[0]?.id,
vacation_type_id: vacationTypeInfo.id,
is_overtime_approved: false,
created_by: createdBy
};
return await this.upsertAttendanceRecord(attendanceData);
}
// 초과근무 승인
static async approveOvertime(workerId, date, approvedBy) {
const db = await getDb();
const [result] = await db.execute(`
UPDATE daily_attendance_records
SET
overtime_approved = TRUE,
overtime_approved_by = ?,
overtime_approved_at = CURRENT_TIMESTAMP,
updated_by = ?,
updated_at = CURRENT_TIMESTAMP
WHERE worker_id = ? AND record_date = ?
`, [approvedBy, approvedBy, workerId, date]);
return result.affectedRows > 0;
}
// 근로 유형 목록 조회
static async getAttendanceTypes() {
const db = await getDb();
const [rows] = await db.execute(
'SELECT id, type_code, type_name, description, is_active, created_at, updated_at FROM work_attendance_types WHERE is_active = TRUE ORDER BY id'
);
return rows;
}
// 휴가 유형 목록 조회
static async getVacationTypes() {
const db = await getDb();
const [rows] = await db.execute(
'SELECT id, type_code, type_name, deduct_days, is_active, created_at, updated_at FROM vacation_types WHERE is_active = TRUE ORDER BY deduct_days DESC'
);
return rows;
}
// 작업자 휴가 잔여 조회
static async getWorkerVacationBalance(workerId, year = null) {
const db = await getDb();
const currentYear = year || new Date().getFullYear();
const [rows] = await db.execute(`
SELECT id, worker_id, year, total_annual_leave, used_annual_leave, notes, created_at, updated_at FROM worker_vacation_balance
WHERE worker_id = ? AND year = ?
`, [workerId, currentYear]);
if (rows.length === 0) {
// 기본 연차 생성 (15일)
await db.execute(`
INSERT INTO worker_vacation_balance (worker_id, year, total_annual_leave)
VALUES (?, ?, 15.0)
`, [workerId, currentYear]);
return {
worker_id: workerId,
year: currentYear,
total_annual_leave: 15.0,
used_annual_leave: 0,
remaining_annual_leave: 15.0
};
}
return rows[0];
}
// 월별 근태 통계
static async getMonthlyAttendanceStats(year, month, workerId = null) {
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 = `
SELECT
w.worker_id,
w.worker_name,
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.is_overtime_approved = 1 OR dar.total_work_hours > 8 THEN 1 END) as overtime_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.vacation_type_id = 2 THEN 1 END) as partial_days,
COUNT(CASE WHEN dar.attendance_type_id = 4 THEN 1 END) as incomplete_days,
COALESCE(SUM(dar.total_work_hours), 0) as total_work_hours,
COALESCE(AVG(dar.total_work_hours), 0) as avg_work_hours
FROM workers w
LEFT JOIN daily_attendance_records dar ON w.worker_id = dar.worker_id
AND YEAR(dar.record_date) = ? AND MONTH(dar.record_date) = ?
WHERE w.employment_status = 'employed'
`;
const params = [year, month];
if (workerId) {
query += ' AND w.worker_id = ?';
params.push(workerId);
}
query += ' GROUP BY w.worker_id, w.worker_name ORDER BY w.worker_name';
const [rows] = await db.execute(query, params);
return rows;
}
// 출근 체크 기록 생성 또는 업데이트
static async upsertCheckin(checkinData) {
const db = await getDb();
const { worker_id, record_date, is_present } = checkinData;
// 해당 날짜에 기록이 있는지 확인
const [existing] = await db.execute(
'SELECT id FROM daily_attendance_records WHERE worker_id = ? AND record_date = ?',
[worker_id, record_date]
);
if (existing.length > 0) {
// 업데이트
await db.execute(
'UPDATE daily_attendance_records SET is_present = ? WHERE id = ?',
[is_present, existing[0].id]
);
return existing[0].id;
} else {
// 새로 생성 (기본값으로)
const [result] = await db.execute(
`INSERT INTO daily_attendance_records
(worker_id, record_date, is_present, attendance_type_id, created_by)
VALUES (?, ?, ?, 1, 1)`,
[worker_id, record_date, is_present]
);
return result.insertId;
}
}
// 특정 날짜의 출근 체크 목록 조회 (휴가 정보 포함)
static async getCheckinList(date) {
const db = await getDb();
const query = `
SELECT
w.worker_id,
w.worker_name,
w.job_type,
w.employment_status,
COALESCE(dar.is_present, TRUE) as is_present,
dar.id as record_id,
vr.request_id as vacation_request_id,
vr.status as vacation_status,
vt.type_name as vacation_type_name,
vt.type_code as vacation_type_code,
vr.days_used as vacation_days
FROM workers w
LEFT JOIN daily_attendance_records dar
ON w.worker_id = dar.worker_id AND dar.record_date = ?
LEFT JOIN vacation_requests vr
ON w.worker_id = vr.worker_id
AND ? BETWEEN vr.start_date AND vr.end_date
AND vr.status = 'approved'
LEFT JOIN vacation_types vt ON vr.vacation_type_id = vt.id
WHERE w.employment_status = 'employed'
ORDER BY w.worker_name
`;
const [rows] = await db.execute(query, [date, date]);
return rows;
}
}
module.exports = AttendanceModel;

View File

@@ -0,0 +1,154 @@
const { getDb } = require('../dbPool');
/**
* 1. 여러 개의 이슈 보고서를 트랜잭션으로 생성합니다.
* @param {Array<object>} reports - 생성할 보고서 데이터 배열
* @returns {Promise<Array<number>>} - 삽입된 ID 배열
*/
const createMany = async (reports) => {
const db = await getDb();
const conn = await db.getConnection();
try {
await conn.beginTransaction();
const insertedIds = [];
const sql = `
INSERT INTO DailyIssueReports
(date, worker_id, project_id, start_time, end_time, issue_type_id)
VALUES (?, ?, ?, ?, ?, ?)
`;
for (const report of reports) {
const { date, worker_id, project_id, start_time, end_time, issue_type_id } = report;
const [result] = await conn.query(sql, [date, worker_id, project_id, start_time, end_time, issue_type_id]);
insertedIds.push(result.insertId);
}
await conn.commit();
return insertedIds;
} catch (err) {
await conn.rollback();
console.error('[Model] 여러 이슈 보고서 생성 중 오류:', err);
throw new Error('데이터베이스에 이슈 보고서를 생성하는 중 오류가 발생했습니다.');
} finally {
conn.release();
}
};
/**
* 2. 특정 날짜의 전체 이슈 목록 조회 (Promise 기반)
*/
const getAllByDate = async (date) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT
d.id, d.date, w.worker_name, p.project_name, d.start_time, d.end_time,
t.category, t.subcategory, d.description
FROM DailyIssueReports d
LEFT JOIN workers w ON d.worker_id = w.worker_id
LEFT JOIN projects p ON d.project_id = p.project_id
LEFT JOIN IssueTypes t ON d.issue_type_id = t.issue_type_id
WHERE d.date = ?
ORDER BY d.start_time ASC`,
[date]
);
return rows;
} catch (err) {
console.error(`[Model] ${date} 이슈 목록 조회 오류:`, err);
throw new Error('데이터베이스에서 이슈 목록을 조회하는 중 오류가 발생했습니다.');
}
};
/**
* 3. 단일 조회 (선택사항: 컨트롤러에서 사용 중)
*/
const getById = async (id, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(`SELECT id, date, worker_id, project_id, issue_type_id, description, created_at, start_time, end_time FROM DailyIssueReports WHERE id = ?`, [id]);
callback(null, rows[0]);
} catch (err) {
callback(err);
}
};
/**
* 4. 수정
*/
const update = async (id, data, callback) => {
try {
const db = await getDb();
const fields = [];
const values = [];
for (const key in data) {
fields.push(`${key} = ?`);
values.push(data[key]);
}
values.push(id); // 마지막에 id
const [result] = await db.query(
`UPDATE DailyIssueReports SET ${fields.join(', ')} WHERE id = ?`,
values
);
callback(null, result.affectedRows);
} catch (err) {
callback(err);
}
};
/**
* 5. 삭제 (Promise 기반)
*/
const remove = async (id) => {
try {
const db = await getDb();
const [result] = await db.query(`DELETE FROM DailyIssueReports WHERE id = ?`, [id]);
return result.affectedRows;
} catch (err) {
console.error(`[Model] 이슈(id: ${id}) 삭제 오류:`, err);
throw new Error('데이터베이스에서 이슈를 삭제하는 중 오류가 발생했습니다.');
}
};
// V1 함수들은 점진적으로 제거 예정
const create = async (report, callback) => {
try {
const db = await getDb();
const {
date,
worker_id,
project_id,
start_time,
end_time,
issue_type_id,
description = null // 선택값 처리
} = report;
const [result] = await db.query(
`INSERT INTO DailyIssueReports
(date, worker_id, project_id, start_time, end_time, issue_type_id, description)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[date, worker_id, project_id, start_time, end_time, issue_type_id, description]
);
callback(null, result.insertId);
} catch (err) {
callback(err);
}
};
module.exports = {
createMany, // 신규
getAllByDate,
remove,
// 레거시 호환성을 위해 V1 함수들 임시 유지
create: (report, callback) => createMany([report]).then(ids => callback(null, ids[0])).catch(err => callback(err)),
getById,
update,
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,120 @@
// models/departmentModel.js
const { getDb } = require('../dbPool');
const departmentModel = {
// 모든 부서 조회 (계층 구조 포함)
async getAll() {
const db = await getDb();
const [rows] = await db.query(`
SELECT d.*,
p.department_name as parent_name,
(SELECT COUNT(*) FROM workers w WHERE w.department_id = d.department_id AND w.status = 'active') as worker_count
FROM departments d
LEFT JOIN departments p ON d.parent_id = p.department_id
ORDER BY d.display_order, d.department_name
`);
return rows;
},
// 활성 부서만 조회
async getActive() {
const db = await getDb();
const [rows] = await db.query(`
SELECT d.*,
p.department_name as parent_name,
(SELECT COUNT(*) FROM workers w WHERE w.department_id = d.department_id AND w.status = 'active') as worker_count
FROM departments d
LEFT JOIN departments p ON d.parent_id = p.department_id
WHERE d.is_active = TRUE
ORDER BY d.display_order, d.department_name
`);
return rows;
},
// 부서 ID로 조회
async getById(departmentId) {
const db = await getDb();
const [rows] = await db.query(`
SELECT d.*,
p.department_name as parent_name
FROM departments d
LEFT JOIN departments p ON d.parent_id = p.department_id
WHERE d.department_id = ?
`, [departmentId]);
return rows[0];
},
// 부서 생성
async create(data) {
const db = await getDb();
const { department_name, parent_id, description, is_active, display_order } = data;
const [result] = await db.query(`
INSERT INTO departments (department_name, parent_id, description, is_active, display_order)
VALUES (?, ?, ?, ?, ?)
`, [department_name, parent_id || null, description || null, is_active !== false, display_order || 0]);
return result.insertId;
},
// 부서 수정
async update(departmentId, data) {
const db = await getDb();
const { department_name, parent_id, description, is_active, display_order } = data;
const [result] = await db.query(`
UPDATE departments
SET department_name = ?, parent_id = ?, description = ?, is_active = ?, display_order = ?
WHERE department_id = ?
`, [department_name, parent_id || null, description || null, is_active, display_order || 0, departmentId]);
return result.affectedRows > 0;
},
// 부서 삭제
async delete(departmentId) {
const db = await getDb();
// 하위 부서가 있는지 확인
const [children] = await db.query('SELECT COUNT(*) as count FROM departments WHERE parent_id = ?', [departmentId]);
if (children[0].count > 0) {
throw new Error('하위 부서가 있어 삭제할 수 없습니다.');
}
// 소속 작업자가 있는지 확인
const [workers] = await db.query('SELECT COUNT(*) as count FROM workers WHERE department_id = ?', [departmentId]);
if (workers[0].count > 0) {
throw new Error('소속 작업자가 있어 삭제할 수 없습니다. 먼저 작업자를 다른 부서로 이동하세요.');
}
const [result] = await db.query('DELETE FROM departments WHERE department_id = ?', [departmentId]);
return result.affectedRows > 0;
},
// 부서별 작업자 조회
async getWorkersByDepartment(departmentId) {
const db = await getDb();
const [rows] = await db.query(`
SELECT w.*, d.department_name, u.user_id, u.username
FROM workers w
LEFT JOIN departments d ON w.department_id = d.department_id
LEFT JOIN users u ON u.worker_id = w.worker_id
WHERE w.department_id = ?
ORDER BY w.worker_name
`, [departmentId]);
return rows;
},
// 작업자 부서 변경
async moveWorker(workerId, departmentId) {
const db = await getDb();
const [result] = await db.query(`
UPDATE workers SET department_id = ? WHERE worker_id = ?
`, [departmentId, workerId]);
return result.affectedRows > 0;
},
// 여러 작업자 부서 일괄 변경
async moveWorkers(workerIds, departmentId) {
const db = await getDb();
const [result] = await db.query(`
UPDATE workers SET department_id = ? WHERE worker_id IN (?)
`, [departmentId, workerIds]);
return result.affectedRows;
}
};
module.exports = departmentModel;

View File

@@ -0,0 +1,949 @@
// models/equipmentModel.js
const { getDb } = require('../dbPool');
const notificationModel = require('./notificationModel');
const EquipmentModel = {
// CREATE - 설비 생성
create: async (equipmentData, callback) => {
try {
const db = await getDb();
const query = `
INSERT INTO equipments (
equipment_code, equipment_name, equipment_type, model_name,
manufacturer, supplier, purchase_price, installation_date, serial_number, specifications,
status, notes, workplace_id, map_x_percent, map_y_percent,
map_width_percent, map_height_percent
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
const values = [
equipmentData.equipment_code,
equipmentData.equipment_name,
equipmentData.equipment_type || null,
equipmentData.model_name || null,
equipmentData.manufacturer || null,
equipmentData.supplier || null,
equipmentData.purchase_price || null,
equipmentData.installation_date || null,
equipmentData.serial_number || null,
equipmentData.specifications || null,
equipmentData.status || 'active',
equipmentData.notes || null,
equipmentData.workplace_id || null,
equipmentData.map_x_percent || null,
equipmentData.map_y_percent || null,
equipmentData.map_width_percent || null,
equipmentData.map_height_percent || null
];
const [result] = await db.query(query, values);
callback(null, {
equipment_id: result.insertId,
...equipmentData
});
} catch (error) {
callback(error);
}
},
// READ ALL - 모든 설비 조회 (필터링 옵션 포함)
getAll: async (filters, callback) => {
try {
const db = await getDb();
let query = `
SELECT
e.*,
w.workplace_name,
wc.category_name
FROM equipments e
LEFT JOIN workplaces w ON e.workplace_id = w.workplace_id
LEFT JOIN workplace_categories wc ON w.category_id = wc.category_id
WHERE 1=1
`;
const values = [];
// 필터링: 작업장 ID
if (filters.workplace_id) {
query += ' AND e.workplace_id = ?';
values.push(filters.workplace_id);
}
// 필터링: 설비 유형
if (filters.equipment_type) {
query += ' AND e.equipment_type = ?';
values.push(filters.equipment_type);
}
// 필터링: 상태
if (filters.status) {
query += ' AND e.status = ?';
values.push(filters.status);
}
// 필터링: 검색어 (설비명, 설비코드)
if (filters.search) {
query += ' AND (e.equipment_name LIKE ? OR e.equipment_code LIKE ?)';
const searchTerm = `%${filters.search}%`;
values.push(searchTerm, searchTerm);
}
query += ' ORDER BY e.equipment_code ASC';
const [rows] = await db.query(query, values);
callback(null, rows);
} catch (error) {
callback(error);
}
},
// READ ONE - 특정 설비 조회
getById: async (equipmentId, callback) => {
try {
const db = await getDb();
const query = `
SELECT
e.*,
w.workplace_name,
wc.category_name
FROM equipments e
LEFT JOIN workplaces w ON e.workplace_id = w.workplace_id
LEFT JOIN workplace_categories wc ON w.category_id = wc.category_id
WHERE e.equipment_id = ?
`;
const [rows] = await db.query(query, [equipmentId]);
callback(null, rows[0]);
} catch (error) {
callback(error);
}
},
// READ BY WORKPLACE - 특정 작업장의 설비 조회
getByWorkplace: async (workplaceId, callback) => {
try {
const db = await getDb();
const query = `
SELECT e.*
FROM equipments e
WHERE e.workplace_id = ?
ORDER BY e.equipment_code ASC
`;
const [rows] = await db.query(query, [workplaceId]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
// READ ACTIVE - 활성 설비만 조회
getActive: async (callback) => {
try {
const db = await getDb();
const query = `
SELECT
e.*,
w.workplace_name,
wc.category_name
FROM equipments e
LEFT JOIN workplaces w ON e.workplace_id = w.workplace_id
LEFT JOIN workplace_categories wc ON w.category_id = wc.category_id
WHERE e.status = 'active'
ORDER BY e.equipment_code ASC
`;
const [rows] = await db.query(query);
callback(null, rows);
} catch (error) {
callback(error);
}
},
// UPDATE - 설비 수정
update: async (equipmentId, equipmentData, callback) => {
try {
const db = await getDb();
const query = `
UPDATE equipments SET
equipment_code = ?,
equipment_name = ?,
equipment_type = ?,
model_name = ?,
manufacturer = ?,
supplier = ?,
purchase_price = ?,
installation_date = ?,
serial_number = ?,
specifications = ?,
status = ?,
notes = ?,
workplace_id = ?,
map_x_percent = ?,
map_y_percent = ?,
map_width_percent = ?,
map_height_percent = ?,
updated_at = NOW()
WHERE equipment_id = ?
`;
const values = [
equipmentData.equipment_code,
equipmentData.equipment_name,
equipmentData.equipment_type || null,
equipmentData.model_name || null,
equipmentData.manufacturer || null,
equipmentData.supplier || null,
equipmentData.purchase_price || null,
equipmentData.installation_date || null,
equipmentData.serial_number || null,
equipmentData.specifications || null,
equipmentData.status || 'active',
equipmentData.notes || null,
equipmentData.workplace_id || null,
equipmentData.map_x_percent || null,
equipmentData.map_y_percent || null,
equipmentData.map_width_percent || null,
equipmentData.map_height_percent || null,
equipmentId
];
const [result] = await db.query(query, values);
if (result.affectedRows === 0) {
return callback(new Error('Equipment not found'));
}
callback(null, { equipment_id: equipmentId, ...equipmentData });
} catch (error) {
callback(error);
}
},
// UPDATE MAP POSITION - 지도상 위치 업데이트 (선택적으로 workplace_id도 업데이트)
updateMapPosition: async (equipmentId, positionData, callback) => {
try {
const db = await getDb();
// workplace_id가 포함된 경우 함께 업데이트
const hasWorkplaceId = positionData.workplace_id !== undefined;
const query = hasWorkplaceId ? `
UPDATE equipments SET
workplace_id = ?,
map_x_percent = ?,
map_y_percent = ?,
map_width_percent = ?,
map_height_percent = ?,
updated_at = NOW()
WHERE equipment_id = ?
` : `
UPDATE equipments SET
map_x_percent = ?,
map_y_percent = ?,
map_width_percent = ?,
map_height_percent = ?,
updated_at = NOW()
WHERE equipment_id = ?
`;
const values = hasWorkplaceId ? [
positionData.workplace_id,
positionData.map_x_percent,
positionData.map_y_percent,
positionData.map_width_percent,
positionData.map_height_percent,
equipmentId
] : [
positionData.map_x_percent,
positionData.map_y_percent,
positionData.map_width_percent,
positionData.map_height_percent,
equipmentId
];
const [result] = await db.query(query, values);
if (result.affectedRows === 0) {
return callback(new Error('Equipment not found'));
}
callback(null, { equipment_id: equipmentId, ...positionData });
} catch (error) {
callback(error);
}
},
// DELETE - 설비 삭제
delete: async (equipmentId, callback) => {
try {
const db = await getDb();
const query = 'DELETE FROM equipments WHERE equipment_id = ?';
const [result] = await db.query(query, [equipmentId]);
if (result.affectedRows === 0) {
return callback(new Error('Equipment not found'));
}
callback(null, { equipment_id: equipmentId });
} catch (error) {
callback(error);
}
},
// CHECK DUPLICATE CODE - 설비 코드 중복 확인
checkDuplicateCode: async (equipmentCode, excludeEquipmentId, callback) => {
try {
const db = await getDb();
let query = 'SELECT equipment_id FROM equipments WHERE equipment_code = ?';
const values = [equipmentCode];
if (excludeEquipmentId) {
query += ' AND equipment_id != ?';
values.push(excludeEquipmentId);
}
const [rows] = await db.query(query, values);
callback(null, rows.length > 0);
} catch (error) {
callback(error);
}
},
// GET EQUIPMENT TYPES - 사용 중인 설비 유형 목록 조회
getEquipmentTypes: async (callback) => {
try {
const db = await getDb();
const query = `
SELECT DISTINCT equipment_type
FROM equipments
WHERE equipment_type IS NOT NULL
ORDER BY equipment_type ASC
`;
const [rows] = await db.query(query);
callback(null, rows.map(row => row.equipment_type));
} catch (error) {
callback(error);
}
},
// GET NEXT EQUIPMENT CODE - 다음 관리번호 자동 생성 (TKP-001 형식)
getNextEquipmentCode: async (prefix = 'TKP', callback) => {
try {
const db = await getDb();
// 해당 접두사로 시작하는 가장 큰 번호 찾기
const query = `
SELECT equipment_code
FROM equipments
WHERE equipment_code LIKE ?
ORDER BY equipment_code DESC
LIMIT 1
`;
const [rows] = await db.query(query, [`${prefix}-%`]);
let nextNumber = 1;
if (rows.length > 0) {
// TKP-001 형식에서 숫자 부분 추출
const lastCode = rows[0].equipment_code;
const match = lastCode.match(new RegExp(`^${prefix}-(\\d+)$`));
if (match) {
nextNumber = parseInt(match[1], 10) + 1;
}
}
// 3자리로 패딩 (001, 002, ...)
const nextCode = `${prefix}-${String(nextNumber).padStart(3, '0')}`;
callback(null, nextCode);
} catch (error) {
callback(error);
}
},
// ==========================================
// 설비 사진 관리
// ==========================================
// ADD PHOTO - 설비 사진 추가
addPhoto: async (equipmentId, photoData, callback) => {
try {
const db = await getDb();
const query = `
INSERT INTO equipment_photos (
equipment_id, photo_path, description, display_order, uploaded_by
) VALUES (?, ?, ?, ?, ?)
`;
const values = [
equipmentId,
photoData.photo_path,
photoData.description || null,
photoData.display_order || 0,
photoData.uploaded_by || null
];
const [result] = await db.query(query, values);
callback(null, {
photo_id: result.insertId,
equipment_id: equipmentId,
...photoData
});
} catch (error) {
callback(error);
}
},
// GET PHOTOS - 설비 사진 조회
getPhotos: async (equipmentId, callback) => {
try {
const db = await getDb();
const query = `
SELECT ep.*, u.name AS uploaded_by_name
FROM equipment_photos ep
LEFT JOIN users u ON ep.uploaded_by = u.user_id
WHERE ep.equipment_id = ?
ORDER BY ep.display_order ASC, ep.created_at ASC
`;
const [rows] = await db.query(query, [equipmentId]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
// DELETE PHOTO - 설비 사진 삭제
deletePhoto: async (photoId, callback) => {
try {
const db = await getDb();
// 먼저 사진 정보 조회 (파일 삭제용)
const [photo] = await db.query(
'SELECT photo_path FROM equipment_photos WHERE photo_id = ?',
[photoId]
);
const query = 'DELETE FROM equipment_photos WHERE photo_id = ?';
const [result] = await db.query(query, [photoId]);
if (result.affectedRows === 0) {
return callback(new Error('Photo not found'));
}
callback(null, { photo_id: photoId, photo_path: photo[0]?.photo_path });
} catch (error) {
callback(error);
}
},
// ==========================================
// 설비 임시 이동
// ==========================================
// MOVE TEMPORARILY - 설비 임시 이동
moveTemporarily: async (equipmentId, moveData, callback) => {
try {
const db = await getDb();
// 1. 설비 현재 위치 업데이트
const updateQuery = `
UPDATE equipments SET
current_workplace_id = ?,
current_map_x_percent = ?,
current_map_y_percent = ?,
current_map_width_percent = ?,
current_map_height_percent = ?,
is_temporarily_moved = TRUE,
moved_at = NOW(),
moved_by = ?,
updated_at = NOW()
WHERE equipment_id = ?
`;
const updateValues = [
moveData.target_workplace_id,
moveData.target_x_percent,
moveData.target_y_percent,
moveData.target_width_percent || null,
moveData.target_height_percent || null,
moveData.moved_by || null,
equipmentId
];
const [result] = await db.query(updateQuery, updateValues);
if (result.affectedRows === 0) {
return callback(new Error('Equipment not found'));
}
// 2. 이동 이력 기록
const logQuery = `
INSERT INTO equipment_move_logs (
equipment_id, move_type,
from_workplace_id, to_workplace_id,
from_x_percent, from_y_percent,
to_x_percent, to_y_percent,
reason, moved_by
) VALUES (?, 'temporary', ?, ?, ?, ?, ?, ?, ?, ?)
`;
await db.query(logQuery, [
equipmentId,
moveData.from_workplace_id || null,
moveData.target_workplace_id,
moveData.from_x_percent || null,
moveData.from_y_percent || null,
moveData.target_x_percent,
moveData.target_y_percent,
moveData.reason || null,
moveData.moved_by || null
]);
callback(null, { equipment_id: equipmentId, moved: true });
} catch (error) {
callback(error);
}
},
// RETURN TO ORIGINAL - 설비 원위치 복귀
returnToOriginal: async (equipmentId, userId, callback) => {
try {
const db = await getDb();
// 1. 현재 임시 위치 정보 조회
const [equipment] = await db.query(
'SELECT current_workplace_id, current_map_x_percent, current_map_y_percent FROM equipments WHERE equipment_id = ?',
[equipmentId]
);
if (!equipment[0]) {
return callback(new Error('Equipment not found'));
}
// 2. 임시 위치 필드 초기화
const updateQuery = `
UPDATE equipments SET
current_workplace_id = NULL,
current_map_x_percent = NULL,
current_map_y_percent = NULL,
current_map_width_percent = NULL,
current_map_height_percent = NULL,
is_temporarily_moved = FALSE,
moved_at = NULL,
moved_by = NULL,
updated_at = NOW()
WHERE equipment_id = ?
`;
await db.query(updateQuery, [equipmentId]);
// 3. 복귀 이력 기록
const logQuery = `
INSERT INTO equipment_move_logs (
equipment_id, move_type,
from_workplace_id, from_x_percent, from_y_percent,
reason, moved_by
) VALUES (?, 'return', ?, ?, ?, '원위치 복귀', ?)
`;
await db.query(logQuery, [
equipmentId,
equipment[0].current_workplace_id,
equipment[0].current_map_x_percent,
equipment[0].current_map_y_percent,
userId || null
]);
callback(null, { equipment_id: equipmentId, returned: true });
} catch (error) {
callback(error);
}
},
// GET TEMPORARILY MOVED - 임시 이동된 설비 목록
getTemporarilyMoved: async (callback) => {
try {
const db = await getDb();
const query = `
SELECT
e.*,
w_orig.workplace_name AS original_workplace_name,
w_curr.workplace_name AS current_workplace_name,
u.name AS moved_by_name
FROM equipments e
LEFT JOIN workplaces w_orig ON e.workplace_id = w_orig.workplace_id
LEFT JOIN workplaces w_curr ON e.current_workplace_id = w_curr.workplace_id
LEFT JOIN users u ON e.moved_by = u.user_id
WHERE e.is_temporarily_moved = TRUE
ORDER BY e.moved_at DESC
`;
const [rows] = await db.query(query);
callback(null, rows);
} catch (error) {
callback(error);
}
},
// GET MOVE LOGS - 설비 이동 이력 조회
getMoveLogs: async (equipmentId, callback) => {
try {
const db = await getDb();
const query = `
SELECT
eml.*,
w_from.workplace_name AS from_workplace_name,
w_to.workplace_name AS to_workplace_name,
u.name AS moved_by_name
FROM equipment_move_logs eml
LEFT JOIN workplaces w_from ON eml.from_workplace_id = w_from.workplace_id
LEFT JOIN workplaces w_to ON eml.to_workplace_id = w_to.workplace_id
LEFT JOIN users u ON eml.moved_by = u.user_id
WHERE eml.equipment_id = ?
ORDER BY eml.moved_at DESC
`;
const [rows] = await db.query(query, [equipmentId]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
// ==========================================
// 설비 외부 반출/반입
// ==========================================
// EXPORT EQUIPMENT - 설비 외부 반출
exportEquipment: async (exportData, callback) => {
try {
const db = await getDb();
// 1. 반출 로그 생성
const logQuery = `
INSERT INTO equipment_external_logs (
equipment_id, log_type, export_date, expected_return_date,
destination, reason, notes, exported_by
) VALUES (?, 'export', ?, ?, ?, ?, ?, ?)
`;
const logValues = [
exportData.equipment_id,
exportData.export_date || new Date().toISOString().slice(0, 10),
exportData.expected_return_date || null,
exportData.destination || null,
exportData.reason || null,
exportData.notes || null,
exportData.exported_by || null
];
const [logResult] = await db.query(logQuery, logValues);
// 2. 설비 상태 업데이트
const status = exportData.is_repair ? 'repair_external' : 'external';
await db.query(
'UPDATE equipments SET status = ?, updated_at = NOW() WHERE equipment_id = ?',
[status, exportData.equipment_id]
);
callback(null, {
log_id: logResult.insertId,
equipment_id: exportData.equipment_id,
exported: true
});
} catch (error) {
callback(error);
}
},
// RETURN EQUIPMENT - 설비 반입 (외부에서 복귀)
returnEquipment: async (logId, returnData, callback) => {
try {
const db = await getDb();
// 1. 반출 로그 조회
const [logs] = await db.query(
'SELECT equipment_id FROM equipment_external_logs WHERE log_id = ?',
[logId]
);
if (!logs[0]) {
return callback(new Error('Export log not found'));
}
const equipmentId = logs[0].equipment_id;
// 2. 반출 로그 업데이트
await db.query(
`UPDATE equipment_external_logs SET
actual_return_date = ?,
returned_by = ?,
notes = CONCAT(IFNULL(notes, ''), '\n반입: ', IFNULL(?, '')),
updated_at = NOW()
WHERE log_id = ?`,
[
returnData.return_date || new Date().toISOString().slice(0, 10),
returnData.returned_by || null,
returnData.notes || '',
logId
]
);
// 3. 설비 상태 복원
await db.query(
'UPDATE equipments SET status = ?, updated_at = NOW() WHERE equipment_id = ?',
[returnData.new_status || 'active', equipmentId]
);
callback(null, {
log_id: logId,
equipment_id: equipmentId,
returned: true
});
} catch (error) {
callback(error);
}
},
// GET EXTERNAL LOGS - 설비 외부 반출 이력 조회
getExternalLogs: async (equipmentId, callback) => {
try {
const db = await getDb();
const query = `
SELECT
eel.*,
u_exp.name AS exported_by_name,
u_ret.name AS returned_by_name
FROM equipment_external_logs eel
LEFT JOIN users u_exp ON eel.exported_by = u_exp.user_id
LEFT JOIN users u_ret ON eel.returned_by = u_ret.user_id
WHERE eel.equipment_id = ?
ORDER BY eel.created_at DESC
`;
const [rows] = await db.query(query, [equipmentId]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
// GET EXPORTED EQUIPMENTS - 현재 외부 반출 중인 설비 목록
getExportedEquipments: async (callback) => {
try {
const db = await getDb();
const query = `
SELECT
e.*,
w.workplace_name,
eel.export_date,
eel.expected_return_date,
eel.destination,
eel.reason,
u.name AS exported_by_name
FROM equipments e
INNER JOIN (
SELECT equipment_id, MAX(log_id) AS latest_log_id
FROM equipment_external_logs
WHERE actual_return_date IS NULL
GROUP BY equipment_id
) latest ON e.equipment_id = latest.equipment_id
INNER JOIN equipment_external_logs eel ON eel.log_id = latest.latest_log_id
LEFT JOIN workplaces w ON e.workplace_id = w.workplace_id
LEFT JOIN users u ON eel.exported_by = u.user_id
WHERE e.status IN ('external', 'repair_external')
ORDER BY eel.export_date DESC
`;
const [rows] = await db.query(query);
callback(null, rows);
} catch (error) {
callback(error);
}
},
// ==========================================
// 설비 수리 신청 (work_issue_reports 연동)
// ==========================================
// CREATE REPAIR REQUEST - 수리 신청 (신고 시스템 활용)
createRepairRequest: async (requestData, callback) => {
try {
const db = await getDb();
// 설비 수리 카테고리 ID 조회
const [categories] = await db.query(
"SELECT category_id FROM issue_report_categories WHERE category_name = '설비 수리' LIMIT 1"
);
if (!categories[0]) {
return callback(new Error('설비 수리 카테고리가 없습니다'));
}
const categoryId = categories[0].category_id;
// 항목 ID 조회 (지정된 항목이 없으면 첫번째 항목 사용)
let itemId = requestData.item_id;
if (!itemId) {
const [items] = await db.query(
'SELECT item_id FROM issue_report_items WHERE category_id = ? LIMIT 1',
[categoryId]
);
itemId = items[0]?.item_id;
}
// 사진 경로 분리 (최대 5장)
const photos = requestData.photo_paths || [];
// work_issue_reports에 삽입
const query = `
INSERT INTO work_issue_reports (
reporter_id, issue_category_id, issue_item_id,
workplace_id, equipment_id,
additional_description,
photo_path1, photo_path2, photo_path3, photo_path4, photo_path5,
status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'reported')
`;
const values = [
requestData.reported_by || null,
categoryId,
itemId,
requestData.workplace_id || null,
requestData.equipment_id,
requestData.description || null,
photos[0] || null,
photos[1] || null,
photos[2] || null,
photos[3] || null,
photos[4] || null
];
const [result] = await db.query(query, values);
// 설비 상태를 repair_needed로 업데이트
await db.query(
'UPDATE equipments SET status = ?, updated_at = NOW() WHERE equipment_id = ?',
['repair_needed', requestData.equipment_id]
);
// 알림 생성
try {
await notificationModel.createRepairNotification({
equipment_id: requestData.equipment_id,
equipment_name: requestData.equipment_name || '설비',
repair_type: requestData.repair_type || '일반 수리',
request_id: result.insertId,
created_by: requestData.reported_by
});
} catch (notifError) {
console.error('알림 생성 실패:', notifError);
// 알림 생성 실패해도 수리 신청은 성공으로 처리
}
callback(null, {
report_id: result.insertId,
equipment_id: requestData.equipment_id,
created: true
});
} catch (error) {
callback(error);
}
},
// GET REPAIR HISTORY - 설비 수리 이력 조회
getRepairHistory: async (equipmentId, callback) => {
try {
const db = await getDb();
const query = `
SELECT
wir.*,
irc.category_name,
iri.item_name,
u_rep.name AS reported_by_name,
u_res.name AS resolved_by_name,
w.workplace_name
FROM work_issue_reports wir
LEFT JOIN issue_report_categories irc ON wir.issue_category_id = irc.category_id
LEFT JOIN issue_report_items iri ON wir.issue_item_id = iri.item_id
LEFT JOIN users u_rep ON wir.reporter_id = u_rep.user_id
LEFT JOIN users u_res ON wir.resolved_by = u_res.user_id
LEFT JOIN workplaces w ON wir.workplace_id = w.workplace_id
WHERE wir.equipment_id = ?
ORDER BY wir.created_at DESC
`;
const [rows] = await db.query(query, [equipmentId]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
// GET REPAIR CATEGORIES - 설비 수리 항목 목록 조회
getRepairCategories: async (callback) => {
try {
const db = await getDb();
// 설비 수리 카테고리의 항목들 조회
const query = `
SELECT iri.item_id, iri.item_name, iri.description, iri.severity
FROM issue_report_items iri
INNER JOIN issue_report_categories irc ON iri.category_id = irc.category_id
WHERE irc.category_name = '설비 수리' AND iri.is_active = 1
ORDER BY iri.display_order ASC
`;
const [rows] = await db.query(query);
callback(null, rows);
} catch (error) {
callback(error);
}
},
// ADD REPAIR CATEGORY - 새 수리 항목 추가
addRepairCategory: async (itemName, callback) => {
try {
const db = await getDb();
// 설비 수리 카테고리 ID 조회
const [categories] = await db.query(
"SELECT category_id FROM issue_report_categories WHERE category_name = '설비 수리'"
);
if (categories.length === 0) {
return callback(new Error('설비 수리 카테고리가 없습니다.'));
}
const categoryId = categories[0].category_id;
// 중복 확인
const [existing] = await db.query(
'SELECT item_id FROM issue_report_items WHERE category_id = ? AND item_name = ?',
[categoryId, itemName]
);
if (existing.length > 0) {
// 이미 존재하면 해당 ID 반환
return callback(null, { item_id: existing[0].item_id, item_name: itemName, isNew: false });
}
// 다음 display_order 구하기
const [maxOrder] = await db.query(
'SELECT MAX(display_order) as max_order FROM issue_report_items WHERE category_id = ?',
[categoryId]
);
const nextOrder = (maxOrder[0].max_order || 0) + 1;
// 새 항목 추가
const [result] = await db.query(
`INSERT INTO issue_report_items (category_id, item_name, display_order, is_active)
VALUES (?, ?, ?, 1)`,
[categoryId, itemName, nextOrder]
);
callback(null, { item_id: result.insertId, item_name: itemName, isNew: true });
} catch (error) {
callback(error);
}
}
};
module.exports = EquipmentModel;

View File

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

View File

@@ -0,0 +1,183 @@
// models/monthlyStatusModel.js
// 월별 작업자 상태 집계 모델
const { getDb } = require('../dbPool');
class MonthlyStatusModel {
// 월별 일자별 요약 조회 (캘린더용)
static async getMonthlySummary(year, month) {
const db = await getDb();
try {
const [rows] = await db.execute(`
SELECT
date,
total_workers,
working_workers,
incomplete_workers,
partial_workers,
complete_workers,
overtime_workers,
vacation_workers,
error_workers,
overtime_warning_workers,
total_work_hours,
total_work_count,
total_error_count,
has_issues,
has_errors,
has_overtime_warning,
last_updated
FROM monthly_summary
WHERE year = ? AND month = ?
ORDER BY date ASC
`, [year, month]);
return rows;
} catch (error) {
console.error('월별 요약 조회 오류:', error);
throw error;
}
}
// 특정 날짜의 작업자별 상태 조회 (모달용)
// ✅ 리팩토링: 집계 테이블 대신 daily_work_reports에서 직접 조회 (중복 문제 완전 해결)
static async getDailyWorkerStatus(date) {
const db = await getDb();
try {
// daily_work_reports에서 직접 집계하여 조회 (중복 없음 보장)
const [rows] = await db.query(`
SELECT
w.worker_id,
w.worker_name,
w.job_type,
YEAR(?) as year,
MONTH(?) as month,
? as date,
COALESCE(SUM(dwr.work_hours), 0) as total_work_hours,
COALESCE(SUM(CASE WHEN dwr.project_id != 13 THEN dwr.work_hours ELSE 0 END), 0) as actual_work_hours,
COALESCE(SUM(CASE WHEN dwr.project_id = 13 THEN dwr.work_hours ELSE 0 END), 0) as vacation_hours,
COUNT(dwr.id) as total_work_count,
COUNT(CASE WHEN dwr.project_id != 13 AND dwr.work_status_id != 2 THEN 1 END) as regular_work_count,
COUNT(CASE WHEN dwr.work_status_id = 2 THEN 1 END) as error_work_count,
CASE
WHEN MAX(CASE WHEN dwr.work_status_id = 2 THEN 1 ELSE 0 END) = 1 THEN 'error'
WHEN SUM(dwr.work_hours) > 12 THEN 'overtime-warning'
WHEN SUM(dwr.work_hours) > 8 THEN 'overtime'
WHEN SUM(dwr.work_hours) = 8 THEN 'complete'
WHEN SUM(dwr.work_hours) > 0 THEN 'partial'
ELSE 'incomplete'
END as work_status,
MAX(CASE WHEN dwr.project_id = 13 THEN 1 ELSE 0 END) as has_vacation,
MAX(CASE WHEN dwr.work_status_id = 2 THEN 1 ELSE 0 END) as has_error,
CASE
WHEN SUM(dwr.work_hours) < 8 AND SUM(dwr.work_hours) > 0 THEN 1
ELSE 0
END as has_issues,
MAX(dwr.created_at) as last_updated
FROM workers w
LEFT JOIN daily_work_reports dwr ON w.worker_id = dwr.worker_id AND dwr.report_date = ?
WHERE w.status = 'active'
GROUP BY w.worker_id, w.worker_name, w.job_type
ORDER BY w.worker_name ASC
`, [date, date, date, date]);
return rows;
} catch (error) {
console.error('일별 작업자 상태 조회 오류:', error);
throw error;
}
}
// 월별 집계 데이터 강제 재계산 (관리용)
static async recalculateMonth(year, month) {
const db = await getDb();
try {
// 해당 월의 모든 날짜와 작업자 조합을 찾아서 재계산
const [workDates] = await db.execute(`
SELECT DISTINCT report_date, worker_id
FROM daily_work_reports
WHERE YEAR(report_date) = ? AND MONTH(report_date) = ?
`, [year, month]);
let updatedCount = 0;
for (const { report_date, worker_id } of workDates) {
await db.execute('CALL UpdateMonthlyWorkerStatus(?, ?)', [report_date, worker_id]);
updatedCount++;
}
console.log(`${year}${month}월 집계 재계산 완료: ${updatedCount}`);
return { success: true, updatedCount };
} catch (error) {
console.error('월별 집계 재계산 오류:', error);
throw error;
}
}
// 특정 날짜 집계 강제 업데이트
static async updateDateSummary(date, workerId = null) {
const db = await getDb();
try {
if (workerId) {
// 특정 작업자만 업데이트
await db.execute('CALL UpdateMonthlyWorkerStatus(?, ?)', [date, workerId]);
} else {
// 해당 날짜의 모든 작업자 업데이트
const [workers] = await db.execute(`
SELECT DISTINCT worker_id
FROM daily_work_reports
WHERE report_date = ?
`, [date]);
for (const { worker_id } of workers) {
await db.execute('CALL UpdateMonthlyWorkerStatus(?, ?)', [date, worker_id]);
}
}
return { success: true };
} catch (error) {
console.error('날짜별 집계 업데이트 오류:', error);
throw error;
}
}
// 집계 테이블 상태 확인
static async getStatusInfo() {
const db = await getDb();
try {
const [summaryCount] = await db.execute(`
SELECT
COUNT(*) as total_days,
MIN(date) as earliest_date,
MAX(date) as latest_date,
MAX(last_updated) as last_update
FROM monthly_summary
`);
const [workerStatusCount] = await db.execute(`
SELECT
COUNT(*) as total_records,
COUNT(DISTINCT worker_id) as unique_workers,
COUNT(DISTINCT date) as unique_dates,
MAX(last_updated) as last_update
FROM monthly_worker_status
`);
return {
summary: summaryCount[0],
workerStatus: workerStatusCount[0]
};
} catch (error) {
console.error('집계 테이블 상태 확인 오류:', error);
throw error;
}
}
}
module.exports = MonthlyStatusModel;

View File

@@ -0,0 +1,197 @@
// models/notificationModel.js
const { getDb } = require('../dbPool');
// 순환 참조를 피하기 위해 함수 내에서 require
async function getRecipientIds(notificationType) {
const db = await getDb();
const [rows] = await db.query(
`SELECT user_id FROM notification_recipients
WHERE notification_type = ? AND is_active = 1`,
[notificationType]
);
return rows.map(r => r.user_id);
}
const notificationModel = {
// 알림 생성
async create(notificationData) {
const db = await getDb();
const { user_id, type, title, message, link_url, reference_type, reference_id, created_by } = notificationData;
const [result] = await db.query(
`INSERT INTO notifications (user_id, type, title, message, link_url, reference_type, reference_id, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[user_id || null, type || 'system', title, message || null, link_url || null, reference_type || null, reference_id || null, created_by || null]
);
return result.insertId;
},
// 읽지 않은 알림 조회 (특정 사용자 또는 전체)
async getUnread(userId = null) {
const db = await getDb();
const [rows] = await db.query(
`SELECT * FROM notifications
WHERE is_read = 0
AND (user_id IS NULL OR user_id = ?)
ORDER BY created_at DESC
LIMIT 50`,
[userId || 0]
);
return rows;
},
// 전체 알림 조회 (페이징)
async getAll(userId = null, page = 1, limit = 20) {
const db = await getDb();
const offset = (page - 1) * limit;
const [rows] = await db.query(
`SELECT * FROM notifications
WHERE (user_id IS NULL OR user_id = ?)
ORDER BY created_at DESC
LIMIT ? OFFSET ?`,
[userId || 0, limit, offset]
);
const [[{ total }]] = await db.query(
`SELECT COUNT(*) as total FROM notifications
WHERE (user_id IS NULL OR user_id = ?)`,
[userId || 0]
);
return { notifications: rows, total, page, limit };
},
// 알림 읽음 처리
async markAsRead(notificationId) {
const db = await getDb();
const [result] = await db.query(
`UPDATE notifications SET is_read = 1, read_at = NOW() WHERE notification_id = ?`,
[notificationId]
);
return result.affectedRows > 0;
},
// 모든 알림 읽음 처리
async markAllAsRead(userId = null) {
const db = await getDb();
const [result] = await db.query(
`UPDATE notifications SET is_read = 1, read_at = NOW()
WHERE is_read = 0 AND (user_id IS NULL OR user_id = ?)`,
[userId || 0]
);
return result.affectedRows;
},
// 알림 삭제
async delete(notificationId) {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM notifications WHERE notification_id = ?`,
[notificationId]
);
return result.affectedRows > 0;
},
// 오래된 알림 삭제 (30일 이상)
async deleteOld(days = 30) {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM notifications WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)`,
[days]
);
return result.affectedRows;
},
// 읽지 않은 알림 개수
async getUnreadCount(userId = null) {
const db = await getDb();
const [[{ count }]] = await db.query(
`SELECT COUNT(*) as count FROM notifications
WHERE is_read = 0 AND (user_id IS NULL OR user_id = ?)`,
[userId || 0]
);
return count;
},
// 수리 신청 알림 생성 헬퍼 (지정된 수신자에게 전송)
async createRepairNotification(repairData) {
const { equipment_id, equipment_name, repair_type, request_id, created_by } = repairData;
// 수리 알림 수신자 목록 가져오기
const recipientIds = await getRecipientIds('repair');
if (recipientIds.length === 0) {
// 수신자가 지정되지 않은 경우 전체 알림 (user_id = null)
return await this.create({
type: 'repair',
title: `수리 신청: ${equipment_name || '설비'}`,
message: `${repair_type} 수리가 신청되었습니다.`,
link_url: `/pages/admin/repair-management.html`,
reference_type: 'work_issue_reports',
reference_id: request_id,
created_by
});
}
// 지정된 수신자 각각에게 알림 생성
const results = [];
for (const userId of recipientIds) {
const notificationId = await this.create({
user_id: userId,
type: 'repair',
title: `수리 신청: ${equipment_name || '설비'}`,
message: `${repair_type} 수리가 신청되었습니다.`,
link_url: `/pages/admin/repair-management.html`,
reference_type: 'work_issue_reports',
reference_id: request_id,
created_by
});
results.push(notificationId);
}
return results;
},
// 일반 알림 생성 (유형별 지정된 수신자에게 전송)
async createTypedNotification(notificationData) {
const { type, title, message, link_url, reference_type, reference_id, created_by } = notificationData;
// 해당 유형의 수신자 목록 가져오기
const recipientIds = await getRecipientIds(type);
if (recipientIds.length === 0) {
// 수신자가 지정되지 않은 경우 전체 알림
return await this.create({
type,
title,
message,
link_url,
reference_type,
reference_id,
created_by
});
}
// 지정된 수신자 각각에게 알림 생성
const results = [];
for (const userId of recipientIds) {
const notificationId = await this.create({
user_id: userId,
type,
title,
message,
link_url,
reference_type,
reference_id,
created_by
});
results.push(notificationId);
}
return results;
}
};
module.exports = notificationModel;

View File

@@ -0,0 +1,146 @@
// models/notificationRecipientModel.js
const { getDb } = require('../dbPool');
const NOTIFICATION_TYPES = {
repair: '설비 수리',
safety: '안전 신고',
nonconformity: '부적합 신고',
equipment: '설비 관련',
maintenance: '정기점검',
system: '시스템'
};
const notificationRecipientModel = {
// 알림 유형 목록 가져오기
getTypes() {
return NOTIFICATION_TYPES;
},
// 유형별 수신자 목록 조회
async getByType(notificationType) {
const db = await getDb();
const [rows] = await db.query(
`SELECT nr.*, u.username, u.name as user_name, r.name as role
FROM notification_recipients nr
JOIN users u ON nr.user_id = u.user_id
LEFT JOIN roles r ON u.role_id = r.id
WHERE nr.notification_type = ? AND nr.is_active = 1
ORDER BY u.name`,
[notificationType]
);
return rows;
},
// 전체 수신자 목록 조회 (유형별 그룹화)
async getAll() {
const db = await getDb();
const [rows] = await db.query(
`SELECT nr.*, u.username, u.name as user_name, r.name as role
FROM notification_recipients nr
JOIN users u ON nr.user_id = u.user_id
LEFT JOIN roles r ON u.role_id = r.id
WHERE nr.is_active = 1
ORDER BY nr.notification_type, u.name`
);
// 유형별로 그룹화
const grouped = {};
for (const type in NOTIFICATION_TYPES) {
grouped[type] = {
label: NOTIFICATION_TYPES[type],
recipients: []
};
}
rows.forEach(row => {
if (grouped[row.notification_type]) {
grouped[row.notification_type].recipients.push(row);
}
});
return grouped;
},
// 수신자 추가
async add(notificationType, userId, createdBy = null) {
const db = await getDb();
const [result] = await db.query(
`INSERT INTO notification_recipients (notification_type, user_id, created_by)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE is_active = 1`,
[notificationType, userId, createdBy]
);
return result.insertId || result.affectedRows > 0;
},
// 수신자 제거 (soft delete)
async remove(notificationType, userId) {
const db = await getDb();
const [result] = await db.query(
`UPDATE notification_recipients SET is_active = 0
WHERE notification_type = ? AND user_id = ?`,
[notificationType, userId]
);
return result.affectedRows > 0;
},
// 수신자 완전 삭제
async delete(notificationType, userId) {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM notification_recipients
WHERE notification_type = ? AND user_id = ?`,
[notificationType, userId]
);
return result.affectedRows > 0;
},
// 유형별 수신자 user_id 목록만 가져오기 (알림 생성용)
async getRecipientIds(notificationType) {
const db = await getDb();
const [rows] = await db.query(
`SELECT user_id FROM notification_recipients
WHERE notification_type = ? AND is_active = 1`,
[notificationType]
);
return rows.map(r => r.user_id);
},
// 사용자가 특정 유형의 수신자인지 확인
async isRecipient(notificationType, userId) {
const db = await getDb();
const [[row]] = await db.query(
`SELECT 1 FROM notification_recipients
WHERE notification_type = ? AND user_id = ? AND is_active = 1`,
[notificationType, userId]
);
return !!row;
},
// 유형별 수신자 일괄 설정
async setRecipients(notificationType, userIds, createdBy = null) {
const db = await getDb();
// 기존 수신자 비활성화
await db.query(
`UPDATE notification_recipients SET is_active = 0
WHERE notification_type = ?`,
[notificationType]
);
// 새 수신자 추가
if (userIds && userIds.length > 0) {
const values = userIds.map(userId => [notificationType, userId, createdBy]);
await db.query(
`INSERT INTO notification_recipients (notification_type, user_id, created_by)
VALUES ?
ON DUPLICATE KEY UPDATE is_active = 1`,
[values]
);
}
return true;
}
};
module.exports = notificationRecipientModel;

View File

@@ -0,0 +1,160 @@
// models/pageAccessModel.js
const db = require('../db/connection');
const PageAccessModel = {
// 사용자의 페이지 권한 조회
getUserPageAccess: (userId, callback) => {
const sql = `
SELECT
p.id,
p.page_key,
p.page_name,
p.page_path,
p.category,
p.is_admin_only,
COALESCE(upa.can_access, 0) as can_access,
upa.granted_at,
upa.granted_by,
granter.username as granted_by_username
FROM pages p
LEFT JOIN user_page_access upa ON p.id = upa.page_id AND upa.user_id = ?
LEFT JOIN users granter ON upa.granted_by = granter.user_id
WHERE p.is_admin_only = 0
ORDER BY p.category, p.display_order
`;
db.query(sql, [userId], callback);
},
// 모든 페이지 목록 조회
getAllPages: (callback) => {
const sql = `
SELECT
id,
page_key,
page_name,
page_path,
category,
description,
is_admin_only,
display_order
FROM pages
WHERE is_admin_only = 0
ORDER BY category, display_order
`;
db.query(sql, callback);
},
// 페이지 권한 부여
grantPageAccess: (userId, pageId, grantedBy, callback) => {
const sql = `
INSERT INTO user_page_access (user_id, page_id, can_access, granted_by, granted_at)
VALUES (?, ?, 1, ?, NOW())
ON DUPLICATE KEY UPDATE
can_access = 1,
granted_by = ?,
granted_at = NOW()
`;
db.query(sql, [userId, pageId, grantedBy, grantedBy], callback);
},
// 페이지 권한 회수
revokePageAccess: (userId, pageId, callback) => {
const sql = `
DELETE FROM user_page_access
WHERE user_id = ? AND page_id = ?
`;
db.query(sql, [userId, pageId], callback);
},
// 여러 페이지 권한 일괄 설정
setUserPageAccess: (userId, pageIds, grantedBy, callback) => {
db.beginTransaction((err) => {
if (err) return callback(err);
// 기존 권한 모두 삭제
const deleteSql = 'DELETE FROM user_page_access WHERE user_id = ?';
db.query(deleteSql, [userId], (err) => {
if (err) {
return db.rollback(() => callback(err));
}
// 새 권한이 없으면 커밋하고 종료
if (!pageIds || pageIds.length === 0) {
return db.commit((err) => {
if (err) return db.rollback(() => callback(err));
callback(null, { affectedRows: 0 });
});
}
// 새 권한 추가
const values = pageIds.map(pageId => [userId, pageId, 1, grantedBy]);
const insertSql = `
INSERT INTO user_page_access (user_id, page_id, can_access, granted_by, granted_at)
VALUES ?
`;
db.query(insertSql, [values], (err, result) => {
if (err) {
return db.rollback(() => callback(err));
}
db.commit((err) => {
if (err) return db.rollback(() => callback(err));
callback(null, result);
});
});
});
});
},
// 특정 페이지 접근 권한 확인
checkPageAccess: (userId, pageKey, callback) => {
const sql = `
SELECT
COALESCE(upa.can_access, 0) as can_access,
p.is_admin_only
FROM pages p
LEFT JOIN user_page_access upa ON p.id = upa.page_id AND upa.user_id = ?
WHERE p.page_key = ?
`;
db.query(sql, [userId, pageKey], (err, results) => {
if (err) return callback(err);
if (results.length === 0) return callback(null, { can_access: false });
callback(null, results[0]);
});
},
// 계정이 있는 작업자 목록 조회 (권한 관리용)
getUsersWithAccounts: (callback) => {
const sql = `
SELECT
u.user_id,
u.username,
u.name,
u.role_id,
r.name as role_name,
u.worker_id,
w.worker_name,
w.job_type,
COUNT(upa.page_id) as granted_pages_count
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 user_page_access upa ON u.user_id = upa.user_id AND upa.can_access = 1
WHERE u.is_active = 1
AND u.role_id IN (4, 5)
GROUP BY u.user_id
ORDER BY w.worker_name, u.username
`;
db.query(sql, callback);
}
};
module.exports = PageAccessModel;

View File

@@ -0,0 +1,358 @@
// patrolModel.js
// 일일순회점검 시스템 모델
const { getDb } = require('../dbPool');
const PatrolModel = {
// ==================== 순회점검 세션 ====================
// 세션 생성 또는 조회
getOrCreateSession: async (patrolDate, patrolTime, categoryId, inspectorId) => {
const db = await getDb();
// 기존 세션 확인
const [existingRows] = await db.query(`
SELECT session_id, status, started_at, completed_at
FROM daily_patrol_sessions
WHERE patrol_date = ? AND patrol_time = ? AND category_id = ?
`, [patrolDate, patrolTime, categoryId]);
if (existingRows.length > 0) {
return existingRows[0];
}
// 새 세션 생성
const [result] = await db.query(`
INSERT INTO daily_patrol_sessions (patrol_date, patrol_time, category_id, inspector_id, started_at)
VALUES (?, ?, ?, ?, CURTIME())
`, [patrolDate, patrolTime, categoryId, inspectorId]);
return {
session_id: result.insertId,
status: 'in_progress',
started_at: new Date().toTimeString().slice(0, 8)
};
},
// 세션 조회
getSession: async (sessionId) => {
const db = await getDb();
const [rows] = await db.query(`
SELECT s.*, u.name AS inspector_name, wc.category_name
FROM daily_patrol_sessions s
LEFT JOIN users u ON s.inspector_id = u.user_id
LEFT JOIN workplace_categories wc ON s.category_id = wc.category_id
WHERE s.session_id = ?
`, [sessionId]);
return rows[0] || null;
},
// 세션 목록 조회
getSessions: async (filters = {}) => {
const db = await getDb();
let query = `
SELECT s.*, u.name AS inspector_name, wc.category_name,
(SELECT COUNT(*) FROM patrol_check_records WHERE session_id = s.session_id AND is_checked = 1) AS checked_count,
(SELECT COUNT(*) FROM patrol_check_records WHERE session_id = s.session_id) AS total_count
FROM daily_patrol_sessions s
LEFT JOIN users u ON s.inspector_id = u.user_id
LEFT JOIN workplace_categories wc ON s.category_id = wc.category_id
WHERE 1=1
`;
const params = [];
if (filters.patrol_date) {
query += ' AND s.patrol_date = ?';
params.push(filters.patrol_date);
}
if (filters.patrol_time) {
query += ' AND s.patrol_time = ?';
params.push(filters.patrol_time);
}
if (filters.category_id) {
query += ' AND s.category_id = ?';
params.push(filters.category_id);
}
if (filters.status) {
query += ' AND s.status = ?';
params.push(filters.status);
}
query += ' ORDER BY s.patrol_date DESC, s.patrol_time DESC';
if (filters.limit) {
query += ' LIMIT ?';
params.push(parseInt(filters.limit));
}
const [rows] = await db.query(query, params);
return rows;
},
// 세션 완료 처리
completeSession: async (sessionId) => {
const db = await getDb();
await db.query(`
UPDATE daily_patrol_sessions
SET status = 'completed', completed_at = CURTIME(), updated_at = NOW()
WHERE session_id = ?
`, [sessionId]);
return true;
},
// 세션 메모 업데이트
updateSessionNotes: async (sessionId, notes) => {
const db = await getDb();
await db.query(`
UPDATE daily_patrol_sessions
SET notes = ?, updated_at = NOW()
WHERE session_id = ?
`, [notes, sessionId]);
return true;
},
// ==================== 체크리스트 항목 ====================
// 체크리스트 항목 조회 (공장/작업장별 필터링)
getChecklistItems: async (categoryId = null, workplaceId = null) => {
const db = await getDb();
let query = `
SELECT *
FROM patrol_checklist_items
WHERE is_active = 1
AND (workplace_id IS NULL OR workplace_id = ?)
AND (category_id IS NULL OR category_id = ?)
ORDER BY check_category, display_order, check_item
`;
const [rows] = await db.query(query, [workplaceId, categoryId]);
return rows;
},
// 체크리스트 항목 CRUD
createChecklistItem: async (data) => {
const db = await getDb();
const [result] = await db.query(`
INSERT INTO patrol_checklist_items (workplace_id, category_id, check_category, check_item, description, display_order, is_required)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, [data.workplace_id, data.category_id, data.check_category, data.check_item, data.description, data.display_order || 0, data.is_required !== false]);
return result.insertId;
},
updateChecklistItem: async (itemId, data) => {
const db = await getDb();
const fields = [];
const params = [];
['workplace_id', 'category_id', 'check_category', 'check_item', 'description', 'display_order', 'is_required', 'is_active'].forEach(key => {
if (data[key] !== undefined) {
fields.push(`${key} = ?`);
params.push(data[key]);
}
});
if (fields.length === 0) return false;
params.push(itemId);
await db.query(`UPDATE patrol_checklist_items SET ${fields.join(', ')}, updated_at = NOW() WHERE item_id = ?`, params);
return true;
},
deleteChecklistItem: async (itemId) => {
const db = await getDb();
await db.query('UPDATE patrol_checklist_items SET is_active = 0, updated_at = NOW() WHERE item_id = ?', [itemId]);
return true;
},
// ==================== 체크 기록 ====================
// 작업장별 체크 기록 조회
getCheckRecords: async (sessionId, workplaceId = null) => {
const db = await getDb();
let query = `
SELECT r.*, ci.check_category, ci.check_item, ci.is_required
FROM patrol_check_records r
JOIN patrol_checklist_items ci ON r.check_item_id = ci.item_id
WHERE r.session_id = ?
`;
const params = [sessionId];
if (workplaceId) {
query += ' AND r.workplace_id = ?';
params.push(workplaceId);
}
query += ' ORDER BY ci.check_category, ci.display_order';
const [rows] = await db.query(query, params);
return rows;
},
// 체크 기록 저장 (upsert)
saveCheckRecord: async (sessionId, workplaceId, checkItemId, isChecked, checkResult = null, note = null) => {
const db = await getDb();
await db.query(`
INSERT INTO patrol_check_records (session_id, workplace_id, check_item_id, is_checked, check_result, note, checked_at)
VALUES (?, ?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
is_checked = VALUES(is_checked),
check_result = VALUES(check_result),
note = VALUES(note),
checked_at = NOW()
`, [sessionId, workplaceId, checkItemId, isChecked, checkResult, note]);
return true;
},
// 여러 체크 기록 일괄 저장
saveCheckRecords: async (sessionId, workplaceId, records) => {
const db = await getDb();
for (const record of records) {
await db.query(`
INSERT INTO patrol_check_records (session_id, workplace_id, check_item_id, is_checked, check_result, note, checked_at)
VALUES (?, ?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
is_checked = VALUES(is_checked),
check_result = VALUES(check_result),
note = VALUES(note),
checked_at = NOW()
`, [sessionId, workplaceId, record.check_item_id, record.is_checked, record.check_result, record.note]);
}
return true;
},
// ==================== 작업장 물품 현황 ====================
// 작업장 물품 조회
getWorkplaceItems: async (workplaceId, activeOnly = true) => {
const db = await getDb();
let query = `
SELECT wi.*, u.name AS created_by_name, it.type_name, it.icon, it.color
FROM workplace_items wi
LEFT JOIN users u ON wi.created_by = u.user_id
LEFT JOIN item_types it ON wi.item_type = it.type_code
WHERE wi.workplace_id = ?
`;
if (activeOnly) {
query += ' AND wi.is_active = 1';
}
query += ' ORDER BY wi.created_at DESC';
const [rows] = await db.query(query, [workplaceId]);
return rows;
},
// 물품 추가
createWorkplaceItem: async (data) => {
const db = await getDb();
const [result] = await db.query(`
INSERT INTO workplace_items
(workplace_id, patrol_session_id, project_id, item_type, item_name, quantity, x_percent, y_percent, width_percent, height_percent, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
data.workplace_id,
data.patrol_session_id,
data.project_id,
data.item_type,
data.item_name,
data.quantity || 1,
data.x_percent,
data.y_percent,
data.width_percent,
data.height_percent,
data.created_by
]);
return result.insertId;
},
// 물품 수정
updateWorkplaceItem: async (itemId, data, userId) => {
const db = await getDb();
const fields = [];
const params = [];
['item_type', 'item_name', 'quantity', 'x_percent', 'y_percent', 'width_percent', 'height_percent', 'is_active', 'project_id'].forEach(key => {
if (data[key] !== undefined) {
fields.push(`${key} = ?`);
params.push(data[key]);
}
});
if (fields.length === 0) return false;
fields.push('updated_by = ?', 'updated_at = NOW()');
params.push(userId, itemId);
await db.query(`UPDATE workplace_items SET ${fields.join(', ')} WHERE item_id = ?`, params);
return true;
},
// 물품 삭제 (비활성화)
deleteWorkplaceItem: async (itemId, userId) => {
const db = await getDb();
await db.query('UPDATE workplace_items SET is_active = 0, updated_by = ?, updated_at = NOW() WHERE item_id = ?', [userId, itemId]);
return true;
},
// 물품 영구 삭제
hardDeleteWorkplaceItem: async (itemId) => {
const db = await getDb();
await db.query('DELETE FROM workplace_items WHERE item_id = ?', [itemId]);
return true;
},
// ==================== 물품 유형 ====================
// 물품 유형 목록 조회
getItemTypes: async () => {
const db = await getDb();
const [rows] = await db.query('SELECT * FROM item_types WHERE is_active = 1 ORDER BY display_order');
return rows;
},
// ==================== 대시보드/통계 ====================
// 오늘 순회점검 현황
getTodayPatrolStatus: async (categoryId = null) => {
const db = await getDb();
const today = new Date().toISOString().slice(0, 10);
let query = `
SELECT s.session_id, s.patrol_time, s.status, s.inspector_id, u.name AS inspector_name,
s.started_at, s.completed_at,
(SELECT COUNT(*) FROM patrol_check_records WHERE session_id = s.session_id AND is_checked = 1) AS checked_count,
(SELECT COUNT(*) FROM patrol_check_records WHERE session_id = s.session_id) AS total_count
FROM daily_patrol_sessions s
LEFT JOIN users u ON s.inspector_id = u.user_id
WHERE s.patrol_date = ?
`;
const params = [today];
if (categoryId) {
query += ' AND s.category_id = ?';
params.push(categoryId);
}
query += ' ORDER BY s.patrol_time';
const [rows] = await db.query(query, params);
return rows;
},
// 작업장별 점검 현황 (세션 기준)
getWorkplaceCheckStatus: async (sessionId) => {
const db = await getDb();
const [rows] = await db.query(`
SELECT w.workplace_id, w.workplace_name,
COUNT(DISTINCT r.check_item_id) AS checked_count,
(SELECT COUNT(*) FROM patrol_checklist_items WHERE is_active = 1) AS total_items,
MAX(r.checked_at) AS last_check_time
FROM workplaces w
LEFT JOIN patrol_check_records r ON w.workplace_id = r.workplace_id AND r.session_id = ?
WHERE w.is_active = 1
GROUP BY w.workplace_id
ORDER BY w.workplace_name
`, [sessionId]);
return rows;
}
};
module.exports = PatrolModel;

View File

@@ -0,0 +1,96 @@
const { getDb } = require('../dbPool');
const create = async (project) => {
const db = await getDb();
const {
job_no, project_name,
contract_date, due_date,
delivery_method, site, pm,
is_active = true,
project_status = 'active',
completed_date = null
} = project;
const [result] = await db.query(
`INSERT INTO projects
(job_no, project_name, contract_date, due_date, delivery_method, site, pm, is_active, project_status, completed_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[job_no, project_name, contract_date, due_date, delivery_method, site, pm, is_active, project_status, completed_date]
);
return result.insertId;
};
const getAll = async () => {
const db = await getDb();
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`
);
return rows;
};
// 활성 프로젝트만 조회 (작업보고서용)
const getActiveProjects = async () => {
const db = await getDb();
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 is_active = TRUE
ORDER BY project_name ASC`
);
return rows;
};
const getById = async (project_id) => {
const db = await getDb();
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 = ?`,
[project_id]
);
return rows[0];
};
const update = async (project) => {
const db = await getDb();
const {
project_id, job_no, project_name,
contract_date, due_date,
delivery_method, site, pm,
is_active, project_status, completed_date
} = project;
const [result] = await db.query(
`UPDATE projects
SET job_no = ?,
project_name = ?,
contract_date = ?,
due_date = ?,
delivery_method= ?,
site = ?,
pm = ?,
is_active = ?,
project_status = ?,
completed_date = ?
WHERE project_id = ?`,
[job_no, project_name, contract_date, due_date, delivery_method, site, pm, is_active, project_status, completed_date, project_id]
);
return result.affectedRows;
};
const remove = async (project_id) => {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM projects WHERE project_id = ?`,
[project_id]
);
return result.affectedRows;
};
module.exports = {
create,
getAll,
getActiveProjects,
getById,
update,
remove
};

View File

@@ -0,0 +1,43 @@
const { getDb } = require('../dbPool');
class RoleModel {
/**
* 모든 역할 목록을 조회합니다.
* @returns {Promise<Array>} 역할 목록
*/
static async findAll() {
const db = await getDb();
const [rows] = await db.query('SELECT id, name, description FROM roles ORDER BY id');
return rows;
}
/**
* ID로 특정 역할을 조회합니다.
* @param {number} id - 역할 ID
* @returns {Promise<Object>} 역할 객체
*/
static async findById(id) {
const db = await getDb();
const [rows] = await db.query('SELECT id, name, description FROM roles WHERE id = ?', [id]);
return rows[0];
}
/**
* 역할에 속한 모든 권한을 조회합니다.
* @param {number} roleId - 역할 ID
* @returns {Promise<Array>} 권한 이름 목록
*/
static async findPermissionsByRoleId(roleId) {
const db = await getDb();
const [rows] = await db.query(
`SELECT p.name
FROM permissions p
JOIN role_permissions rp ON p.id = rp.permission_id
WHERE rp.role_id = ?`,
[roleId]
);
return rows.map(p => p.name);
}
}
module.exports = RoleModel;

View File

@@ -0,0 +1,142 @@
/**
* 작업 모델
*
* @author TK-FB-Project
* @since 2026-01-26
*/
const { getDb } = require('../dbPool');
// ==================== 작업 CRUD ====================
/**
* 작업 생성
*/
const createTask = async (taskData) => {
const db = await getDb();
const { work_type_id, task_name, description } = taskData;
const [result] = await db.query(
`INSERT INTO tasks (work_type_id, task_name, description, is_active)
VALUES (?, ?, ?, 1)`,
[work_type_id || null, task_name, description || null]
);
return result.insertId;
};
/**
* 전체 작업 목록 조회 (공정 정보 포함)
*/
const getAllTasks = async () => {
const db = await getDb();
const [rows] = await db.query(
`SELECT t.task_id, t.work_type_id, t.task_name, t.description, t.is_active,
t.created_at, t.updated_at,
wt.name as work_type_name, wt.category
FROM tasks t
LEFT JOIN work_types wt ON t.work_type_id = wt.id
ORDER BY wt.category ASC, t.task_id DESC`
);
return rows;
};
/**
* 활성 작업만 조회
*/
const getActiveTasks = async () => {
const db = await getDb();
const [rows] = await db.query(
`SELECT t.task_id, t.work_type_id, t.task_name, t.description,
wt.name as work_type_name, wt.category
FROM tasks t
LEFT JOIN work_types wt ON t.work_type_id = wt.id
WHERE t.is_active = 1
ORDER BY wt.category ASC, t.task_name ASC`
);
return rows;
};
/**
* 공정별 작업 목록 조회 (활성 작업만)
*/
const getTasksByWorkType = async (workTypeId) => {
const db = await getDb();
const [rows] = await db.query(
`SELECT t.task_id, t.work_type_id, t.task_name, t.description, t.is_active,
t.created_at, t.updated_at,
wt.name as work_type_name, wt.category
FROM tasks t
LEFT JOIN work_types wt ON t.work_type_id = wt.id
WHERE t.work_type_id = ? AND t.is_active = 1
ORDER BY t.task_name ASC`,
[workTypeId]
);
return rows;
};
/**
* 단일 작업 조회
*/
const getTaskById = async (taskId) => {
const db = await getDb();
const [rows] = await db.query(
`SELECT t.task_id, t.work_type_id, t.task_name, t.description, t.is_active,
t.created_at, t.updated_at,
wt.name as work_type_name, wt.category
FROM tasks t
LEFT JOIN work_types wt ON t.work_type_id = wt.id
WHERE t.task_id = ?`,
[taskId]
);
return rows[0] || null;
};
/**
* 작업 수정
*/
const updateTask = async (taskId, taskData) => {
const db = await getDb();
const { work_type_id, task_name, description, is_active } = taskData;
const [result] = await db.query(
`UPDATE tasks
SET work_type_id = ?,
task_name = ?,
description = ?,
is_active = ?,
updated_at = NOW()
WHERE task_id = ?`,
[
work_type_id || null,
task_name,
description || null,
is_active !== undefined ? is_active : 1,
taskId
]
);
return result;
};
/**
* 작업 삭제
*/
const deleteTask = async (taskId) => {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM tasks WHERE task_id = ?`,
[taskId]
);
return result;
};
module.exports = {
createTask,
getAllTasks,
getActiveTasks,
getTasksByWorkType,
getTaskById,
updateTask,
deleteTask
};

View File

@@ -0,0 +1,994 @@
// models/tbmModel.js - TBM 시스템 모델
const { getDb } = require('../dbPool');
const TbmModel = {
// ==================== TBM 세션 관련 ====================
/**
* TBM 세션 생성
*/
createSession: async (sessionData, callback) => {
try {
const db = await getDb();
const sql = `
INSERT INTO tbm_sessions
(session_date, leader_id, project_id, work_type_id, task_id, work_location, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?)
`;
const values = [
sessionData.session_date,
sessionData.leader_id,
sessionData.project_id || null,
sessionData.work_type_id || null,
sessionData.task_id || null,
sessionData.work_location || null,
sessionData.created_by
];
const [result] = await db.query(sql, values);
callback(null, result);
} catch (err) {
callback(err);
}
},
/**
* 특정 날짜의 TBM 세션 조회
*/
getSessionsByDate: async (date, callback) => {
try {
const db = await getDb();
const sql = `
SELECT
s.*,
w.worker_name as leader_name,
w.job_type as leader_job_type,
u.username as created_by_username,
u.name as created_by_name,
COUNT(DISTINCT ta.worker_id) as team_member_count,
-- 첫 번째 팀원의 작업 정보 가져오기
first_ta.project_id,
first_ta.work_type_id,
first_ta.task_id,
first_ta.workplace_id,
first_p.project_name,
first_wt.name as work_type_name,
first_t.task_name,
first_wp.workplace_name as work_location
FROM tbm_sessions s
LEFT JOIN workers w ON s.leader_id = w.worker_id
LEFT JOIN users u ON s.created_by = u.user_id
LEFT JOIN tbm_team_assignments ta ON s.session_id = ta.session_id
-- 첫 번째 팀원 정보 (가장 먼저 등록된 작업)
LEFT JOIN (
SELECT * FROM tbm_team_assignments
WHERE (session_id, assignment_id) IN (
SELECT session_id, MIN(assignment_id)
FROM tbm_team_assignments
GROUP BY session_id
)
) first_ta ON s.session_id = first_ta.session_id
LEFT JOIN projects first_p ON first_ta.project_id = first_p.project_id
LEFT JOIN work_types first_wt ON first_ta.work_type_id = first_wt.id
LEFT JOIN tasks first_t ON first_ta.task_id = first_t.task_id
LEFT JOIN workplaces first_wp ON first_ta.workplace_id = first_wp.workplace_id
WHERE s.session_date = ?
GROUP BY s.session_id
ORDER BY s.session_id DESC
`;
const [rows] = await db.query(sql, [date]);
callback(null, rows);
} catch (err) {
callback(err);
}
},
/**
* TBM 세션 상세 조회
*/
getSessionById: async (sessionId, callback) => {
try {
const db = await getDb();
const sql = `
SELECT
s.*,
w.worker_name as leader_name,
w.job_type as leader_job_type,
w.phone_number as leader_phone,
p.project_name,
p.job_no,
p.site,
wt.name as work_type_name,
wt.category as work_type_category,
t.task_name,
t.description as task_description,
u.username as created_by_username,
u.name as created_by_name
FROM tbm_sessions s
LEFT JOIN workers w ON s.leader_id = w.worker_id
LEFT JOIN projects p ON s.project_id = p.project_id
LEFT JOIN work_types wt ON s.work_type_id = wt.id
LEFT JOIN tasks t ON s.task_id = t.task_id
LEFT JOIN users u ON s.created_by = u.user_id
WHERE s.session_id = ?
`;
const [rows] = await db.query(sql, [sessionId]);
callback(null, rows);
} catch (err) {
callback(err);
}
},
/**
* TBM 세션 수정
*/
updateSession: async (sessionId, sessionData, callback) => {
try {
const db = await getDb();
const sql = `
UPDATE tbm_sessions
SET
project_id = ?,
work_location = ?,
status = ?,
updated_at = NOW()
WHERE session_id = ?
`;
const values = [
sessionData.project_id,
sessionData.work_location,
sessionData.status,
sessionId
];
const [result] = await db.query(sql, values);
callback(null, result);
} catch (err) {
callback(err);
}
},
/**
* TBM 세션 완료 처리
*/
completeSession: async (sessionId, endTime, callback) => {
try {
const db = await getDb();
const sql = `
UPDATE tbm_sessions
SET
status = 'completed',
end_time = ?,
updated_at = NOW()
WHERE session_id = ?
`;
const [result] = await db.query(sql, [endTime, sessionId]);
callback(null, result);
} catch (err) {
callback(err);
}
},
// ==================== 팀 구성 관련 ====================
/**
* 팀원 추가 (작업자별 상세 정보 포함)
*/
addTeamMember: async (assignmentData, callback) => {
try {
const db = await getDb();
const sql = `
INSERT INTO tbm_team_assignments
(session_id, worker_id, assigned_role, work_detail, is_present, absence_reason,
project_id, work_type_id, task_id, workplace_category_id, workplace_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
assigned_role = VALUES(assigned_role),
work_detail = VALUES(work_detail),
is_present = VALUES(is_present),
absence_reason = VALUES(absence_reason),
project_id = VALUES(project_id),
work_type_id = VALUES(work_type_id),
task_id = VALUES(task_id),
workplace_category_id = VALUES(workplace_category_id),
workplace_id = VALUES(workplace_id)
`;
const values = [
assignmentData.session_id,
assignmentData.worker_id,
assignmentData.assigned_role,
assignmentData.work_detail,
assignmentData.is_present !== undefined ? assignmentData.is_present : true,
assignmentData.absence_reason,
assignmentData.project_id || null,
assignmentData.work_type_id || null,
assignmentData.task_id || null,
assignmentData.workplace_category_id || null,
assignmentData.workplace_id || null
];
const [result] = await db.query(sql, values);
callback(null, result);
} catch (err) {
callback(err);
}
},
/**
* 팀 구성 일괄 추가 (작업자별 상세 정보 포함)
*/
addTeamMembers: async (sessionId, members, callback) => {
try {
if (!members || members.length === 0) {
return callback(null, { affectedRows: 0 });
}
const db = await getDb();
const values = members.map(m => [
sessionId,
m.worker_id,
m.assigned_role || null,
m.work_detail || null,
m.is_present !== undefined ? m.is_present : true,
m.absence_reason || null,
m.project_id || null,
m.work_type_id || null,
m.task_id || null,
m.workplace_category_id || null,
m.workplace_id || null
]);
const sql = `
INSERT INTO tbm_team_assignments
(session_id, worker_id, assigned_role, work_detail, is_present, absence_reason,
project_id, work_type_id, task_id, workplace_category_id, workplace_id)
VALUES ?
`;
const [result] = await db.query(sql, [values]);
callback(null, result);
} catch (err) {
callback(err);
}
},
/**
* TBM 세션의 팀 구성 조회 (작업자별 상세 정보 포함)
*/
getTeamMembers: async (sessionId, callback) => {
try {
const db = await getDb();
const sql = `
SELECT
ta.*,
w.worker_name,
w.job_type,
w.phone_number,
w.department,
p.project_name,
wt.name as work_type_name,
t.task_name,
wc.category_name AS workplace_category_name,
wp.workplace_name
FROM tbm_team_assignments ta
INNER JOIN workers w ON ta.worker_id = w.worker_id
LEFT JOIN projects p ON ta.project_id = p.project_id
LEFT JOIN work_types wt ON ta.work_type_id = wt.id
LEFT JOIN tasks t ON ta.task_id = t.task_id
LEFT JOIN workplace_categories wc ON ta.workplace_category_id = wc.category_id
LEFT JOIN workplaces wp ON ta.workplace_id = wp.workplace_id
WHERE ta.session_id = ?
ORDER BY ta.assigned_at DESC
`;
const [rows] = await db.query(sql, [sessionId]);
callback(null, rows);
} catch (err) {
callback(err);
}
},
/**
* 팀원 제거
*/
removeTeamMember: async (sessionId, workerId, callback) => {
try {
const db = await getDb();
const sql = `
DELETE FROM tbm_team_assignments
WHERE session_id = ? AND worker_id = ?
`;
const [result] = await db.query(sql, [sessionId, workerId]);
callback(null, result);
} catch (err) {
callback(err);
}
},
/**
* 세션의 모든 팀원 삭제
*/
clearAllTeamMembers: async (sessionId, callback) => {
try {
const db = await getDb();
const sql = `
DELETE FROM tbm_team_assignments
WHERE session_id = ?
`;
const [result] = await db.query(sql, [sessionId]);
callback(null, result);
} catch (err) {
callback(err);
}
},
// ==================== 안전 체크리스트 관련 ====================
/**
* 모든 안전 체크 항목 조회
*/
getAllSafetyChecks: async (callback) => {
try {
const db = await getDb();
const sql = `
SELECT *
FROM tbm_safety_checks
WHERE is_active = 1
ORDER BY check_category, display_order
`;
const [rows] = await db.query(sql);
callback(null, rows);
} catch (err) {
callback(err);
}
},
/**
* 카테고리별 안전 체크 항목 조회
*/
getSafetyChecksByCategory: async (category, callback) => {
try {
const db = await getDb();
const sql = `
SELECT *
FROM tbm_safety_checks
WHERE check_category = ? AND is_active = 1
ORDER BY display_order
`;
const [rows] = await db.query(sql, [category]);
callback(null, rows);
} catch (err) {
callback(err);
}
},
/**
* TBM 세션의 안전 체크 기록 조회
*/
getSafetyRecords: async (sessionId, callback) => {
try {
const db = await getDb();
const sql = `
SELECT
sr.*,
sc.check_category,
sc.check_item,
sc.description,
sc.is_required,
u.username as checked_by_username,
u.name as checked_by_name
FROM tbm_safety_records sr
INNER JOIN tbm_safety_checks sc ON sr.check_id = sc.check_id
LEFT JOIN users u ON sr.checked_by = u.user_id
WHERE sr.session_id = ?
ORDER BY sc.check_category, sc.display_order
`;
const [rows] = await db.query(sql, [sessionId]);
callback(null, rows);
} catch (err) {
callback(err);
}
},
/**
* 안전 체크 기록 저장/업데이트
*/
saveSafetyRecord: async (recordData, callback) => {
try {
const db = await getDb();
const sql = `
INSERT INTO tbm_safety_records
(session_id, check_id, is_checked, notes, checked_by, checked_at)
VALUES (?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
is_checked = VALUES(is_checked),
notes = VALUES(notes),
checked_by = VALUES(checked_by),
checked_at = NOW()
`;
const values = [
recordData.session_id,
recordData.check_id,
recordData.is_checked,
recordData.notes,
recordData.checked_by
];
const [result] = await db.query(sql, values);
callback(null, result);
} catch (err) {
callback(err);
}
},
/**
* 안전 체크 일괄 저장
*/
saveSafetyRecords: async (sessionId, records, checkedBy, callback) => {
try {
if (!records || records.length === 0) {
return callback(null, { affectedRows: 0 });
}
const db = await getDb();
const values = records.map(r => [
sessionId,
r.check_id,
r.is_checked,
r.notes || null,
checkedBy
]);
const sql = `
INSERT INTO tbm_safety_records
(session_id, check_id, is_checked, notes, checked_by, checked_at)
VALUES ?
ON DUPLICATE KEY UPDATE
is_checked = VALUES(is_checked),
notes = VALUES(notes),
checked_by = VALUES(checked_by),
checked_at = NOW()
`;
const [result] = await db.query(sql, [values]);
callback(null, result);
} catch (err) {
callback(err);
}
},
// ==================== 작업 인계 관련 ====================
/**
* 작업 인계 생성
*/
createHandover: async (handoverData, callback) => {
try {
const db = await getDb();
const sql = `
INSERT INTO team_handovers
(session_id, from_leader_id, to_leader_id, handover_date, handover_time,
reason, handover_notes, worker_ids)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`;
const values = [
handoverData.session_id,
handoverData.from_leader_id,
handoverData.to_leader_id,
handoverData.handover_date,
handoverData.handover_time,
handoverData.reason,
handoverData.handover_notes,
JSON.stringify(handoverData.worker_ids || [])
];
const [result] = await db.query(sql, values);
callback(null, result);
} catch (err) {
callback(err);
}
},
/**
* 작업 인계 확인
*/
confirmHandover: async (handoverId, confirmedBy, callback) => {
try {
const db = await getDb();
const sql = `
UPDATE team_handovers
SET
is_confirmed = 1,
confirmed_at = NOW(),
confirmed_by = ?
WHERE handover_id = ?
`;
const [result] = await db.query(sql, [confirmedBy, handoverId]);
callback(null, result);
} catch (err) {
callback(err);
}
},
/**
* 특정 날짜의 작업 인계 목록 조회
*/
getHandoversByDate: async (date, callback) => {
try {
const db = await getDb();
const sql = `
SELECT
h.*,
w1.worker_name as from_leader_name,
w2.worker_name as to_leader_name,
u.username as confirmed_by_username,
u.name as confirmed_by_name
FROM team_handovers h
INNER JOIN workers w1 ON h.from_leader_id = w1.worker_id
INNER JOIN workers w2 ON h.to_leader_id = w2.worker_id
LEFT JOIN users u ON h.confirmed_by = u.user_id
WHERE h.handover_date = ?
ORDER BY h.handover_time DESC
`;
const [rows] = await db.query(sql, [date]);
callback(null, rows);
} catch (err) {
callback(err);
}
},
/**
* 인수자가 받은 미확인 인계 건 조회
*/
getPendingHandovers: async (toLeaderId, callback) => {
try {
const db = await getDb();
const sql = `
SELECT
h.*,
w1.worker_name as from_leader_name,
w1.phone_number as from_leader_phone,
s.work_location
FROM team_handovers h
INNER JOIN workers w1 ON h.from_leader_id = w1.worker_id
LEFT JOIN tbm_sessions s ON h.session_id = s.session_id
WHERE h.to_leader_id = ? AND h.is_confirmed = 0
ORDER BY h.handover_date DESC, h.handover_time DESC
`;
const [rows] = await db.query(sql, [toLeaderId]);
callback(null, rows);
} catch (err) {
callback(err);
}
},
// ==================== 통계 및 리포트 ====================
/**
* 특정 기간의 TBM 통계
*/
getTbmStatistics: async (startDate, endDate, callback) => {
try {
const db = await getDb();
const sql = `
SELECT
DATE(session_date) as date,
COUNT(DISTINCT session_id) as session_count,
COUNT(DISTINCT leader_id) as leader_count,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed_count
FROM tbm_sessions
WHERE session_date BETWEEN ? AND ?
GROUP BY DATE(session_date)
ORDER BY date DESC
`;
const [rows] = await db.query(sql, [startDate, endDate]);
callback(null, rows);
} catch (err) {
callback(err);
}
},
/**
* 리더별 TBM 진행 현황
*/
getLeaderStatistics: async (startDate, endDate, callback) => {
try {
const db = await getDb();
const sql = `
SELECT
s.leader_id,
w.worker_name as leader_name,
COUNT(DISTINCT s.session_id) as total_sessions,
SUM(CASE WHEN s.status = 'completed' THEN 1 ELSE 0 END) as completed_sessions,
COUNT(DISTINCT ta.worker_id) as total_team_members
FROM tbm_sessions s
INNER JOIN workers w ON s.leader_id = w.worker_id
LEFT JOIN tbm_team_assignments ta ON s.session_id = ta.session_id
WHERE s.session_date BETWEEN ? AND ?
GROUP BY s.leader_id
ORDER BY total_sessions DESC
`;
const [rows] = await db.query(sql, [startDate, endDate]);
callback(null, rows);
} catch (err) {
callback(err);
}
},
/**
* 작업보고서가 작성되지 않은 TBM 세션의 팀 배정 조회
* @param {number|null} userId - 조회할 사용자 ID (null이면 모든 TBM 조회 - 관리자용)
*/
getIncompleteWorkReports: async (userId, callback) => {
try {
const db = await getDb();
// WHERE 조건 동적 생성
let whereClause = `
WHERE dwr.id IS NULL
AND s.status = 'draft'
`;
const params = [];
// userId가 있으면 created_by 조건 추가 (일반 사용자)
if (userId !== null && userId !== undefined) {
whereClause = `
WHERE s.created_by = ?
AND dwr.id IS NULL
AND s.status = 'draft'
`;
params.push(userId);
}
const sql = `
SELECT
ta.assignment_id,
ta.session_id,
ta.worker_id,
ta.project_id,
ta.work_type_id,
ta.task_id,
ta.workplace_category_id,
ta.workplace_id,
s.session_date,
s.status as session_status,
s.created_by,
w.worker_name,
w.job_type,
p.project_name,
wt.name as work_type_name,
t.task_name,
wp.workplace_name,
wc.category_name,
creator.name as created_by_name
FROM tbm_team_assignments ta
INNER JOIN tbm_sessions s ON ta.session_id = s.session_id
INNER JOIN workers w ON ta.worker_id = w.worker_id
LEFT JOIN users creator ON s.created_by = creator.user_id
LEFT JOIN projects p ON ta.project_id = p.project_id
LEFT JOIN work_types wt ON ta.work_type_id = wt.id
LEFT JOIN tasks t ON ta.task_id = t.task_id
LEFT JOIN workplaces wp ON ta.workplace_id = wp.workplace_id
LEFT JOIN workplace_categories wc ON ta.workplace_category_id = wc.category_id
LEFT JOIN daily_work_reports dwr ON ta.assignment_id = dwr.tbm_assignment_id
${whereClause}
ORDER BY s.session_date DESC, ta.assignment_id ASC
`;
const [rows] = await db.query(sql, params);
callback(null, rows);
} catch (err) {
callback(err);
}
},
// ========== 안전 체크리스트 확장 메서드 ==========
/**
* 유형별 안전 체크 항목 조회
* @param {string} checkType - 체크 유형 (basic, weather, task)
* @param {Object} options - 추가 옵션 (weatherCondition, taskId)
*/
getSafetyChecksByType: async (checkType, options = {}, callback) => {
try {
const db = await getDb();
let sql = `
SELECT sc.*,
wc.condition_name as weather_condition_name,
wc.icon as weather_icon,
t.task_name
FROM tbm_safety_checks sc
LEFT JOIN weather_conditions wc ON sc.weather_condition = wc.condition_code
LEFT JOIN tasks t ON sc.task_id = t.task_id
WHERE sc.is_active = 1 AND sc.check_type = ?
`;
const params = [checkType];
if (checkType === 'weather' && options.weatherCondition) {
sql += ' AND sc.weather_condition = ?';
params.push(options.weatherCondition);
}
if (checkType === 'task' && options.taskId) {
sql += ' AND sc.task_id = ?';
params.push(options.taskId);
}
sql += ' ORDER BY sc.check_category, sc.display_order';
const [rows] = await db.query(sql, params);
callback(null, rows);
} catch (err) {
callback(err);
}
},
/**
* 날씨 조건별 안전 체크 항목 조회 (복수 조건)
* @param {string[]} conditions - 날씨 조건 배열 ['rain', 'wind']
*/
getSafetyChecksByWeather: async (conditions, callback) => {
try {
const db = await getDb();
if (!conditions || conditions.length === 0) {
return callback(null, []);
}
const placeholders = conditions.map(() => '?').join(',');
const sql = `
SELECT sc.*,
wc.condition_name as weather_condition_name,
wc.icon as weather_icon
FROM tbm_safety_checks sc
LEFT JOIN weather_conditions wc ON sc.weather_condition = wc.condition_code
WHERE sc.is_active = 1
AND sc.check_type = 'weather'
AND sc.weather_condition IN (${placeholders})
ORDER BY sc.weather_condition, sc.display_order
`;
const [rows] = await db.query(sql, conditions);
callback(null, rows);
} catch (err) {
callback(err);
}
},
/**
* 작업별 안전 체크 항목 조회 (복수 작업)
* @param {number[]} taskIds - 작업 ID 배열
*/
getSafetyChecksByTasks: async (taskIds, callback) => {
try {
const db = await getDb();
if (!taskIds || taskIds.length === 0) {
return callback(null, []);
}
const placeholders = taskIds.map(() => '?').join(',');
const sql = `
SELECT sc.*,
t.task_name,
wt.name as work_type_name
FROM tbm_safety_checks sc
LEFT JOIN tasks t ON sc.task_id = t.task_id
LEFT JOIN work_types wt ON t.work_type_id = wt.id
WHERE sc.is_active = 1
AND sc.check_type = 'task'
AND sc.task_id IN (${placeholders})
ORDER BY sc.task_id, sc.display_order
`;
const [rows] = await db.query(sql, taskIds);
callback(null, rows);
} catch (err) {
callback(err);
}
},
/**
* TBM 세션에 맞는 필터링된 안전 체크 항목 조회
* 기본 + 날씨 + 작업별 체크항목 통합 조회
* @param {number} sessionId - TBM 세션 ID
* @param {string[]} weatherConditions - 날씨 조건 배열 (optional)
*/
getFilteredSafetyChecks: async (sessionId, weatherConditions = [], callback) => {
try {
const db = await getDb();
// 1. 세션 정보에서 작업 ID 목록 조회
const [assignments] = await db.query(`
SELECT DISTINCT task_id
FROM tbm_team_assignments
WHERE session_id = ? AND task_id IS NOT NULL
`, [sessionId]);
const taskIds = assignments.map(a => a.task_id);
// 2. 기본 체크항목 조회
const [basicChecks] = await db.query(`
SELECT sc.*, 'basic' as section_type
FROM tbm_safety_checks sc
WHERE sc.is_active = 1 AND sc.check_type = 'basic'
ORDER BY sc.check_category, sc.display_order
`);
// 3. 날씨별 체크항목 조회
let weatherChecks = [];
if (weatherConditions && weatherConditions.length > 0) {
const wcPlaceholders = weatherConditions.map(() => '?').join(',');
const [rows] = await db.query(`
SELECT sc.*, wc.condition_name as weather_condition_name, wc.icon as weather_icon,
'weather' as section_type
FROM tbm_safety_checks sc
LEFT JOIN weather_conditions wc ON sc.weather_condition = wc.condition_code
WHERE sc.is_active = 1
AND sc.check_type = 'weather'
AND sc.weather_condition IN (${wcPlaceholders})
ORDER BY sc.weather_condition, sc.display_order
`, weatherConditions);
weatherChecks = rows;
}
// 4. 작업별 체크항목 조회
let taskChecks = [];
if (taskIds.length > 0) {
const taskPlaceholders = taskIds.map(() => '?').join(',');
const [rows] = await db.query(`
SELECT sc.*, t.task_name, wt.name as work_type_name,
'task' as section_type
FROM tbm_safety_checks sc
LEFT JOIN tasks t ON sc.task_id = t.task_id
LEFT JOIN work_types wt ON t.work_type_id = wt.id
WHERE sc.is_active = 1
AND sc.check_type = 'task'
AND sc.task_id IN (${taskPlaceholders})
ORDER BY sc.task_id, sc.display_order
`, taskIds);
taskChecks = rows;
}
// 5. 기존 체크 기록 조회
const [existingRecords] = await db.query(`
SELECT check_id, is_checked, notes
FROM tbm_safety_records
WHERE session_id = ?
`, [sessionId]);
const recordMap = {};
existingRecords.forEach(r => {
recordMap[r.check_id] = { is_checked: r.is_checked, notes: r.notes };
});
// 6. 기록과 병합
const mergeWithRecords = (checks) => {
return checks.map(check => ({
...check,
is_checked: recordMap[check.check_id]?.is_checked || false,
notes: recordMap[check.check_id]?.notes || null
}));
};
const result = {
basic: mergeWithRecords(basicChecks),
weather: mergeWithRecords(weatherChecks),
task: mergeWithRecords(taskChecks),
totalCount: basicChecks.length + weatherChecks.length + taskChecks.length,
weatherConditions: weatherConditions
};
callback(null, result);
} catch (err) {
callback(err);
}
},
/**
* 안전 체크 항목 생성 (관리자용)
*/
createSafetyCheck: async (checkData, callback) => {
try {
const db = await getDb();
const sql = `
INSERT INTO tbm_safety_checks
(check_category, check_type, weather_condition, task_id, check_item, description, is_required, display_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`;
const values = [
checkData.check_category,
checkData.check_type || 'basic',
checkData.weather_condition || null,
checkData.task_id || null,
checkData.check_item,
checkData.description || null,
checkData.is_required !== false,
checkData.display_order || 0
];
const [result] = await db.query(sql, values);
callback(null, { insertId: result.insertId });
} catch (err) {
callback(err);
}
},
/**
* 안전 체크 항목 수정 (관리자용)
*/
updateSafetyCheck: async (checkId, checkData, callback) => {
try {
const db = await getDb();
const sql = `
UPDATE tbm_safety_checks
SET check_category = ?,
check_type = ?,
weather_condition = ?,
task_id = ?,
check_item = ?,
description = ?,
is_required = ?,
display_order = ?,
is_active = ?,
updated_at = NOW()
WHERE check_id = ?
`;
const values = [
checkData.check_category,
checkData.check_type || 'basic',
checkData.weather_condition || null,
checkData.task_id || null,
checkData.check_item,
checkData.description || null,
checkData.is_required !== false,
checkData.display_order || 0,
checkData.is_active !== false,
checkId
];
const [result] = await db.query(sql, values);
callback(null, { affectedRows: result.affectedRows });
} catch (err) {
callback(err);
}
},
/**
* 안전 체크 항목 삭제 (비활성화)
*/
deleteSafetyCheck: async (checkId, callback) => {
try {
const db = await getDb();
// 실제 삭제 대신 비활성화
const sql = `UPDATE tbm_safety_checks SET is_active = 0 WHERE check_id = ?`;
const [result] = await db.query(sql, [checkId]);
callback(null, { affectedRows: result.affectedRows });
} catch (err) {
callback(err);
}
}
};
module.exports = TbmModel;

View File

@@ -0,0 +1,68 @@
const { getDb } = require('../dbPool');
// 1. 전체 도구 조회
const getAll = async () => {
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');
return rows;
};
// 2. 단일 도구 조회
const getById = async (id) => {
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]);
return rows[0];
};
// 3. 도구 생성
const create = async (tool) => {
const db = await getDb();
const { name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note } = tool;
const [result] = await db.query(
`INSERT INTO Tools
(name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note]
);
return result.insertId;
};
// 4. 도구 수정
const update = async (id, tool) => {
const db = await getDb();
const { name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note } = tool;
const [result] = await db.query(
`UPDATE Tools
SET name = ?,
location = ?,
stock = ?,
status = ?,
factory_id = ?,
map_x = ?,
map_y = ?,
map_zone = ?,
map_note = ?
WHERE id = ?`,
[name, location, stock, status, factory_id, map_x, map_y, map_zone, map_note, id]
);
return result.affectedRows;
};
// 5. 도구 삭제
const remove = async (id) => {
const db = await getDb();
const [result] = await db.query('DELETE FROM Tools WHERE id = ?', [id]);
return result.affectedRows;
};
module.exports = {
getAll,
getById,
create,
update,
remove
};

View File

@@ -0,0 +1,36 @@
const { getDb } = require('../dbPool');
// 1. 문서 업로드
const create = async (doc) => {
const db = await getDb();
const sql = `
INSERT INTO uploaded_documents
(title, tags, description, original_name, stored_name, file_path, file_type, file_size, submitted_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
const values = [
doc.title,
doc.tags,
doc.description,
doc.original_name,
doc.stored_name,
doc.file_path,
doc.file_type,
doc.file_size,
doc.submitted_by
];
const [result] = await db.query(sql, values);
return result.insertId;
};
// 2. 전체 문서 목록 조회
const getAll = async () => {
const db = await getDb();
const [rows] = await db.query(`SELECT * FROM uploaded_documents ORDER BY created_at DESC`);
return rows;
};
module.exports = {
create,
getAll
};

View File

@@ -0,0 +1,82 @@
const { getDb } = require('../dbPool');
// 사용자 조회
const findByUsername = async (username) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT u.user_id, u.username, u.password, u.name, u.email,
u.role_id, r.name as role_name,
u._access_level_old as access_level, u.worker_id, u.is_active,
u.last_login_at, u.password_changed_at, u.failed_login_attempts,
u.locked_until, u.created_at, u.updated_at
FROM users u
LEFT JOIN roles r ON u.role_id = r.id
WHERE u.username = ?`, [username]
);
return rows[0];
} catch (err) {
console.error('DB 오류 - 사용자 조회 실패:', err);
throw err;
}
};
/**
* 로그인 실패 횟수를 1 증가시킵니다.
* @param {number} userId - 사용자 ID
*/
const incrementFailedLoginAttempts = async (userId) => {
try {
const db = await getDb();
await db.query(
'UPDATE users SET failed_login_attempts = failed_login_attempts + 1 WHERE user_id = ?',
[userId]
);
} catch (err) {
console.error('DB 오류 - 로그인 실패 횟수 증가 실패:', err);
throw err;
}
};
/**
* 특정 사용자의 계정을 잠급니다.
* @param {number} userId - 사용자 ID
*/
const lockUserAccount = async (userId) => {
try {
const db = await getDb();
await db.query(
'UPDATE users SET locked_until = DATE_ADD(NOW(), INTERVAL 15 MINUTE) WHERE user_id = ?',
[userId]
);
} catch (err) {
console.error('DB 오류 - 계정 잠금 실패:', err);
throw err;
}
};
/**
* 로그인 성공 시, 마지막 로그인 시간을 업데이트하고 실패 횟수와 잠금 상태를 초기화합니다.
* @param {number} userId - 사용자 ID
*/
const resetLoginAttempts = async (userId) => {
try {
const db = await getDb();
await db.query(
'UPDATE users SET last_login_at = NOW(), failed_login_attempts = 0, locked_until = NULL WHERE user_id = ?',
[userId]
);
} catch (err) {
console.error('DB 오류 - 로그인 상태 초기화 실패:', err);
throw err;
}
};
// 명확한 내보내기
module.exports = {
findByUsername,
incrementFailedLoginAttempts,
lockUserAccount,
resetLoginAttempts
};

View File

@@ -0,0 +1,400 @@
/**
* vacationBalanceModel.js
* 휴가 잔액 관련 데이터베이스 쿼리 모델
*/
const { getDb } = require('../dbPool');
const vacationBalanceModel = {
/**
* 특정 작업자의 모든 휴가 잔액 조회 (특정 연도)
*/
async getByWorkerAndYear(workerId, year, callback) {
try {
const db = await getDb();
const query = `
SELECT
vbd.*,
vt.type_name,
vt.type_code,
vt.priority,
vt.is_special
FROM vacation_balance_details vbd
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
WHERE vbd.worker_id = ? AND vbd.year = ?
ORDER BY vt.priority ASC, vt.type_name ASC
`;
const [rows] = await db.query(query, [workerId, year]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 특정 작업자의 특정 휴가 유형 잔액 조회
*/
async getByWorkerTypeYear(workerId, vacationTypeId, year, callback) {
try {
const db = await getDb();
const query = `
SELECT
vbd.*,
vt.type_name,
vt.type_code
FROM vacation_balance_details vbd
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
WHERE vbd.worker_id = ?
AND vbd.vacation_type_id = ?
AND vbd.year = ?
`;
const [rows] = await db.query(query, [workerId, vacationTypeId, year]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 모든 작업자의 휴가 잔액 조회 (특정 연도)
* - 연간 연차 현황 차트용
*/
async getAllByYear(year, callback) {
try {
const db = await getDb();
const query = `
SELECT
vbd.*,
w.worker_name,
w.employment_status,
vt.type_name,
vt.type_code,
vt.priority
FROM vacation_balance_details vbd
INNER JOIN workers w ON vbd.worker_id = w.worker_id
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
WHERE vbd.year = ?
AND w.employment_status = 'employed'
ORDER BY w.worker_name ASC, vt.priority ASC
`;
const [rows] = await db.query(query, [year]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 휴가 잔액 생성
*/
async create(balanceData, callback) {
try {
const db = await getDb();
const query = `INSERT INTO vacation_balance_details SET ?`;
const [rows] = await db.query(query, balanceData);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 휴가 잔액 수정
*/
async update(id, updateData, callback) {
try {
const db = await getDb();
const query = `UPDATE vacation_balance_details SET ? WHERE id = ?`;
const [rows] = await db.query(query, [updateData, id]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 휴가 잔액 삭제
*/
async delete(id, callback) {
try {
const db = await getDb();
const query = `DELETE FROM vacation_balance_details WHERE id = ?`;
const [rows] = await db.query(query, [id]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 작업자의 휴가 사용 일수 업데이트 (차감)
* - 휴가 신청 승인 시 호출
*/
async deductDays(workerId, vacationTypeId, year, daysToDeduct, callback) {
try {
const db = await getDb();
const query = `
UPDATE vacation_balance_details
SET used_days = used_days + ?,
updated_at = NOW()
WHERE worker_id = ?
AND vacation_type_id = ?
AND year = ?
`;
const [rows] = await db.query(query, [daysToDeduct, workerId, vacationTypeId, year]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 작업자의 휴가 사용 일수 복구 (취소)
* - 휴가 신청 취소/거부 시 호출
*/
async restoreDays(workerId, vacationTypeId, year, daysToRestore, callback) {
try {
const db = await getDb();
const query = `
UPDATE vacation_balance_details
SET used_days = GREATEST(0, used_days - ?),
updated_at = NOW()
WHERE worker_id = ?
AND vacation_type_id = ?
AND year = ?
`;
const [rows] = await db.query(query, [daysToRestore, workerId, vacationTypeId, year]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 특정 작업자의 사용 가능한 휴가 일수 확인
* - 우선순위가 높은 순서대로 차감 가능 여부 확인
*/
async getAvailableVacationDays(workerId, year, callback) {
try {
const db = await getDb();
const query = `
SELECT
vbd.id,
vbd.vacation_type_id,
vt.type_name,
vt.type_code,
vt.priority,
vbd.total_days,
vbd.used_days,
vbd.remaining_days
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.remaining_days > 0
ORDER BY vt.priority ASC
`;
const [rows] = await db.query(query, [workerId, year]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 작업자별 휴가 잔액 일괄 생성 (연도별)
* - 매년 초 또는 입사 시 사용
*/
async bulkCreate(balances, callback) {
try {
const db = await getDb();
if (!balances || balances.length === 0) {
return callback(new Error('생성할 휴가 잔액 데이터가 없습니다'));
}
const query = `INSERT INTO vacation_balance_details
(worker_id, vacation_type_id, year, total_days, used_days, notes, created_by)
VALUES ?`;
const values = balances.map(b => [
b.worker_id,
b.vacation_type_id,
b.year,
b.total_days || 0,
b.used_days || 0,
b.notes || null,
b.created_by
]);
const [rows] = await db.query(query, [values]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 근속년수 기반 연차 일수 계산 (한국 근로기준법)
* @param {Date} hireDate - 입사일
* @param {number} targetYear - 대상 연도
* @returns {number} - 부여받을 연차 일수
*/
calculateAnnualLeaveDays(hireDate, targetYear) {
const hire = new Date(hireDate);
const targetDate = new Date(targetYear, 0, 1);
// 근속 월수 계산
const monthsDiff = (targetDate.getFullYear() - hire.getFullYear()) * 12
+ (targetDate.getMonth() - hire.getMonth());
// 1년 미만: 월 1일
if (monthsDiff < 12) {
return Math.floor(monthsDiff);
}
// 1년 이상: 15일 기본 + 2년마다 1일 추가 (최대 25일)
const yearsWorked = Math.floor(monthsDiff / 12);
const additionalDays = Math.floor((yearsWorked - 1) / 2);
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로 휴가 잔액 조회
*/
async getById(id, callback) {
try {
const db = await getDb();
const query = `
SELECT
vbd.*,
w.worker_name,
vt.type_name,
vt.type_code
FROM vacation_balance_details vbd
INNER JOIN workers w ON vbd.worker_id = w.worker_id
INNER JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
WHERE vbd.id = ?
`;
const [rows] = await db.query(query, [id]);
callback(null, rows);
} catch (error) {
callback(error);
}
}
};
module.exports = vacationBalanceModel;

View File

@@ -0,0 +1,271 @@
/**
* vacationRequestModel.js
* 휴가 신청 관련 데이터베이스 쿼리 모델
*/
const { getDb } = require('../dbPool');
const vacationRequestModel = {
/**
* 휴가 신청 생성
*/
async create(requestData, callback) {
try {
const db = await getDb();
const query = `INSERT INTO vacation_requests SET ?`;
const [result] = await db.query(query, requestData);
callback(null, result);
} catch (error) {
callback(error);
}
},
/**
* 휴가 신청 목록 조회 (필터링 지원)
*/
async getAll(filters = {}, callback) {
try {
const db = await getDb();
let query = `
SELECT
vr.*,
w.worker_name,
vt.type_name as vacation_type_name,
vt.deduct_days as vacation_deduct_days,
requester.name as requester_name,
reviewer.name as reviewer_name
FROM vacation_requests vr
INNER JOIN workers w ON vr.worker_id = w.worker_id
INNER JOIN vacation_types vt ON vr.vacation_type_id = vt.id
LEFT JOIN users requester ON vr.requested_by = requester.user_id
LEFT JOIN users reviewer ON vr.reviewed_by = reviewer.user_id
WHERE 1=1
`;
const params = [];
// 작업자 필터
if (filters.worker_id) {
query += ` AND vr.worker_id = ?`;
params.push(filters.worker_id);
}
// 상태 필터
if (filters.status) {
query += ` AND vr.status = ?`;
params.push(filters.status);
}
// 기간 필터
if (filters.start_date) {
query += ` AND vr.start_date >= ?`;
params.push(filters.start_date);
}
if (filters.end_date) {
query += ` AND vr.end_date <= ?`;
params.push(filters.end_date);
}
// 휴가 유형 필터
if (filters.vacation_type_id) {
query += ` AND vr.vacation_type_id = ?`;
params.push(filters.vacation_type_id);
}
query += ` ORDER BY vr.created_at DESC`;
const [rows] = await db.query(query, params);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 특정 휴가 신청 조회
*/
async getById(requestId, callback) {
try {
const db = await getDb();
const query = `
SELECT
vr.*,
w.worker_name,
w.phone_number as worker_phone,
w.email as worker_email,
vt.type_name as vacation_type_name,
vt.type_code as vacation_type_code,
vt.deduct_days as vacation_deduct_days,
requester.name as requester_name,
requester.username as requester_username,
reviewer.name as reviewer_name,
reviewer.username as reviewer_username
FROM vacation_requests vr
INNER JOIN workers w ON vr.worker_id = w.worker_id
INNER JOIN vacation_types vt ON vr.vacation_type_id = vt.id
LEFT JOIN users requester ON vr.requested_by = requester.user_id
LEFT JOIN users reviewer ON vr.reviewed_by = reviewer.user_id
WHERE vr.request_id = ?
`;
const [rows] = await db.query(query, [requestId]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 휴가 신청 수정
*/
async update(requestId, updateData, callback) {
try {
const db = await getDb();
const query = `UPDATE vacation_requests SET ? WHERE request_id = ?`;
const [result] = await db.query(query, [updateData, requestId]);
callback(null, result);
} catch (error) {
callback(error);
}
},
/**
* 휴가 신청 삭제
*/
async delete(requestId, callback) {
try {
const db = await getDb();
const query = `DELETE FROM vacation_requests WHERE request_id = ?`;
const [result] = await db.query(query, [requestId]);
callback(null, result);
} catch (error) {
callback(error);
}
},
/**
* 휴가 신청 승인/거부
*/
async updateStatus(requestId, statusData, callback) {
try {
const db = await getDb();
const query = `
UPDATE vacation_requests
SET
status = ?,
reviewed_by = ?,
reviewed_at = NOW(),
review_note = ?
WHERE request_id = ?
`;
const [result] = await db.query(query, [
statusData.status,
statusData.reviewed_by,
statusData.review_note || null,
requestId
]);
callback(null, result);
} catch (error) {
callback(error);
}
},
/**
* 특정 작업자의 대기 중인 휴가 신청 수
*/
async getPendingCount(workerId, callback) {
try {
const db = await getDb();
const query = `
SELECT COUNT(*) as count
FROM vacation_requests
WHERE worker_id = ? AND status = 'pending'
`;
const [rows] = await db.query(query, [workerId]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 특정 작업자의 승인된 휴가 일수 합계 (특정 기간)
*/
async getApprovedDaysInPeriod(workerId, startDate, endDate, callback) {
try {
const db = await getDb();
const query = `
SELECT COALESCE(SUM(days_used), 0) as total_days
FROM vacation_requests
WHERE worker_id = ?
AND status = 'approved'
AND start_date >= ?
AND end_date <= ?
`;
const [rows] = await db.query(query, [workerId, startDate, endDate]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 휴가 기간 중복 체크
*/
async checkOverlap(workerId, startDate, endDate, excludeRequestId = null, callback) {
try {
const db = await getDb();
let query = `
SELECT COUNT(*) as count
FROM vacation_requests
WHERE worker_id = ?
AND status IN ('pending', 'approved')
AND (
(start_date <= ? AND end_date >= ?) OR
(start_date <= ? AND end_date >= ?) OR
(start_date >= ? AND end_date <= ?)
)
`;
const params = [workerId, startDate, startDate, endDate, endDate, startDate, endDate];
if (excludeRequestId) {
query += ` AND request_id != ?`;
params.push(excludeRequestId);
}
const [rows] = await db.query(query, params);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 모든 대기 중인 휴가 신청 (관리자용)
*/
async getAllPending(callback) {
try {
const db = await getDb();
const query = `
SELECT
vr.*,
w.worker_name,
vt.type_name as vacation_type_name,
requester.name as requester_name
FROM vacation_requests vr
INNER JOIN workers w ON vr.worker_id = w.worker_id
INNER JOIN vacation_types vt ON vr.vacation_type_id = vt.id
LEFT JOIN users requester ON vr.requested_by = requester.user_id
WHERE vr.status = 'pending'
ORDER BY vr.created_at ASC
`;
const [rows] = await db.query(query);
callback(null, rows);
} catch (error) {
callback(error);
}
}
};
module.exports = vacationRequestModel;

View File

@@ -0,0 +1,132 @@
/**
* vacationTypeModel.js
* 휴가 유형 관련 데이터베이스 쿼리 모델
*/
const { getDb } = require('../dbPool');
const vacationTypeModel = {
/**
* 모든 활성 휴가 유형 조회 (우선순위 순서대로)
*/
async getAll(callback) {
try {
const db = await getDb();
const query = `
SELECT *
FROM vacation_types
WHERE is_active = 1
ORDER BY priority ASC, id ASC
`;
const [rows] = await db.query(query);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 시스템 기본 휴가 유형만 조회
*/
async getSystemTypes(callback) {
try {
const db = await getDb();
const query = `
SELECT *
FROM vacation_types
WHERE is_system = 1 AND is_active = 1
ORDER BY priority ASC
`;
const [rows] = await db.query(query);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 특정 ID로 휴가 유형 조회
*/
async getById(id, callback) {
try {
const db = await getDb();
const query = `SELECT * FROM vacation_types WHERE id = ?`;
const [rows] = await db.query(query, [id]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 휴가 유형 코드로 조회
*/
async getByCode(code, callback) {
try {
const db = await getDb();
const query = `SELECT * FROM vacation_types WHERE type_code = ?`;
const [rows] = await db.query(query, [code]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
/**
* 휴가 유형 생성
*/
async create(typeData, callback) {
try {
const db = await getDb();
const query = `INSERT INTO vacation_types SET ?`;
const [result] = await db.query(query, typeData);
callback(null, result);
} catch (error) {
callback(error);
}
},
/**
* 휴가 유형 수정
*/
async update(id, updateData, callback) {
try {
const db = await getDb();
const query = `UPDATE vacation_types SET ? WHERE id = ?`;
const [result] = await db.query(query, [updateData, id]);
callback(null, result);
} catch (error) {
callback(error);
}
},
/**
* 휴가 유형 삭제 (논리적 삭제 - is_active = 0)
*/
async delete(id, callback) {
try {
const db = await getDb();
const query = `UPDATE vacation_types SET is_active = 0, updated_at = NOW() WHERE id = ?`;
const [result] = await db.query(query, [id]);
callback(null, result);
} catch (error) {
callback(error);
}
},
/**
* 우선순위 업데이트
*/
async updatePriority(id, priority, callback) {
try {
const db = await getDb();
const query = `UPDATE vacation_types SET priority = ?, updated_at = NOW() WHERE id = ?`;
const [result] = await db.query(query, [priority, id]);
callback(null, result);
} catch (error) {
callback(error);
}
}
};
module.exports = vacationTypeModel;

View File

@@ -0,0 +1,505 @@
const { getDb } = require('../dbPool');
// ==================== 출입 신청 관리 ====================
/**
* 출입 신청 생성
*/
const createVisitRequest = async (requestData, callback) => {
try {
const db = await getDb();
const {
requester_id,
visitor_company,
visitor_count = 1,
category_id,
workplace_id,
visit_date,
visit_time,
purpose_id,
notes = null
} = requestData;
const [result] = await db.query(
`INSERT INTO workplace_visit_requests
(requester_id, visitor_company, visitor_count, category_id, workplace_id,
visit_date, visit_time, purpose_id, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[requester_id, visitor_company, visitor_count, category_id, workplace_id,
visit_date, visit_time, purpose_id, notes]
);
callback(null, result.insertId);
} catch (err) {
callback(err);
}
};
/**
* 출입 신청 목록 조회 (필터 옵션 포함)
*/
const getAllVisitRequests = async (filters = {}, callback) => {
try {
const db = await getDb();
let query = `
SELECT
vr.request_id, vr.requester_id, vr.visitor_company, vr.visitor_count,
vr.category_id, vr.workplace_id, vr.visit_date, vr.visit_time,
vr.purpose_id, vr.notes, vr.status,
vr.approved_by, vr.approved_at, vr.rejection_reason,
vr.created_at, vr.updated_at,
u.username as requester_name, u.name as requester_full_name,
wc.category_name, w.workplace_name,
vpt.purpose_name,
approver.username as approver_name
FROM workplace_visit_requests vr
INNER JOIN users u ON vr.requester_id = u.user_id
INNER JOIN workplace_categories wc ON vr.category_id = wc.category_id
INNER JOIN workplaces w ON vr.workplace_id = w.workplace_id
INNER JOIN visit_purpose_types vpt ON vr.purpose_id = vpt.purpose_id
LEFT JOIN users approver ON vr.approved_by = approver.user_id
WHERE 1=1
`;
const params = [];
// 필터 적용
if (filters.status) {
query += ` AND vr.status = ?`;
params.push(filters.status);
}
if (filters.visit_date) {
query += ` AND vr.visit_date = ?`;
params.push(filters.visit_date);
}
if (filters.start_date && filters.end_date) {
query += ` AND vr.visit_date BETWEEN ? AND ?`;
params.push(filters.start_date, filters.end_date);
}
if (filters.requester_id) {
query += ` AND vr.requester_id = ?`;
params.push(filters.requester_id);
}
if (filters.category_id) {
query += ` AND vr.category_id = ?`;
params.push(filters.category_id);
}
query += ` ORDER BY vr.visit_date DESC, vr.visit_time DESC, vr.created_at DESC`;
const [rows] = await db.query(query, params);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 출입 신청 상세 조회
*/
const getVisitRequestById = async (requestId, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT
vr.request_id, vr.requester_id, vr.visitor_company, vr.visitor_count,
vr.category_id, vr.workplace_id, vr.visit_date, vr.visit_time,
vr.purpose_id, vr.notes, vr.status,
vr.approved_by, vr.approved_at, vr.rejection_reason,
vr.created_at, vr.updated_at,
u.username as requester_name, u.name as requester_full_name,
wc.category_name, w.workplace_name,
vpt.purpose_name,
approver.username as approver_name
FROM workplace_visit_requests vr
INNER JOIN users u ON vr.requester_id = u.user_id
INNER JOIN workplace_categories wc ON vr.category_id = wc.category_id
INNER JOIN workplaces w ON vr.workplace_id = w.workplace_id
INNER JOIN visit_purpose_types vpt ON vr.purpose_id = vpt.purpose_id
LEFT JOIN users approver ON vr.approved_by = approver.user_id
WHERE vr.request_id = ?`,
[requestId]
);
callback(null, rows[0]);
} catch (err) {
callback(err);
}
};
/**
* 출입 신청 수정
*/
const updateVisitRequest = async (requestId, requestData, callback) => {
try {
const db = await getDb();
const {
visitor_company,
visitor_count,
category_id,
workplace_id,
visit_date,
visit_time,
purpose_id,
notes
} = requestData;
const [result] = await db.query(
`UPDATE workplace_visit_requests
SET visitor_company = ?, visitor_count = ?, category_id = ?, workplace_id = ?,
visit_date = ?, visit_time = ?, purpose_id = ?, notes = ?, updated_at = NOW()
WHERE request_id = ?`,
[visitor_company, visitor_count, category_id, workplace_id,
visit_date, visit_time, purpose_id, notes, requestId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 출입 신청 삭제
*/
const deleteVisitRequest = async (requestId, callback) => {
try {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM workplace_visit_requests WHERE request_id = ?`,
[requestId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 출입 신청 승인
*/
const approveVisitRequest = async (requestId, approvedBy, callback) => {
try {
const db = await getDb();
const [result] = await db.query(
`UPDATE workplace_visit_requests
SET status = 'approved', approved_by = ?, approved_at = NOW(), updated_at = NOW()
WHERE request_id = ?`,
[approvedBy, requestId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 출입 신청 반려
*/
const rejectVisitRequest = async (requestId, rejectionData, callback) => {
try {
const db = await getDb();
const { approved_by, rejection_reason } = rejectionData;
const [result] = await db.query(
`UPDATE workplace_visit_requests
SET status = 'rejected', approved_by = ?, approved_at = NOW(),
rejection_reason = ?, updated_at = NOW()
WHERE request_id = ?`,
[approved_by, rejection_reason, requestId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 출입 신청 상태 변경
*/
const updateVisitRequestStatus = async (requestId, status, callback) => {
try {
const db = await getDb();
const [result] = await db.query(
`UPDATE workplace_visit_requests
SET status = ?, updated_at = NOW()
WHERE request_id = ?`,
[status, requestId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
// ==================== 방문 목적 관리 ====================
/**
* 모든 방문 목적 조회
*/
const getAllVisitPurposes = async (callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT purpose_id, purpose_name, display_order, is_active, created_at
FROM visit_purpose_types
ORDER BY display_order ASC, purpose_id ASC`
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 활성 방문 목적만 조회
*/
const getActiveVisitPurposes = async (callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT purpose_id, purpose_name, display_order, is_active, created_at
FROM visit_purpose_types
WHERE is_active = TRUE
ORDER BY display_order ASC, purpose_id ASC`
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 방문 목적 추가
*/
const createVisitPurpose = async (purposeData, callback) => {
try {
const db = await getDb();
const { purpose_name, display_order = 0, is_active = true } = purposeData;
const [result] = await db.query(
`INSERT INTO visit_purpose_types (purpose_name, display_order, is_active)
VALUES (?, ?, ?)`,
[purpose_name, display_order, is_active]
);
callback(null, result.insertId);
} catch (err) {
callback(err);
}
};
/**
* 방문 목적 수정
*/
const updateVisitPurpose = async (purposeId, purposeData, callback) => {
try {
const db = await getDb();
const { purpose_name, display_order, is_active } = purposeData;
const [result] = await db.query(
`UPDATE visit_purpose_types
SET purpose_name = ?, display_order = ?, is_active = ?
WHERE purpose_id = ?`,
[purpose_name, display_order, is_active, purposeId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 방문 목적 삭제
*/
const deleteVisitPurpose = async (purposeId, callback) => {
try {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM visit_purpose_types WHERE purpose_id = ?`,
[purposeId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
// ==================== 안전교육 기록 관리 ====================
/**
* 안전교육 기록 생성
*/
const createTrainingRecord = async (trainingData, callback) => {
try {
const db = await getDb();
const {
request_id,
trainer_id,
training_date,
training_start_time,
training_end_time = null,
training_topics = null
} = trainingData;
const [result] = await db.query(
`INSERT INTO safety_training_records
(request_id, trainer_id, training_date, training_start_time, training_end_time, training_topics)
VALUES (?, ?, ?, ?, ?, ?)`,
[request_id, trainer_id, training_date, training_start_time, training_end_time, training_topics]
);
callback(null, result.insertId);
} catch (err) {
callback(err);
}
};
/**
* 특정 출입 신청의 안전교육 기록 조회
*/
const getTrainingRecordByRequestId = async (requestId, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT
str.training_id, str.request_id, str.trainer_id, str.training_date,
str.training_start_time, str.training_end_time, str.training_topics,
str.signature_data, str.completed_at, str.created_at, str.updated_at,
u.username as trainer_name, u.name as trainer_full_name
FROM safety_training_records str
INNER JOIN users u ON str.trainer_id = u.user_id
WHERE str.request_id = ?`,
[requestId]
);
callback(null, rows[0]);
} catch (err) {
callback(err);
}
};
/**
* 안전교육 기록 수정
*/
const updateTrainingRecord = async (trainingId, trainingData, callback) => {
try {
const db = await getDb();
const {
training_date,
training_start_time,
training_end_time,
training_topics
} = trainingData;
const [result] = await db.query(
`UPDATE safety_training_records
SET training_date = ?, training_start_time = ?, training_end_time = ?,
training_topics = ?, updated_at = NOW()
WHERE training_id = ?`,
[training_date, training_start_time, training_end_time, training_topics, trainingId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 안전교육 완료 (서명 포함)
*/
const completeTraining = async (trainingId, signatureData, callback) => {
try {
const db = await getDb();
const [result] = await db.query(
`UPDATE safety_training_records
SET signature_data = ?, completed_at = NOW(), updated_at = NOW()
WHERE training_id = ?`,
[signatureData, trainingId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 안전교육 목록 조회 (날짜별 필터)
*/
const getTrainingRecords = async (filters = {}, callback) => {
try {
const db = await getDb();
let query = `
SELECT
str.training_id, str.request_id, str.trainer_id, str.training_date,
str.training_start_time, str.training_end_time, str.training_topics,
str.completed_at, str.created_at, str.updated_at,
u.username as trainer_name, u.name as trainer_full_name,
vr.visitor_company, vr.visitor_count, vr.visit_date
FROM safety_training_records str
INNER JOIN users u ON str.trainer_id = u.user_id
INNER JOIN workplace_visit_requests vr ON str.request_id = vr.request_id
WHERE 1=1
`;
const params = [];
if (filters.training_date) {
query += ` AND str.training_date = ?`;
params.push(filters.training_date);
}
if (filters.start_date && filters.end_date) {
query += ` AND str.training_date BETWEEN ? AND ?`;
params.push(filters.start_date, filters.end_date);
}
if (filters.trainer_id) {
query += ` AND str.trainer_id = ?`;
params.push(filters.trainer_id);
}
query += ` ORDER BY str.training_date DESC, str.training_start_time DESC`;
const [rows] = await db.query(query, params);
callback(null, rows);
} catch (err) {
callback(err);
}
};
module.exports = {
// 출입 신청
createVisitRequest,
getAllVisitRequests,
getVisitRequestById,
updateVisitRequest,
deleteVisitRequest,
approveVisitRequest,
rejectVisitRequest,
updateVisitRequestStatus,
// 방문 목적
getAllVisitPurposes,
getActiveVisitPurposes,
createVisitPurpose,
updateVisitPurpose,
deleteVisitPurpose,
// 안전교육
createTrainingRecord,
getTrainingRecordByRequestId,
updateTrainingRecord,
completeTraining,
getTrainingRecords
};

View File

@@ -0,0 +1,887 @@
/**
* 작업 중 문제 신고 모델
* 부적합/안전 신고 관련 DB 쿼리
*/
const { getDb } = require('../dbPool');
// ==================== 신고 카테고리 관리 ====================
/**
* 모든 신고 카테고리 조회
*/
const getAllCategories = async (callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT category_id, category_type, category_name, description, display_order, is_active, created_at
FROM issue_report_categories
ORDER BY category_type, display_order, category_id`
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 타입별 활성 카테고리 조회 (nonconformity/safety)
*/
const getCategoriesByType = async (categoryType, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT category_id, category_type, category_name, description, display_order
FROM issue_report_categories
WHERE category_type = ? AND is_active = TRUE
ORDER BY display_order, category_id`,
[categoryType]
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 카테고리 생성
*/
const createCategory = async (categoryData, callback) => {
try {
const db = await getDb();
const { category_type, category_name, description = null, display_order = 0 } = categoryData;
const [result] = await db.query(
`INSERT INTO issue_report_categories (category_type, category_name, description, display_order)
VALUES (?, ?, ?, ?)`,
[category_type, category_name, description, display_order]
);
callback(null, result.insertId);
} catch (err) {
callback(err);
}
};
/**
* 카테고리 수정
*/
const updateCategory = async (categoryId, categoryData, callback) => {
try {
const db = await getDb();
const { category_name, description, display_order, is_active } = categoryData;
const [result] = await db.query(
`UPDATE issue_report_categories
SET category_name = ?, description = ?, display_order = ?, is_active = ?
WHERE category_id = ?`,
[category_name, description, display_order, is_active, categoryId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 카테고리 삭제
*/
const deleteCategory = async (categoryId, callback) => {
try {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM issue_report_categories WHERE category_id = ?`,
[categoryId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
// ==================== 사전 정의 신고 항목 관리 ====================
/**
* 카테고리별 활성 항목 조회
*/
const getItemsByCategory = async (categoryId, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT item_id, category_id, item_name, description, severity, display_order
FROM issue_report_items
WHERE category_id = ? AND is_active = TRUE
ORDER BY display_order, item_id`,
[categoryId]
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 모든 항목 조회 (관리용)
*/
const getAllItems = async (callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT iri.item_id, iri.category_id, iri.item_name, iri.description,
iri.severity, iri.display_order, iri.is_active, iri.created_at,
irc.category_name, irc.category_type
FROM issue_report_items iri
INNER JOIN issue_report_categories irc ON iri.category_id = irc.category_id
ORDER BY irc.category_type, irc.display_order, iri.display_order, iri.item_id`
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 항목 생성
*/
const createItem = async (itemData, callback) => {
try {
const db = await getDb();
const { category_id, item_name, description = null, severity = 'medium', display_order = 0 } = itemData;
const [result] = await db.query(
`INSERT INTO issue_report_items (category_id, item_name, description, severity, display_order)
VALUES (?, ?, ?, ?, ?)`,
[category_id, item_name, description, severity, display_order]
);
callback(null, result.insertId);
} catch (err) {
callback(err);
}
};
/**
* 항목 수정
*/
const updateItem = async (itemId, itemData, callback) => {
try {
const db = await getDb();
const { item_name, description, severity, display_order, is_active } = itemData;
const [result] = await db.query(
`UPDATE issue_report_items
SET item_name = ?, description = ?, severity = ?, display_order = ?, is_active = ?
WHERE item_id = ?`,
[item_name, description, severity, display_order, is_active, itemId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 항목 삭제
*/
const deleteItem = async (itemId, callback) => {
try {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM issue_report_items WHERE item_id = ?`,
[itemId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
// ==================== 문제 신고 관리 ====================
// 한국 시간 유틸리티 import
const { getKoreaDatetime } = require('../utils/dateUtils');
/**
* 신고 생성
*/
const createReport = async (reportData, callback) => {
try {
const db = await getDb();
const {
reporter_id,
factory_category_id = null,
workplace_id = null,
custom_location = null,
tbm_session_id = null,
visit_request_id = null,
issue_category_id,
issue_item_id = null,
additional_description = null,
photo_path1 = null,
photo_path2 = null,
photo_path3 = null,
photo_path4 = null,
photo_path5 = null
} = reportData;
// 한국 시간 기준으로 신고 일시 설정
const reportDate = getKoreaDatetime();
const [result] = await db.query(
`INSERT INTO work_issue_reports
(reporter_id, report_date, factory_category_id, workplace_id, custom_location,
tbm_session_id, visit_request_id, issue_category_id, issue_item_id,
additional_description, photo_path1, photo_path2, photo_path3, photo_path4, photo_path5)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[reporter_id, reportDate, factory_category_id, workplace_id, custom_location,
tbm_session_id, visit_request_id, issue_category_id, issue_item_id,
additional_description, photo_path1, photo_path2, photo_path3, photo_path4, photo_path5]
);
// 상태 변경 로그 기록
await db.query(
`INSERT INTO work_issue_status_logs (report_id, previous_status, new_status, changed_by)
VALUES (?, NULL, 'reported', ?)`,
[result.insertId, reporter_id]
);
callback(null, result.insertId);
} catch (err) {
callback(err);
}
};
/**
* 신고 목록 조회 (필터 옵션 포함)
*/
const getAllReports = async (filters = {}, callback) => {
try {
const db = await getDb();
let query = `
SELECT
wir.report_id, wir.reporter_id, wir.report_date,
wir.factory_category_id, wir.workplace_id, wir.custom_location,
wir.tbm_session_id, wir.visit_request_id,
wir.issue_category_id, wir.issue_item_id, wir.additional_description,
wir.photo_path1, wir.photo_path2, wir.photo_path3, wir.photo_path4, wir.photo_path5,
wir.status, wir.assigned_department, wir.assigned_user_id, wir.assigned_at,
wir.resolution_notes, wir.resolved_at,
wir.created_at, wir.updated_at,
u.username as reporter_name, u.name as reporter_full_name,
wc.category_name as factory_name,
w.workplace_name,
irc.category_type, irc.category_name as issue_category_name,
iri.item_name as issue_item_name, iri.severity,
assignee.username as assigned_user_name, assignee.name as assigned_full_name
FROM work_issue_reports wir
INNER JOIN users u ON wir.reporter_id = u.user_id
LEFT JOIN workplace_categories wc ON wir.factory_category_id = wc.category_id
LEFT JOIN workplaces w ON wir.workplace_id = w.workplace_id
INNER JOIN issue_report_categories irc ON wir.issue_category_id = irc.category_id
LEFT JOIN issue_report_items iri ON wir.issue_item_id = iri.item_id
LEFT JOIN users assignee ON wir.assigned_user_id = assignee.user_id
WHERE 1=1
`;
const params = [];
// 필터 적용
if (filters.status) {
query += ` AND wir.status = ?`;
params.push(filters.status);
}
if (filters.category_type) {
query += ` AND irc.category_type = ?`;
params.push(filters.category_type);
}
if (filters.issue_category_id) {
query += ` AND wir.issue_category_id = ?`;
params.push(filters.issue_category_id);
}
if (filters.factory_category_id) {
query += ` AND wir.factory_category_id = ?`;
params.push(filters.factory_category_id);
}
if (filters.workplace_id) {
query += ` AND wir.workplace_id = ?`;
params.push(filters.workplace_id);
}
if (filters.reporter_id) {
query += ` AND wir.reporter_id = ?`;
params.push(filters.reporter_id);
}
if (filters.assigned_user_id) {
query += ` AND wir.assigned_user_id = ?`;
params.push(filters.assigned_user_id);
}
if (filters.start_date && filters.end_date) {
query += ` AND DATE(wir.report_date) BETWEEN ? AND ?`;
params.push(filters.start_date, filters.end_date);
}
if (filters.search) {
query += ` AND (wir.additional_description LIKE ? OR iri.item_name LIKE ? OR wir.custom_location LIKE ?)`;
const searchTerm = `%${filters.search}%`;
params.push(searchTerm, searchTerm, searchTerm);
}
query += ` ORDER BY wir.report_date DESC, wir.report_id DESC`;
// 페이지네이션
if (filters.limit) {
query += ` LIMIT ?`;
params.push(parseInt(filters.limit));
if (filters.offset) {
query += ` OFFSET ?`;
params.push(parseInt(filters.offset));
}
}
const [rows] = await db.query(query, params);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 신고 상세 조회
*/
const getReportById = async (reportId, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT
wir.report_id, wir.reporter_id, wir.report_date,
wir.factory_category_id, wir.workplace_id, wir.custom_location,
wir.tbm_session_id, wir.visit_request_id,
wir.issue_category_id, wir.issue_item_id, wir.additional_description,
wir.photo_path1, wir.photo_path2, wir.photo_path3, wir.photo_path4, wir.photo_path5,
wir.status, wir.assigned_department, wir.assigned_user_id, wir.assigned_at, wir.assigned_by,
wir.resolution_notes, wir.resolution_photo_path1, wir.resolution_photo_path2,
wir.resolved_at, wir.resolved_by,
wir.modification_history,
wir.created_at, wir.updated_at,
u.username as reporter_name, u.name as reporter_full_name,
wc.category_name as factory_name,
w.workplace_name,
irc.category_type, irc.category_name as issue_category_name,
iri.item_name as issue_item_name, iri.severity,
assignee.username as assigned_user_name, assignee.name as assigned_full_name,
assigner.username as assigned_by_name,
resolver.username as resolved_by_name
FROM work_issue_reports wir
INNER JOIN users u ON wir.reporter_id = u.user_id
LEFT JOIN workplace_categories wc ON wir.factory_category_id = wc.category_id
LEFT JOIN workplaces w ON wir.workplace_id = w.workplace_id
INNER JOIN issue_report_categories irc ON wir.issue_category_id = irc.category_id
LEFT JOIN issue_report_items iri ON wir.issue_item_id = iri.item_id
LEFT JOIN users assignee ON wir.assigned_user_id = assignee.user_id
LEFT JOIN users assigner ON wir.assigned_by = assigner.user_id
LEFT JOIN users resolver ON wir.resolved_by = resolver.user_id
WHERE wir.report_id = ?`,
[reportId]
);
callback(null, rows[0]);
} catch (err) {
callback(err);
}
};
/**
* 신고 수정
*/
const updateReport = async (reportId, reportData, userId, callback) => {
try {
const db = await getDb();
// 기존 데이터 조회
const [existing] = await db.query(
`SELECT * FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
if (existing.length === 0) {
return callback(new Error('신고를 찾을 수 없습니다.'));
}
const current = existing[0];
// 수정 이력 생성
const modifications = [];
const now = new Date().toISOString();
for (const key of Object.keys(reportData)) {
if (current[key] !== reportData[key] && reportData[key] !== undefined) {
modifications.push({
field: key,
old_value: current[key],
new_value: reportData[key],
modified_at: now,
modified_by: userId
});
}
}
// 기존 이력과 병합
const existingHistory = current.modification_history ? JSON.parse(current.modification_history) : [];
const newHistory = [...existingHistory, ...modifications];
const {
factory_category_id,
workplace_id,
custom_location,
issue_category_id,
issue_item_id,
additional_description,
photo_path1,
photo_path2,
photo_path3,
photo_path4,
photo_path5
} = reportData;
const [result] = await db.query(
`UPDATE work_issue_reports
SET factory_category_id = COALESCE(?, factory_category_id),
workplace_id = COALESCE(?, workplace_id),
custom_location = COALESCE(?, custom_location),
issue_category_id = COALESCE(?, issue_category_id),
issue_item_id = COALESCE(?, issue_item_id),
additional_description = COALESCE(?, additional_description),
photo_path1 = COALESCE(?, photo_path1),
photo_path2 = COALESCE(?, photo_path2),
photo_path3 = COALESCE(?, photo_path3),
photo_path4 = COALESCE(?, photo_path4),
photo_path5 = COALESCE(?, photo_path5),
modification_history = ?,
updated_at = NOW()
WHERE report_id = ?`,
[factory_category_id, workplace_id, custom_location,
issue_category_id, issue_item_id, additional_description,
photo_path1, photo_path2, photo_path3, photo_path4, photo_path5,
JSON.stringify(newHistory), reportId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 신고 삭제
*/
const deleteReport = async (reportId, callback) => {
try {
const db = await getDb();
// 먼저 사진 경로 조회 (삭제용)
const [photos] = await db.query(
`SELECT photo_path1, photo_path2, photo_path3, photo_path4, photo_path5,
resolution_photo_path1, resolution_photo_path2
FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
const [result] = await db.query(
`DELETE FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
// 삭제할 사진 경로 반환
callback(null, { result, photos: photos[0] });
} catch (err) {
callback(err);
}
};
// ==================== 상태 관리 ====================
/**
* 신고 접수 (reported → received)
*/
const receiveReport = async (reportId, userId, callback) => {
try {
const db = await getDb();
// 현재 상태 확인
const [current] = await db.query(
`SELECT status FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
if (current.length === 0) {
return callback(new Error('신고를 찾을 수 없습니다.'));
}
if (current[0].status !== 'reported') {
return callback(new Error('접수 대기 상태가 아닙니다.'));
}
const [result] = await db.query(
`UPDATE work_issue_reports
SET status = 'received', updated_at = NOW()
WHERE report_id = ?`,
[reportId]
);
// 상태 변경 로그
await db.query(
`INSERT INTO work_issue_status_logs (report_id, previous_status, new_status, changed_by)
VALUES (?, 'reported', 'received', ?)`,
[reportId, userId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 담당자 배정
*/
const assignReport = async (reportId, assignData, callback) => {
try {
const db = await getDb();
const { assigned_department, assigned_user_id, assigned_by } = assignData;
// 현재 상태 확인
const [current] = await db.query(
`SELECT status FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
if (current.length === 0) {
return callback(new Error('신고를 찾을 수 없습니다.'));
}
// 접수 상태 이상이어야 배정 가능
const validStatuses = ['received', 'in_progress'];
if (!validStatuses.includes(current[0].status)) {
return callback(new Error('접수된 상태에서만 담당자 배정이 가능합니다.'));
}
const [result] = await db.query(
`UPDATE work_issue_reports
SET assigned_department = ?, assigned_user_id = ?,
assigned_at = NOW(), assigned_by = ?, updated_at = NOW()
WHERE report_id = ?`,
[assigned_department, assigned_user_id, assigned_by, reportId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 처리 시작 (received → in_progress)
*/
const startProcessing = async (reportId, userId, callback) => {
try {
const db = await getDb();
// 현재 상태 확인
const [current] = await db.query(
`SELECT status FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
if (current.length === 0) {
return callback(new Error('신고를 찾을 수 없습니다.'));
}
if (current[0].status !== 'received') {
return callback(new Error('접수된 상태에서만 처리를 시작할 수 있습니다.'));
}
const [result] = await db.query(
`UPDATE work_issue_reports
SET status = 'in_progress', updated_at = NOW()
WHERE report_id = ?`,
[reportId]
);
// 상태 변경 로그
await db.query(
`INSERT INTO work_issue_status_logs (report_id, previous_status, new_status, changed_by)
VALUES (?, 'received', 'in_progress', ?)`,
[reportId, userId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 처리 완료 (in_progress → completed)
*/
const completeReport = async (reportId, completionData, callback) => {
try {
const db = await getDb();
const { resolution_notes, resolution_photo_path1, resolution_photo_path2, resolved_by } = completionData;
// 현재 상태 확인
const [current] = await db.query(
`SELECT status FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
if (current.length === 0) {
return callback(new Error('신고를 찾을 수 없습니다.'));
}
if (current[0].status !== 'in_progress') {
return callback(new Error('처리 중 상태에서만 완료할 수 있습니다.'));
}
const [result] = await db.query(
`UPDATE work_issue_reports
SET status = 'completed', resolution_notes = ?,
resolution_photo_path1 = ?, resolution_photo_path2 = ?,
resolved_at = NOW(), resolved_by = ?, updated_at = NOW()
WHERE report_id = ?`,
[resolution_notes, resolution_photo_path1, resolution_photo_path2, resolved_by, reportId]
);
// 상태 변경 로그
await db.query(
`INSERT INTO work_issue_status_logs (report_id, previous_status, new_status, changed_by, change_reason)
VALUES (?, 'in_progress', 'completed', ?, ?)`,
[reportId, resolved_by, resolution_notes]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 신고 종료 (completed → closed)
*/
const closeReport = async (reportId, userId, callback) => {
try {
const db = await getDb();
// 현재 상태 확인
const [current] = await db.query(
`SELECT status FROM work_issue_reports WHERE report_id = ?`,
[reportId]
);
if (current.length === 0) {
return callback(new Error('신고를 찾을 수 없습니다.'));
}
if (current[0].status !== 'completed') {
return callback(new Error('완료된 상태에서만 종료할 수 있습니다.'));
}
const [result] = await db.query(
`UPDATE work_issue_reports
SET status = 'closed', updated_at = NOW()
WHERE report_id = ?`,
[reportId]
);
// 상태 변경 로그
await db.query(
`INSERT INTO work_issue_status_logs (report_id, previous_status, new_status, changed_by)
VALUES (?, 'completed', 'closed', ?)`,
[reportId, userId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 상태 변경 이력 조회
*/
const getStatusLogs = async (reportId, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT wisl.log_id, wisl.report_id, wisl.previous_status, wisl.new_status,
wisl.changed_by, wisl.change_reason, wisl.changed_at,
u.username as changed_by_name, u.name as changed_by_full_name
FROM work_issue_status_logs wisl
INNER JOIN users u ON wisl.changed_by = u.user_id
WHERE wisl.report_id = ?
ORDER BY wisl.changed_at ASC`,
[reportId]
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
// ==================== 통계 ====================
/**
* 신고 통계 요약
*/
const getStatsSummary = async (filters = {}, callback) => {
try {
const db = await getDb();
let whereClause = '1=1';
const params = [];
if (filters.start_date && filters.end_date) {
whereClause += ` AND DATE(report_date) BETWEEN ? AND ?`;
params.push(filters.start_date, filters.end_date);
}
if (filters.factory_category_id) {
whereClause += ` AND factory_category_id = ?`;
params.push(filters.factory_category_id);
}
const [rows] = await db.query(
`SELECT
COUNT(*) as total,
SUM(CASE WHEN status = 'reported' THEN 1 ELSE 0 END) as reported,
SUM(CASE WHEN status = 'received' THEN 1 ELSE 0 END) as received,
SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END) as closed
FROM work_issue_reports
WHERE ${whereClause}`,
params
);
callback(null, rows[0]);
} catch (err) {
callback(err);
}
};
/**
* 카테고리별 통계
*/
const getStatsByCategory = async (filters = {}, callback) => {
try {
const db = await getDb();
let whereClause = '1=1';
const params = [];
if (filters.start_date && filters.end_date) {
whereClause += ` AND DATE(wir.report_date) BETWEEN ? AND ?`;
params.push(filters.start_date, filters.end_date);
}
const [rows] = await db.query(
`SELECT
irc.category_type, irc.category_name,
COUNT(*) as count
FROM work_issue_reports wir
INNER JOIN issue_report_categories irc ON wir.issue_category_id = irc.category_id
WHERE ${whereClause}
GROUP BY irc.category_id
ORDER BY irc.category_type, count DESC`,
params
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 작업장별 통계
*/
const getStatsByWorkplace = async (filters = {}, callback) => {
try {
const db = await getDb();
let whereClause = 'wir.workplace_id IS NOT NULL';
const params = [];
if (filters.start_date && filters.end_date) {
whereClause += ` AND DATE(wir.report_date) BETWEEN ? AND ?`;
params.push(filters.start_date, filters.end_date);
}
if (filters.factory_category_id) {
whereClause += ` AND wir.factory_category_id = ?`;
params.push(filters.factory_category_id);
}
const [rows] = await db.query(
`SELECT
wir.factory_category_id, wc.category_name as factory_name,
wir.workplace_id, w.workplace_name,
COUNT(*) as count
FROM work_issue_reports wir
INNER JOIN workplace_categories wc ON wir.factory_category_id = wc.category_id
INNER JOIN workplaces w ON wir.workplace_id = w.workplace_id
WHERE ${whereClause}
GROUP BY wir.factory_category_id, wir.workplace_id
ORDER BY count DESC`,
params
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
module.exports = {
// 카테고리
getAllCategories,
getCategoriesByType,
createCategory,
updateCategory,
deleteCategory,
// 항목
getItemsByCategory,
getAllItems,
createItem,
updateItem,
deleteItem,
// 신고
createReport,
getAllReports,
getReportById,
updateReport,
deleteReport,
// 상태 관리
receiveReport,
assignReport,
startProcessing,
completeReport,
closeReport,
getStatusLogs,
// 통계
getStatsSummary,
getStatsByCategory,
getStatsByWorkplace
};

View File

@@ -0,0 +1,223 @@
const { getDb } = require('../dbPool');
/**
* 1. 여러 건 등록 (트랜잭션 사용)
*/
const createBatch = async (reports, callback) => {
const db = await getDb();
const conn = await db.getConnection();
try {
await conn.beginTransaction();
const sql = `
INSERT INTO WorkReports
(\`date\`, worker_id, project_id, task_id, overtime_hours, work_details, memo)
VALUES (?, ?, ?, ?, ?, ?, ?)
`;
for (const rpt of reports) {
const params = [
rpt.date,
rpt.worker_id,
rpt.project_id,
rpt.task_id || null,
rpt.overtime_hours || null,
rpt.work_details || null,
rpt.memo || null
];
await conn.query(sql, params);
}
await conn.commit();
callback(null);
} catch (err) {
await conn.rollback();
callback(err);
} finally {
conn.release();
}
};
/**
* 2. 단일 등록
*/
const create = async (report, callback) => {
try {
const db = await getDb();
const {
date, worker_id, project_id,
task_id, overtime_hours,
work_details, memo
} = report;
const [result] = await db.query(
`INSERT INTO WorkReports
(\`date\`, worker_id, project_id, task_id, overtime_hours, work_details, memo)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
date,
worker_id,
project_id,
task_id || null,
overtime_hours || null,
work_details || null,
memo || null
]
);
callback(null, result.insertId);
} catch (err) {
callback(err);
}
};
/**
* 3. 날짜별 조회
*/
const getAllByDate = async (date, callback) => {
try {
const db = await getDb();
const sql = `
SELECT
wr.worker_id, -- 이 줄을 추가했습니다
wr.id,
wr.\`date\`,
w.worker_name,
p.project_name,
CONCAT(t.category, ':', t.subcategory) AS task_name,
wr.overtime_hours,
wr.work_details,
wr.memo
FROM WorkReports wr
LEFT JOIN workers w ON wr.worker_id = w.worker_id
LEFT JOIN projects p ON wr.project_id = p.project_id
LEFT JOIN Tasks t ON wr.task_id = t.task_id
WHERE wr.\`date\` = ?
ORDER BY w.worker_name ASC
`;
const [rows] = await db.query(sql, [date]);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 4. 기간 조회
*/
const getByRange = async (start, end, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT id, \`date\`, worker_id, project_id, morning_task_id, afternoon_task_id, overtime_hours, overtime_task_id, work_details, note, memo, created_at, updated_at, morning_project_id, afternoon_project_id, overtime_project_id, task_id FROM WorkReports
WHERE \`date\` BETWEEN ? AND ?
ORDER BY \`date\` ASC`,
[start, end]
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 5. ID로 조회
*/
const getById = async (id, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT id, \`date\`, worker_id, project_id, morning_task_id, afternoon_task_id, overtime_hours, overtime_task_id, work_details, note, memo, created_at, updated_at, morning_project_id, afternoon_project_id, overtime_project_id, task_id FROM WorkReports WHERE id = ?`,
[id]
);
callback(null, rows[0]);
} catch (err) {
callback(err);
}
};
/**
* 6. 수정
*/
const update = async (id, report, callback) => {
try {
const db = await getDb();
const {
date, worker_id, project_id,
task_id, overtime_hours,
work_details, memo
} = report;
const [result] = await db.query(
`UPDATE WorkReports
SET \`date\` = ?,
worker_id = ?,
project_id = ?,
task_id = ?,
overtime_hours = ?,
work_details = ?,
memo = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
[
date,
worker_id,
project_id,
task_id || null,
overtime_hours || null,
work_details || null,
memo || null,
id
]
);
callback(null, result.affectedRows);
} catch (err) {
callback(err);
}
};
/**
* 7. 삭제
*/
const remove = async (id, callback) => {
try {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM WorkReports WHERE id = ?`,
[id]
);
callback(null, result.affectedRows);
} catch (err) {
callback(new Error(err.message || String(err)));
}
};
/**
* 8. 중복 확인
*/
const existsByDateAndWorker = async (date, worker_id, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT 1 FROM WorkReports WHERE \`date\` = ? AND worker_id = ? LIMIT 1`,
[date, worker_id]
);
callback(null, rows.length > 0);
} catch (err) {
callback(err);
}
};
// ✅ 내보내기
module.exports = {
create,
createBatch,
getAllByDate,
getByRange,
getById,
update,
remove,
existsByDateAndWorker
};

View File

@@ -0,0 +1,197 @@
const { getDb } = require('../dbPool');
// 날짜 형식 변환 헬퍼 함수 (ISO 8601 -> MySQL DATE)
const formatDate = (dateStr) => {
if (!dateStr) return null;
if (typeof dateStr === 'string' && dateStr.includes('T')) {
return dateStr.split('T')[0]; // ISO 8601 -> YYYY-MM-DD
}
return dateStr;
};
// 1. 작업자 생성
const create = async (worker) => {
const db = await getDb();
const {
worker_name,
job_type = null,
join_date = null,
salary = null,
annual_leave = null,
status = 'active',
employment_status = 'employed',
department_id = null
} = worker;
const [result] = await db.query(
`INSERT INTO workers
(worker_name, job_type, join_date, salary, annual_leave, status, employment_status, department_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[worker_name, job_type, formatDate(join_date), salary, annual_leave, status, employment_status, department_id]
);
return result.insertId;
};
// 2. 전체 조회
const getAll = async () => {
const db = await getDb();
const [rows] = await db.query(`
SELECT
w.*,
CASE WHEN w.status = 'active' THEN 1 ELSE 0 END AS is_active,
u.user_id,
d.department_name
FROM workers w
LEFT JOIN users u ON w.worker_id = u.worker_id
LEFT JOIN departments d ON w.department_id = d.department_id
ORDER BY w.worker_id DESC
`);
return rows;
};
// 3. 단일 조회
const getById = async (worker_id) => {
const db = await getDb();
const [rows] = await db.query(`
SELECT
w.*,
CASE WHEN w.status = 'active' THEN 1 ELSE 0 END AS is_active,
u.user_id,
d.department_name
FROM workers w
LEFT JOIN users u ON w.worker_id = u.worker_id
LEFT JOIN departments d ON w.department_id = d.department_id
WHERE w.worker_id = ?
`, [worker_id]);
return rows[0];
};
// 4. 작업자 수정
const update = async (worker) => {
const db = await getDb();
const {
worker_id,
worker_name,
job_type,
status,
join_date,
salary,
annual_leave,
employment_status,
department_id
} = worker;
// 업데이트할 필드만 동적으로 구성
const updates = [];
const values = [];
if (worker_name !== undefined) {
updates.push('worker_name = ?');
values.push(worker_name);
}
if (job_type !== undefined) {
updates.push('job_type = ?');
values.push(job_type);
}
if (status !== undefined) {
updates.push('status = ?');
values.push(status);
}
if (join_date !== undefined) {
updates.push('join_date = ?');
values.push(formatDate(join_date));
}
if (salary !== undefined) {
updates.push('salary = ?');
values.push(salary);
}
if (annual_leave !== undefined) {
updates.push('annual_leave = ?');
values.push(annual_leave);
}
if (employment_status !== undefined) {
updates.push('employment_status = ?');
values.push(employment_status);
}
if (department_id !== undefined) {
updates.push('department_id = ?');
values.push(department_id);
}
if (updates.length === 0) {
throw new Error('업데이트할 필드가 없습니다');
}
values.push(worker_id); // WHERE 조건용
const query = `UPDATE workers SET ${updates.join(', ')} WHERE worker_id = ?`;
console.log('🔍 실행할 SQL:', query);
console.log('🔍 SQL 파라미터:', values);
const [result] = await db.query(query, values);
return result.affectedRows;
};
// 5. 삭제 (외래키 제약조건 처리)
const remove = async (worker_id) => {
const db = await getDb();
const conn = await db.getConnection();
try {
await conn.beginTransaction();
console.log(`🗑️ 작업자 삭제 시작: worker_id=${worker_id}`);
// 안전한 삭제: 각 테이블을 개별적으로 처리하고 오류가 발생해도 계속 진행
const tables = [
{ name: 'users', query: 'UPDATE users SET worker_id = NULL WHERE worker_id = ?', action: '업데이트' },
{ name: 'Users', query: 'UPDATE Users SET worker_id = NULL WHERE worker_id = ?', action: '업데이트' },
{ name: 'daily_issue_reports', query: 'DELETE FROM daily_issue_reports WHERE worker_id = ?', action: '삭제' },
{ name: 'DailyIssueReports', query: 'DELETE FROM DailyIssueReports WHERE worker_id = ?', action: '삭제' },
{ name: 'work_reports', query: 'DELETE FROM work_reports WHERE worker_id = ?', action: '삭제' },
{ name: 'WorkReports', query: 'DELETE FROM WorkReports WHERE worker_id = ?', action: '삭제' },
{ name: 'daily_work_reports', query: 'DELETE FROM daily_work_reports WHERE worker_id = ?', action: '삭제' },
{ name: 'monthly_worker_status', query: 'DELETE FROM monthly_worker_status WHERE worker_id = ?', action: '삭제' },
{ name: 'worker_groups', query: 'DELETE FROM worker_groups WHERE worker_id = ?', action: '삭제' }
];
for (const table of tables) {
try {
const [result] = await conn.query(table.query, [worker_id]);
if (result.affectedRows > 0) {
console.log(`${table.name} 테이블 ${table.action}: ${result.affectedRows}`);
}
} catch (tableError) {
console.log(`⚠️ ${table.name} 테이블 ${table.action} 실패 (무시): ${tableError.message}`);
}
}
// 마지막으로 작업자 삭제
const [result] = await conn.query(
`DELETE FROM workers WHERE worker_id = ?`,
[worker_id]
);
console.log(`✅ 작업자 삭제 완료: ${result.affectedRows}`);
await conn.commit();
return result.affectedRows;
} catch (err) {
await conn.rollback();
console.error(`❌ 작업자 삭제 오류 (worker_id: ${worker_id}):`, err);
throw new Error(`작업자 삭제 중 오류가 발생했습니다: ${err.message}`);
} finally {
conn.release();
}
};
module.exports = {
create,
getAll,
getById,
update,
remove
};

View File

@@ -0,0 +1,440 @@
const { getDb } = require('../dbPool');
// ==================== 카테고리(공장) 관련 ====================
/**
* 카테고리 생성
*/
const createCategory = async (category, callback) => {
try {
const db = await getDb();
const {
category_name,
description = null,
display_order = 0,
is_active = true
} = category;
const [result] = await db.query(
`INSERT INTO workplace_categories
(category_name, description, display_order, is_active)
VALUES (?, ?, ?, ?)`,
[category_name, description, display_order, is_active]
);
callback(null, result.insertId);
} catch (err) {
callback(err);
}
};
/**
* 모든 카테고리 조회
*/
const getAllCategories = async (callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT category_id, category_name, description, display_order, is_active, layout_image, created_at, updated_at
FROM workplace_categories
ORDER BY display_order ASC, category_id ASC`
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 활성 카테고리만 조회
*/
const getActiveCategories = async (callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT category_id, category_name, description, display_order, is_active, layout_image, created_at, updated_at
FROM workplace_categories
WHERE is_active = TRUE
ORDER BY display_order ASC, category_id ASC`
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* ID로 카테고리 조회
*/
const getCategoryById = async (categoryId, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT category_id, category_name, description, display_order, is_active, layout_image, created_at, updated_at
FROM workplace_categories
WHERE category_id = ?`,
[categoryId]
);
callback(null, rows[0]);
} catch (err) {
callback(err);
}
};
/**
* 카테고리 수정
*/
const updateCategory = async (categoryId, category, callback) => {
try {
const db = await getDb();
const {
category_name,
description,
display_order,
is_active,
layout_image
} = category;
const [result] = await db.query(
`UPDATE workplace_categories
SET category_name = ?, description = ?, display_order = ?, is_active = ?, layout_image = ?, updated_at = NOW()
WHERE category_id = ?`,
[category_name, description, display_order, is_active, layout_image, categoryId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 카테고리 삭제
*/
const deleteCategory = async (categoryId, callback) => {
try {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM workplace_categories WHERE category_id = ?`,
[categoryId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
// ==================== 작업장 관련 ====================
/**
* 작업장 생성
*/
const createWorkplace = async (workplace, callback) => {
try {
const db = await getDb();
const {
category_id = null,
workplace_name,
description = null,
is_active = true,
workplace_purpose = null,
display_priority = 0
} = workplace;
const [result] = await db.query(
`INSERT INTO workplaces
(category_id, workplace_name, description, is_active, workplace_purpose, display_priority)
VALUES (?, ?, ?, ?, ?, ?)`,
[category_id, workplace_name, description, is_active, workplace_purpose, display_priority]
);
callback(null, result.insertId);
} catch (err) {
callback(err);
}
};
/**
* 모든 작업장 조회 (카테고리 정보 포함)
*/
const getAllWorkplaces = async (callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT w.workplace_id, w.category_id, w.workplace_name, w.description, w.is_active, w.workplace_purpose, w.display_priority,
w.layout_image, w.created_at, w.updated_at,
wc.category_name
FROM workplaces w
LEFT JOIN workplace_categories wc ON w.category_id = wc.category_id
ORDER BY wc.display_order ASC, w.display_priority ASC, w.workplace_id DESC`
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 활성 작업장만 조회
*/
const getActiveWorkplaces = async (callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT w.workplace_id, w.category_id, w.workplace_name, w.description, w.is_active, w.workplace_purpose, w.display_priority,
w.layout_image, w.created_at, w.updated_at,
wc.category_name
FROM workplaces w
LEFT JOIN workplace_categories wc ON w.category_id = wc.category_id
WHERE w.is_active = TRUE
ORDER BY wc.display_order ASC, w.workplace_id DESC`
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 카테고리별 작업장 조회
*/
const getWorkplacesByCategory = async (categoryId, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT w.workplace_id, w.category_id, w.workplace_name, w.description, w.is_active, w.workplace_purpose, w.display_priority,
w.layout_image, w.created_at, w.updated_at,
wc.category_name
FROM workplaces w
LEFT JOIN workplace_categories wc ON w.category_id = wc.category_id
WHERE w.category_id = ?
ORDER BY w.workplace_id DESC`,
[categoryId]
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* ID로 작업장 조회
*/
const getWorkplaceById = async (workplaceId, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT w.workplace_id, w.category_id, w.workplace_name, w.description, w.is_active, w.workplace_purpose, w.display_priority,
w.layout_image, w.created_at, w.updated_at,
wc.category_name
FROM workplaces w
LEFT JOIN workplace_categories wc ON w.category_id = wc.category_id
WHERE w.workplace_id = ?`,
[workplaceId]
);
callback(null, rows[0]);
} catch (err) {
callback(err);
}
};
/**
* 작업장 수정
*/
const updateWorkplace = async (workplaceId, workplace, callback) => {
try {
const db = await getDb();
const {
category_id,
workplace_name,
description,
is_active,
workplace_purpose,
display_priority,
layout_image
} = workplace;
const [result] = await db.query(
`UPDATE workplaces
SET category_id = ?, workplace_name = ?, description = ?, is_active = ?,
workplace_purpose = ?, display_priority = ?, layout_image = ?, updated_at = NOW()
WHERE workplace_id = ?`,
[category_id, workplace_name, description, is_active, workplace_purpose, display_priority, layout_image, workplaceId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 작업장 삭제
*/
const deleteWorkplace = async (workplaceId, callback) => {
try {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM workplaces WHERE workplace_id = ?`,
[workplaceId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
// ==================== 작업장 지도 영역 관련 ====================
/**
* 작업장 지도 영역 생성
*/
const createMapRegion = async (region, callback) => {
try {
const db = await getDb();
const {
workplace_id,
category_id,
x_start,
y_start,
x_end,
y_end,
shape = 'rect',
polygon_points = null
} = region;
const [result] = await db.query(
`INSERT INTO workplace_map_regions
(workplace_id, category_id, x_start, y_start, x_end, y_end, shape, polygon_points)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[workplace_id, category_id, x_start, y_start, x_end, y_end, shape, polygon_points]
);
callback(null, result.insertId);
} catch (err) {
callback(err);
}
};
/**
* 카테고리(공장)별 지도 영역 조회
*/
const getMapRegionsByCategory = async (categoryId, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT mr.*, w.workplace_name, w.description
FROM workplace_map_regions mr
INNER JOIN workplaces w ON mr.workplace_id = w.workplace_id
WHERE mr.category_id = ? AND w.is_active = TRUE
ORDER BY mr.region_id ASC`,
[categoryId]
);
callback(null, rows);
} catch (err) {
callback(err);
}
};
/**
* 작업장별 지도 영역 조회
*/
const getMapRegionByWorkplace = async (workplaceId, callback) => {
try {
const db = await getDb();
const [rows] = await db.query(
`SELECT * FROM workplace_map_regions WHERE workplace_id = ?`,
[workplaceId]
);
callback(null, rows[0]);
} catch (err) {
callback(err);
}
};
/**
* 지도 영역 수정
*/
const updateMapRegion = async (regionId, region, callback) => {
try {
const db = await getDb();
const {
x_start,
y_start,
x_end,
y_end,
shape,
polygon_points
} = region;
const [result] = await db.query(
`UPDATE workplace_map_regions
SET x_start = ?, y_start = ?, x_end = ?, y_end = ?, shape = ?, polygon_points = ?, updated_at = NOW()
WHERE region_id = ?`,
[x_start, y_start, x_end, y_end, shape, polygon_points, regionId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 지도 영역 삭제
*/
const deleteMapRegion = async (regionId, callback) => {
try {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM workplace_map_regions WHERE region_id = ?`,
[regionId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
/**
* 작업장 영역 일괄 삭제 (카테고리별)
*/
const deleteMapRegionsByCategory = async (categoryId, callback) => {
try {
const db = await getDb();
const [result] = await db.query(
`DELETE FROM workplace_map_regions WHERE category_id = ?`,
[categoryId]
);
callback(null, result);
} catch (err) {
callback(err);
}
};
module.exports = {
// 카테고리
createCategory,
getAllCategories,
getActiveCategories,
getCategoryById,
updateCategory,
deleteCategory,
// 작업장
createWorkplace,
getAllWorkplaces,
getActiveWorkplaces,
getWorkplacesByCategory,
getWorkplaceById,
updateWorkplace,
deleteWorkplace,
// 지도 영역
createMapRegion,
getMapRegionsByCategory,
getMapRegionByWorkplace,
updateMapRegion,
deleteMapRegion,
deleteMapRegionsByCategory
};