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