feat: tkuser 통합 관리 서비스 + 전체 시스템 SSO 쿠키 인증 통합

- tkuser 서비스 신규 추가 (API + Web)
  - 사용자/권한/프로젝트/부서/작업자/작업장/설비/작업/휴가 통합 관리
  - 작업장 탭: 공장→작업장 드릴다운 네비게이션 + 구역지도 클릭 연동
  - 작업 탭: 공정(work_types)→작업(tasks) 계층 관리
  - 휴가 탭: 유형 관리 + 연차 배정(근로기준법 자동계산)
- 전 시스템 SSO 쿠키 인증으로 통합 (.technicalkorea.net 공유)
- System 2: 작업 이슈 리포트 기능 강화
- System 3: tkuser API 연동, 페이지 권한 체계 적용
- docker-compose에 tkuser-api, tkuser-web 서비스 추가
- ARCHITECTURE.md, DEPLOYMENT.md 문서 작성

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-12 13:45:52 +09:00
parent 6495b8af32
commit 733bb0cb35
96 changed files with 9721 additions and 825 deletions

View File

@@ -0,0 +1,71 @@
/**
* Department Model
*
* departments 테이블 CRUD (MariaDB)
*/
const { getPool } = require('./userModel');
async function getAll() {
const db = getPool();
const [rows] = await db.query(
`SELECT d.*, p.department_name AS parent_name
FROM departments d
LEFT JOIN departments p ON d.parent_id = p.department_id
ORDER BY d.display_order ASC, d.department_id ASC`
);
return rows;
}
async function getById(id) {
const db = getPool();
const [rows] = await db.query(
`SELECT d.*, p.department_name AS parent_name
FROM departments d
LEFT JOIN departments p ON d.parent_id = p.department_id
WHERE d.department_id = ?`,
[id]
);
return rows[0] || null;
}
async function create({ department_name, parent_id, description, display_order }) {
const db = getPool();
const [result] = await db.query(
`INSERT INTO departments (department_name, parent_id, description, display_order)
VALUES (?, ?, ?, ?)`,
[department_name, parent_id || null, description || null, display_order || 0]
);
return getById(result.insertId);
}
async function update(id, data) {
const db = getPool();
const fields = [];
const values = [];
if (data.department_name !== undefined) { fields.push('department_name = ?'); values.push(data.department_name); }
if (data.parent_id !== undefined) { fields.push('parent_id = ?'); values.push(data.parent_id || null); }
if (data.description !== undefined) { fields.push('description = ?'); values.push(data.description || null); }
if (data.is_active !== undefined) { fields.push('is_active = ?'); values.push(data.is_active); }
if (data.display_order !== undefined) { fields.push('display_order = ?'); values.push(data.display_order); }
if (fields.length === 0) return getById(id);
values.push(id);
await db.query(
`UPDATE departments SET ${fields.join(', ')} WHERE department_id = ?`,
values
);
return getById(id);
}
async function deactivate(id) {
const db = getPool();
await db.query(
'UPDATE departments SET is_active = FALSE WHERE department_id = ?',
[id]
);
}
module.exports = { getAll, getById, create, update, deactivate };

View File

