From d6dd03a52f0fb38d7fb62f9fe15e8163199fdbea Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Thu, 26 Mar 2026 15:39:12 +0900 Subject: [PATCH] =?UTF-8?q?feat(schedule):=20=EA=B3=B5=EC=A0=95=ED=91=9C?= =?UTF-8?q?=20=EC=A0=9C=ED=92=88=EC=9C=A0=ED=98=95=20+=20=ED=91=9C?= =?UTF-8?q?=EC=A4=80=EA=B3=B5=EC=A0=95=20=EC=9E=90=EB=8F=99=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EB=B0=B1=EC=97=94=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - product_types 참조 테이블 + projects.product_type_id FK (tkuser 마이그레이션) - schedule_entries에 work_type_id, risk_assessment_id, source 컬럼 추가 - schedule_phases에 product_type_id 추가 (phase 오염 방지) - generateFromTemplate: tksafety 템플릿 기반 공정 자동 생성 (트랜잭션) - phase 매칭 3단계 우선순위 (전용→범용→신규) - 간트 데이터 NULL 날짜 guard 추가 - system1 startup 마이그레이션 러너 추가 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../api/controllers/scheduleController.js | 31 +++++ .../20260326_schedule_extensions.sql | 22 ++++ system1-factory/api/index.js | 54 +++++++-- system1-factory/api/models/scheduleModel.js | 109 +++++++++++++++++- system1-factory/api/routes/scheduleRoutes.js | 6 + .../api/controllers/projectController.js | 11 +- user-management/api/index.js | 1 + user-management/api/models/projectModel.js | 34 ++++-- user-management/api/routes/projectRoutes.js | 1 + .../migrations/20260326_add_product_types.sql | 22 ++++ 10 files changed, 264 insertions(+), 27 deletions(-) create mode 100644 system1-factory/api/db/migrations/20260326_schedule_extensions.sql create mode 100644 user-management/migrations/20260326_add_product_types.sql diff --git a/system1-factory/api/controllers/scheduleController.js b/system1-factory/api/controllers/scheduleController.js index a974d8e..bfca597 100644 --- a/system1-factory/api/controllers/scheduleController.js +++ b/system1-factory/api/controllers/scheduleController.js @@ -209,6 +209,37 @@ const ScheduleController = { } }, + // === 제품유형 === + getProductTypes: async (req, res) => { + try { + const rows = await ScheduleModel.getProductTypes(); + res.json({ success: true, data: rows }); + } catch (err) { + logger.error('Schedule getProductTypes error:', err); + res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); + } + }, + + // === 표준공정 자동 생성 === + generateFromTemplate: async (req, res) => { + try { + const { project_id, product_type_code } = req.body; + if (!project_id || !product_type_code) { + return res.status(400).json({ success: false, message: '프로젝트와 제품유형을 선택해주세요.' }); + } + const result = await ScheduleModel.generateFromTemplate( + project_id, product_type_code, req.user.user_id || req.user.id + ); + if (result.error) { + return res.status(409).json({ success: false, message: result.error }); + } + res.status(201).json({ success: true, data: result, message: `${result.created}개 표준공정이 생성되었습니다.` }); + } catch (err) { + logger.error('Schedule generateFromTemplate error:', err); + res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); + } + }, + // === 부적합 연동 === getNonconformance: async (req, res) => { try { diff --git a/system1-factory/api/db/migrations/20260326_schedule_extensions.sql b/system1-factory/api/db/migrations/20260326_schedule_extensions.sql new file mode 100644 index 0000000..dfff45a --- /dev/null +++ b/system1-factory/api/db/migrations/20260326_schedule_extensions.sql @@ -0,0 +1,22 @@ +-- schedule_entries 확장: 작업보고서 매핑 + 위험성평가 연결 + 생성 출처 +ALTER TABLE schedule_entries ADD COLUMN work_type_id INT NULL COMMENT 'work_types FK (작업보고서 매핑)'; + +ALTER TABLE schedule_entries ADD COLUMN risk_assessment_id INT NULL COMMENT 'risk_projects FK'; + +ALTER TABLE schedule_entries ADD COLUMN source VARCHAR(20) DEFAULT 'manual' COMMENT '생성 출처 (manual/template)'; + +-- schedule_phases 확장: 제품유형별 phase 구분 +ALTER TABLE schedule_phases ADD COLUMN product_type_id INT NULL COMMENT 'NULL=범용, 값=해당 제품유형 전용'; + +-- FK는 product_types 테이블 존재 시에만 생성 (tkuser 마이그레이션 의존) +-- work_type_id FK +ALTER TABLE schedule_entries ADD CONSTRAINT fk_entry_work_type + FOREIGN KEY (work_type_id) REFERENCES work_types(id) ON DELETE SET NULL; + +-- risk_assessment_id FK (같은 DB, 물리 FK) +ALTER TABLE schedule_entries ADD CONSTRAINT fk_entry_risk_assessment + FOREIGN KEY (risk_assessment_id) REFERENCES risk_projects(id) ON DELETE SET NULL; + +-- schedule_phases.product_type_id FK +ALTER TABLE schedule_phases ADD CONSTRAINT fk_phase_product_type + FOREIGN KEY (product_type_id) REFERENCES product_types(id) ON DELETE SET NULL diff --git a/system1-factory/api/index.js b/system1-factory/api/index.js index 186d254..9f66fac 100644 --- a/system1-factory/api/index.js +++ b/system1-factory/api/index.js @@ -41,26 +41,59 @@ app.use((req, res) => { }); }); -// 서버 시작 -const server = app.listen(PORT, () => { - logger.info(`서버 시작 완료`, { - port: PORT, - env: process.env.NODE_ENV || 'development', - nodeVersion: process.version +// Startup: 마이그레이션 후 서버 시작 +async function runStartupMigrations() { + try { + const { getDb } = require('./dbPool'); + const fs = require('fs'); + const path = require('path'); + const db = await getDb(); + const migrationFiles = ['20260326_schedule_extensions.sql']; + for (const file of migrationFiles) { + const sqlPath = path.join(__dirname, 'db', 'migrations', file); + if (!fs.existsSync(sqlPath)) continue; + const sql = fs.readFileSync(sqlPath, 'utf8'); + const stmts = sql.split(';').map(s => s.trim()).filter(s => s.length > 0); + for (const stmt of stmts) { + try { await db.query(stmt); } catch (err) { + if (['ER_DUP_FIELDNAME', 'ER_TABLE_EXISTS_ERROR', 'ER_DUP_KEYNAME', 'ER_FK_DUP_NAME'].includes(err.code)) { + // 이미 적용됨 — 무시 + } else if (err.code === 'ER_NO_REFERENCED_ROW_2' || err.message.includes('Cannot add foreign key')) { + // product_types 테이블 미존재 (tkuser 미시작) — skip, 재시작 시 retry + logger.warn(`Migration FK skip (dependency not ready): ${err.message}`); + } else { + throw err; + } + } + } + logger.info(`[system1] Migration ${file} completed`); + } + } catch (err) { + logger.error('Migration error:', err.message); + } +} + +let server; + +runStartupMigrations().then(() => { + server = app.listen(PORT, () => { + logger.info(`서버 시작 완료`, { + port: PORT, + env: process.env.NODE_ENV || 'development', + nodeVersion: process.version + }); }); }); // Graceful Shutdown const gracefulShutdown = (signal) => { logger.info(`${signal} 신호 수신 - 서버 종료 시작`); + if (!server) return process.exit(0); server.close(async () => { logger.info('HTTP 서버 종료 완료'); - // 리소스 정리 try { - // DB 연결 종료는 각 요청에서 pool을 사용하므로 불필요 - // Redis 종료 (사용 중인 경우) if (cache.redis) { await cache.redis.quit(); logger.info('캐시 시스템 종료 완료'); @@ -72,15 +105,12 @@ const gracefulShutdown = (signal) => { process.exit(0); }); - // 30초 후 강제 종료 setTimeout(() => { logger.error('강제 종료 - 정상 종료 시간 초과'); - console.error(' 정상 종료 실패, 강제 종료합니다.'); process.exit(1); }, 30000); }; -// 시그널 핸들러 등록 process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); process.on('SIGINT', () => gracefulShutdown('SIGINT')); diff --git a/system1-factory/api/models/scheduleModel.js b/system1-factory/api/models/scheduleModel.js index 88889c8..7024f3f 100644 --- a/system1-factory/api/models/scheduleModel.js +++ b/system1-factory/api/models/scheduleModel.js @@ -49,11 +49,12 @@ const ScheduleModel = { const db = await getDb(); let sql = ` SELECT e.*, p.phase_name, p.color AS phase_color, pr.project_name, pr.job_no AS project_code, - su.name AS created_by_name + su.name AS created_by_name, wt.name AS work_type_name FROM schedule_entries e JOIN schedule_phases p ON e.phase_id = p.phase_id JOIN projects pr ON e.project_id = pr.project_id LEFT JOIN sso_users su ON e.created_by = su.user_id + LEFT JOIN work_types wt ON e.work_type_id = wt.id WHERE 1=1 `; const params = []; @@ -80,7 +81,8 @@ const ScheduleModel = { FROM schedule_entries e JOIN schedule_phases p ON e.phase_id = p.phase_id JOIN projects pr ON e.project_id = pr.project_id - WHERE (YEAR(e.start_date) <= ? AND YEAR(e.end_date) >= ?) + WHERE e.start_date IS NOT NULL AND e.end_date IS NOT NULL + AND (YEAR(e.start_date) <= ? AND YEAR(e.end_date) >= ?) AND e.status != 'cancelled' ORDER BY pr.job_no, p.display_order, e.display_order `, [year, year]); @@ -112,11 +114,12 @@ const ScheduleModel = { const db = await getDb(); const [result] = await db.query( `INSERT INTO schedule_entries - (project_id, phase_id, task_name, start_date, end_date, progress, status, assignee, notes, display_order, created_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + (project_id, phase_id, task_name, start_date, end_date, progress, status, assignee, notes, display_order, created_by, source, work_type_id, risk_assessment_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [data.project_id, data.phase_id, data.task_name, data.start_date, data.end_date, data.progress || 0, data.status || 'planned', data.assignee || null, - data.notes || null, data.display_order || 0, data.created_by || null] + data.notes || null, data.display_order || 0, data.created_by || null, + data.source || 'manual', data.work_type_id || null, data.risk_assessment_id || null] ); return result.insertId; }, @@ -141,7 +144,7 @@ const ScheduleModel = { const db = await getDb(); const fields = []; const params = []; - const allowed = ['task_name', 'start_date', 'end_date', 'progress', 'status', 'assignee', 'notes', 'display_order', 'phase_id']; + const allowed = ['task_name', 'start_date', 'end_date', 'progress', 'status', 'assignee', 'notes', 'display_order', 'phase_id', 'work_type_id', 'risk_assessment_id']; for (const key of allowed) { if (data[key] !== undefined) { fields.push(`${key} = ?`); params.push(data[key]); } } @@ -247,6 +250,100 @@ const ScheduleModel = { await db.query('DELETE FROM schedule_milestones WHERE milestone_id = ?', [milestoneId]); }, + // === 제품유형 === + async getProductTypes() { + const db = await getDb(); + const [rows] = await db.query( + 'SELECT * FROM product_types WHERE is_active = TRUE ORDER BY display_order' + ); + return rows; + }, + + // === 표준공정 자동 생성 === + async generateFromTemplate(projectId, productTypeCode, createdBy) { + const db = await getDb(); + const conn = await db.getConnection(); + try { + await conn.beginTransaction(); + + // 1. 중복 체크 + const [existing] = await conn.query( + "SELECT COUNT(*) AS cnt FROM schedule_entries WHERE project_id = ? AND source = 'template'", + [projectId] + ); + if (existing[0].cnt > 0) { + await conn.rollback(); + return { error: '이미 표준공정이 생성되었습니다' }; + } + + // 2. product_type_id 조회 + const [ptRows] = await conn.query( + 'SELECT id FROM product_types WHERE code = ?', [productTypeCode] + ); + if (ptRows.length === 0) { + await conn.rollback(); + return { error: '존재하지 않는 제품유형입니다' }; + } + const productTypeId = ptRows[0].id; + + // 3. tksafety risk_process_templates 조회 + const [templates] = await conn.query( + 'SELECT * FROM risk_process_templates WHERE product_type = ? ORDER BY display_order', + [productTypeCode] + ); + if (templates.length === 0) { + await conn.rollback(); + return { error: '해당 제품유형의 공정 템플릿이 없습니다' }; + } + + // 4. 각 템플릿 → phase 매칭/생성 → entry 생성 + let createdCount = 0; + for (const tmpl of templates) { + // phase 매칭: 1순위 전용, 2순위 범용, 3순위 신규 + const [specificPhase] = await conn.query( + 'SELECT phase_id FROM schedule_phases WHERE phase_name = ? AND product_type_id = ?', + [tmpl.process_name, productTypeId] + ); + let phaseId; + if (specificPhase.length > 0) { + phaseId = specificPhase[0].phase_id; + } else { + const [genericPhase] = await conn.query( + 'SELECT phase_id FROM schedule_phases WHERE phase_name = ? AND product_type_id IS NULL', + [tmpl.process_name] + ); + if (genericPhase.length > 0) { + phaseId = genericPhase[0].phase_id; + } else { + // 신규 phase 생성 (제품유형 전용) + const [newPhase] = await conn.query( + 'INSERT INTO schedule_phases (phase_name, display_order, product_type_id) VALUES (?, ?, ?)', + [tmpl.process_name, tmpl.display_order, productTypeId] + ); + phaseId = newPhase.insertId; + } + } + + // entry 생성 (날짜 NULL — 관리자가 나중에 입력) + await conn.query( + `INSERT INTO schedule_entries + (project_id, phase_id, task_name, start_date, end_date, status, progress, source, display_order, created_by) + VALUES (?, ?, ?, NULL, NULL, 'planned', 0, 'template', ?, ?)`, + [projectId, phaseId, tmpl.process_name, tmpl.display_order, createdBy] + ); + createdCount++; + } + + await conn.commit(); + return { created: createdCount }; + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); + } + }, + // === 부적합 연동 (격리 함수) === // 향후 System3 API 호출로 전환 시 이 함수만 수정 async getNonconformanceByProject(projectId) { diff --git a/system1-factory/api/routes/scheduleRoutes.js b/system1-factory/api/routes/scheduleRoutes.js index 2a13de8..cfd6475 100644 --- a/system1-factory/api/routes/scheduleRoutes.js +++ b/system1-factory/api/routes/scheduleRoutes.js @@ -3,6 +3,12 @@ const router = express.Router(); const ctrl = require('../controllers/scheduleController'); const { requireMinLevel } = require('../middlewares/auth'); +// 제품유형 +router.get('/product-types', ctrl.getProductTypes); + +// 표준공정 자동 생성 +router.post('/generate-from-template', requireMinLevel('support_team'), ctrl.generateFromTemplate); + // 공정 단계 router.get('/phases', ctrl.getPhases); router.post('/phases', requireMinLevel('admin'), ctrl.createPhase); diff --git a/user-management/api/controllers/projectController.js b/user-management/api/controllers/projectController.js index 6a8d7b7..1cd7af7 100644 --- a/user-management/api/controllers/projectController.js +++ b/user-management/api/controllers/projectController.js @@ -80,4 +80,13 @@ async function remove(req, res, next) { } } -module.exports = { getAll, getActive, getById, create, update, remove }; +async function getProductTypes(req, res, next) { + try { + const types = await projectModel.getProductTypes(); + res.json({ success: true, data: types }); + } catch (err) { + next(err); + } +} + +module.exports = { getAll, getActive, getById, create, update, remove, getProductTypes }; diff --git a/user-management/api/index.js b/user-management/api/index.js index eb1d747..ede2766 100644 --- a/user-management/api/index.js +++ b/user-management/api/index.js @@ -93,6 +93,7 @@ async function start() { const { runMigration, runGenericMigration } = require('./models/vacationSettingsModel'); await runMigration(); await runGenericMigration('20260323_add_resigned_date.sql'); + await runGenericMigration('20260326_add_product_types.sql'); } catch (err) { if (!['ER_DUP_FIELDNAME', 'ER_TABLE_EXISTS_ERROR', 'ER_DUP_KEYNAME'].includes(err.code)) { console.error('Fatal migration error:', err.message); diff --git a/user-management/api/models/projectModel.js b/user-management/api/models/projectModel.js index 894295a..335f50a 100644 --- a/user-management/api/models/projectModel.js +++ b/user-management/api/models/projectModel.js @@ -10,7 +10,10 @@ const { getPool } = require('./userModel'); async function getAll() { const db = getPool(); const [rows] = await db.query( - 'SELECT * FROM projects ORDER BY project_id DESC' + `SELECT p.*, pt.code AS product_type_code, pt.name AS product_type_name + FROM projects p + LEFT JOIN product_types pt ON p.product_type_id = pt.id + ORDER BY p.project_id DESC` ); return rows; } @@ -18,7 +21,10 @@ async function getAll() { async function getActive() { const db = getPool(); const [rows] = await db.query( - 'SELECT * FROM projects WHERE is_active = TRUE ORDER BY project_name ASC' + `SELECT p.*, pt.code AS product_type_code, pt.name AS product_type_name + FROM projects p + LEFT JOIN product_types pt ON p.product_type_id = pt.id + WHERE p.is_active = TRUE ORDER BY p.project_name ASC` ); return rows; } @@ -26,18 +32,29 @@ async function getActive() { async function getById(id) { const db = getPool(); const [rows] = await db.query( - 'SELECT * FROM projects WHERE project_id = ?', + `SELECT p.*, pt.code AS product_type_code, pt.name AS product_type_name + FROM projects p + LEFT JOIN product_types pt ON p.product_type_id = pt.id + WHERE p.project_id = ?`, [id] ); return rows[0] || null; } -async function create({ job_no, project_name, contract_date, due_date, delivery_method, site, pm }) { +async function getProductTypes() { + const db = getPool(); + const [rows] = await db.query( + 'SELECT * FROM product_types WHERE is_active = TRUE ORDER BY display_order' + ); + return rows; +} + +async function create({ job_no, project_name, contract_date, due_date, delivery_method, site, pm, product_type_id }) { const db = getPool(); const [result] = await db.query( - `INSERT INTO projects (job_no, project_name, contract_date, due_date, delivery_method, site, pm) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - [job_no, project_name, contract_date || null, due_date || null, delivery_method || null, site || null, pm || null] + `INSERT INTO projects (job_no, project_name, contract_date, due_date, delivery_method, site, pm, product_type_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [job_no, project_name, contract_date || null, due_date || null, delivery_method || null, site || null, pm || null, product_type_id || null] ); return getById(result.insertId); } @@ -57,6 +74,7 @@ async function update(id, data) { if (data.is_active !== undefined) { fields.push('is_active = ?'); values.push(data.is_active); } if (data.project_status !== undefined) { fields.push('project_status = ?'); values.push(data.project_status); } if (data.completed_date !== undefined) { fields.push('completed_date = ?'); values.push(data.completed_date || null); } + if (data.product_type_id !== undefined) { fields.push('product_type_id = ?'); values.push(data.product_type_id || null); } if (fields.length === 0) return getById(id); @@ -76,4 +94,4 @@ async function deactivate(id) { ); } -module.exports = { getAll, getActive, getById, create, update, deactivate }; +module.exports = { getAll, getActive, getById, create, update, deactivate, getProductTypes }; diff --git a/user-management/api/routes/projectRoutes.js b/user-management/api/routes/projectRoutes.js index d6380e6..caf335f 100644 --- a/user-management/api/routes/projectRoutes.js +++ b/user-management/api/routes/projectRoutes.js @@ -9,6 +9,7 @@ const { requireAuth, requireAdminOrPermission } = require('../middleware/auth'); const projectPerm = requireAdminOrPermission('tkuser.projects'); +router.get('/product-types', requireAuth, projectController.getProductTypes); router.get('/', requireAuth, projectController.getAll); router.get('/active', requireAuth, projectController.getActive); router.get('/:id', requireAuth, projectController.getById); diff --git a/user-management/migrations/20260326_add_product_types.sql b/user-management/migrations/20260326_add_product_types.sql new file mode 100644 index 0000000..f3f9f8b --- /dev/null +++ b/user-management/migrations/20260326_add_product_types.sql @@ -0,0 +1,22 @@ +-- 제품유형 참조 테이블 +CREATE TABLE IF NOT EXISTS product_types ( + id INT AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(20) NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + display_order INT DEFAULT 0, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 초기 데이터 +INSERT IGNORE INTO product_types (code, name, display_order) VALUES + ('PKG', 'Package', 1), + ('VESSEL', '압력용기', 2), + ('HX', '열교환기', 3), + ('SKID', 'Skid', 4); + +-- projects에 product_type_id FK 추가 +ALTER TABLE projects ADD COLUMN product_type_id INT NULL; + +ALTER TABLE projects ADD CONSTRAINT fk_project_product_type + FOREIGN KEY (product_type_id) REFERENCES product_types(id)