Files
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

407 lines
14 KiB
JavaScript

const { getPool } = require('../middleware/auth');
// ==================== DB 마이그레이션 ====================
const runMigration = async () => {
const db = getPool();
try {
// 1. risk_process_templates (마스터)
await db.query(`
CREATE TABLE IF NOT EXISTS risk_process_templates (
id INT AUTO_INCREMENT PRIMARY KEY,
product_type ENUM('PKG','VESSEL','HX','SKID') NOT NULL,
process_name VARCHAR(100) NOT NULL,
display_order INT NOT NULL DEFAULT 0,
UNIQUE KEY uk_type_name (product_type, process_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
`);
// 2. risk_projects (평가 프로젝트)
await db.query(`
CREATE TABLE IF NOT EXISTS risk_projects (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(200) NOT NULL,
assessment_type ENUM('regular','adhoc') NOT NULL DEFAULT 'regular',
product_type ENUM('PKG','VESSEL','HX','SKID') NOT NULL,
year SMALLINT NOT NULL,
month TINYINT NULL,
status ENUM('draft','in_progress','completed') NOT NULL DEFAULT 'draft',
assessed_by VARCHAR(100) NULL,
created_by INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_type_year (assessment_type, year),
INDEX idx_product (product_type),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
`);
// 3. risk_processes (세부 공정)
await db.query(`
CREATE TABLE IF NOT EXISTS risk_processes (
id INT AUTO_INCREMENT PRIMARY KEY,
project_id INT NOT NULL,
process_name VARCHAR(100) NOT NULL,
display_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_project (project_id),
FOREIGN KEY (project_id) REFERENCES risk_projects(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
`);
// 4. risk_assessment_items (평가 항목)
await db.query(`
CREATE TABLE IF NOT EXISTS risk_assessment_items (
id INT AUTO_INCREMENT PRIMARY KEY,
process_id INT NOT NULL,
category VARCHAR(100) NULL,
cause VARCHAR(200) NULL,
hazard TEXT NULL,
regulation VARCHAR(200) NULL,
current_measure TEXT NULL,
likelihood TINYINT NULL,
severity TINYINT NULL,
risk_score TINYINT GENERATED ALWAYS AS (likelihood * severity) STORED,
mitigation_no VARCHAR(20) NULL,
detail TEXT NULL,
display_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_process (process_id),
FOREIGN KEY (process_id) REFERENCES risk_processes(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
`);
// 5. risk_mitigations (감소대책)
await db.query(`
CREATE TABLE IF NOT EXISTS risk_mitigations (
id INT AUTO_INCREMENT PRIMARY KEY,
project_id INT NOT NULL,
mitigation_no VARCHAR(20) NOT NULL,
hazard_summary TEXT NULL,
current_risk_score TINYINT NULL,
improvement_plan TEXT NULL,
manager VARCHAR(100) NULL,
budget VARCHAR(100) NULL,
schedule VARCHAR(100) NULL,
completion_photo VARCHAR(500) NULL,
completion_date DATE NULL,
post_likelihood TINYINT NULL,
post_severity TINYINT NULL,
post_risk_score TINYINT GENERATED ALWAYS AS (post_likelihood * post_severity) STORED,
status ENUM('planned','in_progress','completed') NOT NULL DEFAULT 'planned',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_project (project_id),
UNIQUE KEY uk_proj_no (project_id, mitigation_no),
FOREIGN KEY (project_id) REFERENCES risk_projects(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
`);
// 마스터 시딩 (INSERT IGNORE — 중복 무시)
const templates = [
['PKG', '가공', 1], ['PKG', '용접', 2], ['PKG', '사상', 3],
['PKG', '조립(피팅)', 4], ['PKG', '도장', 5], ['PKG', '검사', 6],
['VESSEL', '절단', 1], ['VESSEL', '가공', 2], ['VESSEL', '조립', 3],
['VESSEL', '용접', 4], ['VESSEL', '검사', 5], ['VESSEL', '열처리', 6],
['VESSEL', '사상', 7], ['VESSEL', '도장', 8],
['HX', '절단', 1], ['HX', '가공', 2], ['HX', '조립', 3],
['HX', '용접', 4], ['HX', '확관', 5], ['HX', '사상/도장', 6],
['HX', '검사', 7],
['SKID', '절단', 1], ['SKID', '가공', 2], ['SKID', '배관조립', 3],
['SKID', '용접', 4], ['SKID', '계장설치', 5], ['SKID', '사상/도장', 6],
['SKID', '검사', 7],
];
for (const [productType, processName, order] of templates) {
await db.query(
'INSERT IGNORE INTO risk_process_templates (product_type, process_name, display_order) VALUES (?, ?, ?)',
[productType, processName, order]
);
}
console.log('[migration] risk tables + templates ready');
} catch (err) {
console.error('[migration] Risk migration error:', err.message);
}
};
// ==================== 공정 템플릿 조회 ====================
const getTemplates = async (productType) => {
const db = getPool();
let query = 'SELECT * FROM risk_process_templates';
const params = [];
if (productType) {
query += ' WHERE product_type = ?';
params.push(productType);
}
query += ' ORDER BY product_type, display_order';
const [rows] = await db.query(query, params);
return rows;
};
// ==================== 프로젝트 CRUD ====================
const createProject = async (data) => {
const db = getPool();
const connection = await db.getConnection();
try {
await connection.beginTransaction();
const { title, assessment_type = 'regular', product_type, year, month = null, assessed_by = null, created_by = null } = data;
const [result] = await connection.query(
`INSERT INTO risk_projects (title, assessment_type, product_type, year, month, assessed_by, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[title, assessment_type, product_type, year, month, assessed_by, created_by]
);
const projectId = result.insertId;
// 정기 평가: 템플릿에서 세부 공정 자동 생성
if (assessment_type === 'regular') {
const [templates] = await connection.query(
'SELECT process_name, display_order FROM risk_process_templates WHERE product_type = ? ORDER BY display_order',
[product_type]
);
for (const t of templates) {
await connection.query(
'INSERT INTO risk_processes (project_id, process_name, display_order) VALUES (?, ?, ?)',
[projectId, t.process_name, t.display_order]
);
}
}
await connection.commit();
return projectId;
} catch (err) {
await connection.rollback();
throw err;
} finally {
connection.release();
}
};
const getAllProjects = async (filters = {}) => {
const db = getPool();
let query = `
SELECT p.*,
(SELECT COUNT(*) FROM risk_assessment_items i
JOIN risk_processes rp ON i.process_id = rp.id
WHERE rp.project_id = p.id) AS total_items,
(SELECT COUNT(*) FROM risk_assessment_items i
JOIN risk_processes rp ON i.process_id = rp.id
WHERE rp.project_id = p.id AND i.risk_score >= 16) AS high_risk_count,
(SELECT COUNT(*) FROM risk_mitigations m
WHERE m.project_id = p.id AND m.status != 'completed') AS open_mitigations
FROM risk_projects p
WHERE 1=1
`;
const params = [];
if (filters.assessment_type) {
query += ' AND p.assessment_type = ?';
params.push(filters.assessment_type);
}
if (filters.year) {
query += ' AND p.year = ?';
params.push(filters.year);
}
if (filters.product_type) {
query += ' AND p.product_type = ?';
params.push(filters.product_type);
}
if (filters.status) {
query += ' AND p.status = ?';
params.push(filters.status);
}
query += ' ORDER BY p.year DESC, p.created_at DESC';
const [rows] = await db.query(query, params);
return rows;
};
const getProjectById = async (id) => {
const db = getPool();
const [projects] = await db.query('SELECT * FROM risk_projects WHERE id = ?', [id]);
if (!projects.length) return null;
const project = projects[0];
// 세부 공정 + 항목 (3단계 중첩)
const [processes] = await db.query(
'SELECT * FROM risk_processes WHERE project_id = ? ORDER BY display_order', [id]
);
for (const proc of processes) {
const [items] = await db.query(
'SELECT * FROM risk_assessment_items WHERE process_id = ? ORDER BY display_order, id', [proc.id]
);
proc.items = items;
}
project.processes = processes;
// 감소대책
const [mitigations] = await db.query(
'SELECT * FROM risk_mitigations WHERE project_id = ? ORDER BY CAST(mitigation_no AS UNSIGNED), mitigation_no', [id]
);
project.mitigations = mitigations;
return project;
};
const updateProject = async (id, data) => {
const db = getPool();
const fields = [];
const params = [];
for (const key of ['title', 'assessment_type', 'product_type', 'year', 'month', 'status', 'assessed_by']) {
if (data[key] !== undefined) {
fields.push(`${key} = ?`);
params.push(data[key]);
}
}
if (!fields.length) return { affectedRows: 0 };
params.push(id);
const [result] = await db.query(
`UPDATE risk_projects SET ${fields.join(', ')} WHERE id = ?`, params
);
return result;
};
const deleteProject = async (id) => {
const db = getPool();
const [result] = await db.query('DELETE FROM risk_projects WHERE id = ?', [id]);
return result;
};
// ==================== 세부 공정 ====================
const addProcess = async (projectId, data) => {
const db = getPool();
const { process_name, display_order = 0 } = data;
const [result] = await db.query(
'INSERT INTO risk_processes (project_id, process_name, display_order) VALUES (?, ?, ?)',
[projectId, process_name, display_order]
);
return result.insertId;
};
// ==================== 평가 항목 CRUD ====================
const createItem = async (processId, data) => {
const db = getPool();
const { category = null, cause = null, hazard = null, regulation = null,
current_measure = null, likelihood = null, severity = null,
mitigation_no = null, detail = null, display_order = 0 } = data;
const [result] = await db.query(
`INSERT INTO risk_assessment_items
(process_id, category, cause, hazard, regulation, current_measure,
likelihood, severity, mitigation_no, detail, display_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[processId, category, cause, hazard, regulation, current_measure,
likelihood, severity, mitigation_no, detail, display_order]
);
return result.insertId;
};
const updateItem = async (itemId, data) => {
const db = getPool();
const fields = [];
const params = [];
for (const key of ['category', 'cause', 'hazard', 'regulation', 'current_measure',
'likelihood', 'severity', 'mitigation_no', 'detail', 'display_order']) {
if (data[key] !== undefined) {
fields.push(`${key} = ?`);
params.push(data[key]);
}
}
if (!fields.length) return { affectedRows: 0 };
params.push(itemId);
const [result] = await db.query(
`UPDATE risk_assessment_items SET ${fields.join(', ')} WHERE id = ?`, params
);
return result;
};
const deleteItem = async (itemId) => {
const db = getPool();
const [result] = await db.query('DELETE FROM risk_assessment_items WHERE id = ?', [itemId]);
return result;
};
// ==================== 감소대책 CRUD ====================
const getMitigationsByProject = async (projectId) => {
const db = getPool();
const [rows] = await db.query(
'SELECT * FROM risk_mitigations WHERE project_id = ? ORDER BY CAST(mitigation_no AS UNSIGNED), mitigation_no',
[projectId]
);
return rows;
};
const createMitigation = async (projectId, data) => {
const db = getPool();
const { mitigation_no, hazard_summary = null, current_risk_score = null,
improvement_plan = null, manager = null, budget = null, schedule = null } = data;
const [result] = await db.query(
`INSERT INTO risk_mitigations
(project_id, mitigation_no, hazard_summary, current_risk_score, improvement_plan, manager, budget, schedule)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[projectId, mitigation_no, hazard_summary, current_risk_score, improvement_plan, manager, budget, schedule]
);
return result.insertId;
};
const updateMitigation = async (mitigationId, data) => {
const db = getPool();
const fields = [];
const params = [];
for (const key of ['mitigation_no', 'hazard_summary', 'current_risk_score', 'improvement_plan',
'manager', 'budget', 'schedule', 'completion_photo', 'completion_date',
'post_likelihood', 'post_severity', 'status']) {
if (data[key] !== undefined) {
fields.push(`${key} = ?`);
params.push(data[key]);
}
}
if (!fields.length) return { affectedRows: 0 };
params.push(mitigationId);
const [result] = await db.query(
`UPDATE risk_mitigations SET ${fields.join(', ')} WHERE id = ?`, params
);
return result;
};
const updateMitigationPhoto = async (mitigationId, photoPath) => {
const db = getPool();
const [result] = await db.query(
'UPDATE risk_mitigations SET completion_photo = ? WHERE id = ?',
[photoPath, mitigationId]
);
return result;
};
module.exports = {
runMigration,
getTemplates,
createProject,
getAllProjects,
getProjectById,
updateProject,
deleteProject,
addProcess,
createItem,
updateItem,
deleteItem,
getMitigationsByProject,
createMitigation,
updateMitigation,
updateMitigationPhoto
};