@@ -0,0 +1,192 @@
/**
* Equipment Model
*
* equipments + equipment_photos CRUD (MariaDB)
*/
const { getPool } = require('./userModel');
// ==================== 기본 CRUD ====================
async function getAll(filters = {}) {
const db = getPool();
let sql = `SELECT e.*, w.workplace_name, c.category_name
FROM equipments e
LEFT JOIN workplaces w ON e.workplace_id = w.workplace_id
LEFT JOIN workplace_categories c ON w.category_id = c.category_id`;
const conditions = [];
const values = [];
if (filters.workplace_id) { conditions.push('e.workplace_id = ?'); values.push(filters.workplace_id); }
if (filters.equipment_type) { conditions.push('e.equipment_type = ?'); values.push(filters.equipment_type); }
if (filters.status) { conditions.push('e.status = ?'); values.push(filters.status); }
if (filters.search) {
conditions.push('(e.equipment_name LIKE ? OR e.equipment_code LIKE ?)');
const term = `%${filters.search}%`;
values.push(term, term);
}
if (conditions.length) sql += ' WHERE ' + conditions.join(' AND ');
sql += ' ORDER BY e.equipment_code ASC';
const [rows] = await db.query(sql, values);
return rows;
}
async function getById(id) {
const db = getPool();
const [rows] = await db.query(
`SELECT e.*, w.workplace_name, c.category_name
FROM equipments e
LEFT JOIN workplaces w ON e.workplace_id = w.workplace_id
LEFT JOIN workplace_categories c ON w.category_id = c.category_id
WHERE e.equipment_id = ?`,
[id]
);
return rows[0] || null;
}
async function getByWorkplace(workplaceId) {
const db = getPool();
const [rows] = await db.query(
`SELECT e.*, w.workplace_name
FROM equipments e
LEFT JOIN workplaces w ON e.workplace_id = w.workplace_id
WHERE e.workplace_id = ?
ORDER BY e.equipment_code ASC`,
[workplaceId]
);
return rows;
}
async function create(data) {
const db = getPool();
const [result] = await db.query(
`INSERT INTO equipments (equipment_code, equipment_name, equipment_type, model_name, manufacturer, supplier, purchase_price, installation_date, serial_number, specifications, status, notes, workplace_id, map_x_percent, map_y_percent, map_width_percent, map_height_percent)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
data.equipment_code, data.equipment_name,
data.equipment_type || null, data.model_name || null,
data.manufacturer || null, data.supplier || null,
data.purchase_price || null, data.installation_date || null,
data.serial_number || null, data.specifications || null,
data.status || 'active', data.notes || null,
data.workplace_id || null,
data.map_x_percent || null, data.map_y_percent || null,
data.map_width_percent || null, data.map_height_percent || null
]
);
return getById(result.insertId);
}
async function update(id, data) {
const db = getPool();
const fields = [];
const values = [];
if (data.equipment_code !== undefined) { fields.push('equipment_code = ?'); values.push(data.equipment_code); }
if (data.equipment_name !== undefined) { fields.push('equipment_name = ?'); values.push(data.equipment_name); }
if (data.equipment_type !== undefined) { fields.push('equipment_type = ?'); values.push(data.equipment_type || null); }
if (data.model_name !== undefined) { fields.push('model_name = ?'); values.push(data.model_name || null); }
if (data.manufacturer !== undefined) { fields.push('manufacturer = ?'); values.push(data.manufacturer || null); }
if (data.supplier !== undefined) { fields.push('supplier = ?'); values.push(data.supplier || null); }
if (data.purchase_price !== undefined) { fields.push('purchase_price = ?'); values.push(data.purchase_price || null); }
if (data.installation_date !== undefined) { fields.push('installation_date = ?'); values.push(data.installation_date || null); }
if (data.serial_number !== undefined) { fields.push('serial_number = ?'); values.push(data.serial_number || null); }
if (data.specifications !== undefined) { fields.push('specifications = ?'); values.push(data.specifications || null); }
if (data.status !== undefined) { fields.push('status = ?'); values.push(data.status); }
if (data.notes !== undefined) { fields.push('notes = ?'); values.push(data.notes || null); }
if (data.workplace_id !== undefined) { fields.push('workplace_id = ?'); values.push(data.workplace_id || null); }
if (data.map_x_percent !== undefined) { fields.push('map_x_percent = ?'); values.push(data.map_x_percent); }
if (data.map_y_percent !== undefined) { fields.push('map_y_percent = ?'); values.push(data.map_y_percent); }
if (data.map_width_percent !== undefined) { fields.push('map_width_percent = ?'); values.push(data.map_width_percent); }
if (data.map_height_percent !== undefined) { fields.push('map_height_percent = ?'); values.push(data.map_height_percent); }
if (fields.length === 0) return getById(id);
values.push(id);
await db.query(`UPDATE equipments SET ${fields.join(', ')} WHERE equipment_id = ?`, values);
return getById(id);
}
async function remove(id) {
const db = getPool();
await db.query('DELETE FROM equipments WHERE equipment_id = ?', [id]);
}
async function getEquipmentTypes() {
const db = getPool();
const [rows] = await db.query(
'SELECT DISTINCT equipment_type FROM equipments WHERE equipment_type IS NOT NULL AND equipment_type != "" ORDER BY equipment_type ASC'
);
return rows.map(r => r.equipment_type);
}
async function getNextCode(prefix = 'TKP') {
const db = getPool();
const [rows] = await db.query(
'SELECT equipment_code FROM equipments WHERE equipment_code LIKE ? ORDER BY equipment_code DESC LIMIT 1',
[`${prefix}-%`]
);
if (!rows.length) return `${prefix}-001`;
const lastNum = parseInt(rows[0].equipment_code.replace(`${prefix}-`, ''), 10) || 0;
return `${prefix}-${String(lastNum + 1).padStart(3, '0')}`;
}
async function checkDuplicateCode(code, excludeId) {
const db = getPool();
let sql = 'SELECT equipment_id FROM equipments WHERE equipment_code = ?';
const values = [code];
if (excludeId) { sql += ' AND equipment_id != ?'; values.push(excludeId); }
const [rows] = await db.query(sql, values);
return rows.length > 0;
}
// ==================== 지도 위치 ====================
async function updateMapPosition(id, positionData) {
const db = getPool();
const fields = ['map_x_percent = ?', 'map_y_percent = ?', 'map_width_percent = ?', 'map_height_percent = ?'];
const values = [positionData.map_x_percent, positionData.map_y_percent, positionData.map_width_percent, positionData.map_height_percent];
if (positionData.workplace_id !== undefined) {
fields.push('workplace_id = ?');
values.push(positionData.workplace_id);
}
values.push(id);
await db.query(`UPDATE equipments SET ${fields.join(', ')} WHERE equipment_id = ?`, values);
return getById(id);
}
// ==================== 사진 ====================
async function addPhoto(equipmentId, photoData) {
const db = getPool();
const [result] = await db.query(
`INSERT INTO equipment_photos (equipment_id, photo_path, description, display_order, uploaded_by) VALUES (?, ?, ?, ?, ?)`,
[equipmentId, photoData.photo_path, photoData.description || null, photoData.display_order || 0, photoData.uploaded_by || null]
);
return { photo_id: result.insertId, equipment_id: equipmentId, ...photoData };
}
async function getPhotos(equipmentId) {
const db = getPool();
const [rows] = await db.query(
'SELECT * FROM equipment_photos WHERE equipment_id = ? ORDER BY display_order ASC, created_at ASC',
[equipmentId]
);
return rows;
}
async function deletePhoto(photoId) {
const db = getPool();
const [photo] = await db.query('SELECT photo_path FROM equipment_photos WHERE photo_id = ?', [photoId]);
await db.query('DELETE FROM equipment_photos WHERE photo_id = ?', [photoId]);
return { photo_id: photoId, photo_path: photo[0]?.photo_path };
}
module.exports = {
getAll, getById, getByWorkplace, create, update, remove,
getEquipmentTypes, getNextCode, checkDuplicateCode,
updateMapPosition,
addPhoto, getPhotos, deletePhoto
};

View File

@@ -0,0 +1,150 @@
/**
* Permission Model
*
* MariaDB user_page_permissions 테이블 CRUD
*/
const { getPool } = require('./userModel');
// 기본 페이지 목록 (시스템별 구분)
const DEFAULT_PAGES = {
// ===== System 1 - 공장관리 =====
// 작업 관리
's1.dashboard': { title: '대시보드', system: 'system1', group: '작업 관리', default_access: true },
's1.work.tbm': { title: 'TBM 관리', system: 'system1', group: '작업 관리', default_access: true },
's1.work.report_create': { title: '작업보고서 작성', system: 'system1', group: '작업 관리', default_access: true },
's1.work.analysis': { title: '작업 분석', system: 'system1', group: '작업 관리', default_access: false },
's1.work.nonconformity': { title: '부적합 현황', system: 'system1', group: '작업 관리', default_access: true },
// 공장 관리
's1.factory.repair_management':{ title: '시설설비 관리', system: 'system1', group: '공장 관리', default_access: false },
's1.inspection.daily_patrol': { title: '일일순회점검', system: 'system1', group: '공장 관리', default_access: false },
's1.inspection.checkin': { title: '출근 체크', system: 'system1', group: '공장 관리', default_access: true },
's1.inspection.work_status': { title: '근무 현황', system: 'system1', group: '공장 관리', default_access: false },
// 안전 관리
's1.safety.visit_request': { title: '출입 신청', system: 'system1', group: '안전 관리', default_access: true },
's1.safety.management': { title: '안전 관리', system: 'system1', group: '안전 관리', default_access: false },
's1.safety.checklist_manage': { title: '체크리스트 관리', system: 'system1', group: '안전 관리', default_access: false },
// 근태 관리
's1.attendance.my_vacation_info': { title: '내 연차 정보', system: 'system1', group: '근태 관리', default_access: true },
's1.attendance.monthly': { title: '월간 근태', system: 'system1', group: '근태 관리', default_access: true },
's1.attendance.vacation_request': { title: '휴가 신청', system: 'system1', group: '근태 관리', default_access: true },
's1.attendance.vacation_management': { title: '휴가 관리', system: 'system1', group: '근태 관리', default_access: false },
's1.attendance.vacation_allocation': { title: '휴가 발생 입력', system: 'system1', group: '근태 관리', default_access: false },
's1.attendance.annual_overview': { title: '연간 휴가 현황', system: 'system1', group: '근태 관리', default_access: false },
// 시스템 관리
's1.admin.workers': { title: '작업자 관리', system: 'system1', group: '시스템 관리', default_access: false },
's1.admin.projects': { title: '프로젝트 관리', system: 'system1', group: '시스템 관리', default_access: false },
's1.admin.tasks': { title: '작업 관리', system: 'system1', group: '시스템 관리', default_access: false },
's1.admin.workplaces': { title: '작업장 관리', system: 'system1', group: '시스템 관리', default_access: false },
's1.admin.equipments': { title: '설비 관리', system: 'system1', group: '시스템 관리', default_access: false },
's1.admin.issue_categories': { title: '신고 카테고리 관리', system: 'system1', group: '시스템 관리', default_access: false },
's1.admin.attendance_report': { title: '출퇴근-보고서 대조', system: 'system1', group: '시스템 관리', default_access: false },
// ===== System 3 - 부적합관리 =====
// 메인
'issues_dashboard': { title: '현황판', system: 'system3', group: '메인', default_access: true },
'issues_inbox': { title: '수신함', system: 'system3', group: '메인', default_access: true },
'issues_management': { title: '관리함', system: 'system3', group: '메인', default_access: false },
'issues_archive': { title: '폐기함', system: 'system3', group: '메인', default_access: false },
// 업무
'daily_work': { title: '일일 공수', system: 'system3', group: '업무', default_access: false },
'projects_manage': { title: '프로젝트 관리', system: 'system3', group: '업무', default_access: false },
// 보고서
'reports': { title: '보고서', system: 'system3', group: '보고서', default_access: false },
'reports_daily': { title: '일일보고서', system: 'system3', group: '보고서', default_access: false },
'reports_weekly': { title: '주간보고서', system: 'system3', group: '보고서', default_access: false },
'reports_monthly': { title: '월간보고서', system: 'system3', group: '보고서', default_access: false }
};
/**
* 사용자의 페이지 권한 목록 조회
*/
async function getUserPermissions(userId) {
const db = getPool();
const [rows] = await db.query(
'SELECT * FROM user_page_permissions WHERE user_id = ? ORDER BY page_name',
[userId]
);
return rows;
}
/**
* 단건 권한 부여/업데이트 (UPSERT)
*/
async function grantPermission({ user_id, page_name, can_access, granted_by_id, notes }) {
const db = getPool();
const [result] = await db.query(
`INSERT INTO user_page_permissions (user_id, page_name, can_access, granted_by_id, notes)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE can_access = VALUES(can_access), granted_by_id = VALUES(granted_by_id), notes = VALUES(notes), granted_at = CURRENT_TIMESTAMP`,
[user_id, page_name, can_access, granted_by_id, notes || null]
);
return { id: result.insertId, user_id, page_name, can_access };
}
/**
* 일괄 권한 부여
*/
async function bulkGrant({ user_id, permissions, granted_by_id }) {
const db = getPool();
let count = 0;
for (const perm of permissions) {
if (!DEFAULT_PAGES[perm.page_name]) continue;
await db.query(
`INSERT INTO user_page_permissions (user_id, page_name, can_access, granted_by_id)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE can_access = VALUES(can_access), granted_by_id = VALUES(granted_by_id), granted_at = CURRENT_TIMESTAMP`,
[user_id, perm.page_name, perm.can_access, granted_by_id]
);
count++;
}
return { updated_count: count };
}
/**
* 접근 권한 확인
*/
async function checkAccess(userId, pageName) {
const db = getPool();
const [rows] = await db.query(
'SELECT can_access FROM user_page_permissions WHERE user_id = ? AND page_name = ?',
[userId, pageName]
);
if (rows.length > 0) {
return { can_access: rows[0].can_access, reason: 'explicit_permission' };
}
// 기본 권한
const pageConfig = DEFAULT_PAGES[pageName];
if (!pageConfig) {
return { can_access: false, reason: 'invalid_page' };
}
return { can_access: pageConfig.default_access, reason: 'default_permission' };
}
/**
* 권한 삭제 (기본값으로 되돌림)
*/
async function deletePermission(permissionId) {
const db = getPool();
const [rows] = await db.query(
'SELECT * FROM user_page_permissions WHERE id = ?',
[permissionId]
);
if (rows.length === 0) return null;
await db.query('DELETE FROM user_page_permissions WHERE id = ?', [permissionId]);
return rows[0];
}
module.exports = {
DEFAULT_PAGES,
getUserPermissions,
grantPermission,
bulkGrant,
checkAccess,
deletePermission
};

View File

@@ -0,0 +1,79 @@
/**
* Project Model
*
* projects 테이블 CRUD (MariaDB)
* System 1과 같은 DB를 공유
*/
const { getPool } = require('./userModel');
async function getAll() {
const db = getPool();
const [rows] = await db.query(
'SELECT * FROM projects ORDER BY project_id DESC'
);
return rows;
}
async function getActive() {
const db = getPool();
const [rows] = await db.query(
'SELECT * FROM projects WHERE is_active = TRUE ORDER BY project_name ASC'
);
return rows;
}
async function getById(id) {
const db = getPool();
const [rows] = await db.query(
'SELECT * FROM projects WHERE project_id = ?',
[id]
);
return rows[0] || null;
}
async function create({ job_no, project_name, contract_date, due_date, delivery_method, site, pm }) {
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]
);
return getById(result.insertId);
}
async function update(id, data) {
const db = getPool();
const fields = [];
const values = [];
if (data.job_no !== undefined) { fields.push('job_no = ?'); values.push(data.job_no); }
if (data.project_name !== undefined) { fields.push('project_name = ?'); values.push(data.project_name); }
if (data.contract_date !== undefined) { fields.push('contract_date = ?'); values.push(data.contract_date || null); }
if (data.due_date !== undefined) { fields.push('due_date = ?'); values.push(data.due_date || null); }
if (data.delivery_method !== undefined) { fields.push('delivery_method = ?'); values.push(data.delivery_method); }
if (data.site !== undefined) { fields.push('site = ?'); values.push(data.site); }
if (data.pm !== undefined) { fields.push('pm = ?'); values.push(data.pm); }
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 (fields.length === 0) return getById(id);
values.push(id);
await db.query(
`UPDATE projects SET ${fields.join(', ')} WHERE project_id = ?`,
values
);
return getById(id);
}
async function deactivate(id) {
const db = getPool();
await db.query(
'UPDATE projects SET is_active = FALSE, project_status = ? WHERE project_id = ?',
['completed', id]
);
}
module.exports = { getAll, getActive, getById, create, update, deactivate };

View File

@@ -0,0 +1,127 @@
/**
* Task Model
*
* work_types + tasks 테이블 CRUD (MariaDB)
* System 1과 같은 DB를 공유
*/
const { getPool } = require('./userModel');
/* ===== Work Types (공정) ===== */
async function getWorkTypes() {
const db = getPool();
const [rows] = await db.query(
'SELECT * FROM work_types ORDER BY category ASC, name ASC'
);
return rows;
}
async function getWorkTypeById(id) {
const db = getPool();
const [rows] = await db.query('SELECT * FROM work_types WHERE id = ?', [id]);
return rows[0] || null;
}
async function createWorkType({ name, category, description }) {
const db = getPool();
const [result] = await db.query(
'INSERT INTO work_types (name, category, description) VALUES (?, ?, ?)',
[name, category || null, description || null]
);
return getWorkTypeById(result.insertId);
}
async function updateWorkType(id, data) {
const db = getPool();
const fields = [];
const values = [];
if (data.name !== undefined) { fields.push('name = ?'); values.push(data.name); }
if (data.category !== undefined) { fields.push('category = ?'); values.push(data.category || null); }
if (data.description !== undefined) { fields.push('description = ?'); values.push(data.description || null); }
if (fields.length === 0) return getWorkTypeById(id);
values.push(id);
await db.query(`UPDATE work_types SET ${fields.join(', ')} WHERE id = ?`, values);
return getWorkTypeById(id);
}
async function deleteWorkType(id) {
const db = getPool();
// 연결된 tasks의 work_type_id를 NULL로 설정 (FK cascade가 처리하지만 명시적으로)
await db.query('UPDATE tasks SET work_type_id = NULL WHERE work_type_id = ?', [id]);
await db.query('DELETE FROM work_types WHERE id = ?', [id]);
}
/* ===== Tasks (작업) ===== */
async function getTasks(workTypeId) {
const db = getPool();
let sql = `SELECT t.*, wt.name AS work_type_name, wt.category AS work_type_category
FROM tasks t
LEFT JOIN work_types wt ON t.work_type_id = wt.id`;
const params = [];
if (workTypeId) {
sql += ' WHERE t.work_type_id = ?';
params.push(workTypeId);
}
sql += ' ORDER BY wt.category ASC, t.task_id DESC';
const [rows] = await db.query(sql, params);
return rows;
}
async function getActiveTasks() {
const db = getPool();
const [rows] = await db.query(
`SELECT t.*, wt.name AS work_type_name, wt.category AS work_type_category
FROM tasks t
LEFT JOIN work_types wt ON t.work_type_id = wt.id
WHERE t.is_active = TRUE
ORDER BY wt.category ASC, t.task_name ASC`
);
return rows;
}
async function getTaskById(id) {
const db = getPool();
const [rows] = await db.query(
`SELECT t.*, wt.name AS work_type_name, wt.category AS work_type_category
FROM tasks t
LEFT JOIN work_types wt ON t.work_type_id = wt.id
WHERE t.task_id = ?`,
[id]
);
return rows[0] || null;
}
async function createTask({ work_type_id, task_name, description, is_active }) {
const db = getPool();
const [result] = await db.query(
'INSERT INTO tasks (work_type_id, task_name, description, is_active) VALUES (?, ?, ?, ?)',
[work_type_id || null, task_name, description || null, is_active !== undefined ? is_active : true]
);
return getTaskById(result.insertId);
}
async function updateTask(id, data) {
const db = getPool();
const fields = [];
const values = [];
if (data.work_type_id !== undefined) { fields.push('work_type_id = ?'); values.push(data.work_type_id || null); }
if (data.task_name !== undefined) { fields.push('task_name = ?'); values.push(data.task_name); }
if (data.description !== undefined) { fields.push('description = ?'); values.push(data.description || null); }
if (data.is_active !== undefined) { fields.push('is_active = ?'); values.push(data.is_active); }
if (fields.length === 0) return getTaskById(id);
values.push(id);
await db.query(`UPDATE tasks SET ${fields.join(', ')} WHERE task_id = ?`, values);
return getTaskById(id);
}
async function deleteTask(id) {
const db = getPool();
await db.query('DELETE FROM tasks WHERE task_id = ?', [id]);
}
module.exports = {
getWorkTypes, getWorkTypeById, createWorkType, updateWorkType, deleteWorkType,
getTasks, getActiveTasks, getTaskById, createTask, updateTask, deleteTask
};

View File

@@ -0,0 +1,158 @@
/**
* User Model
*
* sso_users 테이블 CRUD 및 비밀번호 관리
* sso-auth-service/models/userModel.js 기반
*/
const mysql = require('mysql2/promise');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
let pool;
function getPool() {
if (!pool) {
pool = mysql.createPool({
host: process.env.DB_HOST || 'mariadb',
port: parseInt(process.env.DB_PORT) || 3306,
user: process.env.DB_USER || 'hyungi_user',
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME || 'hyungi',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
});
}
return pool;
}
/**
* pbkdf2_sha256 해시 검증 (passlib 호환)
*/
function verifyPbkdf2(password, storedHash) {
try {
const parts = storedHash.split('$');
if (parts.length < 5) return false;
const rounds = parseInt(parts[2]);
const salt = parts[3].replace(/\./g, '+');
const hash = parts[4].replace(/\./g, '+');
const padded = (s) => s + '='.repeat((4 - s.length % 4) % 4);
const saltBuffer = Buffer.from(padded(salt), 'base64');
const expectedHash = Buffer.from(padded(hash), 'base64');
const derivedKey = crypto.pbkdf2Sync(password, saltBuffer, rounds, expectedHash.length, 'sha256');
return crypto.timingSafeEqual(derivedKey, expectedHash);
} catch (err) {
console.error('pbkdf2 verify error:', err.message);
return false;
}
}
/**
* 비밀번호 검증 (bcrypt 또는 pbkdf2_sha256 자동 감지)
*/
async function verifyPassword(password, storedHash) {
if (!password || !storedHash) return false;
if (storedHash.startsWith('$pbkdf2-sha256$')) {
return verifyPbkdf2(password, storedHash);
}
if (storedHash.startsWith('$2b$') || storedHash.startsWith('$2a$')) {
return bcrypt.compare(password, storedHash);
}
return false;
}
/**
* bcrypt로 비밀번호 해시 생성
*/
async function hashPassword(password) {
return bcrypt.hash(password, 10);
}
async function findByUsername(username) {
const db = getPool();
const [rows] = await db.query(
'SELECT * FROM sso_users WHERE username = ? AND is_active = TRUE',
[username]
);
return rows[0] || null;
}
async function findById(userId) {
const db = getPool();
const [rows] = await db.query(
'SELECT * FROM sso_users WHERE user_id = ?',
[userId]
);
return rows[0] || null;
}
async function findAll() {
const db = getPool();
const [rows] = await db.query(
'SELECT user_id, username, name, department, role, system1_access, system2_access, system3_access, is_active, last_login, created_at FROM sso_users ORDER BY user_id'
);
return rows;
}
async function create({ username, password, name, department, role }) {
const db = getPool();
const password_hash = await hashPassword(password);
const [result] = await db.query(
`INSERT INTO sso_users (username, password_hash, name, department, role)
VALUES (?, ?, ?, ?, ?)`,
[username, password_hash, name || null, department || null, role || 'user']
);
return findById(result.insertId);
}
async function update(userId, data) {
const db = getPool();
const fields = [];
const values = [];
if (data.name !== undefined) { fields.push('name = ?'); values.push(data.name); }
if (data.department !== undefined) { fields.push('department = ?'); values.push(data.department); }
if (data.role !== undefined) { fields.push('role = ?'); values.push(data.role); }
if (data.system1_access !== undefined) { fields.push('system1_access = ?'); values.push(data.system1_access); }
if (data.system2_access !== undefined) { fields.push('system2_access = ?'); values.push(data.system2_access); }
if (data.system3_access !== undefined) { fields.push('system3_access = ?'); values.push(data.system3_access); }
if (data.is_active !== undefined) { fields.push('is_active = ?'); values.push(data.is_active); }
if (data.password) {
fields.push('password_hash = ?');
values.push(await hashPassword(data.password));
}
if (fields.length === 0) return findById(userId);
values.push(userId);
await db.query(
`UPDATE sso_users SET ${fields.join(', ')} WHERE user_id = ?`,
values
);
return findById(userId);
}
async function deleteUser(userId) {
const db = getPool();
await db.query('UPDATE sso_users SET is_active = FALSE WHERE user_id = ?', [userId]);
}
module.exports = {
verifyPassword,
hashPassword,
findByUsername,
findById,
findAll,
create,
update,
deleteUser,
getPool
};

View File

@@ -0,0 +1,219 @@
/**
* Vacation Model
*
* vacation_types + vacation_balance_details CRUD (MariaDB)
* System 1과 같은 DB를 공유
*/
const { getPool } = require('./userModel');
/* ===== Vacation Types (휴가 유형) ===== */
async function getVacationTypes() {
const db = getPool();
const [rows] = await db.query(
'SELECT * FROM vacation_types WHERE is_active = TRUE ORDER BY priority ASC, id ASC'
);
return rows;
}
async function getAllVacationTypes() {
const db = getPool();
const [rows] = await db.query(
'SELECT * FROM vacation_types ORDER BY priority ASC, id ASC'
);
return rows;
}
async function getVacationTypeById(id) {
const db = getPool();
const [rows] = await db.query('SELECT * FROM vacation_types WHERE id = ?', [id]);
return rows[0] || null;
}
async function createVacationType({ type_code, type_name, deduct_days, is_special, priority, description }) {
const db = getPool();
const [result] = await db.query(
`INSERT INTO vacation_types (type_code, type_name, deduct_days, is_special, priority, description, is_system)
VALUES (?, ?, ?, ?, ?, ?, FALSE)`,
[type_code, type_name, deduct_days ?? 1.0, is_special ? 1 : 0, priority ?? 99, description || null]
);
return getVacationTypeById(result.insertId);
}
async function updateVacationType(id, data) {
const db = getPool();
const fields = [];
const values = [];
if (data.type_code !== undefined) { fields.push('type_code = ?'); values.push(data.type_code); }
if (data.type_name !== undefined) { fields.push('type_name = ?'); values.push(data.type_name); }
if (data.deduct_days !== undefined) { fields.push('deduct_days = ?'); values.push(data.deduct_days); }
if (data.is_special !== undefined) { fields.push('is_special = ?'); values.push(data.is_special ? 1 : 0); }
if (data.priority !== undefined) { fields.push('priority = ?'); values.push(data.priority); }
if (data.description !== undefined) { fields.push('description = ?'); values.push(data.description || null); }
if (data.is_active !== undefined) { fields.push('is_active = ?'); values.push(data.is_active ? 1 : 0); }
if (fields.length === 0) return getVacationTypeById(id);
values.push(id);
await db.query(`UPDATE vacation_types SET ${fields.join(', ')} WHERE id = ?`, values);
return getVacationTypeById(id);
}
async function deleteVacationType(id) {
const db = getPool();
// 시스템 유형은 비활성화만
const type = await getVacationTypeById(id);
if (type && type.is_system) {
await db.query('UPDATE vacation_types SET is_active = FALSE WHERE id = ?', [id]);
} else {
await db.query('UPDATE vacation_types SET is_active = FALSE WHERE id = ?', [id]);
}
}
async function updatePriorities(items) {
const db = getPool();
for (const { id, priority } of items) {
await db.query('UPDATE vacation_types SET priority = ? WHERE id = ?', [priority, id]);
}
}
/* ===== Vacation Balances (연차 배정) ===== */
async function getBalancesByYear(year) {
const db = getPool();
const [rows] = await db.query(
`SELECT vbd.*, vt.type_code, vt.type_name, vt.deduct_days, vt.priority,
w.worker_name, w.hire_date
FROM vacation_balance_details vbd
JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
JOIN workers w ON vbd.worker_id = w.worker_id
WHERE vbd.year = ?
ORDER BY w.worker_name ASC, vt.priority ASC`,
[year]
);
return rows;
}
async function getBalancesByWorkerYear(workerId, year) {
const db = getPool();
const [rows] = await db.query(
`SELECT vbd.*, vt.type_code, vt.type_name, vt.deduct_days, vt.priority
FROM vacation_balance_details vbd
JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
WHERE vbd.worker_id = ? AND vbd.year = ?
ORDER BY vt.priority ASC`,
[workerId, year]
);
return rows;
}
async function getBalanceById(id) {
const db = getPool();
const [rows] = await db.query(
`SELECT vbd.*, vt.type_code, vt.type_name
FROM vacation_balance_details vbd
JOIN vacation_types vt ON vbd.vacation_type_id = vt.id
WHERE vbd.id = ?`,
[id]
);
return rows[0] || null;
}
async function createBalance({ worker_id, vacation_type_id, year, total_days, used_days, notes, created_by }) {
const db = getPool();
const [result] = await db.query(
`INSERT INTO vacation_balance_details (worker_id, vacation_type_id, year, total_days, used_days, notes, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE total_days = VALUES(total_days), used_days = VALUES(used_days), notes = VALUES(notes)`,
[worker_id, vacation_type_id, year, total_days ?? 0, used_days ?? 0, notes || null, created_by]
);
return result.insertId ? getBalanceById(result.insertId) : null;
}
async function updateBalance(id, data) {
const db = getPool();
const fields = [];
const values = [];
if (data.total_days !== undefined) { fields.push('total_days = ?'); values.push(data.total_days); }
if (data.used_days !== undefined) { fields.push('used_days = ?'); values.push(data.used_days); }
if (data.notes !== undefined) { fields.push('notes = ?'); values.push(data.notes || null); }
if (fields.length === 0) return getBalanceById(id);
values.push(id);
await db.query(`UPDATE vacation_balance_details SET ${fields.join(', ')} WHERE id = ?`, values);
return getBalanceById(id);
}
async function deleteBalance(id) {
const db = getPool();
await db.query('DELETE FROM vacation_balance_details WHERE id = ?', [id]);
}
async function bulkUpsertBalances(balances) {
const db = getPool();
let count = 0;
for (const b of balances) {
await db.query(
`INSERT INTO vacation_balance_details (worker_id, vacation_type_id, year, total_days, used_days, notes, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE total_days = VALUES(total_days), notes = VALUES(notes)`,
[b.worker_id, b.vacation_type_id, b.year, b.total_days ?? 0, b.used_days ?? 0, b.notes || null, b.created_by]
);
count++;
}
return count;
}
/* ===== 연차 자동 계산 (근로기준법) ===== */
function calculateAnnualDays(hireDate, targetYear) {
if (!hireDate) return 0;
const hire = new Date(hireDate);
const yearStart = new Date(targetYear, 0, 1);
const monthsDiff = (yearStart.getFullYear() - hire.getFullYear()) * 12 + (yearStart.getMonth() - hire.getMonth());
if (monthsDiff < 0) return 0;
if (monthsDiff < 12) {
// 1년 미만: 근무 개월 수
return Math.max(0, Math.floor(monthsDiff));
}
// 1년 이상: 15일 + 2년마다 1일 추가 (최대 25일)
const yearsWorked = Math.floor(monthsDiff / 12);
const additional = Math.floor((yearsWorked - 1) / 2);
return Math.min(15 + additional, 25);
}
async function autoCalculateForAllWorkers(year, createdBy) {
const db = getPool();
const [workers] = await db.query(
'SELECT worker_id, worker_name, hire_date FROM workers WHERE status != ? ORDER BY worker_name',
['inactive']
);
// 연차 유형 (ANNUAL_FULL) 찾기
const [types] = await db.query(
"SELECT id FROM vacation_types WHERE type_code = 'ANNUAL_FULL' AND is_active = TRUE LIMIT 1"
);
if (!types.length) return { count: 0, results: [] };
const annualTypeId = types[0].id;
const results = [];
for (const w of workers) {
const days = calculateAnnualDays(w.hire_date, year);
if (days > 0) {
await db.query(
`INSERT INTO vacation_balance_details (worker_id, vacation_type_id, year, total_days, used_days, notes, created_by)
VALUES (?, ?, ?, ?, 0, ?, ?)
ON DUPLICATE KEY UPDATE total_days = VALUES(total_days), notes = VALUES(notes)`,
[w.worker_id, annualTypeId, year, days, `자동계산 (입사: ${w.hire_date ? w.hire_date.toISOString().substring(0,10) : ''})`, createdBy]
);
results.push({ worker_id: w.worker_id, worker_name: w.worker_name, days, hire_date: w.hire_date });
}
}
return { count: results.length, results };
}
module.exports = {
getVacationTypes, getAllVacationTypes, getVacationTypeById,
createVacationType, updateVacationType, deleteVacationType, updatePriorities,
getBalancesByYear, getBalancesByWorkerYear, getBalanceById,
createBalance, updateBalance, deleteBalance, bulkUpsertBalances,
calculateAnnualDays, autoCalculateForAllWorkers
};

View File

@@ -0,0 +1,74 @@
/**
* Worker Model
*
* workers 테이블 CRUD (MariaDB)
*/
const { getPool } = require('./userModel');
async function getAll() {
const db = getPool();
const [rows] = await db.query(
`SELECT w.*, d.department_name
FROM workers w
LEFT JOIN departments d ON w.department_id = d.department_id
ORDER BY w.worker_id DESC`
);
return rows;
}
async function getById(id) {
const db = getPool();
const [rows] = await db.query(
`SELECT w.*, d.department_name
FROM workers w
LEFT JOIN departments d ON w.department_id = d.department_id
WHERE w.worker_id = ?`,
[id]
);
return rows[0] || null;
}
async function create({ worker_name, job_type, department_id, phone_number, hire_date, notes }) {
const db = getPool();
const [result] = await db.query(
`INSERT INTO workers (worker_name, job_type, department_id, phone_number, hire_date, notes)
VALUES (?, ?, ?, ?, ?, ?)`,
[worker_name, job_type || null, department_id || null, phone_number || null, hire_date || null, notes || null]
);
return getById(result.insertId);
}
async function update(id, data) {
const db = getPool();
const fields = [];
const values = [];
if (data.worker_name !== undefined) { fields.push('worker_name = ?'); values.push(data.worker_name); }
if (data.job_type !== undefined) { fields.push('job_type = ?'); values.push(data.job_type); }
if (data.status !== undefined) { fields.push('status = ?'); values.push(data.status); }
if (data.department_id !== undefined) { fields.push('department_id = ?'); values.push(data.department_id || null); }
if (data.employment_status !== undefined) { fields.push('employment_status = ?'); values.push(data.employment_status); }
if (data.phone_number !== undefined) { fields.push('phone_number = ?'); values.push(data.phone_number || null); }
if (data.hire_date !== undefined) { fields.push('hire_date = ?'); values.push(data.hire_date || null); }
if (data.notes !== undefined) { fields.push('notes = ?'); values.push(data.notes || null); }
if (fields.length === 0) return getById(id);
values.push(id);
await db.query(
`UPDATE workers SET ${fields.join(', ')} WHERE worker_id = ?`,
values
);
return getById(id);
}
async function deactivate(id) {
const db = getPool();
await db.query(
'UPDATE workers SET status = ? WHERE worker_id = ?',
['inactive', id]
);
}
module.exports = { getAll, getById, create, update, deactivate };

View File

@@ -0,0 +1,158 @@
/**
* Workplace Model
*
* workplaces + workplace_categories 테이블 CRUD (MariaDB)
*/
const { getPool } = require('./userModel');
async function getAll() {
const db = getPool();
const [rows] = await db.query(
`SELECT w.*, c.category_name
FROM workplaces w
LEFT JOIN workplace_categories c ON w.category_id = c.category_id
ORDER BY w.display_priority ASC, w.workplace_id DESC`
);
return rows;
}
async function getById(id) {
const db = getPool();
const [rows] = await db.query(
`SELECT w.*, c.category_name
FROM workplaces w
LEFT JOIN workplace_categories c ON w.category_id = c.category_id
WHERE w.workplace_id = ?`,
[id]
);
return rows[0] || null;
}
async function getCategories() {
const db = getPool();
const [rows] = await db.query(
'SELECT * FROM workplace_categories WHERE is_active = TRUE ORDER BY display_order ASC'
);
return rows;
}
async function create({ workplace_name, category_id, workplace_purpose, description, display_priority }) {
const db = getPool();
const [result] = await db.query(
`INSERT INTO workplaces (workplace_name, category_id, workplace_purpose, description, display_priority)
VALUES (?, ?, ?, ?, ?)`,
[workplace_name, category_id || null, workplace_purpose || null, description || null, display_priority || 0]
);
return getById(result.insertId);
}
async function update(id, data) {
const db = getPool();
const fields = [];
const values = [];
if (data.workplace_name !== undefined) { fields.push('workplace_name = ?'); values.push(data.workplace_name); }
if (data.category_id !== undefined) { fields.push('category_id = ?'); values.push(data.category_id || null); }
if (data.workplace_purpose !== undefined) { fields.push('workplace_purpose = ?'); values.push(data.workplace_purpose); }
if (data.description !== undefined) { fields.push('description = ?'); values.push(data.description || null); }
if (data.is_active !== undefined) { fields.push('is_active = ?'); values.push(data.is_active); }
if (data.display_priority !== undefined) { fields.push('display_priority = ?'); values.push(data.display_priority); }
if (fields.length === 0) return getById(id);
values.push(id);
await db.query(
`UPDATE workplaces SET ${fields.join(', ')} WHERE workplace_id = ?`,
values
);
return getById(id);
}
async function deactivate(id) {
const db = getPool();
await db.query(
'UPDATE workplaces SET is_active = FALSE WHERE workplace_id = ?',
[id]
);
}
// ==================== 구역지도 ====================
async function updateCategoryLayoutImage(id, imagePath) {
const db = getPool();
await db.query(
'UPDATE workplace_categories SET layout_image = ? WHERE category_id = ?',
[imagePath, id]
);
const [rows] = await db.query('SELECT * FROM workplace_categories WHERE category_id = ?', [id]);
return rows[0] || null;
}
async function createMapRegion({ workplace_id, category_id, x_start, y_start, x_end, y_end, shape, polygon_points }) {
const db = getPool();
const [result] = await db.query(
`INSERT INTO workplace_map_regions (workplace_id, category_id, x_start, y_start, x_end, y_end, shape, polygon_points)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[workplace_id, category_id, x_start, y_start, x_end, y_end, shape || 'rect', polygon_points || null]
);
const [rows] = await db.query('SELECT * FROM workplace_map_regions WHERE region_id = ?', [result.insertId]);
return rows[0];
}
async function getMapRegionsByCategory(categoryId) {
const db = getPool();
const [rows] = await db.query(
`SELECT r.*, w.workplace_name
FROM workplace_map_regions r
LEFT JOIN workplaces w ON r.workplace_id = w.workplace_id
WHERE r.category_id = ?
ORDER BY r.region_id ASC`,
[categoryId]
);
return rows;
}
async function updateMapRegion(regionId, { x_start, y_start, x_end, y_end, workplace_id, shape, polygon_points }) {
const db = getPool();
const fields = [];
const values = [];
if (x_start !== undefined) { fields.push('x_start = ?'); values.push(x_start); }
if (y_start !== undefined) { fields.push('y_start = ?'); values.push(y_start); }
if (x_end !== undefined) { fields.push('x_end = ?'); values.push(x_end); }
if (y_end !== undefined) { fields.push('y_end = ?'); values.push(y_end); }
if (workplace_id !== undefined) { fields.push('workplace_id = ?'); values.push(workplace_id); }
if (shape !== undefined) { fields.push('shape = ?'); values.push(shape); }
if (polygon_points !== undefined) { fields.push('polygon_points = ?'); values.push(polygon_points); }
if (fields.length === 0) return null;
values.push(regionId);
await db.query(`UPDATE workplace_map_regions SET ${fields.join(', ')} WHERE region_id = ?`, values);
const [rows] = await db.query(
`SELECT r.*, w.workplace_name
FROM workplace_map_regions r
LEFT JOIN workplaces w ON r.workplace_id = w.workplace_id
WHERE r.region_id = ?`,
[regionId]
);
return rows[0] || null;
}
async function deleteMapRegion(regionId) {
const db = getPool();
await db.query('DELETE FROM workplace_map_regions WHERE region_id = ?', [regionId]);
}
async function updateWorkplaceLayoutImage(id, imagePath) {
const db = getPool();
await db.query('UPDATE workplaces SET layout_image = ? WHERE workplace_id = ?', [imagePath, id]);
return getById(id);
}
module.exports = {
getAll, getById, getCategories, create, update, deactivate,
updateCategoryLayoutImage, createMapRegion, getMapRegionsByCategory, updateMapRegion, deleteMapRegion,
updateWorkplaceLayoutImage
};