feat: 다수 기능 개선 - 순찰, 출근, 작업분석, 모바일 UI 등
- 순찰/점검 기능 개선 (zone-detail 페이지 추가) - 출근/근태 시스템 개선 (연차 조회, 근무현황) - 작업분석 대분류 그룹화 및 마이그레이션 스크립트 - 모바일 네비게이션 UI 추가 - NAS 배포 도구 및 문서 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -182,8 +182,10 @@ class WorkAnalysis {
|
||||
|
||||
// 최근 작업 현황
|
||||
async getRecentWork(startDate, endDate, limit = 50) {
|
||||
// work_type_id가 work_types에 있으면 직접 사용,
|
||||
// 없으면 tasks 테이블을 통해 해당 task의 work_type_id로 공정(대분류) 조회
|
||||
// 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,
|
||||
@@ -194,13 +196,20 @@ class WorkAnalysis {
|
||||
p.project_name,
|
||||
p.job_no,
|
||||
dwr.work_type_id as original_work_type_id,
|
||||
COALESCE(wt.id, t.work_type_id) as work_type_id,
|
||||
COALESCE(wt.name, wt2.name) as work_type_name,
|
||||
COALESCE(
|
||||
CASE WHEN t.task_id IS NOT NULL THEN t.work_type_id ELSE NULL END,
|
||||
wt.id
|
||||
) as work_type_id,
|
||||
COALESCE(
|
||||
CASE WHEN t.task_id IS NOT NULL THEN wt2.name ELSE NULL END,
|
||||
wt.name
|
||||
) as work_type_name,
|
||||
t.task_name as task_name,
|
||||
dwr.work_status_id,
|
||||
wst.name as work_status_name,
|
||||
dwr.error_type_id,
|
||||
et.name as error_type_name,
|
||||
iri.item_name as error_type_name,
|
||||
irc.category_name as error_category_name,
|
||||
dwr.work_hours,
|
||||
dwr.created_by,
|
||||
u.name as created_by_name,
|
||||
@@ -212,7 +221,8 @@ class WorkAnalysis {
|
||||
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 error_types et ON dwr.error_type_id = et.id
|
||||
LEFT JOIN issue_report_items iri ON dwr.error_type_id = iri.item_id
|
||||
LEFT JOIN issue_report_categories irc ON iri.category_id = irc.category_id
|
||||
LEFT JOIN users u ON dwr.created_by = u.user_id
|
||||
WHERE dwr.report_date BETWEEN ? AND ?
|
||||
ORDER BY dwr.created_at DESC
|
||||
@@ -236,6 +246,7 @@ class WorkAnalysis {
|
||||
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 || '미지정',
|
||||
@@ -286,20 +297,23 @@ class WorkAnalysis {
|
||||
}
|
||||
|
||||
// 에러 분석
|
||||
// error_type_id는 issue_report_items 테이블 참조 (issue-categories.html에서 관리)
|
||||
async getErrorAnalysis(startDate, endDate) {
|
||||
const query = `
|
||||
SELECT
|
||||
SELECT
|
||||
dwr.error_type_id,
|
||||
et.name as error_type_name,
|
||||
iri.item_name as error_type_name,
|
||||
irc.category_name as error_category_name,
|
||||
COUNT(*) as error_count,
|
||||
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 error_types et ON dwr.error_type_id = et.id
|
||||
LEFT JOIN issue_report_items iri ON dwr.error_type_id = iri.item_id
|
||||
LEFT JOIN issue_report_categories irc ON iri.category_id = irc.category_id
|
||||
WHERE dwr.report_date BETWEEN ? AND ?
|
||||
AND dwr.work_status_id = 2
|
||||
GROUP BY dwr.error_type_id, et.name
|
||||
GROUP BY dwr.error_type_id, iri.item_name, irc.category_name
|
||||
ORDER BY error_count DESC
|
||||
`;
|
||||
|
||||
@@ -308,6 +322,7 @@ class WorkAnalysis {
|
||||
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,
|
||||
@@ -436,14 +451,23 @@ class WorkAnalysis {
|
||||
}
|
||||
// 프로젝트별-작업별 시간 분석용 데이터 조회 (공정/대분류 기준)
|
||||
async getProjectWorkTypeRawData(startDate, endDate) {
|
||||
// work_type_id가 실제로 task_id인 경우 해당 task의 work_type_id로 공정 조회
|
||||
// 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(wt.id, t.work_type_id) as work_type_id,
|
||||
COALESCE(wt.name, wt2.name, CONCAT('작업유형 ', dwr.work_type_id)) as work_type_name,
|
||||
COALESCE(
|
||||
CASE WHEN t.task_id IS NOT NULL THEN t.work_type_id ELSE NULL END,
|
||||
wt.id
|
||||
) as work_type_id,
|
||||
COALESCE(
|
||||
CASE WHEN t.task_id IS NOT NULL THEN wt2.name ELSE NULL END,
|
||||
wt.name,
|
||||
CONCAT('작업유형 ', dwr.work_type_id)
|
||||
) as work_type_name,
|
||||
|
||||
-- 총 시간
|
||||
SUM(dwr.work_hours) as total_hours,
|
||||
@@ -472,8 +496,14 @@ class WorkAnalysis {
|
||||
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(wt.id, t.work_type_id),
|
||||
COALESCE(wt.name, wt2.name)
|
||||
COALESCE(
|
||||
CASE WHEN t.task_id IS NOT NULL THEN t.work_type_id ELSE NULL END,
|
||||
wt.id
|
||||
),
|
||||
COALESCE(
|
||||
CASE WHEN t.task_id IS NOT NULL THEN wt2.name ELSE NULL END,
|
||||
wt.name
|
||||
)
|
||||
ORDER BY p.project_name, work_type_name
|
||||
`;
|
||||
|
||||
|
||||
@@ -432,21 +432,23 @@ class AttendanceModel {
|
||||
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
|
||||
SELECT
|
||||
w.worker_id,
|
||||
w.worker_name,
|
||||
COUNT(CASE WHEN dar.status = 'complete' THEN 1 END) as regular_days,
|
||||
COUNT(CASE WHEN dar.status = 'overtime' THEN 1 END) as overtime_days,
|
||||
COUNT(CASE WHEN dar.status = 'vacation' THEN 1 END) as vacation_days,
|
||||
COUNT(CASE WHEN dar.status = 'partial' THEN 1 END) as partial_days,
|
||||
COUNT(CASE WHEN dar.status = 'incomplete' THEN 1 END) as incomplete_days,
|
||||
SUM(dar.total_work_hours) as total_work_hours,
|
||||
AVG(dar.total_work_hours) as avg_work_hours
|
||||
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.is_active = TRUE
|
||||
WHERE w.employment_status = 'employed'
|
||||
`;
|
||||
|
||||
const params = [year, month];
|
||||
|
||||
@@ -29,7 +29,21 @@ const getAllWorkStatusTypes = async (callback) => {
|
||||
const getAllErrorTypes = async (callback) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query('SELECT id, name, description, severity, solution_guide, created_at, updated_at FROM error_types ORDER BY name ASC');
|
||||
// issue_report_items에서 부적합(nonconformity) 타입의 항목만 조회
|
||||
const [rows] = await db.query(`
|
||||
SELECT
|
||||
iri.item_id as id,
|
||||
iri.item_name as name,
|
||||
iri.description,
|
||||
iri.severity,
|
||||
irc.category_name as category,
|
||||
iri.display_order,
|
||||
iri.created_at
|
||||
FROM issue_report_items iri
|
||||
INNER JOIN issue_report_categories irc ON iri.category_id = irc.category_id
|
||||
WHERE irc.category_type = 'nonconformity' AND iri.is_active = TRUE
|
||||
ORDER BY irc.display_order, iri.display_order, iri.item_name ASC
|
||||
`);
|
||||
callback(null, rows);
|
||||
} catch (err) {
|
||||
console.error('에러 유형 조회 오류:', err);
|
||||
@@ -301,9 +315,10 @@ const removeSpecificEntry = async (entry_id, deleted_by, callback) => {
|
||||
|
||||
/**
|
||||
* 공통 SELECT 쿼리 부분
|
||||
* error_type_id는 issue_report_items 테이블 참조 (issue-categories.html에서 관리)
|
||||
*/
|
||||
const getSelectQuery = () => `
|
||||
SELECT
|
||||
SELECT
|
||||
dwr.id,
|
||||
dwr.report_date,
|
||||
dwr.worker_id,
|
||||
@@ -317,7 +332,8 @@ const getSelectQuery = () => `
|
||||
p.project_name,
|
||||
wt.name as work_type_name,
|
||||
wst.name as work_status_name,
|
||||
et.name as error_type_name,
|
||||
iri.item_name as error_type_name,
|
||||
irc.category_name as error_category_name,
|
||||
u.name as created_by_name,
|
||||
dwr.created_at,
|
||||
dwr.updated_at
|
||||
@@ -326,7 +342,8 @@ const getSelectQuery = () => `
|
||||
LEFT JOIN projects p ON dwr.project_id = p.project_id
|
||||
LEFT JOIN work_types wt ON dwr.work_type_id = wt.id
|
||||
LEFT JOIN work_status_types wst ON dwr.work_status_id = wst.id
|
||||
LEFT JOIN error_types et ON dwr.error_type_id = et.id
|
||||
LEFT JOIN issue_report_items iri ON dwr.error_type_id = iri.item_id
|
||||
LEFT JOIN issue_report_categories irc ON iri.category_id = irc.category_id
|
||||
LEFT JOIN users u ON dwr.created_by = u.user_id
|
||||
`;
|
||||
|
||||
@@ -873,9 +890,11 @@ const createReportEntries = async ({ report_date, worker_id, entries }) => {
|
||||
|
||||
/**
|
||||
* [V2] 공통 SELECT 쿼리 (새로운 스키마 기준)
|
||||
* 주의: work_type_id 컬럼에는 실제로 task_id가 저장됨
|
||||
* error_type_id는 issue_report_items 테이블 참조 (issue-categories.html에서 관리)
|
||||
*/
|
||||
const getSelectQueryV2 = () => `
|
||||
SELECT
|
||||
SELECT
|
||||
dwr.id,
|
||||
dwr.report_date,
|
||||
dwr.worker_id,
|
||||
@@ -887,17 +906,21 @@ const getSelectQueryV2 = () => `
|
||||
dwr.created_by,
|
||||
w.worker_name,
|
||||
p.project_name,
|
||||
t.task_name,
|
||||
wt.name as work_type_name,
|
||||
wst.name as work_status_name,
|
||||
et.name as error_type_name,
|
||||
iri.item_name as error_type_name,
|
||||
irc.category_name as error_category_name,
|
||||
u.name as created_by_name,
|
||||
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 wt ON t.work_type_id = wt.id
|
||||
LEFT JOIN work_status_types wst ON dwr.work_status_id = wst.id
|
||||
LEFT JOIN error_types et ON dwr.error_type_id = et.id
|
||||
LEFT JOIN issue_report_items iri ON dwr.error_type_id = iri.item_id
|
||||
LEFT JOIN issue_report_categories irc ON iri.category_id = irc.category_id
|
||||
LEFT JOIN users u ON dwr.created_by = u.user_id
|
||||
`;
|
||||
|
||||
@@ -967,9 +990,9 @@ const updateReportById = async (reportId, updateData) => {
|
||||
}
|
||||
}
|
||||
|
||||
// updated_by_user_id는 항상 업데이트
|
||||
// updated_by는 항상 업데이트
|
||||
if (updateData.updated_by_user_id) {
|
||||
setClauses.push('updated_by_user_id = ?');
|
||||
setClauses.push('updated_by = ?');
|
||||
queryParams.push(updateData.updated_by_user_id);
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ const getActiveTasks = async () => {
|
||||
};
|
||||
|
||||
/**
|
||||
* 공정별 작업 목록 조회
|
||||
* 공정별 작업 목록 조회 (활성 작업만)
|
||||
*/
|
||||
const getTasksByWorkType = async (workTypeId) => {
|
||||
const db = await getDb();
|
||||
@@ -68,8 +68,8 @@ const getTasksByWorkType = async (workTypeId) => {
|
||||
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 = ?
|
||||
ORDER BY t.task_id DESC`,
|
||||
WHERE t.work_type_id = ? AND t.is_active = 1
|
||||
ORDER BY t.task_name ASC`,
|
||||
[workTypeId]
|
||||
);
|
||||
return rows;
|
||||
|
||||
@@ -260,6 +260,118 @@ const vacationBalanceModel = {
|
||||
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로 휴가 잔액 조회
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user