Files
tk-factory-services/tksafety/api/utils/riskExcelExport.js
Hyungi Ahn e9b69ed87b 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>
2026-03-15 08:05:19 +09:00

212 lines
7.9 KiB
JavaScript

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