feat(tksafety): 위험성평가 모듈 Phase 1 구현 — DB·API·Excel·프론트엔드
5개 테이블(risk_projects/processes/items/mitigations/templates) + 마스터 시딩, 프로젝트·항목·감소대책 CRUD API, ExcelJS 평가표 내보내기, 프로젝트 목록·평가 수행 페이지, 사진 업로드(multer), 네비게이션·CSS 추가. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
211
tksafety/api/utils/riskExcelExport.js
Normal file
211
tksafety/api/utils/riskExcelExport.js
Normal file
@@ -0,0 +1,211 @@
|
||||
const ExcelJS = require('exceljs');
|
||||
|
||||
const RISK_COLORS = {
|
||||
low: { fill: '92D050', font: '000000' }, // 1-4 green
|
||||
moderate: { fill: 'FFFF00', font: '000000' }, // 5-9 amber/yellow
|
||||
substantial: { fill: 'FFC000', font: '000000' }, // 10-15 orange
|
||||
high: { fill: 'FF0000', font: 'FFFFFF' }, // 16-25 red
|
||||
};
|
||||
|
||||
function getRiskLevel(score) {
|
||||
if (!score) return null;
|
||||
if (score <= 4) return 'low';
|
||||
if (score <= 9) return 'moderate';
|
||||
if (score <= 15) return 'substantial';
|
||||
return 'high';
|
||||
}
|
||||
|
||||
function applyRiskColor(cell, score) {
|
||||
const level = getRiskLevel(score);
|
||||
if (!level) return;
|
||||
const c = RISK_COLORS[level];
|
||||
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF' + c.fill } };
|
||||
cell.font = { ...(cell.font || {}), color: { argb: 'FF' + c.font } };
|
||||
}
|
||||
|
||||
function thinBorder() {
|
||||
return {
|
||||
top: { style: 'thin' }, bottom: { style: 'thin' },
|
||||
left: { style: 'thin' }, right: { style: 'thin' }
|
||||
};
|
||||
}
|
||||
|
||||
async function generateRiskExcel(project) {
|
||||
const wb = new ExcelJS.Workbook();
|
||||
wb.creator = 'TK Safety';
|
||||
wb.created = new Date();
|
||||
|
||||
// ========== Sheet 1: 위험성 평가표 ==========
|
||||
const ws = wb.addWorksheet('위험성 평가표');
|
||||
|
||||
// 컬럼 너비
|
||||
ws.columns = [
|
||||
{ width: 5 }, // A: No
|
||||
{ width: 10 }, // B: 분류
|
||||
{ width: 12 }, // C: 원인(작업)
|
||||
{ width: 25 }, // D: 유해·위험요인
|
||||
{ width: 12 }, // E: 관련법규
|
||||
{ width: 20 }, // F: 현재 안전조치
|
||||
{ width: 8 }, // G: 가능성
|
||||
{ width: 8 }, // H: 중대성
|
||||
{ width: 8 }, // I: 위험성
|
||||
{ width: 10 }, // J: 감소대책 No
|
||||
{ width: 20 }, // K: 세부내용
|
||||
];
|
||||
|
||||
// 제목
|
||||
const titleRow = ws.addRow([`위험성평가표 — ${project.title} (${project.year}년)`]);
|
||||
ws.mergeCells(titleRow.number, 1, titleRow.number, 11);
|
||||
titleRow.getCell(1).font = { size: 14, bold: true };
|
||||
titleRow.getCell(1).alignment = { horizontal: 'center' };
|
||||
titleRow.height = 30;
|
||||
|
||||
// 프로젝트 정보
|
||||
const infoRow = ws.addRow([
|
||||
`평가유형: ${project.assessment_type === 'regular' ? '정기' : '수시'}`,
|
||||
'', '', `제품유형: ${project.product_type}`,
|
||||
'', `평가자: ${project.assessed_by || '-'}`,
|
||||
'', `상태: ${project.status === 'draft' ? '작성중' : project.status === 'in_progress' ? '진행중' : '완료'}`,
|
||||
]);
|
||||
ws.mergeCells(infoRow.number, 1, infoRow.number, 3);
|
||||
ws.mergeCells(infoRow.number, 4, infoRow.number, 5);
|
||||
ws.mergeCells(infoRow.number, 6, infoRow.number, 7);
|
||||
ws.mergeCells(infoRow.number, 8, infoRow.number, 11);
|
||||
infoRow.eachCell(c => { c.font = { size: 10 }; });
|
||||
|
||||
ws.addRow([]);
|
||||
|
||||
const HEADER_LABELS = ['No', '분류', '원인(작업)', '유해·위험요인', '관련법규',
|
||||
'현재 안전조치', '가능성', '중대성', '위험성', '감소대책 No', '세부내용'];
|
||||
|
||||
if (!project.processes || project.processes.length === 0) {
|
||||
const emptyRow = ws.addRow(['(평가 항목이 없습니다)']);
|
||||
ws.mergeCells(emptyRow.number, 1, emptyRow.number, 11);
|
||||
emptyRow.getCell(1).alignment = { horizontal: 'center' };
|
||||
emptyRow.getCell(1).font = { italic: true, color: { argb: 'FF999999' } };
|
||||
}
|
||||
|
||||
for (const proc of (project.processes || [])) {
|
||||
// 공정 섹션 헤더 (빨간 배경)
|
||||
const sectionRow = ws.addRow([proc.process_name]);
|
||||
ws.mergeCells(sectionRow.number, 1, sectionRow.number, 11);
|
||||
sectionRow.getCell(1).font = { bold: true, color: { argb: 'FFFFFFFF' }, size: 11 };
|
||||
sectionRow.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFDC2626' } };
|
||||
sectionRow.getCell(1).alignment = { horizontal: 'center', vertical: 'middle' };
|
||||
sectionRow.height = 24;
|
||||
|
||||
// 컬럼 헤더
|
||||
const headerRow = ws.addRow(HEADER_LABELS);
|
||||
headerRow.eachCell((cell) => {
|
||||
cell.font = { bold: true, size: 9, color: { argb: 'FFFFFFFF' } };
|
||||
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFDC2626' } };
|
||||
cell.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true };
|
||||
cell.border = thinBorder();
|
||||
});
|
||||
headerRow.height = 22;
|
||||
|
||||
// 데이터 행
|
||||
const items = proc.items || [];
|
||||
if (items.length === 0) {
|
||||
const noDataRow = ws.addRow(['', '', '', '(항목 없음)', '', '', '', '', '', '', '']);
|
||||
noDataRow.getCell(4).font = { italic: true, color: { argb: 'FF999999' } };
|
||||
for (let i = 1; i <= 11; i++) noDataRow.getCell(i).border = thinBorder();
|
||||
}
|
||||
items.forEach((item, idx) => {
|
||||
const row = ws.addRow([
|
||||
idx + 1,
|
||||
item.category || '',
|
||||
item.cause || '',
|
||||
item.hazard || '',
|
||||
item.regulation || '',
|
||||
item.current_measure || '',
|
||||
item.likelihood || '',
|
||||
item.severity || '',
|
||||
item.risk_score || '',
|
||||
item.mitigation_no || '',
|
||||
item.detail || ''
|
||||
]);
|
||||
row.eachCell((cell, colNumber) => {
|
||||
cell.font = { size: 9 };
|
||||
cell.alignment = { vertical: 'middle', wrapText: true };
|
||||
if ([1, 7, 8, 9, 10].includes(colNumber)) cell.alignment.horizontal = 'center';
|
||||
cell.border = thinBorder();
|
||||
});
|
||||
// 위험성 셀 색상
|
||||
if (item.risk_score) applyRiskColor(row.getCell(9), item.risk_score);
|
||||
});
|
||||
|
||||
ws.addRow([]);
|
||||
}
|
||||
|
||||
// ========== Sheet 2~N: 감소대책 ==========
|
||||
const mitigations = project.mitigations || [];
|
||||
if (mitigations.length > 0) {
|
||||
const msWs = wb.addWorksheet('감소대책 수립 및 실행');
|
||||
|
||||
msWs.columns = [
|
||||
{ width: 5 }, // A: No
|
||||
{ width: 25 }, // B: 유해·위험요인
|
||||
{ width: 10 }, // C: 현재 위험성
|
||||
{ width: 25 }, // D: 개선계획
|
||||
{ width: 12 }, // E: 담당자
|
||||
{ width: 12 }, // F: 예산
|
||||
{ width: 12 }, // G: 일정
|
||||
{ width: 15 }, // H: 완료일
|
||||
{ width: 15 }, // I: 완료사진
|
||||
{ width: 8 }, // J: 가능성(후)
|
||||
{ width: 8 }, // K: 중대성(후)
|
||||
{ width: 8 }, // L: 위험성(후)
|
||||
{ width: 10 }, // M: 상태
|
||||
];
|
||||
|
||||
const titleRow2 = msWs.addRow([`감소대책 수립 및 실행 — ${project.title}`]);
|
||||
msWs.mergeCells(titleRow2.number, 1, titleRow2.number, 13);
|
||||
titleRow2.getCell(1).font = { size: 14, bold: true };
|
||||
titleRow2.getCell(1).alignment = { horizontal: 'center' };
|
||||
|
||||
msWs.addRow([]);
|
||||
|
||||
const mHeaders = ['No', '유해·위험요인', '현재 위험성', '개선계획', '담당자',
|
||||
'예산', '일정', '완료일', '완료사진', '가능성(후)', '중대성(후)', '위험성(후)', '상태'];
|
||||
const mHeaderRow = msWs.addRow(mHeaders);
|
||||
mHeaderRow.eachCell((cell) => {
|
||||
cell.font = { bold: true, size: 9, color: { argb: 'FFFFFFFF' } };
|
||||
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF2563EB' } };
|
||||
cell.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true };
|
||||
cell.border = thinBorder();
|
||||
});
|
||||
|
||||
const STATUS_MAP = { planned: '계획', in_progress: '진행중', completed: '완료' };
|
||||
|
||||
mitigations.forEach((m) => {
|
||||
const row = msWs.addRow([
|
||||
m.mitigation_no,
|
||||
m.hazard_summary || '',
|
||||
m.current_risk_score || '',
|
||||
m.improvement_plan || '',
|
||||
m.manager || '',
|
||||
m.budget || '',
|
||||
m.schedule || '',
|
||||
m.completion_date ? String(m.completion_date).substring(0, 10) : '',
|
||||
m.completion_photo ? '(사진 첨부)' : '',
|
||||
m.post_likelihood || '',
|
||||
m.post_severity || '',
|
||||
m.post_risk_score || '',
|
||||
STATUS_MAP[m.status] || m.status
|
||||
]);
|
||||
row.eachCell((cell, colNumber) => {
|
||||
cell.font = { size: 9 };
|
||||
cell.alignment = { vertical: 'middle', wrapText: true };
|
||||
if ([1, 3, 10, 11, 12, 13].includes(colNumber)) cell.alignment.horizontal = 'center';
|
||||
cell.border = thinBorder();
|
||||
});
|
||||
if (m.current_risk_score) applyRiskColor(row.getCell(3), m.current_risk_score);
|
||||
if (m.post_risk_score) applyRiskColor(row.getCell(12), m.post_risk_score);
|
||||
});
|
||||
}
|
||||
|
||||
return wb.xlsx.writeBuffer();
|
||||
}
|
||||
|
||||
module.exports = { generateRiskExcel };
|
||||
Reference in New Issue
Block a user