Files
tk-factory-services/system1-factory/api/controllers/proxyInputController.js
Hyungi Ahn f09c86ee01 fix(security): CRITICAL 보안 이슈 13건 일괄 수정
- SEC-42: JWT algorithm HS256 명시 (sign 5곳, verify 3곳)
- SEC-44: MariaDB/PhpMyAdmin 포트 127.0.0.1 바인딩
- SEC-29: escHtml = escapeHtml alias 추가 (XSS 방지)
- SEC-39: Python Dockerfile 4개 non-root user + chown
- SEC-43: deploy-remote.sh 삭제 (평문 비밀번호 포함)
- SEC-11,12: SQL SET ? → 명시적 컬럼 whitelist + IN절 parameterized
- QA-34: vacation approveRequest/cancelRequest 트랜잭션 래핑
- SEC-32,34: material_comparison.py 5개 엔드포인트 인증 + confirmed_by
- SEC-33: files.py 17개 미인증 엔드포인트 인증 추가
- SEC-37: chatbot 프롬프트 인젝션 방어 (sanitize + XML 구분자)
- SEC-38: fastapi-bridge 프록시 JWT 검증 + 캐시 키 user_id 포함
- SEC-58/QA-98: monthly-comparison API_BASE_URL 수정 + 401 처리
- SEC-61: monthlyComparisonModel SELECT FOR UPDATE 추가
- SEC-63: proxyInputController 에러 메시지 노출 제거
- QA-103: pageAccessRoutes error→message 통일
- SEC-62: tbm-create onclick 인젝션 → data-attribute event delegation
- QA-99: tbm-mobile/create 캐시 버스팅 갱신
- QA-100,101: ESC 키 리스너 cleanup 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 10:48:58 +09:00

211 lines
8.2 KiB
JavaScript

