feat(tkfb): 공정표 + 생산회의록 시스템 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-17 08:05:18 +09:00
parent b5dc9c2f20
commit d7cc568c01
15 changed files with 2916 additions and 0 deletions

View File

@@ -55,6 +55,8 @@ function setupRoutes(app) {
const purchaseRequestRoutes = require('../routes/purchaseRequestRoutes');
const purchaseRoutes = require('../routes/purchaseRoutes');
const settlementRoutes = require('../routes/settlementRoutes');
const scheduleRoutes = require('../routes/scheduleRoutes');
const meetingRoutes = require('../routes/meetingRoutes');
// Rate Limiters 설정
const rateLimit = require('express-rate-limit');
@@ -167,6 +169,8 @@ function setupRoutes(app) {
app.use('/api/purchase-requests', purchaseRequestRoutes); // 구매신청
app.use('/api/purchases', purchaseRoutes); // 구매 내역
app.use('/api/settlements', settlementRoutes); // 월간 정산
app.use('/api/schedule', scheduleRoutes); // 공정표
app.use('/api/meetings', meetingRoutes); // 생산회의록
app.use('/api', uploadBgRoutes);
// Swagger API 문서

View File

@@ -0,0 +1,182 @@
const MeetingModel = require('../models/meetingModel');
const logger = require('../utils/logger');
const MeetingController = {
// 회의록 목록
getAll: async (req, res) => {
try {
const { year, month, search } = req.query;
const rows = await MeetingModel.getAll({ year, month, search });
res.json({ success: true, data: rows });
} catch (err) {
logger.error('Meeting getAll error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 회의록 상세
getById: async (req, res) => {
try {
const meeting = await MeetingModel.getById(req.params.id);
if (!meeting) return res.status(404).json({ success: false, message: '회의록을 찾을 수 없습니다.' });
res.json({ success: true, data: meeting });
} catch (err) {
logger.error('Meeting getById error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 회의록 생성
create: async (req, res) => {
try {
const { meeting_date, title } = req.body;
if (!meeting_date || !title) {
return res.status(400).json({ success: false, message: '날짜와 제목은 필수입니다.' });
}
const id = await MeetingModel.create({
...req.body,
created_by: req.user.user_id || req.user.id
});
res.status(201).json({ success: true, data: { meeting_id: id }, message: '회의록이 생성되었습니다.' });
} catch (err) {
logger.error('Meeting create error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 회의록 수정
update: async (req, res) => {
try {
const meetingId = req.params.id;
const status = await MeetingModel.getStatus(meetingId);
if (!status) return res.status(404).json({ success: false, message: '회의록을 찾을 수 없습니다.' });
// published 상태면 admin만 수정 가능
const userLevel = req.user.access_level || req.user.role;
if (status === 'published' && !['admin', 'system'].includes(userLevel)) {
return res.status(403).json({ success: false, message: '발행된 회의록은 관리자만 수정할 수 있습니다.' });
}
await MeetingModel.update(meetingId, req.body);
res.json({ success: true, message: '회의록이 수정되었습니다.' });
} catch (err) {
logger.error('Meeting update error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 회의록 발행
publish: async (req, res) => {
try {
await MeetingModel.publish(req.params.id);
res.json({ success: true, message: '회의록이 발행되었습니다.' });
} catch (err) {
logger.error('Meeting publish error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 발행 취소 (admin only)
unpublish: async (req, res) => {
try {
await MeetingModel.unpublish(req.params.id);
res.json({ success: true, message: '발행이 취소되었습니다.' });
} catch (err) {
logger.error('Meeting unpublish error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 회의록 삭제
delete: async (req, res) => {
try {
await MeetingModel.delete(req.params.id);
res.json({ success: true, message: '회의록이 삭제되었습니다.' });
} catch (err) {
logger.error('Meeting delete error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// === 안건 ===
addItem: async (req, res) => {
try {
const { content } = req.body;
if (!content) return res.status(400).json({ success: false, message: '안건 내용을 입력해주세요.' });
// published 체크
const status = await MeetingModel.getStatus(req.params.id);
const userLevel = req.user.access_level || req.user.role;
if (status === 'published' && !['admin', 'system'].includes(userLevel)) {
return res.status(403).json({ success: false, message: '발행된 회의록은 관리자만 수정할 수 있습니다.' });
}
const id = await MeetingModel.addItem(req.params.id, req.body);
res.status(201).json({ success: true, data: { item_id: id }, message: '안건이 추가되었습니다.' });
} catch (err) {
logger.error('Meeting addItem error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
updateItem: async (req, res) => {
try {
// published 체크
const status = await MeetingModel.getStatus(req.params.id);
const userLevel = req.user.access_level || req.user.role;
if (status === 'published' && !['admin', 'system'].includes(userLevel)) {
return res.status(403).json({ success: false, message: '발행된 회의록은 관리자만 수정할 수 있습니다.' });
}
await MeetingModel.updateItem(req.params.itemId, req.body);
res.json({ success: true, message: '안건이 수정되었습니다.' });
} catch (err) {
logger.error('Meeting updateItem error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
deleteItem: async (req, res) => {
try {
// published 체크
const status = await MeetingModel.getStatus(req.params.id);
const userLevel = req.user.access_level || req.user.role;
if (status === 'published' && !['admin', 'system'].includes(userLevel)) {
return res.status(403).json({ success: false, message: '발행된 회의록은 관리자만 수정할 수 있습니다.' });
}
await MeetingModel.deleteItem(req.params.itemId);
res.json({ success: true, message: '안건이 삭제되었습니다.' });
} catch (err) {
logger.error('Meeting deleteItem error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 조치상태 업데이트 (group_leader+)
updateItemStatus: async (req, res) => {
try {
const { status } = req.body;
if (!status) return res.status(400).json({ success: false, message: '상태를 선택해주세요.' });
await MeetingModel.updateItemStatus(req.params.itemId, status);
res.json({ success: true, message: '조치상태가 업데이트되었습니다.' });
} catch (err) {
logger.error('Meeting updateItemStatus error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 미완료 조치사항
getActionItems: async (req, res) => {
try {
const { status, responsible_user_id } = req.query;
const rows = await MeetingModel.getActionItems({ status, responsible_user_id });
res.json({ success: true, data: rows });
} catch (err) {
logger.error('Meeting getActionItems error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
}
};
module.exports = MeetingController;

View File

@@ -0,0 +1,228 @@
const ScheduleModel = require('../models/scheduleModel');
const logger = require('../utils/logger');
const ScheduleController = {
// === 공정 단계 ===
getPhases: async (req, res) => {
try {
const rows = await ScheduleModel.getPhases();
res.json({ success: true, data: rows });
} catch (err) {
logger.error('Schedule getPhases error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
createPhase: async (req, res) => {
try {
const { phase_name, display_order, color } = req.body;
if (!phase_name) return res.status(400).json({ success: false, message: '단계명을 입력해주세요.' });
const id = await ScheduleModel.createPhase({ phase_name, display_order, color });
res.status(201).json({ success: true, data: { phase_id: id }, message: '공정 단계가 추가되었습니다.' });
} catch (err) {
logger.error('Schedule createPhase error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
updatePhase: async (req, res) => {
try {
await ScheduleModel.updatePhase(req.params.id, req.body);
res.json({ success: true, message: '공정 단계가 수정되었습니다.' });
} catch (err) {
logger.error('Schedule updatePhase error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// === 작업 템플릿 ===
getTemplates: async (req, res) => {
try {
const rows = await ScheduleModel.getTemplates(req.query.phase_id);
res.json({ success: true, data: rows });
} catch (err) {
logger.error('Schedule getTemplates error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// === 공정표 항목 ===
getEntries: async (req, res) => {
try {
const { project_id, year, month } = req.query;
const rows = await ScheduleModel.getEntries({ project_id, year, month });
res.json({ success: true, data: rows });
} catch (err) {
logger.error('Schedule getEntries error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
getGanttData: async (req, res) => {
try {
const year = req.query.year || new Date().getFullYear();
const data = await ScheduleModel.getGanttData(year);
res.json({ success: true, data });
} catch (err) {
logger.error('Schedule getGanttData error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
createEntry: async (req, res) => {
try {
const { project_id, phase_id, task_name, start_date, end_date } = req.body;
if (!project_id || !phase_id || !task_name || !start_date || !end_date) {
return res.status(400).json({ success: false, message: '필수 항목을 모두 입력해주세요.' });
}
const id = await ScheduleModel.createEntry({
...req.body,
created_by: req.user.user_id || req.user.id
});
res.status(201).json({ success: true, data: { entry_id: id }, message: '공정표 항목이 추가되었습니다.' });
} catch (err) {
logger.error('Schedule createEntry error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
createBatchEntries: async (req, res) => {
try {
const { project_id, phase_id, entries } = req.body;
if (!project_id || !phase_id || !entries || entries.length === 0) {
return res.status(400).json({ success: false, message: '프로젝트, 단계, 항목 정보를 입력해주세요.' });
}
const ids = await ScheduleModel.createBatchEntries(
project_id, phase_id, entries, req.user.user_id || req.user.id
);
res.status(201).json({ success: true, data: { entry_ids: ids }, message: `${ids.length}개 항목이 일괄 추가되었습니다.` });
} catch (err) {
logger.error('Schedule createBatchEntries error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
updateEntry: async (req, res) => {
try {
await ScheduleModel.updateEntry(req.params.id, req.body);
res.json({ success: true, message: '공정표 항목이 수정되었습니다.' });
} catch (err) {
logger.error('Schedule updateEntry error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
updateProgress: async (req, res) => {
try {
const { progress } = req.body;
if (progress === undefined || progress < 0 || progress > 100) {
return res.status(400).json({ success: false, message: '진행률은 0~100 사이의 값이어야 합니다.' });
}
await ScheduleModel.updateProgress(req.params.id, progress);
res.json({ success: true, message: '진행률이 업데이트되었습니다.' });
} catch (err) {
logger.error('Schedule updateProgress error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
deleteEntry: async (req, res) => {
try {
await ScheduleModel.deleteEntry(req.params.id);
res.json({ success: true, message: '공정표 항목이 삭제되었습니다.' });
} catch (err) {
logger.error('Schedule deleteEntry error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// === 의존관계 ===
addDependency: async (req, res) => {
try {
const { depends_on_entry_id } = req.body;
if (!depends_on_entry_id) {
return res.status(400).json({ success: false, message: '선행 작업을 선택해주세요.' });
}
await ScheduleModel.addDependency(req.params.id, depends_on_entry_id);
res.status(201).json({ success: true, message: '의존관계가 추가되었습니다.' });
} catch (err) {
logger.error('Schedule addDependency error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
removeDependency: async (req, res) => {
try {
await ScheduleModel.removeDependency(req.params.id, req.params.depId);
res.json({ success: true, message: '의존관계가 삭제되었습니다.' });
} catch (err) {
logger.error('Schedule removeDependency error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// === 마일스톤 ===
getMilestones: async (req, res) => {
try {
const rows = await ScheduleModel.getMilestones({ project_id: req.query.project_id });
res.json({ success: true, data: rows });
} catch (err) {
logger.error('Schedule getMilestones error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
createMilestone: async (req, res) => {
try {
const { project_id, milestone_name, milestone_date } = req.body;
if (!project_id || !milestone_name || !milestone_date) {
return res.status(400).json({ success: false, message: '필수 항목을 모두 입력해주세요.' });
}
const id = await ScheduleModel.createMilestone({
...req.body,
created_by: req.user.user_id || req.user.id
});
res.status(201).json({ success: true, data: { milestone_id: id }, message: '마일스톤이 추가되었습니다.' });
} catch (err) {
logger.error('Schedule createMilestone error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
updateMilestone: async (req, res) => {
try {
await ScheduleModel.updateMilestone(req.params.id, req.body);
res.json({ success: true, message: '마일스톤이 수정되었습니다.' });
} catch (err) {
logger.error('Schedule updateMilestone error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
deleteMilestone: async (req, res) => {
try {
await ScheduleModel.deleteMilestone(req.params.id);
res.json({ success: true, message: '마일스톤이 삭제되었습니다.' });
} catch (err) {
logger.error('Schedule deleteMilestone error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// === 부적합 연동 ===
getNonconformance: async (req, res) => {
try {
const { project_id } = req.query;
if (!project_id) {
return res.status(400).json({ success: false, message: '프로젝트를 선택해주세요.' });
}
const rows = await ScheduleModel.getNonconformanceByProject(project_id);
res.json({ success: true, data: rows });
} catch (err) {
logger.error('Schedule getNonconformance error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
}
};
module.exports = ScheduleController;

View File

@@ -0,0 +1,182 @@
/**
* 공정표 + 생산회의록 시스템 마이그레이션
*
* 테이블 8개: schedule_phases, schedule_task_templates, schedule_entries,
* schedule_entry_dependencies, schedule_milestones, meeting_minutes,
* meeting_attendees, meeting_agenda_items
*/
exports.up = async (knex) => {
// 1. 공정 단계
await knex.schema.createTable('schedule_phases', (table) => {
table.increments('phase_id').primary();
table.string('phase_name', 100).notNullable();
table.integer('display_order').defaultTo(0);
table.string('color', 20).defaultTo('#3B82F6');
table.boolean('is_active').defaultTo(true);
});
// 2. 작업 템플릿
await knex.schema.createTable('schedule_task_templates', (table) => {
table.increments('template_id').primary();
table.integer('phase_id').unsigned().notNullable()
.references('phase_id').inTable('schedule_phases').onDelete('CASCADE');
table.string('task_name', 200).notNullable();
table.integer('default_duration_days').defaultTo(7);
table.integer('display_order').defaultTo(0);
table.boolean('is_active').defaultTo(true);
});
// 3. 공정표 항목
await knex.schema.createTable('schedule_entries', (table) => {
table.increments('entry_id').primary();
table.integer('project_id').unsigned().notNullable()
.references('project_id').inTable('projects');
table.integer('phase_id').unsigned().notNullable()
.references('phase_id').inTable('schedule_phases');
table.string('task_name', 200).notNullable();
table.date('start_date').notNullable();
table.date('end_date').notNullable();
table.integer('progress').defaultTo(0);
table.string('status', 20).defaultTo('planned');
table.string('assignee', 100).nullable();
table.text('notes').nullable();
table.integer('display_order').defaultTo(0);
table.integer('created_by').unsigned().nullable()
.references('user_id').inTable('sso_users');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
});
// 4. 작업 의존관계 (다대다)
await knex.schema.createTable('schedule_entry_dependencies', (table) => {
table.increments('id').primary();
table.integer('entry_id').unsigned().notNullable()
.references('entry_id').inTable('schedule_entries').onDelete('CASCADE');
table.integer('depends_on_entry_id').unsigned().notNullable()
.references('entry_id').inTable('schedule_entries').onDelete('CASCADE');
table.unique(['entry_id', 'depends_on_entry_id']);
});
// 5. 마일스톤
await knex.schema.createTable('schedule_milestones', (table) => {
table.increments('milestone_id').primary();
table.integer('project_id').unsigned().notNullable()
.references('project_id').inTable('projects');
table.integer('entry_id').unsigned().nullable()
.references('entry_id').inTable('schedule_entries').onDelete('SET NULL');
table.string('milestone_name', 200).notNullable();
table.date('milestone_date').notNullable();
table.string('milestone_type', 30).defaultTo('deadline');
table.string('status', 20).defaultTo('upcoming');
table.text('notes').nullable();
table.integer('created_by').unsigned().nullable()
.references('user_id').inTable('sso_users');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
});
// 6. 생산회의록
await knex.schema.createTable('meeting_minutes', (table) => {
table.increments('meeting_id').primary();
table.date('meeting_date').notNullable();
table.string('meeting_time', 10).nullable();
table.string('title', 300).notNullable();
table.string('location', 200).nullable();
table.text('summary').nullable();
table.string('status', 20).defaultTo('draft');
table.integer('created_by').unsigned().nullable()
.references('user_id').inTable('sso_users');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
});
// 7. 회의 참석자 (정규화)
await knex.schema.createTable('meeting_attendees', (table) => {
table.increments('id').primary();
table.integer('meeting_id').unsigned().notNullable()
.references('meeting_id').inTable('meeting_minutes').onDelete('CASCADE');
table.integer('user_id').unsigned().notNullable()
.references('user_id').inTable('sso_users');
table.unique(['meeting_id', 'user_id']);
});
// 8. 회의 안건
await knex.schema.createTable('meeting_agenda_items', (table) => {
table.increments('item_id').primary();
table.integer('meeting_id').unsigned().notNullable()
.references('meeting_id').inTable('meeting_minutes').onDelete('CASCADE');
table.integer('project_id').unsigned().nullable()
.references('project_id').inTable('projects');
table.integer('milestone_id').unsigned().nullable()
.references('milestone_id').inTable('schedule_milestones').onDelete('SET NULL');
table.string('item_type', 30).defaultTo('other');
table.text('content').notNullable();
table.text('decision').nullable();
table.text('action_required').nullable();
table.integer('responsible_user_id').unsigned().nullable()
.references('user_id').inTable('sso_users');
table.date('due_date').nullable();
table.string('status', 20).defaultTo('open');
table.integer('display_order').defaultTo(0);
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
});
// Seed: 공정 단계
await knex('schedule_phases').insert([
{ phase_name: 'Outsourcing', display_order: 1, color: '#3B82F6' },
{ phase_name: 'BASE', display_order: 2, color: '#10B981' },
{ phase_name: 'SHOP', display_order: 3, color: '#F59E0B' },
{ phase_name: 'PV/Heat Exchanger', display_order: 4, color: '#8B5CF6' },
]);
// Seed: 작업 템플릿
const phases = await knex('schedule_phases').select('phase_id', 'phase_name');
const phaseMap = {};
phases.forEach(p => { phaseMap[p.phase_name] = p.phase_id; });
const templates = [
// Outsourcing
{ phase_id: phaseMap['Outsourcing'], task_name: '용기입고', default_duration_days: 14, display_order: 1 },
{ phase_id: phaseMap['Outsourcing'], task_name: 'PTK', default_duration_days: 10, display_order: 2 },
{ phase_id: phaseMap['Outsourcing'], task_name: 'Tray and Instrument Wiring', default_duration_days: 7, display_order: 3 },
{ phase_id: phaseMap['Outsourcing'], task_name: 'Painting', default_duration_days: 5, display_order: 4 },
{ phase_id: phaseMap['Outsourcing'], task_name: 'Instrument Wiring', default_duration_days: 7, display_order: 5 },
{ phase_id: phaseMap['Outsourcing'], task_name: 'Packing', default_duration_days: 3, display_order: 6 },
// BASE
{ phase_id: phaseMap['BASE'], task_name: 'Base Fabrication', default_duration_days: 14, display_order: 1 },
{ phase_id: phaseMap['BASE'], task_name: 'Base 제작', default_duration_days: 10, display_order: 2 },
{ phase_id: phaseMap['BASE'], task_name: '용기설치', default_duration_days: 5, display_order: 3 },
// SHOP
{ phase_id: phaseMap['SHOP'], task_name: '배관자재입고', default_duration_days: 7, display_order: 1 },
{ phase_id: phaseMap['SHOP'], task_name: 'Pre-Fabrication for Piping', default_duration_days: 10, display_order: 2 },
{ phase_id: phaseMap['SHOP'], task_name: '1st Piping Assembly', default_duration_days: 14, display_order: 3 },
{ phase_id: phaseMap['SHOP'], task_name: 'Hydro. Test', default_duration_days: 3, display_order: 4 },
{ phase_id: phaseMap['SHOP'], task_name: 'Re-Assembly', default_duration_days: 7, display_order: 5 },
{ phase_id: phaseMap['SHOP'], task_name: 'Tubing', default_duration_days: 5, display_order: 6 },
{ phase_id: phaseMap['SHOP'], task_name: 'FAT', default_duration_days: 2, display_order: 7 },
];
await knex('schedule_task_templates').insert(templates);
// 페이지 접근 권한 등록
await knex.raw(`
INSERT IGNORE INTO pages (page_key, page_name, page_path, description, is_active) VALUES
('work.schedule', '공정표', '/pages/work/schedule.html', '프로젝트 공정표 Gantt 뷰', 1),
('work.meetings', '생산회의록', '/pages/work/meetings.html', '생산회의록 관리', 1),
('work.meeting_detail', '회의록 상세', '/pages/work/meeting-detail.html', '회의록 상세/작성', 1)
`);
};
exports.down = async (knex) => {
await knex.schema.dropTableIfExists('meeting_agenda_items');
await knex.schema.dropTableIfExists('meeting_attendees');
await knex.schema.dropTableIfExists('meeting_minutes');
await knex.schema.dropTableIfExists('schedule_milestones');
await knex.schema.dropTableIfExists('schedule_entry_dependencies');
await knex.schema.dropTableIfExists('schedule_entries');
await knex.schema.dropTableIfExists('schedule_task_templates');
await knex.schema.dropTableIfExists('schedule_phases');
await knex.raw(`DELETE FROM pages WHERE page_key IN ('work.schedule', 'work.meetings', 'work.meeting_detail')`);
};

View File

@@ -0,0 +1,223 @@
// models/meetingModel.js
const { getDb } = require('../dbPool');
const MeetingModel = {
// === 회의록 ===
async getAll(filters = {}) {
const db = await getDb();
let sql = `
SELECT m.*,
su.name AS created_by_name,
(SELECT COUNT(*) FROM meeting_attendees WHERE meeting_id = m.meeting_id) AS attendee_count,
(SELECT COUNT(*) FROM meeting_agenda_items WHERE meeting_id = m.meeting_id) AS agenda_count,
(SELECT COUNT(*) FROM meeting_agenda_items
WHERE meeting_id = m.meeting_id AND status IN ('open','in_progress')) AS open_action_count
FROM meeting_minutes m
LEFT JOIN sso_users su ON m.created_by = su.user_id
WHERE 1=1
`;
const params = [];
if (filters.year && filters.month) {
sql += ' AND YEAR(m.meeting_date) = ? AND MONTH(m.meeting_date) = ?';
params.push(filters.year, filters.month);
} else if (filters.year) {
sql += ' AND YEAR(m.meeting_date) = ?';
params.push(filters.year);
}
if (filters.search) {
sql += ' AND (m.title LIKE ? OR m.summary LIKE ?)';
params.push(`%${filters.search}%`, `%${filters.search}%`);
}
sql += ' ORDER BY m.meeting_date DESC, m.created_at DESC';
const [rows] = await db.query(sql, params);
return rows;
},
async getById(meetingId) {
const db = await getDb();
// 회의 기본정보
const [meetings] = await db.query(`
SELECT m.*, su.name AS created_by_name
FROM meeting_minutes m
LEFT JOIN sso_users su ON m.created_by = su.user_id
WHERE m.meeting_id = ?
`, [meetingId]);
if (meetings.length === 0) return null;
const meeting = meetings[0];
// 참석자
const [attendees] = await db.query(`
SELECT ma.id, ma.user_id, su.name, su.username, su.department
FROM meeting_attendees ma
JOIN sso_users su ON ma.user_id = su.user_id
WHERE ma.meeting_id = ?
ORDER BY su.name
`, [meetingId]);
meeting.attendees = attendees;
// 안건
const [items] = await db.query(`
SELECT ai.*, pr.project_name, pr.project_code,
ms.milestone_name, ms.milestone_date,
su.name AS responsible_name
FROM meeting_agenda_items ai
LEFT JOIN projects pr ON ai.project_id = pr.project_id
LEFT JOIN schedule_milestones ms ON ai.milestone_id = ms.milestone_id
LEFT JOIN sso_users su ON ai.responsible_user_id = su.user_id
WHERE ai.meeting_id = ?
ORDER BY ai.display_order, ai.item_id
`, [meetingId]);
meeting.items = items;
return meeting;
},
async create(data) {
const db = await getDb();
const [result] = await db.query(
`INSERT INTO meeting_minutes (meeting_date, meeting_time, title, location, summary, status, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[data.meeting_date, data.meeting_time || null, data.title,
data.location || null, data.summary || null, 'draft', data.created_by]
);
const meetingId = result.insertId;
// 참석자 추가
if (data.attendees && data.attendees.length > 0) {
const values = data.attendees.map(userId => [meetingId, userId]);
await db.query('INSERT INTO meeting_attendees (meeting_id, user_id) VALUES ?', [values]);
}
return meetingId;
},
async update(meetingId, data) {
const db = await getDb();
const fields = [];
const params = [];
const allowed = ['meeting_date', 'meeting_time', 'title', 'location', 'summary'];
for (const key of allowed) {
if (data[key] !== undefined) { fields.push(`${key} = ?`); params.push(data[key]); }
}
if (fields.length > 0) {
fields.push('updated_at = NOW()');
params.push(meetingId);
await db.query(`UPDATE meeting_minutes SET ${fields.join(', ')} WHERE meeting_id = ?`, params);
}
// 참석자 재설정
if (data.attendees !== undefined) {
await db.query('DELETE FROM meeting_attendees WHERE meeting_id = ?', [meetingId]);
if (data.attendees.length > 0) {
const values = data.attendees.map(userId => [meetingId, userId]);
await db.query('INSERT INTO meeting_attendees (meeting_id, user_id) VALUES ?', [values]);
}
}
},
async publish(meetingId) {
const db = await getDb();
await db.query(
"UPDATE meeting_minutes SET status = 'published', updated_at = NOW() WHERE meeting_id = ?",
[meetingId]
);
},
async unpublish(meetingId) {
const db = await getDb();
await db.query(
"UPDATE meeting_minutes SET status = 'draft', updated_at = NOW() WHERE meeting_id = ?",
[meetingId]
);
},
async delete(meetingId) {
const db = await getDb();
await db.query('DELETE FROM meeting_minutes WHERE meeting_id = ?', [meetingId]);
},
// === 안건 ===
async addItem(meetingId, data) {
const db = await getDb();
const [result] = await db.query(
`INSERT INTO meeting_agenda_items
(meeting_id, project_id, milestone_id, item_type, content, decision, action_required,
responsible_user_id, due_date, status, display_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[meetingId, data.project_id || null, data.milestone_id || null,
data.item_type || 'other', data.content, data.decision || null,
data.action_required || null, data.responsible_user_id || null,
data.due_date || null, data.status || 'open', data.display_order || 0]
);
return result.insertId;
},
async updateItem(itemId, data) {
const db = await getDb();
const fields = [];
const params = [];
const allowed = ['project_id', 'milestone_id', 'item_type', 'content', 'decision',
'action_required', 'responsible_user_id', 'due_date', 'status', 'display_order'];
for (const key of allowed) {
if (data[key] !== undefined) { fields.push(`${key} = ?`); params.push(data[key]); }
}
if (fields.length === 0) return;
fields.push('updated_at = NOW()');
params.push(itemId);
await db.query(`UPDATE meeting_agenda_items SET ${fields.join(', ')} WHERE item_id = ?`, params);
},
async deleteItem(itemId) {
const db = await getDb();
await db.query('DELETE FROM meeting_agenda_items WHERE item_id = ?', [itemId]);
},
async updateItemStatus(itemId, status) {
const db = await getDb();
await db.query(
'UPDATE meeting_agenda_items SET status = ?, updated_at = NOW() WHERE item_id = ?',
[status, itemId]
);
},
// === 미완료 조치사항 ===
async getActionItems(filters = {}) {
const db = await getDb();
let sql = `
SELECT ai.*, m.title AS meeting_title, m.meeting_date,
pr.project_name, pr.project_code,
su.name AS responsible_name
FROM meeting_agenda_items ai
JOIN meeting_minutes m ON ai.meeting_id = m.meeting_id
LEFT JOIN projects pr ON ai.project_id = pr.project_id
LEFT JOIN sso_users su ON ai.responsible_user_id = su.user_id
WHERE ai.item_type IN ('action_item', 'issue', 'decision')
`;
const params = [];
if (filters.status) {
sql += ' AND ai.status = ?';
params.push(filters.status);
} else {
sql += " AND ai.status IN ('open', 'in_progress')";
}
if (filters.responsible_user_id) {
sql += ' AND ai.responsible_user_id = ?';
params.push(filters.responsible_user_id);
}
sql += ' ORDER BY ai.due_date ASC, m.meeting_date DESC';
const [rows] = await db.query(sql, params);
return rows;
},
// 회의록 상태 조회 (published 체크용)
async getStatus(meetingId) {
const db = await getDb();
const [rows] = await db.query(
'SELECT status FROM meeting_minutes WHERE meeting_id = ?',
[meetingId]
);
return rows.length > 0 ? rows[0].status : null;
}
};
module.exports = MeetingModel;

View File

@@ -0,0 +1,265 @@
// models/scheduleModel.js
const { getDb } = require('../dbPool');
const ScheduleModel = {
// === 공정 단계 ===
async getPhases() {
const db = await getDb();
const [rows] = await db.query(
'SELECT * FROM schedule_phases WHERE is_active = 1 ORDER BY display_order'
);
return rows;
},
async createPhase(data) {
const db = await getDb();
const [result] = await db.query(
'INSERT INTO schedule_phases (phase_name, display_order, color) VALUES (?, ?, ?)',
[data.phase_name, data.display_order || 0, data.color || '#3B82F6']
);
return result.insertId;
},
async updatePhase(phaseId, data) {
const db = await getDb();
const fields = [];
const params = [];
if (data.phase_name !== undefined) { fields.push('phase_name = ?'); params.push(data.phase_name); }
if (data.display_order !== undefined) { fields.push('display_order = ?'); params.push(data.display_order); }
if (data.color !== undefined) { fields.push('color = ?'); params.push(data.color); }
if (data.is_active !== undefined) { fields.push('is_active = ?'); params.push(data.is_active); }
if (fields.length === 0) return;
params.push(phaseId);
await db.query(`UPDATE schedule_phases SET ${fields.join(', ')} WHERE phase_id = ?`, params);
},
// === 작업 템플릿 ===
async getTemplates(phaseId) {
const db = await getDb();
let sql = 'SELECT t.*, p.phase_name FROM schedule_task_templates t JOIN schedule_phases p ON t.phase_id = p.phase_id WHERE t.is_active = 1';
const params = [];
if (phaseId) { sql += ' AND t.phase_id = ?'; params.push(phaseId); }
sql += ' ORDER BY p.display_order, t.display_order';
const [rows] = await db.query(sql, params);
return rows;
},
// === 공정표 항목 ===
async getEntries(filters = {}) {
const db = await getDb();
let sql = `
SELECT e.*, p.phase_name, p.color AS phase_color, pr.project_name, pr.project_code,
su.name AS created_by_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
WHERE 1=1
`;
const params = [];
if (filters.project_id) { sql += ' AND e.project_id = ?'; params.push(filters.project_id); }
if (filters.year) {
sql += ' AND (YEAR(e.start_date) = ? OR YEAR(e.end_date) = ?)';
params.push(filters.year, filters.year);
}
if (filters.month && filters.year) {
sql += ' AND ((YEAR(e.start_date) = ? AND MONTH(e.start_date) = ?) OR (YEAR(e.end_date) = ? AND MONTH(e.end_date) = ?))';
params.push(filters.year, filters.month, filters.year, filters.month);
}
sql += ' ORDER BY pr.project_code, p.display_order, e.display_order';
const [rows] = await db.query(sql, params);
return rows;
},
async getGanttData(year) {
const db = await getDb();
// 해당 연도에 걸치는 모든 항목
const [entries] = await db.query(`
SELECT e.*, p.phase_name, p.color AS phase_color, p.display_order AS phase_order,
pr.project_name, pr.project_code
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) >= ?)
AND e.status != 'cancelled'
ORDER BY pr.project_code, p.display_order, e.display_order
`, [year, year]);
// 의존관계
const entryIds = entries.map(e => e.entry_id);
let dependencies = [];
if (entryIds.length > 0) {
const [deps] = await db.query(
`SELECT * FROM schedule_entry_dependencies WHERE entry_id IN (?)`,
[entryIds]
);
dependencies = deps;
}
// 마일스톤
const [milestones] = await db.query(`
SELECT m.*, pr.project_name, pr.project_code
FROM schedule_milestones m
JOIN projects pr ON m.project_id = pr.project_id
WHERE YEAR(m.milestone_date) = ?
ORDER BY m.milestone_date
`, [year]);
return { entries, dependencies, milestones };
},
async createEntry(data) {
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[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]
);
return result.insertId;
},
async createBatchEntries(projectId, phaseId, entries, createdBy) {
const db = await getDb();
const ids = [];
for (const entry of entries) {
const [result] = await db.query(
`INSERT INTO schedule_entries
(project_id, phase_id, task_name, start_date, end_date, display_order, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[projectId, phaseId, entry.task_name, entry.start_date, entry.end_date,
entry.display_order || 0, createdBy]
);
ids.push(result.insertId);
}
return ids;
},
async updateEntry(entryId, data) {
const db = await getDb();
const fields = [];
const params = [];
const allowed = ['task_name', 'start_date', 'end_date', 'progress', 'status', 'assignee', 'notes', 'display_order', 'phase_id'];
for (const key of allowed) {
if (data[key] !== undefined) { fields.push(`${key} = ?`); params.push(data[key]); }
}
if (fields.length === 0) return;
fields.push('updated_at = NOW()');
params.push(entryId);
await db.query(`UPDATE schedule_entries SET ${fields.join(', ')} WHERE entry_id = ?`, params);
},
async updateProgress(entryId, progress) {
const db = await getDb();
let status = 'in_progress';
if (progress === 0) status = 'planned';
if (progress === 100) status = 'completed';
await db.query(
'UPDATE schedule_entries SET progress = ?, status = ?, updated_at = NOW() WHERE entry_id = ?',
[progress, status, entryId]
);
},
async deleteEntry(entryId) {
const db = await getDb();
await db.query('DELETE FROM schedule_entries WHERE entry_id = ?', [entryId]);
},
// === 의존관계 ===
async addDependency(entryId, dependsOnEntryId) {
const db = await getDb();
const [result] = await db.query(
'INSERT IGNORE INTO schedule_entry_dependencies (entry_id, depends_on_entry_id) VALUES (?, ?)',
[entryId, dependsOnEntryId]
);
return result.insertId;
},
async removeDependency(entryId, dependsOnEntryId) {
const db = await getDb();
await db.query(
'DELETE FROM schedule_entry_dependencies WHERE entry_id = ? AND depends_on_entry_id = ?',
[entryId, dependsOnEntryId]
);
},
async getDependencies(entryId) {
const db = await getDb();
const [rows] = await db.query(`
SELECT d.*, e.task_name AS depends_on_task_name
FROM schedule_entry_dependencies d
JOIN schedule_entries e ON d.depends_on_entry_id = e.entry_id
WHERE d.entry_id = ?
`, [entryId]);
return rows;
},
// === 마일스톤 ===
async getMilestones(filters = {}) {
const db = await getDb();
let sql = `
SELECT m.*, pr.project_name, pr.project_code, e.task_name AS entry_task_name,
su.name AS created_by_name
FROM schedule_milestones m
JOIN projects pr ON m.project_id = pr.project_id
LEFT JOIN schedule_entries e ON m.entry_id = e.entry_id
LEFT JOIN sso_users su ON m.created_by = su.user_id
WHERE 1=1
`;
const params = [];
if (filters.project_id) { sql += ' AND m.project_id = ?'; params.push(filters.project_id); }
sql += ' ORDER BY m.milestone_date';
const [rows] = await db.query(sql, params);
return rows;
},
async createMilestone(data) {
const db = await getDb();
const [result] = await db.query(
`INSERT INTO schedule_milestones
(project_id, entry_id, milestone_name, milestone_date, milestone_type, status, notes, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[data.project_id, data.entry_id || null, data.milestone_name, data.milestone_date,
data.milestone_type || 'deadline', data.status || 'upcoming',
data.notes || null, data.created_by || null]
);
return result.insertId;
},
async updateMilestone(milestoneId, data) {
const db = await getDb();
const fields = [];
const params = [];
const allowed = ['milestone_name', 'milestone_date', 'milestone_type', 'status', 'entry_id', 'notes'];
for (const key of allowed) {
if (data[key] !== undefined) { fields.push(`${key} = ?`); params.push(data[key]); }
}
if (fields.length === 0) return;
fields.push('updated_at = NOW()');
params.push(milestoneId);
await db.query(`UPDATE schedule_milestones SET ${fields.join(', ')} WHERE milestone_id = ?`, params);
},
async deleteMilestone(milestoneId) {
const db = await getDb();
await db.query('DELETE FROM schedule_milestones WHERE milestone_id = ?', [milestoneId]);
},
// === 부적합 연동 (격리 함수) ===
// 향후 System3 API 호출로 전환 시 이 함수만 수정
async getNonconformanceByProject(projectId) {
const db = await getDb();
const [rows] = await db.query(
`SELECT id, category, description, review_status, responsible_department,
expected_completion_date, actual_completion_date, report_date
FROM qc_issues WHERE project_id = ? AND review_status != 'disposed'
ORDER BY report_date DESC`,
[projectId]
);
return rows;
}
};
module.exports = ScheduleModel;

View File

@@ -0,0 +1,24 @@
const express = require('express');
const router = express.Router();
const ctrl = require('../controllers/meetingController');
const { requireMinLevel } = require('../middlewares/auth');
// 회의록
router.get('/', ctrl.getAll);
router.get('/action-items', ctrl.getActionItems);
router.get('/:id', ctrl.getById);
router.post('/', requireMinLevel('support_team'), ctrl.create);
router.put('/:id', requireMinLevel('support_team'), ctrl.update);
router.put('/:id/publish', requireMinLevel('support_team'), ctrl.publish);
router.put('/:id/unpublish', requireMinLevel('admin'), ctrl.unpublish);
router.delete('/:id', requireMinLevel('admin'), ctrl.delete);
// 안건
router.post('/:id/items', requireMinLevel('support_team'), ctrl.addItem);
router.put('/:id/items/:itemId', requireMinLevel('support_team'), ctrl.updateItem);
router.delete('/:id/items/:itemId', requireMinLevel('support_team'), ctrl.deleteItem);
// 조치상태 업데이트
router.put('/items/:itemId/status', requireMinLevel('group_leader'), ctrl.updateItemStatus);
module.exports = router;

View File

@@ -0,0 +1,36 @@
const express = require('express');
const router = express.Router();
const ctrl = require('../controllers/scheduleController');
const { requireMinLevel } = require('../middlewares/auth');
// 공정 단계
router.get('/phases', ctrl.getPhases);
router.post('/phases', requireMinLevel('admin'), ctrl.createPhase);
router.put('/phases/:id', requireMinLevel('admin'), ctrl.updatePhase);
// 작업 템플릿
router.get('/templates', ctrl.getTemplates);
// 공정표 항목
router.get('/entries', ctrl.getEntries);
router.get('/entries/gantt', ctrl.getGanttData);
router.post('/entries', requireMinLevel('support_team'), ctrl.createEntry);
router.post('/entries/batch', requireMinLevel('support_team'), ctrl.createBatchEntries);
router.put('/entries/:id', requireMinLevel('support_team'), ctrl.updateEntry);
router.put('/entries/:id/progress', requireMinLevel('group_leader'), ctrl.updateProgress);
router.delete('/entries/:id', requireMinLevel('admin'), ctrl.deleteEntry);
// 의존관계
router.post('/entries/:id/dependencies', requireMinLevel('support_team'), ctrl.addDependency);
router.delete('/entries/:id/dependencies/:depId', requireMinLevel('support_team'), ctrl.removeDependency);
// 마일스톤
router.get('/milestones', ctrl.getMilestones);
router.post('/milestones', requireMinLevel('support_team'), ctrl.createMilestone);
router.put('/milestones/:id', requireMinLevel('support_team'), ctrl.updateMilestone);
router.delete('/milestones/:id', requireMinLevel('admin'), ctrl.deleteMilestone);
// 부적합 연동
router.get('/nonconformance', ctrl.getNonconformance);
module.exports = router;