feat: 다수 기능 개선 - 순찰, 출근, 작업분석, 모바일 UI 등
- 순찰/점검 기능 개선 (zone-detail 페이지 추가) - 출근/근태 시스템 개선 (연차 조회, 근무현황) - 작업분석 대분류 그룹화 및 마이그레이션 스크립트 - 모바일 네비게이션 UI 추가 - NAS 배포 도구 및 문서 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 ');
|
||||
}
|
||||
});
|
||||
|
||||
// 로그인 시도 제한 (브루트포스 방지)
|
||||
|
||||
@@ -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보다 먼저 실행)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
105
api.hyungi.net/db/migrations/20260205_fix_work_type_id_data.js
Normal file
105
api.hyungi.net/db/migrations/20260205_fix_work_type_id_data.js
Normal 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);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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(' 데이터 복구가 필요한 경우 백업에서 복원해주세요.');
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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로 휴가 잔액 조회
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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 테이블이 있는 경우)
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user