/**
* 대리입력 + 일별 현황 컨트롤러
*/
const ProxyInputModel = require('../models/proxyInputModel');
const { getDb } = require('../dbPool');
const logger = require('../../shared/utils/logger');
const ProxyInputController = {
/**
* POST /api/proxy-input — 대리입력 (단일 트랜잭션)
*/
proxyInput: async (req, res) => {
const { session_date, leader_id, entries, safety_notes, work_location } = req.body;
const userId = req.user.user_id || req.user.id;
// 유효성 검사
if (!session_date) {
return res.status(400).json({ success: false, message: '날짜는 필수입니다.' });
}
if (!entries || !Array.isArray(entries) || entries.length === 0) {
return res.status(400).json({ success: false, message: '작업자 정보는 최소 1명 필요합니다.' });
}
if (entries.length > 30) {
return res.status(400).json({ success: false, message: '한 번에 30명까지 입력 가능합니다.' });
}
// 날짜 유효성 (과거 30일 ~ 오늘)
const today = new Date();
today.setHours(0, 0, 0, 0);
const inputDate = new Date(session_date);
const diffDays = Math.floor((today - inputDate) / (1000 * 60 * 60 * 24));
if (diffDays < 0) {
return res.status(400).json({ success: false, message: '미래 날짜는 입력할 수 없습니다.' });
}
if (diffDays > 30) {
return res.status(400).json({ success: false, message: '30일 이내 날짜만 입력 가능합니다.' });
}
// entries 필수 필드 검사
for (const entry of entries) {
if (!entry.user_id || !entry.project_id || !entry.work_type_id || !entry.work_hours) {
return res.status(400).json({ success: false, message: '각 작업자의 user_id, project_id, work_type_id, work_hours는 필수입니다.' });
}
if (entry.work_hours <= 0 || entry.work_hours > 24) {
return res.status(400).json({ success: false, message: '근무 시간은 0 초과 24 이하여야 합니다.' });
}
}
const db = await getDb();
const conn = await db.getConnection();
try {
await conn.beginTransaction();
const userIds = entries.map(e => e.user_id);
// 1. 기존 보고서 확인 (UPSERT 분기)
const [existingReports] = await conn.query(
`SELECT id, user_id FROM daily_work_reports WHERE report_date = ? AND user_id IN (${userIds.map(() => '?').join(',')})`,
[session_date, ...userIds]
);
const existingMap = {};
existingReports.forEach(r => { existingMap[r.user_id] = r.id; });
// 2. 신규 작업자용 TBM 세션 생성 (기존 있으면 재사용)
let sessionId = null;
const newUserIds = userIds.filter(id => !existingMap[id]);
if (newUserIds.length > 0) {
const sessionResult = await ProxyInputModel.createProxySession(conn, {
session_date,
leader_id: leader_id || userId,
proxy_input_by: userId,
created_by: userId,
safety_notes: safety_notes || '',
work_location: work_location || ''
});
sessionId = sessionResult.insertId;
}
// 3. 각 entry 처리 (UPSERT)
const createdWorkers = [];
for (const entry of entries) {
const existingReportId = existingMap[entry.user_id];
if (existingReportId) {
// UPDATE 기존 보고서
await conn.query(`
UPDATE daily_work_reports SET
project_id = ?, work_type_id = ?, work_hours = ?,
work_status_id = ?, start_time = ?, end_time = ?, note = ?,
updated_at = NOW()
WHERE id = ?
`, [entry.project_id, entry.work_type_id, entry.work_hours,
entry.work_status_id || 1, entry.start_time || null, entry.end_time || null,
entry.note || '', existingReportId]);
createdWorkers.push({ user_id: entry.user_id, report_id: existingReportId, action: 'updated' });
} else {
// INSERT 신규 — TBM 배정 + 작업보고서
const assignResult = await ProxyInputModel.createTeamAssignment(conn, {
session_id: sessionId,
user_id: entry.user_id,
project_id: entry.project_id,
work_type_id: entry.work_type_id,
task_id: entry.task_id || null,
workplace_id: entry.workplace_id || null,
work_hours: entry.work_hours
});
const assignmentId = assignResult.insertId;
const reportResult = await ProxyInputModel.createWorkReport(conn, {
report_date: session_date,
user_id: entry.user_id,
project_id: entry.project_id,
work_type_id: entry.work_type_id,
task_id: entry.task_id || null,
work_status_id: entry.work_status_id || 1,
work_hours: entry.work_hours,
start_time: entry.start_time || null,
end_time: entry.end_time || null,
note: entry.note || '',
tbm_session_id: sessionId,
tbm_assignment_id: assignmentId,
created_by: userId,
created_by_name: req.user.name || req.user.username || ''
});
createdWorkers.push({ user_id: entry.user_id, report_id: reportResult.insertId, action: 'created' });
}
// 부적합 처리 (defect_hours > 0 && 기존 defect 없을 때만)
const defectHours = parseFloat(entry.defect_hours) || 0;
const reportId = existingReportId || createdWorkers[createdWorkers.length - 1].report_id;
if (defectHours > 0) {
const [existingDefects] = await conn.query(
'SELECT defect_id FROM work_report_defects WHERE report_id = ?', [reportId]
);
if (existingDefects.length === 0) {
await conn.query(
`INSERT INTO work_report_defects (report_id, defect_hours, category_id, item_id, note) VALUES (?, ?, ?, ?, '대리입력')`,
[reportId, defectHours, entry.defect_category_id || null, entry.defect_item_id || null]
);
await conn.query(
'UPDATE daily_work_reports SET error_hours = ?, work_status_id = 2 WHERE id = ?',
[defectHours, reportId]
);
}
}
}
await conn.commit();
res.status(201).json({
success: true,
message: `${entries.length}명의 대리입력이 완료되었습니다.`,
data: {
session_id: sessionId,
is_proxy_input: true,
created_reports: entries.length,
workers: createdWorkers
}
});
} catch (err) {
try { await conn.rollback(); } catch (e) {}
logger.error('대리입력 오류:', err);
res.status(500).json({ success: false, message: '대리입력 처리 중 오류가 발생했습니다.' });
} finally {
conn.release();
}
},
/**
* GET /api/proxy-input/daily-status — 일별 현황
*/
getDailyStatus: async (req, res) => {
try {
const { date } = req.query;
if (!date) {
return res.status(400).json({ success: false, message: '날짜(date) 파라미터는 필수입니다.' });
}
const data = await ProxyInputModel.getDailyStatus(date);
res.json({ success: true, data });
} catch (err) {
logger.error('일별 현황 조회 오류:', err);
res.status(500).json({ success: false, message: '조회 중 오류가 발생했습니다.' });
}
},
/**
* GET /api/proxy-input/daily-status/detail — 작업자별 상세
*/
getDailyStatusDetail: async (req, res) => {
try {
const { date, user_id } = req.query;
if (!date || !user_id) {
return res.status(400).json({ success: false, message: 'date와 user_id 파라미터는 필수입니다.' });
}
const data = await ProxyInputModel.getDailyStatusDetail(date, parseInt(user_id));
if (!data.worker) {
return res.status(404).json({ success: false, message: '작업자를 찾을 수 없습니다.' });
}
res.json({ success: true, data });
} catch (err) {
logger.error('일별 상세 조회 오류:', err);
res.status(500).json({ success: false, message: '조회 중 오류가 발생했습니다.' });
}
}
};
module.exports = ProxyInputController;