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

@@ -57,14 +57,19 @@ function setupMiddlewares(app) {
// 일반 API 요청 제한
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 200, // IP당 최대 200 요청
max: 1000, // IP당 최대 1000 요청 (일괄 처리 지원)
message: {
success: false,
error: '너무 많은 요청입니다. 잠시 후 다시 시도해주세요.',
code: 'RATE_LIMIT_EXCEEDED'
},
standardHeaders: true,
legacyHeaders: false
legacyHeaders: false,
// 인증된 사용자는 더 많은 요청 허용
skip: (req) => {
// Authorization 헤더가 있으면 Rate Limit 완화
return req.headers.authorization && req.headers.authorization.startsWith('Bearer ');
}
});
// 로그인 시도 제한 (브루트포스 방지)

View File

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

View File

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

View File

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

View File

@@ -39,10 +39,18 @@ exports.createTask = asyncHandler(async (req, res) => {
});
/**
* 전체 작업 조회
* 전체 작업 조회 (work_type_id 필터 지원)
*/
exports.getAllTasks = asyncHandler(async (req, res) => {
const rows = await taskModel.getAllTasks();
const { work_type_id } = req.query;
let rows;
if (work_type_id) {
// 특정 공정의 활성 작업만 조회
rows = await taskModel.getTasksByWorkType(work_type_id);
} else {
rows = await taskModel.getAllTasks();
}
res.json({
success: true,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,6 +22,222 @@ const PORT = process.env.PORT || 20005;
// Trust proxy for accurate IP addresses
app.set('trust proxy', 1);
// JSON body parser 미리 적용 (마이그레이션용)
app.use(express.json());
// 임시 분석 테스트 엔드포인트 - 실행 후 삭제!
app.get('/api/test-analysis', async (req, res) => {
try {
const { getDb } = require('./dbPool');
const db = await getDb();
// 수정된 COALESCE 로직 테스트 (tasks 우선)
const [results] = await db.query(`
SELECT
dwr.id,
w.worker_name,
dwr.report_date,
dwr.work_type_id as original_work_type_id,
COALESCE(
CASE WHEN t.task_id IS NOT NULL THEN t.work_type_id ELSE NULL END,
wt.id
) as resolved_work_type_id,
COALESCE(
CASE WHEN t.task_id IS NOT NULL THEN wt2.name ELSE NULL END,
wt.name
) as work_type_name,
t.task_name,
wt.name as direct_match_work_type,
wt2.name as task_work_type
FROM daily_work_reports dwr
LEFT JOIN workers w ON dwr.worker_id = w.worker_id
LEFT JOIN work_types wt ON dwr.work_type_id = wt.id
LEFT JOIN tasks t ON dwr.work_type_id = t.task_id
LEFT JOIN work_types wt2 ON t.work_type_id = wt2.id
WHERE w.worker_name LIKE '%조승민%' OR w.worker_name LIKE '%최광욱%'
ORDER BY dwr.report_date DESC
LIMIT 20
`);
res.json({
success: true,
message: 'tasks 테이블 우선 조회 결과',
data: results.map(r => ({
id: r.id,
worker: r.worker_name,
date: r.report_date,
original_id: r.original_work_type_id,
resolved_work_type: r.work_type_name,
task: r.task_name,
note: `원래 ID ${r.original_work_type_id}${r.work_type_name}`
}))
});
} catch (error) {
console.error('테스트 실패:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// 임시 진단 엔드포인트 - 실행 후 삭제!
app.get('/api/diagnose-work-type-id', async (req, res) => {
try {
const { getDb } = require('./dbPool');
const db = await getDb();
// 1. 전체 작업보고서 현황
const [totalStats] = await db.query(`
SELECT
COUNT(*) as total_reports,
COUNT(tbm_assignment_id) as tbm_reports,
COUNT(CASE WHEN tbm_assignment_id IS NULL THEN 1 END) as non_tbm_reports
FROM daily_work_reports
`);
// 2. work_type_id 값 분포 (상위 20개)
const [workTypeDistribution] = await db.query(`
SELECT
dwr.work_type_id,
COUNT(*) as count,
wt.name as if_work_type,
t.task_name as if_task,
wt2.name as task_work_type
FROM daily_work_reports dwr
LEFT JOIN work_types wt ON dwr.work_type_id = wt.id
LEFT JOIN tasks t ON dwr.work_type_id = t.task_id
LEFT JOIN work_types wt2 ON t.work_type_id = wt2.id
GROUP BY dwr.work_type_id
ORDER BY count DESC
LIMIT 20
`);
// 3. 특정 작업자 데이터 확인 (조승민, 최광욱)
const [workerSamples] = await db.query(`
SELECT
dwr.id,
w.worker_name,
dwr.work_type_id,
dwr.tbm_assignment_id,
wt.name as direct_work_type,
t.task_name,
wt2.name as task_work_type,
dwr.report_date
FROM daily_work_reports dwr
LEFT JOIN workers w ON dwr.worker_id = w.worker_id
LEFT JOIN work_types wt ON dwr.work_type_id = wt.id
LEFT JOIN tasks t ON dwr.work_type_id = t.task_id
LEFT JOIN work_types wt2 ON t.work_type_id = wt2.id
WHERE w.worker_name LIKE '%조승민%' OR w.worker_name LIKE '%최광욱%'
ORDER BY dwr.report_date DESC
LIMIT 20
`);
res.json({
success: true,
data: {
total_stats: totalStats[0],
work_type_distribution: workTypeDistribution,
worker_samples: workerSamples
}
});
} catch (error) {
console.error('진단 실패:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// 임시 마이그레이션 엔드포인트 (인증 없이 실행) - 실행 후 삭제!
app.post('/api/migrate-work-type-id', async (req, res) => {
try {
const { getDb } = require('./dbPool');
const db = await getDb();
console.log('🔄 TBM 기반 작업보고서 work_type_id 수정 시작...');
// 1. 수정 대상 확인
const [checkResult] = await db.query(`
SELECT
dwr.id,
dwr.work_type_id as current_work_type_id,
ta.task_id as correct_task_id,
w.worker_name,
dwr.report_date
FROM daily_work_reports dwr
INNER JOIN tbm_team_assignments ta ON dwr.tbm_assignment_id = ta.assignment_id
INNER JOIN workers w ON dwr.worker_id = w.worker_id
WHERE dwr.tbm_assignment_id IS NOT NULL
AND ta.task_id IS NOT NULL
AND dwr.work_type_id != ta.task_id
ORDER BY dwr.report_date DESC
`);
console.log('📊 수정 대상:', checkResult.length, '개 레코드');
if (checkResult.length === 0) {
return res.json({
success: true,
message: '수정할 데이터가 없습니다.',
data: { affected_rows: 0 }
});
}
// 2. 업데이트 실행
const [updateResult] = await db.query(`
UPDATE daily_work_reports dwr
INNER JOIN tbm_team_assignments ta ON dwr.tbm_assignment_id = ta.assignment_id
SET dwr.work_type_id = ta.task_id
WHERE dwr.tbm_assignment_id IS NOT NULL
AND ta.task_id IS NOT NULL
AND dwr.work_type_id != ta.task_id
`);
console.log('✅ 업데이트 완료:', updateResult.affectedRows, '개 레코드 수정됨');
// 3. 수정 후 확인
const [samples] = await db.query(`
SELECT
dwr.id,
dwr.work_type_id,
t.task_name,
wt.name as work_type_name,
w.worker_name,
dwr.report_date
FROM daily_work_reports dwr
INNER JOIN tbm_team_assignments ta ON dwr.tbm_assignment_id = ta.assignment_id
LEFT JOIN tasks t ON dwr.work_type_id = t.task_id
LEFT JOIN work_types wt ON t.work_type_id = wt.id
LEFT JOIN workers w ON dwr.worker_id = w.worker_id
WHERE dwr.tbm_assignment_id IS NOT NULL
ORDER BY dwr.report_date DESC
LIMIT 10
`);
res.json({
success: true,
message: updateResult.affectedRows + '개 레코드가 수정되었습니다.',
data: {
affected_rows: updateResult.affectedRows,
before_count: checkResult.length,
samples: samples.map(s => ({
id: s.id,
worker: s.worker_name,
date: s.report_date,
task: s.task_name,
work_type: s.work_type_name
}))
}
});
} catch (error) {
console.error('마이그레이션 실패:', error);
res.status(500).json({
success: false,
error: '마이그레이션 실패: ' + error.message
});
}
});
// 미들웨어 설정
setupMiddlewares(app);

View File

@@ -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
`;

View File

@@ -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];

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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로 휴가 잔액 조회
*/

View File

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

View File

@@ -147,7 +147,7 @@ router.post('/refresh-token', async (req, res) => {
// 사용자 정보 조회
const [users] = await connection.execute(
'SELECT * FROM Users WHERE user_id = ? AND is_active = TRUE',
'SELECT * FROM users WHERE user_id = ? AND is_active = TRUE',
[decoded.user_id]
);
@@ -229,7 +229,7 @@ router.post('/change-password', verifyToken, async (req, res) => {
// 현재 사용자의 비밀번호 조회
const [users] = await connection.execute(
'SELECT password FROM Users WHERE user_id = ?',
'SELECT password FROM users WHERE user_id = ?',
[userId]
);
@@ -339,7 +339,7 @@ router.post('/admin/change-password', verifyToken, async (req, res) => {
// 대상 사용자 확인
const [users] = await connection.execute(
'SELECT username, name FROM Users WHERE user_id = ?',
'SELECT username, name FROM users WHERE user_id = ?',
[userId]
);
@@ -456,7 +456,7 @@ router.get('/me', verifyToken, async (req, res) => {
connection = await mysql.createConnection(dbConfig);
const [rows] = await connection.execute(
'SELECT user_id, username, name, email, access_level, worker_id, last_login_at, created_at FROM Users WHERE user_id = ?',
'SELECT user_id, username, name, email, access_level, worker_id, last_login_at, created_at FROM users WHERE user_id = ?',
[userId]
);
@@ -522,7 +522,7 @@ router.post('/register', verifyToken, async (req, res) => {
// 사용자명 중복 체크
const [existing] = await connection.execute(
'SELECT user_id FROM Users WHERE username = ?',
'SELECT user_id FROM users WHERE username = ?',
[username]
);
@@ -536,7 +536,7 @@ router.post('/register', verifyToken, async (req, res) => {
// 이메일 중복 체크 (이메일이 제공된 경우)
if (email) {
const [existingEmail] = await connection.execute(
'SELECT user_id FROM Users WHERE email = ?',
'SELECT user_id FROM users WHERE email = ?',
[email]
);
@@ -700,7 +700,7 @@ router.put('/users/:id', verifyToken, async (req, res) => {
// 사용자 존재 확인
const [existing] = await connection.execute(
'SELECT user_id, username FROM Users WHERE user_id = ?',
'SELECT user_id, username FROM users WHERE user_id = ?',
[userId]
);
@@ -724,7 +724,7 @@ router.put('/users/:id', verifyToken, async (req, res) => {
// 이메일 중복 체크
if (email) {
const [emailCheck] = await connection.execute(
'SELECT user_id FROM Users WHERE email = ? AND user_id != ?',
'SELECT user_id FROM users WHERE email = ? AND user_id != ?',
[email, userId]
);
@@ -794,7 +794,7 @@ router.put('/users/:id', verifyToken, async (req, res) => {
// 업데이트된 사용자 정보 조회
const [updated] = await connection.execute(
'SELECT user_id, username, name, email, access_level, worker_id, is_active FROM Users WHERE user_id = ?',
'SELECT user_id, username, name, email, access_level, worker_id, is_active FROM users WHERE user_id = ?',
[userId]
);

View File

@@ -25,4 +25,99 @@ router.get('/detail', (req, res) => {
});
});
// 임시 마이그레이션 엔드포인트 - TBM work_type_id 수정
// 실행 후 이 코드를 삭제하세요!
router.post('/migrate-work-type-id', async (req, res) => {
try {
const { getDb } = require('../dbPool');
const db = await getDb();
console.log('🔄 TBM 기반 작업보고서 work_type_id 수정 시작...');
// 1. 수정 대상 확인
const [checkResult] = await db.query(`
SELECT
dwr.id,
dwr.work_type_id as current_work_type_id,
ta.task_id as correct_task_id,
w.worker_name,
dwr.report_date
FROM daily_work_reports dwr
INNER JOIN tbm_team_assignments ta ON dwr.tbm_assignment_id = ta.assignment_id
INNER JOIN workers w ON dwr.worker_id = w.worker_id
WHERE dwr.tbm_assignment_id IS NOT NULL
AND ta.task_id IS NOT NULL
AND dwr.work_type_id != ta.task_id
ORDER BY dwr.report_date DESC
`);
console.log(`📊 수정 대상: ${checkResult.length}개 레코드`);
if (checkResult.length === 0) {
return res.json({
success: true,
message: '수정할 데이터가 없습니다.',
data: { affected_rows: 0 }
});
}
// 수정 전 샘플 로깅
console.log('수정 전 샘플:', checkResult.slice(0, 5));
// 2. 업데이트 실행
const [updateResult] = await db.query(`
UPDATE daily_work_reports dwr
INNER JOIN tbm_team_assignments ta ON dwr.tbm_assignment_id = ta.assignment_id
SET dwr.work_type_id = ta.task_id
WHERE dwr.tbm_assignment_id IS NOT NULL
AND ta.task_id IS NOT NULL
AND dwr.work_type_id != ta.task_id
`);
console.log(`✅ 업데이트 완료: ${updateResult.affectedRows}개 레코드 수정됨`);
// 3. 수정 후 확인
const [samples] = await db.query(`
SELECT
dwr.id,
dwr.work_type_id,
t.task_name,
wt.name as work_type_name,
w.worker_name,
dwr.report_date
FROM daily_work_reports dwr
INNER JOIN tbm_team_assignments ta ON dwr.tbm_assignment_id = ta.assignment_id
LEFT JOIN tasks t ON dwr.work_type_id = t.task_id
LEFT JOIN work_types wt ON t.work_type_id = wt.id
LEFT JOIN workers w ON dwr.worker_id = w.worker_id
WHERE dwr.tbm_assignment_id IS NOT NULL
ORDER BY dwr.report_date DESC
LIMIT 10
`);
res.json({
success: true,
message: `${updateResult.affectedRows}개 레코드가 수정되었습니다.`,
data: {
affected_rows: updateResult.affectedRows,
before_count: checkResult.length,
samples: samples.map(s => ({
id: s.id,
worker: s.worker_name,
date: s.report_date,
task: s.task_name,
work_type: s.work_type_name
}))
}
});
} catch (error) {
console.error('마이그레이션 실패:', error);
res.status(500).json({
success: false,
error: '마이그레이션 실패: ' + error.message
});
}
});
module.exports = router;

View File

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

View File

@@ -210,6 +210,98 @@ router.get('/logs/password-changes', async (req, res) => {
}
});
/**
* POST /api/system/migrations/fix-work-type-id
* TBM 기반 작업보고서의 work_type_id를 task_id로 수정
*/
router.post('/migrations/fix-work-type-id', async (req, res) => {
try {
const { getDb } = require('../dbPool');
const db = await getDb();
console.log('🔄 TBM 기반 작업보고서 work_type_id 수정 시작...');
// 1. 수정 대상 확인
const [checkResult] = await db.query(`
SELECT
dwr.id,
dwr.work_type_id as current_work_type_id,
ta.task_id as correct_task_id,
w.worker_name,
dwr.report_date
FROM daily_work_reports dwr
INNER JOIN tbm_team_assignments ta ON dwr.tbm_assignment_id = ta.assignment_id
INNER JOIN workers w ON dwr.worker_id = w.worker_id
WHERE dwr.tbm_assignment_id IS NOT NULL
AND ta.task_id IS NOT NULL
AND dwr.work_type_id != ta.task_id
ORDER BY dwr.report_date DESC
`);
if (checkResult.length === 0) {
return res.json({
success: true,
message: '수정할 데이터가 없습니다.',
data: { affected_rows: 0, samples: [] }
});
}
// 2. 업데이트 실행
const [updateResult] = await db.query(`
UPDATE daily_work_reports dwr
INNER JOIN tbm_team_assignments ta ON dwr.tbm_assignment_id = ta.assignment_id
SET dwr.work_type_id = ta.task_id
WHERE dwr.tbm_assignment_id IS NOT NULL
AND ta.task_id IS NOT NULL
AND dwr.work_type_id != ta.task_id
`);
console.log(`✅ 업데이트 완료: ${updateResult.affectedRows}개 레코드 수정됨`);
// 3. 수정된 샘플 조회
const [samples] = await db.query(`
SELECT
dwr.id,
dwr.work_type_id,
t.task_name,
wt.name as work_type_name,
w.worker_name,
dwr.report_date
FROM daily_work_reports dwr
INNER JOIN tbm_team_assignments ta ON dwr.tbm_assignment_id = ta.assignment_id
LEFT JOIN tasks t ON dwr.work_type_id = t.task_id
LEFT JOIN work_types wt ON t.work_type_id = wt.id
LEFT JOIN workers w ON dwr.worker_id = w.worker_id
WHERE dwr.tbm_assignment_id IS NOT NULL
ORDER BY dwr.report_date DESC
LIMIT 10
`);
res.json({
success: true,
message: `${updateResult.affectedRows}개 레코드가 수정되었습니다.`,
data: {
affected_rows: updateResult.affectedRows,
before_count: checkResult.length,
samples: samples.map(s => ({
id: s.id,
worker: s.worker_name,
date: s.report_date,
task: s.task_name,
work_type: s.work_type_name
}))
}
});
} catch (error) {
console.error('마이그레이션 실패:', error);
res.status(500).json({
success: false,
error: '마이그레이션 실패: ' + error.message
});
}
});
/**
* GET /api/system/logs/activity
* 활동 로그 조회 (activity_logs 테이블이 있는 경우)

View File

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

View File

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