5개 테이블(risk_projects/processes/items/mitigations/templates) + 마스터 시딩, 프로젝트·항목·감소대책 CRUD API, ExcelJS 평가표 내보내기, 프로젝트 목록·평가 수행 페이지, 사진 업로드(multer), 네비게이션·CSS 추가. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
407 lines
14 KiB
JavaScript
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
|
|
};
|